pcswmm-engineering-optimizer / design_optimizer.py
razaali10's picture
Upload 55 files
7857730 verified
Raw
History Blame Contribute Delete
16 kB
"""Deterministic, COC-constrained design optimizer.
Given a connected model's base INP, this module screens a bounded, fully
enumerated set of candidate designs -- variations on conduit diameter,
conduit roughness, and/or storage depth -- against a FIXED set of City of
Calgary design-standard constraints, and ranks the constraint-satisfying
candidates by a single VARIABLE, user-selected design objective.
Design intent
-------------
SWMM re-simulation is not free, and a black-box gradient/heuristic optimizer
over a discontinuous, constraint-heavy hydraulic model is hard to audit and
easy to trust more than it deserves. This module instead runs a **design of
experiments**: every candidate that gets evaluated is one you can see in the
returned ``evaluations`` list, with its own constraint pass/fail detail and
objective value. There is no hidden search path. Two candidate-generation
modes are supported:
* ``variables`` -- a dict of {object_id: [candidate values]} per override
category; the full Cartesian product is evaluated (grid search), capped
by ``max_evaluations``.
* ``candidates`` -- fully-formed override dicts supplied directly by the
caller, for a hand-picked shortlist rather than a grid.
Fixed constraints -- confirm before treating as authoritative
---------------------------------------------------------------
``COC_DEFAULT_CONSTRAINTS`` below are commonly-used stormwater design
thresholds (SWMM-modelling continuity guidance, typical municipal
self-cleansing/non-erosive velocity bands, no-surcharge and no-surface-
flooding criteria). They are NOT transcribed from a specific, current
edition of the City of Calgary Stormwater Management & Design Manual or its
Industry Bulletins -- that document was not available to read while writing
this module. Every default is overridable via the ``constraints`` argument,
and the engineer of record must confirm each value against the current
SWMDM/Industry Bulletins and project-specific approval conditions before
relying on a "PASS" result for a real submission.
"""
from __future__ import annotations
from dataclasses import dataclass
from itertools import product
from pathlib import Path
from typing import Any, Iterable
import scenario_manager as sm
# ---------------------------------------------------------------------------
# Fixed constraint defaults -- see module docstring caveat above.
# ---------------------------------------------------------------------------
COC_DEFAULT_CONSTRAINTS: dict[str, Any] = {
# No node may surcharge above the pipe crown at the design storm.
# Expressed as the model's own "depth / diameter (or full_depth)" ratio.
"max_depth_ratio": 1.0,
# Self-cleansing minimum velocity, commonly-cited municipal default.
"min_velocity_mps": 0.75,
# Non-erosive maximum velocity for a typical rigid pipe material.
"max_velocity_mps": 6.0,
# No surface flooding at any node during the design storm.
"max_node_flooding_cms": 0.0,
# SWMM continuity error acceptance band (applies to both the runoff and
# flow/routing continuity errors reported by the engine).
"max_continuity_error_pct": 5.0,
# Project-specific allowable peak release rate. Left as None (not
# checked) unless the caller supplies a value and an outfall_link_id --
# there is no generic default for this, it is genuinely project-specific.
"max_allowable_outfall_flow_cms": None,
}
_OVERRIDE_CATEGORIES = (
"conduit_diameter_overrides",
"conduit_roughness_overrides",
"storage_depth_overrides",
)
_DIRECT_OBJECTIVE_METRICS = {
"maximum_storage_volume": "Maximum Storage Volume",
"maximum_storage_depth": "Maximum Storage Depth",
"maximum_link_velocity": "Maximum Link Velocity",
"peak_link_flow": "Peak Link Flow",
"maximum_modelled_depth_ratio": "Maximum Modelled Depth Ratio",
"maximum_node_flooding": "Maximum Node Flooding",
"peak_subcatchment_runoff": "Peak Subcatchment Runoff",
}
SUPPORTED_OBJECTIVE_METRICS = sorted(
set(_DIRECT_OBJECTIVE_METRICS) | {"total_conduit_volume_m3"}
)
@dataclass
class OptimizationCandidate:
candidate_id: str
overrides: dict[str, dict[str, float]]
summary: dict[str, Any]
objective_value: float | None
feasible: bool
constraint_results: list[dict[str, Any]]
scenario_id: str
def _read_conduit_geometry(inp_path: str | Path) -> dict[str, dict[str, float]]:
"""Read every conduit's length (CONDUITS) and diameter (XSECTIONS) from
the base INP, for computing total_conduit_volume_m3. Only circular
(Geom1 = diameter) links are included; other shapes are skipped rather
than guessed at."""
text = Path(inp_path).read_text(encoding="utf-8", errors="ignore")
_, sections = sm._split_sections(text) # noqa: SLF001 -- reuse the same INP splitter as the rest of this codebase
geometry: dict[str, dict[str, float]] = {}
for line in sections.get("CONDUITS", []):
parsed = sm._data_tokens(line) # noqa: SLF001
if not parsed:
continue
tokens, _ = parsed
if len(tokens) > 3:
try:
geometry[tokens[0]] = {"length": float(tokens[3]), "diameter": 0.0}
except ValueError:
continue
for line in sections.get("XSECTIONS", []):
parsed = sm._data_tokens(line) # noqa: SLF001
if not parsed:
continue
tokens, _ = parsed
if len(tokens) > 2 and tokens[0] in geometry:
try:
geometry[tokens[0]]["diameter"] = float(tokens[2])
except ValueError:
continue
return geometry
def _total_conduit_volume_m3(
base_geometry: dict[str, dict[str, float]],
diameter_overrides: dict[str, float],
) -> float:
import math
total = 0.0
for conduit_id, geom in base_geometry.items():
diameter = diameter_overrides.get(conduit_id, geom.get("diameter", 0.0))
length = geom.get("length", 0.0)
total += math.pi / 4.0 * diameter * diameter * length
return total
def _build_candidates_from_variables(
variables: dict[str, dict[str, list[float]]],
max_evaluations: int,
) -> list[dict[str, dict[str, float]]]:
"""Cartesian-product a {category: {object_id: [values]}} spec into a
bounded list of {category: {object_id: value}} override dicts."""
axes: list[tuple[str, str, list[float]]] = []
for category, per_object in (variables or {}).items():
if category not in _OVERRIDE_CATEGORIES:
raise ValueError(
f"Unsupported variable category '{category}'. Supported: "
f"{', '.join(_OVERRIDE_CATEGORIES)}"
)
for object_id, values in per_object.items():
if not isinstance(values, list) or not values:
raise ValueError(
f"variables['{category}']['{object_id}'] must be a non-empty list of candidate values"
)
axes.append((category, object_id, [float(v) for v in values]))
if not axes:
return [{}]
combo_count = 1
for _, _, values in axes:
combo_count *= len(values)
if combo_count > max_evaluations:
raise ValueError(
f"The variable grid has {combo_count} combinations, which exceeds "
f"max_evaluations={max_evaluations}. Narrow the candidate lists, "
f"raise max_evaluations, or switch to the 'candidates' shortlist mode."
)
combos: list[dict[str, dict[str, float]]] = []
value_lists = [values for _, _, values in axes]
for combo in product(*value_lists):
overrides: dict[str, dict[str, float]] = {}
for (category, object_id, _), value in zip(axes, combo):
overrides.setdefault(category, {})[object_id] = value
combos.append(overrides)
return combos
def _evaluate_constraints(
summary: dict[str, Any],
constraints: dict[str, Any],
outfall_flow_cms: float | None,
) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
def check(name: str, actual: Any, passed: bool, limit: Any, note: str = "") -> None:
results.append({
"constraint": name, "actual": actual, "limit": limit,
"passed": bool(passed), "note": note,
})
max_depth_ratio = constraints.get("max_depth_ratio")
if max_depth_ratio is not None:
actual = summary.get("Maximum Modelled Depth Ratio", 0.0)
check("max_depth_ratio", actual, actual <= max_depth_ratio, max_depth_ratio,
f"Controlling link: {summary.get('Depth-Ratio Link', '')}")
min_v = constraints.get("min_velocity_mps")
max_v = constraints.get("max_velocity_mps")
if min_v is not None or max_v is not None:
actual = summary.get("Maximum Link Velocity", 0.0)
if max_v is not None:
check("max_velocity_mps", actual, actual <= max_v, max_v,
f"Controlling link: {summary.get('Velocity Link', '')}")
if min_v is not None:
check("min_velocity_mps", actual, actual >= min_v, min_v,
"Self-cleansing screen uses the model's maximum observed "
"velocity per link as a proxy; confirm against your "
"project's actual low-flow/self-cleansing check.")
max_flood = constraints.get("max_node_flooding_cms")
if max_flood is not None:
actual = summary.get("Maximum Node Flooding", 0.0)
check("max_node_flooding_cms", actual, actual <= max_flood, max_flood)
max_cont = constraints.get("max_continuity_error_pct")
if max_cont is not None:
runoff_err = abs(summary.get("Runoff Error (%)", 0.0))
flow_err = abs(summary.get("Flow Error (%)", 0.0))
check("max_continuity_error_pct (runoff)", runoff_err, runoff_err <= max_cont, max_cont)
check("max_continuity_error_pct (flow/routing)", flow_err, flow_err <= max_cont, max_cont)
max_outfall = constraints.get("max_allowable_outfall_flow_cms")
if max_outfall is not None:
if outfall_flow_cms is None:
check("max_allowable_outfall_flow_cms", None, False, max_outfall,
"NOT EVALUATED: max_allowable_outfall_flow_cms was set but "
"outfall_link_id was not supplied, so no outfall-specific "
"flow could be measured. This candidate is scored infeasible "
"so an unchecked constraint can never silently pass.")
else:
check("max_allowable_outfall_flow_cms", outfall_flow_cms,
outfall_flow_cms <= max_outfall, max_outfall)
return results
def _objective_value(
objective_metric: str,
summary: dict[str, Any],
base_geometry: dict[str, dict[str, float]],
overrides: dict[str, dict[str, float]],
) -> float | None:
if objective_metric == "total_conduit_volume_m3":
return _total_conduit_volume_m3(
base_geometry, overrides.get("conduit_diameter_overrides", {})
)
field = _DIRECT_OBJECTIVE_METRICS.get(objective_metric)
if field is None:
raise ValueError(
f"Unsupported objective metric '{objective_metric}'. Supported: "
f"{', '.join(SUPPORTED_OBJECTIVE_METRICS)}"
)
value = summary.get(field)
return float(value) if value is not None else None
def optimize(
base_inp_path: str | Path,
work_dir: str | Path,
*,
variables: dict[str, dict[str, list[float]]] | None,
candidates: list[dict[str, dict[str, float]]] | None,
objective_metric: str,
objective_direction: str,
constraints: dict[str, Any] | None,
outfall_link_id: str,
max_evaluations: int,
scenario_prefix: str = "opt",
) -> dict[str, Any]:
if objective_direction not in ("minimize", "maximize"):
raise ValueError("objective_direction must be 'minimize' or 'maximize'")
if bool(variables) == bool(candidates):
if not variables and not candidates:
raise ValueError("Supply either 'variables' (grid) or 'candidates' (shortlist).")
# both supplied is allowed (candidates wins for explicitness) -- fallthrough
resolved_constraints = dict(COC_DEFAULT_CONSTRAINTS)
resolved_constraints.update(constraints or {})
if candidates:
if len(candidates) > max_evaluations:
raise ValueError(
f"{len(candidates)} candidates supplied, which exceeds "
f"max_evaluations={max_evaluations}."
)
combos = candidates
else:
combos = _build_candidates_from_variables(variables or {}, max_evaluations)
base_geometry = _read_conduit_geometry(base_inp_path)
evaluations: list[OptimizationCandidate] = []
for index, overrides in enumerate(combos, start=1):
candidate_id = f"{scenario_prefix}_{index}"
definition = sm.ScenarioDefinition(
scenario_id=candidate_id,
scenario_name=f"Optimization candidate {index}",
conduit_diameter_overrides=dict(overrides.get("conduit_diameter_overrides", {})),
conduit_roughness_overrides=dict(overrides.get("conduit_roughness_overrides", {})),
storage_depth_overrides=dict(overrides.get("storage_depth_overrides", {})),
review_status="Preliminary optimization candidate",
)
record = sm.run_scenario(base_inp_path, definition, work_dir=work_dir)
summary = record["summary"]
outfall_flow_cms = None
if outfall_link_id:
link_values = (record["results"].get("link_ts", {}) or {}).get(outfall_link_id)
if link_values:
flows = link_values.get("flow", []) or [0.0]
outfall_flow_cms = max(abs(float(x)) for x in flows)
constraint_results = _evaluate_constraints(summary, resolved_constraints, outfall_flow_cms)
feasible = all(c["passed"] for c in constraint_results)
objective_value = _objective_value(objective_metric, summary, base_geometry, overrides)
evaluations.append(OptimizationCandidate(
candidate_id=candidate_id,
overrides=overrides,
summary=summary,
objective_value=objective_value,
feasible=feasible,
constraint_results=constraint_results,
scenario_id=record["definition"]["scenario_id"],
))
def sort_key(c: OptimizationCandidate):
infeasible_penalty = 0 if c.feasible else 1
value = c.objective_value
if value is None:
value = float("inf") if objective_direction == "minimize" else float("-inf")
direction_value = value if objective_direction == "minimize" else -value
return (infeasible_penalty, direction_value)
ranked = sorted(evaluations, key=sort_key)
best_feasible = next((c for c in ranked if c.feasible), None)
return {
"objective": {"metric": objective_metric, "direction": objective_direction},
"constraints_used": resolved_constraints,
"constraints_source_note": (
"Fixed constraint defaults are commonly-used stormwater design "
"thresholds, not a transcription of a specific SWMDM/Industry "
"Bulletin edition. Confirm every value against current City of "
"Calgary criteria and project approval conditions."
),
"candidates_evaluated": len(evaluations),
"feasible_count": sum(1 for c in evaluations if c.feasible),
"best_feasible_candidate_id": best_feasible.candidate_id if best_feasible else None,
"ranked_candidates": [
{
"rank": i + 1,
"candidate_id": c.candidate_id,
"feasible": c.feasible,
"objective_value": c.objective_value,
"overrides": c.overrides,
"constraint_results": c.constraint_results,
"summary": c.summary,
}
for i, c in enumerate(ranked)
],
}