razaali10's picture
Upload 26 files
76dc0f0 verified
Raw
History Blame Contribute Delete
36.4 kB
"""SWMM analysis tool registry.
Every capability is a plain typed function registered in TOOL_REGISTRY.
The MCP surface, the REST surface, and the internal agent all dispatch to
these same functions, so behaviour is identical regardless of platform.
Epistemics: all screening results are deterministic and distinguish
"screening" from "criterion"; nothing here is a professional engineering
determination. Outputs are bounded (row/point limits) so they remain usable
as LLM tool results.
"""
from __future__ import annotations
import base64
import binascii
import json
import math
from pathlib import Path
from typing import Any, Callable
import pandas as pd
import model_pipeline as mp
import rpt_reconciliation as rr
from calgary_rules import (
CalgaryCriteria,
apply_storage_classification,
criteria_register,
infer_design_event,
)
from preliminary_design_assistant import build_deterministic_findings, findings_dataframe
from results_db import ResultDatabase
from sessions import STORE
from sql_agent import SafeSQLAgent
from swmm_core import run_swmm
from screening_logic import execution_integrity_assessment, resolve_legacy_solver_options
MAX_ROWS = 60
MAX_TS_POINTS = 200
def _df_records(df: pd.DataFrame | None, limit: int = MAX_ROWS) -> dict[str, Any]:
if df is None or df.empty:
return {"rows": [], "row_count": 0, "truncated": False}
clean = df.replace({float("nan"): None})
return {
"rows": json.loads(clean.head(limit).to_json(orient="records")),
"row_count": int(len(df)),
"truncated": bool(len(df) > limit),
}
def _require_results(session) -> None:
if not session.data.get("results"):
raise ValueError(f"Session '{session.id}' has no simulation results yet. Call run_simulation first.")
def _options_map(sections: dict[str, list[list[str]]]) -> dict[str, str]:
return {str(r[0]).upper(): str(r[1]) for r in sections.get("OPTIONS", []) if len(r) >= 2}
def _write_normalized_execution_copy(source: Path, substitutions: list[dict[str, Any]]) -> Path:
"""Write a derivative INP containing only audited legacy substitutions."""
replacements = {str(s["option"]).upper(): format(float(s["effective_value"]), "g")
for s in substitutions}
lines = source.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
in_options = False
changed: set[str] = set()
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
in_options = stripped.upper() == "[OPTIONS]"
continue
if not in_options or not stripped or stripped.startswith(";"):
continue
parts = stripped.split()
key = parts[0].upper() if parts else ""
if key in replacements:
newline = "\r\n" if line.endswith("\r\n") else "\n"
lines[i] = f"{parts[0]:<20} {replacements[key]}{newline}"
changed.add(key)
missing = sorted(set(replacements) - changed)
if missing:
raise ValueError("Could not normalize legacy [OPTIONS] values: " + ", ".join(missing))
target = source.with_name(f"{source.stem}_legacy_defaults_normalized{source.suffix}")
target.write_text("".join(lines), encoding="utf-8", newline="")
return target
# ---------------------------------------------------------------------------
# Model lifecycle
# ---------------------------------------------------------------------------
def upload_model(inp_content: str, filename: str = "model.inp") -> dict:
"""Upload an EPA SWMM .inp model (raw text or base64) and create a session.
Returns a session_id used by every other tool, plus element counts.
"""
text = inp_content
if "[" not in inp_content[:2000]: # likely base64
try:
text = base64.b64decode(inp_content, validate=True).decode("utf-8", errors="replace")
except (binascii.Error, ValueError):
pass
if "[OPTIONS]" not in text.upper() and "[JUNCTIONS]" not in text.upper():
raise ValueError("Content does not look like a SWMM .inp file (no [OPTIONS]/[JUNCTIONS] section).")
session = STORE.create()
safe_name = Path(filename).name or "model.inp"
if not safe_name.lower().endswith(".inp"):
safe_name += ".inp"
inp_path = session.workdir / safe_name
inp_path.write_text(text, encoding="utf-8")
sections = mp.parse_inp_sections(str(inp_path))
session.data.update({"filename": safe_name, "inp_path": str(inp_path), "sections": sections})
counts = {name: len(rows) for name, rows in sections.items()
if name in ("JUNCTIONS", "OUTFALLS", "STORAGE", "CONDUITS", "PUMPS", "WEIRS",
"ORIFICES", "OUTLETS", "SUBCATCHMENTS", "RAINGAGES", "TIMESERIES")}
gages = [row[0] for row in sections.get("RAINGAGES", []) if row]
return {
"session_id": session.id,
"filename": safe_name,
"element_counts": counts,
"rain_gages": gages,
"design_event_inference": infer_design_event(gages) if gages else None,
"next_step": "Call run_simulation with this session_id.",
}
def run_simulation(session_id: str) -> dict:
"""Run the model in the crash-isolated OpenSWMM worker and build summaries.
Also runs the deterministic worker-vs-.rpt reconciliation cross-check.
"""
session = STORE.get(session_id)
inp_path = session.data.get("inp_path")
if not inp_path:
raise ValueError("Session has no uploaded model.")
sections = session.data["sections"]
resolution = resolve_legacy_solver_options(_options_map(sections))
option_errors = resolution["errors"]
if option_errors:
session.data["input_validation_errors"] = option_errors
raise ValueError(
"Input validation failed; simulation was not run. " + " ".join(option_errors)
)
original_path = Path(inp_path)
substitutions = list(resolution["substitutions"])
execution_path = (_write_normalized_execution_copy(original_path, substitutions)
if substitutions else original_path)
results = run_swmm(str(execution_path))
md = results["metadata"]
import hashlib, datetime
original_sha256 = hashlib.sha256(original_path.read_bytes()).hexdigest()
execution_sha256 = hashlib.sha256(execution_path.read_bytes()).hexdigest()
run_id = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
md["model_sha256"] = original_sha256
md["original_model_sha256"] = original_sha256
md["execution_model_sha256"] = execution_sha256
md["original_model_filename"] = original_path.name
md["execution_model_filename"] = execution_path.name
md["legacy_defaults_normalized"] = bool(substitutions)
md["solver_option_substitutions"] = substitutions
md["run_id"] = run_id
integrity = execution_integrity_assessment(md)
md.update({
"execution_integrity_status": integrity["status"],
"results_usable": integrity["results_usable"],
"hydraulic_conclusions_allowed": integrity["hydraulic_conclusions_allowed"],
"execution_integrity_reason": integrity["reason"],
})
node_df = mp.build_node_summary(results["node_ts"], mp.parse_node_types(sections), 0.001, 0.9)
link_df = mp.build_link_summary(results["link_ts"], mp.parse_link_topology(sections),
mp.parse_conduit_geometry(sections), 0.9, 3.0)
sub_df = mp.build_sub_summary(results["sub_ts"], mp.parse_subcatchment_attrs(sections),
results.get("times"), md.get("flow_units", "CMS"))
if not integrity["results_usable"]:
invalid_label = "Not assessed - hydraulic routing solution invalid"
if "Status" in node_df:
node_df["Status"] = invalid_label
if "Status" in link_df:
link_df["Status"] = invalid_label
db = ResultDatabase(str(session.workdir / "results.sqlite"))
db.load(node_df, link_df, sub_df, inp_path=original_path, results=results)
recon = {"verdict": "Not performed"}
recon_links = recon_nodes = recon_cont = None
rpt_path = md.get("report_path")
if rpt_path and Path(str(rpt_path)).exists():
recon_links = rr.reconcile_links(link_df, rpt_path)
recon_nodes = rr.reconcile_nodes(node_df, rpt_path)
recon_cont = rr.reconcile_continuity(md, rpt_path)
recon = rr.reconciliation_summary(recon_links)
session.data.update({
"results": results, "node_df": node_df, "link_df": link_df, "sub_df": sub_df,
"execution_inp_path": str(execution_path),
"db": db, "recon_links": recon_links, "recon_nodes": recon_nodes,
"recon_continuity": recon_cont, "recon_summary": recon,
})
flooded_col = next((c for c in node_df.columns if c.startswith("Peak Flooding (")), None)
flooded = int((pd.to_numeric(node_df[flooded_col], errors="coerce").fillna(0) > 0.001).sum()) if flooded_col else 0
return {
"session_id": session.id,
"simulation": "completed" if integrity["results_usable"] else "completed_invalid",
"execution_integrity": integrity,
"results_usable": integrity["results_usable"],
"model_sha256": original_sha256,
"execution_model_sha256": execution_sha256,
"legacy_defaults_normalized": bool(substitutions),
"solver_option_substitutions": substitutions,
"run_id": run_id,
"flow_units": md.get("flow_units"),
"runoff_continuity_error_pct": round(float(md.get("runoff_error", 0.0)), 3),
"flow_continuity_error_pct": round(float(md.get("flow_error", 0.0)), 3),
"warnings": (results.get("warnings") or md.get("warnings") or [])[:10],
"flooded_nodes": flooded,
"rpt_reconciliation": recon,
"note": ("Values are model results, not engineering determinations."
if integrity["results_usable"] else
"Hydraulic arrays are retained for audit only and must not be used for screening conclusions."),
}
def list_sessions() -> dict:
"""List active sessions (id, model filename, simulated flag, age)."""
return {"sessions": STORE.list()}
def close_session(session_id: str) -> dict:
"""Delete a session and its working files."""
return {"session_id": session_id, "deleted": STORE.drop(session_id)}
# ---------------------------------------------------------------------------
# Results
# ---------------------------------------------------------------------------
def get_node_results(session_id: str, node_type: str = "", sort_by: str = "Depth Ratio",
limit: int = 20) -> dict:
"""Node result summary. Optional node_type filter (junction/storage/outfall);
sorted descending by sort_by column (default Depth Ratio)."""
session = STORE.get(session_id)
_require_results(session)
df = session.data["node_df"]
if node_type:
df = df[df["Type"].astype(str).str.lower() == node_type.lower()]
if sort_by in df.columns:
df = df.sort_values(sort_by, ascending=False)
return _df_records(df, min(int(limit), MAX_ROWS))
def get_link_results(session_id: str, sort_by: str = "Peak Velocity (m/s)", limit: int = 20) -> dict:
"""Link result summary sorted descending by sort_by (default peak velocity)."""
session = STORE.get(session_id)
_require_results(session)
df = session.data["link_df"]
if sort_by in df.columns:
df = df.sort_values(sort_by, ascending=False)
return _df_records(df, min(int(limit), MAX_ROWS))
def get_subcatchment_results(session_id: str, limit: int = 30) -> dict:
"""Subcatchment runoff summary."""
session = STORE.get(session_id)
_require_results(session)
return _df_records(session.data["sub_df"], min(int(limit), MAX_ROWS))
def get_timeseries(session_id: str, object_type: str, object_id: str, variable: str) -> dict:
"""Bounded time series for one object.
object_type: node|link|subcatchment. Variables — node: depth, flooding,
inflow, head, outflow, volume; link: flow, depth, velocity, volume,
capacity; subcatchment: runoff, rainfall, infil. Series longer than 200
points are decimated evenly (peaks preserved via max-in-bucket).
"""
session = STORE.get(session_id)
_require_results(session)
results = session.data["results"]
key = {"node": "node_ts", "link": "link_ts", "subcatchment": "sub_ts"}.get(object_type.lower())
if key is None:
raise ValueError("object_type must be node, link, or subcatchment")
store = results[key]
if object_id not in store:
raise ValueError(f"Unknown {object_type} '{object_id}'. Known: {sorted(store)[:25]}")
series = store[object_id].get(variable)
if not isinstance(series, list):
available = [k for k, v in store[object_id].items() if isinstance(v, list)]
raise ValueError(f"Unknown variable '{variable}'. Available: {available}")
times = results.get("times", [])
n = len(series)
if n > MAX_TS_POINTS:
bucket = math.ceil(n / MAX_TS_POINTS)
points = []
for i in range(0, n, bucket):
chunk = series[i:i + bucket]
j = i + max(range(len(chunk)), key=lambda k: abs(chunk[k]))
points.append({"t": str(times[j]) if j < len(times) else j, "v": round(float(series[j]), 6)})
else:
points = [{"t": str(times[i]) if i < len(times) else i, "v": round(float(v), 6)}
for i, v in enumerate(series)]
peak_val = max(series, key=abs, default=0.0)
peak_idx = series.index(peak_val) if series else 0
time_of_peak = str(times[peak_idx]) if peak_idx < len(times) else None
return {"object_id": object_id, "variable": variable, "n_source_points": n,
"decimated": n > MAX_TS_POINTS, "peak": round(float(peak_val), 6),
"time_of_peak": time_of_peak,
"note": "time_of_peak is from the full-resolution series; do not infer it from decimated point labels.",
"points": points}
def query_results(session_id: str, plan: dict | str) -> dict:
"""Execute a validated JSON retrieval plan against the bounded result DB.
Plan format: {"actions":[{"type":"select"|"aggregate","table":...,
"columns":[...], "filters":[{"column","op","value"}], "order_by":[...],
"limit":N, "aggregations":[{"agg","column"}]}]}. Use get_table_catalog
for table/column names. Read-only; invalid plans degrade gracefully.
"""
session = STORE.get(session_id)
_require_results(session)
if isinstance(plan, str):
plan = json.loads(plan)
agent = SafeSQLAgent(session.data["db"])
result = agent.execute_plan(plan)
return {"context": result.context[:12000]}
def get_table_catalog(session_id: str) -> dict:
"""List queryable tables (results + complete tokenized INP) for query_results."""
session = STORE.get(session_id)
_require_results(session)
return _df_records(session.data["db"].table_catalog(), 60)
# ---------------------------------------------------------------------------
# Screening and review
# ---------------------------------------------------------------------------
def calgary_screening(session_id: str) -> dict:
"""Screen results against City-of-Calgary-style criteria.
Velocity screen (3.0 m/s advisory / 4.0 m/s critical), storage
classification (trap-low vs pond heuristics), and the criteria register.
SCREENING ONLY — thresholds must be confirmed by the responsible engineer.
"""
session = STORE.get(session_id)
_require_results(session)
cfg = dict(session.data.get("report_configuration", {}))
crit = CalgaryCriteria(
minor_release_rate_lps_ha=cfg.get("minor_release_rate_lps_ha"),
trap_low_max_depth_m=cfg.get("trap_low_max_depth_m", 0.50),
entrance_grade_margin_m=cfg.get("entrance_grade_margin_m", 0.30),
pipe_advisory_velocity_mps=cfg.get("velocity_advisory", 3.0),
pipe_critical_velocity_mps=cfg.get("velocity_threshold", 4.0),
conduit_capacity_review_ratio=cfg.get("conduit_depth_ratio", 0.80),
conduit_capacity_warning_ratio=cfg.get("conduit_capacity_warning_ratio", 0.95),
continuity_review_pct=cfg.get("continuity_review", 0.50),
continuity_warning_pct=cfg.get("continuity_warning", 1.00),
special_link_limits=cfg.get("special_link_limits", {}),
storage_classification=cfg.get("storage_classification", {}),
outfall_classification=cfg.get("outfall_classification", {}),
)
integrity = execution_integrity_assessment(session.data["results"].get("metadata", {}))
if not integrity["results_usable"]:
return {
"velocity_screen_flagged": _df_records(pd.DataFrame(), 30),
"storage_classification": _df_records(pd.DataFrame(), 30),
"criteria_register": _df_records(criteria_register(crit), 40),
"status": "Not assessed - hydraulic routing solution invalid",
"execution_integrity": integrity,
}
link_df = session.data["link_df"]
node_df = session.data["node_df"]
vel_col = next((c for c in link_df.columns if c.startswith("Peak Velocity")), None)
lv = link_df[["Link ID", vel_col, "Depth Ratio"]].copy()
lv["Screen"] = lv[vel_col].apply(
lambda v: (f"CRITICAL > {crit.pipe_critical_velocity_mps:g}"
if v > crit.pipe_critical_velocity_mps else
(f"Advisory > {crit.pipe_advisory_velocity_mps:g}"
if v > crit.pipe_advisory_velocity_mps else "OK")))
flagged = lv[lv["Screen"] != "OK"].sort_values(vel_col, ascending=False)
storage = node_df[node_df["Type"].astype(str).str.lower() == "storage"].copy()
storage_class = apply_storage_classification(storage, crit, "m") if not storage.empty else pd.DataFrame()
return {
"velocity_screen_flagged": _df_records(flagged, 30),
"storage_classification": _df_records(storage_class, 30),
"criteria_register": _df_records(criteria_register(crit), 40),
"status": "Screening only — criteria applicability requires engineer confirmation.",
}
def preliminary_design_review(session_id: str) -> dict:
"""Deterministic QA/QC findings register (topology, hydrology, screening)
merged with the worker-vs-.rpt reconciliation findings (RPT-###)."""
session = STORE.get(session_id)
_require_results(session)
inp_text = Path(session.data["inp_path"]).read_text(encoding="utf-8", errors="replace")
findings = build_deterministic_findings(
inp_text=inp_text, node_summary=session.data["node_df"],
link_summary=session.data["link_df"], sub_summary=session.data["sub_df"],
metadata=session.data["results"]["metadata"],
simulation_completed=True,
output_results_available=bool(session.data["results"]["metadata"].get("results_usable", True)))
recon_findings = rr.reconciliation_findings(
session.data.get("recon_links"), session.data.get("recon_nodes"),
session.data.get("recon_continuity"))
all_findings = list(findings) + list(recon_findings)
session.data["findings"] = all_findings
return {"findings": _df_records(findings_dataframe(all_findings), 60),
"rpt_reconciliation": session.data.get("recon_summary", {})}
def get_reconciliation(session_id: str) -> dict:
"""Worker-vs-.rpt reconciliation detail: flagged links and continuity check.
For flagged links, .rpt values are authoritative for screening."""
session = STORE.get(session_id)
_require_results(session)
lr = session.data.get("recon_links")
flagged = lr[lr["Overall Status"] != "OK"] if lr is not None and not lr.empty else pd.DataFrame()
return {"summary": session.data.get("recon_summary", {}),
"flagged_links": _df_records(flagged, 40),
"continuity": _df_records(session.data.get("recon_continuity"), 5)}
# ---------------------------------------------------------------------------
# Scenarios and reporting
# ---------------------------------------------------------------------------
def run_scenario(session_id: str, scenario_name: str,
conduit_diameter_overrides: dict | str | None = None,
rainfall_multiplier: float | None = None) -> dict:
"""Clone the base model, apply controlled changes, re-simulate, compare.
conduit_diameter_overrides: {"link_id": new_diameter_m}. The base model is
never mutated; comparisons quote deterministic summary deltas.
"""
import scenario_manager as sm
session = STORE.get(session_id)
_require_results(session)
if isinstance(conduit_diameter_overrides, str) and conduit_diameter_overrides:
conduit_diameter_overrides = json.loads(conduit_diameter_overrides)
kwargs: dict[str, Any] = {}
if conduit_diameter_overrides:
kwargs["conduit_diameter_overrides"] = {str(k): float(v) for k, v in conduit_diameter_overrides.items()}
if rainfall_multiplier is not None:
kwargs["rainfall_multiplier"] = float(rainfall_multiplier)
scen_id = f"scn_{len(session.data.setdefault('scenarios', [])) + 1}"
definition = sm.ScenarioDefinition(scenario_id=scen_id, scenario_name=scenario_name, **kwargs)
record = sm.run_scenario(session.data["inp_path"], definition,
work_dir=str(session.workdir / "scenarios"))
base_record = sm.base_model_record(session.data["results"])
session.data["scenarios"].append(record)
comparison = sm.comparison_with_base(base_record, session.data["scenarios"])
narrative = sm.deterministic_comparison_analysis(comparison)
session.data["scenario_comparison"] = comparison
keep = [c for c in comparison.columns if comparison[c].dtype != object or c in
("Scenario ID", "Scenario Name", "Simulation Status", "Velocity Link")]
return {"scenario_id": scen_id, "comparison": _df_records(comparison[keep], 20),
"deterministic_analysis": narrative[:6000]}
def attach_figure(session_id: str, image_base64: str, caption: str,
section: str = "results", figure_name: str = "") -> dict:
"""Attach a client-generated figure (PNG/JPEG, base64) to the session so
generate_report embeds it in the AUDITED report instead of the client
rebuilding the document itself.
section: keyword matched against report Heading-1 titles (e.g. "results",
"methodology", "site"); the figure is placed at the end of that section.
Figures are labelled as client-attached illustrative material — they are
not server-verified outputs. Limits: PNG or JPEG, 5 MB decoded.
"""
session = STORE.get(session_id)
try:
blob = base64.b64decode(image_base64, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(f"image_base64 is not valid base64: {exc}")
if len(blob) > 5 * 1024 * 1024:
raise ValueError("Figure exceeds the 5 MB limit.")
if blob[:8] == b"\x89PNG\r\n\x1a\n":
ext = "png"
elif blob[:3] == b"\xff\xd8\xff":
ext = "jpg"
else:
raise ValueError("Only PNG or JPEG figures are accepted (magic-byte check failed).")
figures = session.data.setdefault("figures", [])
figure_id = f"FIG-{len(figures) + 1:02d}"
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in (figure_name or figure_id))
fig_dir = session.workdir / "figures"
fig_dir.mkdir(exist_ok=True)
path = fig_dir / f"{safe}.{ext}"
path.write_bytes(blob)
figures.append({"figure_id": figure_id, "path": str(path), "caption": caption.strip(),
"section": section.strip().lower() or "results", "source": "client-attached"})
return {"figure_id": figure_id, "stored": path.name, "size_bytes": len(blob),
"section": section, "attached_figures": [
{"figure_id": f["figure_id"], "caption": f["caption"], "section": f["section"]}
for f in figures],
"next_step": "Call generate_report; the figure will be embedded with a labelled caption."}
_METADATA_FIELDS = {
"client", "consultant", "consultant_file_no", "subdivision_no", "outline_plan_no",
"development_permit_no", "design_storm", "prepared_by", "checked_by", "municipality",
"contact_name", "contact_email", "legal_description", "construction_drawing_no",
"development_agreement_no",
}
_NARRATIVE_FIELDS = {
"introduction", "site_description", "design_objectives", "methodology",
"applicable_criteria",
}
def set_report_details(session_id: str, details: dict | str) -> dict:
"""Store site-specific narrative and project metadata for generate_report.
details keys — NARRATIVE (multi-line text; design_objectives lines become
bullets): introduction, site_description, design_objectives, methodology,
applicable_criteria. METADATA (single values): client, consultant,
consultant_file_no, subdivision_no, outline_plan_no,
development_permit_no, design_storm, prepared_by, checked_by,
municipality, contact_name, contact_email, legal_description,
construction_drawing_no, development_agreement_no.
Repeated calls merge (later values win); pass an empty string to clear a
key. Attribute provenance inside the text itself (model-derived vs
user-supplied vs inferred) — the server stores it verbatim. For a site
aerial/location image use attach_figure with section="site".
"""
session = STORE.get(session_id)
if isinstance(details, str):
details = json.loads(details)
if not isinstance(details, dict):
raise ValueError("details must be a JSON object of field -> text.")
unknown = sorted(set(details) - _METADATA_FIELDS - _NARRATIVE_FIELDS)
if unknown:
raise ValueError(f"Unknown field(s) {unknown}. Narrative: {sorted(_NARRATIVE_FIELDS)}; "
f"metadata: {sorted(_METADATA_FIELDS)}.")
store = session.data.setdefault("report_details", {})
for key, value in details.items():
text = str(value).strip()
if text:
store[key] = text
else:
store.pop(key, None)
return {"session_id": session.id,
"stored_narrative": sorted(k for k in store if k in _NARRATIVE_FIELDS),
"stored_metadata": sorted(k for k in store if k in _METADATA_FIELDS),
"next_step": "generate_report will include these details; readiness scoring reflects them."}
_REPORT_CONFIG_FIELDS = {
"node_depth_ratio", "minimum_freeboard", "conduit_depth_ratio",
"velocity_threshold", "velocity_advisory", "continuity_review",
"continuity_warning", "suppress_empty_sections", "major_link_ids",
"area_classification", "calgary_enabled", "minor_release_rate_lps_ha",
"trap_low_max_depth_m", "entrance_grade_margin_m",
"conduit_capacity_warning_ratio", "special_link_limits",
"storage_classification", "outfall_classification", "checklist_overrides",
"drawing_inventory", "applicable_reports",
}
_REPORT_CONFIG_LISTS = {"major_link_ids", "drawing_inventory", "applicable_reports"}
_REPORT_CONFIG_MAPS = {
"area_classification", "special_link_limits", "storage_classification",
"outfall_classification", "checklist_overrides",
}
_REPORT_CONFIG_BOOLS = {"suppress_empty_sections", "calgary_enabled"}
def set_report_configuration(session_id: str, configuration: dict | str) -> dict:
"""Set project-specific City of Calgary SWMR criteria and evidence inputs.
Use this after upload and before generate_report. Supported configuration
includes major_link_ids, project-specific flow limits, storage/outfall/area
classifications, numerical screening thresholds, drawing_inventory,
applicable_reports, and checklist_overrides. Repeated calls merge; JSON
null removes a field. This configures deterministic screening and report
completeness—it does not establish municipal acceptance or professional
authentication.
"""
session = STORE.get(session_id)
if isinstance(configuration, str):
configuration = json.loads(configuration)
if not isinstance(configuration, dict):
raise ValueError("configuration must be a JSON object.")
unknown = sorted(set(configuration) - _REPORT_CONFIG_FIELDS)
if unknown:
raise ValueError(f"Unknown report configuration field(s): {unknown}. "
f"Supported: {sorted(_REPORT_CONFIG_FIELDS)}")
normalized: dict[str, Any] = {}
for key, value in configuration.items():
if value is None:
normalized[key] = None
elif key in _REPORT_CONFIG_LISTS:
if not isinstance(value, (list, tuple)):
raise ValueError(f"{key} must be a JSON array.")
normalized[key] = tuple(str(x).strip() for x in value if str(x).strip())
elif key in _REPORT_CONFIG_MAPS:
if not isinstance(value, dict):
raise ValueError(f"{key} must be a JSON object.")
if key == "special_link_limits":
try:
normalized[key] = {str(k): float(v) for k, v in value.items()}
except (TypeError, ValueError):
raise ValueError("special_link_limits values must be numeric.")
else:
normalized[key] = {str(k): str(v) for k, v in value.items()}
elif key in _REPORT_CONFIG_BOOLS:
if not isinstance(value, bool):
raise ValueError(f"{key} must be true or false.")
normalized[key] = value
else:
if isinstance(value, bool):
raise ValueError(f"{key} must be numeric or null.")
try:
normalized[key] = float(value)
except (TypeError, ValueError):
raise ValueError(f"{key} must be numeric or null.")
stored = session.data.setdefault("report_configuration", {})
for key, value in normalized.items():
if value is None:
stored.pop(key, None)
else:
stored[key] = value
return {
"session_id": session.id,
"report_configuration": stored,
"next_step": "Run Calgary screening/review as needed, then call generate_report.",
"disclaimer": "Project criteria and classifications require responsible-engineer verification.",
}
def generate_report(session_id: str, project_name: str, client: str = "",
consultant: str = "", prepared_by: str = "",
outline_plan_no: str = "",
include_model_appendix: bool = True) -> dict:
"""Generate the Calgary-style SWMR draft package (docx + audit zip).
include_model_appendix (default True) reproduces the model input (.inp)
and engine output (.rpt) as fixed-width listings in APPENDIX D, per the
Calgary SWMR checklist; very long files are middle-truncated in the docx
with the untruncated copies archived in the audit zip under model/.
Returns download paths served by this Space at /files/{session_id}/{name}.
The draft-readiness score honestly reflects missing project information.
"""
from report_engine import ReportCriteria, ReportMetadata, generate_report_package
session = STORE.get(session_id)
_require_results(session)
stored = dict(session.data.get("report_details", {}))
meta_kwargs = {k: v for k, v in stored.items() if k in _METADATA_FIELDS}
# Explicit arguments override stored details when provided.
for key, value in (("client", client), ("consultant", consultant),
("prepared_by", prepared_by), ("outline_plan_no", outline_plan_no)):
if value:
meta_kwargs[key] = value
narrative = {k: v for k, v in stored.items() if k in _NARRATIVE_FIELDS}
meta = ReportMetadata(project_name=project_name, **meta_kwargs)
report_criteria = ReportCriteria(**dict(session.data.get("report_configuration", {})))
model_listings = None
if include_model_appendix:
inp_p = Path(session.data["inp_path"])
model_listings = {"inp_name": inp_p.name,
"inp_text": inp_p.read_text(encoding="utf-8", errors="replace")}
execution_p = Path(session.data.get("execution_inp_path", inp_p))
if execution_p != inp_p:
model_listings.update({
"execution_inp_name": execution_p.name,
"execution_inp_text": execution_p.read_text(encoding="utf-8", errors="replace"),
})
rpt_p = (session.data["results"].get("metadata", {}) or {}).get("report_path")
if rpt_p and Path(str(rpt_p)).exists():
model_listings["rpt_name"] = Path(str(rpt_p)).name
model_listings["rpt_text"] = Path(str(rpt_p)).read_text(encoding="utf-8", errors="replace")
findings = session.data.get("findings") or []
pkg = generate_report_package(
metadata=meta, inp_sections=session.data["sections"],
node_summary=session.data["node_df"], link_summary=session.data["link_df"],
sub_summary=session.data["sub_df"],
simulation_metadata=session.data["results"]["metadata"],
criteria=report_criteria,
result_db_bytes=session.data["db"].export_bytes(),
preliminary_review_artifacts={
"findings": findings, "status": "Preliminary",
"manifest": {"rpt_reconciliation": session.data.get("recon_summary", {})},
} if findings else None,
attached_figures=session.data.get("figures") or None,
narrative_sections=narrative or None,
model_listings=model_listings,
reconciliation={"links": session.data.get("recon_links"),
"summary": session.data.get("recon_summary", {})},
model_identity={"inp_name": Path(session.data["inp_path"]).name,
"sha256": session.data["results"]["metadata"].get("model_sha256", "—"),
"execution_inp_name": session.data["results"]["metadata"].get("execution_model_filename"),
"execution_sha256": session.data["results"]["metadata"].get("execution_model_sha256"),
"legacy_defaults_normalized": session.data["results"]["metadata"].get("legacy_defaults_normalized", False),
"solver_option_substitutions": session.data["results"]["metadata"].get("solver_option_substitutions", []),
"session_id": session.id,
"run_id": session.data["results"]["metadata"].get("run_id", "—"),
"engine": "EPA SWMM / OpenSWMM 6 (crash-isolated worker, engine Rev 23.2)",
"status": ("Completed - results usable" if
session.data["results"]["metadata"].get("results_usable", True)
else "Completed - hydraulic results invalid")})
outputs = session.workdir / "outputs"
outputs.mkdir(exist_ok=True)
files = {}
for key, blob in pkg.items():
if isinstance(blob, (bytes, bytearray)):
name = pkg.get(f"{key}_name") if isinstance(pkg.get(f"{key}_name"), str) else f"{key}.bin"
if key == "docx":
name = f"{project_name.replace(' ', '_')}_SWMR_Draft.docx"
elif key == "zip":
name = f"{project_name.replace(' ', '_')}_SWMR_Package.zip"
(outputs / name).write_bytes(blob)
files[key] = f"/files/{session.id}/{name}"
return {"files": files, "size_bytes": {k: len(v) for k, v in pkg.items() if isinstance(v, (bytes, bytearray))},
"note": "Draft for engineering review — not an issued document."}
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
TOOL_REGISTRY: dict[str, Callable[..., dict]] = {
fn.__name__: fn for fn in [
upload_model, run_simulation, list_sessions, close_session,
get_node_results, get_link_results, get_subcatchment_results,
get_timeseries, query_results, get_table_catalog,
calgary_screening, preliminary_design_review, get_reconciliation,
run_scenario, attach_figure, set_report_details, set_report_configuration,
generate_report,
]
}