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() {
))}