feat(weather): add globe wind, observed weather and cyclone context (#617)

Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view.

Co-authored-by: Gustavo Beneduzi <[email protected]>
Co-authored-by: Daniel Slay <[email protected]>
Co-authored-by: Rehaan Delmotra <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-22 17:45:39 -05:00
1 parent f01b6a5d84
commit 0251c74109
180 files changed
+30645 -434

No files matched your search

+25
View File
@@ -1600,6 +1600,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
}
});
for (const card of ['live', 'stale', 'absent', 'covered']) {
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
const id = 'cyclone:fixture:cone:0';
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
const harness = installWireHarness({ id });
const requests = [];
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
if (card === 'covered') host.hitTest = (_x, _y, options) => {
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
};
_setVesselOverlayHostForTest(host);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(requests.length, card === 'live' ? 1 : 0);
} finally {
_setVesselOverlayHostForTest(null);
harness.cleanup();
unregisterPickOwner('weather-cyclones');
}
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
+43 -2
View File
@@ -24,6 +24,7 @@
* @module data/analystEngine
*/
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
@@ -61,6 +62,21 @@ export const ANALYST_LAYERS = {
text: ['place'],
flags: [],
},
satellites: {
numeric: ['altitudeM', 'speedMps'],
text: ['name', 'noradId', 'satelliteClass', 'group'],
flags: [],
},
'local-datacenters': {
numeric: [],
text: ['name', 'operator', 'capacity'],
flags: [],
},
'local-dams': {
numeric: [],
text: ['name', 'operator', 'river', 'output'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -163,12 +179,15 @@ export function createAnalystEngine(providers) {
// 1) Source records
let records;
let layersQueried;
let queriedSnapshots;
if (layers === null) {
records = lastResult.items.slice();
layersQueried = lastResult.coverage.layersQueried;
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
} else {
records = [];
layersQueried = [];
queriedSnapshots = [];
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
if (unknown.length) {
return {
@@ -180,7 +199,22 @@ export function createAnalystEngine(providers) {
for (const key of layers) {
if (!ANALYST_LAYERS[key]) continue;
const rows = providers.getRecords(key) || [];
layersQueried.push({ layerKey: key, records: rows.length });
const snapshot = providers.getLayerSnapshot?.(key);
if (snapshot) queriedSnapshots.push(snapshot);
layersQueried.push({
layerKey: key,
records: rows.length,
...(snapshot
? {
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
enabled: snapshot.enabled,
error: snapshot.error,
}
: {}),
...providers.getRecordCoverage?.(key, rows),
});
for (const row of rows) records.push({ layerKey: key, ...row });
}
}
@@ -292,8 +326,15 @@ export function createAnalystEngine(providers) {
coverage: {
layersQueried,
scope: scopeNote,
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
followUp: Boolean(spec.followUp && lastResult),
note: 'client-side data only — answers cover what the enabled layers currently hold',
note: layersQueried.some(
(layer) => layer.basis === 'bounded-loaded-records',
)
? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.'
: 'client-side data only — answers cover what the enabled layers currently hold',
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
+79
View File
@@ -195,9 +195,88 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
});
test('analyst: satellites and local infrastructure are queryable layers', async () => {
const SATS = [
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
];
const DAMS = [
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
];
const DCS = [
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
];
const eng = createAnalystEngine({
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const sats = await eng.query({
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
});
assert.equal(sats.ok, true);
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
assert.equal(sats.items[0].noradId, '25544');
assert.ok(Number.isFinite(sats.items[0].distanceKm));
const nav = await eng.query({
layers: ['satellites'], scope: { kind: 'anywhere' },
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
});
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
const dams = await eng.query({
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
});
assert.equal(dams.count, 1);
assert.equal(dams.items[0].id, 'Austin Dam');
assert.equal(dams.items[0].river, 'Colorado');
const dcs = await eng.query({
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
});
assert.equal(dcs.count, 1);
assert.equal(dcs.items[0].id, 'AUS-1');
});
test('helpers: haversine sanity + scope radius', () => {
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
const engine = createAnalystEngine({
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const first = await engine.query();
state = 'nominal';
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.feedProvenance.overall, 'stale');
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
engine.reset();
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHlsPuller,
fetchHlsBytes,
parseHlsMedia,
HLS_LIMITS,
} from '../../server/providers/cctv/stream.js';
const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
[12, 13, 14],
);
for (const bad of [
playlist.replace('a.ts', 'https://evil.example/a.ts'),
playlist.replace('a.ts', '//evil.example/a.ts'),
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
playlist.replace('12', '-1'),
playlist.replace('2,', 'Infinity,'),
])
assert.throws(() => parseHlsMedia(bad, base));
});
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
let init;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async (_url, options) => {
init = options;
return new Response('', {
status: 302,
headers: { Location: 'https://evil.example' },
});
},
}),
);
assert.equal(init.redirect, 'error');
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () => new Response('abcdef'),
}),
);
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () =>
new Response('a', { headers: { 'content-length': '100' } }),
}),
);
});
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
const manager = createHlsPuller({
limits: {
...HLS_LIMITS,
sessions: 1,
segmentBytes: 4,
sessionBytes: 6,
segments: 2,
pollMs: 100000,
},
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const [a, b] = await Promise.all([
manager.ensure('a', base),
manager.ensure('a', base),
]);
assert.equal(a, b);
await assert.rejects(manager.ensure('b', base));
assert.equal(await manager.waitReady(a), true);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
const text = await manager.buildPlaylist(a, 'a');
assert.match(text, /seg_0\.ts\?session=/);
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
manager.stop('a', 'stale-token');
assert.equal(manager.stats().sessions, 1);
manager.stop('a', a.token);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
let observed;
const manager = createHlsPuller({
fetchImpl: async (_url, { signal }) => {
observed = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true,
}),
);
},
});
const entry = await manager.ensure('a', base);
await manager.shutdown();
assert.equal(observed.aborted, true);
assert.equal(entry.stopping, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await assert.rejects(manager.ensure('a', base));
});
test('idle cleanup stops all polling without a background sweep', async () => {
let calls = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
fetchImpl: async (url) => {
calls++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
await manager.ensure('a', base);
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
const stoppedCalls = calls;
await new Promise((r) => setTimeout(r, 30));
assert.equal(calls, stoppedCalls);
await manager.shutdown();
});
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
const before = await manager.buildPlaylist(entry, 'a');
assert.match(before, /seg_0\.ts/);
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
await new Promise((r) => setTimeout(r, 30));
const after = await manager.buildPlaylist(entry, 'a');
assert.match(
after,
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('upstream discontinuity tags survive the media parser', () => {
const parsed = parseHlsMedia(
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
base,
);
assert.equal(parsed[0].discontinuity, false);
assert.equal(parsed[1].discontinuity, true);
});
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
current = playlist.replaceAll('.ts', '.ts?generation=2');
await new Promise((r) => setTimeout(r, 30));
assert.match(
await manager.buildPlaylist(entry, 'a'),
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
let downloads = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
fetchImpl: async (url) => {
downloads++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
const [a, b] = await Promise.all([
manager.ensure('camera', base, 'viewer-a'),
manager.ensure('camera', base, 'viewer-b'),
]);
assert.equal(a, b);
await manager.waitReady(a);
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
assert.equal(a.leases.size, 2);
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
manager.release('camera', 'viewer-a');
assert.equal(manager.stats().sessions, 1);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
assert.match(
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
/lease=viewer-b/,
);
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
assert.equal(b.controller.signal.aborted, false);
manager.release('camera', 'viewer-b');
assert.equal(b.controller.signal.aborted, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const entry = await manager.ensure('camera', base, 'abandoned');
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
assert.equal(entry.leases.has('abandoned'), false);
assert.equal(entry.leases.has('active'), true);
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
+454
View File
@@ -0,0 +1,454 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
cycloneProxy,
parseCycloneStatus,
attachCycloneGeometry,
} from '../../server/providers/cyclones.js';
const NOW = Date.UTC(2026, 8, 16, 3, 35);
const TIME = '2026-09-16T03:00:00.000Z';
function status(overrides = {}) {
return {
activeStorms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
intensity: '25',
pressure: '1006',
longitudeNumeric: -125.8,
latitudeNumeric: 15.5,
movementDir: 255,
movementSpeed: 12,
lastUpdate: TIME,
forecastAdvisory: {
advNum: '010',
issuance: TIME,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
...overrides,
},
],
};
}
function collections(adv = '10') {
const geometry = [
{ type: 'Point', coordinates: [-125.8, 15.5] },
{
type: 'LineString',
coordinates: [
[-125.8, 15.5],
[-128, 15],
],
},
{
type: 'Polygon',
coordinates: [
[
[-126, 15],
[-128, 15],
[-127, 17],
[-126, 15],
],
],
},
];
return geometry.map((shape, index) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
advisnum: adv,
tau: 0,
maxwind: 25,
gust: 9999,
},
geometry: shape,
},
],
}));
}
function install(options = {}, preview = false) {
let handler;
const plugin = cycloneProxy({ now: () => NOW, ...options });
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/cyclones');
handler = callback;
},
},
});
const begin = (url = '/', method = 'GET') => {
const res = new EventEmitter();
res.writeHead = (code, headers) => {
res.statusCode = code;
res.headers = headers;
};
res.end = (body) => {
res.body = body;
};
return { res, done: handler({ url, method }, res).then(() => res) };
};
return { begin, request: (url, method) => begin(url, method).done };
}
const body = (res) => JSON.parse(res.body);
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
function fetcher(calls = [], data = status(), parts = collections()) {
return async (url, options) => {
calls.push({ url: new URL(url), options });
return Response.json(
url.includes('CurrentStorms')
? data
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
);
};
}
test('status retains official clocks, knots, source links and finite current position', () => {
const storm = parseCycloneStatus(status(), NOW)[0];
assert.equal(storm.advisoryNumber, '10');
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
const missing = parseCycloneStatus(
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
NOW,
)[0];
assert.equal(missing.windKt, null);
assert.equal(missing.pressureHpa, null);
assert.equal(missing.movement.speedKt, null);
assert.equal(
parseCycloneStatus(
status({
forecastAdvisory: {
advNum: '10',
issuance: TIME,
url: 'https://www.nhc.noaa.gov.evil/secret',
},
}),
NOW,
)[0].advisoryUrl,
null,
);
});
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
for (const payload of [
{},
{ activeStorms: null },
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
status({ id: 'wp012026' }),
status({ name: 'a'.repeat(81) }),
status({ name: '<script>' }),
status({ longitudeNumeric: Infinity }),
status({ latitudeNumeric: 91 }),
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
status({
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
}),
])
assert.throws(() => parseCycloneStatus(payload, NOW));
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
parts[1].features[0].geometry.coordinates = [
[179, 15],
[-179, 16],
];
parts[2].features[0].geometry.coordinates.push([
[-126.5, 15.5],
[-127, 15.5],
[-126.8, 16],
[-126.5, 15.5],
]);
const good = attachCycloneGeometry(storms, parts)[0];
assert.equal(good.geometryStatus, 'current');
assert.equal(good.geometryAdvisoryNumber, '10');
assert.deepEqual(good.track, parts[1].features[0].geometry);
assert.equal(good.cone.coordinates.length, 2);
assert.equal(good.forecastPoints[0].gustKt, null);
assert.equal(good.forecastPoints[0].tauHours, 0);
assert.equal(
'validAt' in good.forecastPoints[0],
false,
'do not fabricate point time from advisory + tau',
);
const old = attachCycloneGeometry(storms, collections('9'))[0];
assert.equal(old.geometryStatus, 'pending');
assert.equal(old.track, null);
assert.equal(old.cone, null);
assert.deepEqual(old.forecastPoints, []);
assert.deepEqual(old.position, storms[0].position);
const mixed = collections();
mixed[2] = collections('9')[2];
const partial = attachCycloneGeometry(storms, mixed)[0];
assert.equal(partial.geometryStatus, 'pending');
assert.equal(
partial.track,
null,
'a new track and old cone cannot form one advisory snapshot',
);
});
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
const storms = parseCycloneStatus(status(), NOW);
const mutations = [
(p) => {
p[0].exceededTransferLimit = true;
},
(p) => {
p[0].features = Array(501).fill(p[0].features[0]);
},
(p) => {
p[1].features.push(p[1].features[0]);
},
(p) => {
p[0].features.push(p[0].features[0]);
},
(p) => {
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
},
(p) => {
p[1].features[0].geometry.coordinates[1] = [181, 1];
},
(p) => {
p[2].features[0].geometry.coordinates[0].pop();
},
(p) => {
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
},
(p) => {
p[1].features[0].geometry = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
};
},
(p) => {
p[2].features[0].geometry = {
type: 'GeometryCollection',
geometries: [],
};
},
];
for (const mutate of mutations) {
const parts = collections();
mutate(parts);
assert.throws(() => attachCycloneGeometry(storms, parts));
}
});
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
for (const preview of [false, true]) {
const calls = [];
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
const snapshot = body(await request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.stale, false);
assert.match(snapshot.coverage, /not worldwide/);
assert.equal(snapshot.storms[0].geometryStatus, 'current');
assert.equal(snapshot.fetchedAt, NOW);
assert.equal(calls.length, 4);
assert.equal(
calls[0].url.href,
'https://www.nhc.noaa.gov/CurrentStorms.json',
);
for (const call of calls) assert.equal(call.options.redirect, 'error');
for (const call of calls.slice(1)) {
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
assert.equal(call.url.searchParams.get('outSR'), '4326');
assert.equal(call.url.searchParams.get('f'), 'geojson');
assert.notEqual(call.url.searchParams.get('outFields'), '*');
}
assert.equal(
calls[2].url.searchParams.get('outFields'),
'idp_source,advisnum',
);
assert.deepEqual(body(await request()), snapshot);
assert.equal(calls.length, 4);
}
});
test('query/method rejection cannot redirect or widen provider work', async () => {
let calls = 0;
const { request } = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const url of [
'/?url=https://evil',
'/?storm=ep152026',
'/other',
'/?bbox=0,0,1,1',
])
assert.equal((await request(url)).statusCode, 400);
assert.equal((await request('/', 'POST')).statusCode, 405);
assert.equal(calls, 0);
});
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
const calls = [];
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
assert.deepEqual(body(await empty.request()).storms, []);
assert.equal(body(await empty.request()).unavailable, false);
assert.equal(calls.length, 1);
const broken = install({
fetchImpl: async (url) =>
url.includes('CurrentStorms')
? Response.json(status())
: new Response('secret detail', { status: 503 }),
});
const snapshot = body(await broken.request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
assert.equal(snapshot.storms[0].track, null);
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
});
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
let clock = NOW;
let fail = false;
let calls = 0;
const good = fetcher();
const { request } = install({
now: () => clock,
fetchImpl: async (...args) => {
calls++;
if (fail) throw new Error('private');
return good(...args);
},
});
await request();
clock += 299_000;
await request();
assert.equal(calls, 4);
fail = true;
clock += 2000;
const stale = body(await request());
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, NOW);
assert.equal(stale.storms[0].issuedAt, TIME);
await request();
assert.equal(calls, 5);
clock += 12 * 3600_000;
const expired = body(await request());
assert.equal(expired.unavailable, true);
assert.deepEqual(expired.storms, []);
});
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
let release;
let signal;
const good = fetcher();
const { begin } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms')) {
signal = options.signal;
await new Promise((resolve, reject) => {
release = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
});
});
}
return good(url, options);
},
});
const a = begin();
const b = begin();
await nextTurn();
a.res.emit('close');
await a.done;
assert.equal(signal.aborted, false);
assert.equal(a.res.body, undefined);
release();
assert.equal(body(await b.done).unavailable, false);
let calls = 0;
const cancel = install({
fetchImpl: async (url, options) => {
calls++;
if (calls > 1) return good(url, options);
signal = options.signal;
return new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
},
});
const c = cancel.begin();
await nextTurn();
c.res.emit('close');
await c.done;
assert.equal(signal.aborted, true);
assert.equal(
body(await cancel.request()).unavailable,
false,
'cancellation does not impose upstream failure cooldown',
);
});
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
for (const stalled of [false, true]) {
let cancelled = false;
const { request } = install({
timeoutMs: 15,
fetchImpl: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
},
cancel() {
cancelled = true;
},
}),
),
});
const snapshot = body(await request());
assert.equal(snapshot.unavailable, true);
assert.equal(cancelled, true);
}
});
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
let release;
const good = fetcher();
const { request } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms'))
await new Promise((resolve) => {
release = resolve;
});
return good(url, options);
},
});
const waiting = Array.from({ length: 32 }, () => request());
await nextTurn();
assert.equal((await request()).statusCode, 429);
release();
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
const redirected = install({
fetchImpl: async () =>
new Response('private', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
});
assert.equal(body(await redirected.request()).unavailable, true);
});
+32
View File
@@ -147,6 +147,10 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'deldot-cctv',
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
},
{
key: 'caltrans-cctv',
html:
@@ -212,6 +216,34 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
{
key: 'weather-noaa',
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
},
{
key: 'weather-cyclones',
html: 'Cyclone advisories: <a href="https://www.nhc.noaa.gov/" target="_blank" rel="noopener">NOAA/NWS NHC / CPHC</a> · Atlantic and eastern/central North Pacific. Forecast center uncertainty, not storm size.',
},
{
key: 'weather-lightning',
html: 'Lightning density: NOAA/NWS nowCOAST · derived from Vaisala NLDN/GLD360. <a href="https://ocean.weather.gov/lightning/lightning_pdd.php" target="_blank" rel="noopener">Public derived density product</a>, not raw detections.',
},
{
key: 'wind-gfs',
html:
'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' +
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)',
},
{
key: 'ecmwf-ifs',
html:
'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' +
'<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a> · ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>. ' +
'Modified: resampled 10 m IFS forecast vectors and animated display. ' +
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'datacenters',
+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
+8
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
@@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', ()
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
+253
View File
@@ -0,0 +1,253 @@
import { layerFeedState } from './feedState.js';
/**
* Shared layer snapshot envelope for voice and HUD answers.
*
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
* summary must all name the same feed-state. This module is the one envelope:
* it classifies `getStats()` through `layerFeedState` and stamps the result
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
* HUD summary proxy can import it from Node.
*
* @module data/layerSnapshot
*/
/** Feed states the Data Layers chips and voice/HUD provenance share. */
export const LAYER_FEED_STATES = Object.freeze([
'nominal',
'loading',
'degraded',
'stale',
'fallback',
'unavailable',
]);
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
export const LAYER_FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/** Snapshot feed-state including a disabled layer. */
export const SNAPSHOT_FEED_STATES = Object.freeze([
...LAYER_FEED_STATES,
'off',
]);
/**
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
* neighbour.
*/
export const FEED_STATE_SEVERITY = Object.freeze({
unavailable: 0,
loading: 1,
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
});
export { layerFeedState } from './feedState.js';
function snapshotError(stats) {
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
if (err == null || err === '') return null;
return err instanceof Error ? err.message : String(err);
}
/**
* Age of a lastUpdate timestamp in whole seconds.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {number|null} Non-negative seconds, or null when unknown.
*/
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
const stamp = Number(lastUpdate);
if (!Number.isFinite(stamp) || stamp <= 0) return null;
return Math.max(0, Math.floor((now - stamp) / 1000));
}
/**
* Compact age label matching the Data Layers meta line.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
*/
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
const sec = snapshotAgeSec(lastUpdate, now);
if (sec === null) return null;
if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
let worstRank = Number.POSITIVE_INFINITY;
for (const state of states || []) {
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
const rank = FEED_STATE_SEVERITY[state];
if (rank < worstRank) {
worst = state;
worstRank = rank;
}
}
return worst;
}
/**
* One honest layer snapshot for voice/HUD narration.
*
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
* even if leftover stats would otherwise classify as stale. Counts and errors
* still travel so a disabled layer with a last-known error is inspectable.
*
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
* @param {{ now?: number }} [options]
* @returns {{
* id: string|null,
* name: string|null,
* enabled: boolean,
* feedState: string,
* source: string|null,
* count: number,
* lastUpdate: number|null,
* ageSec: number|null,
* ageLabel: string|null,
* error: string|null,
* }}
*/
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
const source = String(stats.source || layer.source || '').trim() || null;
const enabled = Boolean(layer.enabled);
const countRaw = Number(stats.count);
const count = Number.isFinite(countRaw) ? countRaw : 0;
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
? Number(stats.lastUpdate)
: null;
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
return {
id: layer.id || layer.layerKey || null,
name: layer.name || layer.label || layer.id || layer.layerKey || null,
enabled,
feedState,
source,
count,
lastUpdate,
ageSec: snapshotAgeSec(lastUpdate, now),
ageLabel: snapshotAgeLabel(lastUpdate, now),
error: snapshotError(stats),
};
}
/**
* Snapshot every layer in a roster.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {Array<object>}
*/
export function layerSnapshots(layers = [], options) {
if (!Array.isArray(layers)) return [];
return layers.map((layer) => layerSnapshot(layer, options));
}
function asSnapshot(entry, now) {
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
if (typeof entry.feedState === 'string') return entry;
return layerSnapshot(entry, { now });
}
/**
* Mechanical narration note so the model does not have to infer honesty.
* @param {Array<object>} layers Snapshots.
* @param {string|null} overall Worst considered feed-state.
* @returns {string}
*/
export function feedProvenanceNote(layers = [], overall = null) {
if (!overall) {
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
}
if (overall === 'nominal') {
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
}
if (overall === 'off') {
const names =
layers
.map((s) => s.name || s.id)
.filter(Boolean)
.join(', ') || 'the requested layer';
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
}
const flagged = layers.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
);
const bits = flagged.map((s) => {
const extras = [
s.source,
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
s.error,
]
.filter(Boolean)
.join(', ');
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
/**
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
*/
export function feedProvenanceEnvelope(
snapshots = [],
{ now = Date.now() } = {},
) {
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
asSnapshot(entry, now),
);
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
const overall =
worstFeedState(active.map((s) => s.feedState)) ||
(layers.length ? 'off' : null);
return {
overall,
layers,
note: feedProvenanceNote(layers, overall),
};
}
/**
* Compact voice-tool layer row: identity + the fields a model must read.
* @param {object} layer Raw `getAll()` row or snapshot.
* @param {{ now?: number }} [options]
* @returns {object}
*/
export function viewStateLayerRow(layer, options) {
const snap = layerSnapshot(layer, options);
return {
id: snap.id,
name: snap.name,
enabled: snap.enabled,
count: snap.count,
error: snap.error,
feedState: snap.feedState,
source: snap.source,
lastUpdate: snap.lastUpdate,
};
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
FEED_STATE_SEVERITY,
LAYER_FEED_STATE_LABELS,
LAYER_FEED_STATES,
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
feedProvenanceNote,
layerFeedState,
layerSnapshot,
layerSnapshots,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
worstFeedState,
} from './layerSnapshot.js';
const NOW = 1_700_000_000_000;
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
const src = readFileSync(
new URL('./layerSnapshot.js', import.meta.url),
'utf8',
);
assert.equal(src.includes("from './manager.js'"), false);
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
});
test('layerFeedState matches the chip classifier for every honesty class', () => {
assert.equal(
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
'unavailable',
);
assert.equal(
layerFeedState({
status: 'unavailable',
error: 'feed down',
count: 50,
lastUpdate: 1,
}),
'unavailable',
);
assert.equal(
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({
source: 'adsb.lol',
fallback: false,
count: 10,
lastUpdate: 1,
}),
'nominal',
);
assert.equal(
layerFeedState({ stale: true, count: 0, lastUpdate: 1 }),
'stale',
);
assert.equal(
layerFeedState({
error: 'partial group failure',
count: 50,
lastUpdate: 1,
}),
'degraded',
);
assert.equal(layerFeedState({ loading: true }), 'loading');
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
assert.equal(
layerFeedState({
status: 'zoom-in',
error: 'zoom in to search',
count: 12,
}),
'nominal',
);
assert.equal(
layerFeedState({ status: 'zoom-in', stale: true, count: 12 }),
'stale',
);
});
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
}
assert.equal(
FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale,
true,
);
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
assert.equal(worstFeedState([]), null);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: false,
source: 'OpenSky Network',
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'off');
assert.equal(snap.enabled, false);
assert.equal(snap.count, 12);
assert.equal(snap.ageLabel, '4m ago');
});
test('an enabled stale layer carries age, source, and a mechanical note', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'stale');
assert.equal(snap.source, 'OpenSky Network');
assert.equal(snap.ageSec, 240);
assert.equal(snap.ageLabel, '4m ago');
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
assert.equal(envelope.overall, 'stale');
assert.match(envelope.note, /Live Flights is STALE/);
assert.match(envelope.note, /OpenSky Network/);
assert.match(envelope.note, /4m ago/);
assert.match(envelope.note, /do not describe this as live/i);
});
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
const layers = layerSnapshots(
[
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: true,
stats: { count: 3, lastUpdate: NOW - 10_000 },
},
{
id: 'local-firms',
name: 'Active Fires',
enabled: true,
stats: {
error: 'FIRMS key missing',
count: 8,
lastUpdate: NOW - 30_000,
},
},
],
{ now: NOW },
);
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
assert.equal(envelope.overall, 'degraded');
assert.match(envelope.note, /Active Fires is DEGRADED/);
assert.match(envelope.note, /FIRMS key missing/);
});
test('an unavailable feed with no prior data does not become a confident zero', () => {
const snap = layerSnapshot(
{
id: 'ais-live-vessels',
name: 'Live Vessels',
enabled: true,
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
},
{ now: NOW },
);
assert.equal(snap.feedState, 'unavailable');
const envelope = feedProvenanceEnvelope([snap]);
assert.equal(envelope.overall, 'unavailable');
assert.match(envelope.note, /UNAVAILABLE/);
});
test('fallback provenance names the fallback source instead of calling it live', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
source: 'adsb.lol fallback',
count: 40,
lastUpdate: NOW - 8_000,
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'fallback');
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
});
test('view-state rows keep the existing identity fields and add feedState', () => {
const row = viewStateLayerRow(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
count: 9,
lastUpdate: 42,
error: null,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.deepEqual(row, {
id: 'flights',
name: 'Live Flights',
enabled: true,
count: 9,
error: null,
feedState: 'nominal',
source: 'OpenSky Network',
lastUpdate: 42,
});
});
test('empty roster envelope does not pretend to be nominal', () => {
const envelope = feedProvenanceEnvelope([]);
assert.equal(envelope.overall, null);
assert.match(envelope.note, /Do not invent/);
});
test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeSec(null, NOW), null);
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
+84 -1
View File
@@ -168,7 +168,14 @@ function stringOption(key, token, defaultValue) {
});
}
function enumOption(key, token, defaultValue, values, codes) {
function enumOption(
key,
token,
defaultValue,
values,
codes,
{ absentValue = defaultValue } = {},
) {
const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
@@ -176,6 +183,7 @@ function enumOption(key, token, defaultValue, values, codes) {
key,
token,
defaultValue,
absentValue,
normalize: (value) => normalizeEnum(values, value),
encode: (value) => codes[value],
decode: (value) => reverse[value] || null,
@@ -205,6 +213,52 @@ function integerOption(key, token, defaultValue) {
}
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-radar': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
enumOption(
'product',
'p',
'clouds-regional',
['clouds', 'clouds-regional'],
{ clouds: 'g', 'clouds-regional': 'r' },
),
]),
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
enumOption(
'overlay',
'o',
'none',
['none', 'speed', 'temperature', 'pressure'],
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
{ absentValue: 'speed' },
),
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
'km/h': 'k',
'm/s': 'm',
mph: 'i',
}),
booleanOption('paused', 'p', false),
]),
flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
@@ -384,6 +438,35 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
Object.freeze({
id: 'weather-cyclones',
token: 'y',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'weather-lightning',
token: 'l',
disposition: 'enabled+options',
optionOwner: 'weather-lightning',
}),
Object.freeze({
id: 'weather-radar',
token: 'v',
disposition: 'enabled+options',
optionOwner: 'weather-radar',
}),
Object.freeze({
id: 'weather-satellite',
token: 'o',
disposition: 'enabled+options',
optionOwner: 'weather-satellite',
}),
Object.freeze({
id: 'wind',
token: 'k',
disposition: 'enabled+options',
optionOwner: 'wind',
}),
]);
export const REGISTERED_LAYER_IDS = Object.freeze(
+60 -2
View File
@@ -158,8 +158,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 21);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
assert.equal(REGISTERED_LAYER_IDS.length, 26);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 26);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
@@ -331,6 +331,7 @@ test('compact URL omits absent-meaning option state and still resolves to it', (
// below, and the divergence itself in the two codec tests above.
state.options.flights = { models3d: false, models3dMode: 'proximity', selectedFlightsTrackingId: null, selectedMilitaryTrackingId: null };
state.options.satellites = { catalog: 'core', showPoints: true, showOrbits: true, selectedSatTrackingId: null };
state.options.wind.overlay = 'speed'; // Frozen v2 omitted-token meaning; new boots use trails.
const params = encodeLayerStateParams(new URLSearchParams('v=2'), state);
assert.equal(params.has('lo'), false);
const roundTrip = decodeLayerStateParams(params);
@@ -1604,3 +1605,60 @@ test('the owner layer going away revokes the pending watch at any origin', async
f.coordinator.destroy();
}
});
test('wind appearance shares round trip while old links retain weather defaults', () => {
const state = normalizeLayerState({
enabledLayerIds: ['wind'],
options: {
wind: { model: 'ifs', overlay: 'pressure', units: 'mph', paused: true },
},
});
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(state))).options.wind,
{ model: 'ifs', overlay: 'pressure', units: 'mph', paused: true },
);
const defaults = createDefaultLayerState().options.wind;
assert.deepEqual(defaults, {
model: 'gfs',
overlay: 'none',
units: 'km/h',
paused: false,
});
const legacy = decodeLayerStateParams(new URLSearchParams('v=2&l=k'));
assert.equal(legacy.options.wind.overlay, 'speed', 'old links retain their authored field');
assert.equal(decodeLayerStateParams(new URLSearchParams(encode(createDefaultLayerState()))).options.wind.overlay, 'none', 'new default is encoded explicitly');
const old = normalizeLayerState({ options: { wind: { model: 'ifs' } } });
assert.deepEqual(old.options.wind, { ...defaults, model: 'ifs' });
const invalid = normalizeLayerState({
options: {
wind: {
model: 'unknown',
overlay: 'clouds',
units: '<script>',
paused: 'yes',
},
},
});
assert.deepEqual(invalid.options.wind, defaults);
});
test('observed weather round trips product and opacity without persisting historical playback', () => {
const state = normalizeLayerState({ enabledLayerIds: ['weather-radar', 'weather-satellite'], options: { 'weather-radar': { opacity: 'light', play: true }, 'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 } } });
const params = new URLSearchParams(encode(state));
const decoded = decodeLayerStateParams(params);
assert.deepEqual(decoded, state);
assert.equal(state.options['weather-satellite'].product, 'clouds');
assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false);
});
test('satellite infrared display mode round trips and invalid or absent values use filtered', () => {
for (const infrared of ['full', 'filtered', undefined, 'invalid']) {
const state = normalizeLayerState({ enabledLayerIds: ['weather-satellite'], options: {
'weather-satellite': { infrared, product: 'clouds', step: -1, play: true },
} });
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(state))), state);
assert.equal(state.options['weather-satellite'].infrared, infrared === 'full' ? 'full' : 'filtered');
assert.equal(Object.hasOwn(state.options['weather-satellite'], 'step'), false);
assert.equal(Object.hasOwn(state.options['weather-satellite'], 'play'), false);
}
});
+222
View File
@@ -0,0 +1,222 @@
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
const DC_RAW = {
id: '1176042553',
lat: -52.942,
lon: -70.85,
properties: {
tags: {
name: 'AWS',
operator: 'Amazon Web Services',
'operator:short': 'AWS',
'capacity:it_load': '27 MW',
},
},
};
const DAM_RAW = {
id: '-19685440',
lat: -25.41,
lon: -54.59,
properties: {
name: 'Usina Hidrelétrica de Itaipu',
output: '14000 MW',
tags: {
name: 'Usina Hidrelétrica de Itaipu',
'name:en': 'Itaipu Dam',
operator: 'Itaipú Binacional',
associated_river: 'Paraná',
'plant:output:electricity': '14000 MW',
},
},
};
test('infra analyst record: datacenter maps name, operator, capacity', () => {
const r = mapAnalystRecord(DC_RAW, 'local-datacenters');
assert.deepEqual(r, {
id: 'AWS',
name: 'AWS',
lat: -52.942,
lon: -70.85,
operator: 'Amazon Web Services',
capacity: '27 MW',
river: null,
output: null,
});
});
test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => {
const r = mapAnalystRecord(DAM_RAW, 'local-dams');
assert.equal(r.id, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.name, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.operator, 'Itaipú Binacional');
assert.equal(r.river, 'Paraná');
assert.equal(r.output, '14000 MW');
assert.equal(r.capacity, null);
});
test('infra analyst record: unnamed feature falls back to source id', () => {
const r = mapAnalystRecord(
{
id: 'dc-42',
lat: 30.2,
lon: -97.7,
properties: { tags: { operator: 'Example Cloud' } },
},
'local-datacenters',
);
assert.equal(r.id, 'dc-42');
assert.equal(r.name, null);
assert.equal(r.operator, 'Example Cloud');
});
test('infra analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined, 'local-dams');
assert.equal(r.id, 'Dam');
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord(
{
...DAM_RAW,
entity: {},
position: { x: 1 },
},
'local-dams',
);
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('entity' in r, false);
assert.equal('position' in r, false);
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
}
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
type: 'Feature',
id: 'runtime-dam',
properties: {
name: 'Runtime Dam',
output: '12 MW',
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.7, 30.2],
[-97.69, 30.2],
[-97.69, 30.21],
[-97.7, 30.2],
],
],
},
}),
});
const viewer = {
selectedEntity: undefined,
dataSources: {
add(dataSource) {
return dataSource;
},
remove() {
return true;
},
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
moveEnd: new MockLayerEvent(),
flyTo() {},
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender: new MockLayerEvent(),
sampleHeightSupported: false,
sampleHeight() {
return undefined;
},
screenSpaceCameraController: { enableInputs: true },
pick() {
return null;
},
requestRender() {},
},
};
const layer = createLocalGeoJsonLayer({
id: 'local-dams',
url: '/runtime-dam.geojsonl',
name: 'Runtime Dams',
color: '#0088ff',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
projectToWindow: () => ({ x: 400, y: 300 }),
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
try {
assert.deepEqual(
layer.getAnalystRecords(),
[],
'unenabled layer has no analyst records',
);
await layer.enable(viewer);
const rows = layer.getAnalystRecords();
assert.equal(rows.length, 1);
assert.equal(rows[0].id, 'Runtime Dam');
assert.equal(rows[0].name, 'Runtime Dam');
assert.equal(rows[0].operator, 'Test Hydro');
assert.equal(rows[0].river, 'Test River');
assert.equal(rows[0].output, '12 MW');
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
assert.equal(
layer.getAnalystRecords(0).length,
1,
'non-finite/zero cap still returns at least one',
);
layer.disable(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'disable keeps stems but analyst snapshot is empty',
);
} finally {
layer.destroy(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'destroy releases analyst records',
);
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
+107 -1
View File
@@ -89,6 +89,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -310,7 +382,7 @@ export function localDatasetError(error) {
* standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers.
*/
@@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer(
computed: _lodComputed,
}),
/**
* Snapshot in-memory infrastructure features as plain JSON-safe objects
* for the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty. Disable keeps the loaded
* stems for reuse; this method still returns [] until the next enable.
* @param {number} [maxCount=2000] Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_stemRecords.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (let i = 0; i < _stemRecords.length; i++) {
if (result.length >= limit) break;
const record = _stemRecords[i];
const carto = record.carto;
result.push(
mapAnalystRecord(
{
id: record.id,
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
properties: propertyObject(record.entity),
},
id,
),
);
}
return result;
},
enable: async (viewer) => {
if (_destroyed) return;
_enabled = true;
+141
View File
@@ -2746,6 +2746,12 @@ function makeControlElement() {
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
@@ -3204,6 +3210,7 @@ test('an async layer pushes its own row refresh, and a busy chip refuses clicks'
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
@@ -3336,3 +3343,137 @@ test('row action chips use live disabled state and descriptive counts without wr
else globalThis.document = originalDocument;
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = scheduler === 'animation frame'
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
manager.register(layer.module);
try {
manager.buildTogglePanel(makeControlElement());
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRaf;
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
const container = makeControlElement();
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const row = container.querySelector('.data-toggle-controls');
const chip = row.children[0];
let legend = row.children[1];
const info = row.children[2];
chip.focus();
let writes = 0;
for (const key of ['textContent', 'title']) {
let value = info[key];
Object.defineProperty(info, key, {
get: () => value,
set: (next) => { writes++; value = next; },
});
}
controls = structuredClone(controls);
manager._refreshTogglePanel();
assert.equal(row.children[1], legend);
assert.equal(row.children[2], info);
assert.equal(writes, 0);
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assert.notEqual(row.children[1], legend);
legend = row.children[1];
assert.equal(row.children[2], info);
assert.equal(globalThis.document.activeElement, chip);
}
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(row.children.map(node => node.className), [
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
'data-toggle-legend-item', 'data-toggle-controls-info',
]);
assert.equal(row.children[2], legend);
assert.equal(row.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(row.hidden, true);
assert.equal(info.hidden, true);
assert.deepEqual(row.children, [info]);
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
manager._refreshTogglePanel();
assert.equal(row.children.at(-1), info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
+458
View File
@@ -0,0 +1,458 @@
// src/data/satellitePass.js
/**
* Satellite pass prediction with naked-eye visibility.
* Look angles come from SGP4; rise/set are bisected to sub-second precision,
* the peak is refined with a parabola, and visibility uses the USNO
* low-precision Sun position with a cylindrical Earth shadow. Scan pattern from
* skylight (MIT) shared/src/celestial.ts nextISSPass.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
const EARTH_RADIUS_KM = 6371.0;
const AU_KM = 149597870.7;
/**
* Observer look angles at an instant, or null when propagation fails.
* @param {Object} satrec SGP4 satellite record
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @param {number} latDeg Observer latitude in degrees [-90, 90]
* @param {number} lonDeg Observer longitude in degrees [-180, 180]
* @returns {{ elevDeg: number, azDeg: number, satECI: {x:number, y:number, z:number} } | null}
*/
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
let pv;
try {
pv = propagate(satrec, date);
} catch {
return null;
}
const pos = pv && pv.position;
if (
!pos ||
typeof pos === 'boolean' ||
![pos.x, pos.y, pos.z].every(Number.isFinite)
)
return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
satECI: pos,
};
}
/**
* Compute Sun position in geocentric ECI coordinates using USNO low-precision solar coordinates.
* Accurate to within ~1 arcminute (~0.017°) within centuries of J2000.0.
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {{ x: number, y: number, z: number, raDeg: number, decDeg: number }}
*/
export function solarPositionECI(dateMs) {
const JD = dateMs / 86400000 + 2440587.5;
const D = JD - 2451545.0; // days since J2000.0
const g = ((((357.529 + 0.98560028 * D) % 360) + 360) % 360) * D2R;
const q = (((280.459 + 0.98564736 * D) % 360) + 360) % 360;
const L =
((((q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) % 360) + 360) % 360) *
D2R;
const e = (23.439 - 0.00000036 * D) * D2R;
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
return {
x: AU_KM * Math.cos(dec) * Math.cos(ra),
y: AU_KM * Math.cos(dec) * Math.sin(ra),
z: AU_KM * Math.sin(dec),
raDeg: (((ra * R2D) % 360) + 360) % 360,
decDeg: dec * R2D,
};
}
/**
* Check whether a satellite in ECI is illuminated by the Sun (outside Earth shadow).
* Uses a cylindrical Earth shadow model with mean radius 6,371 km.
* @param {{ x: number, y: number, z: number }} satECI Satellite position in km
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {boolean} True if sunlit, false if in eclipse (Earth shadow)
*/
export function isSatelliteSunlit(satECI, dateMs) {
if (!satECI || typeof satECI === 'boolean') return false;
const sun = solarPositionECI(dateMs);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
const proj = satECI.x * sx + satECI.y * sy + satECI.z * sz;
if (proj > 0) return true; // Sunward side of Earth
const dx = satECI.x - proj * sx;
const dy = satECI.y - proj * sy;
const dz = satECI.z - proj * sz;
return Math.hypot(dx, dy, dz) > EARTH_RADIUS_KM;
}
/**
* Compute the observer local solar elevation angle in degrees.
* @param {number} latDeg Observer latitude in degrees
* @param {number} lonDeg Observer longitude in degrees
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {number} Solar elevation in degrees [-90, 90]
*/
export function observerSolarElevation(latDeg, lonDeg, dateMs) {
const sun = solarPositionECI(dateMs);
const gmst = gstime(new Date(dateMs));
const gmstDeg = (((gmst * R2D) % 360) + 360) % 360;
const localHourAngleDeg =
(((gmstDeg + lonDeg - sun.raDeg) % 360) + 360) % 360;
const H = localHourAngleDeg * D2R;
const phi = latDeg * D2R;
const dec = sun.decDeg * D2R;
const sinElev =
Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H);
return Math.asin(Math.max(-1, Math.min(1, sinElev))) * R2D;
}
/**
* Check whether the observer sky is dark (civil, nautical, or astronomical twilight / night).
* Default threshold is -6° (civil twilight).
* @param {number} latDeg Observer latitude
* @param {number} lonDeg Observer longitude
* @param {number} dateMs UTC timestamp
* @param {number} [maxSunElevDeg=-6] Sun elevation ceiling (default -6° for civil twilight)
* @returns {boolean} True if sun is at or below the threshold
*/
export function isObserverDark(latDeg, lonDeg, dateMs, maxSunElevDeg = -6) {
return observerSolarElevation(latDeg, lonDeg, dateMs) <= maxSunElevDeg;
}
/**
* Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0.
* Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s.
* @private
*/
function _bisectBoundary(
satrec,
latDeg,
lonDeg,
tLow,
tHigh,
minElevDeg,
isRising,
steps = 10,
) {
let low = tLow;
let high = tHigh;
for (let i = 0; i < steps; i++) {
const mid = (low + high) / 2;
const look = lookAnglesAt(satrec, mid, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (isRising) {
if (elev >= minElevDeg) {
high = mid;
} else {
low = mid;
}
} else {
if (elev >= minElevDeg) {
low = mid;
} else {
high = mid;
}
}
}
return isRising ? Math.ceil(high) : Math.floor(low);
}
/**
* Find the next pass of a satellite over an observer location.
* Uses 20s coarse scanning, bisection for sub-second rise/set times,
* parabolic interpolation for the peak, and optional naked-eye visibility filtering.
*
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude [-90, 90]
* @param {number} options.lonDeg Observer longitude [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°)
* @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h)
* @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s)
* @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s)
* @param {boolean} [options.requireVisible=false] Skip passes that are not naked-eye visible
* @param {number} [options.maxSunElevDeg=-6] Ceiling for observer darkness (default -6° for civil twilight)
* @returns {{
* riseMs: number,
* setMs: number,
* maxElevDeg: number,
* maxElevMs: number,
* riseAzDeg: number,
* visible: boolean,
* sunlit: boolean,
* observerDark: boolean
* } | null} `visible` is true when any sampled part of the pass is sunlit under a
* dark sky; `sunlit` and `observerDark` describe the peak only.
*/
export function findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 20,
fineStepSec = 5,
requireVisible = false,
maxSunElevDeg = -6,
}) {
if (
!satrec ||
![
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
maxSunElevDeg,
].every(Number.isFinite) ||
Math.abs(latDeg) > 90 ||
Math.abs(lonDeg) > 180 ||
Math.abs(fromMs) > 8.63e15 ||
minElevDeg < 0 ||
minElevDeg > 90 ||
horizonHours <= 0 ||
horizonHours > 72 ||
coarseStepSec < 1 ||
coarseStepSec > 120 ||
fineStepSec < 0.1 ||
fineStepSec > 60
)
return null;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarseMs = coarseStepSec * 1000;
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
let searchCursor = fromMs;
while (searchCursor <= horizonMs) {
let tPrev = searchCursor;
let tHit = null;
let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90;
// Check if we start already inside a pass
if (elevPrev >= minElevDeg) {
tHit = searchCursor;
} else {
// Coarse forward scan
for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) {
const look = lookAnglesAt(satrec, t, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (elev >= minElevDeg) {
tHit = t;
break;
}
tPrev = t;
elevPrev = elev;
}
}
if (tHit == null) return null; // No pass found in remaining horizon
// Resolve rise
let riseMs;
if (tHit === searchCursor && elevPrev >= minElevDeg) {
riseMs = searchCursor;
} else {
riseMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tPrev,
tHit,
minElevDeg,
true,
);
}
// Transit the pass to track peak and bracket set between tStepPrev and tCurr
let tCurr = riseMs;
let maxElevDeg = -90;
let maxElevMs = riseMs;
let tStepPrev = riseMs;
let setMs = null;
let hasVisibleSegment = false;
let elevPrevSample = null;
let peakLeftElev = null;
let peakRightElev = null;
let peakStepMs = fineMs;
// Visibility at an instant inside [riseMs, setMs], so no elevation gate
const checkVisibility = (tMs) => {
if (hasVisibleSegment) return;
const look = lookAnglesAt(satrec, tMs, latDeg, lonDeg);
if (!look) return;
const sunlit = isSatelliteSunlit(look.satECI, tMs);
const dark = isObserverDark(latDeg, lonDeg, tMs, maxSunElevDeg);
if (sunlit && dark) {
hasVisibleSegment = true;
}
};
// Check visibility at rise
checkVisibility(riseMs);
// Transit forward in fine steps
while (tCurr <= horizonMs) {
const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (look) {
const sunlit = isSatelliteSunlit(look.satECI, tCurr);
const dark = isObserverDark(latDeg, lonDeg, tCurr, maxSunElevDeg);
if (sunlit && dark && elev >= minElevDeg) {
hasVisibleSegment = true;
}
}
if (elev < minElevDeg && tCurr > riseMs) {
if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
// Below threshold: bisect set between tStepPrev and tCurr
setMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tStepPrev,
tCurr,
minElevDeg,
false,
);
break;
}
if (elev > maxElevDeg) {
maxElevDeg = elev;
maxElevMs = tCurr;
peakLeftElev = elevPrevSample;
peakRightElev = null;
} else if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
elevPrevSample = elev;
tStepPrev = tCurr;
tCurr += fineMs;
}
if (setMs == null) {
setMs = Math.min(horizonMs, tCurr);
}
// Refine peak culmination via 3-point parabolic interpolation around maxElevMs
// using adjacent transit samples (zero additional SGP4 calls)
let refinedPeakMs = maxElevMs;
let refinedPeakElevDeg = maxElevDeg;
if (
Number.isFinite(peakLeftElev) &&
Number.isFinite(peakRightElev) &&
peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down
) {
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
if (Math.abs(shift) < peakStepMs) {
refinedPeakMs = Math.max(
riseMs,
Math.min(setMs, Math.round(maxElevMs + shift)),
);
refinedPeakElevDeg = Math.min(
90,
maxElevDeg -
Math.pow(peakLeftElev - peakRightElev, 2) /
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)),
);
}
}
// Sun and sky state at the peak
const peakLook = lookAnglesAt(satrec, refinedPeakMs, latDeg, lonDeg);
const peakSunlit = peakLook
? isSatelliteSunlit(peakLook.satECI, refinedPeakMs)
: false;
const peakDark = isObserverDark(
latDeg,
lonDeg,
refinedPeakMs,
maxSunElevDeg,
);
// Check visibility at the refined peak and at set
checkVisibility(refinedPeakMs);
checkVisibility(setMs);
if (!hasVisibleSegment) {
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
const look = lookAnglesAt(satrec, time, latDeg, lonDeg);
return [
Boolean(look && isSatelliteSunlit(look.satECI, time)),
isObserverDark(latDeg, lonDeg, time, maxSunElevDeg),
];
});
}
// Skip passes with no visible part when requireVisible is set
if (requireVisible && !hasVisibleSegment) {
searchCursor = Math.max(setMs + 1000, searchCursor + coarseMs);
continue;
}
const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg: refinedPeakElevDeg,
maxElevMs: refinedPeakMs,
riseAzDeg: riseLook ? riseLook.azDeg : 0,
visible: hasVisibleSegment,
sunlit: peakSunlit,
observerDark: peakDark,
};
}
return null;
}
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
export function hasVisibleInterval(start, end, conditionsAt) {
const visible = (states) => states[0] && states[1];
for (let left = start; left < end; left += 5000) {
const right = Math.min(end, left + 5000);
const a = conditionsAt(left);
const b = conditionsAt(right);
if (visible(a) || visible(b)) return true;
const cuts = [left, right];
for (let condition = 0; condition < 2; condition++) {
if (a[condition] === b[condition]) continue;
let low = left;
let high = right;
while (high - low > 20) {
const mid = (low + high) / 2;
if (conditionsAt(mid)[condition] === a[condition]) low = mid;
else high = mid;
}
cuts.push((low + high) / 2);
}
cuts.sort((a, b) => a - b);
for (let i = 1; i < cuts.length; i++) {
if (visible(conditionsAt((cuts[i - 1] + cuts[i]) / 2))) return true;
}
}
return false;
}
+282
View File
@@ -0,0 +1,282 @@
// src/data/satellitePass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec, propagate } from 'satellite.js';
import {
findNextSatellitePass,
hasVisibleInterval,
lookAnglesAt,
solarPositionECI,
isSatelliteSunlit,
observerSolarElevation,
isObserverDark,
} from './satellitePass.js';
// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC).
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
test('solarPositionECI matches the Sun at J2000.0', () => {
// Reference: Cesium Simon1994PlanetaryPositions at 2000-01-01 12:00 UTC
// gives RA 281.289°, Dec -23.033°.
const j2000 = solarPositionECI(Date.UTC(2000, 0, 1, 12, 0, 0));
assert.ok(
Math.abs(j2000.raDeg - 281.289) < 0.05,
`RA ${j2000.raDeg}° within 0.05°`,
);
assert.ok(
Math.abs(j2000.decDeg - -23.033) < 0.05,
`Dec ${j2000.decDeg}° within 0.05°`,
);
});
test('solarPositionECI calculates reasonable solar coordinates across seasons', () => {
// Near Autumnal Equinox (Sept 22, 2020): declination ≈ 0°
const equinox = solarPositionECI(Date.UTC(2020, 8, 22, 12, 0, 0));
assert.ok(
Math.abs(equinox.decDeg) < 1.0,
'declination near equinox must be close to 0°',
);
// Near Summer Solstice (June 21, 2020): declination ≈ +23.44°
const solsticeSummer = solarPositionECI(Date.UTC(2020, 5, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeSummer.decDeg - 23.44) < 0.5,
'summer declination ≈ 23.44°',
);
// Near Winter Solstice (Dec 21, 2020): declination ≈ -23.44°
const solsticeWinter = solarPositionECI(Date.UTC(2020, 11, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeWinter.decDeg - -23.44) < 0.5,
'winter declination ≈ -23.44°',
);
});
test('observerSolarElevation distinguishes local noon from local midnight', () => {
// Greenwich noon on equinox: solar elevation should be close to 90 - lat
const greenwichNoon = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 12, 0, 0),
);
assert.ok(greenwichNoon > 30, 'sun must be well above horizon at local noon');
// Greenwich midnight: solar elevation must be deeply negative
const greenwichMidnight = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 0, 0, 0),
);
assert.ok(
greenwichMidnight < -20,
'sun must be well below horizon at midnight',
);
assert.ok(isObserverDark(51.5, 0, Date.UTC(2020, 8, 22, 0, 0, 0), -6));
});
test('isSatelliteSunlit accurately computes cylindrical umbra shadow', () => {
const summerDate = Date.UTC(2020, 5, 21, 12, 0, 0);
const sun = solarPositionECI(summerDate);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
// Point toward the Sun: must be sunlit
const daysideSat = { x: 7000 * sx, y: 7000 * sy, z: 7000 * sz };
assert.equal(isSatelliteSunlit(daysideSat, summerDate), true);
// Point directly in Earth shadow (opposite Sun, r = 6700 km, < Earth radius offset): must be eclipsed
const shadowedSat = { x: -6700 * sx, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(shadowedSat, summerDate), false);
// Point on night side but high altitude / far perpendicular to shadow axis (> 6371 km): must be sunlit
const highAltSat = { x: -6700 * sx + 10000, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(highAltSat, summerDate), true);
});
test('isSatelliteSunlit accurately identifies SGP4 orbital shadow entry and exit', () => {
const satrec = twoline2satrec(L1, L2);
// Minute 17 after FROM_MS (1221914820000): ISS is inside Earth umbra shadow cone
const tShadowed = FROM_MS + 17 * 60_000;
const pvShadowed = propagate(satrec, new Date(tShadowed));
assert.equal(
isSatelliteSunlit(pvShadowed.position, tShadowed),
false,
'ISS must be eclipsed in Earth umbra during orbital night',
);
// Minute 18 after FROM_MS (1221914880000): ISS emerges from umbra into sunlight
const tSunlit = FROM_MS + 18 * 60_000;
const pvSunlit = propagate(satrec, new Date(tSunlit));
assert.equal(
isSatelliteSunlit(pvSunlit.position, tSunlit),
true,
'ISS must be illuminated upon orbital sunrise',
);
});
test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
});
assert.ok(pass, 'expected a pass within 24h at Austin for ISS');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Verify bisection accuracy at rise time:
const beforeRise = lookAnglesAt(
satrec,
pass.riseMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atRise = lookAnglesAt(
satrec,
pass.riseMs,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const afterRise = lookAnglesAt(
satrec,
pass.riseMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeRise && beforeRise.elevDeg < 10.05,
'elevation before riseMs must be below threshold',
);
assert.ok(
atRise && Math.abs(atRise.elevDeg - 10) < 0.2,
'elevation at riseMs must be within 0.2° of 10°',
);
assert.ok(
afterRise && afterRise.elevDeg > 9.95,
'elevation after riseMs must be rising above threshold',
);
// Verify bisection accuracy at set time:
const beforeSet = lookAnglesAt(
satrec,
pass.setMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg);
const afterSet = lookAnglesAt(
satrec,
pass.setMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeSet && beforeSet.elevDeg > 9.95,
'elevation before setMs must be above threshold',
);
assert.ok(
atSet && Math.abs(atSet.elevDeg - 10) < 0.2,
'elevation at setMs must be within 0.2° of 10°',
);
assert.ok(
afterSet && afterSet.elevDeg < 10.05,
'elevation after setMs must be below threshold',
);
// Parabolic culmination accuracy: max elevation must be peak
assert.ok(pass.maxElevDeg >= atRise.elevDeg);
assert.ok(pass.maxElevDeg >= atSet.elevDeg);
});
test('findNextSatellitePass skips non-visible passes when requireVisible is true', () => {
const satrec = twoline2satrec(L1, L2);
const geometricPass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
requireVisible: false,
});
assert.ok(geometricPass);
const visiblePass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
horizonHours: 72,
requireVisible: true,
});
assert.ok(visiblePass, 'expected at least one visible pass within horizon');
assert.equal(visiblePass.visible, true);
assert.ok(visiblePass.riseMs >= geometricPass.riseMs);
});
test('findNextSatellitePass samples the peak at fineStepSec', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 };
const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 });
const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 });
assert.ok(fine && coarse);
// The step is used: peak samples differ.
assert.notEqual(fine.maxElevMs, coarse.maxElevMs);
// Rise comes from bisection, so it does not depend on the step.
assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000);
// Parabolic refinement keeps 10s sampling close to the 1s peak.
assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000);
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
});
test('invalid numerical search options return promptly without unbounded scans', () => {
const satrec = twoline2satrec(L1, L2);
const options = { satrec, ...AUSTIN, fromMs: FROM_MS };
for (const change of [
{ coarseStepSec: 0 },
{ coarseStepSec: -1 },
{ horizonHours: Infinity },
{ horizonHours: 73 },
{ fineStepSec: NaN },
{ latDeg: 91 },
{ fromMs: NaN },
]) {
assert.equal(findNextSatellitePass({ ...options, ...change }), null);
}
});
test('visibility catches a short overlap between shadow exit and dawn', () => {
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]),
true,
);
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]),
false,
);
});
test('a pass already underway clips rise to the requested start', () => {
const satrec = twoline2satrec(L1, L2);
const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS });
const clipped = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: first.maxElevMs,
});
assert.equal(clipped.riseMs, first.maxElevMs);
assert.ok(clipped.setMs >= clipped.riseMs);
assert.ok(
clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs,
);
});
+154
View File
@@ -0,0 +1,154 @@
// Focused tests for the satellite analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import satellitesLayer, {
mapAnalystRecord,
_clearDenseCatalogStateForTest,
_setTrackedSatelliteRefreshStateForTest,
} from './satellites.js';
const ISS_NORAD = 25544;
const ISS_L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const ISS_L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const FULL_RAW = {
noradId: 25544,
name: 'ISS (ZARYA)',
group: 'stations',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
};
test('satellite analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RAW);
assert.deepEqual(r, {
id: 'ISS (ZARYA)',
noradId: '25544',
name: 'ISS (ZARYA)',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
satelliteClass: 'STATION · ISS',
group: 'stations',
});
});
test('satellite analyst record: nameless row falls back to SAT-norad id', () => {
const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 });
assert.equal(r.id, 'SAT-25544');
assert.equal(r.name, null);
});
test('satellite analyst record: ISS class wins even when ingested as visual', () => {
const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' });
assert.equal(r.satelliteClass, 'STATION · ISS');
});
test('satellite analyst record: GPS group maps to NAV · GPS', () => {
const r = mapAnalystRecord({
noradId: 24876,
name: 'GPS BIIR-2 (PRN 13)',
group: 'gps-ops',
lat: 0,
lon: 0,
altitudeM: 20_200_000,
speedMps: 3870,
});
assert.equal(r.satelliteClass, 'NAV · GPS');
assert.equal(r.group, 'gps-ops');
});
test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined);
assert.equal(r.id, 'SAT-00000');
assert.equal(r.satelliteClass, null);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord({
...FULL_RAW,
position: { x: 1 },
satrec: { dummy: true },
});
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('position' in r, false);
assert.equal('satrec' in r, false);
});
test('satellite getAnalystRecords: disabled or empty catalog returns []', () => {
_clearDenseCatalogStateForTest();
assert.deepEqual(satellitesLayer.getAnalystRecords(), []);
});
test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => {
const satrec = twoline2satrec(ISS_L1, ISS_L2);
const point = {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000),
};
_setTrackedSatelliteRefreshStateForTest({
noradId: ISS_NORAD,
name: 'ISS (ZARYA)',
satrec,
entity: {},
point,
viewer: {},
now: () => Date.now(),
neighbours: [
{
noradId: 99999,
name: 'STARLINK-TEST',
satrec,
group: 'dense',
point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) },
},
{
noradId: 24876,
name: 'GPS BIIR-2',
satrec,
group: 'gps-ops',
point: {
position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000),
},
},
],
});
try {
const capped = satellitesLayer.getAnalystRecords(2);
assert.equal(capped.length, 2);
assert.deepEqual(
capped.map((r) => r.noradId),
['25544', '24876'],
);
assert.equal(
capped.some((r) => r.group === 'dense'),
false,
);
const all = satellitesLayer.getAnalystRecords(10);
assert.equal(all.length, 3);
assert.equal(all[2].noradId, '99999');
assert.equal(all[2].satelliteClass, 'COMMS · STARLINK');
for (const row of all) {
assert.ok(
Number.isFinite(row.lat) && Number.isFinite(row.lon),
`${row.id} needs a geodetic position`,
);
assert.deepEqual(JSON.parse(JSON.stringify(row)), row);
}
} finally {
_clearDenseCatalogStateForTest();
}
});
+3
View File
@@ -41,6 +41,7 @@ export const _clearSatelliteLabelLifecycleForTest =
export const applySatellitePointFocusDeemphasis =
layer.applySatellitePointFocusDeemphasis;
export const getNextIssPass = layer.getNextIssPass;
export const getNextSatellitePass = layer.getNextSatellitePass;
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
@@ -49,3 +50,5 @@ export {
ISS_OVERLAY_SOURCE_OPTIONS,
} from '../layers/satellites/index.js';
export default layer;
export { mapAnalystRecord } from '../layers/satellites/records.js';
+3 -2
View File
@@ -135,7 +135,7 @@ export function applyVesselOverlayPolicy(
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
const gapPx = Math.max(selected ? 28 : 12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
@@ -144,7 +144,8 @@ export function applyVesselOverlayPolicy(
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
// Selected detail cards may move beside the contact to clear solid panels.
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
+1
View File
@@ -75,6 +75,7 @@ test('vessel host policy uses always-on shared fade and protected selected lane'
id: 'vessel:2', position, title: 'SELECTED', gapPx: 12, selected: true,
});
assert.equal(selected.variant, 'selected');
assert.equal(selected.verticalOnly, false, 'selected detail can clear panels on either side');
assert.equal(selected.protected, true);
assert.equal(selected.collisionGroup, 'ambient-card');
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
createWindSource(),
['getSnapshot'],
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => {
const writes = []; let deleted = false;
const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) };
await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/);
assert.deepEqual(writes, [20,0]); assert.equal(deleted, true);
});
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gfsObjectKey,
selectLatestGfsCycle,
} from '../../server/providers/wind/catalog.js';
import {
parseGfsIdx,
windMessageRanges,
fetchRange,
fetchGfsWind,
loadOptionalScalar,
} from '../../server/providers/wind/gfs.js';
test('catalog and index helpers', async () => {
assert.equal(
gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }),
'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003',
);
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
const p = parseGfsIdx(
'1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z',
);
assert.deepEqual(windMessageRanges(p), {
u: { start: 10, end: 19 },
v: { start: 20, end: 29 },
});
const b = await fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
});
assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
});
test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => {
await assert.rejects(
fetchRange({ url: 'x', start: -1, end: 2 }),
/range too large/,
);
let cancelled = false;
const body = new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(10));
},
cancel() {
cancelled = true;
},
});
await assert.rejects(
fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () => new Response(body, { status: 206 }),
}),
/budget/,
);
assert.equal(cancelled, true);
});
const WEATHER_IDX = [
'1:0:d=2026091506:UGRD:10 m above ground:6 hour fcst:',
'2:2:d=2026091506:VGRD:10 m above ground:6 hour fcst:',
'3:4:d=2026091506:TMP:850 mb:6 hour fcst:',
'4:6:d=2026091506:TMP:2 m above ground:6 hour fcst:',
'5:8:d=2026091506:PRMSL:mean sea level:6 hour fcst:',
'6:10:d=2026091506:HGT:surface:6 hour fcst:',
].join('\n');
test('GFS overlays select exact levels from the same issue and forecast object', async () => {
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'temperature' })
.scalar,
{ start: 6, end: 7 },
);
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'pressure' }).scalar,
{ start: 8, end: 9 },
);
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchGfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.idx')) return new Response(WEATHER_IDX);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 6 ? 293.15 : bytes[0] === 8 ? 101325 : bytes[0],
),
units: bytes[0] === 6 ? 'K' : bytes[0] === 8 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('.f006.idx'));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 20 : 1013.25),
) < 0.0001,
);
}
assert.throws(
() =>
windMessageRanges(
parseGfsIdx(WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')),
{ overlay: 'temperature' },
),
/missing TMP/,
);
});
test('GFS preserves wind when its optional temperature is missing or malformed', async () => {
for (const failure of ['missing', 'fetch', 'decode', 'geometry', 'units']) {
const result = await fetchGfsWind({
overlay: 'temperature',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
if (url.endsWith('.idx'))
return new Response(
failure === 'missing'
? WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')
: WEATHER_IDX,
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 6 && failure === 'fetch')
throw new Error('private provider failure');
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => {
if (bytes[0] === 6 && failure === 'decode')
throw new Error('bad message');
return {
ni: 4,
nj: 3,
lo1: bytes[0] === 6 && failure === 'geometry' ? 90 : 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0] === 6 ? 293.15 : bytes[0]),
units: bytes[0] === 6 && failure !== 'units' ? 'K' : 'm/s',
};
},
});
assert.equal(result.scalar, undefined);
assert.equal(result.grid.scalar, undefined);
assert.equal(result.scalarError, 'Temperature field unavailable');
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
}
});
test('optional scalar timeout cancels only its own fetch and honors parent cancellation', async () => {
const parent = new AbortController();
let active;
const value = await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'https://fixed.test/field',
timeoutMs: 5,
signal: parent.signal,
decodeImpl: () => {
throw new Error('not reached');
},
fetchImpl: (_, { signal }) => {
active = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
});
assert.equal(value, undefined);
assert.equal(active.aborted, true);
assert.equal(parent.signal.aborted, false);
parent.abort();
let calls = 0;
assert.equal(
await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'x',
signal: parent.signal,
fetchImpl: () => {
calls++;
},
decodeImpl: () => {},
}),
undefined,
);
assert.equal(calls, 0);
});
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resampleWindGrid,
resampleWeatherFields,
} from '../../server/providers/wind/grid.js';
test('resamples and wraps a wind grid', () => {
const r = resampleWindGrid({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
v: new Array(12).fill(1),
dx: 90,
dy: 90,
});
assert.deepEqual([r.nx, r.ny], [4, 3]);
assert.equal(r.u[0], 0);
assert.equal(r.u[3], 3);
assert.equal(r.u[4], 4);
});
const component = (values, extra = {}) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(values),
...extra,
});
test('weather resampling converts temperature and MSL pressure on the wind geometry', () => {
const u = component(3);
const v = component(-4);
for (const [overlay, raw, units, expected, outUnits] of [
['temperature', 293.15, 'K', 20, '°C'],
['pressure', 101325, 'Pa', 1013.25, 'hPa'],
]) {
const result = resampleWeatherFields({
u,
v,
scalar: component(raw, { units }),
overlay,
targetDx: 90,
});
assert.equal(result.scalar.kind, overlay);
assert.equal(result.scalar.units, outUnits);
assert.deepEqual([...result.grid.u], Array(12).fill(3));
assert.deepEqual([...result.grid.v], Array(12).fill(-4));
assert.ok(result.grid.scalar.every((n) => Math.abs(n - expected) < 0.0001));
}
assert.equal(
'scalar' in resampleWeatherFields({ u, v, targetDx: 90 }).grid,
false,
);
});
test('weather components cannot share a grid when shifted, truncated or non-finite', () => {
const u = component(1);
const v = component(2);
const scalar = component(273.15, { units: 'K' });
for (const bad of [
{ ...v, lo1: 90 },
{ ...v, ni: 3 },
{ ...v, dj: 45 },
]) {
assert.throws(() => resampleWeatherFields({ u, v: bad }), /Mismatched/);
}
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, la1: 89 },
overlay: 'temperature',
}),
/Mismatched/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, units: '°C' },
overlay: 'temperature',
}),
/units/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: [1] },
overlay: 'temperature',
}),
/geometry/,
);
const invalid = component(1);
invalid.values[5] = NaN;
assert.throws(
() => resampleWeatherFields({ u, v: invalid, targetDx: 180 }),
/values/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: invalid.values },
overlay: 'temperature',
}),
/values/,
);
assert.throws(
() => resampleWeatherFields({ u, v, overlay: 'dewpoint' }),
/Unknown/,
);
});
test('optional degradation cannot hide invalid wind geometry', async () => {
const { resampleWeatherSnapshot } =
await import('../../server/providers/wind/grid.js');
const u = component(1);
const v = component(2);
const result = resampleWeatherSnapshot({
u,
v,
overlay: 'pressure',
targetDx: 90,
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.throws(
() =>
resampleWeatherSnapshot({ u, v: { ...v, la1: 89 }, overlay: 'pressure' }),
/Mismatched/,
);
assert.throws(
() =>
resampleWindGrid({ ...u, u: u.values, v: v.values, dx: 0.25, dy: 0.25 }),
/budget/,
);
});
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ifsObjectUrls,
parseIfsIndex,
ifsWindRanges,
nearestIfsStep,
selectLatestIfsCycle,
fetchIfsWind,
} from '../../server/providers/wind/ifs.js';
import { nearestGfsStep } from '../../server/providers/wind/catalog.js';
test('IFS inventory selects only 10 m components and distinct index suffix', () => {
const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 });
assert.match(urls.index, /9h-oper-fc\.index$/);
assert.equal(urls.index.includes('.grib2.index'), false);
assert.deepEqual(
ifsWindRanges(
parseIfsIndex(
'{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}',
),
),
{ u: { start: 20, end: 29 }, v: { start: 40, end: 54 } },
);
assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
assert.equal(nearestIfsStep(8, 6), 9);
assert.equal(nearestIfsStep(200, 6), 144);
assert.equal(nearestIfsStep(151, 0), 150);
assert.equal(nearestGfsStep(8.2), 8);
});
test('IFS 2t and msl companions use the same issue/valid object as 10u and 10v', async () => {
const entries = ['10u', '10v', '2t', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchIfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 4 ? 283.15 : bytes[0] === 6 ? 100000 : bytes[0],
),
units: bytes[0] === 4 ? 'K' : bytes[0] === 6 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('-9h-oper-fc.index'));
assert.equal(result.cycle.runIso, '2026-09-15T06:00:00.000Z');
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 10 : 1000),
) < 0.0001,
);
}
assert.throws(
() =>
ifsWindRanges(
parseIfsIndex(
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join('\n'),
),
{ overlay: 'pressure' },
),
/missing msl/,
);
});
test('IFS optional pressure refusal preserves same-cycle wind', async () => {
const entries = ['10u', '10v', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
const result = await fetchIfsWind({
overlay: 'pressure',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 4) return new Response('Unavailable', { status: 503 });
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0]),
}),
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
});
+657
View File
@@ -0,0 +1,657 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { windProxy } from '../../server/providers/wind.js';
/** Boot a mounted provider middleware without a real server. */
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
assert.equal(routes.size, 1);
const handler = [...routes.values()][0];
return async (url = '/', method = 'GET') => {
const res = {
statusCode: 200,
headers: {},
headersSent: false,
writeHead(status, headers) {
this.statusCode = status;
for (const [name, value] of Object.entries(headers || {}))
this.headers[name.toLowerCase()] = value;
this.headersSent = true;
},
end(body) {
this.body = body;
this.headersSent = true;
},
};
await handler({ url, method }, res);
return res;
};
}
const IDX = [
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
'3:10:d=2026091400:HGT:surface:anl:',
].join('\n');
const DECODED_U = {
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
};
const DECODED_V = {
...DECODED_U,
values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
};
/** Resampled-grid metadata shared by the injected model fakes. */
const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 };
/** Fetch double: `.idx` text, and range bodies of the requested length. */
function makeFetch({ fail = false, counter } = {}) {
return async (url, options = {}) => {
if (counter) counter.calls += 1;
if (fail) throw new Error('gfs upstream down');
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
const length = Number(match[2]) - Number(match[1]) + 1;
return new Response(new Uint8Array(length), { status: 206 });
};
}
function proxy(options = {}) {
return windProxy({
now: () => Date.UTC(2026, 8, 14, 12),
targetDx: 90,
fetchImpl: makeFetch(),
decodeImpl: async (buffer) =>
buffer[0] === undefined ? DECODED_U : DECODED_U,
...options,
});
}
test('wind manifest describes the GFS cycle and resampled grid', async () => {
const request = install(proxy());
const res = await request('/');
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.body);
assert.equal(body.schemaVersion, 1);
assert.equal(body.model, 'gfs');
assert.equal(body.cycle.hour, 6);
assert.equal(body.cycle.date, '20260914');
assert.equal(body.units, 'm/s');
assert.deepEqual(body.grid, {
nx: 4,
ny: 3,
lo1: 0,
la1: 90,
dx: 90,
dy: 90,
});
assert.match(
body.gridUrl,
/^\/api\/wind\/grid\/gfs-20260914-6-f6-90\.bin\?model=gfs$/,
);
assert.equal(body.stale, false);
});
test('wind grid route returns Float32 U then V', async () => {
let u = 0;
const request = install(
proxy({
decodeImpl: async () => {
u += 1;
return u === 1 ? DECODED_U : DECODED_V;
},
}),
);
const manifest = JSON.parse((await request('/')).body);
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(res.statusCode, 200);
assert.equal(res.headers['content-type'], 'application/octet-stream');
assert.equal(res.body.length, 4 * 3 * 4 * 2);
const floats = new Float32Array(
res.body.buffer,
res.body.byteOffset,
res.body.length / 4,
);
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
});
test('wind status omits the grid url', async () => {
const request = install(proxy());
await request('/');
const body = JSON.parse((await request('/status')).body);
assert.equal('gridUrl' in body, false);
assert.equal(body.model, 'gfs');
});
test('wind refresh is cached within the TTL and refetches after it', async () => {
const counter = { calls: 0 };
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
);
await request('/');
const afterFirst = counter.calls;
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
await request('/');
assert.equal(
counter.calls,
afterFirst,
'a second request inside the TTL must not refetch',
);
clock += 2 * 3600_000;
await request('/');
assert.equal(
counter.calls,
afterFirst + 3,
'a request past the TTL refetches',
);
});
test('wind serves last-good with stale on upstream failure', async () => {
let failing = false;
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({
now: () => clock,
fetchImpl: async (...args) => {
if (failing) throw new Error('gfs upstream down');
return makeFetch()(...args);
},
}),
);
const good = JSON.parse((await request('/')).body);
failing = true;
clock += 2 * 3600_000;
const degraded = JSON.parse((await request('/')).body);
assert.equal(degraded.stale, true);
assert.equal(degraded.reason, 'Wind upstream unavailable');
const frame = await request(good.gridUrl.replace('/api/wind', ''));
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
});
test('wind returns a JSON 404 for an unknown grid', async () => {
const request = install(proxy());
await request('/');
const res = await request('/grid/nope.bin');
assert.equal(res.statusCode, 404);
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
});
test('wind grid ids and URLs are model-scoped', async () => {
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
ifs: async () => ({
cycle: { date: '20260914', hour: 0 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
},
}),
);
const gfs = JSON.parse((await request('/')).body);
const ifs = JSON.parse((await request('/?model=ifs')).body);
assert.match(gfs.gridUrl, /gfs-20260914-6-f0-90\.bin\?model=gfs$/);
assert.match(ifs.gridUrl, /ifs-20260914-0-f0-90\.bin\?model=ifs$/);
// Each model's grid resolves through its own model parameter.
assert.equal(
(await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
assert.equal(
(await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind rejects a non-finite grid instead of caching it', async () => {
const bad = new Float32Array(12);
bad[3] = Number.POSITIVE_INFINITY;
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: bad, v: new Float32Array(12) },
}),
},
}),
);
const body = JSON.parse((await request('/')).body);
assert.equal(body.unavailable, true);
assert.equal(body.reason, 'Wind upstream unavailable');
});
test('wind rejects unknown models, paths and methods before acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error();
},
},
}),
);
assert.equal((await request('/?model=toString')).statusCode, 400);
assert.equal((await request('/secret')).statusCode, 404);
assert.equal((await request('/manifest', 'POST')).statusCode, 405);
assert.equal((await request('/grid/unknown.bin')).statusCode, 404);
assert.equal(calls, 0);
});
test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(step),
v: new Float32Array(12),
},
}),
},
}),
);
const first = JSON.parse((await request('/')).body);
clock += 3600_000;
step = 7;
const second = JSON.parse((await request('/')).body);
assert.notEqual(first.gridUrl, second.gridUrl);
assert.equal(
(await request(first.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error('private upstream detail');
},
},
}),
);
const results = await Promise.all([request('/'), request('/')]);
assert.equal(calls, 1);
assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable');
await request('/');
assert.equal(calls, 1);
});
test('shared upstream work survives one disconnect and aborts when the final client leaves', async () => {
const { EventEmitter } = await import('node:events');
let handler;
let signal;
let loads = 0;
const plugin = proxy({
models: {
gfs: ({ signal: next }) => {
loads++;
signal = next;
return new Promise((resolve, reject) =>
next.addEventListener('abort', () => reject(next.reason), {
once: true,
}),
);
},
},
});
plugin.configureServer({
middlewares: {
use: (_path, value) => {
handler = value;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
const a = handler({ url: '/manifest', method: 'GET' }, first);
const b = handler({ url: '/manifest', method: 'GET' }, second);
assert.equal(loads, 1);
first.emit('close');
assert.equal(signal.aborted, false);
second.emit('close');
assert.equal(signal.aborted, true);
await Promise.all([a, b]);
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
function weatherModel({ overlay = 'none', step = 6 } = {}) {
const scalar =
overlay === 'none'
? null
: {
kind: overlay,
units: overlay === 'temperature' ? '°C' : 'hPa',
level:
overlay === 'temperature' ? '2 m above ground' : 'mean sea level',
};
return {
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m above ground',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(1),
v: new Float32Array(12).fill(2),
...(scalar
? {
scalar: new Float32Array(12).fill(
overlay === 'temperature' ? 20 : 1013.25,
),
}
: {}),
},
...(scalar ? { scalar } : {}),
};
}
test('weather variants cache independently, bind binary URLs, and retain two generations per slot', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const calls = [];
const model =
(name) =>
async ({ overlay }) => {
calls.push(`${name}:${overlay}`);
return weatherModel({ overlay, step });
};
const request = install(
proxy({
now: () => clock,
models: { gfs: model('gfs'), ifs: model('ifs') },
}),
);
const slots = [];
for (const name of ['gfs', 'ifs'])
for (const overlay of ['none', 'temperature', 'pressure']) {
const query = `?model=${name}&overlay=${overlay}`;
const [a, b] = await Promise.all([
request(`/manifest${query}`),
request(`/manifest${query}`),
]);
const manifest = JSON.parse(a.body);
assert.deepEqual(JSON.parse(b.body), manifest);
assert.equal(
manifest.scalar?.kind,
overlay === 'none' ? undefined : overlay,
);
const binary = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(binary.body.length, 12 * (overlay === 'none' ? 8 : 12));
const floats = new Float32Array(
binary.body.buffer,
binary.body.byteOffset,
binary.body.length / 4,
);
assert.deepEqual(
[...floats.slice(0, 24)],
[...Array(12).fill(1), ...Array(12).fill(2)],
);
if (overlay !== 'none') {
assert.deepEqual(
[...floats.slice(24)],
Array(12).fill(overlay === 'temperature' ? 20 : 1013.25),
);
assert.equal(
(
await request(
manifest.gridUrl
.replace('/api/wind', '')
.replace(`&overlay=${overlay}`, ''),
)
).statusCode,
404,
);
}
slots.push({ query, first: manifest.gridUrl });
}
assert.equal(
calls.length,
6,
'one shared acquisition for each bounded variant',
);
for (const slot of slots) await request(`/manifest${slot.query}`);
assert.equal(calls.length, 6);
clock += 3600_000;
step = 7;
for (const slot of slots)
slot.second = JSON.parse(
(await request(`/manifest${slot.query}`)).body,
).gridUrl;
for (const slot of slots)
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
200,
);
clock += 3600_000;
step = 8;
for (const slot of slots) {
await request(`/manifest${slot.query}`);
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
404,
);
assert.equal(
(await request(slot.second.replace('/api/wind', ''))).statusCode,
200,
);
}
});
test('overlay failures retain only their own last-good scalar and unknown overlays never acquire', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let failing = false;
let calls = 0;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
calls++;
if (failing && overlay !== 'none') throw new Error('private detail');
return weatherModel({ overlay });
},
},
}),
);
const first = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
await request('/manifest');
failing = true;
clock += 3600_000;
const stale = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(stale.stale, true);
assert.equal(stale.gridUrl, first.gridUrl);
assert.equal(stale.scalar.kind, 'temperature');
assert.equal(
JSON.parse((await request('/manifest?overlay=pressure')).body).unavailable,
true,
);
assert.equal(JSON.parse((await request('/manifest')).body).stale, false);
const before = calls;
for (const overlay of ['toString', 'humidity', 'temperature,pressure']) {
assert.equal(
(await request(`/manifest?overlay=${overlay}`)).statusCode,
400,
);
}
assert.equal(calls, before);
});
test('weather provider rejects malformed scalar metadata, lengths and non-finite values', async () => {
const mutations = [
(value) => {
value.scalar.units = 'K';
},
(value) => {
value.grid.scalar = new Float32Array(11);
},
(value) => {
value.grid.scalar[3] = Infinity;
},
(value) => {
value.grid.v = new Float32Array(1);
},
];
for (const mutate of mutations) {
const request = install(
proxy({
models: {
gfs: async ({ overlay }) => {
const value = weatherModel({ overlay });
mutate(value);
return value;
},
},
}),
);
const manifest = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(manifest.unavailable, true);
assert.equal(manifest.gridUrl, undefined);
}
});
test('disconnecting an overlay request does not abort a different variant', async () => {
const { EventEmitter } = await import('node:events');
let handler;
const pending = new Map();
proxy({
models: {
gfs: ({ overlay, signal }) =>
new Promise((resolve, reject) => {
pending.set(overlay, { signal, resolve });
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
},
}).configureServer({
middlewares: {
use: (_path, fn) => {
handler = fn;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
second.writeHead = () => {};
second.end = (body) => {
second.body = body;
};
const a = handler(
{ url: '/manifest?overlay=temperature', method: 'GET' },
first,
);
const b = handler(
{ url: '/manifest?overlay=pressure', method: 'GET' },
second,
);
first.emit('close');
assert.equal(pending.get('temperature').signal.aborted, true);
assert.equal(pending.get('pressure').signal.aborted, false);
pending.get('pressure').resolve(weatherModel({ overlay: 'pressure' }));
await Promise.all([a, b]);
assert.equal(JSON.parse(second.body).scalar.kind, 'pressure');
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
test('optional scalar failure serves wind and recovery has a distinct immutable grid URL', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let missing = true;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
if (!missing) return weatherModel({ overlay });
return {
...weatherModel(),
scalarError: 'Temperature field unavailable',
};
},
},
}),
);
const partial = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(partial.overlay, 'temperature');
assert.equal(partial.unavailable, false);
assert.equal(partial.stale, false);
assert.equal(partial.scalar, undefined);
assert.equal(partial.scalarError, 'Temperature field unavailable');
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
clock += 3600_000;
missing = false;
const complete = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.deepEqual(
complete.cycle,
partial.cycle,
'recovery is the same cycle without mixing components',
);
assert.equal(complete.scalar.kind, 'temperature');
assert.equal(complete.scalarError, undefined);
assert.notEqual(
complete.gridUrl,
partial.gridUrl,
'8-byte and 12-byte payloads cannot reuse immutable URL',
);
assert.equal(
(await request(complete.gridUrl.replace('/api/wind', ''))).body.length,
12 * 12,
);
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
});