Merge branch 'main' into fix/cyclone-movement-knots

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-24 21:33:20 -07:00
commit 19ed82af07
6 files changed
+78 -14

No files matched your search

@@ -0,0 +1,4 @@
- The terrain-heights bounds tests no longer read the checkout's
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
`npm test` no longer fails after the dev server has cached terrain near
Austin (KnottyDyes, #1244).
+2
View File
@@ -0,0 +1,2 @@
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
cached model run (quanticsoul4772, #1303).
+1 -1
View File
@@ -6018,7 +6018,7 @@ closed. Performance and live agency reliability require recorded validation.
## Wind forecast models
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results.
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary.
### Local Weather review candidate
+37 -12
View File
@@ -99,6 +99,18 @@ function stubTerrainUpstream(t) {
return asked;
}
/**
* A cache file of the test's own, removed when the test ends. Without one the
* proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a
* point the app has already cached there would answer a request the test
* expects to reach the stubbed upstream (#1242).
*/
async function isolatedCachePath(t) {
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
t.after(() => rm(dir, { recursive: true, force: true }));
return path.join(dir, 'terrain-heights.json');
}
test('a coordinate off the globe is not a cache key', () => {
// Finite but not on Earth — the old guard accepted every one of these.
for (const raw of [
@@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
upstreamCalls += 1;
return new Response('[]', { status: 200 });
});
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const refused = await request(route, { url: '/?points=0,91' });
assert.equal(refused.statusCode, 400);
@@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
stubFetch(t, async () => new Response('[]', { status: 200 }));
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const mixed = await request(route, {
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
});
@@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => {
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
const asked = stubTerrainUpstream(t);
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
'/api/terrain/heights',
);
const route = install(
terrainHeightsProxy({
cachePath: await isolatedCachePath(t),
cacheMaxEntries: 3,
}),
).get('/api/terrain/heights');
const point = (i) => `-97.7${i}000,30.26000`;
for (let i = 1; i <= 4; i += 1)
@@ -168,9 +187,7 @@ test('the point cache stops growing, and an evicted point is simply refetched',
test('an over-large cache file is not read back into memory', async (t) => {
const asked = stubTerrainUpstream(t);
const point = '-97.74000,30.26000';
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
const cachePath = path.join(dir, 'terrain-heights.json');
t.after(() => rm(dir, { recursive: true, force: true }));
const cachePath = await isolatedCachePath(t);
// A cache file holding the answer, but larger than the ceiling allows.
const padded = {
@@ -201,7 +218,9 @@ test('an over-large cache file is not read back into memory', async (t) => {
test('the route refuses a flood of requests from one client', async (t) => {
stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
let refusedAt = 0;
for (let i = 1; i <= 95; i += 1) {
@@ -218,7 +237,9 @@ test('the route refuses a flood of requests from one client', async (t) => {
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
@@ -253,7 +274,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async
test('the app served behind a reverse proxy still gets terrain', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const host = 'globe.example.lan';
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
// cost-bearing routes refuse these; terrain is keyless and needed to render,
@@ -347,7 +370,9 @@ test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
for (let i = 0; i < 90; i += 1)
await request(route, {
+6 -1
View File
@@ -204,10 +204,14 @@ export const getWind = defineTool({
const speed = windSpeed(vector.u, vector.v);
const from = windFrom(vector.u, vector.v);
const kph = speed * 3.6;
// When the model upstream fails, the proxy keeps serving the last run it
// has, however old, marked stale.
const stale = snapshot.stale === true;
return {
summary:
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
(from === 'Calm' ? ', calm.' : ` from the ${from}.`),
(from === 'Calm' ? ', calm' : ` from the ${from}`) +
(stale ? ' (forecast may be stale).' : '.'),
data: {
view: suggestView(services, {
point,
@@ -224,6 +228,7 @@ export const getWind = defineTool({
level: snapshot.level ?? '10 m above ground',
model,
cycle: snapshot.cycle ?? null,
stale,
},
};
},
+28
View File
@@ -148,6 +148,34 @@ test('wind is sampled from the model grid at the location', async () => {
const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } });
assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.');
assert.equal(calm.data.calm, true);
assert.equal(calm.data.stale, false);
// The proxy's last run, served while the model upstream fails.
const cached = composeCatalog({
tools: coreTools,
services: {
wind: {
getSnapshot: async (options) => ({
...(await wind.getSnapshot(options)),
stale: true,
reason: 'Wind upstream unavailable',
}),
},
},
});
const stale = await cached.call('get_wind', { location: { lat: 0, lon: 0 } });
assert.equal(
stale.summary,
'Wind at 0.0000, 0.0000: 0 km/h, calm (forecast may be stale).',
);
assert.equal(stale.data.stale, true);
u.fill(10);
const staleWest = await cached.call('get_wind', {
location: { lat: 30.27, lon: -97.74 },
});
assert.equal(
staleWest.summary,
'Wind at 30.2700, -97.7400: 36 km/h from the W (forecast may be stale).',
);
const down = composeCatalog({
tools: coreTools,
services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },