diff --git a/app/helpers/solver_helper.py b/app/helpers/solver_helper.py index 1b1c613..ac2f6a5 100644 --- a/app/helpers/solver_helper.py +++ b/app/helpers/solver_helper.py @@ -11,8 +11,9 @@ from models.item import Item from lib.errors.item_generation_error import ItemGenerationError +# should probably be factored out into a bundle class method or a method in the solver run def build_constraints(solver_run: SolverRun, problem: LpProblem, - items: list[Item], bundles: list[Bundle], selected_items: list[Item], selected_bundles: list[Bundle]) -> LpProblem: + items: list[Item], bundles: list[Bundle], selected_items: list[Item], selected_bundles: list[Bundle], current_drift: int) -> LpProblem: logging.info('Creating Constraints...') try: @@ -47,7 +48,7 @@ def build_constraints(solver_run: SolverRun, problem: LpProblem, elif attribute.type == 'bundle': logging.info('Bundles Constraint Generating...') # TODO: account for many different bundle types, since the id condition in L33 could yield duplicates - if selected_bundles != None: + if selected_bundles != None and selected_bundles > 0: # make sure the total bundles used in generated form is limited between min-max set problem += lpSum([ bundles[bundle.id] for bundle in selected_bundles @@ -55,6 +56,52 @@ def build_constraints(solver_run: SolverRun, problem: LpProblem, int(constraint.maximum)) logging.info('Constraints Created...') + + # Behold our very own Elastic constraints! + for tif_target in solver_run.objective_function.tif_targets: + problem += lpSum([ + bundle.tif(solver_run.irt_model, tif_target.theta) + * bundles[bundle.id] + for bundle in selected_bundles + ] + [ + item.iif(solver_run, tif_target.theta) * + items[item.id] + for item in selected_items + ]) >= tif_target.minimum( + ), f'Min TIF theta({tif_target.theta}) at target {tif_target.value} drift at {current_drift}%' + problem += lpSum([ + bundle.tif(solver_run.irt_model, tif_target.theta) + * bundles[bundle.id] + for bundle in selected_bundles + ] + [ + item.iif(solver_run, tif_target.theta) * + items[item.id] + for item in selected_items + ]) <= tif_target.maximum( + ), f'Max TIF theta({tif_target.theta}) at target {tif_target.value} drift at {current_drift}%' + + for tcc_target in solver_run.objective_function.tcc_targets: + problem += lpSum([ + bundle.trf(solver_run.irt_model, tcc_target.theta) + * bundles[bundle.id] + for bundle in selected_bundles + ] + [ + item.irf(solver_run, tcc_target.theta) * + items[item.id] + for item in selected_items + ]) >= tcc_target.minimum( + ), f'Min TCC theta({tcc_target.theta}) at target {tcc_target.value} drift at {current_drift}%' + problem += lpSum([ + bundle.trf(solver_run.irt_model, tcc_target.theta) + * bundles[bundle.id] + for bundle in selected_bundles + ] + [ + item.irf(solver_run, tcc_target.theta) * + items[item.id] + for item in selected_items + ]) <= tcc_target.maximum( + ), f'Max TCC theta({tcc_target.theta}) at target {tcc_target.value} drift at {current_drift}%' + return problem except ValueError as error: logging.error(error) @@ -62,7 +109,7 @@ def build_constraints(solver_run: SolverRun, problem: LpProblem, "Bundle min and/or max larger than bundle amount provided", error.args[0]) - +# should probably be factored out into a bundle class method or a method in the solver run def get_random_bundles(total_form_items: int, bundles: list[Bundle], min: int, diff --git a/app/main.py b/app/main.py index 44b0da9..e9f0143 100644 --- a/app/main.py +++ b/app/main.py @@ -43,7 +43,7 @@ class ServiceListener(Consumer): logging.error(f'action of type {action} does not exist.') def main(): - logging.info('Starting IRT Service: That Was Rasch (v1.5.0)...') + logging.info('Starting IRT Service: The Enemies Within (v1.7.0)...') # ToDo: Figure out a much better way of doing this. # LocalStack wants 'endpoint_url', while prod doesnt :( diff --git a/app/models/form.py b/app/models/form.py index e2842f2..242acdc 100644 --- a/app/models/form.py +++ b/app/models/form.py @@ -29,3 +29,10 @@ class Form(BaseModel): tcc_results=irt_helper.generate_tcc_results(items, solver_run), status=status, solver_variables=solver_variables) + + def has_item(self, item: Item) -> bool: + for i in self.items: + if item == i: + return True + + return False diff --git a/app/models/irt_problem.py b/app/models/irt_problem.py new file mode 100644 index 0000000..8f7897f --- /dev/null +++ b/app/models/irt_problem.py @@ -0,0 +1,3 @@ +from models.problem import Problem + +class IrtProblem(Problem): \ No newline at end of file diff --git a/app/models/problem.py b/app/models/problem.py new file mode 100644 index 0000000..e03ab53 --- /dev/null +++ b/app/models/problem.py @@ -0,0 +1,79 @@ +from pydantic import BaseModel +from typing import Any, List +from pulp import LpProblem, LpVariable, lpSum + +import logging, math + +from helpers import solver_helper + +from models.solver_run import SolverRun +from models.solution import Solution +from models.item import Item +from models.bundle import Bundle + +class Problem(BaseModel): + items: List[Item] + bundles: List[Bundle] + problem: Any + solver_items_var: Any = None + solver_bundles_var: Any = None + + def __init__(self, **data) -> None: + super().__init__(**data) + + # setup common Solver variables + self.solver_items_var = LpVariable.dicts("Item", + [item.id for item in self.items], + lowBound=0, + upBound=1, + cat='Binary') + self.solver_bundles_var = LpVariable.dicts("Bundle", + [bundle.id for bundle in self.bundles], + lowBound=0, + upBound=1, + cat='Binary') + + # objective function + self.problem += lpSum([ + bundle.count * self.solver_bundles_var[bundle.id] + for bundle in self.bundles + ] + [ + self.solver_items_var[item.id] + for item in self.items + ]) + + def solve(self) -> LpProblem: + self.problem.solve() + return self.problem + + def generate(self, solution: Solution, solver_run: SolverRun): + + # Form Constraints + self.problem += lpSum( + [ + bundle.count * self.solver_bundles_var[bundle.id] + for bundle in self.bundles + ] + [ + 1 * self.solver_items_var[item.id] + for item in self.items + ] + ) == solver_run.total_form_items, f'Total bundle form items for form' + + # each time a form is generated, we want to ensure + # that it is unique to all other forms generated before it + self.problem += lpSum( + [ + solution.items_exist_in_forms(bundle.items) * self.solver_bundles_var[bundle.id] + for bundle in self.bundles + ] + [ + solution.items_exist_in_forms([item]) * self.solver_items_var[item.id] + for item in self.items + ] + ) <= solver_run.total_form_items - 1, f'Ensuring uniqueness for form' + + def generate_constraints(self, solver_run: SolverRun, current_drift: int): + self.problem = solver_helper.build_constraints( + solver_run, self.problem, self.solver_items_var, self.solver_bundles_var, self.items, self.bundles, current_drift) + + + diff --git a/app/models/solution.py b/app/models/solution.py index 0f7c2ea..47fbd25 100644 --- a/app/models/solution.py +++ b/app/models/solution.py @@ -2,8 +2,21 @@ from pydantic import BaseModel from typing import List from models.form import Form +from models.item import Item class Solution(BaseModel): response_id: int forms: List[Form] + + def items_exist_in_forms(self, items: [Item]) -> bool: + items_found = 0 + + for item in items: + for form in self.forms: + if form.has_item(item): + items_found += 1 + + return items_found + + diff --git a/app/services/form_generation_service.py b/app/services/form_generation_service.py index bfb191e..6b3080a 100644 --- a/app/services/form_generation_service.py +++ b/app/services/form_generation_service.py @@ -8,6 +8,7 @@ from lib.errors.item_generation_error import ItemGenerationError from models.solver_run import SolverRun from models.solution import Solution +from models.problem import Problem from models.form import Form from models.target import Target @@ -71,24 +72,6 @@ class FormGenerationService(Base): form_number = form_count + 1 current_drift = 0 # FF Tokyo Drift - # adding an element of randomness to the items and bundles used - # may need to change impl based on limit of items available - selected_items = self.solver_run.select_items_by_percent(30) - selected_bundles = self.solver_run.select_bundles_by_percent( - 30) - - # setup common Solver variables - items = LpVariable.dicts("Item", - [item.id for item in selected_items], - lowBound=0, - upBound=1, - cat='Binary') - bundles = LpVariable.dicts("Bundle", - [bundle.id for bundle in selected_bundles], - lowBound=0, - upBound=1, - cat='Binary') - logging.info(f'Generating Solution for Form {form_number} using the {self.solver_run.irt_model.model} IRT model') while current_drift <= Target.max_drift(): @@ -97,99 +80,11 @@ class FormGenerationService(Base): drift_percent) # create problem - problem = LpProblem('ata-form-generate', LpMinimize) - - # objective function - problem += lpSum([ - bundle.count * bundles[bundle.id] - for bundle in selected_bundles - ] + [ - items[item.id] - for item in selected_items - ]) - - # Form Constraints - problem += lpSum( - [ - bundle.count * bundles[bundle.id] - for bundle in selected_bundles - ] + [ - 1 * items[item.id] - for item in selected_items - ] - ) == self.solver_run.total_form_items, f'Total bundle form items for form {form_number}' - - # Dynamic constraints.. currently we only support Metadata and Bundles(Cases/Passages) - problem = solver_helper.build_constraints( - self.solver_run, problem, items, bundles, selected_items, selected_bundles) - - # form uniqueness constraints - # for form in solution.forms: - # form_item_options = [ - # bundles[bundle.id] - # for bundle in selected_bundles - # ] + [ - # items[item.id] - # for item in selected_items - # ] - # problem += len( - # set(form.solver_variables) - # & set(form_item_options)) / float( - # len( - # set(form.solver_variables) - # | set(form_item_options))) * 100 >= 10 - - logging.info('Creating TIF and TCC Elastic constraints') - - # Behold our very own Elastic constraints! - for tif_target in self.solver_run.objective_function.tif_targets: - problem += lpSum([ - bundle.tif(self.solver_run.irt_model, tif_target.theta) - * bundles[bundle.id] - for bundle in selected_bundles - ] + [ - item.iif(self.solver_run, tif_target.theta) * - items[item.id] - for item in selected_items - ]) >= tif_target.minimum( - ), f'Min TIF theta({tif_target.theta}) at target {tif_target.value} drift at {current_drift}%' - problem += lpSum([ - bundle.tif(self.solver_run.irt_model, tif_target.theta) - * bundles[bundle.id] - for bundle in selected_bundles - ] + [ - item.iif(self.solver_run, tif_target.theta) * - items[item.id] - for item in selected_items - ]) <= tif_target.maximum( - ), f'Max TIF theta({tif_target.theta}) at target {tif_target.value} drift at {current_drift}%' - - for tcc_target in self.solver_run.objective_function.tcc_targets: - problem += lpSum([ - bundle.trf(self.solver_run.irt_model, tcc_target.theta) - * bundles[bundle.id] - for bundle in selected_bundles - ] + [ - item.irf(self.solver_run, tcc_target.theta) * - items[item.id] - for item in selected_items - ]) >= tcc_target.minimum( - ), f'Min TCC theta({tcc_target.theta}) at target {tcc_target.value} drift at {current_drift}%' - problem += lpSum([ - bundle.trf(self.solver_run.irt_model, tcc_target.theta) - * bundles[bundle.id] - for bundle in selected_bundles - ] + [ - item.irf(self.solver_run, tcc_target.theta) * - items[item.id] - for item in selected_items - ]) <= tcc_target.maximum( - ), f'Max TCC theta({tcc_target.theta}) at target {tcc_target.value} drift at {current_drift}%' - - logging.info( - f'Solving for Form {form_number} with a drift of {current_drift}%' - ) - problem.solve() + problem_handler = Problem(items = self.solver_run.items, bundles = self.solver_run.bundles, problem = LpProblem('ata-form-generate', LpMinimize)) + problem_handler.generate(solution, self.solver_run) + problem_handler.generate_constraints(self.solver_run, current_drift) + + problem = problem_handler.solve() if LpStatus[problem.status] == 'Infeasible': logging.info(