From 988e2633ab9a1d57d505abffe699a81d3987434c Mon Sep 17 00:00:00 2001 From: Patrick Kastner <1919773+kastnerp@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:43:16 -0400 Subject: [PATCH] feat(thermal): admit_thermal links the v6 wind case, takes --priority, and refuses an E3D that printed nothing Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++++++ pyproject.toml | 2 +- scripts/admit_thermal.py | 49 +++++++++++++++++++++++++++++-------- tests/test_admit_thermal.py | 34 +++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f6a90..e8d4a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com). ## [Unreleased] +## [0.25.1] - 2026-10-01 + +### Changed +- **`scripts/admit_thermal.py` links each thermal site to its v6 wind case.** The pending v4 + cases move to `cyl-1008/of12-v6` (respec), which leaves each site with a quarantined v4 case + pointing at a pending v6 one. The script now takes one wind case per site, the newest recipe's, + and leaves the moved ones out, so no site is proposed twice or linked to a parked case. +- `--priority` sets the thermal cases' lease order (default 200, after the wind cases at 50). + +### Fixed +- **An `E3D climate-index find` that printed nothing is an error, not "no station".** Before Eddy3D + #964 a station without statistics among the nearest 300 made `find` exit 1 with empty output, + and the script read that as no TMYx station for every site: a `--post` run would have admitted + nothing and said only "skipped". Exit 1 with `[]` is still "no station". + ## [0.25.0] - 2026-10-01 ### Added diff --git a/pyproject.toml b/pyproject.toml index c6660ff..08557a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "casebroker" # back from the installed distribution metadata and everything that reports a # version (the OpenAPI document, /healthz, the dashboard badge) goes through # there. Bumping it here and tagging v is the whole release. -version = "0.25.0" +version = "0.25.1" description = "Central case broker for the v2 real-city CFD campaign: one database of cases, many workers leasing the next one to simulate." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/admit_thermal.py b/scripts/admit_thermal.py index d7a171f..91f56e7 100644 --- a/scripts/admit_thermal.py +++ b/scripts/admit_thermal.py @@ -46,7 +46,8 @@ # Never posted again, and never produced beside RECIPE: a node on an older build still declares # one, so while a case of it is pending or leased that node keeps archiving it. WITHDRAWN = ("surf-1008/rad6R0P2-fft-v1",) -WIND_RECIPES = ("cyl-1008/of12-v5", "cyl-1008/of12-v4") +# Newest first: a site moved v4 -> v6 (POST /v1/cases/respec) holds both, and its wind case is the v6 one. +WIND_RECIPES = ("cyl-1008/of12-v6", "cyl-1008/of12-v5", "cyl-1008/of12-v4") PRIORITY = 200 BATCH = 25 STATIONS = 300 @@ -99,9 +100,13 @@ def find(lat: float, lon: float) -> list[dict[str, Any]]: # nearest were all EnergyPlus TMY/TMY2/TMY3 and IWEC files, with its TMYx further out. out = subprocess.run([e3d, "climate-index", "find", "--lat", repr(lat), "--lon", repr(lon), "--limit", str(STATIONS)], capture_output=True, text=True, timeout=300) - if out.returncode not in (0, 1): - raise RuntimeError(f"E3D climate-index find failed ({out.returncode}): {out.stderr.strip()[:300]}") - return json.loads(out.stdout or "[]") + # Exit 1 with "[]" is "no station"; exit 1 with NOTHING printed is E3D failing -- before + # Eddy3D #964 a station without statistics (NaN) among the 300 did exactly that, and reading + # it as "no station" skipped every site of the pilot without a word. + if out.returncode not in (0, 1) or not out.stdout.strip(): + raise RuntimeError(f"E3D climate-index find failed (exit {out.returncode}, nothing printed): " + f"{out.stderr.strip()[:300]}") + return json.loads(out.stdout) return find @@ -114,6 +119,27 @@ def e3d_catalogue(e3d: str) -> str | None: return None +def one_per_site(cases: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + """Each site's wind case: the one of the newest recipe in WIND_RECIPES. A site moved to a new + recipe keeps its old case in quarantine, pointing at the new one; linking the thermal case to + that, or proposing the site twice, would be wrong. Quarantined cases are left out for that reason.""" + best: dict[tuple[float, float], tuple[int, dict[str, Any]]] = {} + for c in cases: + if c.get("state") == "quarantined": + continue + spec = c.get("spec") or {} + if isinstance(spec, str): + spec = json.loads(spec) + if spec.get("lat") is None or spec.get("lon") is None: + continue + key = (round(float(spec["lat"]), 5), round(float(spec["lon"]), 5)) + recipe = c.get("recipe") + rank = WIND_RECIPES.index(recipe) if recipe in WIND_RECIPES else len(WIND_RECIPES) + if key not in best or rank < best[key][0]: + best[key] = (rank, c) + return [c for _, c in best.values()] + + def withdrawn_in_flight(cases: Iterable[dict[str, Any]]) -> dict[str, int]: """How many cases of each WITHDRAWN recipe are still pending or leased. A done or quarantined one is finished with: it stays its own training set and no node takes it.""" @@ -124,14 +150,15 @@ def withdrawn_in_flight(cases: Iterable[dict[str, Any]]) -> dict[str, int]: return dict(out) -def thermal_case(wind: dict[str, Any], station: dict[str, Any], catalogue: str | None, campaign: str) -> dict[str, Any]: +def thermal_case(wind: dict[str, Any], station: dict[str, Any], catalogue: str | None, campaign: str, + priority: int = PRIORITY) -> dict[str, Any]: spec = wind.get("spec") or {} if isinstance(spec, str): spec = json.loads(spec) return { "lat": spec["lat"], "lon": spec["lon"], "recipe": RECIPE, "city_cluster": wind["city_cluster"], "lcz": wind.get("lcz"), - "priority": PRIORITY, "labels": {"campaign": campaign}, + "priority": priority, "labels": {"campaign": campaign}, "spec": { "lcz": wind.get("lcz"), "wind_case": wind["case_id"], @@ -179,6 +206,8 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--e3d", required=True, help="the E3D executable whose climate catalogue picks the weather") ap.add_argument("--count", type=int, default=50) ap.add_argument("--campaign", default="thermal-pilot", help="the `campaign` label") + ap.add_argument("--priority", type=int, default=PRIORITY, + help=f"lease order, lower first (default {PRIORITY}: after the wind cases, which sit at 50)") ap.add_argument("--any-state", action="store_true", help="take wind sites in any state, not only finished ones (a thermal case never waits for its " "wind case; this is for a campaign with too few finished sites to sample)") @@ -203,8 +232,8 @@ def main(argv: list[str] | None = None) -> int: wanted = {} if a.any_state else {"state": "done"} # By case id and by recipe here as well: a broker from before `recipe` was a filter ignores # it and answers with every case, twice. - done = list({c["case_id"]: c for r in WIND_RECIPES for c in broker.cases(recipe=r, **wanted) - if c.get("recipe") == r and (a.any_state or c.get("state") == "done")}.values()) + done = one_per_site({c["case_id"]: c for r in WIND_RECIPES for c in broker.cases(recipe=r, **wanted) + if c.get("recipe") == r and (a.any_state or c.get("state") == "done")}.values()) already = [c for c in broker.cases(recipe=RECIPE) if c.get("recipe") == RECIPE] taken = set() for c in already: @@ -227,12 +256,12 @@ def main(argv: list[str] | None = None) -> int: if station is None: print(f" {wind['case_id']}: no TMYx station among the nearest {STATIONS}; skipped") continue - cases.append(thermal_case(wind, station, catalogue, a.campaign)) + cases.append(thermal_case(wind, station, catalogue, a.campaign, a.priority)) print(f" {wind['case_id']} {wind.get('lcz') or '?':>6} {wind['city_cluster']:<24} " f"{station['key']} ({station['distanceKm']:.1f} km)") if not a.post: - print(f"dry run: {len(cases)} would be posted under {RECIPE} at priority {PRIORITY}; pass --post") + print(f"dry run: {len(cases)} would be posted under {RECIPE} at priority {a.priority}; pass --post") return 0 added = 0 for i in range(0, len(cases), BATCH): diff --git a/tests/test_admit_thermal.py b/tests/test_admit_thermal.py index 89de6fa..f8da3ad 100644 --- a/tests/test_admit_thermal.py +++ b/tests/test_admit_thermal.py @@ -120,3 +120,37 @@ def test_a_finished_withdrawn_case_does_not_block_posting(monkeypatch): # with no finished wind site to pair, posts nothing and succeeds). held = [{"case_id": "t1", "recipe": V1, "state": "done", "spec": {}}] assert run_main(monkeypatch, held) == 0 + + +# -- the v6 move, and an E3D that fails ---------------------------------------------- + +def test_a_site_is_linked_to_its_newest_wind_case_and_a_moved_one_is_left_out(): + site = {"lat": 38.91964, "lon": 121.64291} + v4 = {"case_id": "v2-old", "recipe": "cyl-1008/of12-v4", "state": "quarantined", "spec": dict(site)} + v6 = {"case_id": "v2-new", "recipe": "cyl-1008/of12-v6", "state": "pending", "spec": dict(site)} + other = {"case_id": "v2-oth", "recipe": "cyl-1008/of12-v5", "state": "done", "spec": {"lat": 1.0, "lon": 2.0}} + assert sorted(c["case_id"] for c in admit.one_per_site([v4, v6, other])) == ["v2-new", "v2-oth"] + # A pending v4 next to a pending v6 of the same site: still one case, the v6 one. + v4p = dict(v4, state="pending") + assert [c["case_id"] for c in admit.one_per_site([v4p, v6])] == ["v2-new"] + + +def test_an_e3d_that_printed_nothing_is_an_error_not_no_station(monkeypatch): + import subprocess + answers = {"out": ""} + monkeypatch.setattr(subprocess, "run", lambda *a, **k: subprocess.CompletedProcess( + a[0], 1, stdout=answers["out"], stderr="error: .NET number values such as positive and negative infinity")) + find = admit.e3d_find("E3D") + try: + find(1.0, 2.0) + raise AssertionError("an E3D that crashed was read as no station") + except RuntimeError as e: + assert "nothing printed" in str(e) + answers["out"] = "[]" # exit 1 with an empty list IS no station + assert find(1.0, 2.0) == [] + + +def test_the_priority_is_the_callers(): + station = {"key": "k", "url": "u", "distanceKm": 1.0} + assert admit.thermal_case(wind(1, "LCZ1", "c"), station, None, "p")["priority"] == admit.PRIORITY + assert admit.thermal_case(wind(1, "LCZ1", "c"), station, None, "p", priority=40)["priority"] == 40 diff --git a/uv.lock b/uv.lock index 4eb7267..6b1aac7 100644 --- a/uv.lock +++ b/uv.lock @@ -60,7 +60,7 @@ wheels = [ [[package]] name = "casebroker" -version = "0.25.0" +version = "0.25.1" source = { editable = "." } dependencies = [ { name = "duckdb" },