From 8ce5e6e5405e2d0b3b3e1af0fe4b2c2b9ed311d2 Mon Sep 17 00:00:00 2001 From: spushy Date: Wed, 9 Feb 2022 01:13:49 -0500 Subject: [PATCH] combination based bundle solving --- app/helpers/solver_helper.py | 68 ++++++++++++------- app/models/bundle.py | 4 ++ app/models/solver_run.py | 18 ++++- app/services/loft_service.py | 123 ++++++++++++++++++++++------------- 4 files changed, 139 insertions(+), 74 deletions(-) diff --git a/app/helpers/solver_helper.py b/app/helpers/solver_helper.py index bbd6e89..dce9b0c 100644 --- a/app/helpers/solver_helper.py +++ b/app/helpers/solver_helper.py @@ -1,10 +1,16 @@ -from pulp import lpSum +from itertools import combinations +from pulp import lpSum, LpProblem from random import randint, sample + +from models.bundle import Bundle +from models.item import Item +from models.solver_run import SolverRun + import logging from lib.errors.item_generation_error import ItemGenerationError -def build_constraints(solver_run, problem, items, bundles): +def build_constraints(solver_run: SolverRun, problem: LpProblem, items: list[Item], bundles: list[Bundle] or None) -> LpProblem: try: total_form_items = solver_run.total_form_items constraints = solver_run.constraints @@ -24,33 +30,47 @@ def build_constraints(solver_run, problem, items, bundles): problem += lpSum([con[item.id] * items[item.id] for item in solver_run.items]) <= round(total_form_items * (max / 100)), f'{attribute.id} - {attribute.value} - max' - elif attribute.type == 'bundle': - # TODO: account for many different bundle types, since the id condition in L33 could yield duplicates - if solver_run.bundles != None: - total_bundles = randint(constraint.minimum, constraint.maximum) - selected_bundles = sample(solver_run.bundles, total_bundles) - total_bundle_items = 0 - - for bundle in selected_bundles: - con = dict(zip([item.id for item in solver_run.items], - [(getattr(item, bundle.type, False) == bundle.id) - for item in solver_run.items])) - problem += lpSum([con[item.id] - * items[item.id] - for item in solver_run.items]) == bundle.count, f'Bundle constraint for {bundle.type} ({bundle.id})' - total_bundle_items += bundle.count - - # make sure all other items added to the form - # are not a part of any bundle - # currently only supports single bundle constraints, will need refactoring for multiple bundle constraints + elif attribute.type == 'bundle' and bundles: + total_bundle_items = sum(bundle.count for bundle in bundles) + + for bundle in bundles: con = dict(zip([item.id for item in solver_run.items], - [(getattr(item, attribute.id, None) == None) - for item in solver_run.items])) + [(getattr(item, bundle.type, False) == bundle.id) + for item in solver_run.items])) problem += lpSum([con[item.id] * items[item.id] - for item in solver_run.items]) == solver_run.total_form_items - total_bundle_items, f'Remaining items are not of a bundle type' + for item in solver_run.items]) == bundle.count, f'Bundle constraint for {bundle.type} ({bundle.id})' + + # make sure all other items added to the form + # are not a part of any bundle + # currently only supports single bundle constraints, will need refactoring for multiple bundle constraints + con = dict(zip([item.id for item in solver_run.items], + [(getattr(item, attribute.id, None) == None) + for item in solver_run.items])) + problem += lpSum([con[item.id] + * items[item.id] + for item in solver_run.items]) == solver_run.total_form_items - total_bundle_items, f'Remaining items are not of a bundle type' return problem except ValueError as error: logging.error(error) raise ItemGenerationError("Bundle min and/or max larger than bundle amount provided", error.args[0]) + +def valid_bundle_combinations(total_form_items: int, total_bundles: int, min_bundles: int, bundles: list[Bundle], selected_bundle_combinations: list[list[Bundle]] = []) -> list[list[Bundle]]: + if total_bundles < min_bundles: + return selected_bundle_combinations + else: + # generate all bundle combinations + bundle_combinations = [list(combination) for combination in combinations(bundles, total_bundles)] + + # iterate through all the combinations + # if the combination item count is less than or equal to + # the total items on a form, add it to selected bundles + for bundle_combination in bundle_combinations: + total_bundle_items = sum(bundle.count for bundle in bundle_combination) + if total_bundle_items <= total_form_items: + selected_bundle_combinations.append(bundle_combination) + + # recurse to continue generating combinations + # all the way to the minimum amount of bundles allowed + return valid_bundle_combinations(total_form_items, total_bundles - 1, min_bundles, bundles, selected_bundle_combinations) \ No newline at end of file diff --git a/app/models/bundle.py b/app/models/bundle.py index 48eea7e..e3ede4c 100644 --- a/app/models/bundle.py +++ b/app/models/bundle.py @@ -1,6 +1,10 @@ from pydantic import BaseModel +from typing import List + +from models.item import Item class Bundle(BaseModel): id: int count: int + items: List[Item] type: str diff --git a/app/models/solver_run.py b/app/models/solver_run.py index 4694ad3..49c32b3 100644 --- a/app/models/solver_run.py +++ b/app/models/solver_run.py @@ -1,3 +1,5 @@ +import logging + from pydantic import BaseModel from typing import List, Optional @@ -20,17 +22,19 @@ class SolverRun(BaseModel): advanced_options: Optional[AdvancedOptions] engine: str - def get_item(self, item_id): + def get_item(self, item_id: int) -> Item or bool: for item in self.items: if str(item.id) == item_id: return item return False - def remove_items(self, items): + def remove_items(self, items: list[Item]) -> bool: self.items = [item for item in self.items if item not in items] return True def generate_bundles(self): + logging.info('Generating Bundles...') + bundle_constraints = (constraint.reference_attribute for constraint in self.constraints if constraint.reference_attribute.type == 'bundle') for bundle_constraint in bundle_constraints: @@ -53,16 +57,24 @@ class SolverRun(BaseModel): self.bundles.append(Bundle( id=attribute_id, count=1, + items=[item], type=type_attribute )) else: self.bundles[bundle_index].count += 1 + self.bundles[bundle_index].items.append(item) else: self.bundles = [Bundle( id=attribute_id, count=1, + items=[item], type=type_attribute )] - def get_constraint(self, name): + def get_constraint(self, name: str) -> Constraint or None: return next((constraint for constraint in self.constraints if constraint.reference_attribute.id == name), None) + + # temp function until we build out bundles to more than just for cases + # for now it treats "bundle" attributes as a single unique constraint + def get_constraint_by_type(self, type: str) -> Constraint or None: + return next((constraint for constraint in self.constraints if constraint.reference_attribute.type == type), None) \ No newline at end of file diff --git a/app/services/loft_service.py b/app/services/loft_service.py index 99dd63c..bd330c5 100644 --- a/app/services/loft_service.py +++ b/app/services/loft_service.py @@ -9,6 +9,7 @@ from models.solver_run import SolverRun from models.solution import Solution from models.form import Form from models.item import Item +from models.bundle import Bundle from services.base import Base @@ -25,7 +26,7 @@ class LoftService(Base): logging.error(error) self.result = self.stream_to_s3_bucket(ItemGenerationError("Provided params causing error in calculation results")) - def create_solver_run_from_attributes(self): + def create_solver_run_from_attributes(self) -> SolverRun: logging.info('Retrieving attributes from message...') # get s3 object self.key = aws_helper.get_key_from_message(self.source) @@ -52,67 +53,95 @@ class LoftService(Base): return solver_run - def generate_solution(self): - # unsolved solution - solution = Solution( - response_id=random.randint(100, 5000), - forms=[] - ) - + def generate_solution(self) -> Solution: + logging.info('Generating Solution...') + # counter for number of forms f = 0 + + # setup vars + items = LpVariable.dicts( + "Item", [item.id for item in self.solver_run.items], lowBound=1, upBound=1, cat='Binary') + + # check if problem request has bundles + bundle_constraint = self.solver_run.get_constraint_by_type('bundle') # iterate for number of forms that require creation # currently creates distinc forms with no item overlap while f < self.solver_run.total_forms: - # setup vars - items = LpVariable.dicts( - "Item", [item.id for item in self.solver_run.items], lowBound=1, upBound=1, cat='Binary') - bundles = LpVariable.dicts( - "Bundle", [bundle.id for bundle in self.solver_run.bundles], lowBound=1, upBound=1, cat='Binary') + # unsolved solution + solution = Solution( + response_id=random.randint(100, 5000), + forms=[] + ) + + # initiate problem + problem = None + count = 0 + if bundle_constraint: + # generate valid bundle combinations + bundle_combinations = solver_helper.valid_bundle_combinations( + self.solver_run.total_form_items, + int(bundle_constraint.maximum), + int(bundle_constraint.minimum), + self.solver_run.bundles) - problem_objection_functions = [] - - # create problem - problem = LpProblem("ata-form-generate", LpMinimize) - - # dummy objective function, because it just makes things easierâ„¢ - problem += lpSum([items[item.id] - for item in self.solver_run.items]) - - # constraints - problem += lpSum([items[item.id] - for item in self.solver_run.items]) == self.solver_run.total_form_items, 'Total form items' - - # dynamic constraints - problem = solver_helper.build_constraints(self.solver_run, problem, items, bundles) - - # multi-objective constraints - for target in self.solver_run.objective_function.tif_targets: - problem += lpSum([item.iif(self.solver_run, target.theta)*items[item.id] - for item in self.solver_run.items]) >= target.value - 8, f'max tif theta ({target.theta}) target value {target.value}' - problem += lpSum([item.iif(self.solver_run, target.theta)*items[item.id] - for item in self.solver_run.items]) <= target.value + 8, f'min tif theta ({target.theta}) target value {target.value}' - - for target in self.solver_run.objective_function.tcc_targets: - problem += lpSum([item.irf(self.solver_run, target.theta)*items[item.id] - for item in self.solver_run.items]) >= target.value - 20, f'max tcc theta ({target.theta}) target value {target.value}' - problem += lpSum([item.irf(self.solver_run, target.theta)*items[item.id] - for item in self.solver_run.items]) <= target.value + 20, f'min tcc theta ({target.theta}) target value {target.value}' - - # solve problem - problem.solve() + # scramble bundle_combinations to ensure distinctiveness for each form generated + random.shuffle(bundle_combinations) + for bundles in bundle_combinations: + problem = self.solve_problem(items, bundles) + + # if optimal solution found, break loop + if LpStatus[problem.status] == 'Optimal': + break + else: # no bundles + problem = self.solve_problem(items) + + # successfull form, increment and exit out of loop + f += 1 + # add return items and create as a form form_items = service_helper.solution_items(problem.variables(), self.solver_run) # add form to solution solution.forms.append(Form.create(form_items, self.solver_run, LpStatus[problem.status])) - # successfull form, increment - f += 1 + return solution + + def solve_problem(self, items: list[Item], bundles: list[Bundle] or None = None) -> LpProblem: + # create problem + problem = LpProblem("ata-form-generate", LpMinimize) + + # dummy objective function, because it just makes things easierâ„¢ + problem += lpSum([items[item.id] + for item in self.solver_run.items]) + + # constraints + problem += lpSum([items[item.id] + for item in self.solver_run.items]) == self.solver_run.total_form_items, 'Total form items' + + # dynamic constraints + problem = solver_helper.build_constraints(self.solver_run, problem, items, bundles) + + # multi-objective constraints + for target in self.solver_run.objective_function.tif_targets: + problem += lpSum([item.iif(self.solver_run, target.theta)*items[item.id] + for item in self.solver_run.items]) >= target.value - 8, f'max tif theta ({target.theta}) target value {target.value}' + problem += lpSum([item.iif(self.solver_run, target.theta)*items[item.id] + for item in self.solver_run.items]) <= target.value + 8, f'min tif theta ({target.theta}) target value {target.value}' + + for target in self.solver_run.objective_function.tcc_targets: + problem += lpSum([item.irf(self.solver_run, target.theta)*items[item.id] + for item in self.solver_run.items]) >= target.value - 20, f'max tcc theta ({target.theta}) target value {target.value}' + problem += lpSum([item.irf(self.solver_run, target.theta)*items[item.id] + for item in self.solver_run.items]) <= target.value + 20, f'min tcc theta ({target.theta}) target value {target.value}' + + # solve problem + problem.solve() + + return problem - return solution def stream_to_s3_bucket(self, error = None): self.file_name = f'{service_helper.key_to_uuid(self.key)}.csv'