Spaces:
Sleeping
Sleeping
File size: 13,949 Bytes
aad9a8a ed201cf 76dc0f0 ed201cf 76dc0f0 ed201cf 76dc0f0 ed201cf 76dc0f0 ed201cf 76dc0f0 ed201cf 76dc0f0 ed201cf 76dc0f0 ed201cf aad9a8a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 | """Deterministic screening logic shared by the report engine and tests.
Implements the evidence-precedence, continuity-disclosure, and
missing-information rules that must never be delegated to an LLM:
1. Effective-velocity precedence — for links flagged by the worker-vs-.rpt
reconciliation, the engine .rpt value governs screening; both values,
their differences, and whether the discrepancy changes the screening
classification are recorded. Unflagged links use worker values.
2. Continuity disclosure — runoff and routing errors are reported separately,
sign preserved, each checked against ABSOLUTE review/warning thresholds;
water quality is "Not applicable" when no pollutants are modelled.
3. Missing-information register — deterministic list of evidence the report
cannot supply, assembled from metadata, the criteria register, and the
checklist. Anything listed here can never be a Pass elsewhere.
"""
from __future__ import annotations
import math
from typing import Any, Mapping
import pandas as pd
# ---------------------------------------------------------------------------
# Solver-option and execution-integrity gates
# ---------------------------------------------------------------------------
def resolve_legacy_solver_options(options: Mapping[str, Any]) -> dict[str, Any]:
"""Resolve auditable SWMM legacy-zero sentinels for an execution copy.
Older/converted INP files can explicitly serialize zero for dynamic-wave
options that EPA SWMM displays and executes using unit-aware defaults.
This function returns substitutions for an immutable derivative; it never
edits the uploaded source model. Negative and non-numeric values remain
blocking errors. Omitted values remain omitted for the engine to default.
"""
opts = {str(k).upper(): str(v).strip() for k, v in options.items()}
if opts.get("FLOW_ROUTING", "").upper() != "DYNWAVE":
return {"effective_options": dict(opts), "substitutions": [], "errors": []}
flow_units = opts.get("FLOW_UNITS", "").upper()
si_units = flow_units in {"CMS", "LPS", "MLD"}
defaults = {
"MAX_TRIALS": (8.0, "count"),
"HEAD_TOLERANCE": (0.0015 if si_units else 0.005, "m" if si_units else "ft"),
"MIN_SURFAREA": (1.167 if si_units else 12.566, "m2" if si_units else "ft2"),
}
effective = dict(opts)
substitutions: list[dict[str, Any]] = []
errors: list[str] = []
for name, (default, units) in defaults.items():
if name not in opts:
continue # omitted means use the engine default
try:
value = float(opts[name])
except (TypeError, ValueError):
errors.append(f"{name} must be numeric for dynamic-wave routing.")
continue
if value < 0:
errors.append(
f"{name} cannot be negative for dynamic-wave routing; "
f"the uploaded value is {opts[name]!r}."
)
elif value == 0:
effective[name] = format(default, "g")
substitutions.append({
"option": name, "original_value": opts[name],
"effective_value": default, "units": units,
"reason": "Recognized legacy zero/default sentinel",
})
return {"effective_options": effective,
"substitutions": substitutions, "errors": errors}
def validate_solver_options(options: Mapping[str, Any]) -> list[str]:
"""Return only blocking errors after legacy-default resolution."""
return list(resolve_legacy_solver_options(options)["errors"])
def execution_integrity_assessment(metadata: Mapping[str, Any]) -> dict[str, Any]:
"""Classify whether hydraulic results can support screening conclusions."""
def number(key: str, default: float = 0.0) -> float:
try:
return float(metadata.get(key, default))
except (TypeError, ValueError):
return default
steps = int(number("routing_steps"))
failed = int(number("not_converged_steps"))
pct_failed = number("pct_not_converged")
flow_error = abs(number("flow_error"))
runoff_error = abs(number("runoff_error"))
invalid_reasons: list[str] = []
if steps > 0 and failed >= steps:
invalid_reasons.append("every routing step failed to converge")
elif pct_failed >= 5.0:
invalid_reasons.append(f"{pct_failed:.3f}% of routing steps failed to converge")
if flow_error >= 10.0:
invalid_reasons.append(f"flow-routing continuity error is {flow_error:.3f}%")
if invalid_reasons:
return {
"status": "invalid",
"results_usable": False,
"hydraulic_conclusions_allowed": False,
"reason": "; ".join(invalid_reasons) + ".",
}
limitations: list[str] = []
if failed > 0:
limitations.append(f"{failed} routing step(s) did not converge")
if flow_error > 1.0:
limitations.append(f"flow-routing continuity error is {flow_error:.3f}%")
if runoff_error > 1.0:
limitations.append(f"runoff continuity error is {runoff_error:.3f}%")
return {
"status": "limited" if limitations else "valid",
"results_usable": True,
"hydraulic_conclusions_allowed": True,
"reason": "; ".join(limitations) + ("." if limitations else "Execution-integrity checks passed."),
}
# ---------------------------------------------------------------------------
# Velocity classification and evidence precedence
# ---------------------------------------------------------------------------
def classify_velocity(velocity: float | None, advisory: float = 3.0,
critical: float = 4.0) -> str:
"""Deterministic dual-threshold screening classification (not a
regulatory determination)."""
if velocity is None:
return "Not assessed"
try:
v = float(velocity)
except (TypeError, ValueError):
return "Not assessed"
if math.isnan(v):
return "Not assessed"
if v > critical:
return f"Critical screening exceedance (> {critical:g} m/s)"
if v > advisory:
return f"Advisory screening exceedance (> {advisory:g} m/s)"
return f"Below advisory threshold ({advisory:g} m/s)"
def effective_velocity_table(link_df: pd.DataFrame,
recon_links: pd.DataFrame | None,
advisory: float = 3.0,
critical: float = 4.0) -> pd.DataFrame:
"""Per-conduit screening table applying the reconciliation precedence.
Columns: Link ID, Worker Peak Velocity, RPT Peak Velocity,
Screening Velocity, Evidence Source, Delta (abs), Delta (%),
Screening Classification, Classification Changed by Reconciliation.
"""
if link_df is None or link_df.empty:
return pd.DataFrame()
vel_col = next((c for c in link_df.columns if c.startswith("Peak Velocity")), None)
if vel_col is None:
return pd.DataFrame()
recon: dict[str, dict[str, Any]] = {}
if recon_links is not None and not recon_links.empty:
for _, r in recon_links.iterrows():
recon[str(r.get("Link ID"))] = r.to_dict()
rows: list[dict[str, Any]] = []
for _, r in link_df.iterrows():
link_id = str(r.get("Link ID"))
worker_v = pd.to_numeric(pd.Series([r.get(vel_col)]), errors="coerce").iloc[0]
rec = recon.get(link_id, {})
rpt_v = rec.get("RPT Peak Velocity")
rpt_v = float(rpt_v) if rpt_v is not None and not (isinstance(rpt_v, float) and math.isnan(rpt_v)) else None
flagged = str(rec.get("Overall Status", "OK")) not in ("OK", "Unavailable", "nan", "None")
if flagged and rpt_v is not None:
eff, source = rpt_v, "engine .rpt (reconciliation-flagged)"
else:
eff, source = (float(worker_v) if pd.notna(worker_v) else None), "worker time series"
worker_class = classify_velocity(float(worker_v) if pd.notna(worker_v) else None, advisory, critical)
eff_class = classify_velocity(eff, advisory, critical)
delta_abs = (float(worker_v) - rpt_v) if (pd.notna(worker_v) and rpt_v is not None) else None
delta_pct = (100.0 * delta_abs / abs(rpt_v)) if (delta_abs is not None and rpt_v not in (None, 0)) else None
rows.append({
"Link ID": link_id,
"Worker Peak Velocity (m/s)": round(float(worker_v), 3) if pd.notna(worker_v) else None,
"RPT Peak Velocity (m/s)": round(rpt_v, 3) if rpt_v is not None else None,
"Screening Velocity (m/s)": round(eff, 3) if eff is not None else None,
"Evidence Source": source,
"Delta (m/s)": round(delta_abs, 3) if delta_abs is not None else None,
"Delta (%)": round(delta_pct, 1) if delta_pct is not None else None,
"Screening Classification": eff_class,
"Classification Changed by Reconciliation": (
"Yes" if (flagged and rpt_v is not None and worker_class != eff_class)
else ("No" if flagged else "n/a - not flagged")),
})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# Continuity disclosure
# ---------------------------------------------------------------------------
def continuity_disclosure(metadata: Mapping[str, Any], review_pct: float = 0.5,
warning_pct: float = 1.0,
has_pollutants: bool = False) -> list[str]:
"""Sign-preserving continuity lines with symmetric absolute thresholds."""
lines: list[str] = []
for label, key in (("Surface-runoff continuity error", "runoff_error"),
("Flow-routing continuity error", "flow_error")):
val = metadata.get(key)
try:
v = float(val)
except (TypeError, ValueError):
lines.append(f"{label}: not reported by the engine.")
continue
lines.append(f"{label}: {v:+.3f}% (engine-reported sign preserved).")
if abs(v) > warning_pct:
lines.append(f"⚠️ {label} magnitude |{v:.3f}%| exceeds the {warning_pct:g}% absolute warning threshold and must be reviewed before the results are relied upon.")
elif abs(v) > review_pct:
lines.append(f"{label} magnitude |{v:.3f}%| exceeds the {review_pct:g}% absolute review threshold.")
if has_pollutants:
qv = metadata.get("quality_error")
try:
lines.append(f"Water-quality continuity error: {float(qv):+.3f}%.")
except (TypeError, ValueError):
lines.append("Water-quality continuity error: pollutants modelled but continuity not reported — review engine output.")
else:
lines.append("Water-quality continuity: Not applicable — no pollutants modelled.")
return lines
# ---------------------------------------------------------------------------
# Missing-information register
# ---------------------------------------------------------------------------
_METADATA_LABELS = {
"legal_description": "Legal land description",
"outline_plan_no": "Outline plan number",
"subdivision_no": "Subdivision number",
"development_permit_no": "Development permit number",
"consultant_file_no": "Consultant file number",
"prepared_by": "Prepared by (responsible person)",
"checked_by": "Checked by (reviewer)",
"client": "Client",
"consultant": "Consultant",
"construction_drawing_no": "Construction drawing number",
"development_agreement_no": "Development agreement number",
}
def missing_information_register(metadata: Mapping[str, Any],
criteria_register: pd.DataFrame | None,
checklist: pd.DataFrame | None) -> pd.DataFrame:
"""Deterministic register of evidence the report cannot supply.
Items listed here block any related Pass classification elsewhere.
"""
rows: list[dict[str, str]] = []
for key, label in _METADATA_LABELS.items():
value = str(metadata.get(key, "") or "").strip()
if not value or value.lower() in ("not provided", "none", "-", "—"):
rows.append({"Item": label, "Category": "Project information",
"Status": "Not provided",
"Consequence": "Related administrative checklist items remain incomplete."})
if criteria_register is not None and not criteria_register.empty:
status_col = next((c for c in criteria_register.columns if "status" in c.lower()), None)
name_col = next((c for c in criteria_register.columns
if c.lower() in ("criterion", "requirement", "item", "name")),
criteria_register.columns[0])
if status_col:
for _, r in criteria_register.iterrows():
if "not established" in str(r.get(status_col, "")).lower():
rows.append({"Item": str(r.get(name_col)), "Category": "Governing criteria",
"Status": "Not established",
"Consequence": "Related screening cannot be reported as Pass; results remain screening-only."})
if checklist is not None and not checklist.empty and "Status" in checklist.columns:
for _, r in checklist.iterrows():
if str(r.get("Status", "")).strip().lower() == "missing":
rows.append({"Item": f"{r.get('Item')}: {str(r.get('Requirement'))[:80]}",
"Category": "SWMR checklist",
"Status": "Missing",
"Consequence": "Required for a submission-ready report."})
if not rows:
rows.append({"Item": "None identified", "Category": "—", "Status": "—",
"Consequence": "All tracked evidence items were supplied."})
return pd.DataFrame(rows)
|