MIP-0083: A forecast experiment in marola-app — IFS, GFS and later providers sampled every 4 hours at registered points, scored against ground truth, with runs in MLflow and samples in the OODS lake¶
| Status | Accepted (Hoffmann, 2026-10-09: "check if this MIP is solid enough for implementation phase, create all tasks in marola-app") — redefined from scratch the same day; the earlier Accepted text, its amendments and the marola-eval move are superseded (git log keeps them) |
| Author | Hoffmann, from #724 and #723, redefined in the project thread on 2026-10-09 |
| Created | 2026-10-09 |
| Phase | None: R&D outside the phase list (#724). It writes to MIP-0075's B2 bucket, which already exists and is free; it provisions nothing. Phase 1 (the Telegram bot) is not done, and nothing here moves it |
| Related | #724 (the source issue), #723 (the study: points, protocol, paper), MIP-0075 (the OODS lake on B2 and its DuckDB store), MIP-0010 (MLflow and RunLedger), marola-dev/marola-site#99 (the page), h0ffmann/ww3-gpu#92 (the wind chapter), marola-dev/marola-app#72 (task 1, written before this redefinition) |
| Effort | XL — the OWB-South scope of §4.10 on top of: an sbt module with four forecast clients, two observation clients, a registry, a scorer, an MLflow ledger on B2 and lake tables; a scheduled workflow. Two new dependencies (kyo-config, kyo-schema-json, §4.9) plus MIP-0075's duckdb_jdbc |
| Gain | user value — marola.dev can say which forecast has been right lately at a given coast; community/outreach — an open, continuously updated comparison of physics and AI models on the Brazilian coast through a strong El Niño |
| Effort vs Gain | do next for v0, tasks 1–8 (schemas, two providers, stations, scorer, lake, MLflow, the cycle: local and free); do later for rows 9–16; do when X lands for 19 (X = WeatherNext 3 access) and 20 (X = MONAN output) |
| Depends on | MIP-0075's bucket and key (exist since 2026-10-05) for the B2 run, and its OodsStore (task 5 there) for the B2 lake; v0 runs on a local lake meanwhile (§5.6); Google's approval for WeatherNext 3 (task 17, a person's act; not needed for v0); #723 freezing the strong-wind thresholds before any score is published |
| Blocked by | none |
| Risk | A run is skipped or fetched twice and the record drifts from what each publisher actually issued, so every score answers a different question than it claims; §5.5 makes every expected run an explicit sampled, backfilled or missing row keyed by provider and run time |
| Cost so far | — |
Readiness¶
| Manually reviewed | no |
| Written by | Hoffmann, with Claude Code |
| Tasks | MIP-0083.tasks.md |
| Tests | SchemaSpec, FairnessSpec, GroundTruthSpec, SamplingRegistrySpec, ProtocolSpec, ForecastClientSpec, ObservationClientSpec, ScoreMonoidSpec, MatcherSpec, AnalogSpec, LakeSamplesSpec, CycleSpec, ExportSpec in marola-app's experiment module (§7) |
| Spec-kit | none |
| Issues | v0: marola-dev/marola-app#63–#70 (rewritten from the superseded text's issues; #71 closed). Rows 9–21 are filed when v0 is done |
1. Summary¶
marola-app gains an experiment sbt module, written in Kyo, that every 4 hours (adjustable)
samples 10 m wind and 2 m temperature forecasts at a registry of sampling points. v0 runs two
providers, ECMWF IFS and NOAA GFS, and adding one is a row in providers.json, not code
(§4.4). WeatherNext 2, WeatherNext 3 and the rest come after. It pairs every
matured forecast with what an instrument measured at that point, and keeps bias, RMSE and CRPS
over a 90-day window. Each cycle is an MLflow run (params, metrics, artifacts); every forecast and
observation sample is a row in MIP-0075's OODS lake on Backblaze B2. Nothing is published until
the ground-truth step has frozen the points it scores against. marola also issues its own
forecast, an analog ensemble built from the coast's own observations (§5.11), chosen so its errors
track the providers' as little as a skilful forecast can; it is scored as one more provider, and
every pair of providers' error correlation is measured.
2. Motivation¶
723 asks whether a free AI model matches a physics model on this coast, and a one-off answer goes¶
stale with every model upgrade. Two facts make it urgent:
- Open-Meteo serves only the latest WeatherNext 2 run, with no single-run archive (v Open-Meteo single-runs page, 2026-10-09), so a run not sampled by the cycle that saw it is gone.
- A strong El Niño is under way (#723, citing NOAA CPC's 2026-10-08 advisory); its peak, October 2026 to March 2027, is the most informative window of the study.
marola-app already has an Http with fixture replay, a JsonValue, RunLedger with an MLflow
REST implementation (MIP-0010), and, with MIP-0075, a DuckDB store on B2. What it lacks is a
forecast client that takes models=, any observation client, and anything that runs on a schedule.
3. User-visible change¶
Nothing changes in the CLI, the bot or the board. New surfaces:
# in a marola-app checkout
just experiment-cycle # one cycle against a local MLflow and a local lake directory
just experiment-rescore # rebuild every score from the lake's samples alone
and, once points are scored (§5.8), forecast-benchmark/latest.json on marola-site's
site-data for the wind page (marola-site#99).
4. Data sources and dependencies reviewed¶
4.1 How wind forecasts are fetched (point 1)¶
| Route | Providers | Fit |
|---|---|---|
| Open-Meteo APIs (forecast, ensemble, single-run) | IFS (ecmwf_ifs), GFS (ncep_gfs013), WeatherNext 2 (google_weathernext2_ensemble) |
taken: one JSON client for every Open-Meteo provider, cell_selection=nearest for every model, no key for non-commercial use, ~10,000 calls a day |
| Google's WeatherNext 3 channels (BigQuery, Earth Engine, GCS Zarr) | WeatherNext 3 | taken for WN3 only: not on Open-Meteo (§4.3) |
| The originals (ECMWF open data, NOAA NOMADS or AWS) | IFS, GFS | rejected as the main route: GRIB2 decoding on the JVM and a grid rule per provider; kept as #723 step 2's one-off cross-check of Open-Meteo's re-gridding |
Open-Meteo facts (v its docs pages, 2026-10-09):
- IFS HRES 9 km: 4 runs a day, hourly to 90 h, up to 15 days; single runs kept from 2024-03-14, so a missed run is backfilled.
- GFS: 0.11° for surface fields, hourly to 120 h then 3-hourly, 16 days, 4 runs a day; single
runs kept from 2026-04-02. The id
ncep_gfs013is from Open-Meteo's website source ⚠ until a live call records it. - WeatherNext 2: 64 members, 0.25°, 6-hourly native (
temporal_resolution=native), 15 days; Open-Meteo processes only the 00 and 12 UTC runs; no single-run archive. - Each model's metadata JSON gives
last_run_initialisation_timeandlast_run_availability_time; wait 10 minutes after the latter. The URL pattern is ⚠ until task 4 records it.
4.2 How forecast temperature is fetched (point 2)¶
The same requests carry temperature_2m: IFS, GFS and WeatherNext 2 all list it (v Open-Meteo
docs, 2026-10-09; WeatherNext 2 also lists its ensemble spread). No second client is needed.
WeatherNext 3's station-trained 2 m temperature comes at 0.05° (§4.3). Observed temperature comes
from the same METAR and INMET records as the wind (§4.5).
4.3 WeatherNext 3 (point 3)¶
Announced 2026-09-03: 64 members, a run every UTC hour, 15 days for the 00/06/12/18 cycles and 48 h for the others; 2 m temperature and dew point at 0.05°, surface wind at 0.1° (v WinBuzzer, 2026-09-05). Access (v developers.google.com/weathernext access guide, 2026-10-09): a Data Request Form with a Google account, usually approved in 5–7 business days, one approval for Cloud Storage, BigQuery and Earth Engine; researchers and students qualify; no paid contract needed. BigQuery and Earth Engine carry the surface mean and percentiles, GCS the full ensemble as Zarr (Requester Pays). Data older than 1 h is CC BY 4.0; real-time data is under Google's experimental terms.
Outreach (TODO, a person's act): apply through the form, preferably as UFRJ (Hoffmann's Poli
account) and UFSC (LabECO, the ww3-gpu co-advisors), naming #723 and this MIP; record the answer
here. Until then WeatherNext 3 has no providers.json row. The client reads BigQuery's
statistics (mean and percentiles) at the registry's points: CRPS needs members, so WN3 is scored
on its mean until the Zarr route is costed ⚠.
4.4 Providers: two in v0, the rest added without code (point 5, amended 2026-10-09)¶
The owner set v0 at the minimum, two providers, with later ones arriving without friction. The two are the ones that cost least and lose nothing when a cycle is missed:
| Provider | Publisher | Kind | Members | Runs sampled | Backfill | In |
|---|---|---|---|---|---|---|
| IFS HRES 9 km | ECMWF | physics | 1 | 00/06/12/18 | single-run API | v0 |
| GFS | NOAA/NCEP | physics | 1 | 00/06/12/18 | single-run API | v0 |
| WeatherNext 2 | Google DeepMind | AI | 64 | 00/12 | none: runs before it is added are lost | a providers.json row |
| AIFS, AIGFS, ICON, IFS ENS, GEFS | ECMWF, NOAA, DWD | AI and physics | 1–51 | per model | per model ⚠ | a providers.json row each |
| WeatherNext 3 | Google DeepMind | AI | 64 (BigQuery mean and percentiles) | 00/06/12/18 | Google's archive ⚠ | after access (§4.3): a new route |
| MONAN | INPE | physics | 1 | 00/12 | ⚠ | once INPE publishes output: a new route |
What makes adding one frictionless. A provider is data, not an enum case. A providers.json
row gives id, route, the route's model id (ecmwf_ifs, ncep_gfs013,
google_weathernext2_ensemble, …), the runs, members, lead range and added_on. route is the
one closed enum (open_meteo, weathernext3_bigquery, monan, marola_analog), with one
ForecastSource per route:
- A model Open-Meteo serves is one reviewed JSON row. No Scala change, no new spec beyond
ProtocolSpecreading the row, and a recorded fixture for its first live call. - A model behind a new channel (WeatherNext 3, MONAN) adds one route and one source.
- Every score, the scorecard and MLflow key on the provider id, so nothing downstream changes.
- A provider starts on its
added_ondate. Its window fills from there, and the scorecard shows it aslow_sampleuntilmin_n.
The cost of starting with two is WeatherNext 2: Open-Meteo keeps only its latest run, so the El Niño months before its row lands are not recoverable. Adding it is one row on the day the owner wants it.
UKMO and others follow the same rule. The 2025 NHC verification (GFS's 120 h track error 362.3 n mi, the largest; DeepMind's ensemble best to 72 h) and Hurricane Isaias's split between AI and physics guidance (outcome pending) are context, not evidence for this coast (v NHC Verification_2025.pdf; M. Lowry, 2026-10-07).
4.5 Ground truth¶
- METAR (
https://aviationweather.gov/api/data/metar?ids=SBFL&format=json): no key, 100 requests a minute, a custom User-Agent; only the last 15 days are kept (v aviationweather.gov data API page, 2026-10-09). Wind in knots, temperature in °C. - INMET automatic stations: hourly 10 m wind and 2 m temperature; the API at
apitempo.inmet.gov.brmay need a token ⚠; whether its wind is a 10-minute or hourly mean ⚠. - NOAA CPC's Niño-3.4 anomaly and ENSO status, stored with every cycle (#723) ⚠ file not fetched.
4.6 MLflow on B2 (point 6)¶
MLflow stores artifacts on Backblaze B2 natively: b2://<bucket>@s3.<region>.backblazeb2.com/<path>
with AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from a B2 application key (the master key
does not work with the S3 API), AWS_DEFAULT_REGION=us-east-005, and boto3 beside MLflow (v
MLflow artifact-store docs, 2026-10-09). The tracking store stays a SQLite file, downloaded and
uploaded per cycle as MIP-0075 does its catalog. marola pins MLflow 3.16.0.
4.7 B2 from Scala (point 7)¶
MIP-0075 §4.5 already settled it: org.duckdb:duckdb_jdbc 1.5.6.0 with the httpfs and
ducklake extensions, an OodsStore trait, wrapped in Kyo at the boundary (Sync.defer per
call, Scope for the connection), B2 reached as S3 at s3.us-east-005.backblazeb2.com, the secret
built from the environment and never PERSISTENT. This MIP adds tables to that lake (§5.6) and no
second S3 client. Compared: the AWS SDK v2 S3 client with an endpoint override (works, ~10 MB of
jars, but writes objects, not tables); kyo-sql (no DuckDB dialect at RC7, MIP-0075 §4.5).
4.8 Window, cadence and registry (point 9)¶
The experiment window is 90 days, the cadence every 4 hours; both are values in
protocol.json, not code, so a change is a reviewed commit whose git sha every MLflow run
records. The cron line is the one place the cadence also lives (§5.9).
4.9 Kyo modules (point 8)¶
Kyo 1.0.0-RC7, marola-app's pin. Its repository at tag v1.0.0-RC7 (1d54c02, 2026-09-28) has 70
modules; 62 are on Maven Central as io.getkyo:<m>_3:1.0.0-RC7 (each .pom fetched 2026-10-09;
kyo-bench, kyo-compat, kyo-examples, kyo-scheduler-finagle, kyo-test, two test kits and
the website are not published).
| Module | Use here | |
|---|---|---|
kyo-core, kyo-direct, kyo-combinators |
effects at the I/O boundary, direct style | already in marola-app |
kyo-prelude, kyo-data |
Abort, Env, Var, Emit; Chunk, Maybe, Result |
pulled in by kyo-core |
kyo-config |
StaticFlag for the lake path, MLflow URI and dry-run switch |
add; the protocol stays a file (§4.8) |
kyo-schema, kyo-schema-json |
derives Schema codecs for the registry, protocol and export |
add, replacing hand-written readers like task 1's; checked against the jar before use (.claude/agents/jar-verifier.md) |
kyo-stats-registry, kyo-stats-otlp |
counters per cycle | later, if MLflow's metrics are not enough |
kyo-http |
HTTP client | not now: marola's Http has the fixture seam every spec uses, and RC7's client has no proxy class (MIP-0075 §4.6) |
kyo-flow |
durable workflows | rejected: a cycle is idempotent and re-run from the lake (§5.5), so a workflow engine's persistence buys nothing |
kyo-sql, kyo-sql-sqlite |
SQL | not usable: no DuckDB dialect; MLflow's SQLite is MLflow's, read only through its REST API |
kyo-stm, kyo-actor, kyo-reactive-streams |
concurrency | not needed: one cycle, one writer |
the rest (kyo-ai, kyo-browser, kyo-mcp, kyo-pod, kyo-ui, kyo-zio, …) |
— | unrelated to this job |
4.10 Operational WeatherBench for the South: what Brazilian open data can and cannot replicate (amended 2026-10-09)¶
The long-term goal (owner, 2026-10-09) is a South-region counterpart of Brightband's Operational WeatherBench (OWB). What OWB does (v its methodology page, 2026-10-09):
- It scores IFS ENS and HRES, AIFS ENS, GFS, GEFS, AIGFS, AIGEFS, WeatherNext 2 and 3 and Brightband's own runs of GraphCast, Aurora 1.5 and Atlas, against an ERA5 climatology baseline.
- It runs every 00/06/12/18 UTC cycle at leads every 6 h to 360 h, with a scorecard at days 1, 3, 5, 7, 10 and 15 and a 12Z-only ranking.
- Truth is each model's own analysis on a 0.25° global grid, with no regridding and areas weighted by cos(latitude). IFS analysis is the truth for WeatherNext and the climatology, ERA5 for precipitation, and no station, buoy or radiosonde is used.
- Its metrics are RMSE, ACC for Z500, 850 hPa vector-wind RMSE and 10 m wind speed. For 24 h precipitation it uses SEEPS. For ensembles it uses fair CRPS, spread, debiased ensemble-mean RMSE and spread-skill ratio, from K = 16 strided members.
- Windows are aggregated by component: squares are averaged then rooted, and the spread-skill ratio is rebuilt from window components.
- The scoring library is WeatherBench-X (Python), and the dashboard is CC BY 4.0.
The Brazilian sources it can draw on:
| Source | What | Access (checked 2026-10-09) |
|---|---|---|
| INMET automatic stations | hourly 10 m wind, gust, 2 m temperature, pressure, precipitation | apitempo.inmet.gov.br ⚠ token, §4.5 |
| METAR (DECEA airports, via aviationweather.gov; IEM archive) | 10 m wind, temperature, pressure | §4.5; DECEA's own REDEMET API needs a key ⚠ |
| PNBOIA (Marinha/CHM) moored and drifting buoys | wind, pressure, waves, SST; offshore SC, RS (three new Pelotas Basin stations in 2026), Santos Basin | free download through the BNDO, also sent to the GTS; ~16 million qualified observations 2002–2026 (v marinha.mil.br/chm/node/70352 and /chm/bndo/acesso). File format, wind averaging and latency ⚠ |
| SIMCOSTA (FURG) coastal buoys | wind, waves, met | ⚠ not reached; access unverified |
| CHM METAREA V warnings (Navy) | gale and near-gale warnings by area (force 7–9), issue time and validity in UTC, pt and en text | a web page with no archive (v marinha.mil.br/chm/node/1046602); must be archived by us from day one |
| BNDO meteorological stations and radiosondes | station and upper-air records | free download or the request form; a Termo de Compromisso may restrict redistribution (v /chm/bndo/acesso) |
| Radiosondes Florianópolis and Porto Alegre | 850 hPa wind and temperature, 00/12 UTC | IGRA or Wyoming ⚠ station ids not checked |
| MERGE (INPE/CPTEC) | daily gauge-plus-GPM precipitation, 0.1°, South America, from 2000-06-01 | GRIB2, CC BY 4.0, BDC STAC prec_merge_daily-1 (v data.inpe.br, 2026-10-09) |
| MONAN (INPE) | Brazil's own global model, 10 km, two runs a day, 11 days, operational since September 2026 | public output ⚠ not found (v acessa.com, 2026-09-03) |
| ERA5 | climatology baseline | Open-Meteo's historical API (point) or ARCO-ERA5 ⚠ |
What this experiment can replicate
| OWB element | Here | How |
|---|---|---|
| Models IFS HRES, GFS, AIGFS, AIFS (deterministic), WeatherNext 2, WeatherNext 3 | yes | Open-Meteo (§4.1) and Google (§4.3); each a providers.json row (§4.4) |
| IFS ENS, GEFS, AIFS ENS, AIGEFS members | partly | Open-Meteo's ensemble API carries IFS ENS, GEFS and AIFS ENS ⚠ per model, at points |
| Brazil's own model (MONAN), which OWB lacks | yes, once INPE publishes output | task 20 |
| 00/06/12/18 cycles, 6-hourly leads to 360 h, scorecard days 1/3/5/7/10/15, a 12Z ranking | yes | §5.5, lead range raised to 360 h |
| RMSE, bias, fair CRPS, spread, debiased ensemble-mean RMSE, spread-skill ratio, K = 16 strided members | yes | all are sums, so all are score-monoid cells (§5.2), and window aggregation matches OWB's by construction |
| 10 m wind speed as a scalar, ensemble mean of member speeds | yes | OWB's rule, adopted |
| 850 hPa vector-wind RMSE | at the radiosonde points only | task 13 |
| 24 h precipitation SEEPS | yes, against MERGE at stations and over the South box | a 2000–2025 MERGE climatology gives SEEPS's dry probability and thresholds; task 15 |
| Climatology baseline | yes, per point | ERA5 point series, or the station's own record, as the skill floor |
| CC BY 4.0 scorecard, open method | yes | §5.8, with each source's licence carried through |
What it adds that OWB does not have
- Verification against observations: stations, buoys, radiosondes and MERGE, not only each model's analysis.
- Offshore wind truth from PNBOIA.
- The Navy's warnings as a forecast to verify. Hit rate, false-alarm ratio and lead of CHM force ≥ 7 warnings per area, against PNBOIA and coastal stations, with each model's probability of force ≥ 7 scored alongside.
What it cannot do, and why
- Run GraphCast, Aurora or Atlas. That needs GPUs on every cycle: Phase 2 money (MIP-0057), with no free path.
- Score each model against its own analysis on a grid, which is OWB's default. Open-Meteo serves points. Grids mean GRIB2 from ECMWF, NOMADS or AWS and WeatherNext through Earth Engine, which is GRIB decoding and gigabytes per cycle. Not in the Kyo module. A later marola-ml job could run WeatherBench-X on a South crop (about 20–35°S, 40–58°W) if the owner wants grid scores; that is task 21, a decision, not a default.
- ACC for Z500, global and hemispheric regions: these need the grid path above.
- Compare numbers with OWB's directly. Station truth includes representativeness error that analysis truth does not. A score here answers "how close to what was measured at this coast", not "how close to the model's own best estimate". Every page says so.
- Reconstruct the Navy warning history. No archive is published, so the record starts on the day task 14 lands. Area polygons for the verification are ⚠ until CHM's chart is digitised.
- Guarantee buoy coverage. PNBOIA has run up to nine buoys at once (2016, per CHM). A missing
buoy is a
missingrow, never a gap filled from a model. - Precipitation for WeatherNext 3, as at OWB, and hourly precipitation truth: MERGE is daily (13 UTC to 12 UTC).
- Redistribute BNDO data covered by a Termo de Compromisso. Such rows stay out of the public export and are scored privately.
5. Design¶
5.1 The flow¶
The registry decides where to sample; clients write samples to the lake; the matcher and scorer read only the lake, so a score is always recomputable from stored samples (§5.5); MLflow records each cycle; the export waits for the ground-truth gate (§5.8). marola's analog model (§5.11) reads the observation history and today's large-scale forecast and writes its own forecast back to the lake as one more provider.
5.2 Category-theory principles, in Kyo¶
The module is written so that its laws are testable, not as theory for its own sake:
- Scores are commutative monoids. A score cell (provider, point, lead, bin) keeps
n,Σe,Σe²,Σ|e|and the CRPS sums;combineadds them andemptyis zeros. Bias, RMSE and mean CRPS are read off at the end. Per-day partials then combine into any window in any order, so the 90-day score is a fold over 90 daily cells,rescoreequals the logged score by construction, andScoreMonoidSpecchecks associativity, identity and commutativity on fixed cases. - Providers are natural transformations into one sample type. Each client maps its native
payload into
ForecastSample(provider, run, point, valid time, lead, variable, value, members); unit conversion is amapon the value, and the lawsmap(identity) == identityandmap(f andThen g) == map(f) andThen map(g)hold by being plain case-class copies. - Matching is a pullback. Forecasts and observations are joined over the shared key (point, valid time); nothing else may pair two samples.
- The cycle is Kleisli composition in Kyo.
fetch: Point => Chunk[ForecastSample] < (Sync & Abort[FetchError]),store,match,scoreandlogcompose withmap/flatMap; the effect row in each signature is the only place I/O may happen. Pure parts (registry, matcher, scorer) carry no Kyo effect at all (marola-app'sscala.md).
5.3 Where the code lives¶
experiment/ in marola-app, dependsOn(core, local) for Http, JsonValue, RunLedger and
OodsStore. marola-app#72's verify project is renamed experiment and its GroundTruth becomes
part of the registry (task 2).
File (experiment/src/main/scala/marola/experiment/) |
What |
|---|---|
GroundTruth.scala |
the instruments and their lifecycle (from #72) |
SamplingRegistry.scala |
sampling points (§5.4) |
Protocol.scala |
providers, variables, leads, bins, window, cadence, min_n |
Provider.scala |
Provider (a providers.json row) and enum Route(label): OpenMeteo, WeatherNext3BigQuery, Monan, MarolaAnalog (§4.4, §5.11) |
Analogs.scala |
§5.11: the analog library, the search and the analog members |
ForecastSource.scala |
trait ForecastSource { def latestRun: Maybe[Instant] < (Sync & Abort[FetchError]); def fetch(run: Instant, points: Chunk[SamplingPoint]): Chunk[ForecastSample] < (Sync & Abort[FetchError]) } |
OpenMeteoForecasts.scala |
the v0 route; WeatherNext3Forecasts.scala and MonanForecasts.scala later |
Observations.scala |
MetarClient, InmetClient → ObservationSample |
Score.scala |
the monoid (§5.2) |
Matcher.scala, Scorer.scala |
§5.5 |
LakeSamples.scala |
the lake tables over OodsStore (§5.6) |
Cycle.scala, Export.scala, Main.scala |
cycle, rescore, screen, export |
5.4 The sampling-point registry (point 9)¶
experiment/src/main/resources/experiment/sampling-points.json, checked by SamplingRegistrySpec:
every point has id, lat, lon, kind (station: tied to a ground-truth instrument and
scorable; coast: a beach or offshore point, sampled and never scored), ground_truth (the
instrument id, for station), added_on and retired_on. A point is sampled from added_on; a
retired point keeps its rows. The ground-truth file (#72) stays the source of the instruments'
status; the registry only says where forecasts are sampled. v0's registry is #72's nine
candidate stations, one station point each, and no coast point.
5.5 Sampling, matching and scoring¶
- Grid rule: the nearest cell for every provider (
cell_selection=nearest; nearest on WN3's grid); the cell centre is stored with every sample, and the station-to-cell distance and grid spacing are reported beside every score. - Time grid: valid times 00/06/12/18 UTC (WeatherNext 2's native step); leads every 6 h from 6 to 360 h, as OWB scores; scorecard days 1, 3, 5, 7, 10 and 15 (§4.10), and a 12Z-only ranking.
- Ensembles: K = 16 members drawn at strided positions, as OWB does; scored with fair CRPS, spread, debiased ensemble-mean RMSE (MSE − s²/K) and the spread-skill ratio rebuilt from window sums.
- Runs: each cycle reads every provider's latest run time, samples a run not yet in the run
index, then walks the expected runs since the last cycle: a missed IFS or GFS run is
backfilledfrom the single-run API, a missed WeatherNext 2 run ismissingwith a reason. A missing run is a row, never a gap filled from a neighbour. - Pairs: a METAR within ±10 min of the valid time, or the INMET record for that hour. Wind speed (m/s) and direction (scored only when observed speed ≥ 2 m/s), 2 m temperature (°C).
- Scores: bias, RMSE, fair CRPS (equal to absolute error for a single run), direction MAE,
n,days,low_sample = n < min_n; bins by observed wind (calm < 5.5, moderate 5.5–10.8, strong ≥ 10.8 m/s); windows 7, 30 and 90 days. - Error correlation: each cell also keeps
Σeᵢeⱼfor every pair of providers on shared pairs, so the error correlation matrix is read off any window like the other scores (§5.11).
5.6 What goes where: MLflow and the lake (points 6 and 7)¶
| Store | Holds | Why there |
|---|---|---|
OODS lake on B2, tables experiment_point, forecast_sample, observation_sample, run_index |
every sample, partitioned by provider and month | the record the paper and rescore read; queryable with DuckDB by anyone with read access |
MLflow (SQLite file and artifacts under b2://…/mlflow/) |
one run per cycle: params, the cycle's daily score cells as metrics, the export | the comparison UI and lineage of each cycle |
The lake writes go through a SampleStore trait. v0's implementation is a local DuckLake
directory over duckdb_jdbc (MIP-0075 §4.5's engine and extensions), so v0 does not wait for
MIP-0075, which is still a Draft. When MIP-0075's OodsStore lands, a second SampleStore writes
the same tables to the B2 lake and shares its oods-lake concurrency group, so there is one
writer for the lake and MLflow's file alike.
One experiment per protocol version: a change to the protocol or the registry starts
forecast-benchmark-v2, so no run mixes two protocols. Metrics are logged once a day (about 900
rows a day per provider at two variables and four headline leads ⚠ to measure); scores.json
keeps every window, bin and lead.
5.7 Ground truth first¶
marola-app#72's ground-truth.json and docs/4-reference_ground-truth.md stay as written: the
candidate instruments, #723's strong-wind thresholds (placeholders until frozen) and the rules the
loader enforces. Every candidate is sampled from day one; none is scored until scored.
5.8 Publishing (point 4)¶
The export (forecast-benchmark/latest.json on marola-site's site-data, the schema vendored by
marola-site#99) is written only when at least one point is scored, which needs #723's frozen
thresholds and the screen (task 9). Before that, cycles sample, store and log, and the export
step is skipped with a logged reason. Each later protocol version's results are also published as
a dated Zenodo dataset by the study (#723, MIP-0079), not by this job.
5.9 One cycle on GitHub Actions¶
.github/workflows/experiment.yml in marola-app: schedule: cron "17 */4 * * *" (off minute 0),
workflow_dispatch, concurrency: oods-lake (MIP-0075's group), secrets B2_KEY_ID and
B2_APPLICATION_KEY (MIP-0075's ETL key, or a key scoped to the experiment's prefixes ⚠). Steps:
JDK 25, sbt experiment/assembly, download mlflow.db and the lake catalog with their versions,
pip install mlflow==3.16.0 boto3, start mlflow server on localhost with
--artifacts-destination b2://…, run cycle, upload mlflow.db and the catalog back, then the
export (§5.8). No cloud resource is created; no IaC is needed.
Until the B2 lake lands (§5.6), v0's workflow keeps its local lake directory and mlflow.db in
the Actions cache, restored and saved each cycle under one key. That is a stopgap: the cache is
evicted after 7 days unused and capped at 10 GB per repo ⚠, so v0's record is only durable once
the B2 SampleStore replaces it.
5.10 What is deterministic¶
Everything, marola's analog model included. No LLM touches the experiment, its scores or the export.
5.11 marola's own forecast: an analog ensemble of local observations (amended 2026-10-09)¶
The owner asked for marola's own wind forecast with errors that are not highly correlated with
the providers'. A blend of IFS, GFS and WeatherNext (the first version of this section) fails that
by construction: it is a weighted average of the very errors it should avoid. Any skilful
forecast correlates with the truth, so what can be lowered is the error correlation, and only
with information the global models lack. At a coastal station that is the local wind climate:
sea breeze, terrain channelling and the instrument's exposure, at scales a 9–25 km grid cannot
resolve. The pick is an analog ensemble (AnEn; Delle Monache et al. 2013,
doi:10.1175/MWR-D-12-00281.1 ⚠ not re-read for this amendment), marola_analog.
- How it forecasts. For a station, lead and issue time, find the k = 20 past forecasts most similar to today's in a few large-scale predictors, and issue the 20 wind and temperature values observed at those analogs' valid times. The output is drawn from what the instrument actually measured, so a bias, a missed sea breeze or a smoothed peak in the model does not pass through.
- Predictors chosen to stay away from the providers' surface wind: IFS mean sea-level pressure gradient across the point, 850 hPa wind, 2 m land-sea temperature contrast, hour of day and day of year; plus, up to 24 h lead, the latest observation. The 10 m wind is left out on purpose: it is the field the providers get wrong locally, and matching on it would copy their errors. Whether Open-Meteo's IFS single-run API serves 850 hPa wind and MSL pressure is ⚠ until task 16 records it.
- The library: IFS single runs from 2024-03-14 (Open-Meteo's archive, §4.1) at each station, paired with the Iowa Environmental Mesonet METAR archive (§11): about 2.5 years, roughly 3,600 runs per station and lead. The backfill is about 3,600 calls per station, spread over days to stay inside Open-Meteo's daily limit.
- Search: distance is the weighted sum of standardised predictor differences in a ±3 h window around the lead (Delle Monache's metric), with weights fixed by #723 on the first year and then frozen. With a few thousand candidates the search is a plain sort in Scala, with no index and no ML library.
- Strictly causal. The library holds only runs whose valid time is before the issue time, so
every member is something a forecaster could have known then.
AnalogSpecchecks that a later observation changes nothing. - An ensemble by construction: 20 members, scored with CRPS against WeatherNext's 64, so its spread is tested, not assumed.
- Measured, not claimed. §5.5's error-correlation cells (
Σeᵢeⱼper pair of providers, another monoid) give the correlation ofmarola_analog's errors with each provider's per station and lead, logged to MLflow and shown beside every score. - What to expect. Lower error correlation and a real chance to win at 0–48 h, where local effects dominate. At 5–10 days its skill comes from IFS's large-scale fields, so the correlation with IFS rises with lead; the export shows both curves.
- Wind direction is the circular mean of the members' directions, never computed from averaged u and v.
- Coast points have no observations and get no analog forecast.
- MLflow: each run logs
analog_k,analog_predictors,analog_weights_shaand the library size as params. A change to any of them starts a new experiment version (§5.6).
5.12 The schemas come first (amended 2026-10-09)¶
Every later task writes or reads one of these, so they land first as task 1, with no client and
no scorer. Each is a Scala 3 case class or enum in experiment/.../schema/ with derives Schema
(kyo-schema). The JSON form goes through kyo-schema-json's Json.encode and Json.decode,
and the lake form is a table whose DDL is written by hand and checked in; SchemaSpec checks that
its columns are the Schema's fields (RC7's Json.jsonSchema generates the JSON Schema, but
nothing generates SQL). A schema change is a new version, never an edit.
| Schema | Fields (abridged) | Stored as |
|---|---|---|
Provider, Route, Variable, Unit |
a provider row (id, route, model id, runs, members, leads, added_on); enums with label and fromLabel |
providers.json; codes in every row |
Instrument |
id, network (inmet, metar, pnboia, simcosta, radiosonde, merge), lat, lon, height, averaging, licence, status |
ground-truth.json |
SamplingPoint |
id, lat, lon, kind (station, buoy, coast, upper_air), instrument id, added_on, retired_on |
sampling-points.json |
Protocol |
version, variables, leads, scorecard days, window, cadence, K, min_n, bins |
protocol.json |
ForecastSample |
provider, run_init, fetched_at, point, valid time, lead, variable, member (or none), value, cell lat/lon, source URL |
forecast_sample |
ObservationSample |
instrument, valid time, variable, value, averaging, QC flag | observation_sample |
RunIndexRow |
provider, run_init, available_at, content_sha, state (sampled, backfilled, missing), reason, fetched at |
run_index |
ScoreCell |
provider, point, variable, lead, bin, day, n, Σe, Σe², Σ|e|, fair-CRPS sum, spread sum | score_cell |
PairCell |
provider a, provider b, point, variable, lead, day, n, Σ(eₐ − e_b), Σ(eₐ − e_b)², Σeₐe_b, Σeₐ², Σe_b² (§5.5, §5.14) | pair_cell |
NavyWarning |
number, area, force, gust, valid from/to, issued at, raw text sha | navy_warning |
Scorecard |
protocol version, window, rows per provider × variable × day, licences | forecast-benchmark/latest.json and its JSON Schema for marola-site |
SchemaSpec round-trips one fixture of each, checks the generated JSON Schema against the
checked-in file and the DDL's columns against each Schema.
5.13 Kyo at its current release¶
Kyo 1.0.0-RC7 is the newest release: Maven Central's kyo-core_3 metadata lists it as latest on
2026-09-28 (checked 2026-10-09). The experiment uses it the way its current API intends. Names
below were checked in the RC7 source and are re-checked against the jar before use.
Async.foreach(points, concurrency = n)fetches points in parallel, bounded per provider.Meter.initRateLimiterkeeps aviationweather.gov at 100 requests a minute and Open-Meteo inside its daily limit.Retry(Schedule…)(kyo-data'sSchedule) backs off on a 429 or a 5xx.Abort[FetchError]gives typed failures, so a missing run becomes aRunIndexRow, never an exception. Results stayResult, values that may be absent stayMaybe, and samples travel asChunk.Scopeowns the DuckDB connection and the MLflow subprocess.Clockis the only source of "now", so tests fix the time.LayerandEnvwire the sources, the store and the ledger. A test swaps in fixture layers, with no mocks.kyo-config'sStaticFlagcarries the lake path, MLflow URI and dry-run switch.Logcarries per-cycle lines.Streampages lake reads forrescore, so a 90-day window is never loaded at once.- Outside Kyo: the registry, protocol, matcher, score monoid, analogs and scorecard are pure and carry no Kyo effect.
5.14 Fair comparison when runs are not fetched in the same window (amended 2026-10-09)¶
Providers publish on different schedules: IFS and GFS run four times a day, WeatherNext 2 twice, and each becomes available hours after its start, at different delays. A cycle every 4 h fetches whatever is newest, so two providers' samples in one cycle can come from runs hours apart. When a forecast was fetched must never decide its score. These rules make the comparison fair:
- Every sample is keyed by its run, not by its fetch. A
ForecastSamplestoresrun_init(the model's initialisation time, from the provider's metadata) andfetched_atseparately.lead = valid_time − run_init. A run fetched 20 minutes or 2 days after it appeared gives the same rows. - A run's content is pinned. The fetch asks for that run by its init time (Open-Meteo's
single-run API) and checks the response's init against it. A response from the "latest"
endpoint is accepted only if the metadata reports the same
last_run_initialisation_timebefore and after the call; otherwise it is discarded and retried, so a newer run is never stored under an older run's time. Each run's rows carry a content hash. Abackfilledrun matching an earliersampledcopy of the same run is checked hash-for-hash, and a mismatch is aRunIndexRowwith a reason. Whether Open-Meteo's archive is byte-identical to its live output is ⚠ until task 4 records an overlap. - Head-to-head scores use the common set only. Provider A beats B only over the (point,
run_init, lead, valid time) cells where both have a sample and an observation exists: paired, pairwise intersection. A run one of them missed drops the cell for that pair, not for the others. The scorecard's ranking uses the intersection across every ranked provider, as OWB ranks on the 12Z cycle alone. - Same init cycles. A provider that runs only at 00/12 UTC is compared on 00/12 inits. The 12Z ranking (§4.10) uses only 12Z runs from every provider. Valid times stay on the 00/06/12/18 grid, so hourly models are not judged on hours a 6-hourly model never forecasts.
- Two views, both labelled. The rules above give model skill: same init, same lead.
Users, though, act on whatever forecast exists when they ask. A second view, issued skill,
compares the latest run each provider had made available at a fixed issue time (00/06/12/18
UTC). Availability comes from
last_run_availability_time. Lead is counted from the issue time, so a slow publisher pays for its latency there. The scorecard shows model skill by default and issued skill beside it. - Same window, same days. A provider added later (§4.4) is ranked only over the days it
shares with the others. The scorecard prints each provider's first day, and
nfor each pair. - Same ensemble footing. K = 16 strided members and fair CRPS (§5.5) for every ensemble. A deterministic run is a one-member ensemble, so CRPS equals absolute error and nothing is padded.
- A win must beat chance. Each head-to-head difference gets a 95% interval from a paired block bootstrap over days, because errors on one day are correlated across leads and points. A difference whose interval contains zero is shown as a tie. The day blocks keep the sums monoid-friendly: per-day paired-difference cells are combined and then resampled.
run_init, available_at, fetched_at and content_sha join §5.12's ForecastSample and
RunIndexRow, so the schemas carry fairness from task 1.
6. Scoring / safety impact¶
None. Swimability.score reads nothing this produces; using the winning model there is a later
decision made with this data (#724).
7. Verification plan¶
munit specs in experiment/, fixtures replayed through Http.withTransport, the lake on a local
directory as MIP-0075 tests it:
SchemaSpec:every_schema_round_trips,json_schema_matches_checked_in,ddl_columns_match_schema_fields,unknown_label_is_malformed.GroundTruthSpec(#72's eight cases).SamplingRegistrySpec:station_point_needs_ground_truth,coast_point_never_scored,retired_point_keeps_rows.ProtocolSpec:cadence_and_window_read_from_file,unknown_provider_is_malformed.ForecastClientSpec:ifs_gfs_wind_and_temperature_parsed,request_built_from_providers_row,cell_selection_nearest_sent,gfs_gap_backfilled_from_single_run,rate_limit_429_retried; with task 11,ensemble_members_strided_to_16andno_archive_gap_recorded_missing.ObservationClientSpec:metar_knots_to_ms_and_temperature,inmet_hour_parsed.ScoreMonoidSpec:combine_is_associative,empty_is_identity,combine_is_commutative,ninety_daily_cells_equal_one_window,crps_of_single_run_is_abs_error— values worked by hand.MatcherSpec:pairs_only_same_point_and_valid_time,metar_outside_10_min_is_missing.FairnessSpec(§5.14):lead_counts_from_run_init_not_fetch,late_fetch_gives_identical_rows,run_changed_during_fetch_is_discarded,backfill_hash_mismatch_recorded,head_to_head_uses_pairwise_intersection,twelve_z_ranking_uses_12z_inits_only,issued_skill_charges_latency,late_provider_ranked_on_shared_days,bootstrap_interval_containing_zero_is_tie— on hand-built runs with staggered availability.LakeSamplesSpec:samples_round_trip_through_local_lake,rerun_cycle_writes_no_duplicates.CycleSpecwith a recordingRunLedger:cycle_logs_params_and_artifacts,export_skipped_until_a_point_is_scored,failed_fetch_ends_run_failed.ExportSpec:export_matches_schema.AnalogSpec:twenty_nearest_by_weighted_distance,members_are_observed_values,later_observation_does_not_change_forecast,ten_m_wind_not_a_predictor,direction_is_circular_mean— on a hand-built library of 30 runs.ScoreMonoidSpecalso:error_correlation_of_identical_errors_is_one,fair_crps_matches_weatherbenchx_case,debiased_ens_mean_rmse,spread_skill_rebuilt_from_window,seeps_uninformed_is_one_perfect_is_zero,strided_k16_members.MarineObservationSpec(PNBOIA, SIMCOSTA),UpperAirSpec,NavyWarningSpec(metarea_v_text_parsed_pt_and_en,warning_hit_and_false_alarm_counted),MergePrecipSpec.
Live, after the workflow lands: two dispatches back to back (the second waits on oods-lake), and
just experiment-rescore equal to the last cycle's scores.json. Done when 7 days of
unattended cycles show in MLflow with no unexplained gap in run_index.
8. Risks, limitations, and honest caveats¶
- Open-Meteo re-grids the originals; #723 step 2's cross-check stays a task of the study.
- Point verification favours the finer grid near a coast; the cell distance is reported, not removed.
- METAR and INMET measure differently (10-minute mean vs possibly hourly ⚠); scores are split by instrument kind.
- GitHub drops scheduled runs under load; a dropped cycle loses no WeatherNext 2 run unless three in a row are dropped (runs are 12 h apart).
- marola's analog model can look better than it is. A library that peeks at the future, or predictor weights tuned on the scored window, would flatter it. The library is strictly causal and the weights are frozen on a year outside the window (§5.11).
- Its library is short. About 2.5 years holds few analogs for rare states; strong-wind members are sparse, and the strong bin's score will say so.
- It is conditioned on IFS. If IFS's archive or grid changes (a cycle upgrade), old analogs stop matching new forecasts. The library restarts from the upgrade date, which the run index records.
- The ensemble mean is not a forecast anyone issued; CRPS is the fair comparison and the export shows both.
- WeatherNext 3 may never be granted, or only its statistics; the experiment does not wait for it (§4.4).
- v0's two providers are both physics models, so v0 answers "IFS or GFS here", not "AI or physics". The AI question starts on the day WeatherNext 2's row lands, and its pre-row runs are gone.
- Sharing the lake's writer group makes the experiment wait behind a long ETL; at 4-hour cadence that costs minutes.
9. Alternatives considered¶
- Do nothing / #723 alone: stale at the next model upgrade, and the El Niño WeatherNext 2 members are lost.
- A new repo (marola-eval) in Scala, Rust or Haskell: proposed earlier on 2026-10-09 and withdrawn by the owner; the app keeps its HTTP, JSON and ledger code by staying here.
- GCS for MLflow, with WIF and Besom: a second cloud for the one bucket B2 already provides.
- An always-on MLflow: Phase 2 (MIP-0057).
- GRIB from the originals: §4.1.
kyo-flowfor durability: §4.9.- A stacked blend of the providers for marola's forecast (the first version of §5.11): its errors are a weighted average of the providers', the opposite of what the owner asked for.
- A regional model of marola's own (WRF): paid compute every cycle, and its boundary conditions come from GFS or IFS, so its errors inherit theirs.
- A learned post-processor (gradient boosting, a neural net) on the providers' output: more parameters than 2.5 years of 6-hourly pairs support, and correlated with its inputs; marola-ml if the analog model leaves skill on the table.
11. Open questions¶
- Which 5-year archive does the strong-wind screen read, given METAR keeps 15 days? Default: the Iowa Environmental Mesonet METAR archive and INMET's historical files ⚠, checked in task 9; #723 freezes the thresholds.
- Does INMET's API need a token? Default: if yes, an Actions secret
INMET_TOKENthe owner requests; METAR-only points run meanwhile. - WeatherNext 3: statistics or full ensemble? Default: BigQuery statistics (mean and percentiles) until the Zarr route's Requester Pays cost is stated and confirmed by the owner.
- A B2 key scoped to the experiment, or MIP-0075's ETL key? Default: a scoped application key, created by the owner.
- Is
min_n = 30right? Default: yes until #723 calibrates it. - Is marola's analog forecast shown on marola.dev, and under what name? Default: not
before it beats the best single provider's 30-day CRPS at a
scoredpoint for some lead. Then it is shown as "marola (from this station's own record)", marked experimental, with its error correlation beside it. - Which predictor weights? Default: equal weights until #723 fits them on the first library year, then frozen.
Appendix¶
Checked live¶
- https://open-meteo.com/en/docs and its ecmwf, gfs, google-weathernext, single-runs and licence
pages, 2026-10-09: ids, grids, steps, runs,
temperature_2m, backfill dates; ids also read from github.com/open-meteo/open-meteo-website at its 2026-10-09 tip. - https://developers.google.com/weathernext/guides/access-forecast, 2026-10-09: WeatherNext 3 access, channels, licence. https://winbuzzer.com/2026/09/05/google-weathernext-3-hourly-runs-finer-local-forecasts-xcxwbn/, 2026-10-09: WN3's runs and grids.
- https://mlflow.org/docs/latest/self-hosting/architecture/artifact-store/, 2026-10-09:
b2://URIs, application key,AWS_DEFAULT_REGION. - github.com/getkyo/kyo at
v1.0.0-RC7(1d54c02) and each module's.pomon Maven Central, 2026-10-09: the module list of §4.9. - https://aviationweather.gov/data/api/, 2026-10-09: METAR endpoint, limits, 15 days.
- https://www.nhc.noaa.gov/verification/pdfs/Verification_2025.pdf and M. Lowry's 2026-10-07 post, 2026-10-09: §4.4's context.
-
MIP-0075 §4.3–§4.5 (B2, DuckLake, DuckDB from Scala) at marola
main, 2026-10-09. -
https://owb.brightband.com/methodology, 2026-10-09: OWB's models, truth, metrics, leads, aggregation and caveats (§4.10).
- https://www.marinha.mil.br/chm/bndo/acesso, /chm/node/70352 and /chm/node/1046602, 2026-10-09: BNDO access and terms, PNBOIA, METAREA V warnings.
- https://data.inpe.br/bdc/stac/v1/collections/prec_merge_daily-1, 2026-10-09: MERGE.
- Maven Central
io/getkyo/kyo-core_3/maven-metadata.xml, 2026-10-09: RC7 islatest; §5.13's names in the RC7 source (Async.foreach,Meter.initRateLimiter,Retry,Schedule,Scope,Clock,Layer,Stream,StaticFlag,Schema,Json.encode/decode).
Not checked¶
- Any live call to Open-Meteo, aviationweather.gov or INMET (this session's proxy refused them; the project's environment now allows all hosts, so tasks 4 and 5 record the first fixtures).
kyo-configandkyo-schema-jsonagainst their jars; whether MLflow 3.16.0's server needsboto3alone forb2://.- INMET's token and wind averaging; CPC's file; the Open-Meteo metadata URL.
- WeatherNext 3's BigQuery schema and cost per query.
- PNBOIA's file format, wind averaging and latency; SIMCOSTA access; radiosonde station ids; MONAN's public output; Open-Meteo's ensemble coverage per model; CHM's area polygons.