Spaces:
Sleeping
Sleeping
| """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, | |
| ] | |
| } | |