Spaces:
Sleeping
Sleeping
Upload 24 files
Browse files- DETERMINISTIC_EXECUTION_GATE.md +42 -0
- report_engine.py +44 -3
- screening_logic.py +77 -0
- test_regression.py +23 -1
- tools.py +75 -152
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 |
-
|
| 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 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
| 170 |
session = STORE.create()
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
+
}
|