swmm-water-quality
Complete SWMM engine coverage: pollutant buildup/washoff simulation support and load reporting. Validate water-quality config JSON, build INPs with WQ sections, and extract pollutant load summaries from completed runs.
Install
npx skills add https://github.com/Zhonghao1995/agentic-swmm-workflow/tree/main/skills/swmm-water-quality
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zhonghao1995-agentic-swmm-workflow@llmmart
git clone https://github.com/Zhonghao1995/agentic-swmm-workflow.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zhonghao1995/agentic-swmm-workflow collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SWMM Water Quality Skill
Purpose
Complete SWMM engine coverage for pollutant buildup/washoff simulation and load reporting. This skill provides:
validate_wq_config.py— validate a WQ config JSON before passing it to the builder.extract_wq_loads.py— extract WQ load summaries from a SWMM RPT.
The water-quality sections ([POLLUTANTS], [LANDUSES], [COVERAGES],
[BUILDUP], [WASHOFF], [LOADINGS]) are emitted by
skills/swmm-builder/scripts/build_swmm_inp.py via the
--water-quality-json flag (see also build_inp tool's
water_quality_json argument).
Agent tool: read_wq_loads
Read pollutant load summaries from a completed run's .rpt file. Returns
wq_present=false for non-WQ runs.
read_wq_loads(rpt_path="runs/my_run/model.rpt")
Returns a structured JSON with:
wq_present(bool)pollutants— sorted list of pollutant namesrunoff_quality_continuity— mass-balance rows (metric + per-pollutant kg)quality_routing_continuity— routing mass-balance rowssubcatchment_washoff— per-subcatchment loads (kg per pollutant)link_loads— per-link transport loads (kg per pollutant)outfall_loads— per-outfall flow stats + pollutant loads
WQ config JSON schema
Top-level keys (all required when the key is present; empty arrays are valid):
{
"pollutants": [...],
"landuses": [...],
"coverages": [...],
"buildup": [...],
"washoff": [...],
"loadings": []
}
pollutants entries
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | required | No spaces |
units |
string | required | MG/L, UG/L, or #/L |
c_rain |
float | 0 |
Concentration in precipitation |
c_gw |
float | 0 |
Concentration in groundwater |
c_ii |
float | 0 |
Concentration in RDII |
k_decay_per_day |
float | 0 |
First-order decay (1/days) |
snow_only |
bool | false |
Buildup during snow only |
co_pollutant |
string | "*" |
Co-pollutant name or "*" |
co_fraction |
float | 0 |
Co-pollutant fraction (0–1) |
init_conc |
float | 0 |
Initial dry-weather concentration |
landuses entries
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | required | |
sweep_interval |
float | 0 |
Days between sweeping (0 = no sweeping) |
availability |
float | 0 |
Fraction of buildup removed by sweeping (0–1) |
last_sweep |
float | 0 |
Days since last sweep at start |
coverages entries
| Field | Type | Notes |
|---|---|---|
subcatchment |
string | Must reference an existing subcatchment |
landuse |
string | Must reference a defined land use |
percent |
float (0–100) | Percent coverage; per-subcatchment sum must be ≤ 100 |
buildup entries
| Field | Type | Notes |
|---|---|---|
landuse |
string | Must reference a defined land use |
pollutant |
string | Must reference a defined pollutant |
func_type |
string | POW, EXP, or SAT (EXT not supported in v1) |
c1 |
float | Max buildup (kg/ha or count/ha when normalizer=AREA) |
c2 |
float | Rate constant |
c3 |
float | Third coefficient (unused for EXP/SAT) |
normalizer |
string | AREA or CURBLENGTH |
washoff entries
| Field | Type | Notes |
|---|---|---|
landuse |
string | Must reference a defined land use |
pollutant |
string | Must reference a defined pollutant |
func_type |
string | EXP, RC, or EMC |
c1 |
float | Coefficient 1 |
c2 |
float | Coefficient 2 (0 for EMC) |
sweep_removal |
float (0–1) | Fraction removed by sweeping |
bmp_removal |
float (0–1) | Fraction removed |
loadings entries (optional)
| Field | Type | Notes |
|---|---|---|
subcatchment |
string | Must reference an existing subcatchment |
pollutant |
string | Must reference a defined pollutant |
init_buildup |
float | Initial buildup mass |
Scripts
scripts/validate_wq_config.py— standalone CLI validatorscripts/extract_wq_loads.py— RPT load extractor
Executed examples
Validate a WQ config JSON
# Write a minimal WQ config JSON:
cat > /tmp/wq_example.json << 'EOJSON'
{
"pollutants": [{"name": "TSS", "units": "MG/L", "c_rain": 0, "c_gw": 0,
"c_ii": 0, "k_decay_per_day": 0, "snow_only": false,
"co_pollutant": "*", "co_fraction": 0, "init_conc": 0}],
"landuses": [{"name": "Residential", "sweep_interval": 0, "availability": 0, "last_sweep": 0}],
"coverages": [{"subcatchment": "S1", "landuse": "Residential", "percent": 100}],
"buildup": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EXP",
"c1": 15, "c2": 0.5, "c3": 0, "normalizer": "AREA"}],
"washoff": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EMC",
"c1": 50, "c2": 0, "sweep_removal": 0, "bmp_removal": 0}],
"loadings": []
}
EOJSON
python3 skills/swmm-water-quality/scripts/validate_wq_config.py \
--wq-json /tmp/wq_example.json
# Output: {"ok": true, "pollutant_count": 1, "landuse_count": 1, ...}
Extract WQ load summaries from a completed run RPT
python3 skills/swmm-water-quality/scripts/extract_wq_loads.py \
--rpt tests/fixtures/wq/wq_smoke.rpt
# Output: {"ok": true, "wq_present": true, "pollutants": ["TSS"],
# "runoff_quality_continuity": [...], ...}
Build an INP with water quality sections
python3 skills/swmm-builder/scripts/build_swmm_inp.py \
--subcatchments-csv <subcatchments.csv> \
--params-json <params.json> \
--network-json <network.json> \
--water-quality-json /tmp/wq_example.json \
--out-inp /tmp/wq_model.inp \
--out-manifest /tmp/wq_model_manifest.json
Validation constraints
Enforced by both validate_wq_config.py and build_swmm_inp.py:
- Referential: every
[BUILDUP]/[WASHOFF]landuse/pollutant must exist - Referential: every
[COVERAGES]/[LOADINGS]subcatchment must exist - Enum:
units∈ {MG/L,UG/L,#/L} - Enum: buildup
func_type∈ {POW,EXP,SAT} (EXT rejected with message) - Enum: washoff
func_type∈ {EXP,RC,EMC} - Range: coverage
percent∈ [0, 100]; per-subcatchment sum ≤ 100 - Range:
sweep_removal,bmp_removal,co_fraction∈ [0, 1]
Files (agentic-swmm-workflow)
-
docs
-
rpt_wq_sections.md 5 KB
# RPT Water Quality Section Reference > Ground-truth strings extracted from SWMM 5.2.4 engine output. > Generated by PR1 engine smoke test on 2026-06-09. > > These verbatim title and header lines are the canonical source for the > PR2 `SectionSchema` entries in `rpt_summary.py`. Do not edit without > re-running the smoke against a real SWMM 5.2.4 binary. ## Engine version ``` EPA SWMM 5.2 (Build 5.2.4) ``` ## Smoke configuration - 1 pollutant: `TSS` (MG/L) - 1 land use: `Residential` (no sweeping) - 4 subcatchments: `S1`, `S2`, `S3`, `S4` (100% Residential each) - Buildup: `EXP`, C1=15, C2=0.5, normalizer=AREA - Washoff: `EMC`, C1=50 - Flow routing: DYNWAVE, 1-hour simulation ## Confirmed section banner titles The following lines appear verbatim between `**...**` separators in the RPT: | # | Banner title line | Confidence | |---|---|---| | 1 | `Runoff Quality Continuity` | CONFIRMED | | 2 | `Quality Routing Continuity` | CONFIRMED | | 3 | `Subcatchment Washoff Summary` | CONFIRMED | | 4 | `Link Pollutant Load Summary` | CONFIRMED | Note: the PRD anticipated possible title `Conduit Pollutant Load Summary` — the engine emits `Link Pollutant Load Summary`. Use this string in PR2 `SectionSchema`. ## Verbatim section headers (title + column header rows) ### 1. Runoff Quality Continuity ``` ************************** TSS Runoff Quality Continuity kg ************************** ---------- ``` Column: one column per defined pollutant (`TSS` here), units `kg`. Rows: `Initial Buildup`, `Surface Buildup`, `Wet Deposition`, `Sweeping Removal`, `Infiltration Loss`, `BMP Removal`, `Surface Runoff`, `Remaining Buildup`, `Continuity Error (%)`. Variable-column structure: column names are pollutant names. ### 2. Quality Routing Continuity ``` ************************** TSS Quality Routing Continuity kg ************************** ---------- ``` Column: one column per defined pollutant, units `kg`. Rows: `Dry Weather Inflow`, `Wet Weather Inflow`, `Groundwater Inflow`, `RDII Inflow`, `External Inflow`, `External Outflow`, `Flooding Loss`, `Exfiltration Loss`, `Mass Reacted`, `Initial Stored Mass`, `Final Stored Mass`, `Continuity Error (%)`. Variable-column structure: column names are pollutant names. ### 3. Subcatchment Washoff Summary ``` **************************** Subcatchment Washoff Summary **************************** ---------------------------------- TSS Subcatchment kg ---------------------------------- ``` One row per subcatchment. One pollutant column per defined pollutant. Variable-column: column names are pollutant names, units `kg`. ### 4. Link Pollutant Load Summary ``` *************************** Link Pollutant Load Summary *************************** ---------------------------------- TSS Link kg ---------------------------------- ``` One row per link. One pollutant column per defined pollutant. Variable-column: column names are pollutant names, units `kg`. ### 5. Outfall Loading Summary (WQ extension) When WQ is enabled, the existing section gains pollutant-load columns after `total_volume`: ``` ------------------------------------------------------------------------- Flow Avg Max Total Total Freq Flow Flow Volume TSS Outfall Node Pcnt CMS CMS 10^6 ltr kg ------------------------------------------------------------------------- ``` Extension: extra tokens after column 5 are pollutant loads (kg each). The PR2 parser must handle ≥5 columns rather than exactly 5. ## VERIFY item resolutions (from PR1 engine smoke) | Item | Status | Resolution | |---|---|---| | `InitConc` column present in 5.2.4? | RESOLVED — YES | Engine accepted `InitConc` as the 10th column in `[POLLUTANTS]` rows | | `CoFraction` — `*` vs `0` when CoPollutant is `*`? | RESOLVED — use `0` | Engine accepted `0.0` (not `*`); use numeric zero | | `LastSweep` required when `SweepInterval=0`? | RESOLVED — YES | Engine accepted `LastSweep=0` row; omitting it untested | | `SweepRemoval` on `[WASHOFF]` or `[LANDUSES]`? | RESOLVED — `[WASHOFF]` | SWMM 5.2.4 puts SweepRemoval on `[WASHOFF]` rows as the 6th column | | EXT buildup form supported in 5.2.4? | DEFERRED — not tested | EXT excluded from v1 (PRD recommendation); validate-reject with clear message | | WQ continuity error interpretation (>5% = setup error?) | CONFIRMED | Quality Routing Continuity error was -38.8% in smoke due to EMC with no routing decay — expected for this minimal fixture | | Buildup units (metric vs imperial)? | RESOLVED — metric | With FLOW_UNITS=CMS, buildup C1 units are kg/ha for MG/L pollutants | | `Subcatchment Washoff Summary` — per-subcatchment rows or per-subcatchment per-pollutant? | RESOLVED | One row per subcatchment; one column per pollutant |
-
-
scripts
-
extract_wq_loads.py 12 KB
#!/usr/bin/env python3 """Extract water-quality load summaries from a SWMM .rpt file. Stdlib-only — no agentic_swmm imports. This script is called by the audit pipeline (via importlib) and can also be run standalone: python3 extract_wq_loads.py --rpt path/to/model.rpt python3 extract_wq_loads.py --rpt path/to/model.rpt --out-json loads.json Output (stdout JSON): { "ok": true, "wq_present": true, "pollutants": ["TSS"], "runoff_quality_continuity": [ {"metric": "Initial Buildup", "values": {"TSS": 0.0}}, ... {"metric": "Continuity Error (%)", "values": {"TSS": 0.0}} ], "quality_routing_continuity": [ {"metric": "Dry Weather Inflow", "values": {"TSS": 0.0}}, ... {"metric": "Continuity Error (%)", "values": {"TSS": -38.789}} ], "subcatchment_washoff": [ {"name": "S1", "loads": {"TSS": 0.109}}, ... ], "link_loads": [ {"name": "C1", "loads": {"TSS": 0.295}}, ... ], "outfall_loads": [ {"node": "OF1", "flow_freq_pct": 84.43, "avg_flow": 0.026, "max_flow": 0.058, "total_volume_10_6_ltr": 0.081, "pollutant_loads": {"TSS": 0.524}} ] } When WQ is not enabled in the rpt: {"ok": true, "wq_present": false} """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # WQ detection # --------------------------------------------------------------------------- def _wq_enabled(rpt_text: str) -> bool: """Return True iff the rpt was produced by a WQ-enabled run.""" return "Water Quality .......... YES" in rpt_text # --------------------------------------------------------------------------- # Title location helper # --------------------------------------------------------------------------- def _locate_title(lines: list[str], title: str) -> int: """Return line index whose stripped text starts with ``title``, or -1.""" for idx, line in enumerate(lines): if line.strip().startswith(title): return idx return -1 # --------------------------------------------------------------------------- # WQ continuity parser # --------------------------------------------------------------------------- def _parse_wq_continuity(lines: list[str], title_line_idx: int) -> list[dict[str, Any]]: """Parse Runoff/Routing Quality Continuity rows. Banner:: ************************** TSS Runoff Quality Continuity kg ************************** ---------- Initial Buildup .......... 0.000 ... """ # Pollutant names from the opening asterisk line before the title. pol_names: list[str] = [] for back in range(title_line_idx - 1, max(0, title_line_idx - 5), -1): stripped = lines[back].strip() if stripped.startswith("*"): tokens = stripped.split() pol_names = [t for t in tokens if not t.startswith("*") and t != "**"] break # Advance past the title and the combined asterisk+dash closing banner. cursor = title_line_idx + 1 while cursor < len(lines): stripped = lines[cursor].strip() if not stripped: cursor += 1 continue if stripped.startswith("*"): cursor += 1 continue break # first data row rows: list[dict[str, Any]] = [] while cursor < len(lines): stripped = lines[cursor].strip() if not stripped or stripped.startswith("***") or stripped.startswith("---"): break parts = re.split(r"\s{2,}", stripped) if len(parts) < 2: cursor += 1 continue metric = parts[0].rstrip(". ").strip() values: dict[str, Any] = {} for i, tok in enumerate(parts[1:]): tok = tok.strip() if not tok: continue try: val = float(tok) except ValueError: continue col_name = pol_names[i] if i < len(pol_names) else f"col{i}" values[col_name] = val if metric and values: rows.append({"metric": metric, "values": values}) cursor += 1 return rows # --------------------------------------------------------------------------- # WQ entity load parser (washoff summary / link load summary) # --------------------------------------------------------------------------- def _parse_wq_entity_loads(lines: list[str], title_line_idx: int) -> list[dict[str, Any]]: """Parse Subcatchment Washoff Summary or Link Pollutant Load Summary.""" cursor = title_line_idx + 1 while cursor < len(lines) and not lines[cursor].lstrip().startswith("---"): cursor += 1 cursor += 1 # past top dash hdr_lines: list[str] = [] while cursor < len(lines) and not lines[cursor].lstrip().startswith("---"): if lines[cursor].strip(): hdr_lines.append(lines[cursor]) cursor += 1 cursor += 1 # past bottom dash pol_names: list[str] = [] if len(hdr_lines) >= 2: pol_names = hdr_lines[-2].split() elif len(hdr_lines) == 1: pol_names = [] rows: list[dict[str, Any]] = [] while cursor < len(lines): stripped = lines[cursor].strip() if not stripped or stripped.startswith("---") or stripped.startswith("***"): break tokens = stripped.split() if len(tokens) < 2 or tokens[0] == "System": cursor += 1 continue name = tokens[0] loads: dict[str, float] = {} for i, tok in enumerate(tokens[1:]): try: val = float(tok) except ValueError: continue col_name = pol_names[i] if i < len(pol_names) else f"col{i}" loads[col_name] = val if loads: rows.append({"name": name, "loads": loads}) cursor += 1 return rows # --------------------------------------------------------------------------- # Outfall Loading Summary parser (handles >= 5 tokens, optional WQ columns) # --------------------------------------------------------------------------- def _parse_outfall_loading(lines: list[str], title_line_idx: int) -> list[dict[str, Any]]: """Parse Outfall Loading Summary, including optional WQ pollutant columns.""" cursor = title_line_idx + 1 while cursor < len(lines) and not lines[cursor].lstrip().startswith("---"): cursor += 1 top_dash = cursor cursor += 1 # past top dash hdr_lines: list[str] = [] while cursor < len(lines) and not lines[cursor].lstrip().startswith("---"): if lines[cursor].strip(): hdr_lines.append(lines[cursor]) cursor += 1 cursor += 1 # past bottom dash pol_names: list[str] = [] if len(hdr_lines) >= 2: name_tokens = hdr_lines[-2].split() pol_names = name_tokens[4:] # tokens at index >= 4 are pollutant names rows: list[dict[str, Any]] = [] while cursor < len(lines): stripped = lines[cursor].strip() if not stripped or stripped.startswith("---") or stripped.startswith("***"): break tokens = stripped.split() if len(tokens) < 5 or tokens[0] == "System": cursor += 1 continue try: row: dict[str, Any] = { "node": tokens[0], "flow_freq_pct": float(tokens[1]), "avg_flow": float(tokens[2]), "max_flow": float(tokens[3]), "total_volume_10_6_ltr": float(tokens[4]), "pollutant_loads": {}, } if len(tokens) > 5: pol_loads: dict[str, float] = {} for i, pol_name in enumerate(pol_names): tok_idx = 5 + i if tok_idx < len(tokens): try: pol_loads[pol_name] = float(tokens[tok_idx]) except ValueError: pass row["pollutant_loads"] = pol_loads rows.append(row) except (ValueError, IndexError): pass cursor += 1 return rows # --------------------------------------------------------------------------- # Pollutant name extraction from WQ continuity results # --------------------------------------------------------------------------- def _extract_pollutants( runoff_cont: list[dict[str, Any]], routing_cont: list[dict[str, Any]], washoff: list[dict[str, Any]], link_loads: list[dict[str, Any]], outfall_loads: list[dict[str, Any]], ) -> list[str]: """Return sorted list of pollutant names found in any WQ section.""" names: set[str] = set() for row in runoff_cont + routing_cont: names.update(row.get("values", {}).keys()) for row in washoff + link_loads: names.update(row.get("loads", {}).keys()) for row in outfall_loads: names.update((row.get("pollutant_loads") or {}).keys()) return sorted(names) # --------------------------------------------------------------------------- # Main extraction function # --------------------------------------------------------------------------- def extract_wq_loads(rpt_text: str) -> dict[str, Any]: """Parse all WQ sections from ``rpt_text`` and return a structured dict. Returns ``{"ok": True, "wq_present": False}`` when WQ is not enabled. """ if not _wq_enabled(rpt_text): return {"ok": True, "wq_present": False} lines = rpt_text.splitlines() runoff_idx = _locate_title(lines, "Runoff Quality Continuity") routing_idx = _locate_title(lines, "Quality Routing Continuity") washoff_idx = _locate_title(lines, "Subcatchment Washoff Summary") link_idx = _locate_title(lines, "Link Pollutant Load Summary") outfall_idx = _locate_title(lines, "Outfall Loading Summary") runoff_cont = _parse_wq_continuity(lines, runoff_idx) if runoff_idx >= 0 else [] routing_cont = _parse_wq_continuity(lines, routing_idx) if routing_idx >= 0 else [] washoff = _parse_wq_entity_loads(lines, washoff_idx) if washoff_idx >= 0 else [] link_loads = _parse_wq_entity_loads(lines, link_idx) if link_idx >= 0 else [] outfall_loads = _parse_outfall_loading(lines, outfall_idx) if outfall_idx >= 0 else [] pollutants = _extract_pollutants(runoff_cont, routing_cont, washoff, link_loads, outfall_loads) return { "ok": True, "wq_present": True, "pollutants": pollutants, "runoff_quality_continuity": runoff_cont, "quality_routing_continuity": routing_cont, "subcatchment_washoff": washoff, "link_loads": link_loads, "outfall_loads": outfall_loads, } # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( description="Extract water-quality load summaries from a SWMM .rpt file." ) ap.add_argument("--rpt", required=True, type=Path, help="Path to SWMM .rpt file.") ap.add_argument( "--out-json", type=Path, default=None, help="Optional path to write JSON output (also printed to stdout).", ) args = ap.parse_args(argv) rpt_path = args.rpt if not rpt_path.exists(): print( json.dumps({"ok": False, "error": f"rpt not found: {rpt_path}"}), file=sys.stderr, ) return 1 rpt_text = rpt_path.read_text(encoding="utf-8", errors="replace") result = extract_wq_loads(rpt_text) output = json.dumps(result, indent=2) print(output) if args.out_json: args.out_json.parent.mkdir(parents=True, exist_ok=True) args.out_json.write_text(output, encoding="utf-8") return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_wq_config.py 12.5 KB
#!/usr/bin/env python3 """Validate a water-quality config JSON against referential and enum constraints. Stdlib-only; no agentic_swmm imports. Usage: python3 validate_wq_config.py --wq-json path/to/wq.json [--subcatchments-csv path/to/sub.csv] Exit 0 on success. Exit 1 on validation failure — writes a JSON error report to stdout. """ from __future__ import annotations import argparse import csv import json import sys from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # Enum constants (mirrored from build_swmm_inp.py — no import dependency) # --------------------------------------------------------------------------- _WQ_UNITS = {"MG/L", "UG/L", "#/L"} _BUILDUP_FUNCS = {"POW", "EXP", "SAT"} # EXT excluded in v1 _WASHOFF_FUNCS = {"EXP", "RC", "EMC"} _NORMALIZERS = {"AREA", "CURBLENGTH"} # --------------------------------------------------------------------------- # Minimal helpers (duplicated from build_swmm_inp.py — cheapest-correct) # --------------------------------------------------------------------------- def _require_non_blank(value: Any, *, field: str, context: str) -> str: if value is None: raise ValueError(f"{context} missing required field '{field}'") text = str(value).strip() if not text: raise ValueError(f"{context} field '{field}' must be a non-blank string") return text def _require_number( value: Any, *, field: str, context: str, min_value: float | None = None, max_value: float | None = None, ) -> float: if value is None: raise ValueError(f"{context} missing required numeric field '{field}'") if isinstance(value, bool): raise ValueError(f"{context} field '{field}' must be numeric, got boolean") if isinstance(value, (int, float)): parsed = float(value) elif isinstance(value, str): raw = value.strip() if not raw: raise ValueError(f"{context} field '{field}' must be numeric, got blank string") try: parsed = float(raw) except ValueError as exc: raise ValueError(f"{context} field '{field}' must be numeric, got: {value}") from exc else: raise ValueError(f"{context} field '{field}' must be numeric, got type: {type(value).__name__}") if min_value is not None and parsed < min_value: raise ValueError(f"{context} field '{field}' must be >= {min_value}, got {parsed}") if max_value is not None and parsed > max_value: raise ValueError(f"{context} field '{field}' must be <= {max_value}, got {parsed}") return parsed # --------------------------------------------------------------------------- # Core validation logic (canonical implementation) # --------------------------------------------------------------------------- def validate_wq_config(wq: dict[str, Any], *, known_subcatchment_ids: set[str] | None = None) -> None: """Validate cross-references and enum/range constraints in the WQ JSON. Parameters ---------- wq: Parsed water-quality config object (the JSON root dict). known_subcatchment_ids: Optional set of subcatchment IDs for referential checks on [COVERAGES] and [LOADINGS]. Pass ``None`` to skip those checks. Raises ------ ValueError On the first constraint violation found. Message includes section and entry context for easy debugging. """ subs = known_subcatchment_ids or set() # ---- [POLLUTANTS] ---- pollutant_names: set[str] = set() for i, p in enumerate(wq.get("pollutants", []), start=1): ctx = f"[POLLUTANTS] entry {i}" if not isinstance(p, dict): raise ValueError(f"{ctx} must be a JSON object") name = _require_non_blank(p.get("name"), field="name", context=ctx) if " " in name: raise ValueError(f"{ctx} 'name' must not contain spaces, got '{name}'") if name in pollutant_names: raise ValueError(f"[POLLUTANTS] duplicate pollutant name '{name}'") pollutant_names.add(name) units_raw = _require_non_blank(p.get("units"), field="units", context=ctx) if units_raw.upper() not in _WQ_UNITS: raise ValueError(f"{ctx} 'units' must be one of {sorted(_WQ_UNITS)}, got '{units_raw}'") _require_number(p.get("c_rain", 0.0), field="c_rain", context=ctx, min_value=0.0) _require_number(p.get("c_gw", 0.0), field="c_gw", context=ctx, min_value=0.0) _require_number(p.get("c_ii", 0.0), field="c_ii", context=ctx, min_value=0.0) _require_number(p.get("k_decay_per_day", 0.0), field="k_decay_per_day", context=ctx, min_value=0.0) _require_number(p.get("co_fraction", 0.0), field="co_fraction", context=ctx, min_value=0.0, max_value=1.0) _require_number(p.get("init_conc", 0.0), field="init_conc", context=ctx, min_value=0.0) # ---- [LANDUSES] ---- landuse_names: set[str] = set() for i, lu in enumerate(wq.get("landuses", []), start=1): ctx = f"[LANDUSES] entry {i}" if not isinstance(lu, dict): raise ValueError(f"{ctx} must be a JSON object") name = _require_non_blank(lu.get("name"), field="name", context=ctx) if name in landuse_names: raise ValueError(f"[LANDUSES] duplicate land-use name '{name}'") landuse_names.add(name) _require_number(lu.get("sweep_interval", 0.0), field="sweep_interval", context=ctx, min_value=0.0) _require_number(lu.get("availability", 0.0), field="availability", context=ctx, min_value=0.0, max_value=1.0) _require_number(lu.get("last_sweep", 0.0), field="last_sweep", context=ctx, min_value=0.0) # ---- [COVERAGES] ---- coverage_sums: dict[str, float] = {} for i, cov in enumerate(wq.get("coverages", []), start=1): ctx = f"[COVERAGES] entry {i}" if not isinstance(cov, dict): raise ValueError(f"{ctx} must be a JSON object") sub = _require_non_blank(cov.get("subcatchment"), field="subcatchment", context=ctx) lu = _require_non_blank(cov.get("landuse"), field="landuse", context=ctx) pct = _require_number(cov.get("percent"), field="percent", context=ctx, min_value=0.0, max_value=100.0) if subs and sub not in subs: raise ValueError(f"{ctx} 'subcatchment' '{sub}' not found in subcatchments") if lu not in landuse_names: raise ValueError(f"{ctx} 'landuse' '{lu}' not defined in [LANDUSES]") coverage_sums[sub] = coverage_sums.get(sub, 0.0) + pct for sub, total in coverage_sums.items(): if total > 100.0 + 1e-9: raise ValueError( f"[COVERAGES] subcatchment '{sub}' coverage percents sum to {total:.2f}, must be <= 100" ) # ---- [BUILDUP] ---- for i, bu in enumerate(wq.get("buildup", []), start=1): ctx = f"[BUILDUP] entry {i}" if not isinstance(bu, dict): raise ValueError(f"{ctx} must be a JSON object") lu = _require_non_blank(bu.get("landuse"), field="landuse", context=ctx) pol = _require_non_blank(bu.get("pollutant"), field="pollutant", context=ctx) ft = _require_non_blank(bu.get("func_type"), field="func_type", context=ctx).upper() norm = _require_non_blank(bu.get("normalizer", "AREA"), field="normalizer", context=ctx).upper() if lu not in landuse_names: raise ValueError(f"{ctx} 'landuse' '{lu}' not defined in [LANDUSES]") if pol not in pollutant_names: raise ValueError(f"{ctx} 'pollutant' '{pol}' not defined in [POLLUTANTS]") if ft == "EXT": raise ValueError( f"{ctx} FuncType 'EXT' (external time series) is not supported in v1. " "Use POW, EXP, or SAT." ) if ft not in _BUILDUP_FUNCS: raise ValueError(f"{ctx} 'func_type' must be one of {sorted(_BUILDUP_FUNCS)}, got '{ft}'") if norm not in _NORMALIZERS: raise ValueError(f"{ctx} 'normalizer' must be AREA or CURBLENGTH, got '{norm}'") _require_number(bu.get("c1", 0.0), field="c1", context=ctx) _require_number(bu.get("c2", 0.0), field="c2", context=ctx) _require_number(bu.get("c3", 0.0), field="c3", context=ctx) # ---- [WASHOFF] ---- for i, wo in enumerate(wq.get("washoff", []), start=1): ctx = f"[WASHOFF] entry {i}" if not isinstance(wo, dict): raise ValueError(f"{ctx} must be a JSON object") lu = _require_non_blank(wo.get("landuse"), field="landuse", context=ctx) pol = _require_non_blank(wo.get("pollutant"), field="pollutant", context=ctx) ft = _require_non_blank(wo.get("func_type"), field="func_type", context=ctx).upper() if lu not in landuse_names: raise ValueError(f"{ctx} 'landuse' '{lu}' not defined in [LANDUSES]") if pol not in pollutant_names: raise ValueError(f"{ctx} 'pollutant' '{pol}' not defined in [POLLUTANTS]") if ft not in _WASHOFF_FUNCS: raise ValueError(f"{ctx} 'func_type' must be one of {sorted(_WASHOFF_FUNCS)}, got '{ft}'") _require_number(wo.get("c1", 0.0), field="c1", context=ctx) _require_number(wo.get("c2", 0.0), field="c2", context=ctx) _require_number(wo.get("sweep_removal", 0.0), field="sweep_removal", context=ctx, min_value=0.0, max_value=1.0) _require_number(wo.get("bmp_removal", 0.0), field="bmp_removal", context=ctx, min_value=0.0, max_value=1.0) # ---- [LOADINGS] ---- for i, lo in enumerate(wq.get("loadings", []), start=1): ctx = f"[LOADINGS] entry {i}" if not isinstance(lo, dict): raise ValueError(f"{ctx} must be a JSON object") sub = _require_non_blank(lo.get("subcatchment"), field="subcatchment", context=ctx) pol = _require_non_blank(lo.get("pollutant"), field="pollutant", context=ctx) if subs and sub not in subs: raise ValueError(f"{ctx} 'subcatchment' '{sub}' not found in subcatchments") if pol not in pollutant_names: raise ValueError(f"{ctx} 'pollutant' '{pol}' not defined in [POLLUTANTS]") _require_number(lo.get("init_buildup"), field="init_buildup", context=ctx, min_value=0.0) # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- def _read_subcatchment_ids(path: Path) -> set[str]: with path.open("r", encoding="utf-8", newline="") as f: rows = list(csv.DictReader(f)) ids: set[str] = set() for row in rows: sid = str(row.get("subcatchment_id", "")).strip() if sid: ids.add(sid) return ids def main() -> None: ap = argparse.ArgumentParser( description="Validate a water-quality config JSON (referential consistency + enum/range checks)." ) ap.add_argument("--wq-json", type=Path, required=True, help="Path to WQ config JSON") ap.add_argument( "--subcatchments-csv", type=Path, default=None, help="Optional subcatchments CSV for referential checks on [COVERAGES] and [LOADINGS]", ) args = ap.parse_args() try: raw = args.wq_json.read_text(encoding="utf-8") wq: Any = json.loads(raw) except FileNotFoundError: report = {"ok": False, "error": f"File not found: {args.wq_json}"} print(json.dumps(report, indent=2)) sys.exit(1) except json.JSONDecodeError as exc: report = {"ok": False, "error": f"JSON parse error: {exc}"} print(json.dumps(report, indent=2)) sys.exit(1) if not isinstance(wq, dict): report = {"ok": False, "error": "WQ JSON must be a top-level object"} print(json.dumps(report, indent=2)) sys.exit(1) sub_ids: set[str] | None = None if args.subcatchments_csv is not None: sub_ids = _read_subcatchment_ids(args.subcatchments_csv) try: validate_wq_config(wq, known_subcatchment_ids=sub_ids) except ValueError as exc: report = {"ok": False, "error": str(exc)} print(json.dumps(report, indent=2)) sys.exit(1) pollutant_count = len(wq.get("pollutants", [])) landuse_count = len(wq.get("landuses", [])) report = { "ok": True, "pollutant_count": pollutant_count, "landuse_count": landuse_count, "coverage_rows": len(wq.get("coverages", [])), "buildup_rows": len(wq.get("buildup", [])), "washoff_rows": len(wq.get("washoff", [])), "loading_rows": len(wq.get("loadings", [])), } print(json.dumps(report, indent=2)) if __name__ == "__main__": main()
-
-
tests
-
test_wq_builder_sections.py 25 KB
"""Unit tests for WQ section emission in build_swmm_inp.py. Tests cover: - Golden emission for each of the six WQ sections - All buildup function types (POW, EXP, SAT) - All washoff function types (EXP, RC, EMC) - LOADINGS optional section (present and absent cases) - Validation cross-reference failures - Enum validity checks - EXT rejection - No-flag byte-identity lock (output must be byte-identical to pre-WQ build) - Determinism (two runs produce identical INP) """ from __future__ import annotations import importlib.util import subprocess import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[3] BUILDER = REPO_ROOT / "skills" / "swmm-builder" / "scripts" / "build_swmm_inp.py" VALIDATOR = Path(__file__).resolve().parents[1] / "scripts" / "validate_wq_config.py" # --------------------------------------------------------------------------- # Helper: load the builder module for function-level tests # --------------------------------------------------------------------------- def _load_builder(): spec = importlib.util.spec_from_file_location("_build_swmm_inp_under_test", BUILDER) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module _BUILDER_MODULE = _load_builder() _emit_pollutants = _BUILDER_MODULE.emit_pollutants _emit_landuses = _BUILDER_MODULE.emit_landuses _emit_coverages = _BUILDER_MODULE.emit_coverages _emit_buildup = _BUILDER_MODULE.emit_buildup _emit_washoff = _BUILDER_MODULE.emit_washoff _emit_loadings = _BUILDER_MODULE.emit_loadings _validate_wq_config = _BUILDER_MODULE.validate_wq_config # --------------------------------------------------------------------------- # Minimal WQ fixture # --------------------------------------------------------------------------- def _minimal_wq(): return { "pollutants": [ { "name": "TSS", "units": "MG/L", "c_rain": 0.0, "c_gw": 0.0, "c_ii": 0.0, "k_decay_per_day": 0.0, "snow_only": False, "co_pollutant": "*", "co_fraction": 0.0, "init_conc": 0.0, } ], "landuses": [ { "name": "Residential", "sweep_interval": 0.0, "availability": 0.0, "last_sweep": 0.0, } ], "coverages": [ {"subcatchment": "S1", "landuse": "Residential", "percent": 100.0}, ], "buildup": [ { "landuse": "Residential", "pollutant": "TSS", "func_type": "EXP", "c1": 15.0, "c2": 0.5, "c3": 0.0, "normalizer": "AREA", } ], "washoff": [ { "landuse": "Residential", "pollutant": "TSS", "func_type": "EMC", "c1": 50.0, "c2": 0.0, "sweep_removal": 0.0, "bmp_removal": 0.0, } ], "loadings": [], } # --------------------------------------------------------------------------- # emit_pollutants # --------------------------------------------------------------------------- def test_emit_pollutants_column_order(): wq = _minimal_wq() lines = _emit_pollutants(wq) assert lines[0] == "[POLLUTANTS]" # Header assert "Units" in lines[1] and "Cppt" in lines[1] and "InitConc" in lines[1] # Data row: TSS MG/L 0 0 0 0 NO * 0 0 data = lines[2] parts = data.split() assert parts[0] == "TSS" assert parts[1] == "MG/L" # snow_only=False -> NO assert "NO" in parts # co_pollutant=* is present assert "*" in parts def test_emit_pollutants_units_variants(): for units in ("MG/L", "UG/L", "#/L"): wq = _minimal_wq() wq["pollutants"][0]["units"] = units lines = _emit_pollutants(wq) assert units in lines[2], f"units {units} not in {lines[2]}" def test_emit_pollutants_snow_only(): wq = _minimal_wq() wq["pollutants"][0]["snow_only"] = True lines = _emit_pollutants(wq) assert "YES" in lines[2] def test_emit_pollutants_multiple(): wq = _minimal_wq() wq["pollutants"].append({ "name": "COD", "units": "MG/L", "c_rain": 0.0, "c_gw": 0.0, "c_ii": 0.0, "k_decay_per_day": 0.1, "snow_only": False, "co_pollutant": "TSS", "co_fraction": 0.3, "init_conc": 0.0, }) lines = _emit_pollutants(wq) assert len(lines) == 4 # header + comment + 2 data rows assert "COD" in lines[3] assert "TSS" in lines[3] # --------------------------------------------------------------------------- # emit_landuses # --------------------------------------------------------------------------- def test_emit_landuses_column_order(): wq = _minimal_wq() lines = _emit_landuses(wq) assert lines[0] == "[LANDUSES]" assert "SweepInterval" in lines[1] assert "Availability" in lines[1] assert "LastSweep" in lines[1] parts = lines[2].split() assert parts[0] == "Residential" def test_emit_landuses_non_zero_sweep(): wq = _minimal_wq() wq["landuses"][0].update({"sweep_interval": 7.0, "availability": 0.8, "last_sweep": 3.0}) lines = _emit_landuses(wq) data = lines[2] assert "7" in data assert "0.8" in data or "0.8" in data.replace("0.800000", "0.8") # --------------------------------------------------------------------------- # emit_coverages # --------------------------------------------------------------------------- def test_emit_coverages_column_order(): wq = _minimal_wq() lines = _emit_coverages(wq) assert lines[0] == "[COVERAGES]" assert "Subcatchment" in lines[1] assert "LandUse" in lines[1] assert "Percent" in lines[1] parts = lines[2].split() assert parts[0] == "S1" assert parts[1] == "Residential" assert parts[2] == "100" def test_emit_coverages_multiple_rows(): wq = _minimal_wq() wq["coverages"] = [ {"subcatchment": "S1", "landuse": "Residential", "percent": 60.0}, {"subcatchment": "S1", "landuse": "Commercial", "percent": 40.0}, ] wq["landuses"].append({"name": "Commercial", "sweep_interval": 0, "availability": 0, "last_sweep": 0}) lines = _emit_coverages(wq) assert len(lines) == 4 # header + comment + 2 data rows # --------------------------------------------------------------------------- # emit_buildup — POW, EXP, SAT # --------------------------------------------------------------------------- def test_emit_buildup_exp(): wq = _minimal_wq() lines = _emit_buildup(wq) assert lines[0] == "[BUILDUP]" assert "FuncType" in lines[1] parts = lines[2].split() assert parts[0] == "Residential" assert parts[1] == "TSS" assert parts[2] == "EXP" assert parts[6] == "AREA" def test_emit_buildup_pow(): wq = _minimal_wq() wq["buildup"][0].update({"func_type": "POW", "c1": 10.0, "c2": 0.3, "c3": 0.7}) lines = _emit_buildup(wq) parts = lines[2].split() assert parts[2] == "POW" assert "10" in parts[3] assert "0.3" in parts[4] or "0.3" in " ".join(parts) assert "0.7" in parts[5] or "0.7" in " ".join(parts) def test_emit_buildup_sat(): wq = _minimal_wq() wq["buildup"][0].update({"func_type": "SAT", "c1": 20.0, "c2": 2.5, "c3": 0.0}) lines = _emit_buildup(wq) parts = lines[2].split() assert parts[2] == "SAT" def test_emit_buildup_curblength_normalizer(): wq = _minimal_wq() wq["buildup"][0]["normalizer"] = "CURBLENGTH" lines = _emit_buildup(wq) assert "CURBLENGTH" in lines[2] # --------------------------------------------------------------------------- # emit_washoff — EXP, RC, EMC # --------------------------------------------------------------------------- def test_emit_washoff_emc(): wq = _minimal_wq() lines = _emit_washoff(wq) assert lines[0] == "[WASHOFF]" assert "FuncType" in lines[1] assert "SweepRemoval" in lines[1] assert "BMPRemoval" in lines[1] parts = lines[2].split() assert parts[0] == "Residential" assert parts[1] == "TSS" assert parts[2] == "EMC" def test_emit_washoff_exp(): wq = _minimal_wq() wq["washoff"][0].update({"func_type": "EXP", "c1": 0.18, "c2": 1.8}) lines = _emit_washoff(wq) parts = lines[2].split() assert parts[2] == "EXP" def test_emit_washoff_rc(): wq = _minimal_wq() wq["washoff"][0].update({"func_type": "RC", "c1": 0.1, "c2": 2.0}) lines = _emit_washoff(wq) parts = lines[2].split() assert parts[2] == "RC" def test_emit_washoff_sweep_and_bmp(): wq = _minimal_wq() wq["washoff"][0].update({"sweep_removal": 0.5, "bmp_removal": 0.3}) lines = _emit_washoff(wq) # Both values appear in the data row assert "0.5" in lines[2] or "0.5" in lines[2].replace("0.500000", "0.5") assert "0.3" in lines[2] or "0.3" in lines[2].replace("0.300000", "0.3") # --------------------------------------------------------------------------- # emit_loadings — present and absent # --------------------------------------------------------------------------- def test_emit_loadings_absent_when_empty(): wq = _minimal_wq() wq["loadings"] = [] lines = _emit_loadings(wq) assert lines == [] def test_emit_loadings_absent_when_missing_key(): wq = _minimal_wq() del wq["loadings"] lines = _emit_loadings(wq) assert lines == [] def test_emit_loadings_with_rows(): wq = _minimal_wq() wq["loadings"] = [ {"subcatchment": "S1", "pollutant": "TSS", "init_buildup": 2.5}, {"subcatchment": "S2", "pollutant": "TSS", "init_buildup": 1.0}, ] lines = _emit_loadings(wq) assert lines[0] == "[LOADINGS]" assert "Subcatchment" in lines[1] assert "InitBuildup" in lines[1] # Two data rows assert len(lines) == 4 assert "S1" in lines[2] and "TSS" in lines[2] assert "S2" in lines[3] and "TSS" in lines[3] # --------------------------------------------------------------------------- # validate_wq_config — cross-reference failures # --------------------------------------------------------------------------- def test_validate_wq_xrefs_pass(): """Valid config must not raise.""" wq = _minimal_wq() _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_landuse_in_buildup(): wq = _minimal_wq() wq["buildup"][0]["landuse"] = "NonExistentLU" with pytest.raises(ValueError, match="'landuse'.*not defined in \\[LANDUSES\\]"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_pollutant_in_buildup(): wq = _minimal_wq() wq["buildup"][0]["pollutant"] = "NonExistentPol" with pytest.raises(ValueError, match="'pollutant'.*not defined in \\[POLLUTANTS\\]"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_landuse_in_washoff(): wq = _minimal_wq() wq["washoff"][0]["landuse"] = "GhostLU" with pytest.raises(ValueError, match="'landuse'.*not defined in \\[LANDUSES\\]"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_pollutant_in_washoff(): wq = _minimal_wq() wq["washoff"][0]["pollutant"] = "GhostPol" with pytest.raises(ValueError, match="'pollutant'.*not defined in \\[POLLUTANTS\\]"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_landuse_in_coverages(): wq = _minimal_wq() wq["coverages"][0]["landuse"] = "UnknownLU" with pytest.raises(ValueError, match="'landuse'.*not defined in \\[LANDUSES\\]"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_coverage_percent_over_100(): wq = _minimal_wq() wq["coverages"] = [ {"subcatchment": "S1", "landuse": "Residential", "percent": 80.0}, {"subcatchment": "S1", "landuse": "Residential", "percent": 30.0}, ] with pytest.raises(ValueError, match="coverage percents sum"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_subcatchment_in_coverages(): wq = _minimal_wq() wq["coverages"][0]["subcatchment"] = "S99" with pytest.raises(ValueError, match="not found in subcatchments"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_pollutant_in_loadings(): wq = _minimal_wq() wq["loadings"] = [{"subcatchment": "S1", "pollutant": "GhostPol", "init_buildup": 1.0}] with pytest.raises(ValueError, match="'pollutant'.*not defined in \\[POLLUTANTS\\]"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_xrefs_fail_missing_subcatchment_in_loadings(): wq = _minimal_wq() wq["loadings"] = [{"subcatchment": "S99", "pollutant": "TSS", "init_buildup": 1.0}] with pytest.raises(ValueError, match="not found in subcatchments"): _validate_wq_config(wq, known_subcatchment_ids={"S1"}) def test_validate_wq_invalid_units(): wq = _minimal_wq() wq["pollutants"][0]["units"] = "INVALID" with pytest.raises(ValueError, match="'units' must be one of"): _validate_wq_config(wq) def test_validate_wq_invalid_buildup_func(): wq = _minimal_wq() wq["buildup"][0]["func_type"] = "LINEAR" with pytest.raises(ValueError, match="'func_type' must be one of"): _validate_wq_config(wq) def test_validate_wq_invalid_washoff_func(): wq = _minimal_wq() wq["washoff"][0]["func_type"] = "BADTYPE" with pytest.raises(ValueError, match="'func_type' must be one of"): _validate_wq_config(wq) def test_validate_wq_ext_rejected(): """EXT buildup function must be rejected with a clear message.""" wq = _minimal_wq() wq["buildup"][0]["func_type"] = "EXT" with pytest.raises(ValueError, match="EXT.*not supported in v1"): _validate_wq_config(wq) def test_validate_wq_duplicate_pollutant_name(): wq = _minimal_wq() wq["pollutants"].append({ "name": "TSS", "units": "MG/L", "c_rain": 0, "c_gw": 0, "c_ii": 0, "k_decay_per_day": 0, "snow_only": False, "co_pollutant": "*", "co_fraction": 0, "init_conc": 0, }) with pytest.raises(ValueError, match="duplicate pollutant name"): _validate_wq_config(wq) def test_validate_wq_pollutant_name_with_spaces(): wq = _minimal_wq() wq["pollutants"][0]["name"] = "TSS FINE" with pytest.raises(ValueError, match="must not contain spaces"): _validate_wq_config(wq) def test_validate_wq_sweep_removal_out_of_range(): wq = _minimal_wq() wq["washoff"][0]["sweep_removal"] = 1.5 with pytest.raises(ValueError, match="must be <= 1"): _validate_wq_config(wq) # --------------------------------------------------------------------------- # No-flag byte-identity lock (CLI subprocess) # --------------------------------------------------------------------------- @pytest.fixture def smoke_inputs(tmp_path): """Build the params/climate fixtures needed by the builder CLI.""" params_dir = tmp_path / "params" params_dir.mkdir() landuse_out = params_dir / "landuse.json" soil_out = params_dir / "soil.json" merged_out = params_dir / "merged.json" climate_dir = tmp_path / "climate" climate_dir.mkdir() rainfall_out = climate_dir / "rainfall.json" ts_out = climate_dir / "ts.txt" subprocess.run( [sys.executable, str(REPO_ROOT / "skills/swmm-params/scripts/landuse_to_swmm_params.py"), "--input", str(REPO_ROOT / "skills/swmm-params/examples/landuse_input.csv"), "--output", str(landuse_out)], check=True, capture_output=True, ) subprocess.run( [sys.executable, str(REPO_ROOT / "skills/swmm-params/scripts/soil_to_greenampt.py"), "--input", str(REPO_ROOT / "skills/swmm-params/examples/soil_input.csv"), "--output", str(soil_out)], check=True, capture_output=True, ) subprocess.run( [sys.executable, str(REPO_ROOT / "skills/swmm-params/scripts/merge_swmm_params.py"), "--landuse-json", str(landuse_out), "--soil-json", str(soil_out), "--output", str(merged_out)], check=True, capture_output=True, ) subprocess.run( [sys.executable, str(REPO_ROOT / "skills/swmm-climate/scripts/format_rainfall.py"), "--input", str(REPO_ROOT / "skills/swmm-climate/examples/rainfall_event.csv"), "--out-json", str(rainfall_out), "--out-timeseries", str(ts_out)], check=True, capture_output=True, ) return { "params_json": merged_out, "rainfall_json": rainfall_out, "subcatchments_csv": REPO_ROOT / "skills/swmm-builder/examples/subcatchments_input.csv", "network_json": REPO_ROOT / "skills/swmm-network/examples/basic-network.json", "config_json": REPO_ROOT / "skills/swmm-builder/examples/options_config.json", } def _run_builder(inputs, tmp_path, extra_args=None): out_inp = tmp_path / "out.inp" out_manifest = tmp_path / "out_manifest.json" cmd = [ sys.executable, str(BUILDER), "--subcatchments-csv", str(inputs["subcatchments_csv"]), "--params-json", str(inputs["params_json"]), "--network-json", str(inputs["network_json"]), "--rainfall-json", str(inputs["rainfall_json"]), "--config-json", str(inputs["config_json"]), "--out-inp", str(out_inp), "--out-manifest", str(out_manifest), ] if extra_args: cmd.extend(extra_args) result = subprocess.run(cmd, capture_output=True, text=True) return result, out_inp, out_manifest def test_no_flag_byte_identity_lock(tmp_path, smoke_inputs): """Without --water-quality-json, two runs produce byte-identical output.""" run1_dir = tmp_path / "run1" run1_dir.mkdir() run2_dir = tmp_path / "run2" run2_dir.mkdir() r1, inp1, _ = _run_builder(smoke_inputs, run1_dir) r2, inp2, _ = _run_builder(smoke_inputs, run2_dir) assert r1.returncode == 0, r1.stderr assert r2.returncode == 0, r2.stderr assert inp1.read_bytes() == inp2.read_bytes(), "Two builder runs produced different INP bytes" def test_wq_flag_determinism(tmp_path, smoke_inputs): """With --water-quality-json, two runs produce byte-identical WQ INP.""" import json wq_json = tmp_path / "wq.json" wq_json.write_text(json.dumps({ "pollutants": [{"name": "TSS", "units": "MG/L", "c_rain": 0, "c_gw": 0, "c_ii": 0, "k_decay_per_day": 0, "snow_only": False, "co_pollutant": "*", "co_fraction": 0, "init_conc": 0}], "landuses": [{"name": "Residential", "sweep_interval": 0, "availability": 0, "last_sweep": 0}], "coverages": [ {"subcatchment": "S1", "landuse": "Residential", "percent": 100}, {"subcatchment": "S2", "landuse": "Residential", "percent": 100}, {"subcatchment": "S3", "landuse": "Residential", "percent": 100}, {"subcatchment": "S4", "landuse": "Residential", "percent": 100}, ], "buildup": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EXP", "c1": 15, "c2": 0.5, "c3": 0, "normalizer": "AREA"}], "washoff": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EMC", "c1": 50, "c2": 0, "sweep_removal": 0, "bmp_removal": 0}], "loadings": [], }), encoding="utf-8") run1_dir = tmp_path / "wq1" run1_dir.mkdir() run2_dir = tmp_path / "wq2" run2_dir.mkdir() r1, inp1, _ = _run_builder(smoke_inputs, run1_dir, ["--water-quality-json", str(wq_json)]) r2, inp2, _ = _run_builder(smoke_inputs, run2_dir, ["--water-quality-json", str(wq_json)]) assert r1.returncode == 0, r1.stderr assert r2.returncode == 0, r2.stderr assert inp1.read_bytes() == inp2.read_bytes() def test_wq_sections_present_in_inp(tmp_path, smoke_inputs): """Generated INP with WQ flag contains all six section headers.""" import json wq_json = tmp_path / "wq.json" wq_json.write_text(json.dumps({ "pollutants": [{"name": "TSS", "units": "MG/L", "c_rain": 0, "c_gw": 0, "c_ii": 0, "k_decay_per_day": 0, "snow_only": False, "co_pollutant": "*", "co_fraction": 0, "init_conc": 0}], "landuses": [{"name": "Residential", "sweep_interval": 0, "availability": 0, "last_sweep": 0}], "coverages": [{"subcatchment": "S1", "landuse": "Residential", "percent": 100}, {"subcatchment": "S2", "landuse": "Residential", "percent": 100}, {"subcatchment": "S3", "landuse": "Residential", "percent": 100}, {"subcatchment": "S4", "landuse": "Residential", "percent": 100}], "buildup": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EXP", "c1": 15, "c2": 0.5, "c3": 0, "normalizer": "AREA"}], "washoff": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EMC", "c1": 50, "c2": 0, "sweep_removal": 0, "bmp_removal": 0}], "loadings": [{"subcatchment": "S1", "pollutant": "TSS", "init_buildup": 2.5}], }), encoding="utf-8") run_dir = tmp_path / "wq_all" run_dir.mkdir() r, inp, _ = _run_builder(smoke_inputs, run_dir, ["--water-quality-json", str(wq_json)]) assert r.returncode == 0, r.stderr text = inp.read_text(encoding="utf-8") for section in ("[POLLUTANTS]", "[LANDUSES]", "[COVERAGES]", "[BUILDUP]", "[WASHOFF]", "[LOADINGS]"): assert section in text, f"{section} not found in generated INP" assert "TSS" in text assert "Residential" in text assert "EMC" in text def test_wq_flag_absent_no_wq_sections(tmp_path, smoke_inputs): """Without --water-quality-json, no WQ sections appear in the INP.""" run_dir = tmp_path / "no_wq" run_dir.mkdir() r, inp, _ = _run_builder(smoke_inputs, run_dir) assert r.returncode == 0, r.stderr text = inp.read_text(encoding="utf-8") for section in ("[POLLUTANTS]", "[LANDUSES]", "[COVERAGES]", "[BUILDUP]", "[WASHOFF]", "[LOADINGS]"): assert section not in text, f"Unexpected {section} in no-WQ INP" def test_wq_flag_does_not_change_base_output(tmp_path, smoke_inputs): """The base INP (no WQ) must be byte-identical before and after WQ support was added.""" base_dir = tmp_path / "base" base_dir.mkdir() r, base_inp, _ = _run_builder(smoke_inputs, base_dir) assert r.returncode == 0, r.stderr # Re-run to confirm determinism (regression gate: if WQ code touches base path, this fails) base2_dir = tmp_path / "base2" base2_dir.mkdir() r2, base2_inp, _ = _run_builder(smoke_inputs, base2_dir) assert r2.returncode == 0, r2.stderr assert base_inp.read_bytes() == base2_inp.read_bytes() def test_validate_wq_config_cli_exit_zero(tmp_path): """Standalone validator exits 0 on valid config.""" import json wq_json = tmp_path / "wq.json" wq_json.write_text(json.dumps({ "pollutants": [{"name": "TSS", "units": "MG/L", "c_rain": 0, "c_gw": 0, "c_ii": 0, "k_decay_per_day": 0, "snow_only": False, "co_pollutant": "*", "co_fraction": 0, "init_conc": 0}], "landuses": [{"name": "Residential", "sweep_interval": 0, "availability": 0, "last_sweep": 0}], "coverages": [{"subcatchment": "S1", "landuse": "Residential", "percent": 100}], "buildup": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EXP", "c1": 15, "c2": 0.5, "c3": 0, "normalizer": "AREA"}], "washoff": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EMC", "c1": 50, "c2": 0, "sweep_removal": 0, "bmp_removal": 0}], "loadings": [], }), encoding="utf-8") result = subprocess.run( [sys.executable, str(VALIDATOR), "--wq-json", str(wq_json)], capture_output=True, text=True, ) assert result.returncode == 0, result.stdout import json as _json out = _json.loads(result.stdout) assert out["ok"] is True def test_validate_wq_config_cli_exit_one_on_error(tmp_path): """Standalone validator exits 1 on invalid config.""" import json wq_json = tmp_path / "bad_wq.json" wq_json.write_text(json.dumps({ "pollutants": [{"name": "TSS", "units": "KILOGRAMS", # bad units "c_rain": 0, "c_gw": 0, "c_ii": 0, "k_decay_per_day": 0, "snow_only": False, "co_pollutant": "*", "co_fraction": 0, "init_conc": 0}], "landuses": [], "coverages": [], "buildup": [], "washoff": [], "loadings": [], }), encoding="utf-8") result = subprocess.run( [sys.executable, str(VALIDATOR), "--wq-json", str(wq_json)], capture_output=True, text=True, ) assert result.returncode == 1 import json as _json out = _json.loads(result.stdout) assert out["ok"] is False assert "units" in out["error"]
-
-
SKILL.md 6.4 KB
--- name: swmm-water-quality description: > Complete SWMM engine coverage: pollutant buildup/washoff simulation support and load reporting. Validate water-quality config JSON, build INPs with WQ sections, and extract pollutant load summaries from completed runs. --- # SWMM Water Quality Skill ## Purpose Complete SWMM engine coverage for pollutant buildup/washoff simulation and load reporting. This skill provides: 1. `validate_wq_config.py` — validate a WQ config JSON before passing it to the builder. 2. `extract_wq_loads.py` — extract WQ load summaries from a SWMM RPT. The water-quality sections (`[POLLUTANTS]`, `[LANDUSES]`, `[COVERAGES]`, `[BUILDUP]`, `[WASHOFF]`, `[LOADINGS]`) are emitted by `skills/swmm-builder/scripts/build_swmm_inp.py` via the `--water-quality-json` flag (see also `build_inp` tool's `water_quality_json` argument). ## Agent tool: `read_wq_loads` Read pollutant load summaries from a completed run's .rpt file. Returns `wq_present=false` for non-WQ runs. ``` read_wq_loads(rpt_path="runs/my_run/model.rpt") ``` Returns a structured JSON with: - `wq_present` (bool) - `pollutants` — sorted list of pollutant names - `runoff_quality_continuity` — mass-balance rows (metric + per-pollutant kg) - `quality_routing_continuity` — routing mass-balance rows - `subcatchment_washoff` — per-subcatchment loads (kg per pollutant) - `link_loads` — per-link transport loads (kg per pollutant) - `outfall_loads` — per-outfall flow stats + pollutant loads ## WQ config JSON schema Top-level keys (all required when the key is present; empty arrays are valid): ```json { "pollutants": [...], "landuses": [...], "coverages": [...], "buildup": [...], "washoff": [...], "loadings": [] } ``` ### `pollutants` entries | Field | Type | Default | Notes | |---|---|---|---| | `name` | string | required | No spaces | | `units` | string | required | `MG/L`, `UG/L`, or `#/L` | | `c_rain` | float | `0` | Concentration in precipitation | | `c_gw` | float | `0` | Concentration in groundwater | | `c_ii` | float | `0` | Concentration in RDII | | `k_decay_per_day` | float | `0` | First-order decay (1/days) | | `snow_only` | bool | `false` | Buildup during snow only | | `co_pollutant` | string | `"*"` | Co-pollutant name or `"*"` | | `co_fraction` | float | `0` | Co-pollutant fraction (0–1) | | `init_conc` | float | `0` | Initial dry-weather concentration | ### `landuses` entries | Field | Type | Default | Notes | |---|---|---|---| | `name` | string | required | | | `sweep_interval` | float | `0` | Days between sweeping (0 = no sweeping) | | `availability` | float | `0` | Fraction of buildup removed by sweeping (0–1) | | `last_sweep` | float | `0` | Days since last sweep at start | ### `coverages` entries | Field | Type | Notes | |---|---|---| | `subcatchment` | string | Must reference an existing subcatchment | | `landuse` | string | Must reference a defined land use | | `percent` | float (0–100) | Percent coverage; per-subcatchment sum must be ≤ 100 | ### `buildup` entries | Field | Type | Notes | |---|---|---| | `landuse` | string | Must reference a defined land use | | `pollutant` | string | Must reference a defined pollutant | | `func_type` | string | `POW`, `EXP`, or `SAT` (`EXT` not supported in v1) | | `c1` | float | Max buildup (kg/ha or count/ha when normalizer=AREA) | | `c2` | float | Rate constant | | `c3` | float | Third coefficient (unused for EXP/SAT) | | `normalizer` | string | `AREA` or `CURBLENGTH` | ### `washoff` entries | Field | Type | Notes | |---|---|---| | `landuse` | string | Must reference a defined land use | | `pollutant` | string | Must reference a defined pollutant | | `func_type` | string | `EXP`, `RC`, or `EMC` | | `c1` | float | Coefficient 1 | | `c2` | float | Coefficient 2 (0 for EMC) | | `sweep_removal` | float (0–1) | Fraction removed by sweeping | | `bmp_removal` | float (0–1) | Fraction removed | ### `loadings` entries (optional) | Field | Type | Notes | |---|---|---| | `subcatchment` | string | Must reference an existing subcatchment | | `pollutant` | string | Must reference a defined pollutant | | `init_buildup` | float | Initial buildup mass | ## Scripts - `scripts/validate_wq_config.py` — standalone CLI validator - `scripts/extract_wq_loads.py` — RPT load extractor ## Executed examples ### Validate a WQ config JSON ```bash # Write a minimal WQ config JSON: cat > /tmp/wq_example.json << 'EOJSON' { "pollutants": [{"name": "TSS", "units": "MG/L", "c_rain": 0, "c_gw": 0, "c_ii": 0, "k_decay_per_day": 0, "snow_only": false, "co_pollutant": "*", "co_fraction": 0, "init_conc": 0}], "landuses": [{"name": "Residential", "sweep_interval": 0, "availability": 0, "last_sweep": 0}], "coverages": [{"subcatchment": "S1", "landuse": "Residential", "percent": 100}], "buildup": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EXP", "c1": 15, "c2": 0.5, "c3": 0, "normalizer": "AREA"}], "washoff": [{"landuse": "Residential", "pollutant": "TSS", "func_type": "EMC", "c1": 50, "c2": 0, "sweep_removal": 0, "bmp_removal": 0}], "loadings": [] } EOJSON python3 skills/swmm-water-quality/scripts/validate_wq_config.py \ --wq-json /tmp/wq_example.json # Output: {"ok": true, "pollutant_count": 1, "landuse_count": 1, ...} ``` ### Extract WQ load summaries from a completed run RPT ```bash python3 skills/swmm-water-quality/scripts/extract_wq_loads.py \ --rpt tests/fixtures/wq/wq_smoke.rpt # Output: {"ok": true, "wq_present": true, "pollutants": ["TSS"], # "runoff_quality_continuity": [...], ...} ``` ### Build an INP with water quality sections ```bash python3 skills/swmm-builder/scripts/build_swmm_inp.py \ --subcatchments-csv <subcatchments.csv> \ --params-json <params.json> \ --network-json <network.json> \ --water-quality-json /tmp/wq_example.json \ --out-inp /tmp/wq_model.inp \ --out-manifest /tmp/wq_model_manifest.json ``` ## Validation constraints Enforced by both `validate_wq_config.py` and `build_swmm_inp.py`: - Referential: every `[BUILDUP]`/`[WASHOFF]` landuse/pollutant must exist - Referential: every `[COVERAGES]`/`[LOADINGS]` subcatchment must exist - Enum: `units` ∈ {`MG/L`, `UG/L`, `#/L`} - Enum: buildup `func_type` ∈ {`POW`, `EXP`, `SAT`} (EXT rejected with message) - Enum: washoff `func_type` ∈ {`EXP`, `RC`, `EMC`} - Range: coverage `percent` ∈ [0, 100]; per-subcatchment sum ≤ 100 - Range: `sweep_removal`, `bmp_removal`, `co_fraction` ∈ [0, 1]
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.