From 17020b857cca8eb236f6d55ab8434417940fcb23 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Tue, 9 Jun 2026 16:53:35 -0400 Subject: [PATCH] added resource constraints --- constraint_validator.py | 25 +++++++++++++++++++++++++ solve.py | 33 ++++++++++++++++++++++++++++++++- templates/solver.html | 38 ++++++++++++++++++++++++++++++++++++++ web_solve.py | 14 ++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 constraint_validator.py diff --git a/constraint_validator.py b/constraint_validator.py new file mode 100644 index 0000000..448e74c --- /dev/null +++ b/constraint_validator.py @@ -0,0 +1,25 @@ +"""Create constraint lambdas from expressions.""" + +import re + + +def make_constraint_lambda(expr_str): + """Create a lambda function from a constraint expression. + + Transforms resource identifiers to res dict access and creates a lambda + with restricted builtins. + + Args: + expr_str: Constraint expression like "E[3] >= 50" or "B[2] + S[2] >= 100" + + Returns: + A lambda function: lambda res: + + Raises: + Exception: If expression fails to eval + """ + # Transform identifiers to res subscript access: E -> res["E"], B -> res["B"], etc. + expr = re.sub(r'\b([A-Z])\b', r'res["\1"]', expr_str) + + # Create lambda with restricted builtins (no imports, no dangerous functions) + return eval(f"lambda res: {expr}", {"__builtins__": {}}) diff --git a/solve.py b/solve.py index ba5e760..94cbd54 100644 --- a/solve.py +++ b/solve.py @@ -61,6 +61,12 @@ FIXED_CHOICES = { } } +# Resource constraints: list of callables that receive a dict with keys E, B, S, C +# mapping to resource variables indexed by step. Each callable returns a constraint +# (or None to skip) to be passed to m.Add(). Example: +# lambda res: res["E"][3] >= 50 # Ensure at least 50 E at step 3 +RESOURCE_CONSTRAINTS = [] + # Arrival schedule. Key = step (1..5), value = list of city types that arrive # at the START of that step. Types: 'H' Hub, 'F' Foundry, 'M' Metropolis, 'N' Monument. # Arriving cities act that same step. @@ -983,6 +989,7 @@ def solve( num_workers=8, verbose=True, fixed_choices=FIXED_CHOICES, + resource_constraints=None, ): # ---- build the city list ----------------------------------------- @@ -1505,6 +1512,28 @@ def solve( finalE, finalB, finalS = E[NUM_STEPS + 1], B[NUM_STEPS + 1], S[NUM_STEPS + 1] + # ---- Apply resource constraints ---- + if resource_constraints is None: + resource_constraints = RESOURCE_CONSTRAINTS + + if verbose and resource_constraints: + print(f"Adding {len(resource_constraints)} resource constraint(s)") + + res_dict = { + "E": E, + "B": B, + "S": S, + "C": C, + } + for i, constraint_fn in enumerate(resource_constraints): + constraint = constraint_fn(res_dict) + if verbose: + print(f" Constraint {i + 1}: {constraint}") + print(f" Type: {type(constraint)}") + print(f" Constraint object: {constraint}") + if constraint is not None: + m.Add(constraint) + # ---- Phase 1: real per-resource ceilings ---- def _ceiling(var): s = cp_model.CpSolver() @@ -1759,9 +1788,11 @@ def _report( print(" step: E B S C") for t in range(1, NUM_STEPS + 2): label = f"after5" if t == NUM_STEPS + 1 else f"start {t}" + c_val = solver.Value(C[t]) + c_str = f"{c_val / 10:6.1f}" print( f" {label:>8}: {solver.Value(E[t]) / 10:6.1f} {solver.Value(B[t]) / 10:6.1f} " - f"{solver.Value(S[t]) / 10:6.1f} {solver.Value(C[t]) / 10:6.1f}" + f"{solver.Value(S[t]) / 10:6.1f} {c_str}" ) if capital_spent is not None: diff --git a/templates/solver.html b/templates/solver.html index 97f2447..40335e5 100644 --- a/templates/solver.html +++ b/templates/solver.html @@ -449,6 +449,16 @@ Constraint + +
+

Resource Constraints (Optional)

+

+ Enforce minimum resource levels at specific steps. Examples: E[3] >= 50, B[2] + S[2] >= 100 +

+
+ +
+
@@ -473,6 +483,7 @@ let cityCount = 0; let actionConstraintCount = 0; let governorConstraintCount = 0; + let resourceConstraintCount = 0; const AVAILABLE_ACTIONS = {{actions | tojson }}; const AVAILABLE_AGENTS = {{agents | tojson }}; @@ -652,6 +663,21 @@ container.appendChild(row); } + function addResourceConstraint() { + const container = document.getElementById('resourceConstraints'); + const id = resourceConstraintCount++; + + const row = document.createElement('div'); + row.className = 'constraint-row'; + row.id = `resource-constraint-${id}`; + row.style.gridTemplateColumns = '1fr auto'; + row.innerHTML = ` + + + `; + container.appendChild(row); + } + document.getElementById('solverForm').addEventListener('submit', async (e) => { e.preventDefault(); @@ -687,6 +713,15 @@ fixedGovernors[`${city},${step},${agent}`] = enabled; }); + // Collect resource constraints + const resourceConstraints = []; + form.querySelectorAll('[id^="resource-constraint-"]').forEach(row => { + const expr = row.querySelector('[name^="resource_expr_"]').value.trim(); + if (expr) { + resourceConstraints.push(expr); + } + }); + // Add constraints to data if any exist if (Object.keys(fixedActions).length > 0) { data.fixed_actions = fixedActions; @@ -694,6 +729,9 @@ if (Object.keys(fixedGovernors).length > 0) { data.fixed_governors = fixedGovernors; } + if (resourceConstraints.length > 0) { + data.resource_constraints = resourceConstraints; + } document.getElementById('loading').classList.add('show'); document.getElementById('outputSection').classList.remove('show'); diff --git a/web_solve.py b/web_solve.py index cb55eee..c6f95bc 100644 --- a/web_solve.py +++ b/web_solve.py @@ -7,6 +7,7 @@ import solve import io import sys from contextlib import redirect_stdout +from constraint_validator import make_constraint_lambda app = Flask(__name__) @@ -135,6 +136,18 @@ def solve_handler(): city, step = int(city), int(step) fixed_choices["governors"][(city, step, agent)] = enabled + # Parse resource constraints + resource_constraints = None + resource_constraints_data = data.get("resource_constraints", []) + if resource_constraints_data: + resource_constraints = [] + for expr in resource_constraints_data: + try: + constraint_lambda = make_constraint_lambda(expr) + resource_constraints.append(constraint_lambda) + except Exception as e: + raise ValueError(f"Invalid resource constraint '{expr}': {e}") + # Capture solver output output_buffer = io.StringIO() with redirect_stdout(output_buffer): @@ -147,6 +160,7 @@ def solve_handler(): num_workers=8, verbose=verbose, fixed_choices=fixed_choices, + resource_constraints=resource_constraints, ) output = output_buffer.getvalue()