From bfc4225f72b86b69d34560282599a7cee3e85ec0 Mon Sep 17 00:00:00 2001 From: Ali Akherati Date: Tue, 18 Aug 2026 09:33:44 -0700 Subject: [PATCH] feat(studio): the release date is a month and a day, not a day of year (SCIENCE-1) SCIENCE-1 is answered: the climatology is a ZONAL-MEAN, MONTHLY average, selected by the month of the release. That decides the shape of the date, so the two land together. A monthly climatology is an average over years, so there is no year to give -- accepting one would imply the run used that year's weather. schedule.month and schedule.day_of_month are entered; day_of_year is DERIVED on a fixed non-leap calendar. Entering both would allow a config whose month and day disagree: the climatology read at one date and the sun at another. studio/science/calendar.py owns the convention. Non-leap is a declaration, not an oversight: the same date in a leap year is day 173 and the solar declination differs by about 0.01 degrees, far below the uncertainty in anything it feeds, and the fixed calendar buys a config that means one thing forever. The test pins the value that ties it to the archive -- 21 June is day 172, the ensemble's number, now derived rather than typed. 29 and 31 February are refused rather than clamped, in the schema as well as in the derivation, so the error names the fields the user typed. Verified in the browser: 21 June -> 172, 21 December -> 355, 1 January -> 1, 30 February -> 422 with the calendar's own message. The SZA panel follows: 13.74 h of daylight in June against 9.93 h in December at 30N, with a December minimum SZA of 53.42 degrees against the 30 + 23.44 = 53.44 the geometry demands. The model seam refuses an unresolved day_of_year the same way it refuses an unresolved concentration; a raw config reaching the model would pick a declination of its own. Docs: SCIENCE-1 marked answered with its consequences, task 1.1 unblocked in PHASE_1.md (still needs a source decision -- and BLOCKING-5's note that reanalysis stratospheric water vapour is biased dry suggests H2O may want a different source from p and T). 346 Python Tier-A (+19). Hash moved again within the unmerged 0.3.0 (...6d3a73 -> ...7813ae): the default run is the same 21 June, described differently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EaDHnPdiacsybr8Tp5WnqR --- docs/studio/OPEN_QUESTIONS.md | 42 ++++++-- docs/studio/PROGRESS.md | 33 ++++++ docs/studio/plan/PHASE_1.md | 15 ++- studio/modelio/preview.py | 13 ++- studio/modelio/scenario.py | 10 +- studio/resolve/registry.py | 14 +++ studio/schema/config.py | 57 ++++++++++- studio/schema/layout.py | 11 +- studio/science/calendar.py | 112 ++++++++++++++++++++ studio/tests/unit/test_calendar.py | 119 ++++++++++++++++++++++ studio/tests/unit/test_config_hash.py | 7 +- studio/tests/unit/test_preview.py | 6 +- studio/tests/unit/test_resolve_graph.py | 12 +++ studio/tests/unit/test_runset.py | 6 +- studio/tests/unit/test_schema_metadata.py | 6 +- 15 files changed, 430 insertions(+), 33 deletions(-) create mode 100644 studio/science/calendar.py create mode 100644 studio/tests/unit/test_calendar.py diff --git a/docs/studio/OPEN_QUESTIONS.md b/docs/studio/OPEN_QUESTIONS.md index 6eb0026..03c303a 100644 --- a/docs/studio/OPEN_QUESTIONS.md +++ b/docs/studio/OPEN_QUESTIONS.md @@ -114,16 +114,42 @@ literature notes in `tomas-jax`. Phase 1 is genuinely new code. ## Science -### SCIENCE-1 — Climatology sampling convention · **OPEN** · blocks Phase 1 · [#53](https://github.com/reflective-org/SANDBOX/issues/53) +### SCIENCE-1 — Climatology sampling convention · **ANSWERED** (2026-08-18) · [#53](https://github.com/reflective-org/SANDBOX/issues/53) *Zonal mean vs. specific longitude; monthly climatology vs. daily vs. a specific reanalysis timestep.* -These give materially different tropopause heights and temperatures. Note that longitude and date are -required anyway for the solar zenith angle (`stratchem-jax/solar.py`, and `CoupledScenario.longitude` -/ `day_of_year` / `start_utc_hour`), so a longitude-resolved option should exist even if the default -is zonal-mean. - -The reduced climatology's dimensions depend on the answer, so decide before the preprocessing -pipeline is written, not after. +**Answered (Ali, 2026-08-18): monthly climatology**, selected by the month of the release date. Not a +daily climatology and not a specific reanalysis timestep — so the product carries a **month** axis of +12, and the run's date picks the month rather than a date in a particular year. + +Consequences to hold onto: + +- **The year is meaningless to the meteorology.** A monthly climatology is an average over years, so + a config must not imply it is using the weather of a particular year. Either the date field stays + year-free, or a year is accepted and the schema states plainly that it affects nothing but the + calendar arithmetic. +- **Month must be derived, not entered twice.** The solar zenith angle needs the day of year + (`solar.py`), and the climatology needs the month; entering both invites a config whose month and + day disagree. One is primary and the other is derived — which is the dependency engine's job. +- **The day↔month mapping needs a stated convention**, because it depends on leap years. Day 172 is + 21 June in a non-leap year and 20 June in a leap year. Fixing a non-leap mapping keeps the + ensemble's `day_of_year = 172` reading as "late June" without introducing a year. + +**Answered (Ali, 2026-08-18): zonal-mean**, with a longitude-resolved product possible later. So the +reduced climatology is **(lat × month × level)** — of order 86k values per field, a few MB, small +enough to commit with a checksum rather than fetched from anywhere at run time. That is also how the +paper ensemble reasons: a latitude band, not a place. + +Longitude is still a required input, because the solar zenith angle needs it (`solar.py`); it simply +does not select the meteorology. The convention is recorded in the dataset identifier that goes into +provenance (ADR-006), so adding a longitude-resolved product later cannot silently reinterpret a +config made against this one. + +**Date entry follows from this** (decided in the same conversation): a calendar date **without a +year**, entered as month + day of month. `day_of_year` becomes DERIVED — the solar declination needs +it and the climatology needs the month, and entering both would allow a config whose month and day +disagree. A monthly climatology is an average over years, so accepting a year would imply we used a +particular year's weather, which we do not. The day↔month mapping uses a fixed **non-leap** calendar, +which keeps the ensemble's `day_of_year = 172` as 21 June. --- diff --git a/docs/studio/PROGRESS.md b/docs/studio/PROGRESS.md index 72d0968..1cc4b00 100644 --- a/docs/studio/PROGRESS.md +++ b/docs/studio/PROGRESS.md @@ -29,6 +29,39 @@ derivations to resolve rather than fixtures. --- +### 2026-08-18 — SCIENCE-1 answered, and the date became a date + +**Answered (Ali): zonal-mean, monthly climatology**, selected by the month of the release. So the +reduced product is `(lat × month × level)` — order 86k values per field, a few MB, small enough to +commit with a checksum instead of fetching at run time, and the same way the paper ensemble reasons +(a latitude band, not a place). Longitude remains a required input because the solar zenith angle +needs it; it just does not select the meteorology. The convention goes into the dataset identifier in +provenance, so a longitude-resolved product later cannot silently reinterpret a config made now. +**Task 1.1 is unblocked** — what it still needs is a source decision (ERA5 / MERRA-2 / MLS), and +BLOCKING-5's warning that reanalysis stratospheric water vapour is biased dry suggests H₂O may want a +different source from p and T. + +**The date follows from the answer.** A monthly climatology is an average over years, so there is no +year to give: `schedule.month` and `schedule.day_of_month` are entered and **`day_of_year` is +derived** on a fixed non-leap calendar. Entering both would allow a config whose month and day +disagree — the climatology read at one date and the sun at another. `studio/science/calendar.py` owns +the convention, and the test pins the value that ties it to the archive: **21 June is day 172**, the +ensemble's number, now derived rather than typed. + +Non-leap is a declaration, not an oversight. The same date in a leap year is day 173 and the solar +declination differs by ~0.01°, far below the uncertainty in anything it feeds; the fixed calendar buys +a config that means one thing forever. 29 and 31 February are refused rather than clamped, in the +schema (so the error names the fields typed) as well as in the derivation. + +Verified in the browser: 21 June → 172, 21 December → 355, 1 January → 1, 30 February → 422 with the +calendar's own message. The SZA panel follows: 13.74 h of daylight in June against 9.93 h in December +at 30°N, with a December minimum SZA of 53.42° against the 30 + 23.44 = 53.44° the geometry demands. + +The model seam now refuses an unresolved `day_of_year` the same way it refuses an unresolved +concentration — a raw config reaching the model would pick a solar declination of its own. + +--- + ### 2026-08-17 — Schema 0.3.0: the emission system has one degree of freedom Raised in use: *"I thought we give the emission rate and the speed of traveling, and by that we diff --git a/docs/studio/plan/PHASE_1.md b/docs/studio/plan/PHASE_1.md index 09ac265..576122f 100644 --- a/docs/studio/plan/PHASE_1.md +++ b/docs/studio/plan/PHASE_1.md @@ -14,7 +14,7 @@ panels or the comparison view. | # | Stage | Spec | Fields today | Blocked on | |---|---|---|---|---| -| 1 | Environment | §5.1 | 7 | **SCIENCE-1** — sampling convention, so no climatology-derived p/T or tropopause-relative altitude | +| 1 | Environment | §5.1 | 8 | SCIENCE-1 **answered** (zonal-mean monthly); the product itself is task 1.1 | | 2 | Plume volume and t = 0 | §5.2 | 3 + 1 derived | **SCIENCE-2** — engine exit vs post-vortex breakup changes V₀ by orders of magnitude | | 3 | Initial concentration | §5.3 | 1 + 1 derived | — | | 4 | Dilution | §5.4 | 6 | SCIENCE-5 answered: `background_evolves` fixed False | @@ -29,9 +29,16 @@ All 42 schema fields are placed and rendered; `test_layout.py` fails if a new on - **1.0 Wizard shell** — *done (2026-08-17)*. Layout manifest, generated form, override/stale actions, review with diff, submit. React + Vite + TS per ADR-007. -- **1.1 Climatology product** — reduced ERA5 (or MERRA-2) fields on a stratospheric subset, with - checksums recorded in provenance (ADR-006). **Blocked on SCIENCE-1**: the sampling convention - decides the product's dimensions, so building it first risks rebuilding it. +- **1.1 Climatology product** — *unblocked (2026-08-18).* SCIENCE-1 is answered: **zonal-mean, + monthly**, so the reduced product is `(lat × month × level)` — of order 86k values per field, a few + MB, small enough to **commit with a checksum** rather than fetch at run time. Longitude stays an + input for the solar zenith angle but does not select the meteorology, and the convention goes into + the dataset identifier recorded in provenance (ADR-006) so a longitude-resolved product added later + cannot reinterpret an existing config. Still needs a decision on the **source** (ERA5 vs MERRA-2 vs + MLS) — note BLOCKING-5's caveat that reanalysis stratospheric water vapour is biased dry, so H₂O + may want a different source from p and T. +- **1.0b Date entry** — *done (2026-08-18).* Month + day of month entered, `day_of_year` derived on a + fixed non-leap calendar. No year, because a monthly climatology is an average over years. - **1.2 Environment stage science** — `dataset`, `tropopause_definition`, `altitude_specification`, and p/T/H₂O as *derived, overridable* fields. The derivation raises until 1.1 exists (ADR-005 — no plausible substitute), and `dataset: USER` keeps today's direct entry working, so diff --git a/studio/modelio/preview.py b/studio/modelio/preview.py index 8d13741..e8288fb 100644 --- a/studio/modelio/preview.py +++ b/studio/modelio/preview.py @@ -83,18 +83,21 @@ def sza_diurnal(config: RunConfig) -> dict[str, Any]: release hour sits in that. """ solar = _solar() - latitude = config.site.latitude_deg - longitude = config.site.longitude_deg - day = config.schedule.day_of_year + # Resolved first: day_of_year is derived from the month and day (SCIENCE-1), so a raw config has + # it as None and the panel would draw a curve for a date nobody chose. + resolved = resolve(config).config + latitude = resolved.site.latitude_deg + longitude = resolved.site.longitude_deg + day = resolved.schedule.day_of_year hours = [24.0 * i / (_DIURNAL_POINTS - 1) for i in range(_DIURNAL_POINTS)] sza = [float(solar.solar_zenith_angle(latitude, longitude, day, h)) for h in hours] daylight = [h for h, z in zip(hours, sza, strict=True) if z < 90.0] return { "hours": hours, "sza_deg": sza, - "release_hour": config.schedule.start_utc_hour, + "release_hour": resolved.schedule.start_utc_hour, "release_sza_deg": float( - solar.solar_zenith_angle(latitude, longitude, day, config.schedule.start_utc_hour) + solar.solar_zenith_angle(latitude, longitude, day, resolved.schedule.start_utc_hour) ), "min_sza_deg": min(sza), # Counted from the sampled points rather than solved for: at ten-minute resolution this is diff --git a/studio/modelio/scenario.py b/studio/modelio/scenario.py index 97b1516..5cf3846 100644 --- a/studio/modelio/scenario.py +++ b/studio/modelio/scenario.py @@ -74,6 +74,14 @@ def to_scenario(config: RunConfig | ResolvedConfig) -> CoupledScenario: "start with no SO2." ) + day_of_year = run.schedule.day_of_year + if day_of_year is None: + raise ValueError( + "schedule.day_of_year is unresolved. It is a DERIVED field since the date became a " + "month and a day (SCIENCE-1): run the config through studio.resolve.resolve() before " + "converting it, rather than letting the model pick a solar declination of its own." + ) + concentrations: dict[str, float] = {**run.background.gas_pptv, "SO2": so2_pptv} dilution_background: dict[str, float] = { "SO2": run.background.so2_pptv, @@ -86,7 +94,7 @@ def to_scenario(config: RunConfig | ResolvedConfig) -> CoupledScenario: WTR=run.site.h2o_ppmv, latitude=run.site.latitude_deg, longitude=run.site.longitude_deg, - day_of_year=run.schedule.day_of_year, + day_of_year=day_of_year, start_utc_hour=run.schedule.start_utc_hour, days=run.schedule.duration_days, DT=run.numerics.output_dt_s, diff --git a/studio/resolve/registry.py b/studio/resolve/registry.py index a8941fd..6f72d97 100644 --- a/studio/resolve/registry.py +++ b/studio/resolve/registry.py @@ -28,6 +28,7 @@ initial_mixing_ratio_pptv, plume_volume_cm3, ) +from studio.science.calendar import day_of_year as calendar_day_of_year from studio.science.plume import ( duration_from_track, emission_duration_s, @@ -63,6 +64,14 @@ def compute(self, values: Mapping[str, Any]) -> Any: return self.fn({path: values[path] for path in self.inputs}) +def _day_of_year(values: Mapping[str, Any]) -> int: + """(month, day) -> day of year on the fixed non-leap calendar (SCIENCE-1).""" + return calendar_day_of_year( + month=values["schedule.month"], + day_of_month=values["schedule.day_of_month"], + ) + + def _emission_duration(values: Mapping[str, Any]) -> float: """The duration the run uses, from whichever quantity was entered. @@ -155,6 +164,11 @@ def _so2_initial_pptv(values: Mapping[str, Any]) -> float: #: Derived field path -> how to compute it. Completeness against the schema is enforced by test. DERIVATIONS: Final[dict[str, Derivation]] = { + "schedule.day_of_year": Derivation( + inputs=("schedule.month", "schedule.day_of_month"), + fn=_day_of_year, + summary="day of year on a fixed non-leap calendar (21 June is day 172)", + ), "injection.emission_duration_s": Derivation( inputs=( "injection.emission_input", diff --git a/studio/schema/config.py b/studio/schema/config.py index 9128a36..7b14b9f 100644 --- a/studio/schema/config.py +++ b/studio/schema/config.py @@ -159,16 +159,45 @@ class Site(SchemaModel): class Schedule(SchemaModel): """When the release happens and how long the box is integrated.""" - day_of_year: int = SciField( - default=172, + month: int = SciField( + default=6, unit=Unit.DIMENSIONLESS, ge=1, - le=366, - label="Day of year", - description="Day of year of the release; sets the solar declination.", + le=12, + label="Month", + description=( + "Month of the release. Selects the monthly climatology (SCIENCE-1: the reduced product " + "is a monthly average over years, so there is no year to give) and, with the day, sets " + "the solar declination." + ), provenance=Provenance.PAPER_ENSEMBLE, source="coupled/paper_ensemble/TABLE_microphysics_parameters.md (day 172, ~21 June)", ) + day_of_month: int = SciField( + default=21, + unit=Unit.DIMENSIONLESS, + ge=1, + le=31, + label="Day of month", + description=( + "Day of the month. Validated against the month on a fixed NON-LEAP calendar, so 31 " + "February is refused rather than clamped." + ), + provenance=Provenance.PAPER_ENSEMBLE, + source="coupled/paper_ensemble/TABLE_microphysics_parameters.md (day 172, ~21 June)", + ) + day_of_year: int | None = SciField( + default=None, + unit=Unit.DIMENSIONLESS, + label="Day of year", + description=( + "Day of year, on a fixed non-leap calendar: what the solar declination is computed " + "from. Derived rather than entered, so it cannot disagree with the month the " + "climatology is read at. 21 June is day 172, which is the ensemble's value." + ), + provenance=Provenance.DERIVED, + derived_from=["schedule.month", "schedule.day_of_month"], + ) start_utc_hour: float = SciField( default=0.0, unit=Unit.HOUR, @@ -197,6 +226,24 @@ class Schedule(SchemaModel): examples=[10, 60], ) + @model_validator(mode="after") + def _the_date_must_exist(self) -> Schedule: + """Refuse 31 February at validation, not in the derivation. + + The derivation would catch it too, but its error names a calendar function; this one names + the two fields the user typed. Same reasoning as the DT/dt_couple rule elsewhere in this + module: validate where the message can be useful. + """ + from studio.science.calendar import days_in_month + + length = days_in_month(self.month) + if self.day_of_month > length: + raise ValueError( + f"month {self.month} has {length} days on the non-leap calendar (SCIENCE-1), so " + f"day_of_month={self.day_of_month} is not a date" + ) + return self + class Injection(SchemaModel): """What is released, and into what volume. diff --git a/studio/schema/layout.py b/studio/schema/layout.py index 95c318f..a93ee6c 100644 --- a/studio/schema/layout.py +++ b/studio/schema/layout.py @@ -99,8 +99,15 @@ class Stage: ), Section( title="Date and time", - fields=("schedule.day_of_year", "schedule.start_utc_hour"), - note="Sets the solar zenith angle, and so the photolysis rates.", + fields=( + "schedule.month", + "schedule.day_of_month", + "schedule.day_of_year", + "schedule.start_utc_hour", + ), + note="A date without a year: the climatology is a monthly average over years " + "(SCIENCE-1), so there is no year to give. The month selects the climatology; the " + "day of year, derived on a non-leap calendar, sets the solar declination.", ), ), ), diff --git a/studio/science/calendar.py b/studio/science/calendar.py new file mode 100644 index 0000000..e60f057 --- /dev/null +++ b/studio/science/calendar.py @@ -0,0 +1,112 @@ +# Copyright (C) 2026 University Corporation for Atmospheric Research +# SPDX-License-Identifier: Apache-2.0 +"""Calendar arithmetic, on a fixed non-leap year. + +Not physics, but it belongs here for the same reason the physics does: it is a **convention** that +several things depend on, and a second copy of it would eventually disagree with the first. + +**Why non-leap.** SCIENCE-1 settled the climatology as a monthly average over years, so a run has no +year -- a date is a month and a day, and February has 28 days by declaration. The alternative, +accepting a year, would imply the meteorology came from that year's weather, which it does not. + +The consequence is stated rather than hidden: on this calendar 21 June is day 172, which is what the +paper ensemble uses (``TABLE_microphysics_parameters.md``: "day 172, ~21 June"). In a leap year the +same date is day 173, and the solar declination differs by about 0.01 degrees -- far below the +uncertainty in anything the declination feeds. Choosing the fixed calendar buys a config that means +one thing forever; the cost is that arithmetic. +""" + +from __future__ import annotations + +from typing import Final + +#: Days in each month of a non-leap year, January first. +DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + +#: Day of year before each month starts, non-leap: ``CUMULATIVE_DAYS[5] == 151``, so 1 June is +#: day 152 and 21 June is day 172 -- the ensemble's value. +CUMULATIVE_DAYS: Final[tuple[int, ...]] = tuple(sum(DAYS_IN_MONTH[:index]) for index in range(12)) + +#: Month names, for labels and error messages. Index 0 is January. +MONTH_NAMES: Final[tuple[str, ...]] = ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +) + +#: Days in a non-leap year. Named so the 365 in a range check is not a bare literal. +DAYS_IN_YEAR: Final[int] = 365 + + +def days_in_month(month: int) -> int: + """Length of ``month`` (1-12) on the non-leap calendar. + + Raises: + ValueError: If the month is out of range, rather than indexing past the end of the tuple. + """ + if not 1 <= month <= 12: + raise ValueError(f"month must be 1-12, got {month}") + return DAYS_IN_MONTH[month - 1] + + +def day_of_year(*, month: int, day_of_month: int) -> int: + """Day of year for a (month, day) pair on the non-leap calendar. + + Keyword-only: ``day_of_year(6, 21)`` and ``day_of_year(21, 6)`` are both plausible-looking calls + and only one is right, which is exactly the mistake worth making impossible. + + Raises: + ValueError: If the day does not exist in that month. 31 February is a typo, not a date, + and clamping it to the 28th would run a day the user did not choose. + """ + length = days_in_month(month) + if not 1 <= day_of_month <= length: + raise ValueError( + f"{MONTH_NAMES[month - 1]} has {length} days on the non-leap calendar, " + f"so day {day_of_month} does not exist" + ) + return CUMULATIVE_DAYS[month - 1] + day_of_month + + +def month_and_day(day: int) -> tuple[int, int]: + """The inverse: (month, day of month) for a day of year. + + For labels, and for the round-trip test that pins the mapping. + """ + if not 1 <= day <= DAYS_IN_YEAR: + raise ValueError( + f"day of year must be 1-{DAYS_IN_YEAR} on the non-leap calendar, got {day}" + ) + for index in range(11, -1, -1): + if day > CUMULATIVE_DAYS[index]: + return index + 1, day - CUMULATIVE_DAYS[index] + raise AssertionError( # pragma: no cover + "unreachable: day >= 1 always exceeds CUMULATIVE_DAYS[0] == 0" + ) + + +def describe(*, month: int, day_of_month: int) -> str: + """A human date, e.g. ``21 June``. No year, because the climatology has none.""" + days_in_month(month) # validates the month before it is used as an index + return f"{day_of_month} {MONTH_NAMES[month - 1]}" + + +__all__ = [ + "CUMULATIVE_DAYS", + "DAYS_IN_MONTH", + "DAYS_IN_YEAR", + "MONTH_NAMES", + "day_of_year", + "days_in_month", + "describe", + "month_and_day", +] diff --git a/studio/tests/unit/test_calendar.py b/studio/tests/unit/test_calendar.py new file mode 100644 index 0000000..959f9e5 --- /dev/null +++ b/studio/tests/unit/test_calendar.py @@ -0,0 +1,119 @@ +# Copyright (C) 2026 University Corporation for Atmospheric Research +# SPDX-License-Identifier: Apache-2.0 +"""The date: a month and a day on a fixed non-leap calendar (SCIENCE-1). + +SCIENCE-1 settled the climatology as a **monthly average over years**, which decides the shape of +the date. There is no year to give -- accepting one would imply the run used that year's weather -- +so a date is a month and a day, and ``day_of_year`` is derived rather than entered. Entering both +would allow a config whose month and day disagree: the climatology read at one date, the sun at +another. + +The calendar is non-leap by declaration. That is a convention, so it is pinned here rather than +trusted: on it, 21 June is day 172, which is exactly what the paper ensemble uses. +""" + +from __future__ import annotations + +import pytest + +from studio.resolve import resolve +from studio.schema import RunConfig +from studio.science.calendar import ( + CUMULATIVE_DAYS, + DAYS_IN_MONTH, + DAYS_IN_YEAR, + day_of_year, + days_in_month, + describe, + month_and_day, +) + + +@pytest.mark.tier_a +def test_the_calendar_is_a_non_leap_year() -> None: + """365 days, February 28. The convention itself, asserted.""" + assert sum(DAYS_IN_MONTH) == DAYS_IN_YEAR == 365 + assert days_in_month(2) == 28 + assert CUMULATIVE_DAYS[0] == 0, "nothing precedes January" + + +@pytest.mark.tier_a +def test_21_june_is_day_172_as_the_ensemble_has_it() -> None: + """The one value that ties this convention to the archived runs.""" + assert day_of_year(month=6, day_of_month=21) == 172 + + +@pytest.mark.tier_a +def test_the_year_is_covered_end_to_end() -> None: + assert day_of_year(month=1, day_of_month=1) == 1 + assert day_of_year(month=12, day_of_month=31) == DAYS_IN_YEAR + + +@pytest.mark.tier_a +def test_every_day_round_trips() -> None: + """The mapping and its inverse agree for all 365 days, not just the ones anyone tried.""" + for day in range(1, DAYS_IN_YEAR + 1): + month, day_of_month = month_and_day(day) + assert day_of_year(month=month, day_of_month=day_of_month) == day + + +@pytest.mark.tier_a +@pytest.mark.parametrize( + ("month", "day"), + [(2, 29), (2, 31), (4, 31), (6, 31), (9, 31), (11, 31)], +) +def test_a_date_that_does_not_exist_is_refused(month: int, day: int) -> None: + """31 February is a typo, not a date. + + Clamping it to the 28th would run a day the user did not choose, and 29 February is refused for + the same reason the calendar is non-leap: there is no year in which to have one. + """ + with pytest.raises(ValueError, match="does not exist"): + day_of_year(month=month, day_of_month=day) + + +@pytest.mark.tier_a +@pytest.mark.parametrize("month", [0, 13, -1, 99]) +def test_a_month_out_of_range_is_refused_not_wrapped(month: int) -> None: + """Indexing a tuple with 13 would raise something unhelpful; 0 would silently mean December.""" + with pytest.raises(ValueError, match="month must be 1-12"): + days_in_month(month) + + +@pytest.mark.tier_a +def test_describe_names_the_month_and_gives_no_year() -> None: + assert describe(month=6, day_of_month=21) == "21 June" + assert "2026" not in describe(month=6, day_of_month=21), "a monthly climatology has no year" + + +@pytest.mark.tier_a +def test_the_schema_derives_the_day_of_year_from_the_date() -> None: + """The default config still resolves to the ensemble's day 172, now via the date.""" + config = RunConfig() + assert (config.schedule.month, config.schedule.day_of_month) == (6, 21) + assert config.schedule.day_of_year is None, "derived, so unset until resolved" + assert resolve(config).config.schedule.day_of_year == 172 + + +@pytest.mark.tier_a +def test_the_schema_refuses_a_date_that_does_not_exist() -> None: + """Validated in the schema as well as the derivation, so the error names the fields typed.""" + with pytest.raises(ValueError, match="non-leap calendar"): + RunConfig.model_validate({"schedule": {"month": 2, "day_of_month": 30}}) + + +@pytest.mark.tier_a +def test_moving_the_date_moves_the_day_of_year() -> None: + """The wizard's claim: change the month, and what depends on it follows.""" + december = resolve(RunConfig.model_validate({"schedule": {"month": 12, "day_of_month": 21}})) + assert december.config.schedule.day_of_year == 355 + assert december.is_consistent + + +@pytest.mark.tier_a +def test_the_model_seam_refuses_an_unresolved_date(repo_root: object) -> None: + """A raw config must not reach the model: it would pick a solar declination of its own.""" + from studio.modelio.scenario import to_scenario + + with pytest.raises(ValueError, match=r"schedule\.day_of_year is unresolved|so2_initial_pptv"): + to_scenario(RunConfig()) diff --git a/studio/tests/unit/test_config_hash.py b/studio/tests/unit/test_config_hash.py index fde5069..bd13377 100644 --- a/studio/tests/unit/test_config_hash.py +++ b/studio/tests/unit/test_config_hash.py @@ -34,14 +34,17 @@ #: * ...46cbe3 -> ...373ab4 (0.2.0) when the temperature feedback was refused. The VALUE of #: heating_to_t did not change -- False either way -- but schema_version is part of the hashed #: payload, which is what makes "old configs are never silently reinterpreted" true. -#: * ...373ab4 -> the value below (0.3.0) when the emission system arrived: a selector for which +#: * ...6d3a73 -> the value below, still 0.3.0 and unmerged, when the release date became a month +#: and a day with day_of_year derived (SCIENCE-1: the climatology is a monthly average, so there +#: is no year, and the month must not be enterable twice). +#: * ...373ab4 -> ...6d3a73 (0.3.0) when the emission system arrived: a selector for which #: of rate, duration and length is given, the three entered values, the platform speed, and three #: derived quantities where there was one primary. The default RUN is #: unchanged -- same 1 t into the same 1.5e12 cm^3, and the initial mixing ratio is identical to #: the last bit -- but the config that describes it now has four more fields, so it is a different #: configuration and must hash differently. A schema that grew a field without moving the hash #: would be one where two different configs could share an identity. -GOLDEN_DEFAULT_HASH = "422d88bffeb7d44519a2977664f427be03f8ea221f8b748b9a271936226d3a73" +GOLDEN_DEFAULT_HASH = "a201086e2dc9039fe95b0d11bd184a7fccad39b5c9cbca83ee83c766957813ae" @pytest.mark.tier_a diff --git a/studio/tests/unit/test_preview.py b/studio/tests/unit/test_preview.py index 00c0658..0a66f40 100644 --- a/studio/tests/unit/test_preview.py +++ b/studio/tests/unit/test_preview.py @@ -144,7 +144,7 @@ def test_polar_night_is_reported_as_dark_rather_than_as_a_flat_line() -> None: """At 80 S in June the sun never rises, and the panel must say so.""" from studio.modelio.preview import sza_diurnal - polar = {"site": {"latitude_deg": -80.0}, "schedule": {"day_of_year": 172}} + polar = {"site": {"latitude_deg": -80.0}, "schedule": {"month": 6, "day_of_month": 21}} panel = sza_diurnal(RunConfig.model_validate(polar)) assert panel["sun_up"] is False assert panel["daylight_hours"] == 0.0 @@ -176,8 +176,8 @@ def test_the_curves_follow_the_config() -> None: short = dilution_curve(RunConfig.model_validate({"schedule": {"duration_days": 2}})) assert math.isclose(max(short["days"]), 2.0) - winter = sza_diurnal(RunConfig.model_validate({"schedule": {"day_of_year": 355}})) - summer = sza_diurnal(RunConfig.model_validate({"schedule": {"day_of_year": 172}})) + winter = sza_diurnal(RunConfig.model_validate({"schedule": {"month": 12, "day_of_month": 21}})) + summer = sza_diurnal(RunConfig.model_validate({"schedule": {"month": 6, "day_of_month": 21}})) assert winter["daylight_hours"] < summer["daylight_hours"], "30 N has shorter days in December" diff --git a/studio/tests/unit/test_resolve_graph.py b/studio/tests/unit/test_resolve_graph.py index 5d9677d..b3080a5 100644 --- a/studio/tests/unit/test_resolve_graph.py +++ b/studio/tests/unit/test_resolve_graph.py @@ -113,13 +113,25 @@ def test_the_schemas_derived_chain() -> None: # duration -> {rate, length} -> volume -> mixing ratio # Declared order would get this wrong at three separate steps, so the topological requirement is # load-bearing rather than illustrative. + # `schedule.day_of_year` depends on nothing in the emission chain, so where it lands among the + # ready nodes is decided by the sort, not by the physics -- so the ORDER assertions below are + # the ones that carry weight, and this tuple is only a snapshot of the whole set. assert schema_derived_fields() == ( "injection.emission_duration_s", + "schedule.day_of_year", "injection.emission_rate_kg_s", "injection.plume_length_m", "injection.plume_volume_cm3", "injection.so2_initial_pptv", ) + order = schema_derived_fields() + for earlier, later in ( + ("injection.emission_duration_s", "injection.emission_rate_kg_s"), + ("injection.emission_duration_s", "injection.plume_length_m"), + ("injection.plume_length_m", "injection.plume_volume_cm3"), + ("injection.plume_volume_cm3", "injection.so2_initial_pptv"), + ): + assert order.index(earlier) < order.index(later), f"{later} must resolve after {earlier}" assert "injection.plume_volume_cm3" in graph.dependencies_of("injection.so2_initial_pptv") assert graph.downstream_of(["injection.plume_length_m"]) == ( "injection.plume_volume_cm3", diff --git a/studio/tests/unit/test_runset.py b/studio/tests/unit/test_runset.py index c7ba508..26dc87c 100644 --- a/studio/tests/unit/test_runset.py +++ b/studio/tests/unit/test_runset.py @@ -15,6 +15,7 @@ import pytest +from studio.resolve import resolve from studio.schema import ( Axis, AxisKind, @@ -177,7 +178,10 @@ def test_the_golden_case_resolves_to_the_ensembles_values() -> None: # unswept values stay at the ensemble's fixed configuration assert config.microphysics.n_bins == 80 assert config.microphysics.ion_pair_rate == 30.0 - assert config.schedule.day_of_year == 172 + # 21 June on the non-leap calendar (SCIENCE-1). The ensemble's 172 is now derived from the + # entered date rather than typed, so this asserts the mapping as well as the value. + assert (config.schedule.month, config.schedule.day_of_month) == (6, 21) + assert resolve(config).config.schedule.day_of_year == 172 assert config.chemistry.so2_ho2_rate == 1e-18 assert config.switches.aerosol_to_j is False assert config.switches.heating_to_t is False diff --git a/studio/tests/unit/test_schema_metadata.py b/studio/tests/unit/test_schema_metadata.py index 18605db..8a8c482 100644 --- a/studio/tests/unit/test_schema_metadata.py +++ b/studio/tests/unit/test_schema_metadata.py @@ -145,7 +145,8 @@ def test_bounded_quantities_declare_their_range() -> None: "site.temperature_k", "site.pressure_mbar", "site.h2o_ppmv", - "schedule.day_of_year", + "schedule.month", + "schedule.day_of_month", "schedule.start_utc_hour", "schedule.duration_days", "injection.so2_mass_kg", @@ -168,7 +169,8 @@ def test_bounded_quantities_declare_their_range() -> None: ("site.latitude_deg", 91.0), ("site.temperature_k", 0.0), ("site.pressure_mbar", -1.0), - ("schedule.day_of_year", 367), + ("schedule.month", 13), + ("schedule.day_of_month", 32), ("schedule.start_utc_hour", 24.0), ("microphysics.condensation_alpha", 1.5), ("microphysics.nucleation_rate_scale", -1.0),