dws-city-res-solve/web_solve.py

205 lines
7.9 KiB
Python

"""Web interface for City Resource Optimization solver."""
from flask import Flask, render_template, request, jsonify
from ortools.sat.python import cp_model
import json
import solve
import io
import sys
from contextlib import redirect_stdout
from constraint_validator import make_constraint_lambda
app = Flask(__name__)
@app.route("/")
def index():
"""Serve the solver form."""
return render_template(
"solver.html",
initial=solve.INITIAL,
actions=solve.ENABLED_ACTIONS,
agents=solve.AGENT_AVAILABILITY,
num_steps=solve.NUM_STEPS,
objective_factors=solve.OBJECTIVE_FACTORS,
objective_mode=solve.OBJECTIVE_MODE,
)
@app.route("/solve", methods=["POST"])
def solve_handler():
"""Handle solver requests from the form."""
try:
data = request.get_json()
# Parse initial resources (divide by 10 since UI shows unscaled values)
# R = Renown, L = Luxuries, X = Express tickets (default 0; no sources yet)
initial_defaults = {"E": 3, "B": 3, "S": 3, "C": 3, "R": 0, "L": 0, "X": 0}
initial = tuple(
int(float(data.get(f"initial_{r}", d)) * 10)
for r, d in initial_defaults.items()
)
# Parse per-city arrivals and departures (dynamic number of cities)
arrivals = {}
for step in range(1, solve.NUM_STEPS + 1):
arrivals[step] = []
# Find all cities submitted (city_0_type, city_1_type, etc.)
city_indices = set()
for key in data.keys():
if key.startswith("city_") and key.endswith("_type"):
city_idx = int(key.split("_")[1])
city_indices.add(city_idx)
for city_idx in sorted(city_indices):
city_type = data.get(f"city_{city_idx}_type")
arrival_step_str = data.get(f"city_{city_idx}_arrival_step")
departure_step_str = data.get(f"city_{city_idx}_departure_step")
adjacent_str = data.get(f"city_{city_idx}_adjacent", "")
if city_type and city_type != "none" and arrival_step_str:
arrival_step = int(arrival_step_str)
departure_step = (
int(departure_step_str)
if departure_step_str
else solve.NUM_STEPS + 1
)
# Parse adjacent cities (comma-separated, whitespace ignored)
adjacent_to = []
if adjacent_str:
adjacent_to = [
int(idx.strip())
for idx in adjacent_str.split(",")
if idx.strip()
]
# Parse base flag (checkbox-style: may arrive as bool or string)
base_val = data.get(f"city_{city_idx}_base", False)
if isinstance(base_val, str):
base_val = base_val.lower() in ("true", "on", "1", "yes")
city_data = {
"type": city_type,
"adjacent_to": adjacent_to,
"base": bool(base_val),
"departure_step": departure_step,
}
# Parse vat values for foundries (if specified, use them; otherwise let normalize_city default to 1)
if city_type == "F":
vat_e = data.get(f"city_{city_idx}_vat_E")
vat_b = data.get(f"city_{city_idx}_vat_B")
vat_s = data.get(f"city_{city_idx}_vat_S")
vats = {}
if vat_e:
vats["E"] = int(vat_e)
if vat_b:
vats["B"] = int(vat_b)
if vat_s:
vats["S"] = int(vat_s)
if vats:
city_data["vats"] = vats
arrivals[arrival_step].append(city_data)
# Parse enabled actions
enabled_actions = {}
for action_name in solve.ENABLED_ACTIONS.keys():
enabled_actions[action_name] = data.get(f"action_{action_name}", False)
# Parse agent availability
agent_availability = {}
for agent_name in solve.AGENT_AVAILABILITY.keys():
steps_str = data.get(f"agent_{agent_name}_steps", "")
if steps_str:
agent_availability[agent_name] = [
int(s) for s in steps_str.split(",") if s
]
else:
agent_availability[agent_name] = []
solve.AGENT_AVAILABILITY = agent_availability
# Parse time limit
time_limit = float(data.get("time_limit", 60.0))
# Parse verbose flag
verbose = data.get("verbose", True)
# Parse fixed constraints
fixed_choices = None
fixed_actions_data = data.get("fixed_actions", {})
fixed_governors_data = data.get("fixed_governors", {})
if fixed_actions_data or fixed_governors_data:
fixed_choices = {}
if fixed_actions_data:
fixed_choices["actions"] = {}
for key, action in fixed_actions_data.items():
city, step = map(int, key.split(","))
fixed_choices["actions"][(city, step)] = action
if fixed_governors_data:
fixed_choices["governors"] = {}
for key, enabled in fixed_governors_data.items():
city, step, agent = key.rsplit(",", 2)
city, step = int(city), int(step)
fixed_choices["governors"][(city, step, agent)] = enabled
# Parse objective (factor = exponent in "product" mode, weight in
# "sum" mode; missing keys = resource excluded). Factors are NOT x10
# scaled — they apply to the scaled resource totals and the report
# descales the objective. None falls back to solve.py's defaults.
objective_mode = data.get("objective_mode") or None
objective_factors = None
objective_factors_data = data.get("objective_factors")
if objective_factors_data is not None:
# All-zero dict passes through so solve()'s validation rejects it
# with a clear error instead of silently using the defaults.
objective_factors = {r: int(f) for r, f in objective_factors_data.items()}
# 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):
solver, status = solve.solve(
initial=initial,
arrivals=arrivals,
max_res=solve.MAX_RES,
max_vat=solve.MAX_VAT,
# min to avoid bricking stuff
# time_limit=min(time_limit, 60.0),
time_limit=time_limit,
num_workers=8,
verbose=verbose,
fixed_choices=fixed_choices,
resource_constraints=resource_constraints,
objective_factors=objective_factors,
objective_mode=objective_mode,
)
output = output_buffer.getvalue()
status_name = solver.StatusName(status) if status else "UNKNOWN"
return jsonify({"success": True, "status": status_name, "output": output})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 400
if __name__ == "__main__":
app.run(debug=False, host="0.0.0.0", port=5000)