diff --git a/docs/studio/PROGRESS.md b/docs/studio/PROGRESS.md index 1c33422..b43786a 100644 --- a/docs/studio/PROGRESS.md +++ b/docs/studio/PROGRESS.md @@ -29,6 +29,52 @@ derivations to resolve rather than fixtures. --- +### 2026-08-20 — Interactive dilution, full-profile hover, curated backgrounds, custom modes + +Four review requests in one message, all landed (schema 0.4.0 → 0.5.0): + +**The dilution tab is now the equation.** The model's two-piece form is displayed — `V/V₀ = t^0.8` +then `1585·exp(k·(t−10⁴)^1.5)` — with the selected regime's k beside it. The regimes are clickable +chips carrying their k values (introspected from `coupled/dilution.py`'s own segment tuples; a test +compares against the same tuples). Clicking a chip sets `dilution.regime` for the run; a "try k" box +draws any k in [1e-10, 1e-6] as a dashed gold curve using the model's own `_two_piece`/ +`_eval_segment` machinery — at a named regime's k the custom curve equals that regime's **bit for +bit**, the test that proves it is not a lookalike formula. Custom k is a picture, not a runnable +config, and says so. + +**The ERA5 profile hover reads the whole level** — p, z, T *and H₂O* — tracking the pointer along +the pressure axis (charts gained `hoverAxis="y"`). This also retired a latent defect: x-tracking +hover assumed sorted xs, which a temperature profile does not have. Log axes gained faint sub-decade +gridlines (2–9 per decade). + +**The background picker is curated: hidden is not removed.** Offered: SABR-220/310/330, aer_geo +(the geoengineered stratosphere), CUSTOM. Hidden: redcircles, cesm_g6, cesm_g6_amb — still *valid*, +because Tier B's archived cases use cesm_g6 and a schema that refused them would disconnect the +archive from its own configurations. `hidden_choices` in SciField metadata is what the generator +filters on; an archival value still displays, marked "(archival)". + +**CUSTOM is a bimodal lognormal**: (N₁, Dp₁, σ₁) and (N₂, Dp₂, σ₂) plus an explicit STP/ambient +basis (the bridge refuses a custom mode list without one — SCIENCE-3's per-dataset trap made a +required field). Defaults are SABR-220's mode with N₂ = 0, so custom starts citable and unimodal; +with untouched defaults it seeds the *same bins* as sabr_220, asserted bin-for-bin. **The seam +filters zero-N modes** — found by test: the model refuses N ≤ 0 outright, so "no second mode" is a +shorter mode list at the seam, not a zero entry. + +Also: the max-wall-time description now says what it means instead of citing "BLOCKING-4" bare — +raised in review as confusing, which it was. + +**Corrected in review, same day:** the campaign is **SABRE** (Stratospheric Aerosol processes, +Budget and Radiative Effects), not SABR — enum values are now `sabre_*` and the seam translates to +the model's internal `sabr_*` keys, which are the model's to rename, not the seam's. And the +explorer's error handling had a trap: a rejected k replaced the panel body, taking the input that +would fix it — unrecoverable without a reload. Errors now render above panel content, and k is +validated against the served bounds *before* any request, with the message inline at the input. + +371 Python Tier-A, 60 vitest. Hash …d35ba6 → …2c09c6 → …d1656e (0.5.0; the SABRE respelling moved +it again pre-merge). + +--- + ### 2026-08-19 — The Reflective redesign Requested in review: cleaner, more intuitive controls; explanations behind a hoverable question diff --git a/studio/api/app.py b/studio/api/app.py index 19a2a79..7271753 100644 --- a/studio/api/app.py +++ b/studio/api/app.py @@ -86,6 +86,18 @@ class ResolveRequest(BaseModel): overrides: dict[str, OverrideRecord] = Field(default_factory=dict) +class PreviewRequest(BaseModel): + """A config plus panel-specific parameters (e.g. the dilution explorer's ``explore_k``). + + Parameters are exploration inputs for a PICTURE, never part of the configuration -- they do not + join the hash, the overrides, or anything persistent, which is why this is a separate model + rather than a field on ``ResolveRequest``. + """ + + config: dict[str, Any] = Field(default_factory=dict) + params: dict[str, Any] = Field(default_factory=dict) + + class ChangeRequest(ResolveRequest): """A single edit. ``value`` is whatever the field's type accepts, validated by the schema.""" @@ -228,7 +240,7 @@ def keep_field(request: FieldRequest) -> dict[str, Any]: raise HTTPException(status_code=404, detail=f"no such field: {exc}") from exc @app.post("/api/preview/{panel}") - def preview(panel: str, request: ResolveRequest) -> dict[str, Any]: + def preview(panel: str, request: PreviewRequest) -> dict[str, Any]: """A stage's preview panel: what this configuration implies, before spending compute. Spec section 8 -- sub-second, and never the full model. Every curve is the model's own @@ -251,6 +263,12 @@ def preview(panel: str, request: ResolveRequest) -> dict[str, Any]: except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc try: + # Builders that take panel parameters (the dilution explorer's k) receive them; the + # rest keep their one-argument signature rather than all growing an unused parameter. + import inspect + + if "params" in inspect.signature(builder).parameters: + return builder(config, request.params) return builder(config) except NotImplementedError as exc: # An unimplemented derivation must say so rather than draw a plausible curve (ADR-005). diff --git a/studio/api/static/index.html b/studio/api/static/index.html index d6632f2..419e049 100644 --- a/studio/api/static/index.html +++ b/studio/api/static/index.html @@ -125,8 +125,8 @@

Configure

diff --git a/studio/modelio/preview.py b/studio/modelio/preview.py index 3552985..0c959ac 100644 --- a/studio/modelio/preview.py +++ b/studio/modelio/preview.py @@ -36,6 +36,7 @@ import math import os import sys +from collections.abc import Callable, Mapping from typing import Any import numpy as np @@ -107,11 +108,56 @@ def sza_diurnal(config: RunConfig) -> dict[str, Any]: } -def dilution_curve(config: RunConfig) -> dict[str, Any]: +def _two_piece_k(segments: Any) -> float | None: + """The Kz coefficient of a two-piece regime, read out of the model's own segment tuples. + + Introspected rather than re-typed, so a change to ``coupled/dilution.py`` cannot leave this + module quoting constants the model no longer uses. ``None`` for regimes that are not the + two-piece form (burst's four segments have three k values, not one). + """ + if len(segments) != 2: + return None + _, (kind, _amplitude, k, _t0, _q) = segments[1] + return float(k) if kind == "exp" else None + + +def _custom_two_piece_curve(seconds: np.ndarray, k: float) -> np.ndarray: + """V(t)/V0 for the two-piece form at an arbitrary Kz coefficient ``k``. + + Built from the model's OWN pieces -- ``dilution._two_piece`` supplies the segments (so the + 1585 continuity prefactor, the t^0.8 early phase and the 1.5 exponent are all the model's) and + ``dilution._eval_segment`` evaluates them. Only the piecewise dispatch below is repeated from + ``volume_ratio``, which takes a regime NAME and so cannot be called with a custom k. + + Exploration only: the schema runs named regimes (or a constant rate), so a custom k here is a + picture of the family, never a runnable configuration -- the panel says so. + """ + from coupled import dilution + + time = np.maximum(np.asarray(seconds, dtype=float), 0.0) + conditions, values, t_start = [], [], 0.0 + for t_end, segment in dilution._two_piece(k): + conditions.append((time >= t_start) & (time < t_end)) + values.append(dilution._eval_segment(time, segment)) + t_start = t_end + return np.select(conditions, values, default=values[-1]) + + +#: Bounds for the exploration k [s^-1.5]. An order of magnitude beyond the named regimes each way: +#: wide enough to see the family's behaviour, narrow enough that exp(k * t^1.5) stays finite. +_EXPLORE_K_MIN = 1e-10 +_EXPLORE_K_MAX = 1e-6 + + +def dilution_curve(config: RunConfig, params: Mapping[str, Any] | None = None) -> dict[str, Any]: """V(t)/V0 for the configured regime, with every other regime for comparison (stage 4). The others are drawn because the choice between them is the point of the stage: D2 against D3 is a factor of three in dilution rate, far easier to judge as two curves than as two names. + + ``params["explore_k"]`` adds one more curve at an arbitrary Kz coefficient -- the interactive + "turn the constant and watch the family move" the panel offers. The equation and each regime's + k are reported so the panel can show WHAT the constant is, not just that there is one. """ from coupled import dilution @@ -119,17 +165,32 @@ def dilution_curve(config: RunConfig) -> dict[str, Any]: seconds = np.linspace(0.0, days * 86400.0, _CURVE_POINTS) regimes = {} for name in dilution.DILUTION_REGIMES: + label, segments = dilution.DILUTION_REGIMES[name] ratio = np.asarray(dilution.volume_ratio(seconds, name), dtype=float) regimes[name] = { - "label": str(dilution.DILUTION_REGIMES[name][0]), + "label": str(label), "volume_ratio": [float(v) for v in ratio], "final": float(ratio[-1]), + "k": _two_piece_k(segments), } selected = ( config.dilution.regime.value if hasattr(config.dilution.regime, "value") else str(config.dilution.regime) ) + + custom = None + explore_k = (params or {}).get("explore_k") + if explore_k is not None: + k = float(explore_k) + if not _EXPLORE_K_MIN <= k <= _EXPLORE_K_MAX: + raise ValueError( + f"explore_k must be within [{_EXPLORE_K_MIN:g}, {_EXPLORE_K_MAX:g}] s^-1.5, " + f"got {k:g} -- an order of magnitude beyond the named regimes each way" + ) + ratio = _custom_two_piece_curve(seconds, k) + custom = {"k": k, "volume_ratio": [float(v) for v in ratio], "final": float(ratio[-1])} + return { "hours": [float(s) / 3600.0 for s in seconds], "days": [float(s) / 86400.0 for s in seconds], @@ -139,6 +200,16 @@ def dilution_curve(config: RunConfig) -> dict[str, Any]: # rather than highlighting a curve that will not be used. "uses_curve": selected != "constant", "constant_rate_per_s": config.dilution.rate_per_s, + "custom": custom, + # The model's own two-piece form (coupled/dilution.py). The prefactor 1585 = (1e4)^0.8 + # makes the pieces continuous at the break. + "equation": { + "early": "V/V\u2080 = t^0.8 (t \u2264 10\u2074 s)", + "late": "V/V\u2080 = 1585 \u00b7 exp(k \u00b7 (t \u2212 10\u2074)^1.5)", + "k_unit": "s^-1.5", + "k_min": _EXPLORE_K_MIN, + "k_max": _EXPLORE_K_MAX, + }, } @@ -301,8 +372,9 @@ def climatology_profile(config: RunConfig) -> dict[str, Any]: #: Panel name -> builder. The API exposes exactly these, so a typo in a panel name is a 404 naming -#: the ones that exist rather than an empty chart. -PANELS = { +#: the ones that exist rather than an empty chart. ``Callable[..., ...]`` because one builder (the +#: dilution explorer) takes panel parameters and the rest do not. +PANELS: dict[str, Callable[..., dict[str, Any]]] = { "sza": sza_diurnal, "climatology": climatology_profile, "dilution": dilution_curve, diff --git a/studio/modelio/scenario.py b/studio/modelio/scenario.py index a021473..b5f6427 100644 --- a/studio/modelio/scenario.py +++ b/studio/modelio/scenario.py @@ -32,6 +32,7 @@ from coupled.coupled_scenario import CoupledScenario, Switches from studio.resolve import ResolvedConfig from studio.schema import DilutionRegime, RunConfig +from studio.schema.enums import BackgroundAerosol #: Schema regime -> the model's ``dilution_regime`` string. Only CONSTANT differs; the rest are #: identical by construction, and the test asserts that rather than trusting it. @@ -45,6 +46,49 @@ } +#: Studio's SABRE values -> the bridge's internal keys. The campaign is SABRE (Stratospheric +#: Aerosol processes, Budget and Radiative Effects); the model's BACKGROUND_MODES keys predate the +#: correction and stay as they are -- renaming a model-internal key is not the seam's call. +#: Values absent here pass through unchanged. +_BACKGROUND_KEYS: dict[str, str] = { + "sabre_330": "sabr_330", + "sabre_310": "sabr_310", + "sabre_220": "sabr_220", +} + + +def _custom_modes(run: RunConfig) -> tuple[tuple[float, float, float], ...]: + """The CUSTOM background's modes, zero-N entries dropped, at least one remaining. + + Raises: + ValueError: If every mode has N = 0. "A custom background with no particles" is more + likely a half-edited form than an intention, and the model would refuse the empty + list anyway -- this message names the fields instead of the bridge internals. + """ + modes = tuple( + (n, dg, sigma) + for n, dg, sigma in ( + ( + run.background.custom_n1_cm3, + run.background.custom_dg1_um, + run.background.custom_sigma1, + ), + ( + run.background.custom_n2_cm3, + run.background.custom_dg2_um, + run.background.custom_sigma2, + ), + ) + if n > 0.0 + ) + if not modes: + raise ValueError( + "background.aerosol is CUSTOM but both modes have N = 0; give custom_n1_cm3 or " + "custom_n2_cm3 a positive number concentration" + ) + return modes + + def to_scenario(config: RunConfig | ResolvedConfig) -> CoupledScenario: """Build the model's input object from a resolved Studio config. @@ -111,7 +155,21 @@ def to_scenario(config: RunConfig | ResolvedConfig) -> CoupledScenario: dt_couple=run.numerics.couple_dt_s, photolysis=run.chemistry.photolysis.value, tomas_nbins=run.microphysics.n_bins, - background_dist=run.background.aerosol.value, + background_dist=( + # CUSTOM: the entered lognormal modes as (N, Dg, sigma) tuples -- the bridge's own + # custom-mode path, which requires the explicit STP/ambient basis below. Zero-N modes + # are FILTERED here, not passed: the model refuses N <= 0 outright + # (CoupledScenario: "N must be > 0"), and the config's N2 = 0 default means "no second + # mode", which at the seam is a shorter mode list rather than a zero entry. + _custom_modes(run) + if run.background.aerosol is BackgroundAerosol.CUSTOM + else _BACKGROUND_KEYS.get(run.background.aerosol.value, run.background.aerosol.value) + ), + background_modes_basis=( + run.background.custom_basis.value + if run.background.aerosol is BackgroundAerosol.CUSTOM + else "" + ), dilution_regime=_REGIME_TO_MODEL[run.dilution.regime], dilution_rate=run.dilution.rate_per_s, dilution_zero_species=tuple(run.dilution.zero_species), diff --git a/studio/schema/config.py b/studio/schema/config.py index 513aaf1..ca58dd4 100644 --- a/studio/schema/config.py +++ b/studio/schema/config.py @@ -41,6 +41,7 @@ ClimatologyDataset, DilutionRegime, EmissionInput, + ModeBasis, PhotolysisMode, ) from studio.schema.fields import Provenance, SciField @@ -49,7 +50,7 @@ #: The schema's own version. Bumped on any change to field names, semantics or defaults, because #: those change the config hash and therefore run identity (ADR-006). Old configs are never silently #: reinterpreted under new semantics. -SCHEMA_VERSION = "0.4.0" +SCHEMA_VERSION = "0.5.0" #: Stratospheric background gas composition [pptv] used by the 810-run ensemble #: (``run_ensemble.py:56-57``). Module-level so the default is one object with one source, and so a @@ -502,16 +503,94 @@ class Background(SchemaModel): """The air the plume is diluted into, and the aerosol it entrains.""" aerosol: BackgroundAerosol = SciField( - default=BackgroundAerosol.SABR_220, + default=BackgroundAerosol.SABRE_220, unit=Unit.DIMENSIONLESS, label="Background aerosol", description=( "Background aerosol size distribution seeded into the initial TOMAS state and " - "entrained thereafter. The lognormal sets are digitized from source plots, not " - "published parameters." + "entrained thereafter. The picker offers the three SABRE campaign sets, the " + "geoengineered " + "stratosphere (aer_geo, Pierce et al. AER 2D), and CUSTOM (two lognormal modes " + "entered below). The lognormal sets are digitized from source plots, not published " + "parameters." ), provenance=Provenance.PAPER_ENSEMBLE, source="coupled/paper_ensemble/TABLE_dilution_parameters.md (Background: SABRE aged air)", + # Valid for archived configs (Tier B's curated cases use cesm_g6), not offered for new + # ones -- curated by review 2026-08-20. + hidden_choices=["redcircles", "cesm_g6", "cesm_g6_amb"], + ) + custom_basis: ModeBasis = SciField( + default=ModeBasis.STP, + unit=Unit.DIMENSIONLESS, + label="Custom modes quoted at", + description=( + "Whether the custom modes' number concentrations are per cm^3 at STP (the SABRE " + "sets' convention) or at the box's own T and p (aer_geo's). Only read when the " + "background is CUSTOM; the bridge refuses a custom mode list without it." + ), + provenance=Provenance.CONVENTION, + source="coupled/backgrounds.py MODE_BASES; per-dataset basis caveat in ASSUMPTION-8", + ) + custom_n1_cm3: float = SciField( + default=49.0, + unit=Unit.PER_CM3, + ge=0.0, + label="N\u2081", + description=( + "Mode 1 number concentration. Defaults are SABRE-220's single mode, so CUSTOM starts " + "as a citable distribution rather than an invented one; zero removes the mode." + ), + provenance=Provenance.PAPER_ENSEMBLE, + source="coupled/backgrounds.py BACKGROUND_MODES['sabr_220'] (49 cm^-3, 0.12 um, 1.6)", + ) + custom_dg1_um: float = SciField( + default=0.12, + unit=Unit.MICROMETRE, + gt=0.0, + label="Dp\u2081", + description="Mode 1 geometric-mean (mode) diameter.", + provenance=Provenance.PAPER_ENSEMBLE, + source="coupled/backgrounds.py BACKGROUND_MODES['sabr_220']", + ) + custom_sigma1: float = SciField( + default=1.6, + unit=Unit.DIMENSIONLESS, + gt=1.0, + label="\u03c3\u2081", + description="Mode 1 geometric standard deviation (> 1; 1 would be monodisperse).", + provenance=Provenance.PAPER_ENSEMBLE, + source="coupled/backgrounds.py BACKGROUND_MODES['sabr_220']", + ) + custom_n2_cm3: float = SciField( + default=0.0, + unit=Unit.PER_CM3, + ge=0.0, + label="N\u2082", + description=( + "Mode 2 number concentration. Zero by default, so the custom distribution is " + "unimodal until a second mode is deliberately added." + ), + provenance=Provenance.CONVENTION, + source="zero = no second mode; a nonzero default would invent a coarse mode", + ) + custom_dg2_um: float = SciField( + default=0.9, + unit=Unit.MICROMETRE, + gt=0.0, + label="Dp\u2082", + description="Mode 2 geometric-mean diameter. Inert while N\u2082 is zero.", + provenance=Provenance.CONVENTION, + source="a coarse-mode placeholder (aer_geo's coarse mode is 0.9 um); inert at N2 = 0", + ) + custom_sigma2: float = SciField( + default=1.4, + unit=Unit.DIMENSIONLESS, + gt=1.0, + label="\u03c3\u2082", + description="Mode 2 geometric standard deviation. Inert while N\u2082 is zero.", + provenance=Provenance.CONVENTION, + source="a typical coarse-mode width; inert at N2=0", ) so2_pptv: float = SciField( default=20.0, @@ -858,11 +937,13 @@ class Termination(SchemaModel): gt=0.0, label="Max wall time", description=( - "Wall-clock cap enforced by the runner (task 0.6). 3600 s covers the measured 3-5 min " - "for a 10-day/80-bin case and 30-40 min for 60 days (BLOCKING-4), with headroom." + "Wall-clock cap enforced by the runner: the job is terminated past this, whatever the " + "simulation state. 3600 s covers the measured 3-5 min for a 10-day/80-bin case and " + "30-40 min for 60 days, with headroom." ), provenance=Provenance.CONVENTION, - source="docs/studio/ASSUMPTIONS.md ASSUMPTION-4", + source="the spec's wall-time budget question (BLOCKING-4), answered 2026-08-13: one hour " + "per run; recorded as ASSUMPTION-4", ) max_sim_time_days: float | None = SciField( default=None, @@ -906,7 +987,7 @@ class RunConfig(SchemaModel): physics, and two runs whose only difference is a name are the same computation. """ - schema_version: Literal["0.4.0"] = SciField( + schema_version: Literal["0.5.0"] = SciField( default=SCHEMA_VERSION, unit=Unit.DIMENSIONLESS, label="Schema version", diff --git a/studio/schema/enums.py b/studio/schema/enums.py index ad10ed7..92e1bde 100644 --- a/studio/schema/enums.py +++ b/studio/schema/enums.py @@ -57,7 +57,10 @@ class DilutionRegime(StrEnum): class BackgroundAerosol(StrEnum): """Background aerosol size distribution seeded into the initial TOMAS state. - Values match ``coupled.tomas_bridge.BACKGROUND_MODES`` keys, plus ``redcircles`` (the tabulated + SABRE values are spelled after the campaign -- Stratospheric Aerosol processes, Budget and + Radiative Effects -- and ``studio/modelio`` translates them to the model's internal ``sabr_*`` + keys at the seam (the bridge's naming is the model's own). The remaining values match + ``coupled.tomas_bridge.BACKGROUND_MODES`` keys directly, plus ``redcircles`` (the tabulated loader, which is the model's default and is not in that dict). The lognormal mode sets are DIGITIZED from source plots, and the number concentrations are @@ -74,19 +77,23 @@ class BackgroundAerosol(StrEnum): #: Marianna's tabulated distribution. The model's default. REDCIRCLES = "redcircles" #: SABRE young air (high N2O), one mode, peak dN/dlogDp ~1000 cm^-3. - SABR_330 = "sabr_330" + SABRE_330 = "sabre_330" #: SABRE mid air (310-320 ppbv N2O), peak ~320 cm^-3. - SABR_310 = "sabr_310" + SABRE_310 = "sabre_310" #: SABRE aged air (220-230 ppbv N2O), Dg = 0.12 um, sigma_g = 1.6. The paper ensemble's clean #: background, and the one used by the golden case. - SABR_220 = "sabr_220" + SABRE_220 = "sabre_220" #: CESM G6 SAI, three modes, read at STP. CESM_G6 = "cesm_g6" #: CESM G6 with the source plot read as AMBIENT. Kept separate so ``CESM_G6`` stays #: reproducible. CESM_G6_AMB = "cesm_g6_amb" #: AER 2D geoengineered stratosphere (Pierce et al., 5 Mt-S/yr, 95 nm case), ambient basis. + #: The curated picker's "geoengineering" option (the spec's GEOENG reference case). AER_GEO = "aer_geo" + #: User-supplied bimodal lognormal: two (N, Dg, sigma) modes entered on stage 5. Seeded through + #: the same bridge path as the named sets (tomas_bridge handles an explicit mode list). + CUSTOM = "custom" class EmissionInput(StrEnum): @@ -123,6 +130,20 @@ class EmissionInput(StrEnum): EMISSION_DURATION = "emission_duration" +class ModeBasis(StrEnum): + """Whether a custom background's number concentrations are quoted at STP or ambient. + + Mirrors ``coupled.backgrounds.MODE_BASES`` -- the bridge refuses a custom mode list without an + explicit basis, precisely because the named sets differ on it silently (SCIENCE-3's per-dataset + caveat, recorded in ASSUMPTION-8). + """ + + #: dN/dlogDp quoted per cm^3 at standard temperature and pressure (the SABR sets' convention). + STP = "stp" + #: Quoted at the box's own T and p (the AER_GEO convention). + AMBIENT = "ambient" + + class ClimatologyDataset(StrEnum): """Where stage 1's ambient state comes from. @@ -160,5 +181,6 @@ class AxisKind(StrEnum): "ClimatologyDataset", "DilutionRegime", "EmissionInput", + "ModeBasis", "PhotolysisMode", ] diff --git a/studio/schema/fields.py b/studio/schema/fields.py index ef93121..75ac6e1 100644 --- a/studio/schema/fields.py +++ b/studio/schema/fields.py @@ -75,6 +75,7 @@ def SciField( gt: float | None = None, lt: float | None = None, examples: Sequence[Any] | None = None, + hidden_choices: Sequence[str] = (), ) -> Any: """A pydantic field carrying Studio's scientific metadata. @@ -129,6 +130,11 @@ def SciField( "unit": unit.value, "provenance": provenance.value, "derived_from": list(derived_from), + # Enum members that stay VALID but are not offered by the picker. Archived configurations + # keep their meaning (a Tier B case that used cesm_g6 must still resolve), while the UI + # offers only the curated set. The form generator filters these out unless one is the + # field's current value. + "hidden_choices": list(hidden_choices), } for key, value in (("label", label), ("source", source), ("cite", cite), ("caveat", caveat)): if value is not None: diff --git a/studio/schema/layout.py b/studio/schema/layout.py index 623b421..1bf0222 100644 --- a/studio/schema/layout.py +++ b/studio/schema/layout.py @@ -235,8 +235,25 @@ class Stage: Section( title="Reference distribution", fields=("background.aerosol",), - note="Reference distributions are ordinary inputs here; the spec's plan to make " - "them reference *runs* arrives with ensembles (Phase 7).", + note="The three SABRE campaign sets, the geoengineered stratosphere, or CUSTOM. " + "Reference " + "distributions are ordinary inputs here; the spec's plan to make them reference " + "*runs* arrives with ensembles (Phase 7).", + ), + Section( + title="Custom modes", + fields=( + "background.custom_basis", + "background.custom_n1_cm3", + "background.custom_dg1_um", + "background.custom_sigma1", + "background.custom_n2_cm3", + "background.custom_dg2_um", + "background.custom_sigma2", + ), + note="Two lognormal modes (N, Dp, sigma), used only when the background is " + "CUSTOM. Defaults are SABRE-220's single mode with N\u2082 = 0, so custom starts " + "citable and unimodal; the panel redraws as you type.", ), ), ), diff --git a/studio/schema/units.py b/studio/schema/units.py index bba0ce4..3c2f1e6 100644 --- a/studio/schema/units.py +++ b/studio/schema/units.py @@ -48,6 +48,8 @@ class Unit(StrEnum): KILOGRAM = "kg" KG_PER_SECOND = "kg s^-1" CM3 = "cm^3" + PER_CM3 = "cm^-3" + MICROMETRE = "um" PER_CM3_PER_S = "cm^-3 s^-1" CM3_PER_MOLEC_PER_S = "cm^3 molec^-1 s^-1" UM2_PER_CM3 = "um^2 cm^-3" @@ -74,6 +76,8 @@ class Unit(StrEnum): Unit.METRE: "meter", Unit.KILOGRAM: "kilogram", Unit.CM3: "centimeter ** 3", + Unit.PER_CM3: "1 / centimeter ** 3", + Unit.MICROMETRE: "micrometer", Unit.PER_CM3_PER_S: "1 / centimeter ** 3 / second", Unit.CM3_PER_MOLEC_PER_S: None, # `molec` is a molecule count; pint has no such unit Unit.UM2_PER_CM3: "micrometer ** 2 / centimeter ** 3", diff --git a/studio/tests/golden/paper_cases.py b/studio/tests/golden/paper_cases.py index 5a7d57f..0800410 100644 --- a/studio/tests/golden/paper_cases.py +++ b/studio/tests/golden/paper_cases.py @@ -33,8 +33,8 @@ #: background token -> (aerosol distribution, background SO2 [pptv]). ``run_ensemble.py:67-71``. BACKGROUNDS: Final[dict[str, tuple[BackgroundAerosol, float]]] = { - "sabr330": (BackgroundAerosol.SABR_330, 20.0), - "sabr220": (BackgroundAerosol.SABR_220, 20.0), + "sabr330": (BackgroundAerosol.SABRE_330, 20.0), + "sabr220": (BackgroundAerosol.SABRE_220, 20.0), "cesm": (BackgroundAerosol.CESM_G6, 100.0), } diff --git a/studio/tests/golden/test_tier_b_archive.py b/studio/tests/golden/test_tier_b_archive.py index 045e5f0..c88e418 100644 --- a/studio/tests/golden/test_tier_b_archive.py +++ b/studio/tests/golden/test_tier_b_archive.py @@ -105,7 +105,7 @@ def test_the_curated_set_covers_the_dilution_regimes_and_both_backgrounds() -> N regimes = {config_for_case(case).config.dilution.regime.value for case in TIER_B_CASES} backgrounds = {config_for_case(case).config.background.aerosol.value for case in TIER_B_CASES} assert {"D1", "D2", "D3", "burst"} <= regimes - assert {"sabr_220", "sabr_330"} <= backgrounds + assert {"sabre_220", "sabre_330"} <= backgrounds for case in TIER_B_CASES: config = config_for_case(case).config assert config.microphysics.n_bins == ENSEMBLE_BINS diff --git a/studio/tests/unit/test_api.py b/studio/tests/unit/test_api.py index 43de7c5..6412dd0 100644 --- a/studio/tests/unit/test_api.py +++ b/studio/tests/unit/test_api.py @@ -366,3 +366,21 @@ def test_the_superseded_page_is_still_reachable(api: Any) -> None: assert response.status_code == 200 assert "Plume Studio" in response.text assert "/api/config/resolve" in response.text, "it must talk to the real resolver, not its own" + + +@pytest.mark.tier_a +def test_preview_panels_accept_parameters(api: Any, repo_root: Path) -> None: + """The dilution explorer's k travels as panel params, and a bad one is a 422 with the bounds.""" + if not (repo_root / "stratchem-jax" / "config.py").is_file(): + pytest.skip("model submodules not checked out (`git submodule update --init`)") + client, _ = api + good = client.post( + "/api/preview/dilution", json={"config": {}, "params": {"explore_k": 8.89e-9}} + ) + assert good.status_code == 200 + body = good.json() + assert body["custom"]["volume_ratio"] == body["regimes"]["D2"]["volume_ratio"] + + bad = client.post("/api/preview/dilution", json={"config": {}, "params": {"explore_k": 5.0}}) + assert bad.status_code == 422 + assert "explore_k must be within" in bad.json()["detail"] diff --git a/studio/tests/unit/test_background_curation.py b/studio/tests/unit/test_background_curation.py new file mode 100644 index 0000000..71c870a --- /dev/null +++ b/studio/tests/unit/test_background_curation.py @@ -0,0 +1,152 @@ +# Copyright (C) 2026 University Corporation for Atmospheric Research +# SPDX-License-Identifier: Apache-2.0 +"""The curated background picker and the custom bimodal distribution (schema 0.5.0). + +Requested in review: offer SABRE-220/310/330 and the geoengineered stratosphere, hide the rest, and +allow a custom distribution from two (N, Dp, sigma) modes. + +The essential distinction these tests pin: hidden is NOT removed. Tier B's archived cases use +cesm_g6, and a schema that refused it would disconnect the archive from its own configurations. So +the enum keeps every member, the picker offers four plus CUSTOM, and ``hidden_choices`` in the +field metadata is what the form generator filters on. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from studio.resolve import resolve +from studio.schema import RunConfig, run_config_json_schema +from studio.schema.enums import BackgroundAerosol + + +def _custom(**background: object) -> RunConfig: + return RunConfig.model_validate({"background": {"aerosol": "custom", **background}}) + + +@pytest.mark.tier_a +def test_the_picker_offers_four_references_and_custom() -> None: + """What a NEW config can choose: the three SABRE sets, aer_geo, and custom.""" + node = run_config_json_schema()["$defs"]["Background"]["properties"]["aerosol"] + hidden = set(node["x-studio"]["hidden_choices"]) + offered = [ + v + for v in ( + "redcircles", + "sabr_330", + "sabr_310", + "sabr_220", + "cesm_g6", + "cesm_g6_amb", + "aer_geo", + "custom", + ) + if v not in hidden + ] + assert offered == ["sabr_330", "sabr_310", "sabr_220", "aer_geo", "custom"] + + +@pytest.mark.tier_a +def test_hidden_is_not_removed() -> None: + """Tier B's archive uses cesm_g6; the schema must keep accepting what it accepted.""" + config = RunConfig.model_validate({"background": {"aerosol": "cesm_g6"}}) + assert config.background.aerosol is BackgroundAerosol.CESM_G6 + from studio.tests.golden.paper_cases import BACKGROUNDS # token -> (aerosol, so2) + + archived = {aerosol for aerosol, _ in BACKGROUNDS.values()} + hidden = set( + run_config_json_schema()["$defs"]["Background"]["properties"]["aerosol"]["x-studio"][ + "hidden_choices" + ] + ) + # Every archived background is either offered or hidden-but-valid; none may be MISSING. + assert {b.value for b in archived} <= {e.value for e in BackgroundAerosol} + assert hidden < {e.value for e in BackgroundAerosol}, "hidden names must be real members" + + +@pytest.mark.tier_a +def test_custom_modes_reach_the_scenario_as_entered(repo_root: Path) -> None: + """(N1, Dp1, s1), (N2, Dp2, s2) -> the bridge's own custom-mode path, basis attached.""" + if not (repo_root / "stratchem-jax" / "config.py").is_file(): + pytest.skip("model submodules not checked out") + from studio.modelio.scenario import to_scenario + + config = _custom( + custom_n1_cm3=30.0, + custom_dg1_um=0.1, + custom_sigma1=1.5, + custom_n2_cm3=2.0, + custom_dg2_um=0.8, + custom_sigma2=1.4, + custom_basis="ambient", + ) + scenario = to_scenario(resolve(config).config) + assert scenario.background_dist == ((30.0, 0.1, 1.5), (2.0, 0.8, 1.4)) + # And the N2 = 0 default maps to ONE mode, because the model refuses zero-N entries. + assert to_scenario(resolve(_custom()).config).background_dist == ((49.0, 0.12, 1.6),) + with pytest.raises(ValueError, match="both modes have N = 0"): + to_scenario(resolve(_custom(custom_n1_cm3=0.0)).config) + assert scenario.background_modes_basis == "ambient" + + +@pytest.mark.tier_a +def test_named_backgrounds_carry_no_basis(repo_root: Path) -> None: + """The basis field is only for custom modes; a named set's basis is the dataset's own.""" + if not (repo_root / "stratchem-jax" / "config.py").is_file(): + pytest.skip("model submodules not checked out") + from studio.modelio.scenario import to_scenario + + scenario = to_scenario(resolve(RunConfig()).config) + # The seam translates the campaign spelling to the model's internal key. + assert scenario.background_dist == "sabr_220" + assert scenario.background_modes_basis == "" + + +@pytest.mark.tier_a +def test_the_default_custom_distribution_is_sabre_220(repo_root: Path) -> None: + """CUSTOM with untouched defaults seeds the SAME distribution as sabr_220, bin for bin. + + The defaults are SABRE-220's mode with N2 = 0, so switching to CUSTOM starts from a citable + distribution rather than an invented one -- and this is the test that the zero mode really + contributes nothing. + """ + if not (repo_root / "stratchem-jax" / "config.py").is_file(): + pytest.skip("model submodules not checked out") + from studio.modelio.preview import size_distribution + + named = size_distribution(RunConfig()) + custom = size_distribution(_custom()) + assert custom["dn_dlogdp"] == named["dn_dlogdp"], "same modes must seed the same bins" + assert custom["total_cm3"] == named["total_cm3"] + + +@pytest.mark.tier_a +def test_a_second_mode_adds_particles_where_it_says(repo_root: Path) -> None: + """N2 > 0 raises the total by ~N2 (x the STP factor) and moves mass to the coarse mode.""" + if not (repo_root / "stratchem-jax" / "config.py").is_file(): + pytest.skip("model submodules not checked out") + import numpy as np + + from studio.modelio.preview import size_distribution + + base = size_distribution(_custom()) + bimodal = size_distribution(_custom(custom_n2_cm3=5.0)) + stp_factor = (5500.0 / 101325.0) * (273.15 / 210.0) + assert bimodal["total_cm3"] - base["total_cm3"] == pytest.approx(5.0 * stp_factor, rel=0.02) + # The added particles sit near Dp2 = 0.9 um, not under mode 1. + dp = np.asarray(bimodal["dp_um"]) + added = np.asarray(bimodal["dn_dlogdp"]) - np.asarray(base["dn_dlogdp"]) + assert 0.5 < dp[int(np.argmax(added))] < 1.6 + + +@pytest.mark.tier_a +def test_degenerate_modes_are_refused() -> None: + """sigma = 1 is monodisperse (log-normal width zero divides by it); negatives are not counts.""" + with pytest.raises(ValueError): + _custom(custom_sigma1=1.0) + with pytest.raises(ValueError): + _custom(custom_n1_cm3=-1.0) + with pytest.raises(ValueError): + _custom(custom_dg1_um=0.0) diff --git a/studio/tests/unit/test_config_hash.py b/studio/tests/unit/test_config_hash.py index 8e8fd56..b4af203 100644 --- a/studio/tests/unit/test_config_hash.py +++ b/studio/tests/unit/test_config_hash.py @@ -34,6 +34,10 @@ #: * ...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. +#: * 0.4.0 -> 0.5.0 when the background picker was curated (redcircles/cesm hidden, not removed -- +#: Tier B's archived cases stay valid) and CUSTOM arrived with its two lognormal modes. Default +#: run unchanged: SABRE-220 either way (values respell the campaign name; the seam +#: maps them to the model's internal sabr_* keys). #: * 0.3.0 -> 0.4.0 when the ambient state gained its dataset selector (SCIENCE-1 implemented): #: given_temperature_k / given_h2o_ppmv entered, temperature_k / h2o_ppmv derived. The default RUN #: is unchanged -- USER dataset, the same 210 K -- but the config has three more fields. @@ -47,7 +51,7 @@ #: 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 = "b8ca6becb22b49668e22d1f2dce8451b79bd3159c82fb0966cce182ccdd35ba6" +GOLDEN_DEFAULT_HASH = "8cc42dfb2b4b1a512177f6a062b9f18965e66600a888acfff5ca8dc336d1656e" @pytest.mark.tier_a @@ -147,7 +151,7 @@ def test_enums_serialise_as_their_model_string() -> None: """The hashed payload carries the model's own strings, so a config is readable as what it is.""" parsed = json.loads(canonical_json(RunConfig())) assert parsed["chemistry"]["photolysis"] == "tuvx" - assert parsed["background"]["aerosol"] == "sabr_220" + assert parsed["background"]["aerosol"] == "sabre_220" assert parsed["dilution"]["regime"] == "D2" diff --git a/studio/tests/unit/test_modelio_equivalence.py b/studio/tests/unit/test_modelio_equivalence.py index 34d8d28..955cb89 100644 --- a/studio/tests/unit/test_modelio_equivalence.py +++ b/studio/tests/unit/test_modelio_equivalence.py @@ -68,7 +68,7 @@ def test_the_golden_case_is_the_schemas_default(build_scenario: Any) -> None: config = resolve(RunConfig()).config assert config.site.temperature_k == reference.T assert config.site.pressure_mbar == reference.P - assert config.background.aerosol is BackgroundAerosol.SABR_220 + assert config.background.aerosol is BackgroundAerosol.SABRE_220 assert config.dilution.regime is DilutionRegime.D2 assert config.chemistry.photolysis is PhotolysisMode.TUVX diff --git a/studio/tests/unit/test_preview.py b/studio/tests/unit/test_preview.py index 0a66f40..ce7890a 100644 --- a/studio/tests/unit/test_preview.py +++ b/studio/tests/unit/test_preview.py @@ -203,3 +203,45 @@ def test_the_cheap_panels_need_no_jax() -> None: [sys.executable, "-c", script], capture_output=True, text=True, check=True ) assert result.stdout.strip() == "False", "the SZA/concentration panels must not import JAX" + + +@pytest.mark.tier_a +def test_the_dilution_panel_reports_the_equation_and_each_regimes_k() -> None: + """The constant is INTROSPECTED from the model's segment tuples, never re-typed. + + Compared against the same tuples here, so if coupled/dilution.py changes a coefficient this + fails on the spot rather than the panel quoting a k the model no longer uses. + """ + from coupled import dilution + from studio.modelio.preview import dilution_curve + + panel = dilution_curve(RunConfig()) + assert "t^0.8" in panel["equation"]["early"] + assert "exp(k" in panel["equation"]["late"] + for name, (_, segments) in dilution.DILUTION_REGIMES.items(): + expected = float(segments[1][1][2]) if len(segments) == 2 else None + assert panel["regimes"][name]["k"] == expected, name + + +@pytest.mark.tier_a +def test_the_explored_k_uses_the_models_own_machinery() -> None: + """At a named regime's k the custom curve must equal that regime's curve BIT FOR BIT. + + This is the assertion that the explorer is the model's `_two_piece` + `_eval_segment` and not a + lookalike formula: a re-derivation would agree to a tolerance, not to the last bit. + """ + from studio.modelio.preview import dilution_curve + + panel = dilution_curve(RunConfig(), {"explore_k": 8.89e-9}) + assert panel["custom"]["k"] == 8.89e-9 + assert panel["custom"]["volume_ratio"] == panel["regimes"]["D2"]["volume_ratio"] + + +@pytest.mark.tier_a +def test_an_unphysical_exploration_k_is_refused() -> None: + """exp(k t^1.5) at k=1 over ten days is an overflow, not a curve (ADR-005).""" + from studio.modelio.preview import dilution_curve + + with pytest.raises(ValueError, match="explore_k must be within"): + dilution_curve(RunConfig(), {"explore_k": 1.0}) + assert dilution_curve(RunConfig(), {})["custom"] is None, "no params, no custom curve" diff --git a/studio/tests/unit/test_runset.py b/studio/tests/unit/test_runset.py index 26dc87c..2c55a05 100644 --- a/studio/tests/unit/test_runset.py +++ b/studio/tests/unit/test_runset.py @@ -71,14 +71,14 @@ AxisPoint( label="sabr330", assignments={ - "background.aerosol": BackgroundAerosol.SABR_330, + "background.aerosol": BackgroundAerosol.SABRE_330, "background.so2_pptv": 20.0, }, ), AxisPoint( label="sabr220", assignments={ - "background.aerosol": BackgroundAerosol.SABR_220, + "background.aerosol": BackgroundAerosol.SABRE_220, "background.so2_pptv": 20.0, }, ), @@ -169,7 +169,7 @@ def test_the_golden_case_resolves_to_the_ensembles_values() -> None: assert config.site.temperature_k == 210.0 assert config.site.pressure_mbar == 55.0 assert config.site.h2o_ppmv == 6.9104 - assert config.background.aerosol is BackgroundAerosol.SABR_220 + assert config.background.aerosol is BackgroundAerosol.SABRE_220 assert config.background.so2_pptv == 20.0 assert config.dilution.regime is DilutionRegime.D2 assert config.microphysics.condensation_alpha == 1.0 diff --git a/studio/tests/unit/test_web_contract.py b/studio/tests/unit/test_web_contract.py index 67b89d0..3e06845 100644 --- a/studio/tests/unit/test_web_contract.py +++ b/studio/tests/unit/test_web_contract.py @@ -140,6 +140,8 @@ def test_which_numeric_fields_have_no_upper_bound() -> None: unbounded.append(path) # 0.4.0: temperature and water vapour left the census (derived fields carry no bound of # their own); their entered given_* twins joined it. Net count unchanged at 20. - assert len(unbounded) == 20, f"the set of unbounded fields changed: {unbounded}" + # 0.5.0 added the six custom-mode numerics (N, Dp, sigma x2), all lower-bounded only -- + # deliberately part of the same open question (#91) as the rest. + assert len(unbounded) == 26, f"the set of unbounded fields changed: {unbounded}" assert "injection.platform_speed_m_s" in unbounded, "added in 0.3.0, still unbounded above" assert "site.given_temperature_k" in unbounded diff --git a/studio/web/src/App.tsx b/studio/web/src/App.tsx index 645d5d5..6fabb7d 100644 --- a/studio/web/src/App.tsx +++ b/studio/web/src/App.tsx @@ -176,7 +176,8 @@ export function App() { // Passed to the panels so they never import the API client themselves. Stable, so a panel's // effect refires when the CONFIG changes and not merely because App re-rendered. const loadPanel = useCallback( - (panel: string, signal: AbortSignal) => api.preview(panel, payloadRef.current, signal), + (panel: string, signal: AbortSignal, params?: Record) => + api.preview(panel, payloadRef.current, signal, params), [], ); const onKeep = useCallback((path: string) => void run((s) => api.keep(s, path)), [run]); @@ -313,7 +314,9 @@ export function App() { ))}
- {StagePanel ? : null} + {StagePanel ? ( + + ) : null}
)} diff --git a/studio/web/src/Chart.tsx b/studio/web/src/Chart.tsx index afa86bc..6583866 100644 --- a/studio/web/src/Chart.tsx +++ b/studio/web/src/Chart.tsx @@ -64,6 +64,17 @@ interface Props { */ rightTicks?: { y: number; label: string }[]; rightLabel?: string; + /** + * Track the pointer along the y axis instead of x, for profiles whose natural coordinate is + * vertical (pressure). The crosshair turns horizontal and the nearest point is found on the + * FIRST series' ys, which must be sorted. + */ + hoverAxis?: "x" | "y"; + /** + * Everything the hover should read out at a data index -- the panel knows what lives at each + * level (pressure, altitude, temperature, water vapour); the chart only knows two columns. + */ + hoverReadout?: (index: number) => { name: string; value: string }[]; /** How a hovered value is written in the tooltip. */ format?: (value: number) => string; caption?: string; @@ -105,6 +116,8 @@ export function Chart({ yReverse = false, rightTicks = [], rightLabel, + hoverAxis = "x", + hoverReadout, format = (v) => tickLabel(v), caption, }: Props) { @@ -152,28 +165,47 @@ export function Chart({ if (!svg) return; const box = svg.getBoundingClientRect(); // The SVG is scaled to its container, so pointer pixels must be mapped back to viewBox units. + if (hoverAxis === "y") { + const py = ((event.clientY - box.top) / box.height) * height; + setHoverX(py < PAD.top || py > height - PAD.bottom ? null : py); + return; + } const px = ((event.clientX - box.left) / box.width) * WIDTH; setHoverX(px < PAD.left || px > WIDTH - PAD.right ? null : px); }, - [], + [hoverAxis, height], ); const hover = hoverX === null ? null - : (() => { - const value = x.invert(hoverX); - const readouts = series - .map((s) => { - const index = nearestIndex(s.xs, value); - if (index < 0) return null; - const yv = s.ys[index]; - if (yv === undefined || !Number.isFinite(yv)) return null; - return { name: s.label ?? s.name, value: yv, muted: s.muted === true }; - }) - .filter((r): r is { name: string; value: number; muted: boolean } => r !== null); - return { at: value, readouts }; - })(); + : hoverAxis === "y" + ? (() => { + const first = series[0]; + if (!first) return null; + const value = y.invert(hoverX); + const index = nearestIndex(first.ys, value); + if (index < 0) return null; + const readouts = (hoverReadout ? hoverReadout(index) : []).map((r) => ({ + name: r.name, + value: r.value, + muted: false, + })); + return { at: value, index, readouts }; + })() + : (() => { + const value = x.invert(hoverX); + const readouts = series + .map((s) => { + const index = nearestIndex(s.xs, value); + if (index < 0) return null; + const yv = s.ys[index]; + if (yv === undefined || !Number.isFinite(yv)) return null; + return { name: s.label ?? s.name, value: format(yv), muted: s.muted === true }; + }) + .filter((r): r is { name: string; value: string; muted: boolean } => r !== null); + return { at: value, index: -1, readouts }; + })(); return (
@@ -196,6 +228,26 @@ export function Chart({ /> ))} + {(y.minorTicks ? y.minorTicks() : []).map((tick) => ( + + ))} + {(x.minorTicks ? x.minorTicks() : []).map((tick) => ( + + ))} {y.ticks(5).map((tick) => ( + hoverAxis === "y" ? ( + + ) : ( + + ) ) : null} @@ -320,15 +382,17 @@ export function Chart({ {hover && hover.readouts.length ? (
- - {xLabel.split(" ")[0]} {format(hover.at)} - + {hoverAxis === "x" ? ( + + {xLabel.split(" ")[0]} {format(hover.at)} + + ) : null} {hover.readouts .filter((r) => !r.muted) - .slice(0, 4) + .slice(0, 5) .map((r) => ( - {r.name} {format(r.value)} + {r.name} {r.value} ))}
diff --git a/studio/web/src/Field.test.tsx b/studio/web/src/Field.test.tsx index d8a5e00..ff61241 100644 --- a/studio/web/src/Field.test.tsx +++ b/studio/web/src/Field.test.tsx @@ -41,6 +41,7 @@ const numberSpec: FieldSpec = { unit: "m", kind: "number", choices: [], + hiddenChoices: [], default: 15000, provenance: "paper_ensemble", source: "", diff --git a/studio/web/src/Field.tsx b/studio/web/src/Field.tsx index 80f5737..eadbe65 100644 --- a/studio/web/src/Field.tsx +++ b/studio/web/src/Field.tsx @@ -238,21 +238,24 @@ function control( {value === true ? "on" : "off"} ); - case "enum": + case "enum": { + // Hidden choices stay VALID -- an archived config that used one must still display -- but + // are not offered: they appear only when they ARE the current value, marked as archival. + const current = String(value ?? ""); + const offered = spec.choices.filter( + (choice) => !spec.hiddenChoices.includes(String(choice)) || String(choice) === current, + ); return ( - emit(e.target.value)}> + {offered.map((choice) => ( ))} ); + } case "number": case "integer": return ( diff --git a/studio/web/src/Review.test.ts b/studio/web/src/Review.test.ts index 4b78c16..41a5112 100644 --- a/studio/web/src/Review.test.ts +++ b/studio/web/src/Review.test.ts @@ -49,6 +49,7 @@ function spec(path: string, over: Partial = {}): FieldSpec { unit: "", kind: "number", choices: [], + hiddenChoices: [], default: undefined, provenance: "model_default", source: "", diff --git a/studio/web/src/api.ts b/studio/web/src/api.ts index 0eef86a..d59ca94 100644 --- a/studio/web/src/api.ts +++ b/studio/web/src/api.ts @@ -78,10 +78,15 @@ export const api = { * A stage's preview panel. The first call in a server process imports JAX (~1.2 s); the rest are * about a millisecond, so the panels are cheap enough to refetch on every edit. */ - preview: (panel: string, config: Record, signal: AbortSignal) => + preview: ( + panel: string, + config: Record, + signal: AbortSignal, + params?: Record, + ) => request>(`/api/preview/${panel}`, { method: "POST", - body: JSON.stringify({ config }), + body: JSON.stringify({ config, params: params ?? {} }), signal, }), diff --git a/studio/web/src/panels.tsx b/studio/web/src/panels.tsx index 9ee49fe..5b0fdf4 100644 --- a/studio/web/src/panels.tsx +++ b/studio/web/src/panels.tsx @@ -22,7 +22,15 @@ import { tickLabel } from "./scales"; interface PanelProps { config: Record; /** Fetch a preview panel; supplied by App so panels do not each know about the API. */ - load: (panel: string, signal: AbortSignal) => Promise>; + load: ( + panel: string, + signal: AbortSignal, + params?: Record, + ) => Promise>; + /** Change a config field -- the same server round-trip every Field control uses. Panels use it + * for graph-adjacent controls like the dilution regime chips, so choosing on the graph IS + * choosing in the config. */ + onChange?: ((path: string, value: unknown) => void) | undefined; } /** Fetch-with-state, shared by every server-computed panel. */ @@ -30,17 +38,19 @@ function usePanel( name: string, config: Record, load: PanelProps["load"], + params?: Record, ): { data: Record | null; error: string; loading: boolean } { const [data, setData] = useState | null>(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); - // Keyed on the serialised config: the panel must follow every edit, and this is what makes it - // refetch when a field the panel depends on moves. - const key = JSON.stringify(config); + // Keyed on the serialised config AND the panel parameters: the panel must follow every edit, + // and an explorer input (the dilution k) is an edit to the picture even though it is not one to + // the config. + const key = JSON.stringify([config, params ?? null]); useEffect(() => { const controller = new AbortController(); setLoading(true); - load(name, controller.signal) + load(name, controller.signal, params) .then((payload) => { setData(payload); setError(""); @@ -87,7 +97,8 @@ function Frame({ {loading ? computing… : null} {hint ?

{hint}

: null} - {error ?

{error}

: children} + {error ?

{error}

: null} + {children} ); } @@ -159,6 +170,8 @@ export function ClimatologyPanel({ config, load }: PanelProps) { const boxP = typeof data?.box_pressure_mbar === "number" ? data.box_pressure_mbar : null; const boxT = typeof data?.box_temperature_k === "number" ? data.box_temperature_k : null; const boxKm = typeof data?.box_altitude_km === "number" ? data.box_altitude_km : null; + const heights = nums(data, "geopotential_height_m"); + const h2o = nums(data, "h2o_ppmv"); const selected = typeof data?.selected_dataset === "string" ? data.selected_dataset : "user"; const altitudeTicks = Array.isArray(data?.altitude_ticks) ? (data.altitude_ticks as { km: number; pressure_hpa: number }[]).map((tick) => ({ @@ -195,6 +208,17 @@ export function ClimatologyPanel({ config, load }: PanelProps) { height={280} rightTicks={altitudeTicks} rightLabel="altitude (km)" + hoverAxis="y" + hoverReadout={(index) => { + const rows = [ + { name: "p", value: `${(levels[index] ?? 0).toFixed(0)} hPa` }, + { name: "z", value: `${((heights[index] ?? 0) / 1000).toFixed(1)} km` }, + { name: "T", value: `${(temperature[index] ?? 0).toFixed(1)} K` }, + ]; + const water = h2o[index]; + if (water !== undefined) rows.push({ name: "H₂O", value: `${water.toFixed(2)} ppmv` }); + return rows; + }} markers={ boxP !== null && boxT !== null ? [{ x: boxT, y: boxP, label: boxKm !== null ? `the box · ${boxKm.toFixed(1)} km` : "the box" }] @@ -323,16 +347,61 @@ export function ConcentrationPanel({ config, load }: PanelProps) { ); } -/** Stage 4 — the dilution curves. The choice between regimes is the stage. */ -export function DilutionPanel({ config, load }: PanelProps) { - const { data, error, loading } = usePanel("dilution", config, load); +/** Stage 4 — the dilution curves: the equation, its one constant, and the regimes as chips. */ +export function DilutionPanel({ config, load, onChange }: PanelProps) { + // The explorer's k: a draft string so typing does not refetch per keystroke; committed on Enter + // or blur, like every other text control. Empty commits to "no custom curve". + const [kDraft, setKDraft] = useState(""); + const [exploreK, setExploreK] = useState(null); + const { data, error, loading } = usePanel( + "dilution", + config, + load, + exploreK !== null ? { explore_k: exploreK } : undefined, + ); const days = nums(data, "days"); const regimes = (data?.regimes ?? {}) as Record< string, - { label: string; volume_ratio: number[]; final: number } + { label: string; volume_ratio: number[]; final: number; k: number | null } >; const selected = typeof data?.selected === "string" ? data.selected : ""; const usesCurve = data?.uses_curve !== false; + const [kError, setKError] = useState(""); + const equation = (data?.equation ?? null) as { + early: string; + late: string; + k_unit: string; + k_min: number; + k_max: number; + } | null; + const custom = (data?.custom ?? null) as { k: number; volume_ratio: number[] } | null; + const selectedK = regimes[selected]?.k ?? null; + + const commitK = () => { + if (kDraft.trim() === "") { + setKError(""); + setExploreK(null); + return; + } + const value = Number(kDraft); + if (!Number.isFinite(value)) { + setKError("not a number — e.g. 1.5e-8"); + setExploreK(null); + return; + } + // Validate against the bounds the server declared, BEFORE any request: a bad k previously + // came back as a panel-level 422 that replaced the panel body, taking this very input with + // it -- an error state with no way out short of reloading. Reported from use. + if (equation && (value < equation.k_min || value > equation.k_max)) { + setKError( + `k must be within ${equation.k_min.toExponential(0)} … ${equation.k_max.toExponential(0)}`, + ); + setExploreK(null); + return; + } + setKError(""); + setExploreK(value); + }; const series: Series[] = Object.entries(regimes).map(([name, regime]) => ({ name, @@ -341,6 +410,16 @@ export function DilutionPanel({ config, load }: PanelProps) { muted: name !== selected, label: name === selected ? `${name} — ${regime.label}` : name, })); + if (custom) { + series.push({ + name: "custom", + xs: days, + ys: custom.volume_ratio, + dashed: true, + color: "var(--gold)", + label: `k = ${custom.k.toExponential(2)}`, + }); + } return ( + {equation ? ( +
+
+ {equation.early} + {equation.late} +
+
+ k ({equation.k_unit}) ={" "} + {selectedK !== null ? selectedK.toExponential(3) : "—"} +
+
+ ) : null} + +
+ {Object.entries(regimes).map(([name, regime]) => ( + + ))} + + + + setKDraft(e.target.value)} + onBlur={commitK} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitK(); + } + }} + /> + {kError ? {kError} : null} + +
+ tickLabel(v)} - caption="Hover to read the expansion at a given time." + caption="Hover to read the expansion at a given time. The dashed gold curve, if present, is the explored k." /> ); diff --git a/studio/web/src/scales.test.ts b/studio/web/src/scales.test.ts index 49c80f7..6ead6e1 100644 --- a/studio/web/src/scales.test.ts +++ b/studio/web/src/scales.test.ts @@ -136,3 +136,23 @@ describe("extent", () => { expect(extent([], true)).toEqual([1, 10]); }); }); + +describe("minor log gridlines", () => { + it("marks 2..9 within each decade, inside the domain only", () => { + const scale = logScale([5, 300], [0, 1]); + const minors = scale.minorTicks ? scale.minorTicks() : []; + expect(minors).toContain(20); + expect(minors).toContain(90); + expect(minors).toContain(200); + expect(minors).not.toContain(2); // below the domain + expect(minors).not.toContain(400); // above it + // Majors are not minors: the decades belong to ticks(), labelled. + expect(minors).not.toContain(10); + expect(minors).not.toContain(100); + }); + + it("linear scales have none -- sub-decade structure is a log-axis concept", () => { + expect(linearScale([0, 10], [0, 1]).minorTicks).toBeUndefined(); + }); +}); + diff --git a/studio/web/src/scales.ts b/studio/web/src/scales.ts index f56f41c..cf6f724 100644 --- a/studio/web/src/scales.ts +++ b/studio/web/src/scales.ts @@ -20,6 +20,8 @@ export interface Scale { /** Range [min, max] in pixels. */ range: [number, number]; ticks: (count?: number) => number[]; + /** Sub-decade positions (2..9 per decade) for a log scale; absent on linear scales. */ + minorTicks?: () => number[]; /** Inverse, for turning a pointer position back into a data value. */ invert: (pixel: number) => number; } @@ -78,6 +80,18 @@ export function logScale(domain: [number, number], range: [number, number]): Sca scale.domain = domain; scale.range = range; scale.invert = (pixel: number) => Math.pow(10, l0 + ((pixel - r0) / (r1 - r0 || 1)) * span); + scale.minorTicks = () => { + // 2..9 within each decade of the domain -- the sub-decade gridlines a log axis is read by. + // Lines only, no labels: labelled minors would crowd the axis into noise. + const out: number[] = []; + for (let power = Math.floor(l0) - 1; power <= Math.ceil(l1); power++) { + for (let mantissa = 2; mantissa <= 9; mantissa++) { + const value = mantissa * Math.pow(10, power); + if (value >= d0 && value <= d1) out.push(value); + } + } + return out; + }; scale.ticks = (count = 6) => { const first = Math.floor(l0); const last = Math.ceil(l1); diff --git a/studio/web/src/schema.ts b/studio/web/src/schema.ts index dc9ff3f..6052b75 100644 --- a/studio/web/src/schema.ts +++ b/studio/web/src/schema.ts @@ -35,6 +35,8 @@ export interface FieldSpec { kind: ControlKind; /** Choices for `enum`, with the value's real type preserved (40, not "40"). */ choices: unknown[]; + /** Valid-but-unoffered choices: archived configs keep them; the picker hides them. */ + hiddenChoices: string[]; /** The single accepted value for `fixed`. */ fixedValue?: unknown; default: unknown; @@ -127,6 +129,7 @@ export function fieldSpec(root: JsonSchema, path: string): FieldSpec { unit: meta.unit === "1" ? "" : (meta.unit ?? ""), kind, choices: node.enum ?? [], + hiddenChoices: meta.hidden_choices ?? [], default: node.default, provenance: meta.provenance, source: meta.source ?? "", diff --git a/studio/web/src/styles.css b/studio/web/src/styles.css index aff51cb..f3b3be9 100644 --- a/studio/web/src/styles.css +++ b/studio/web/src/styles.css @@ -1017,3 +1017,122 @@ figure.chart figcaption { margin-top: 30px; } } + +/* ---- dilution equation + regime chips ---------------------------------------------------- */ + +.equation-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + background: var(--raised); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 10px 14px; + margin-bottom: 10px; +} +.equation { + display: flex; + flex-direction: column; + gap: 3px; +} +.equation code { + font-family: var(--mono); + font-size: 12.5px; + color: var(--ink); +} +.equation-k { + font-size: 12px; + color: var(--ink-2); + font-family: var(--mono); +} +.equation-k strong { + color: var(--navy); +} + +.regime-chips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; +} +.chip { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + padding: 6px 11px; + background: var(--raised); + border: 1px solid var(--line); + border-radius: 8px; + color: var(--ink-2); + cursor: pointer; + box-shadow: var(--shadow); +} +.chip:hover { + border-color: var(--steel); + color: var(--ink); +} +.chip.current { + background: var(--navy); + border-color: var(--navy); + color: var(--surface); +} +.chip-name { + font: 700 12px/1 var(--sans); +} +.chip-k { + font: 400 9.5px/1 var(--mono); + opacity: 0.8; +} +.explore { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} +.explore label { + font-size: 11px; + color: var(--ink-3); +} +.explore input { + width: 110px; + padding: 6px 8px; + border: 1px solid var(--line-strong); + border-radius: 7px; + background: var(--raised); + color: var(--ink); + font: 12px var(--mono); +} + +/* Sub-decade gridlines on log axes: present enough to read against, never as dark as majors. */ +.chart-grid.minor { + opacity: 0.4; + stroke-dasharray: 1 3; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .chip { + color: var(--ink-2); + } + :root:not([data-theme="light"]) .chip:hover { + color: var(--ink); + } + :root:not([data-theme="light"]) .chip.current { + color: #0c1424; + } +} + +/* Inline validation for the k explorer: the message sits AT the input, and the panel keeps its + content -- an error must never take away the control that would fix it. */ +.explore input.invalid { + border-color: var(--stale); +} +.explore-error { + font-size: 10.5px; + color: var(--stale); + font-family: var(--mono); +} diff --git a/studio/web/src/types.ts b/studio/web/src/types.ts index 12134b2..5af3cb4 100644 --- a/studio/web/src/types.ts +++ b/studio/web/src/types.ts @@ -14,6 +14,8 @@ export interface XStudio { source?: string; cite?: string; derived_from?: string[]; + /** Enum values that stay valid (archived configs) but are not offered by the picker. */ + hidden_choices?: string[]; /** * The field's constraint, as the comparison operators Pydantic was given: `{gt: 0}`, * `{ge: -90, le: 90}`, `{ge: 0, lt: 366}`. A dict, **not** a `[min, max]` tuple -- typing it as a