Skip to content

Commit 4baa618

Browse files
committed
Merge branch 'zed/models'
2 parents 6c80a62 + 375e69c commit 4baa618

11 files changed

Lines changed: 226 additions & 133 deletions

CONTEXT.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
# MeteoCompare
22

3-
Multi-model weather forecast comparison. The app pulls deterministic numerical-weather-prediction (NWP) forecasts from many models via open-meteo, weights them, and produces a single aggregate forecast plus a per-timestep confidence score. A secondary verification surface compares past forecasts against a reference reanalysis field to expose which models (and the aggregate) were actually right.
3+
Multi-model weather forecast comparison. The app pulls operational forecast products from many models via open-meteo, weights them, and produces a single aggregate forecast plus a per-timestep confidence score. A secondary verification surface compares past forecasts against a reference reanalysis field to expose which models (and the aggregate) were actually right.
44

55
## Language
66

77
### Models & runs
88

99
**Model**:
10-
A specific NWP product exposed by open-meteo, e.g. ECMWF IFS or DWD ICON-EU. One logical model per row in `src/domain/models.ts`.
10+
A specific forecast product exposed by open-meteo, e.g. ECMWF IFS HRES, DWD ICON-EU, or ECMWF AIFS. One logical model per row in `src/domain/models.ts`.
1111
_Avoid_: provider, source, dataset.
1212

1313
**Probability graft**:
1414
open-meteo derives `precipitation_probability` only from ensembles, so deterministic models return null for it. Where a model has an ensemble-backed _seamless_ sibling, we fetch that sibling solely for the probability variable and read its series under the registered model's id — the _graft_. Today `icon_seamless` is grafted onto DWD ICON (`icon_global`). The **graft source** (`icon_seamless`) is never a Model: no registry row, no chip, no aggregate vote of its own; it supplies one variable under the host model's identity and weight. Note we consume open-meteo's _derived_ probability, never raw ensemble members (see "Aggregate").
1515
_Avoid_: registering a graft source as a Model.
1616

1717
**Model class**:
18-
Resolution-and-scope tier: `global`, `regional-mid`, or `regional-cam` (convection-allowing). Drives lead-time decay and precipitation boost.
18+
Resolution/product-and-scope tier: `global`, `regional-mid`, `regional-cam` (convection-allowing), `ai` (machine-learned single forecast product), or `ensemble-mean` (mean of ensemble members exposed as one forecast product). Drives lead-time decay, precipitation boost, and how strongly a model contributes relative to deterministic NWP products.
1919

2020
**Home region**:
2121
The geographic bounding box where a regional model has a structural advantage. Drives the region bonus inside the weighting recipe. Global models have no home region.

README.md

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Frontend-only (Vue 3 + Vite). Forecasts come straight from [open-meteo.com](http
66

77
## Features
88

9-
- **11 NWP models**, automatically dropped in/out based on geographic coverage and forecast horizon.
9+
- **21 forecast models/products**, automatically dropped in/out based on geographic coverage and forecast horizon.
1010
- **Aggregate-first UI**: temperature + ±1σ confidence band, precipitation bars, daily strip with weather icon / high / low / precip prob / wind.
1111
- **Confidence score** per timestep — derived from inter-model spread normalised against typical seasonal spread, a model-count penalty, and lead-time decay encoded in the model weights.
1212
- **Multi-model breakdown** (opt-in) — spaghetti chart of every contributing model with per-model toggles, switchable between temperature, precipitation, precipitation probability, wind speed, and cloud cover.
@@ -22,7 +22,7 @@ Per timestep and per variable:
2222
2. **Weight them.**
2323
- Base weight = 1.
2424
- Region bonus of +0.2 (mid-resolution) or +0.3 (convection-allowing) when the location is inside the model's home region.
25-
- Lead-time decay per model class: convection-allowing models fade out by 60 h, mid-resolution regionals by 120 h, globals decay gently from 72 h → 0.4× by 240 h.
25+
- Lead-time decay per model class: convection-allowing models fade out by 60 h, mid-resolution regionals by 120 h, globals decay gently from 72 h → 0.4× by 240 h, and AI plus ensemble-mean products follow global decay with a smaller vote.
2626
- Variable boost: CAMs get ×1.3 for precipitation, since they explicitly resolve convection.
2727
3. **Aggregate**:
2828
- **Temperature / precip / cloud cover / wind speed** → weighted mean + weighted standard deviation.
@@ -50,28 +50,36 @@ Weather codes have no meaningful stdDev, so they use severity-group agreement in
5050
`confidence = clamp(weightShare(same severity group) × modelFactor, 0, 1)`.
5151

5252
Lead-time decay is handled entirely in the model weighting layer (not as a separate
53-
multiplier here): CAMs fade out by 60 h, regionals by 120 h, globals decay past 72 h.
53+
multiplier here): CAMs fade out by 60 h, regionals by 120 h, globals decay past 72 h,
54+
and AI plus ensemble-mean products follow global decay with a smaller vote.
5455

5556
The badge maps the result to one of three tiers — high (≥70 %, emerald), mid (≥40 %, amber), low (rose).
5657

5758
## Models
5859

59-
| Open-meteo id | Provider | Resolution / scope | Class | Max lead |
60-
| ---------------------- | ------------------ | --------------------------- | ------------ | -------- |
61-
| `ecmwf_ifs025` | ECMWF | 25 km global | global | 240 h |
62-
| `gfs_global` | NOAA | 13–25 km global | global | 384 h |
63-
| `gfs_hrrr` | NOAA | 3 km CONUS CAM | regional-cam | 48 h |
64-
| `icon_global` | DWD | 11 km global | global | 180 h |
65-
| `icon_eu` | DWD | 7 km Europe | regional-mid | 120 h |
66-
| `icon_d2` | DWD | 2 km central Europe CAM | regional-cam | 48 h |
67-
| `gem_seamless` | Environment Canada | 2.5–15 km, NA focus | regional-mid | 240 h |
68-
| `meteofrance_seamless` | Météo-France | 1.3 km AROME / 25 km ARPEGE | regional-cam | 102 h |
69-
| `ukmo_seamless` | UK Met Office | 2 km UKV / 10 km global | regional-mid | 168 h |
70-
| `knmi_seamless` | KNMI | 2.5 km Benelux | regional-cam | 60 h |
71-
| `metno_seamless` | MET Norway | 2.5 km Nordics | regional-cam | 60 h |
72-
| `jma_seamless` | JMA | 5 km Japan / 55 km global | regional-mid | 264 h |
73-
| `kma_seamless` | KMA | 1.5–13 km, Korea focus | regional-mid | 288 h |
74-
| `bom_access_global` | BOM | 15 km global, Aus. focus | global | 240 h |
60+
| Open-meteo id | Provider | Resolution / scope | Class | Max lead |
61+
| ----------------------------- | ------------------ | ---------------------------------- | ------------- | -------- |
62+
| `ecmwf_ifs` | ECMWF | 9 km HRES global | global | 240 h |
63+
| `gfs_seamless` | NOAA | seamless NOAA global/U.S. coverage | global | 384 h |
64+
| `gem_seamless` | Environment Canada | 2.5–15 km, NA focus | regional-mid | 240 h |
65+
| `ukmo_seamless` | UK Met Office | 2 km UKV / 10 km global | regional-mid | 168 h |
66+
| `meteofrance_seamless` | Météo-France | 1.3 km AROME / 25 km ARPEGE | regional-cam | 102 h |
67+
| `cma_grapes_global` | CMA | 15 km global, East Asia focus | global | 240 h |
68+
| `bom_access_global` | BOM | 15 km global, Aus. focus | global | 240 h |
69+
| `jma_seamless` | JMA | 5 km Japan / 55 km global | regional-mid | 264 h |
70+
| `kma_seamless` | KMA | 1.5–13 km, Korea focus | regional-mid | 288 h |
71+
| `icon_global` | DWD | 11 km global | global | 180 h |
72+
| `icon_eu` | DWD | 7 km Europe | regional-mid | 120 h |
73+
| `icon_d2` | DWD | 2 km central Europe CAM | regional-cam | 48 h |
74+
| `knmi_harmonie_arome_europe` | KNMI | 2 km Harmonie AROME Europe | regional-cam | 60 h |
75+
| `dmi_harmonie_arome_europe` | DMI | 2 km Harmonie AROME Europe | regional-cam | 60 h |
76+
| `metno_nordic` | MET Norway | 2.5 km Nordics | regional-cam | 60 h |
77+
| `meteoswiss_icon_seamless` | MeteoSwiss | 1–2 km ICON Switzerland seamless | regional-cam | 120 h |
78+
| `geosphere_arome_austria` | GeoSphere Austria | AROME Austria | regional-cam | 60 h |
79+
| `ecmwf_aifs025_single` | ECMWF | 0.25° AI forecast | ai | 360 h |
80+
| `gfs_graphcast025` | NOAA | 0.25° GraphCast forecast | ai | 384 h |
81+
| `ncep_aigfs025` | NOAA | 0.25° AI-enhanced GFS | ai | 384 h |
82+
| `ncep_hgefs025_ensemble_mean` | NOAA | 0.25° ensemble mean | ensemble-mean | 384 h |
7583

7684
## Tech
7785

src/api/omForecast.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export async function fetchForecast(req: ForecastRequest, signal?: AbortSignal):
100100
}
101101

102102
/** Pull a per-model hourly series for one base variable.
103-
* open-meteo suffixes each variable with the model id (e.g. temperature_2m_ecmwf_ifs025). */
103+
* open-meteo suffixes each variable with the model id (e.g. temperature_2m_ecmwf_ifs). */
104104
export function extractHourlyByModel(resp: ForecastResponse, variable: HourlyVar, modelIds: string[]): Record<string, (number | null)[]> {
105105
const out: Record<string, (number | null)[]> = {};
106106
for (const id of modelIds) {

src/components/chartOption.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const DATA: HourlySeries = {
3333
cloud_cover: [pt(50), pt(60), pt(70), pt(80), pt(90)],
3434
},
3535
perModel: {
36-
temperature_2m: { ecmwf_ifs025: [10, 11, 12, 13, 14], gfs_global: [9, 10, 11, 12, 13] },
36+
temperature_2m: { ecmwf_ifs: [10, 11, 12, 13, 14], gfs_seamless: [9, 10, 11, 12, 13] },
3737
},
3838
};
3939

@@ -87,12 +87,12 @@ describe("buildHourlyChartOption — axis pinning", () => {
8787
});
8888

8989
describe("buildHourlyChartOption — spaghetti", () => {
90-
const models = [getModel("ecmwf_ifs025")!, getModel("gfs_global")!];
90+
const models = [getModel("ecmwf_ifs")!, getModel("gfs_seamless")!];
9191
it("builds one per-model series per available model when showModels is on", () => {
9292
const ids = seriesOf({ ...base, models, showModels: true })
9393
.map((s) => s.id)
9494
.filter((id): id is string => !!id?.startsWith("s-"));
95-
expect(ids.toSorted()).toEqual(["s-ecmwf_ifs025", "s-gfs_global"]);
95+
expect(ids.toSorted()).toEqual(["s-ecmwf_ifs", "s-gfs_seamless"]);
9696
});
9797
it("builds no per-model series when showModels is off", () => {
9898
const ids = seriesOf({ ...base, models, showModels: false }).filter((s) => s.id?.startsWith("s-"));
@@ -128,7 +128,7 @@ describe("buildHourlyChartOption — night/now marks", () => {
128128
});
129129

130130
describe("buildHourlyChartOption — visibility toggles", () => {
131-
const models = [getModel("ecmwf_ifs025")!, getModel("gfs_global")!];
131+
const models = [getModel("ecmwf_ifs")!, getModel("gfs_seamless")!];
132132

133133
it("emits aggregate + band toggles for a line view", () => {
134134
const t = togglesOf(base);
@@ -155,7 +155,7 @@ describe("buildHourlyChartOption — visibility toggles", () => {
155155

156156
it("emits one model toggle per spaghetti series and none when models are off", () => {
157157
const on = togglesOf({ ...base, models, showModels: true }).filter((x) => x.group === "model");
158-
expect(on.map((x) => x.id).toSorted()).toEqual(["s-ecmwf_ifs025", "s-gfs_global"]);
158+
expect(on.map((x) => x.id).toSorted()).toEqual(["s-ecmwf_ifs", "s-gfs_seamless"]);
159159
expect(on.every((x) => !!x.modelId && x.props.includes("lineStyle"))).toBe(true);
160160
expect(togglesOf({ ...base, models, showModels: false }).some((x) => x.group === "model")).toBe(false);
161161
});

src/domain/aggregate.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { aggregateSeries } from "./aggregate";
44
import { getModel } from "./models";
55

66
const PARIS = { lat: 48.85, lon: 2.35 };
7-
const subset = [getModel("ecmwf_ifs025")!, getModel("gfs_global")!, getModel("icon_global")!, getModel("meteofrance_seamless")!];
7+
const subset = [getModel("ecmwf_ifs")!, getModel("gfs_seamless")!, getModel("icon_global")!, getModel("meteofrance_seamless")!];
88

99
function makeTimes(n: number, baseISO: string): string[] {
1010
const base = new Date(baseISO).getTime();
@@ -16,8 +16,8 @@ describe("aggregateSeries (temperature)", () => {
1616
const baseTime = new Date("2026-05-20T00:00:00Z");
1717
const times = makeTimes(4, "2026-05-20T00:00:00Z");
1818
const series = {
19-
ecmwf_ifs025: [10, 11, 12, 13],
20-
gfs_global: [10, 11, 12, 13],
19+
ecmwf_ifs: [10, 11, 12, 13],
20+
gfs_seamless: [10, 11, 12, 13],
2121
icon_global: [10, 11, 12, 13],
2222
meteofrance_seamless: [10, 11, 12, 13],
2323
};
@@ -38,8 +38,8 @@ describe("aggregateSeries (temperature)", () => {
3838
const baseTime = new Date("2026-05-20T00:00:00Z");
3939
const times = makeTimes(1, "2026-05-20T00:00:00Z");
4040
const series = {
41-
ecmwf_ifs025: [10],
42-
gfs_global: [14],
41+
ecmwf_ifs: [10],
42+
gfs_seamless: [14],
4343
icon_global: [12],
4444
meteofrance_seamless: [11],
4545
};
@@ -59,8 +59,8 @@ describe("aggregateSeries (temperature)", () => {
5959
const baseTime = new Date("2026-05-20T00:00:00Z");
6060
const times = makeTimes(1, "2026-05-20T00:00:00Z");
6161
const series = {
62-
ecmwf_ifs025: [10],
63-
gfs_global: [null],
62+
ecmwf_ifs: [10],
63+
gfs_seamless: [null],
6464
icon_global: [null],
6565
meteofrance_seamless: [null],
6666
};
@@ -84,8 +84,8 @@ describe("aggregateSeries (wind_direction_10m)", () => {
8484
const out = aggregateSeries(
8585
times,
8686
{
87-
ecmwf_ifs025: [350],
88-
gfs_global: [10],
87+
ecmwf_ifs: [350],
88+
gfs_seamless: [10],
8989
icon_global: [355],
9090
meteofrance_seamless: [5],
9191
},
@@ -110,8 +110,8 @@ describe("aggregateSeries (wind_direction_10m)", () => {
110110
const out = aggregateSeries(
111111
times,
112112
{
113-
ecmwf_ifs025: [180],
114-
gfs_global: [185],
113+
ecmwf_ifs: [180],
114+
gfs_seamless: [185],
115115
icon_global: [175],
116116
meteofrance_seamless: [180],
117117
},
@@ -131,8 +131,8 @@ describe("aggregateSeries (wind_direction_10m)", () => {
131131
const out = aggregateSeries(
132132
times,
133133
{
134-
ecmwf_ifs025: [0],
135-
gfs_global: [180],
134+
ecmwf_ifs: [0],
135+
gfs_seamless: [180],
136136
icon_global: [0],
137137
meteofrance_seamless: [180],
138138
},
@@ -155,8 +155,8 @@ describe("aggregateSeries (weather_code)", () => {
155155
const times = makeTimes(1, "2026-05-20T00:00:00Z");
156156
// 3 models say "rain" (61, 63, 80), 1 says "clear" (0).
157157
const series = {
158-
ecmwf_ifs025: [61],
159-
gfs_global: [63],
158+
ecmwf_ifs: [61],
159+
gfs_seamless: [63],
160160
icon_global: [80],
161161
meteofrance_seamless: [0],
162162
};

src/domain/aggregateVariables.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { confidenceFor } from "./confidence";
66
import { getModel } from "./models";
77

88
const PARIS = { lat: 48.85, lon: 2.35 };
9-
const subset = [getModel("ecmwf_ifs025")!, getModel("gfs_global")!, getModel("icon_global")!, getModel("meteofrance_seamless")!];
9+
const subset = [getModel("ecmwf_ifs")!, getModel("gfs_seamless")!, getModel("icon_global")!, getModel("meteofrance_seamless")!];
1010

1111
function makeTimes(n: number, baseISO: string): string[] {
1212
const base = new Date(baseISO).getTime();
@@ -24,8 +24,8 @@ describe("aggregateVariables — index→lead-hours convention", () => {
2424
// (no clamping) — the typicalSpread band differs between lead 1 and lead 36.
2525
const perModel = {
2626
temperature_2m: {
27-
ecmwf_ifs025: [10, 10],
28-
gfs_global: [10.6, 10.6],
27+
ecmwf_ifs: [10, 10],
28+
gfs_seamless: [10.6, 10.6],
2929
icon_global: [10.3, 10.3],
3030
meteofrance_seamless: [10.1, 10.1],
3131
},
@@ -48,7 +48,7 @@ describe("aggregateVariables — index→lead-hours convention", () => {
4848
describe("aggregateVariables — key vs family", () => {
4949
it("keys the result by `key` but weights/scores by `family`", () => {
5050
const times = makeTimes(1, "2026-05-20T00:00:00Z");
51-
const byModel = { ecmwf_ifs025: [18], gfs_global: [22], icon_global: [20], meteofrance_seamless: [19] };
51+
const byModel = { ecmwf_ifs: [18], gfs_seamless: [22], icon_global: [20], meteofrance_seamless: [19] };
5252
const out = aggregateVariables({
5353
times,
5454
perModel: { temperature_2m_max: byModel },
@@ -72,8 +72,8 @@ describe("aggregateVariables — shape", () => {
7272
it("echoes perModel and keys aggregate/confidence per variable, lengths match times", () => {
7373
const times = makeTimes(3, "2026-05-20T00:00:00Z");
7474
const perModel = {
75-
temperature_2m: { ecmwf_ifs025: [10, 11, 12], gfs_global: [10, 11, 12], icon_global: [10, 11, 12], meteofrance_seamless: [10, 11, 12] },
76-
precipitation: { ecmwf_ifs025: [0, 1, 0], gfs_global: [0, 1, 0], icon_global: [0, 2, 0], meteofrance_seamless: [0, 1, 0] },
75+
temperature_2m: { ecmwf_ifs: [10, 11, 12], gfs_seamless: [10, 11, 12], icon_global: [10, 11, 12], meteofrance_seamless: [10, 11, 12] },
76+
precipitation: { ecmwf_ifs: [0, 1, 0], gfs_seamless: [0, 1, 0], icon_global: [0, 2, 0], meteofrance_seamless: [0, 1, 0] },
7777
};
7878
const out = aggregateVariables({
7979
times,
@@ -100,7 +100,7 @@ describe("aggregateVariables — shape", () => {
100100
describe("aggregateVariables — weather_code is lead-independent", () => {
101101
it("scores weather_code by agreement, so cadence does not change confidence", () => {
102102
const times = makeTimes(1, "2026-05-20T00:00:00Z");
103-
const perModel = { weather_code: { ecmwf_ifs025: [61], gfs_global: [63], icon_global: [80], meteofrance_seamless: [0] } };
103+
const perModel = { weather_code: { ecmwf_ifs: [61], gfs_seamless: [63], icon_global: [80], meteofrance_seamless: [0] } };
104104
const vars = [{ key: "weather_code", family: "weather_code" as const }];
105105
const base = { times, perModel, vars, models: subset, lat: PARIS.lat, lon: PARIS.lon, baseTime };
106106

@@ -115,7 +115,7 @@ describe("aggregateVariables — weather_code is lead-independent", () => {
115115
describe("aggregateVariables — degenerate input", () => {
116116
it("yields a NaN aggregate value and zero confidence when every model is null", () => {
117117
const times = makeTimes(1, "2026-05-20T00:00:00Z");
118-
const perModel = { temperature_2m: { ecmwf_ifs025: [null], gfs_global: [null], icon_global: [null], meteofrance_seamless: [null] } };
118+
const perModel = { temperature_2m: { ecmwf_ifs: [null], gfs_seamless: [null], icon_global: [null], meteofrance_seamless: [null] } };
119119
const out = aggregateVariables({
120120
times,
121121
perModel,

0 commit comments

Comments
 (0)