Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions api/config/storms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- "impact" — curated floodplain polygon (impact_geometry) + bbox envelope
- "none" — no flood-bound overlay (e.g. summer control)

volume_compare:
- true — included in History Event volume · compare (lake-owned membership)
- volume_compare_rank — display order (ascending; Chandra = 1)

impact_geometry is a GeoJSON FeatureCollection (hand-curated v0).
impact_bbox remains the envelope for clients that only clip by bbox.
"""
Expand All @@ -31,6 +35,8 @@
"severity": "high",
"impact_summary": "Muchelney cut off for weeks; A361 approaches flooded.",
"bounds_mode": "impact",
"volume_compare": True,
"volume_compare_rank": 3,
"notes": (
"Somerset Levels winter flooding; Muchelney isolation peaked mid-February. "
"as_of is the mid-event evaluation instant (not early January onset)."
Expand Down Expand Up @@ -76,6 +82,8 @@
"severity": "high",
"impact_summary": "Named storm; Parrett corridor under pressure before Dennis.",
"bounds_mode": "impact",
"volume_compare": True,
"volume_compare_rank": 4,
"notes": "Storm Ciara weekend; Dennis followed a week later.",
},
{
Expand All @@ -90,6 +98,8 @@
"severity": "high",
"impact_summary": "Named-storm peak; corridor hindcast golden eval.",
"bounds_mode": "impact",
"volume_compare": True,
"volume_compare_rank": 2,
"notes": "Named storm window used for golden analogue eval.",
},
{
Expand Down Expand Up @@ -121,6 +131,8 @@
"impassable; major incident 27 Jan–18 Feb after Storm Chandra on saturated Levels."
),
"bounds_mode": "impact",
"volume_compare": True,
"volume_compare_rank": 1,
"notes": (
"Product impetus event. Met Office named Storm Chandra (UK impact 26–27 Jan 2026) "
"inside a longer wet spell; Somerset Council closed the A361 from ~25 Jan with "
Expand Down Expand Up @@ -149,6 +161,13 @@
def _enrich_storm(raw: Dict[str, Any]) -> Dict[str, Any]:
storm = dict(raw)
storm_id = str(storm["id"])
storm["volume_compare"] = bool(storm.get("volume_compare"))
if storm["volume_compare"]:
rank = storm.get("volume_compare_rank")
storm["volume_compare_rank"] = int(rank) if rank is not None else 99
else:
storm.pop("volume_compare_rank", None)

evidence = warning_evidence_for(storm_id)
if evidence:
storm["warning_evidence"] = evidence
Expand Down Expand Up @@ -176,6 +195,22 @@ def list_storms(corridor: Optional[str] = None) -> List[Dict[str, Any]]:
return sorted(rows, key=lambda s: s.get("as_of") or "", reverse=True)


def list_volume_compare_storms(corridor: Optional[str] = None) -> List[Dict[str, Any]]:
"""Golden History volume-compare set — lake-owned membership and order."""
rows = [
s
for s in list_storms(corridor)
if s.get("volume_compare")
]
return sorted(
rows,
key=lambda s: (
int(s.get("volume_compare_rank") or 99),
str(s.get("as_of") or ""),
),
)


def get_storm(storm_id: str) -> Optional[Dict[str, Any]]:
for storm in STORMS:
if storm["id"] == storm_id:
Expand Down
8 changes: 7 additions & 1 deletion api/routes/predictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from api.config.corridors import list_corridor_ids
from api.config.hipims import get_hipims_pilot, list_hipims_pilot_ids
from api.config.place_bboxes import get_place_bbox, list_place_ids
from api.config.storms import get_storm, list_storms
from api.config.storms import get_storm, list_storms, list_volume_compare_storms
from api.services.predictions import predict_corridor
from api.services.volume import list_dtm_tiles, resolve_dtm_resolution

Expand Down Expand Up @@ -76,6 +76,10 @@ def get_prediction_corridors() -> Dict[str, Any]:
@router.get("/v1/storms")
def get_storms(
corridor: Optional[str] = Query(None, description="Filter by corridor id"),
volume_compare: Optional[bool] = Query(
None,
description="When true, return only lake-curated History volume-compare storms (ordered)",
),
) -> Dict[str, Any]:
if corridor and corridor not in list_corridor_ids():
raise HTTPException(
Expand All @@ -86,6 +90,8 @@ def get_storms(
"known": list_corridor_ids(),
},
)
if volume_compare is True:
return {"storms": list_volume_compare_storms(corridor)}
return {"storms": list_storms(corridor)}


Expand Down
1 change: 1 addition & 0 deletions docs/accuracy-ladder.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Use this when adding a place or storm:
- [ ] Flood Watch History mounts Event volume panel; Live/Transport do not
- [ ] Panel shows Low confidence / gauge-rise or percentile method (no mock fill)
- [ ] History compare table lists Chandra / Dennis / 2014 / Ciara volume metrics
- [ ] Compare membership is lake-owned (`volume_compare` on storms / `?volume_compare=true`)
- [ ] Map draws A361 depth strip from `road.samples` (lng/lat/depthM) in History

### Historic warnings (AfA435)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_volume_compare_storms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Tests for lake-owned History volume-compare storm membership."""

from __future__ import annotations

import unittest

from api.config.storms import get_storm, list_volume_compare_storms
from fastapi.testclient import TestClient

from api.main import app


class VolumeCompareStormsTests(unittest.TestCase):
def test_golden_four_flagged_and_ordered(self):
rows = list_volume_compare_storms("a361-muchelney")
ids = [s["id"] for s in rows]
self.assertEqual(
ids,
[
"place-2026-01-chandra-levels",
"eval-2020-02",
"eval-2014-01",
"place-2020-02-ciara",
],
)
for storm in rows:
self.assertTrue(storm["volume_compare"])
self.assertIsInstance(storm["volume_compare_rank"], int)

def test_control_and_wet_spells_excluded(self):
self.assertFalse(get_storm("eval-stable-summer")["volume_compare"])
self.assertFalse(get_storm("place-2019-11")["volume_compare"])
self.assertNotIn(
"place-2014-01-onset",
[s["id"] for s in list_volume_compare_storms("a361-muchelney")],
)

def test_storms_endpoint_volume_compare_filter(self):
client = TestClient(app)
res = client.get(
"/v1/storms",
params={"corridor": "a361-muchelney", "volume_compare": "true"},
)
self.assertEqual(res.status_code, 200)
body = res.json()
ids = [s["id"] for s in body["storms"]]
self.assertEqual(ids[0], "place-2026-01-chandra-levels")
self.assertEqual(len(ids), 4)
self.assertTrue(all(s.get("volume_compare") for s in body["storms"]))


if __name__ == "__main__":
unittest.main()
Loading