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
42 changes: 34 additions & 8 deletions docs/studio/OPEN_QUESTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
33 changes: 33 additions & 0 deletions docs/studio/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions docs/studio/plan/PHASE_1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions studio/modelio/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion studio/modelio/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions studio/resolve/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand Down
57 changes: 52 additions & 5 deletions studio/schema/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions studio/schema/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
),
),
),
Expand Down
Loading
Loading