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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com).

## [Unreleased]

## [0.26.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.26.0] - 2026-10-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> is the whole release.
version = "0.26.0"
version = "0.26.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 = [
Expand Down
49 changes: 39 additions & 10 deletions scripts/admit_thermal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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."""
Expand All @@ -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"],
Expand Down Expand Up @@ -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)")
Expand All @@ -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:
Expand All @@ -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):
Expand Down
34 changes: 34 additions & 0 deletions tests/test_admit_thermal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading