combination based bundle solving

This commit is contained in:
spushy 2022-02-09 01:13:49 -05:00
parent 744abbb7b8
commit 8ce5e6e540
4 changed files with 139 additions and 74 deletions

View File

@ -1,10 +1,16 @@
from pulp import lpSum from itertools import combinations
from pulp import lpSum, LpProblem
from random import randint, sample from random import randint, sample
from models.bundle import Bundle
from models.item import Item
from models.solver_run import SolverRun
import logging import logging
from lib.errors.item_generation_error import ItemGenerationError 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: try:
total_form_items = solver_run.total_form_items total_form_items = solver_run.total_form_items
constraints = solver_run.constraints constraints = solver_run.constraints
@ -24,33 +30,47 @@ def build_constraints(solver_run, problem, items, bundles):
problem += lpSum([con[item.id] problem += lpSum([con[item.id]
* items[item.id] * items[item.id]
for item in solver_run.items]) <= round(total_form_items * (max / 100)), f'{attribute.id} - {attribute.value} - max' for item in solver_run.items]) <= round(total_form_items * (max / 100)), f'{attribute.id} - {attribute.value} - max'
elif attribute.type == 'bundle': elif attribute.type == 'bundle' and bundles:
# TODO: account for many different bundle types, since the id condition in L33 could yield duplicates total_bundle_items = sum(bundle.count for bundle in bundles)
if solver_run.bundles != None:
total_bundles = randint(constraint.minimum, constraint.maximum) for bundle in bundles:
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
con = dict(zip([item.id for item in solver_run.items], con = dict(zip([item.id for item in solver_run.items],
[(getattr(item, attribute.id, None) == None) [(getattr(item, bundle.type, False) == bundle.id)
for item in solver_run.items])) for item in solver_run.items]))
problem += lpSum([con[item.id] problem += lpSum([con[item.id]
* items[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 return problem
except ValueError as error: except ValueError as error:
logging.error(error) logging.error(error)
raise ItemGenerationError("Bundle min and/or max larger than bundle amount provided", error.args[0]) 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)

View File

@ -1,6 +1,10 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List
from models.item import Item
class Bundle(BaseModel): class Bundle(BaseModel):
id: int id: int
count: int count: int
items: List[Item]
type: str type: str

View File

@ -1,3 +1,5 @@
import logging
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
@ -20,17 +22,19 @@ class SolverRun(BaseModel):
advanced_options: Optional[AdvancedOptions] advanced_options: Optional[AdvancedOptions]
engine: str engine: str
def get_item(self, item_id): def get_item(self, item_id: int) -> Item or bool:
for item in self.items: for item in self.items:
if str(item.id) == item_id: if str(item.id) == item_id:
return item return item
return False 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] self.items = [item for item in self.items if item not in items]
return True return True
def generate_bundles(self): def generate_bundles(self):
logging.info('Generating Bundles...')
bundle_constraints = (constraint.reference_attribute for constraint in self.constraints if constraint.reference_attribute.type == 'bundle') bundle_constraints = (constraint.reference_attribute for constraint in self.constraints if constraint.reference_attribute.type == 'bundle')
for bundle_constraint in bundle_constraints: for bundle_constraint in bundle_constraints:
@ -53,16 +57,24 @@ class SolverRun(BaseModel):
self.bundles.append(Bundle( self.bundles.append(Bundle(
id=attribute_id, id=attribute_id,
count=1, count=1,
items=[item],
type=type_attribute type=type_attribute
)) ))
else: else:
self.bundles[bundle_index].count += 1 self.bundles[bundle_index].count += 1
self.bundles[bundle_index].items.append(item)
else: else:
self.bundles = [Bundle( self.bundles = [Bundle(
id=attribute_id, id=attribute_id,
count=1, count=1,
items=[item],
type=type_attribute 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) 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)

View File

@ -9,6 +9,7 @@ from models.solver_run import SolverRun
from models.solution import Solution from models.solution import Solution
from models.form import Form from models.form import Form
from models.item import Item from models.item import Item
from models.bundle import Bundle
from services.base import Base from services.base import Base
@ -25,7 +26,7 @@ class LoftService(Base):
logging.error(error) logging.error(error)
self.result = self.stream_to_s3_bucket(ItemGenerationError("Provided params causing error in calculation results")) 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...') logging.info('Retrieving attributes from message...')
# get s3 object # get s3 object
self.key = aws_helper.get_key_from_message(self.source) self.key = aws_helper.get_key_from_message(self.source)
@ -52,67 +53,95 @@ class LoftService(Base):
return solver_run return solver_run
def generate_solution(self): def generate_solution(self) -> Solution:
# unsolved solution logging.info('Generating Solution...')
solution = Solution(
response_id=random.randint(100, 5000),
forms=[]
)
# counter for number of forms # counter for number of forms
f = 0 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 # iterate for number of forms that require creation
# currently creates distinc forms with no item overlap # currently creates distinc forms with no item overlap
while f < self.solver_run.total_forms: while f < self.solver_run.total_forms:
# setup vars # unsolved solution
items = LpVariable.dicts( solution = Solution(
"Item", [item.id for item in self.solver_run.items], lowBound=1, upBound=1, cat='Binary') response_id=random.randint(100, 5000),
bundles = LpVariable.dicts( forms=[]
"Bundle", [bundle.id for bundle in self.solver_run.bundles], lowBound=1, upBound=1, cat='Binary') )
# 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 = [] # scramble bundle_combinations to ensure distinctiveness for each form generated
random.shuffle(bundle_combinations)
# 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()
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 # add return items and create as a form
form_items = service_helper.solution_items(problem.variables(), self.solver_run) form_items = service_helper.solution_items(problem.variables(), self.solver_run)
# add form to solution # add form to solution
solution.forms.append(Form.create(form_items, self.solver_run, LpStatus[problem.status])) solution.forms.append(Form.create(form_items, self.solver_run, LpStatus[problem.status]))
# successfull form, increment return solution
f += 1
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): def stream_to_s3_bucket(self, error = None):
self.file_name = f'{service_helper.key_to_uuid(self.key)}.csv' self.file_name = f'{service_helper.key_to_uuid(self.key)}.csv'