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),