Skip to content

Commit 394cc95

Browse files
authored
Merge pull request #10 from Flowm/claude/sharp-pare-fa727d
feat(training): trained-weights overview with per-fit reach
2 parents 895c514 + ee636ed commit 394cc95

6 files changed

Lines changed: 474 additions & 138 deletions

File tree

CONTEXT.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ A coarse lead-hour bucket (0–48 h / 48–96 h / 96–168 h) the scorecard scor
134134
**Coverage**:
135135
The hours a model actually returned data for within the window — a runtime fact (retention varies per model and run date). Sub-full-coverage models are flagged `*` and still ranked; their empty lead bands show the gap. Distinct from **Available models**, which is the binary did-it-return-anything set.
136136

137+
### Training
138+
139+
**Trained weights**:
140+
Per-model weight **multipliers** fitted on top of the heuristic weighting from a location's stored verification sample (ADR 0007), persisted on-device and applied to the aggregate only when the user opts in via the settings toggle. A multiplier of 1 leaves the heuristic unchanged. **Training** is the act of fitting; **trained weights** are the stored result. The per-model scorecard scores these as the **Aggregate (tuned)** row.
141+
_Avoid_: _training weights_ (blurs the act and the result); _tuned weights_ as a separate concept (it is the same thing — "tuned" survives only in the scorecard's **Aggregate (tuned)** row label).
142+
143+
**Training location**:
144+
The gridded location whose stored sample a fit was computed from — and the center of any **reach**. The training page is scoped to one training location at a time; the trained-weights overview at the bottom lists every training location stored on the device.
145+
146+
**Reach** (or **reach radius**):
147+
The distance around a **training location** within which its **trained weights** also apply to other locations. Uniform within the radius, hard cutoff at the edge — no distance falloff. The default is _this point only_ (no reach: the weights apply solely to the training location's own grid cell). Resolution precedence when several sources could apply: a location's own exact-cell fit always wins; otherwise the nearest training location whose reach covers the point.
148+
_Avoid_: **coverage** (taken — it means temporal data availability, see "Coverage") and **region** (taken — a model's structural-advantage box, see "Home region") for this spatial concept; reserve **reach** for it.
149+
137150
## Flagged ambiguities
138151

139152
**"Predictability" / "probability" / "agreement".**

src/analysis/learnedWeightsStore.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { describe, it, expect, beforeEach } from "vitest";
22

3-
import { clearWeights, loadWeights, saveWeights } from "./learnedWeightsStore";
3+
import { clearWeights, clearWeightsByKey, listWeights, loadWeights, saveWeights, setReach } from "./learnedWeightsStore";
4+
import { sampleKey } from "./sampleStore";
5+
6+
const INNSBRUCK = { lat: 47.2654, lon: 11.3927 };
7+
// ~33 km north of Innsbruck — a different 0.25° cell.
8+
const NEARBY = { lat: 47.5654, lon: 11.3927 };
49

510
describe("learnedWeightsStore", () => {
611
beforeEach(() => localStorage.clear());
712

813
it("round-trips weights, keyed by the 0.25° grid cell", () => {
914
saveWeights(48.12, 11.38, { multipliers: { ecmwf_ifs: 1.5 }, trainedAt: "2026-06-01T00:00:00Z", improvement: 2.3 });
1015
expect(loadWeights(48.12, 11.38)?.multipliers.ecmwf_ifs).toBe(1.5);
11-
// A nearby point resolves to the same stored weights.
16+
// A nearby point in the same cell resolves to the same stored weights.
1217
expect(loadWeights(48.0, 11.49)?.multipliers.ecmwf_ifs).toBe(1.5);
1318
});
1419

@@ -21,4 +26,66 @@ describe("learnedWeightsStore", () => {
2126
clearWeights(10, 10);
2227
expect(loadWeights(10, 10)).toBeNull();
2328
});
29+
30+
describe("reach", () => {
31+
const store = (loc: { lat: number; lon: number }, radiusKm: number, mult: Record<string, number>): void =>
32+
saveWeights(loc.lat, loc.lon, {
33+
multipliers: mult,
34+
trainedAt: "2026-06-01T00:00:00Z",
35+
improvement: 1,
36+
location: { name: "x", latitude: loc.lat, longitude: loc.lon },
37+
radiusKm,
38+
});
39+
40+
it("does not reach a different cell when radius is 0 (this point only)", () => {
41+
store(INNSBRUCK, 0, { ecmwf_ifs: 1.4 });
42+
expect(loadWeights(NEARBY.lat, NEARBY.lon)).toBeNull();
43+
});
44+
45+
it("reaches a different cell within the radius", () => {
46+
store(INNSBRUCK, 50, { ecmwf_ifs: 1.4 });
47+
expect(loadWeights(NEARBY.lat, NEARBY.lon)?.multipliers.ecmwf_ifs).toBe(1.4);
48+
});
49+
50+
it("does not reach beyond the radius", () => {
51+
store(INNSBRUCK, 25, { ecmwf_ifs: 1.4 });
52+
expect(loadWeights(NEARBY.lat, NEARBY.lon)).toBeNull();
53+
});
54+
55+
it("prefers an exact-cell fit over a covering neighbour", () => {
56+
store(INNSBRUCK, 100, { ecmwf_ifs: 1.1 });
57+
store(NEARBY, 0, { ecmwf_ifs: 1.9 });
58+
expect(loadWeights(NEARBY.lat, NEARBY.lon)?.multipliers.ecmwf_ifs).toBe(1.9);
59+
});
60+
61+
it("picks the nearest covering center when several reach the point", () => {
62+
// Far center reaches with a big radius; near center reaches too — near wins.
63+
store({ lat: 48.0654, lon: 11.3927 }, 250, { ecmwf_ifs: 0.5 }); // ~89 km away
64+
store({ lat: 47.4654, lon: 11.3927 }, 250, { ecmwf_ifs: 1.7 }); // ~22 km away
65+
expect(loadWeights(INNSBRUCK.lat, INNSBRUCK.lon)?.multipliers.ecmwf_ifs).toBe(1.7);
66+
});
67+
});
68+
69+
describe("management", () => {
70+
it("lists every stored entry with its grid key", () => {
71+
saveWeights(INNSBRUCK.lat, INNSBRUCK.lon, { multipliers: {}, trainedAt: "t", improvement: 0 });
72+
saveWeights(10, 10, { multipliers: {}, trainedAt: "t", improvement: 0 });
73+
const keys = listWeights()
74+
.map((e) => e.key)
75+
.toSorted();
76+
expect(keys).toEqual([sampleKey(10, 10), sampleKey(INNSBRUCK.lat, INNSBRUCK.lon)].toSorted());
77+
});
78+
79+
it("updates only the reach radius via setReach", () => {
80+
saveWeights(INNSBRUCK.lat, INNSBRUCK.lon, { multipliers: { ecmwf_ifs: 1.4 }, trainedAt: "t", improvement: 0 });
81+
setReach(sampleKey(INNSBRUCK.lat, INNSBRUCK.lon), 50);
82+
expect(loadWeights(NEARBY.lat, NEARBY.lon)?.multipliers.ecmwf_ifs).toBe(1.4);
83+
});
84+
85+
it("clears an entry by key", () => {
86+
saveWeights(INNSBRUCK.lat, INNSBRUCK.lon, { multipliers: {}, trainedAt: "t", improvement: 0 });
87+
clearWeightsByKey(sampleKey(INNSBRUCK.lat, INNSBRUCK.lon));
88+
expect(listWeights()).toHaveLength(0);
89+
});
90+
});
2491
});

src/analysis/learnedWeightsStore.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,44 @@
22
// by the same 0.25° grid as gathered samples. Payloads are tiny (one number per
33
// model), so localStorage suits better than IndexedDB. The live forecast applies
44
// these only when the user opts in via the settings toggle.
5+
//
6+
// A stored fit can declare a *reach* radius (km): the area around its training
7+
// location within which the same weights apply to other locations (uniform, hard
8+
// cutoff). loadWeights resolves the exact grid cell first; failing that, it falls
9+
// back to the nearest training location whose reach covers the point. See ADR
10+
// 0007 and the "Reach" / "Training location" glossary entries in CONTEXT.md.
11+
12+
import { haversineKm } from "@/domain/geo";
513

614
import { sampleKey } from "./sampleStore";
715

816
const PREFIX = "meteocompare:weights:";
917

18+
/** Reach radii offered in the UI (km). 0 = "this point only" (no reach). */
19+
export const REACH_PRESETS_KM = [0, 25, 50, 100, 250] as const;
20+
1021
export interface StoredWeights {
1122
multipliers: Record<string, number>;
1223
/** ISO timestamp when fitted (passed in by the caller). */
1324
trainedAt: string;
1425
/** Out-of-sample validation improvement at fit time (composite delta). */
1526
improvement: number;
27+
/** Training location — the center of any reach, and the overview's label.
28+
* Absent on entries stored before reach existed. */
29+
location?: { name: string; detail?: string; latitude: number; longitude: number };
30+
/** Reach radius in km: these weights apply to any location within this distance
31+
* of the training location. Absent / 0 = "this point only" (the exact cell). */
32+
radiusKm?: number;
1633
}
1734

18-
export function loadWeights(lat: number, lon: number): StoredWeights | null {
19-
if (typeof localStorage === "undefined") return null;
20-
const raw = localStorage.getItem(PREFIX + sampleKey(lat, lon));
35+
/** A stored entry paired with its grid key, for the device-wide overview. */
36+
export interface WeightEntry {
37+
/** The 0.25° grid key, e.g. "47.25,11.50". */
38+
key: string;
39+
weights: StoredWeights;
40+
}
41+
42+
function parse(raw: string | null): StoredWeights | null {
2143
if (!raw) return null;
2244
try {
2345
return JSON.parse(raw) as StoredWeights;
@@ -26,12 +48,69 @@ export function loadWeights(lat: number, lon: number): StoredWeights | null {
2648
}
2749
}
2850

51+
/** The training center to measure reach from: the stored exact coords when
52+
* present, else the grid-cell center parsed back from the key. */
53+
function centerOf(entry: WeightEntry): { lat: number; lon: number } {
54+
const loc = entry.weights.location;
55+
if (loc) return { lat: loc.latitude, lon: loc.longitude };
56+
const [latStr = "", lonStr = ""] = entry.key.split(",");
57+
return { lat: Number(latStr), lon: Number(lonStr) };
58+
}
59+
60+
/** Resolve trained weights for a location: the exact grid cell wins; otherwise
61+
* the nearest training location whose reach covers the point. null when none. */
62+
export function loadWeights(lat: number, lon: number): StoredWeights | null {
63+
if (typeof localStorage === "undefined") return null;
64+
const exact = parse(localStorage.getItem(PREFIX + sampleKey(lat, lon)));
65+
if (exact) return exact;
66+
67+
let best: StoredWeights | null = null;
68+
let bestDist = Infinity;
69+
for (const entry of listWeights()) {
70+
const reach = entry.weights.radiusKm ?? 0;
71+
if (reach <= 0) continue;
72+
const c = centerOf(entry);
73+
const dist = haversineKm(lat, lon, c.lat, c.lon);
74+
if (dist <= reach && dist < bestDist) {
75+
bestDist = dist;
76+
best = entry.weights;
77+
}
78+
}
79+
return best;
80+
}
81+
82+
/** Every stored weight set on this device, paired with its grid key. */
83+
export function listWeights(): WeightEntry[] {
84+
if (typeof localStorage === "undefined") return [];
85+
const out: WeightEntry[] = [];
86+
for (let i = 0; i < localStorage.length; i++) {
87+
const k = localStorage.key(i);
88+
if (!k?.startsWith(PREFIX)) continue;
89+
const weights = parse(localStorage.getItem(k));
90+
if (weights) out.push({ key: k.slice(PREFIX.length), weights });
91+
}
92+
return out;
93+
}
94+
2995
export function saveWeights(lat: number, lon: number, weights: StoredWeights): void {
3096
if (typeof localStorage === "undefined") return;
3197
localStorage.setItem(PREFIX + sampleKey(lat, lon), JSON.stringify(weights));
3298
}
3399

100+
/** Update only the reach radius of an existing entry (by grid key). No-op when
101+
* the entry is gone. */
102+
export function setReach(key: string, radiusKm: number): void {
103+
if (typeof localStorage === "undefined") return;
104+
const weights = parse(localStorage.getItem(PREFIX + key));
105+
if (!weights) return;
106+
localStorage.setItem(PREFIX + key, JSON.stringify({ ...weights, radiusKm }));
107+
}
108+
34109
export function clearWeights(lat: number, lon: number): void {
110+
clearWeightsByKey(sampleKey(lat, lon));
111+
}
112+
113+
export function clearWeightsByKey(key: string): void {
35114
if (typeof localStorage === "undefined") return;
36-
localStorage.removeItem(PREFIX + sampleKey(lat, lon));
115+
localStorage.removeItem(PREFIX + key);
37116
}

src/domain/geo.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { haversineKm } from "./geo";
4+
5+
describe("haversineKm", () => {
6+
it("is zero for identical points", () => {
7+
expect(haversineKm(47.2654, 11.3927, 47.2654, 11.3927)).toBe(0);
8+
});
9+
10+
it("matches one degree of latitude (~111 km)", () => {
11+
expect(haversineKm(0, 0, 1, 0)).toBeCloseTo(111.19, 1);
12+
});
13+
14+
it("shrinks a degree of longitude toward the poles", () => {
15+
// ~74 km at 48°N, not the ~111 km it spans at the equator.
16+
expect(haversineKm(48, 11, 48, 12)).toBeCloseTo(74.3, 0);
17+
});
18+
19+
it("is symmetric", () => {
20+
expect(haversineKm(47, 11, 48, 12)).toBeCloseTo(haversineKm(48, 12, 47, 11), 6);
21+
});
22+
});

src/domain/geo.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Tiny geographic helpers. Pure and unit-tested.
2+
3+
const EARTH_RADIUS_KM = 6371;
4+
const toRad = (deg: number): number => (deg * Math.PI) / 180;
5+
6+
/** Great-circle distance between two lat/lon points, in kilometres. */
7+
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
8+
const dLat = toRad(lat2 - lat1);
9+
const dLon = toRad(lon2 - lon1);
10+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
11+
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
12+
}

0 commit comments

Comments
 (0)