Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/studio/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion studio/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions studio/api/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ <h2>Configure</h2>
</label>
<label><span>Background aerosol</span>
<select name="background.aerosol">
<option value="sabr_220" selected>SABR-220</option><option value="sabr_330">SABR-330</option>
<option value="sabr_310">SABR-310</option><option value="cesm_g6">CESM G6</option>
<option value="sabre_220" selected>SABRE-220</option><option value="sabre_330">SABRE-330</option>
<option value="sabre_310">SABRE-310</option><option value="cesm_g6">CESM G6</option>
<option value="redcircles">redcircles</option>
</select>
</label>
Expand Down
80 changes: 76 additions & 4 deletions studio/modelio/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import math
import os
import sys
from collections.abc import Callable, Mapping
from typing import Any

import numpy as np
Expand Down Expand Up @@ -107,29 +108,89 @@ 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

days = config.schedule.duration_days
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],
Expand All @@ -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,
},
}


Expand Down Expand Up @@ -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,
Expand Down
60 changes: 59 additions & 1 deletion studio/modelio/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading