Claude Skill

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.

LLM Mart · 0 points · 5 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zhonghao1995-agentic-swmm-workflow-skills_swmm-water-quality-2d743b9.zip · 15 KB
Part of zhonghao1995/agentic-swmm-workflow — 18 skills

Install

skills CLI npx skills add https://github.com/Zhonghao1995/agentic-swmm-workflow/tree/main/skills/swmm-water-quality
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zhonghao1995-agentic-swmm-workflow@llmmart
Git 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:

  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):

{
  "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

# 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.

No comments yet.

Reviews (0)

No reviews yet.

Related