File size: 28,124 Bytes
e545bf5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
"""Preliminary SWMM scenario generation, execution, and comparison.

Rev22.1 intentionally treats generated storms and parameter changes as preliminary
engineering scenarios.  It does not represent generated rainfall as an approved
City of Calgary design storm unless the user supplies and verifies the source.
"""
from __future__ import annotations

from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from io import BytesIO
from pathlib import Path
from typing import Any, Iterable
import csv
import hashlib
import json
import math
import re
import tempfile
import zipfile

import pandas as pd

from swmm_core import run_swmm


@dataclass
class StormDefinition:
    mode: str = "existing_model"
    name: str = "Existing model rainfall"
    return_period: str = "Model-defined"
    duration_minutes: int = 60
    interval_minutes: int = 5
    total_depth_mm: float | None = None
    peak_position: float = 0.40
    source_status: str = "Existing model input"
    source_reference: str = ""
    selected_timeseries: str = ""


@dataclass
class ScenarioChange:
    object_type: str
    object_id: str
    parameter: str
    old_value: Any = None
    new_value: Any = None
    status: str = "requested"
    note: str = ""


@dataclass
class ScenarioDefinition:
    scenario_id: str
    scenario_name: str
    description: str = ""
    storm: StormDefinition = field(default_factory=StormDefinition)
    imperviousness_overrides: dict[str, float] = field(default_factory=dict)
    conduit_diameter_overrides: dict[str, float] = field(default_factory=dict)
    conduit_roughness_overrides: dict[str, float] = field(default_factory=dict)
    storage_depth_overrides: dict[str, float] = field(default_factory=dict)
    simulation_hours: float | None = None
    review_status: str = "Preliminary"
    created_at: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))


def safe_scenario_id(value: str) -> str:
    cleaned = re.sub(r"[^A-Za-z0-9_-]+", "_", value.strip()).strip("_")
    return cleaned[:64] or "scenario"


def _split_sections(text: str) -> tuple[list[str], dict[str, list[str]]]:
    preamble: list[str] = []
    sections: dict[str, list[str]] = {}
    current: str | None = None
    for raw in text.splitlines():
        m = re.match(r"^\s*\[([^]]+)\]\s*$", raw)
        if m:
            current = m.group(1).upper()
            sections.setdefault(current, [])
        elif current is None:
            preamble.append(raw)
        else:
            sections[current].append(raw)
    return preamble, sections


def _join_sections(preamble: list[str], sections: dict[str, list[str]]) -> str:
    out = list(preamble)
    if out and out[-1].strip():
        out.append("")
    for name, lines in sections.items():
        out.append(f"[{name}]")
        out.extend(lines)
        out.append("")
    return "\n".join(out).rstrip() + "\n"


def _data_tokens(line: str) -> tuple[list[str], str] | None:
    body, sep, comment = line.partition(";")
    if not body.strip() or body.lstrip().startswith(";"):
        return None
    return body.split(), (sep + comment if sep else "")


def _replace_tokens(line: str, tokens: list[str], comment: str) -> str:
    return "  ".join(str(x) for x in tokens) + ((" " + comment) if comment else "")


def _update_named_row(lines: list[str], object_id: str, token_index: int, new_value: Any) -> tuple[list[str], Any, bool]:
    updated: list[str] = []
    old = None
    changed = False
    for line in lines:
        parsed = _data_tokens(line)
        if parsed and parsed[0] and parsed[0][0].casefold() == object_id.casefold():
            tokens, comment = parsed
            if len(tokens) > token_index:
                old = tokens[token_index]
                tokens[token_index] = f"{new_value:g}" if isinstance(new_value, float) else str(new_value)
                line = _replace_tokens(line, tokens, comment)
                changed = True
        updated.append(line)
    return updated, old, changed


def _set_option(lines: list[str], key: str, value: str) -> list[str]:
    result: list[str] = []
    found = False
    for line in lines:
        parsed = _data_tokens(line)
        if parsed and parsed[0][0].upper() == key.upper():
            tokens, comment = parsed
            tokens = [key, value]
            line = _replace_tokens(line, tokens, comment)
            found = True
        result.append(line)
    if not found:
        result.append(f"{key:<20} {value}")
    return result


def chicago_style_incremental_depths(total_depth_mm: float, duration_minutes: int, interval_minutes: int, peak_position: float = 0.4) -> list[float]:
    """Create a preliminary, mass-conserving Chicago-style alternating-block storm.

    This is deliberately labelled preliminary. It is useful for scenario workflow
    testing, but it is not a replacement for a verified municipal IDF-based storm.
    """
    n = max(1, int(math.ceil(duration_minutes / interval_minutes)))
    # Smooth synthetic intensity pattern with a sharp peak and positive tails.
    ranks = list(range(1, n + 1))
    weights = [1.0 / (r ** 0.72) for r in ranks]
    scale = total_depth_mm / sum(weights)
    blocks = [w * scale for w in weights]
    peak_idx = min(n - 1, max(0, round((n - 1) * peak_position)))
    order = [peak_idx]
    offset = 1
    while len(order) < n:
        right = peak_idx + offset
        left = peak_idx - offset
        if right < n:
            order.append(right)
        if left >= 0 and len(order) < n:
            order.append(left)
        offset += 1
    result = [0.0] * n
    for block, idx in zip(sorted(blocks, reverse=True), order):
        result[idx] = block
    # exact conservation after floating-point arithmetic
    result[-1] += total_depth_mm - sum(result)
    return result


def _inject_preliminary_storm(sections: dict[str, list[str]], storm: StormDefinition, scenario_id: str) -> None:
    if storm.total_depth_mm is None:
        raise ValueError("Total storm depth is required for a generated preliminary storm.")
    interval = max(1, int(storm.interval_minutes))
    duration = max(interval, int(storm.duration_minutes))
    series_id = f"SCN_{safe_scenario_id(scenario_id)}_RAIN"
    depths = chicago_style_incremental_depths(float(storm.total_depth_mm), duration, interval, float(storm.peak_position))

    # Replace/add the time series. Values are interval rainfall depths; the gage is VOLUME.
    ts_lines = [ln for ln in sections.get("TIMESERIES", []) if not (_data_tokens(ln) and _data_tokens(ln)[0][0].casefold() == series_id.casefold())]
    start = datetime(2000, 1, 1, 0, 0)
    ts_lines.append(f"; Preliminary scenario storm: {storm.name}; source status: {storm.source_status}")
    for i, depth in enumerate(depths):
        t = start + timedelta(minutes=i * interval)
        ts_lines.append(f"{series_id:<24} {t.strftime('%m/%d/%Y')} {t.strftime('%H:%M')} {depth:.6f}")
    sections["TIMESERIES"] = ts_lines

    # Point all model rain gages to the generated series while retaining their IDs.
    rg_lines: list[str] = []
    for line in sections.get("RAINGAGES", []):
        parsed = _data_tokens(line)
        if parsed:
            tokens, comment = parsed
            if len(tokens) >= 1:
                gage_id = tokens[0]
                tokens = [gage_id, "VOLUME", f"0:{interval:02d}", "1.0", "TIMESERIES", series_id]
                line = _replace_tokens(line, tokens, comment)
        rg_lines.append(line)
    sections["RAINGAGES"] = rg_lines



def extract_rainfall_event_catalog(text: str) -> list[dict[str, str]]:
    """Return rainfall time-series available inside an INP model.

    SWMM models often retain several design-event series (for example 1:5 and
    1:100) while only one series is referenced by the active rain gage.
    """
    _, sections = _split_sections(text)
    active = set()
    gages = {}
    for line in sections.get("RAINGAGES", []):
        parsed = _data_tokens(line)
        if not parsed:
            continue
        tokens, _ = parsed
        if len(tokens) >= 6 and tokens[4].upper() == "TIMESERIES":
            gages[tokens[0]] = tokens[5]
            active.add(tokens[5].casefold())
    ids=[]
    seen=set()
    for line in sections.get("TIMESERIES", []):
        parsed=_data_tokens(line)
        if not parsed:
            continue
        tokens,_=parsed
        if not tokens:
            continue
        sid=tokens[0]
        if sid.casefold() in seen:
            continue
        seen.add(sid.casefold())
        ids.append({"event_id":sid,"active":"Yes" if sid.casefold() in active else "No","used_by":", ".join(k for k,v in gages.items() if v.casefold()==sid.casefold())})
    return ids


def _select_existing_timeseries(sections: dict[str, list[str]], series_id: str) -> None:
    """Point every TIMESERIES-based rain gage to an existing model series."""
    available={x["event_id"].casefold():x["event_id"] for x in extract_rainfall_event_catalog(_join_sections([], sections))}
    if series_id.casefold() not in available:
        raise ValueError(f"Rainfall time series {series_id!r} was not found in the selected model.")
    canonical=available[series_id.casefold()]
    new=[]
    changed=0
    for line in sections.get("RAINGAGES", []):
        parsed=_data_tokens(line)
        if parsed:
            tokens,comment=parsed
            if len(tokens)>=6 and tokens[4].upper()=="TIMESERIES":
                tokens[5]=canonical
                line=_replace_tokens(line,tokens,comment)
                changed+=1
        new.append(line)
    if changed==0:
        raise ValueError("The selected model has no TIMESERIES-based rain gage to assign.")
    sections["RAINGAGES"]=new

def build_scenario_inp(base_inp: str | Path, scenario: ScenarioDefinition, output_path: str | Path) -> dict[str, Any]:
    base = Path(base_inp)
    text = base.read_text(encoding="utf-8", errors="ignore")
    preamble, sections = _split_sections(text)
    change_log: list[ScenarioChange] = []

    if scenario.storm.mode == "generated_preliminary":
        _inject_preliminary_storm(sections, scenario.storm, scenario.scenario_id)
        change_log.append(ScenarioChange("storm", scenario.storm.name, "rainfall_series", None, scenario.storm.total_depth_mm, "applied", scenario.storm.source_status))
    elif scenario.storm.mode == "existing_timeseries" and scenario.storm.selected_timeseries:
        _select_existing_timeseries(sections, scenario.storm.selected_timeseries)
        change_log.append(ScenarioChange("storm", scenario.storm.selected_timeseries, "active_timeseries", None, scenario.storm.selected_timeseries, "applied", "Existing series selected from model"))

    if scenario.simulation_hours and scenario.simulation_hours > 0:
        start = datetime(2000, 1, 1, 0, 0)
        end = start + timedelta(hours=float(scenario.simulation_hours))
        opts = sections.setdefault("OPTIONS", [])
        for key, val in [
            ("START_DATE", start.strftime("%m/%d/%Y")),
            ("START_TIME", start.strftime("%H:%M:%S")),
            ("REPORT_START_DATE", start.strftime("%m/%d/%Y")),
            ("REPORT_START_TIME", start.strftime("%H:%M:%S")),
            ("END_DATE", end.strftime("%m/%d/%Y")),
            ("END_TIME", end.strftime("%H:%M:%S")),
        ]:
            opts = _set_option(opts, key, val)
        sections["OPTIONS"] = opts

    # SWMM [SUBCATCHMENTS]: Name RainGage Outlet Area %Imperv Width %Slope ...
    for oid, value in scenario.imperviousness_overrides.items():
        lines, old, ok = _update_named_row(sections.get("SUBCATCHMENTS", []), oid, 4, float(value))
        sections["SUBCATCHMENTS"] = lines
        change_log.append(ScenarioChange("subcatchment", oid, "imperviousness_percent", old, value, "applied" if ok else "not_found"))

    # SWMM [XSECTIONS]: Link Shape Geom1 ... ; circular Geom1 is diameter.
    for oid, value in scenario.conduit_diameter_overrides.items():
        lines, old, ok = _update_named_row(sections.get("XSECTIONS", []), oid, 2, float(value))
        sections["XSECTIONS"] = lines
        change_log.append(ScenarioChange("link", oid, "diameter_or_geom1", old, value, "applied" if ok else "not_found"))

    # SWMM [CONDUITS]: Name From To Length Roughness ...
    for oid, value in scenario.conduit_roughness_overrides.items():
        lines, old, ok = _update_named_row(sections.get("CONDUITS", []), oid, 4, float(value))
        sections["CONDUITS"] = lines
        change_log.append(ScenarioChange("conduit", oid, "manning_n", old, value, "applied" if ok else "not_found"))

    # SWMM [STORAGE]: Name Elev MaxDepth InitDepth Shape ...
    for oid, value in scenario.storage_depth_overrides.items():
        lines, old, ok = _update_named_row(sections.get("STORAGE", []), oid, 2, float(value))
        sections["STORAGE"] = lines
        change_log.append(ScenarioChange("storage", oid, "maximum_depth", old, value, "applied" if ok else "not_found"))

    output = Path(output_path)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(_join_sections(preamble, sections), encoding="utf-8")
    return {
        "scenario": asdict(scenario),
        "changes": [asdict(x) for x in change_log],
        "input_sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
        "output_path": str(output),
    }


def summarize_scenario_results(results: dict[str, Any], scenario: ScenarioDefinition) -> dict[str, Any]:
    node_ts = results.get("node_ts", {}) or {}
    link_ts = results.get("link_ts", {}) or {}
    sub_ts = results.get("sub_ts", {}) or {}
    meta = results.get("metadata", {}) or {}

    max_flood = max((max(v.get("flooding", []) or [0.0]) for v in node_ts.values()), default=0.0)
    total_peak_inflow = max((max(v.get("inflow", []) or [0.0]) for v in node_ts.values()), default=0.0)
    max_velocity = 0.0
    max_velocity_link = ""
    max_depth_ratio = 0.0
    max_depth_ratio_link = ""
    peak_link_flow = 0.0
    for lid, values in link_ts.items():
        vel = max((abs(float(x)) for x in values.get("velocity", []) or [0.0]), default=0.0)
        if vel > max_velocity:
            max_velocity, max_velocity_link = vel, lid
        depth = max((float(x) for x in values.get("depth", []) or [0.0]), default=0.0)
        diameter = float(values.get("diameter", 0.0) or 0.0)
        ratio = depth / diameter if diameter > 0 else 0.0
        if ratio > max_depth_ratio:
            max_depth_ratio, max_depth_ratio_link = ratio, lid
        peak_link_flow = max(peak_link_flow, max((abs(float(x)) for x in values.get("flow", []) or [0.0]), default=0.0))
    peak_runoff = max((max(v.get("runoff", []) or [0.0]) for v in sub_ts.values()), default=0.0)
    return {
        "Scenario ID": scenario.scenario_id,
        "Scenario Name": scenario.scenario_name,
        "Storm": scenario.storm.name,
        "Storm Status": scenario.storm.source_status,
        "Simulation Status": "Completed",
        "Runoff Error (%)": float(meta.get("runoff_error", 0.0) or 0.0),
        "Flow Error (%)": float(meta.get("flow_error", 0.0) or 0.0),
        "Peak Subcatchment Runoff": peak_runoff,
        "Peak Link Flow": peak_link_flow,
        "Maximum Link Velocity": max_velocity,
        "Velocity Link": max_velocity_link,
        "Maximum Modelled Depth Ratio": max_depth_ratio,
        "Depth-Ratio Link": max_depth_ratio_link,
        "Maximum Node Flooding": max_flood,
        "Maximum Node Inflow": total_peak_inflow,
        "Review Status": scenario.review_status,
        "Model Role": "Scenario",
    }


def run_scenario(base_inp: str | Path, scenario: ScenarioDefinition, work_dir: str | Path | None = None) -> dict[str, Any]:
    root = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="swmm_scenario_"))
    root.mkdir(parents=True, exist_ok=True)
    sid = safe_scenario_id(scenario.scenario_id)
    inp = root / f"{sid}.inp"
    manifest = build_scenario_inp(base_inp, scenario, inp)
    rpt = root / f"{sid}.rpt"
    out = root / f"{sid}.out"
    results = run_swmm(inp, rpt, out)
    # Attach final-input geometry to result records so scenario depth-ratio
    # summaries are computed from the scenario model rather than shown as zero.
    final_sections = {}
    try:
        _, final_sections = _split_sections(inp.read_text(encoding="utf-8", errors="ignore"))
        diameters = {}
        for line in final_sections.get("XSECTIONS", []):
            parsed = _data_tokens(line)
            if parsed:
                tokens, _ = parsed
                if len(tokens) >= 3:
                    try:
                        diameters[tokens[0]] = float(tokens[2])
                    except ValueError:
                        pass
        for lid, values in (results.get("link_ts", {}) or {}).items():
            if lid in diameters:
                values["diameter"] = diameters[lid]
    except Exception:
        pass
    summary = summarize_scenario_results(results, scenario)
    summary["Conduit Count"] = len(final_sections.get("CONDUITS", []) or [])
    storage_ids = [(_data_tokens(line)[0][0] if _data_tokens(line) else "") for line in (final_sections.get("STORAGE", []) or [])]
    storage_ids = [x for x in storage_ids if x]
    storage_depth = 0.0
    storage_volume = 0.0
    storage_node = ""
    for node_id in storage_ids:
        values = (results.get("node_ts", {}) or {}).get(node_id, {})
        depth = max((float(x) for x in values.get("depth", []) or [0.0]), default=0.0)
        volume = max((float(x) for x in values.get("volume", []) or [0.0]), default=0.0)
        if depth > storage_depth:
            storage_depth, storage_node = depth, node_id
        storage_volume = max(storage_volume, volume)
    summary["Maximum Storage Depth"] = storage_depth if storage_ids else None
    summary["Maximum Storage Volume"] = storage_volume if storage_ids else None
    summary["Controlling Storage Node"] = storage_node if storage_ids else ""
    manifest.update({
        "report_path": str(rpt),
        "output_path": str(out),
        "report_sha256": hashlib.sha256(rpt.read_bytes()).hexdigest() if rpt.exists() else None,
        "summary": summary,
    })
    return {"definition": asdict(scenario), "manifest": manifest, "results": results, "summary": summary, "files": {"inp": inp.read_bytes(), "rpt": rpt.read_bytes() if rpt.exists() else b"", "out": out.read_bytes() if out.exists() else b""}}



def base_model_record(results: dict[str, Any], *, scenario_name: str = "Base Model", review_status: str = "Reference model", source_name: str = "Base Model") -> dict[str, Any]:
    """Create a comparison record for the currently simulated base model."""
    definition = ScenarioDefinition(
        scenario_id="BASE_MODEL",
        scenario_name=scenario_name,
        description="Current uploaded and simulated reference model.",
        storm=StormDefinition(
            mode="existing_model",
            name="Existing model rainfall",
            return_period="Model-defined",
            source_status="Base model input",
        ),
        review_status=review_status,
    )
    summary = summarize_scenario_results(results or {}, definition)
    summary["Model Role"] = "Base"
    summary["Source Model"] = source_name
    summary["Input Changed"] = "Reference"
    summary["Input SHA256 (short)"] = ""
    summary["Conduit Count"] = sum(1 for values in ((results or {}).get("link_ts", {}) or {}).values() if float(values.get("diameter", 0) or 0) > 0)
    return {
        "definition": asdict(definition),
        "manifest": {"summary": summary, "model_role": "Base", "source": "Current uploaded model"},
        "results": results or {},
        "summary": summary,
        "files": {},
    }


def comparison_with_base(base_results: dict[str, Any] | None, records: Iterable[dict[str, Any]], *, base_name: str = "Base Model") -> pd.DataFrame:
    """Return one comparison table containing the base model and scenario records."""
    combined: list[dict[str, Any]] = []
    if base_results:
        combined.append(base_model_record(base_results, scenario_name=base_name, source_name=base_name))
    combined.extend(list(records))
    df = comparison_dataframe(combined)
    if not df.empty and "Model Role" not in df.columns:
        df.insert(0, "Model Role", ["Base" if str(v) == "BASE_MODEL" else "Scenario" for v in df.get("Scenario ID", [])])
    elif not df.empty:
        df["Model Role"] = df["Model Role"].fillna("Scenario")
    if not df.empty:
        base_rows = df[df["Scenario ID"].astype(str) == "BASE_MODEL"]
        if not base_rows.empty:
            b = base_rows.iloc[0]
            metric_cols = ["Peak Subcatchment Runoff", "Peak Link Flow", "Maximum Link Velocity", "Maximum Modelled Depth Ratio", "Maximum Node Flooding", "Maximum Node Inflow"]
            flags = []
            for _, row in df.iterrows():
                if str(row.get("Scenario ID")) == "BASE_MODEL":
                    flags.append("Reference")
                    continue
                changed = False
                for col in metric_cols:
                    try:
                        if abs(float(row.get(col, 0) or 0) - float(b.get(col, 0) or 0)) > 1e-8:
                            changed = True
                            break
                    except Exception:
                        pass
                flags.append("Different" if changed else "No summary-level difference")
            df["Hydraulic Difference"] = flags
    return df


def deterministic_comparison_analysis(comparison: pd.DataFrame) -> str:
    """Create a conservative, deterministic scenario-comparison narrative."""
    if comparison is None or comparison.empty:
        return "No scenario comparison data are available."
    df = comparison.copy()
    base = df[df["Scenario ID"].astype(str) == "BASE_MODEL"]
    if base.empty:
        base = df.iloc[[0]]
    b = base.iloc[0]
    metrics = [
        ("Peak Subcatchment Runoff", "peak subcatchment runoff"),
        ("Peak Link Flow", "peak link flow"),
        ("Maximum Link Velocity", "maximum link velocity"),
        ("Maximum Modelled Depth Ratio", "maximum modelled depth ratio"),
        ("Maximum Node Flooding", "maximum node flooding"),
        ("Maximum Node Inflow", "maximum node inflow"),
        ("Maximum Storage Depth", "maximum storage depth"),
        ("Maximum Storage Volume", "maximum storage volume"),
    ]
    lines = [
        "The comparison uses the current uploaded model as the reference case. Differences are calculated from deterministic simulation summaries and are intended for preliminary engineering review.",
        "",
    ]
    # Guard: if every base metric is zero while at least one scenario is
    # non-zero, the base record almost certainly did not carry usable
    # results (e.g. it was built from an empty or malformed results dict).
    # Deltas quoted against a zeroed base are misleading, so say so
    # explicitly instead of presenting "+X relative to base" as fact.
    def _metric_value(record, col):
        try:
            return abs(float(record.get(col, 0) or 0))
        except Exception:
            return 0.0
    base_all_zero = all(_metric_value(b, col) <= 1e-12 for col, _ in metrics)
    any_scenario_nonzero = any(
        _metric_value(row, col) > 1e-12
        for _, row in df[df["Scenario ID"].astype(str) != str(b.get("Scenario ID", "BASE_MODEL"))].iterrows()
        for col, _ in metrics
    )
    if base_all_zero and any_scenario_nonzero:
        lines.insert(0, (
            "CAUTION: All reference-case metrics are zero while scenario results are non-zero. "
            "The base record does not appear to contain usable simulation results; differences below "
            "are NOT valid deltas against the uploaded model. Re-simulate or rebuild the base record "
            "before relying on this comparison."
        ))
        lines.insert(1, "")
    scenarios = df[df["Scenario ID"].astype(str) != str(b.get("Scenario ID", "BASE_MODEL"))]
    if scenarios.empty:
        lines.append("No alternative scenario has been completed.")
        return "\n".join(lines)
    for _, row in scenarios.iterrows():
        sid = row.get("Scenario ID", "Scenario")
        name = row.get("Scenario Name", sid)
        lines.append(f"{name} ({sid}):")
        changes=[]
        no_conduits = int(row.get("Conduit Count", 0) or 0) == 0
        for col,label in metrics:
            if no_conduits and col in {"Maximum Link Velocity", "Maximum Modelled Depth Ratio"}:
                changes.append(f"- {label}: Not applicable — no conduits in model")
                continue
            try:
                bv=float(b.get(col,0) or 0); sv=float(row.get(col,0) or 0)
            except Exception:
                continue
            delta=sv-bv
            if abs(bv)>1e-12:
                pct=100*delta/abs(bv)
                changes.append(f"- {label}: {sv:.4g} ({delta:+.4g}; {pct:+.1f}% relative to base)")
            else:
                changes.append(f"- {label}: {sv:.4g} ({delta:+.4g} relative to base)")
        lines.extend(changes)
        if str(row.get("Hydraulic Difference", "")) == "No summary-level difference":
            lines.append("- No difference was detected in the reported summary metrics. Check the input-change status and detailed time series before treating this as a distinct hydraulic alternative.")
        if str(row.get("Input Changed", "")) == "No":
            lines.append("- The final scenario input is identical to the base-model input; identical results are expected.")
        if float(row.get("Maximum Node Flooding",0) or 0)>0:
            lines.append("- Modelled node flooding is present and requires review.")
        if abs(float(row.get("Runoff Error (%)",0) or 0))>1 or abs(float(row.get("Flow Error (%)",0) or 0))>1:
            lines.append("- Numerical continuity exceeds 1% for at least one reported balance and requires review.")
        lines.append("")
    lines.append("The comparison does not establish design adequacy or compliance. The responsible engineer must review model changes, storm sources, controlling elements, physical feasibility, and applicable project criteria.")
    return "\n".join(lines).strip()

def comparison_dataframe(records: Iterable[dict[str, Any]]) -> pd.DataFrame:
    rows = [r.get("summary", {}) for r in records]
    return pd.DataFrame(rows)


def build_scenario_package(records: Iterable[dict[str, Any]]) -> bytes:
    records = list(records)
    buffer = BytesIO()
    with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        comparison = comparison_dataframe(records)
        zf.writestr("scenario_comparison.csv", comparison.to_csv(index=False))
        manifest = []
        for rec in records:
            sid = safe_scenario_id(rec.get("definition", {}).get("scenario_id", "scenario"))
            base = f"scenarios/{sid}"
            editable_clone = rec.get("files", {}).get("editable_clone", b"")
            if editable_clone:
                zf.writestr(f"{base}/{sid}_editable_clone_source.inp", editable_clone)
            zf.writestr(f"{base}/{sid}.inp", rec.get("files", {}).get("inp", b""))
            zf.writestr(f"{base}/{sid}.rpt", rec.get("files", {}).get("rpt", b""))
            out_bytes = rec.get("files", {}).get("out", b"")
            if out_bytes:
                zf.writestr(f"{base}/{sid}.out", out_bytes)
            zf.writestr(f"{base}/scenario_definition.json", json.dumps(rec.get("definition", {}), indent=2, default=str))
            zf.writestr(f"{base}/scenario_manifest.json", json.dumps(rec.get("manifest", {}), indent=2, default=str))
            manifest.append(rec.get("manifest", {}))
        zf.writestr("scenario_register.json", json.dumps(manifest, indent=2, default=str))
        zf.writestr("README.txt", "Rev22.1 preliminary scenario package. Editable clone source files are preserved separately from final scenario input files. Generated storms and parameter changes require professional verification before design use or municipal submission.\n")
    return buffer.getvalue()