razaali10 commited on
Commit
ed201cf
·
verified ·
1 Parent(s): 0708dfc

Upload 24 files

Browse files
DETERMINISTIC_EXECUTION_GATE.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deterministic execution-integrity gate
2
+
3
+ This revision prevents invalid hydraulic arrays from being presented as
4
+ successful SWMR results.
5
+
6
+ ## Changes
7
+
8
+ 1. Dynamic-wave preflight rejects explicit `MAX_TRIALS <= 0` and
9
+ `HEAD_TOLERANCE <= 0`. Omitted values remain untouched so the engine default
10
+ applies. The uploaded INP is never edited.
11
+ 2. Post-run integrity classification records `valid`, `limited`, or `invalid`
12
+ using routing convergence and flow-routing continuity evidence.
13
+ 3. A run is invalid when every routing step fails, at least 5% of routing
14
+ steps fail, or the routing continuity error is at least 10%.
15
+ 4. Invalid hydraulic arrays remain available in the audit database, but node,
16
+ link, storage, control, capacity, spill, and depth-velocity conclusions are
17
+ set to `Not assessed - hydraulic routing solution invalid`.
18
+ 5. The executive summary and model identity disclose the execution gate.
19
+ 6. Calgary screening returns no pass/fail hydraulic results for an invalid run.
20
+ 7. Regression tests cover the Kincora zero-value failure and the corrected
21
+ usable-run metadata.
22
+
23
+ ## Kincora failure reproduced
24
+
25
+ The failed input explicitly contained:
26
+
27
+ ```ini
28
+ MAX_TRIALS 0
29
+ HEAD_TOLERANCE 0
30
+ ```
31
+
32
+ It produced 7,201/7,201 non-convergent steps and 100% routing continuity
33
+ error. Removing those explicit zero overrides in a separate diagnostic copy
34
+ restored 0 non-convergent steps and 0.022% routing continuity error. This
35
+ revision rejects the failed configuration rather than silently changing it.
36
+
37
+ ## Validation
38
+
39
+ - Python compilation passed for all packaged modules.
40
+ - 30 deterministic unit checks passed.
41
+ - Preflight integration requires the Space dependencies and isolated
42
+ OpenSWMM worker environment described in the existing README.
report_engine.py CHANGED
@@ -647,13 +647,19 @@ def _critical_elements(node_df: pd.DataFrame, link_df: pd.DataFrame, units: Unit
647
 
648
  def _summary_findings(node_df: pd.DataFrame, link_df: pd.DataFrame, sub_df: pd.DataFrame, metadata: dict[str, Any], units: UnitContext, options: dict[str, str], criteria: ReportCriteria) -> list[str]:
649
  findings: list[str] = []
650
- from screening_logic import continuity_disclosure
 
 
 
651
  findings.extend(continuity_disclosure(
652
  metadata, review_pct=criteria.continuity_review,
653
  warning_pct=criteria.continuity_warning,
654
  has_pollutants=bool(metadata.get("has_pollutants"))))
655
  if False: # legacy block replaced by deterministic continuity_disclosure
656
  pass
 
 
 
657
  if node_df is not None and not node_df.empty:
658
  flooded_col = next((c for c in node_df.columns if c.startswith('Peak Flooding (')), None)
659
  flooded = int((pd.to_numeric(node_df[flooded_col], errors='coerce').fillna(0) > 0).sum()) if flooded_col else 0
@@ -726,6 +732,13 @@ def _event_summary(sub_df: pd.DataFrame, metadata: dict[str, Any], options: dict
726
 
727
 
728
  def _executive_summary(findings: list[str], critical_nodes: pd.DataFrame, critical_links: pd.DataFrame, units: UnitContext, criteria: ReportCriteria) -> list[str]:
 
 
 
 
 
 
 
729
  lines = ["The simulation completed and the principal model results were screened using the project criteria listed in this report."]
730
  if critical_nodes.empty:
731
  lines.append(f"No junctions or dividers met the generic node screening criteria (depth ratio ≥ {criteria.node_depth_ratio:.2f}, rim clearance ≤ {criteria.minimum_freeboard:g} {units.length}, or flooding greater than zero). Storage facilities are assessed separately using their Calgary storage classification.")
@@ -1080,6 +1093,14 @@ def generate_report_package(
1080
  ) -> dict[str, bytes | str]:
1081
  """Generate editable Word report and ZIP package entirely in memory."""
1082
  criteria = criteria or ReportCriteria()
 
 
 
 
 
 
 
 
1083
  options = _inp_options(inp_sections)
1084
  units = _unit_context(options.get("FLOW_UNITS", ""))
1085
  sub_table = _subcatchment_model_table(inp_sections, sub_summary)
@@ -1117,7 +1138,6 @@ def generate_report_package(
1117
  storage_table.at[idx, "Status"] = "Review: control flow occurs but storage depth is zero"
1118
  area_table = _area_classification(sub_table, units, criteria.area_classification)
1119
  from screening_logic import effective_velocity_table, missing_information_register
1120
- simulation_metadata = dict(simulation_metadata or {})
1121
  simulation_metadata["has_pollutants"] = bool(inp_sections.get("POLLUTANTS"))
1122
  recon_links_df = None
1123
  if reconciliation and reconciliation.get("links") is not None:
@@ -1148,6 +1168,18 @@ def generate_report_package(
1148
  criteria_table = criteria_register(calgary)
1149
  # Calgary-specific calculations use SI design rules. In US models, tables remain available but are flagged for review.
1150
  minor_capacity = build_minor_system_capacity_table(link_table, units.system, units.flow, units.length, calgary)
 
 
 
 
 
 
 
 
 
 
 
 
1151
  # Checklist applicability is based on actual model element types, not on
1152
  # whether the generic link table happens to contain outlet controls.
1153
  _conduit_rows = inp_sections.get("CONDUITS", []) or []
@@ -1158,7 +1190,7 @@ def generate_report_package(
1158
  metadata,
1159
  criteria,
1160
  has_model=bool(inp_sections),
1161
- has_results=not node_report.empty,
1162
  has_storage=not storage_table.empty,
1163
  has_controls=not control_table.empty,
1164
  has_overland=_has_overland,
@@ -1355,6 +1387,12 @@ def generate_report_package(
1355
  overland_cols = ["Link ID", "From Node", "To Node", "Shape", f"Peak Flow ({units.flow})", f"Peak Depth ({units.length})", f"Peak Velocity ({units.velocity})", "Depth Ratio", "Status"]
1356
  _add_df_table(doc, "Table 9 - Overland Flow Assessment", overland[[c for c in overland_cols if c in overland.columns]], landscape=True)
1357
  overland_compliance = build_overland_compliance_table(overland, calgary, units.flow, units.length, units.velocity) if units.system == "SI" else pd.DataFrame()
 
 
 
 
 
 
1358
  if not overland_compliance.empty:
1359
  _add_df_table(doc, "Table 9A - Calgary Major-System Depth-Velocity Screening", overland_compliance, landscape=True, font_size=7.2)
1360
 
@@ -1383,6 +1421,9 @@ def generate_report_package(
1383
  doc.add_heading("4.4 Storage and Trap-Low Assessment", level=2)
1384
  _add_narrative_block(doc, narrative_sections, "storage")
1385
  storage_calgary = apply_storage_classification(storage_table, calgary, units.length) if not storage_table.empty else pd.DataFrame()
 
 
 
1386
  if not criteria.suppress_empty_sections or not storage_calgary.empty:
1387
  _add_df_table(doc, "Table 13 - Storage Unit Performance", storage_calgary, landscape=True, font_size=7.0)
1388
  if criteria.suppress_empty_sections and control_table.empty and storage_table.empty:
 
647
 
648
  def _summary_findings(node_df: pd.DataFrame, link_df: pd.DataFrame, sub_df: pd.DataFrame, metadata: dict[str, Any], units: UnitContext, options: dict[str, str], criteria: ReportCriteria) -> list[str]:
649
  findings: list[str] = []
650
+ from screening_logic import continuity_disclosure, execution_integrity_assessment
651
+ integrity = execution_integrity_assessment(metadata)
652
+ if not integrity["results_usable"]:
653
+ findings.append("HYDRAULIC RESULTS INVALID: " + integrity["reason"])
654
  findings.extend(continuity_disclosure(
655
  metadata, review_pct=criteria.continuity_review,
656
  warning_pct=criteria.continuity_warning,
657
  has_pollutants=bool(metadata.get("has_pollutants"))))
658
  if False: # legacy block replaced by deterministic continuity_disclosure
659
  pass
660
+ if not integrity["results_usable"]:
661
+ findings.append("All result-dependent hydraulic screening is Not assessed; raw arrays remain in the audit package only.")
662
+ return findings
663
  if node_df is not None and not node_df.empty:
664
  flooded_col = next((c for c in node_df.columns if c.startswith('Peak Flooding (')), None)
665
  flooded = int((pd.to_numeric(node_df[flooded_col], errors='coerce').fillna(0) > 0).sum()) if flooded_col else 0
 
732
 
733
 
734
  def _executive_summary(findings: list[str], critical_nodes: pd.DataFrame, critical_links: pd.DataFrame, units: UnitContext, criteria: ReportCriteria) -> list[str]:
735
+ invalid_line = next((x for x in findings if x.startswith("HYDRAULIC RESULTS INVALID:")), None)
736
+ if invalid_line:
737
+ return [
738
+ "The engine process completed, but the hydraulic routing solution failed the deterministic execution-integrity gate.",
739
+ invalid_line,
740
+ "Hydraulic arrays are retained for audit only. Capacity, surcharge, flooding, storage, control, spill-route and depth-velocity conclusions are Not assessed.",
741
+ ]
742
  lines = ["The simulation completed and the principal model results were screened using the project criteria listed in this report."]
743
  if critical_nodes.empty:
744
  lines.append(f"No junctions or dividers met the generic node screening criteria (depth ratio ≥ {criteria.node_depth_ratio:.2f}, rim clearance ≤ {criteria.minimum_freeboard:g} {units.length}, or flooding greater than zero). Storage facilities are assessed separately using their Calgary storage classification.")
 
1093
  ) -> dict[str, bytes | str]:
1094
  """Generate editable Word report and ZIP package entirely in memory."""
1095
  criteria = criteria or ReportCriteria()
1096
+ simulation_metadata = dict(simulation_metadata or {})
1097
+ from screening_logic import execution_integrity_assessment
1098
+ integrity = execution_integrity_assessment(simulation_metadata)
1099
+ simulation_metadata.update({
1100
+ "execution_integrity_status": integrity["status"],
1101
+ "results_usable": integrity["results_usable"],
1102
+ "execution_integrity_reason": integrity["reason"],
1103
+ })
1104
  options = _inp_options(inp_sections)
1105
  units = _unit_context(options.get("FLOW_UNITS", ""))
1106
  sub_table = _subcatchment_model_table(inp_sections, sub_summary)
 
1138
  storage_table.at[idx, "Status"] = "Review: control flow occurs but storage depth is zero"
1139
  area_table = _area_classification(sub_table, units, criteria.area_classification)
1140
  from screening_logic import effective_velocity_table, missing_information_register
 
1141
  simulation_metadata["has_pollutants"] = bool(inp_sections.get("POLLUTANTS"))
1142
  recon_links_df = None
1143
  if reconciliation and reconciliation.get("links") is not None:
 
1168
  criteria_table = criteria_register(calgary)
1169
  # Calgary-specific calculations use SI design rules. In US models, tables remain available but are flagged for review.
1170
  minor_capacity = build_minor_system_capacity_table(link_table, units.system, units.flow, units.length, calgary)
1171
+ if not integrity["results_usable"]:
1172
+ invalid_label = "Not assessed - hydraulic routing solution invalid"
1173
+ for df in (node_report, link_report, link_table, control_table, storage_table):
1174
+ if df is not None and not df.empty:
1175
+ df["Status"] = invalid_label
1176
+ if control_recon is not None and not control_recon.empty:
1177
+ control_recon["Check"] = "Indeterminate - hydraulic routing solution invalid"
1178
+ if minor_capacity is not None and not minor_capacity.empty:
1179
+ if "Status" in minor_capacity:
1180
+ minor_capacity["Status"] = invalid_label
1181
+ if "Assessment Basis" in minor_capacity:
1182
+ minor_capacity["Assessment Basis"] = invalid_label
1183
  # Checklist applicability is based on actual model element types, not on
1184
  # whether the generic link table happens to contain outlet controls.
1185
  _conduit_rows = inp_sections.get("CONDUITS", []) or []
 
1190
  metadata,
1191
  criteria,
1192
  has_model=bool(inp_sections),
1193
+ has_results=not node_report.empty and integrity["results_usable"],
1194
  has_storage=not storage_table.empty,
1195
  has_controls=not control_table.empty,
1196
  has_overland=_has_overland,
 
1387
  overland_cols = ["Link ID", "From Node", "To Node", "Shape", f"Peak Flow ({units.flow})", f"Peak Depth ({units.length})", f"Peak Velocity ({units.velocity})", "Depth Ratio", "Status"]
1388
  _add_df_table(doc, "Table 9 - Overland Flow Assessment", overland[[c for c in overland_cols if c in overland.columns]], landscape=True)
1389
  overland_compliance = build_overland_compliance_table(overland, calgary, units.flow, units.length, units.velocity) if units.system == "SI" else pd.DataFrame()
1390
+ if not integrity["results_usable"] and not overland_compliance.empty:
1391
+ for col in ("Depth-Velocity Status", "Special Limit Status"):
1392
+ if col in overland_compliance:
1393
+ overland_compliance[col] = "Not assessed - hydraulic routing solution invalid"
1394
+ if "Spill Active" in overland_compliance:
1395
+ overland_compliance["Spill Active"] = "Not assessed"
1396
  if not overland_compliance.empty:
1397
  _add_df_table(doc, "Table 9A - Calgary Major-System Depth-Velocity Screening", overland_compliance, landscape=True, font_size=7.2)
1398
 
 
1421
  doc.add_heading("4.4 Storage and Trap-Low Assessment", level=2)
1422
  _add_narrative_block(doc, narrative_sections, "storage")
1423
  storage_calgary = apply_storage_classification(storage_table, calgary, units.length) if not storage_table.empty else pd.DataFrame()
1424
+ if not integrity["results_usable"] and not storage_calgary.empty:
1425
+ if "Calgary Status" in storage_calgary:
1426
+ storage_calgary["Calgary Status"] = "Not assessed - hydraulic routing solution invalid"
1427
  if not criteria.suppress_empty_sections or not storage_calgary.empty:
1428
  _add_df_table(doc, "Table 13 - Storage Unit Performance", storage_calgary, landscape=True, font_size=7.0)
1429
  if criteria.suppress_empty_sections and control_table.empty and storage_table.empty:
screening_logic.py CHANGED
@@ -22,6 +22,83 @@ from typing import Any, Mapping
22
  import pandas as pd
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # ---------------------------------------------------------------------------
26
  # Velocity classification and evidence precedence
27
  # ---------------------------------------------------------------------------
 
22
  import pandas as pd
23
 
24
 
25
+ # ---------------------------------------------------------------------------
26
+ # Solver-option and execution-integrity gates
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def validate_solver_options(options: Mapping[str, Any]) -> list[str]:
30
+ """Return deterministic blocking errors for unusable solver settings.
31
+
32
+ The uploaded engineering model remains immutable. This function never
33
+ substitutes defaults or edits the INP; it only prevents a known-invalid
34
+ configuration from being presented as a completed hydraulic analysis.
35
+ """
36
+ opts = {str(k).upper(): str(v).strip() for k, v in options.items()}
37
+ if opts.get("FLOW_ROUTING", "").upper() != "DYNWAVE":
38
+ return []
39
+ errors: list[str] = []
40
+ for name, label in (("MAX_TRIALS", "hydraulic trials"),
41
+ ("HEAD_TOLERANCE", "head tolerance")):
42
+ if name not in opts:
43
+ continue # omitted means use the engine default
44
+ try:
45
+ value = float(opts[name])
46
+ except (TypeError, ValueError):
47
+ errors.append(f"{name} must be numeric for dynamic-wave routing.")
48
+ continue
49
+ if value <= 0:
50
+ errors.append(
51
+ f"{name} must be greater than zero for dynamic-wave routing; "
52
+ f"the uploaded value {opts[name]!r} disables a usable {label}."
53
+ )
54
+ return errors
55
+
56
+
57
+ def execution_integrity_assessment(metadata: Mapping[str, Any]) -> dict[str, Any]:
58
+ """Classify whether hydraulic results can support screening conclusions."""
59
+ def number(key: str, default: float = 0.0) -> float:
60
+ try:
61
+ return float(metadata.get(key, default))
62
+ except (TypeError, ValueError):
63
+ return default
64
+
65
+ steps = int(number("routing_steps"))
66
+ failed = int(number("not_converged_steps"))
67
+ pct_failed = number("pct_not_converged")
68
+ flow_error = abs(number("flow_error"))
69
+ runoff_error = abs(number("runoff_error"))
70
+
71
+ invalid_reasons: list[str] = []
72
+ if steps > 0 and failed >= steps:
73
+ invalid_reasons.append("every routing step failed to converge")
74
+ elif pct_failed >= 5.0:
75
+ invalid_reasons.append(f"{pct_failed:.3f}% of routing steps failed to converge")
76
+ if flow_error >= 10.0:
77
+ invalid_reasons.append(f"flow-routing continuity error is {flow_error:.3f}%")
78
+
79
+ if invalid_reasons:
80
+ return {
81
+ "status": "invalid",
82
+ "results_usable": False,
83
+ "hydraulic_conclusions_allowed": False,
84
+ "reason": "; ".join(invalid_reasons) + ".",
85
+ }
86
+
87
+ limitations: list[str] = []
88
+ if failed > 0:
89
+ limitations.append(f"{failed} routing step(s) did not converge")
90
+ if flow_error > 1.0:
91
+ limitations.append(f"flow-routing continuity error is {flow_error:.3f}%")
92
+ if runoff_error > 1.0:
93
+ limitations.append(f"runoff continuity error is {runoff_error:.3f}%")
94
+ return {
95
+ "status": "limited" if limitations else "valid",
96
+ "results_usable": True,
97
+ "hydraulic_conclusions_allowed": True,
98
+ "reason": "; ".join(limitations) + ("." if limitations else "Execution-integrity checks passed."),
99
+ }
100
+
101
+
102
  # ---------------------------------------------------------------------------
103
  # Velocity classification and evidence precedence
104
  # ---------------------------------------------------------------------------
test_regression.py CHANGED
@@ -41,8 +41,30 @@ def check(name: str, cond: bool, detail: str = "") -> None:
41
  def unit_tests() -> None:
42
  print("\n== Unit tests: screening_logic ==")
43
  from screening_logic import (classify_velocity, continuity_disclosure,
 
44
  effective_velocity_table,
45
- missing_information_register)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  check("advisory classification (3.5)", "Advisory" in classify_velocity(3.5))
48
  check("critical classification (4.5)", "Critical" in classify_velocity(4.5))
 
41
  def unit_tests() -> None:
42
  print("\n== Unit tests: screening_logic ==")
43
  from screening_logic import (classify_velocity, continuity_disclosure,
44
+ execution_integrity_assessment,
45
  effective_velocity_table,
46
+ missing_information_register,
47
+ validate_solver_options)
48
+
49
+ solver_errors = validate_solver_options({
50
+ "FLOW_ROUTING": "DYNWAVE", "MAX_TRIALS": "0", "HEAD_TOLERANCE": "0"})
51
+ check("zero MAX_TRIALS rejected", any("MAX_TRIALS" in x for x in solver_errors))
52
+ check("zero HEAD_TOLERANCE rejected", any("HEAD_TOLERANCE" in x for x in solver_errors))
53
+ check("omitted dynamic-wave overrides use engine defaults",
54
+ validate_solver_options({"FLOW_ROUTING": "DYNWAVE"}) == [])
55
+
56
+ invalid = execution_integrity_assessment({
57
+ "routing_steps": 7201, "not_converged_steps": 7201,
58
+ "pct_not_converged": 100.0, "flow_error": 100.0,
59
+ "runoff_error": -1.933})
60
+ check("100% nonconvergence invalidates results", invalid["status"] == "invalid")
61
+ check("invalid run blocks hydraulic conclusions",
62
+ not invalid["results_usable"] and not invalid["hydraulic_conclusions_allowed"])
63
+ valid = execution_integrity_assessment({
64
+ "routing_steps": 12641, "not_converged_steps": 0,
65
+ "pct_not_converged": 0.0, "flow_error": 0.022,
66
+ "runoff_error": -1.933})
67
+ check("corrected Kincora run remains usable", valid["results_usable"])
68
 
69
  check("advisory classification (3.5)", "Advisory" in classify_velocity(3.5))
70
  check("critical classification (4.5)", "Critical" in classify_velocity(4.5))
tools.py CHANGED
@@ -33,6 +33,7 @@ from results_db import ResultDatabase
33
  from sessions import STORE
34
  from sql_agent import SafeSQLAgent
35
  from swmm_core import run_swmm
 
36
 
37
  MAX_ROWS = 60
38
  MAX_TS_POINTS = 200
@@ -54,160 +55,47 @@ def _require_results(session) -> None:
54
  raise ValueError(f"Session '{session.id}' has no simulation results yet. Call run_simulation first.")
55
 
56
 
 
 
 
 
57
  # ---------------------------------------------------------------------------
58
  # Model lifecycle
59
  # ---------------------------------------------------------------------------
60
 
61
- MAX_MODEL_BYTES = 20 * 1024 * 1024
62
-
63
-
64
- def _decode_model_payload(
65
- inp_content: str,
66
- filename: str = "model.inp",
67
- encoding: str = "auto",
68
- ) -> tuple[str, str]:
69
- """Normalize raw text, base64, or a JSON-wrapped SWMM model payload."""
70
- if not isinstance(inp_content, str) or not inp_content.strip():
71
- raise ValueError("inp_content is required and must be a non-empty string.")
72
-
73
- value = inp_content.strip()
74
- resolved_filename = str(filename or "model.inp").strip() or "model.inp"
75
- selected_encoding = str(encoding or "auto").strip().lower()
76
-
77
- # Accept a complete JSON object passed as the inp_content value:
78
- # {"filename": "...", "inp_content": "...", "encoding": "base64|raw|auto"}
79
- if value.startswith("{"):
80
- try:
81
- payload = json.loads(value)
82
- except json.JSONDecodeError:
83
- payload = None
84
-
85
- if isinstance(payload, dict):
86
- if "inp_content" not in payload:
87
- raise ValueError("JSON upload payload must contain an 'inp_content' field.")
88
- value = str(payload.get("inp_content") or "").strip()
89
- resolved_filename = (
90
- str(payload.get("filename") or "").strip()
91
- or resolved_filename
92
- )
93
- selected_encoding = str(
94
- payload.get("encoding") or selected_encoding
95
- ).strip().lower()
96
-
97
- if not value:
98
- raise ValueError("The upload payload contains no SWMM model content.")
99
-
100
- if selected_encoding in {"raw", "text"}:
101
- model_text = value
102
-
103
- elif selected_encoding == "base64":
104
- try:
105
- decoded = base64.b64decode(value, validate=True)
106
- except (binascii.Error, ValueError) as exc:
107
- raise ValueError("Invalid base64 SWMM model content.") from exc
108
- try:
109
- model_text = decoded.decode("utf-8-sig")
110
- except UnicodeDecodeError:
111
- model_text = decoded.decode("cp1252")
112
-
113
- elif selected_encoding == "auto":
114
- # Raw INP text normally exposes a section header near the beginning.
115
- if "[" in value[:2000]:
116
- model_text = value
117
- else:
118
- try:
119
- decoded = base64.b64decode(value, validate=True)
120
- try:
121
- candidate = decoded.decode("utf-8-sig")
122
- except UnicodeDecodeError:
123
- candidate = decoded.decode("cp1252")
124
- except (binascii.Error, ValueError, UnicodeDecodeError):
125
- candidate = ""
126
-
127
- model_text = candidate if "[" in candidate[:2000] else value
128
-
129
- else:
130
- raise ValueError("encoding must be one of: auto, raw, text, or base64.")
131
-
132
- encoded_size = len(model_text.encode("utf-8"))
133
- if encoded_size > MAX_MODEL_BYTES:
134
- raise ValueError(
135
- f"SWMM model exceeds the {MAX_MODEL_BYTES // (1024 * 1024)} MB upload limit."
136
- )
137
-
138
- upper = model_text.upper()
139
- if "[OPTIONS]" not in upper and "[JUNCTIONS]" not in upper:
140
- raise ValueError(
141
- "Content does not look like a SWMM .inp file "
142
- "(no [OPTIONS] or [JUNCTIONS] section)."
143
- )
144
-
145
- safe_name = Path(resolved_filename).name or "model.inp"
146
- if not safe_name.lower().endswith(".inp"):
147
- safe_name = f"{Path(safe_name).stem}.inp"
148
-
149
- return model_text, safe_name
150
-
151
-
152
- def upload_model(
153
- inp_content: str,
154
- filename: str = "model.inp",
155
- encoding: str = "auto",
156
- ) -> dict:
157
- """Upload an EPA SWMM model as raw text, base64, or a JSON-wrapped payload.
158
-
159
- JSON payload format:
160
- {"filename": "model.inp", "inp_content": "...", "encoding": "auto|raw|base64"}
161
 
162
  Returns a session_id used by every other tool, plus element counts.
163
  """
164
- model_text, safe_name = _decode_model_payload(
165
- inp_content=inp_content,
166
- filename=filename,
167
- encoding=encoding,
168
- )
169
-
 
 
170
  session = STORE.create()
171
- try:
172
- inp_path = session.workdir / safe_name
173
- inp_path.write_text(model_text, encoding="utf-8", newline="\n")
174
-
175
- sections = mp.parse_inp_sections(str(inp_path))
176
- if not sections:
177
- raise ValueError("The uploaded file could not be parsed into SWMM sections.")
178
-
179
- session.data.update({
180
- "filename": safe_name,
181
- "inp_path": str(inp_path),
182
- "sections": sections,
183
- "upload_encoding": encoding,
184
- "model_size_bytes": inp_path.stat().st_size,
185
- })
186
-
187
- counts = {
188
- name: len(rows)
189
- for name, rows in sections.items()
190
- if name in (
191
- "JUNCTIONS", "OUTFALLS", "STORAGE", "CONDUITS", "PUMPS",
192
- "WEIRS", "ORIFICES", "OUTLETS", "SUBCATCHMENTS",
193
- "RAINGAGES", "TIMESERIES",
194
- )
195
- }
196
- gages = [row[0] for row in sections.get("RAINGAGES", []) if row]
197
-
198
- return {
199
- "session_id": session.id,
200
- "filename": safe_name,
201
- "model_size_bytes": inp_path.stat().st_size,
202
- "element_counts": counts,
203
- "rain_gages": gages,
204
- "design_event_inference": infer_design_event(gages) if gages else None,
205
- "next_step": "Call run_simulation with this session_id.",
206
- }
207
-
208
- except Exception:
209
- STORE.drop(session.id)
210
- raise
211
 
212
 
213
  def run_simulation(session_id: str) -> dict:
@@ -219,6 +107,13 @@ def run_simulation(session_id: str) -> dict:
219
  inp_path = session.data.get("inp_path")
220
  if not inp_path:
221
  raise ValueError("Session has no uploaded model.")
 
 
 
 
 
 
 
222
  results = run_swmm(inp_path)
223
  md = results["metadata"]
224
  import hashlib, datetime
@@ -226,12 +121,24 @@ def run_simulation(session_id: str) -> dict:
226
  run_id = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
227
  md["model_sha256"] = sha256
228
  md["run_id"] = run_id
229
- sections = session.data["sections"]
 
 
 
 
 
 
230
  node_df = mp.build_node_summary(results["node_ts"], mp.parse_node_types(sections), 0.001, 0.9)
231
  link_df = mp.build_link_summary(results["link_ts"], mp.parse_link_topology(sections),
232
  mp.parse_conduit_geometry(sections), 0.9, 3.0)
233
  sub_df = mp.build_sub_summary(results["sub_ts"], mp.parse_subcatchment_attrs(sections),
234
  results.get("times"), md.get("flow_units", "CMS"))
 
 
 
 
 
 
235
  db = ResultDatabase(str(session.workdir / "results.sqlite"))
236
  db.load(node_df, link_df, sub_df, inp_path=inp_path, results=results)
237
 
@@ -253,7 +160,9 @@ def run_simulation(session_id: str) -> dict:
253
  flooded = int((pd.to_numeric(node_df[flooded_col], errors="coerce").fillna(0) > 0.001).sum()) if flooded_col else 0
254
  return {
255
  "session_id": session.id,
256
- "simulation": "completed",
 
 
257
  "model_sha256": sha256,
258
  "run_id": run_id,
259
  "flow_units": md.get("flow_units"),
@@ -262,7 +171,9 @@ def run_simulation(session_id: str) -> dict:
262
  "warnings": (results.get("warnings") or md.get("warnings") or [])[:10],
263
  "flooded_nodes": flooded,
264
  "rpt_reconciliation": recon,
265
- "note": "Values are model results, not engineering determinations.",
 
 
266
  }
267
 
268
 
@@ -391,6 +302,15 @@ def calgary_screening(session_id: str) -> dict:
391
  """
392
  session = STORE.get(session_id)
393
  _require_results(session)
 
 
 
 
 
 
 
 
 
394
  crit = CalgaryCriteria()
395
  link_df = session.data["link_df"]
396
  node_df = session.data["node_df"]
@@ -419,7 +339,8 @@ def preliminary_design_review(session_id: str) -> dict:
419
  inp_text=inp_text, node_summary=session.data["node_df"],
420
  link_summary=session.data["link_df"], sub_summary=session.data["sub_df"],
421
  metadata=session.data["results"]["metadata"],
422
- simulation_completed=True, output_results_available=True)
 
423
  recon_findings = rr.reconciliation_findings(
424
  session.data.get("recon_links"), session.data.get("recon_nodes"),
425
  session.data.get("recon_continuity"))
@@ -623,7 +544,9 @@ def generate_report(session_id: str, project_name: str, client: str = "",
623
  "session_id": session.id,
624
  "run_id": session.data["results"]["metadata"].get("run_id", "—"),
625
  "engine": "EPA SWMM / OpenSWMM 6 (crash-isolated worker, engine Rev 23.2)",
626
- "status": "Completed"})
 
 
627
  outputs = session.workdir / "outputs"
628
  outputs.mkdir(exist_ok=True)
629
  files = {}
@@ -652,4 +575,4 @@ TOOL_REGISTRY: dict[str, Callable[..., dict]] = {
652
  calgary_screening, preliminary_design_review, get_reconciliation,
653
  run_scenario, attach_figure, set_report_details, generate_report,
654
  ]
655
- }
 
33
  from sessions import STORE
34
  from sql_agent import SafeSQLAgent
35
  from swmm_core import run_swmm
36
+ from screening_logic import execution_integrity_assessment, validate_solver_options
37
 
38
  MAX_ROWS = 60
39
  MAX_TS_POINTS = 200
 
55
  raise ValueError(f"Session '{session.id}' has no simulation results yet. Call run_simulation first.")
56
 
57
 
58
+ def _options_map(sections: dict[str, list[list[str]]]) -> dict[str, str]:
59
+ return {str(r[0]).upper(): str(r[1]) for r in sections.get("OPTIONS", []) if len(r) >= 2}
60
+
61
+
62
  # ---------------------------------------------------------------------------
63
  # Model lifecycle
64
  # ---------------------------------------------------------------------------
65
 
66
+ def upload_model(inp_content: str, filename: str = "model.inp") -> dict:
67
+ """Upload an EPA SWMM .inp model (raw text or base64) and create a session.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  Returns a session_id used by every other tool, plus element counts.
70
  """
71
+ text = inp_content
72
+ if "[" not in inp_content[:2000]: # likely base64
73
+ try:
74
+ text = base64.b64decode(inp_content, validate=True).decode("utf-8", errors="replace")
75
+ except (binascii.Error, ValueError):
76
+ pass
77
+ if "[OPTIONS]" not in text.upper() and "[JUNCTIONS]" not in text.upper():
78
+ raise ValueError("Content does not look like a SWMM .inp file (no [OPTIONS]/[JUNCTIONS] section).")
79
  session = STORE.create()
80
+ safe_name = Path(filename).name or "model.inp"
81
+ if not safe_name.lower().endswith(".inp"):
82
+ safe_name += ".inp"
83
+ inp_path = session.workdir / safe_name
84
+ inp_path.write_text(text, encoding="utf-8")
85
+ sections = mp.parse_inp_sections(str(inp_path))
86
+ session.data.update({"filename": safe_name, "inp_path": str(inp_path), "sections": sections})
87
+ counts = {name: len(rows) for name, rows in sections.items()
88
+ if name in ("JUNCTIONS", "OUTFALLS", "STORAGE", "CONDUITS", "PUMPS", "WEIRS",
89
+ "ORIFICES", "OUTLETS", "SUBCATCHMENTS", "RAINGAGES", "TIMESERIES")}
90
+ gages = [row[0] for row in sections.get("RAINGAGES", []) if row]
91
+ return {
92
+ "session_id": session.id,
93
+ "filename": safe_name,
94
+ "element_counts": counts,
95
+ "rain_gages": gages,
96
+ "design_event_inference": infer_design_event(gages) if gages else None,
97
+ "next_step": "Call run_simulation with this session_id.",
98
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
 
101
  def run_simulation(session_id: str) -> dict:
 
107
  inp_path = session.data.get("inp_path")
108
  if not inp_path:
109
  raise ValueError("Session has no uploaded model.")
110
+ sections = session.data["sections"]
111
+ option_errors = validate_solver_options(_options_map(sections))
112
+ if option_errors:
113
+ session.data["input_validation_errors"] = option_errors
114
+ raise ValueError(
115
+ "Input validation failed; simulation was not run. " + " ".join(option_errors)
116
+ )
117
  results = run_swmm(inp_path)
118
  md = results["metadata"]
119
  import hashlib, datetime
 
121
  run_id = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
122
  md["model_sha256"] = sha256
123
  md["run_id"] = run_id
124
+ integrity = execution_integrity_assessment(md)
125
+ md.update({
126
+ "execution_integrity_status": integrity["status"],
127
+ "results_usable": integrity["results_usable"],
128
+ "hydraulic_conclusions_allowed": integrity["hydraulic_conclusions_allowed"],
129
+ "execution_integrity_reason": integrity["reason"],
130
+ })
131
  node_df = mp.build_node_summary(results["node_ts"], mp.parse_node_types(sections), 0.001, 0.9)
132
  link_df = mp.build_link_summary(results["link_ts"], mp.parse_link_topology(sections),
133
  mp.parse_conduit_geometry(sections), 0.9, 3.0)
134
  sub_df = mp.build_sub_summary(results["sub_ts"], mp.parse_subcatchment_attrs(sections),
135
  results.get("times"), md.get("flow_units", "CMS"))
136
+ if not integrity["results_usable"]:
137
+ invalid_label = "Not assessed - hydraulic routing solution invalid"
138
+ if "Status" in node_df:
139
+ node_df["Status"] = invalid_label
140
+ if "Status" in link_df:
141
+ link_df["Status"] = invalid_label
142
  db = ResultDatabase(str(session.workdir / "results.sqlite"))
143
  db.load(node_df, link_df, sub_df, inp_path=inp_path, results=results)
144
 
 
160
  flooded = int((pd.to_numeric(node_df[flooded_col], errors="coerce").fillna(0) > 0.001).sum()) if flooded_col else 0
161
  return {
162
  "session_id": session.id,
163
+ "simulation": "completed" if integrity["results_usable"] else "completed_invalid",
164
+ "execution_integrity": integrity,
165
+ "results_usable": integrity["results_usable"],
166
  "model_sha256": sha256,
167
  "run_id": run_id,
168
  "flow_units": md.get("flow_units"),
 
171
  "warnings": (results.get("warnings") or md.get("warnings") or [])[:10],
172
  "flooded_nodes": flooded,
173
  "rpt_reconciliation": recon,
174
+ "note": ("Values are model results, not engineering determinations."
175
+ if integrity["results_usable"] else
176
+ "Hydraulic arrays are retained for audit only and must not be used for screening conclusions."),
177
  }
178
 
179
 
 
302
  """
303
  session = STORE.get(session_id)
304
  _require_results(session)
305
+ integrity = execution_integrity_assessment(session.data["results"].get("metadata", {}))
306
+ if not integrity["results_usable"]:
307
+ return {
308
+ "velocity_screen_flagged": _df_records(pd.DataFrame(), 30),
309
+ "storage_classification": _df_records(pd.DataFrame(), 30),
310
+ "criteria_register": _df_records(criteria_register(CalgaryCriteria()), 40),
311
+ "status": "Not assessed - hydraulic routing solution invalid",
312
+ "execution_integrity": integrity,
313
+ }
314
  crit = CalgaryCriteria()
315
  link_df = session.data["link_df"]
316
  node_df = session.data["node_df"]
 
339
  inp_text=inp_text, node_summary=session.data["node_df"],
340
  link_summary=session.data["link_df"], sub_summary=session.data["sub_df"],
341
  metadata=session.data["results"]["metadata"],
342
+ simulation_completed=True,
343
+ output_results_available=bool(session.data["results"]["metadata"].get("results_usable", True)))
344
  recon_findings = rr.reconciliation_findings(
345
  session.data.get("recon_links"), session.data.get("recon_nodes"),
346
  session.data.get("recon_continuity"))
 
544
  "session_id": session.id,
545
  "run_id": session.data["results"]["metadata"].get("run_id", "—"),
546
  "engine": "EPA SWMM / OpenSWMM 6 (crash-isolated worker, engine Rev 23.2)",
547
+ "status": ("Completed - results usable" if
548
+ session.data["results"]["metadata"].get("results_usable", True)
549
+ else "Completed - hydraulic results invalid")})
550
  outputs = session.workdir / "outputs"
551
  outputs.mkdir(exist_ok=True)
552
  files = {}
 
575
  calgary_screening, preliminary_design_review, get_reconciliation,
576
  run_scenario, attach_figure, set_report_details, generate_report,
577
  ]
578
+ }