diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..10a525546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -600,6 +600,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add a keyless Air quality layer to the Weather group, with Environment and + Climate Change Canada's AQHI as the first registered provider. Readings carry + `{ provider, scale, value, band }` so another network can be added without + inventing a shared numeric meaning across indices that do not share one; the + row and readout both state that coverage is Canada only. The WEATHER card + shows the band legend and the highest readings currently in view. AQHI is + reported as an integer from 1 with an open-ended "10+", so readings round to + that published form rather than the decimal precision the API returns. + Readings older than six hours are dropped, and the layer reports `stale` + through `getStats()` so feed provenance cannot describe an aged feed as live. - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ab059cebb..4e498cb6b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -31,6 +31,7 @@ How to read this: | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | +| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | @@ -72,6 +73,14 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei ### Notes on the live sources +- **Air quality (ECCC AQHI).** Keyless. Read from the MSC GeoMet OGC API (`api.weather.gc.ca`): the `aqhi-stations` catalog plus `aqhi-observations-realtime`. **Coverage is Canada only** — ECCC is the single registered provider, and the layer row and readout card both say so. Roughly 134 stations across ECCC's five administrative zones (Atlantic, Quebec, Ontario, Prairies & North, Pacific & Yukon). AQHI publishes hourly, so readings poll every 20 minutes and the station catalog, which changes a few times a year, is refetched only every 12 hours. Observations are requested with `latest=true`; without it the collection returns the full history, roughly 2,000 rows nationally. Readings older than six hours are dropped rather than displayed as current. + + Readings are stored as `{ provider, scale, value, band }` rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added later without inventing a shared numeric meaning that does not exist. + + AQHI is reported by ECCC as an integer from 1 to 10 with an open-ended "10+" above that. The API returns the underlying decimal (1.08, 2.32); the layer rounds to the published form rather than displaying a precision the index does not claim. + + **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. + - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. diff --git a/README.md b/README.md index f6a3ae886..9f69fdbd6 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a | 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | +| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..cdaa687b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2848,6 +2848,7 @@ its criteria cannot be silently ignored. | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | diff --git a/package.json b/package.json index e48881c6d..eca8aa8b7 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "./layers/bikeshare": "./src/layers/bikeshare/index.js", "./layers/directions": "./src/layers/directions/index.js", "./layers/cctv": "./src/layers/cctv/index.js", + "./layers/air-quality": "./src/layers/airQuality/index.js", "./layers/alpr": "./src/layers/alpr/index.js", "./layers/radio": "./src/layers/radio/index.js", "./layers/submarine-cables": "./src/layers/submarineCables/index.js", @@ -228,6 +229,7 @@ "./layers/alpr/source": "./src/layers/alpr/source.js", "./layers/bikeshare/source": "./src/layers/bikeshare/source.js", "./layers/cctv/source": "./src/layers/cctv/source.js", + "./layers/air-quality/source": "./src/layers/airQuality/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/firms/source": "./src/layers/firms/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..2e27bf753 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -346,6 +346,12 @@ "src/layers/vessels/tracking.js", "src/layers/vessels/ownership.test.mjs", "src/data/earthquakes.js", + "src/app/layers/airQuality.js", + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/model.test.mjs", + "src/layers/airQuality/ownership.test.mjs", + "src/layers/airQuality/source.js", "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..67f304f3f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2027,7 +2027,11 @@ "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/app/layers/airQuality.js", + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" ], "external": [ "hls.js", @@ -2511,7 +2515,11 @@ "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/app/layers/airQuality.js", + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" ], "external": [ "hls.js", @@ -2853,7 +2861,9 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" ], "external": [] }, @@ -3072,5 +3082,22 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "air-quality": { + "exports": ["./layers/air-quality"], + "modules": [ + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" + ], + "external": ["cesium"] + }, + "air-quality-source": { + "exports": ["./layers/air-quality/source"], + "modules": [ + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" + ], + "external": [] } } diff --git a/scripts/qa-air-quality.mjs b/scripts/qa-air-quality.mjs new file mode 100644 index 000000000..68e6bd505 --- /dev/null +++ b/scripts/qa-air-quality.mjs @@ -0,0 +1,171 @@ +/** + * qa-air-quality.mjs — headless proof for the Air quality layer. + * + * Checks what unit tests cannot: that the live ECCC feed answers, that readings + * reach the app in the provider-shaped form other networks will share, and that + * the Weather readout descriptor carries a legend and an in-view list. + * + * AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API + * returns the underlying decimal, so a reading arriving as 1.08 — or a station + * with no reading surfacing as a confident 1 — is a real defect this catches. + * + * Run: node scripts/qa-air-quality.mjs --url http://localhost:4173 + */ + +import puppeteer from 'puppeteer'; + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + let exitCode = 0; + const consoleErrors = []; + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--use-gl=swiftshader', + '--enable-unsafe-swiftshader', + '--no-sandbox', + '--disable-dev-shm-usage', + ], + defaultViewport: { width: 1400, height: 900 }, + }); + + try { + const page = await browser.newPage(); + page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160))); + page.on('console', (m) => { + if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160)); + }); + + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: 90000 }, + ); + await sleep(1500); + + const stats = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('air-quality', true); + const mod = dm.layers.get('air-quality')?.module; + if (!mod) return { missing: true }; + let s = null; + for (let i = 0; i < 45; i++) { + await new Promise((r) => setTimeout(r, 1000)); + s = mod.getStats(); + if (s.count > 0 || s.error) break; + } + return s; + }); + + const loaded = !stats?.missing && !stats?.error && stats?.count > 0; + record('stations report a current index', loaded, + `count=${stats?.count} error=${stats?.error}`); + if (!loaded) exitCode = 1; + + const records = await page.evaluate( + () => + window.__godsEyeView.dataManager + .layers.get('air-quality') + ?.module.getAnalystRecords(500) || [], + ); + + // The provider-neutral contract other networks will share. + const shaped = records.every( + (r) => r.provider && r.scale && Number.isFinite(r.value) && r.band, + ); + record('every reading carries provider, scale, value and band', shaped, + records.length + ? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}` + : 'no records'); + if (!shaped) exitCode = 1; + + const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1); + record('every AQHI value is in the published integer form, floored at 1', published, + `values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`); + if (!published) exitCode = 1; + + const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort(); + record('coverage spans multiple ECCC administrative zones', zones.length >= 3, + `zones=${zones.join(',') || 'none'} stations=${records.length}`); + if (zones.length < 3) exitCode = 1; + + // The card's whole point is "highest readings in view", so put the camera + // over Canada before asserting on it — the default view is over Austin, + // where an empty in-view list is correct and proves nothing. + await page.evaluate(() => { + const gev = window.__godsEyeView; + const ellipsoid = gev.viewer.scene.globe.ellipsoid; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight in progress */ + } + gev.viewer.camera.setView({ + destination: ellipsoid.cartographicToCartesian({ + longitude: -100 * d2r, + latitude: 55 * d2r, + height: 4_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }); + await sleep(2500); + + const controls = await page.evaluate( + () => + window.__godsEyeView.dataManager + .layers.get('air-quality') + ?.module.getRowControls?.() || null, + ); + const readoutOk = + controls?.readout === true && + Array.isArray(controls?.legend?.colors) && + controls.legend.colors.length === controls.legend.labels.length && + Array.isArray(controls?.list?.items) && + controls.list.items.length > 0 && + controls.list.items.every((item) => item.id && item.label && item.value) && + /Canada only/.test(controls?.summary?.coverage || ''); + record('the Weather readout lists the worst readings in view, with a legend', readoutOk, + controls + ? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"` + : 'no descriptor'); + if (!readoutOk) exitCode = 1; + + record('no console errors', consoleErrors.length === 0, + consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean'); + if (consoleErrors.length) exitCode = 1; + } catch (e) { + console.error('\x1b[31mHarness error:\x1b[0m', e); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok === true).length; + const fail = results.filter((r) => r.ok === false).length; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7ed03d0d2..a9728f317 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -23,6 +23,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js'; import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; +import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -53,6 +54,7 @@ const SOURCE_METHODS = Object.freeze({ weather: ['getSnapshot'], cyclones: ['getSnapshot'], earthquakes: ['getSnapshot'], + 'air-quality': ['getSnapshot'], 'fire-perimeters': ['getSnapshot'], cables: ['fetch'], }); @@ -144,6 +146,7 @@ export function createApplicationCatalog({ ...(resolveAsset ? { resolveAsset } : {}), }), createApplicationEarthquakes({ source: sources.earthquakes }), + createApplicationAirQuality({ source: sources['air-quality'] }), createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 6043b1262..3a802edbe 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 29); + assert.equal(first.layers.length, 30); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js new file mode 100644 index 000000000..1f25c06d9 --- /dev/null +++ b/src/app/layers/airQuality.js @@ -0,0 +1,6 @@ +import { createAirQualityLayer } from '../../layers/airQuality/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire air-quality readings to the application overlay host. */ +export function createApplicationAirQuality(options) { + return createAirQualityLayer({ overlayHost, ...options }); +} diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index f9fc23b70..f47c454a2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -162,6 +162,13 @@ export const DATA_CREDITS = [ }, OSM_CREDIT, OPENMAPTILES_CREDIT, + { + key: 'eccc-aqhi', + html: + 'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' + + '(eccc-msc.github.io/open-data) ' + + '· ECCC Data Servers End-use Licence', + }, { key: 'overture-military-names', html: 'Military area names: Overture Maps Foundation (ODbL)', diff --git a/src/data/layerState.js b/src/data/layerState.js index eccc555f6..ea43cfd15 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -553,6 +553,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS = * owns stable URL ordering. */ export const LAYER_STATE_REGISTRY = Object.freeze([ + Object.freeze({ + id: 'air-quality', + token: '0', + disposition: 'enabled-only', + }), Object.freeze({ id: 'ais-live-vessels', token: 'a', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1b504a439..3bebbc7b3 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -199,8 +199,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, 28); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28); + assert.equal(REGISTERED_LAYER_IDS.length, 29); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -239,7 +239,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '0'); + assert.equal(nextLayerStateToken(), '3'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }), '4', @@ -247,7 +247,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'03456789'].map((digit) => [`prior-${digit}`, digit]), + [...'3456789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -298,7 +298,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations( LAYER_STATE_TOKEN_RESERVATIONS, - { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, ), true, ); @@ -307,7 +307,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 0/, + /next free token 3/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 7b0a9263c..28223762e 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -122,13 +122,20 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + // The legacy assignments are a closed compatibility set that never grows, so + // the ledger is exactly that set plus every token reserved since. assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS }, + { ...LEGACY_LAYER_STATE_TOKENS, 'air-quality': '0' }, ); assert.throws( - () => parseLayerStateTokenReservations(reservationRows.slice(1)), + () => + parseLayerStateTokenReservations( + // Dropping any legacy row must fail. This used to slice off row 0, + // which is no longer a legacy reservation. + reservationRows.filter(([id]) => id !== 'ais-live-vessels'), + ), /Missing or changed legacy/, ); assert.throws( @@ -145,9 +152,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '0']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) .future, - '0', + '3', ); assert.throws( () => @@ -165,14 +172,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '0']] }, + { rows: [...reservationRows, ['retired-layer', '3']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '3'); + assert.equal(nextLayerStateToken(published), '4'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '3', + newcomer: '4', }), true, ); @@ -182,7 +189,7 @@ test('published retired digits and competing PRs advance the merge-time allocati ...published, competing: '0', }), - /next free token 3/, + /next free token 4/, ); assert.throws( () => @@ -196,8 +203,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => { +test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -218,8 +225,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '0']], - [{ id: 'pr-b', token: '0' }], + [...reservationRows, ['pr-b', '3']], + [{ id: 'pr-b', token: '3' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -227,10 +234,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '0'], ['pr-b', '0']], + [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], [ - { id: 'pr-a', token: '0' }, - { id: 'pr-b', token: '0' }, + { id: 'pr-a', token: '3' }, + { id: 'pr-b', token: '3' }, ], ); const staleRebase = runCheck(); @@ -238,16 +245,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '3'); - const validBaselineRows = [...reservationRows, ['pr-a', '0']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]); + assert.equal(nextLayerStateToken(published), '4'); + const validBaselineRows = [...reservationRows, ['pr-a', '3']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 3/); + assert.match(helper.stdout, /pr-b: 4/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -255,20 +262,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '0'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], [ - { id: 'pr-a', token: '0' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '3' }, + { id: 'pr-b', token: '4' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /29 published, 1 new/); + assert.match(corrected.stdout, /30 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -320,7 +327,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'03456789'].map((digit) => [ + const priorDigits = [...'3456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -357,7 +364,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 67); + assert.equal(params.get('l').length, 69); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -375,7 +382,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 29); + assert.equal(Object.keys(published).length, 30); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 600358c7c..7329ad59f 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -1,4 +1,5 @@ [ + ["air-quality", "0"], ["ais-live-vessels", "a"], ["alpr-cameras", "p"], ["bhote-koshi-2026", "h"], diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js new file mode 100644 index 000000000..43a76f857 --- /dev/null +++ b/src/layers/airQuality/index.js @@ -0,0 +1,308 @@ +import * as Cesium from 'cesium'; +import { + AIR_QUALITY_BANDS, + AIR_QUALITY_OVERLAY_SOURCE_ID, + AIR_QUALITY_OVERLAY_COHORT_LIMIT, + AIR_QUALITY_OVERLAY_COLLISION_CAPACITY, + AIR_QUALITY_PROVIDERS, + createAirQualityOverlayEntry, + mapAnalystRecord, + newestObservation, + readingsAreStale, + readingsInView, + selectAirQualityOverlayCohort, +} from './model.js'; +export * from './model.js'; +export { createEcccAirQualitySource } from './source.js'; + +/** Station markers are a point reading, not an extent. */ +const STATION_MARKER_RADIUS_M = 6_000; +/** Readings listed in the readout card. */ +const READOUT_LIMIT = 5; + +/** Own one air-quality display, its refresh lifecycle, and its readout card. */ +export function createAirQualityLayer({ source, overlayHost } = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Air quality requires a snapshot source'); + if (!overlayHost) throw new TypeError('Air quality requires an overlay host'); + let _viewer = null; + let _request = null; + let _dataSource = null; + let _readings = []; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + let rowControlsListener = null; + + const notify = () => rowControlsListener?.(); + + /** The camera's lon/lat rectangle in degrees, or null when unresolvable. */ + const viewRectangle = () => { + const rectangle = _viewer?.camera?.computeViewRectangle?.(); + if (!rectangle) return null; + const toDeg = Cesium.Math.toDegrees; + return { + west: toDeg(rectangle.west), + south: toDeg(rectangle.south), + east: toDeg(rectangle.east), + north: toDeg(rectangle.north), + }; + }; + + const layer = { + id: 'air-quality', + name: 'Air quality', + icon: '◍', + source: 'ECCC MSC GeoMet', + updateInterval: 1_200_000, + + init(viewer) { + if (_viewer) throw new Error('Air quality layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('air-quality'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _readings = []; + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, true); + notify(); + }, + + disable() { + // Abort in flight: a response arriving after disable must not redraw. + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID); + overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false); + notify(); + }, + + async update() { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const readings = await source.getSnapshot({ signal: request.signal }); + // A superseded or aborted request must not touch the scene, and neither + // must one that lands after the layer was switched off. + if (request.signal.aborted || _request !== request || !_enabled) + return false; + _readings = readings; + this._render(); + _count = _readings.length; + _lastUpdate = Date.now(); + _lastError = null; + notify(); + return true; + } catch (error) { + if (request.signal.aborted || _request !== request) return false; + _lastError = error?.message || 'Air quality request failed'; + console.warn('[Data:AirQuality] Refresh failed:', _lastError); + notify(); + return false; + } finally { + if (_request === request) _request = null; + } + }, + + /** Rebuild entities and overlay labels from the current readings. */ + _render() { + if (!_dataSource) return; + _dataSource.entities.removeAll(); + const overlayEntries = []; + for (const reading of _readings) { + const position = Cesium.Cartesian3.fromDegrees( + reading.lon, + reading.lat, + ); + const color = Cesium.Color.fromCssColorString(reading.color); + _dataSource.entities.add( + new Cesium.Entity({ + id: `air-quality:${reading.id}`, + position, + ellipse: { + // Static axes, as in the earthquake layer: a per-frame axis + // re-tessellates the clamped ground primitive every frame. + semiMajorAxis: STATION_MARKER_RADIUS_M, + semiMinorAxis: STATION_MARKER_RADIUS_M, + material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)), + outline: true, + outlineColor: color.withAlpha(0.9), + outlineWidth: 2, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + properties: { + provider: reading.provider, + scale: reading.scale, + value: reading.value, + band: reading.band, + name: reading.name, + zone: reading.zone, + risk: reading.risk, + observedMs: reading.observedMs, + note: reading.note, + lat: reading.lat, + lon: reading.lon, + }, + }), + ); + overlayEntries.push( + createAirQualityOverlayEntry({ + id: reading.id, + position, + title: `${reading.name} ${reading.label}`, + accent: reading.color, + value: reading.value, + }), + ); + } + if (_enabled) { + overlayHost.setEntries( + AIR_QUALITY_OVERLAY_SOURCE_ID, + selectAirQualityOverlayCohort(overlayEntries), + { + cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY, + moving: false, + }, + ); + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + _enabled = false; + overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID); + overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false); + if (_dataSource) { + viewer?.dataSources?.remove(_dataSource, true); + _dataSource = null; + } + _viewer = null; + _readings = []; + _count = 0; + _lastUpdate = null; + _lastError = null; + rowControlsListener = null; + }, + + /** + * Readout descriptor for the Weather panel: the band legend, and the worst + * readings currently in view. + * + * `readout: true` renders the toggle and source line only; the card owns + * the rest. Coverage is stated plainly because this layer is not global. + */ + getRowControls() { + const provider = AIR_QUALITY_PROVIDERS.eccc; + const inView = readingsInView(_readings, viewRectangle(), READOUT_LIMIT); + const newest = _readings.reduce( + (max, reading) => Math.max(max, reading.observedMs || 0), + 0, + ); + return { + readout: true, + summary: { + label: provider.label, + coverage: provider.coverage, + validTime: newest ? new Date(newest).toISOString() : undefined, + detail: `${provider.network} · ${provider.scale}`, + status: _lastError + ? _lastError + : _count === 0 + ? 'No readings available' + : `${_count} stations reporting`, + units: provider.scale, + }, + legend: { + colors: AIR_QUALITY_BANDS.map((band) => band.color), + labels: AIR_QUALITY_BANDS.map((band) => band.label), + units: provider.scale, + categorical: true, + }, + list: { + ariaLabel: 'Highest air-quality readings in view', + items: inView.map((reading) => ({ + id: reading.id, + label: reading.name, + value: `${reading.scale} ${reading.label}`, + accent: reading.color, + detail: reading.risk, + })), + }, + }; + }, + + setRowControlsListener(listener) { + rowControlsListener = typeof listener === 'function' ? listener : null; + }, + + /** + * The current reading nearest a point — what "the air quality here" means. + * @param {number} lat + * @param {number} lon + * @returns {object|null} Reading with distanceKm, or null when none loaded. + */ + nearestReading(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon) || !_readings.length) + return null; + const cosLat = Math.cos((lat * Math.PI) / 180); + let best = null; + let bestDistance = Infinity; + for (const reading of _readings) { + const dLat = (reading.lat - lat) * 111.32; + const dLon = (reading.lon - lon) * 111.32 * cosLat; + const distance = Math.hypot(dLat, dLon); + if (distance < bestDistance) { + bestDistance = distance; + best = reading; + } + } + return best ? { ...best, distanceKm: bestDistance } : null; + }, + + /** + * Snapshot readings as JSON-safe records for the analyst query engine. + * @param {number} [maxCount=500] + * @returns {Array} + */ + getAnalystRecords(maxCount = 500) { + if (!_enabled || !_readings.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 500; + return _readings + .slice(0, limit) + .map((reading, index) => mapAnalystRecord(reading, index)); + }, + + getStats() { + // `lastUpdate` is when a response was received; `observedAt` is when the + // network actually measured. Freshness is judged on the latter, so a feed + // that keeps answering with hours-old observations reports STALE rather + // than being narrated as live by the HUD and voice provenance envelope. + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + observedAt: newestObservation(_readings), + stale: readingsAreStale(_readings), + }; + }, + }; + return layer; +} diff --git a/src/layers/airQuality/model.js b/src/layers/airQuality/model.js new file mode 100644 index 000000000..93f13404f --- /dev/null +++ b/src/layers/airQuality/model.js @@ -0,0 +1,374 @@ +/** + * @module layers/airQuality/model + * + * Air-quality readings, normalized across networks. Pure — no Cesium, no + * network. + * + * Air-quality indices are not interchangeable numbers. Canada's AQHI runs 1 to + * 10+ on health risk; the US AQI runs 0 to 500 on concentration breakpoints; + * Europe's EAQI is a five-step category. A reading is therefore carried as + * `{ provider, scale, value, band }` — who measured it, which index it is, the + * value on that index's own terms, and the band it falls in — so a second + * network can be added without retrofitting a shared numeric meaning that does + * not exist. + * + * Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a + * later provider registers against. + */ + +/** Band identifiers shared across providers, in ascending severity. */ +export const AIR_QUALITY_BANDS = Object.freeze([ + Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }), + Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }), + Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }), + Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }), +]); + +const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band])); + +/** Colour used when a station reports no usable reading. */ +export const AIR_QUALITY_UNKNOWN_COLOR = '#6b7785'; + +export const AIR_QUALITY_OVERLAY_SOURCE_ID = 'air-quality'; +export const AIR_QUALITY_OVERLAY_COHORT_LIMIT = 48; +export const AIR_QUALITY_OVERLAY_COLLISION_CAPACITY = 32; + +/** + * A reading this old is stale enough that displaying it would misinform. + * AQHI publishes hourly, so six hours is five missed publications. + */ +export const OBSERVATION_MAX_AGE_MS = 6 * 60 * 60 * 1000; + +/** + * How old the newest observation may be before the layer reports itself stale. + * + * This is deliberately separate from the age at which a reading is dropped. + * `lastUpdate` records when we last *received* a response, which stays current + * even when the network publishes nothing new; freshness has to be judged on + * the source's own observation time instead. AQHI publishes hourly, so a newest + * reading beyond two hours means at least one publication was missed and the + * feed should not be described as live. + */ +export const STALE_AFTER_MS = 2 * 60 * 60 * 1000; + +/** + * The newest observation time across a set of readings, or null when empty. + * @param {Array} readings + * @returns {number|null} Epoch ms. + */ +export function newestObservation(readings) { + if (!Array.isArray(readings) || readings.length === 0) return null; + let newest = null; + for (const reading of readings) { + const at = Number(reading?.observedMs); + if (Number.isFinite(at) && (newest === null || at > newest)) newest = at; + } + return newest; +} + +/** + * Is a set of readings stale, judged on source observation time? + * @param {Array} readings + * @param {number} [now=Date.now()] + * @returns {boolean} + */ +export function readingsAreStale(readings, now = Date.now()) { + const newest = newestObservation(readings); + if (newest === null) return false; // nothing to be stale about + return now - newest > STALE_AFTER_MS; +} + +/** + * Registered provider descriptors. `coverage` is stated plainly because the + * layer is not global and the row must say so. + */ +export const AIR_QUALITY_PROVIDERS = Object.freeze({ + eccc: Object.freeze({ + id: 'eccc', + scale: 'AQHI', + label: 'Air quality', + network: 'Environment and Climate Change Canada', + coverage: 'Canada only · ECCC AQHI network', + /** ECCC's published band thresholds, evaluated in order. */ + bands: Object.freeze([ + Object.freeze({ id: 'low', max: 3 }), + Object.freeze({ id: 'moderate', max: 6 }), + Object.freeze({ id: 'high', max: 10 }), + Object.freeze({ id: 'very-high', max: Infinity }), + ]), + }), +}); + +/** Band descriptor for an id, or null. */ +export function airQualityBandById(bandId) { + return BAND_BY_ID.get(String(bandId ?? '')) || null; +} + +/** Accent colour for a band id, falling back to neutral grey. */ +export function airQualityColor(bandId) { + return airQualityBandById(bandId)?.color || AIR_QUALITY_UNKNOWN_COLOR; +} + +/** + * The published AQHI value for a raw ECCC reading: an integer from 1 up. + * + * ECCC reports whole numbers with a floor of 1 — there is no "AQHI 0" and no + * "AQHI 1.08". A raw value below 1 is a legitimate low reading, not an error, + * and is published as 1. + * + * @param {number|string|null|undefined} raw - Raw `aqhi` value from the API. + * @returns {number|null} Published integer AQHI, or null when unusable. + */ +export function aqhiDisplayValue(raw) { + // Guard the empty forms BEFORE coercing: Number(null) and Number('') are both + // 0, which would otherwise floor to a confident "AQHI 1" for a station that + // reported nothing at all. + if (raw === null || raw === undefined || raw === '') return null; + if (typeof raw !== 'number' && typeof raw !== 'string') return null; + if (typeof raw === 'string' && raw.trim() === '') return null; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return null; + return Math.max(1, Math.round(value)); +} + +/** + * The band id for a value on a provider's scale. + * @param {string} providerId + * @param {number|null} value - Already in the provider's published form. + * @returns {string|null} + */ +export function bandForValue(providerId, value) { + const provider = AIR_QUALITY_PROVIDERS[String(providerId ?? '')]; + if (!provider || !Number.isFinite(value)) return null; + return provider.bands.find((band) => value <= band.max)?.id ?? null; +} + +/** + * Display text for a value on a provider's scale. + * AQHI collapses everything above ten into the open-ended "10+". + * @param {string} providerId + * @param {number|null} value + * @returns {string} + */ +export function valueLabel(providerId, value) { + if (!Number.isFinite(value)) return '--'; + if (providerId === 'eccc') return value > 10 ? '10+' : String(value); + return String(value); +} + +/** Plain-language risk phrase for a band id. */ +export function bandRiskText(bandId) { + const band = airQualityBandById(bandId); + return band ? `${band.label} health risk` : null; +} + +/** + * Validate a station FeatureCollection into an id-keyed catalog. + * + * Atomic: a structurally broken response returns null so the caller keeps its + * last good catalog. A station missing coordinates is skipped, since one + * unusable station is not evidence the catalog is broken. + * + * @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`. + * @returns {Map|null} Stations by location_id, or null. + */ +export function normalizeAqhiStations(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const stations = new Map(); + for (const feature of geojson.features) { + const properties = feature?.properties; + if ( + !properties || + typeof properties !== 'object' || + Array.isArray(properties) + ) + return null; + const coordinates = feature?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) continue; + const [lon, lat] = coordinates.map(Number); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + const id = String(properties.location_id ?? feature.id ?? '').trim(); + if (!id) continue; + stations.set(id, { + id, + name: String(properties.location_name_en || id).trim(), + // ECCC's administrative zone: atl, que, ont, pnr, pyr. + zone: String(properties['eccc_administrative-zone'] || '').trim(), + lat, + lon, + }); + } + return stations; +} + +/** + * Join latest ECCC observations onto known stations into provider-shaped rows. + * + * The catalog is the only thing that supplies a reading its coordinates, so an + * observation without a matching station cannot be placed and is dropped. + * Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old + * air-quality number presented as current is worse than no number. + * + * @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`. + * @param {Map} stations - Known stations by location_id. + * @param {number} [now=Date.now()] - Clock seam for tests. + * @returns {Array|null} Normalized readings, or null when malformed. + */ +export function normalizeAqhiObservations(geojson, stations, now = Date.now()) { + if (!Array.isArray(geojson?.features)) return null; + if (!(stations instanceof Map)) return null; + const provider = AIR_QUALITY_PROVIDERS.eccc; + const newest = new Map(); + for (const feature of geojson.features) { + const properties = feature?.properties; + if ( + !properties || + typeof properties !== 'object' || + Array.isArray(properties) + ) + return null; + const stationId = String(properties.location_id ?? '').trim(); + const station = stations.get(stationId); + if (!station) continue; + + const value = aqhiDisplayValue(properties.aqhi); + if (value === null) continue; + const observedMs = Date.parse(properties.observation_datetime); + if (!Number.isFinite(observedMs)) continue; + if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue; + + const previous = newest.get(stationId); + if (previous && previous.observedMs >= observedMs) continue; + const band = bandForValue(provider.id, value); + newest.set(stationId, { + id: `${provider.id}:${stationId}`, + provider: provider.id, + scale: provider.scale, + value, + band, + stationId, + name: station.name, + zone: station.zone, + lat: station.lat, + lon: station.lon, + observedMs, + color: airQualityColor(band), + label: valueLabel(provider.id, value), + risk: bandRiskText(band), + note: String(properties.special_notes_en || '').trim() || null, + }); + } + return [...newest.values()].sort( + (a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId), + ); +} + +/** + * Build the source-owned presentation for one station label. + * @param {object} input + * @returns {object} + */ +export function createAirQualityOverlayEntry({ + id, + position, + title, + accent, + value, +}) { + return { + id: String(id), + position, + variant: 'label', + title, + accent, + // Worse air wins the label budget — the reading people need to see first. + priority: Math.round(Number(value) || 0) * 1000, + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + gapPx: 14, + verticalOnly: true, + placement: 'above', + }; +} + +/** Keep the worst readings, with stable identity as the tie-break. */ +export function selectAirQualityOverlayCohort( + entries, + limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT, +) { + const cap = Math.max( + 0, + Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)), + ); + if (!Array.isArray(entries) || cap === 0) return []; + return entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || String(a.id).localeCompare(String(b.id)), + ) + .slice(0, cap); +} + +/** + * The worst readings inside a lon/lat rectangle, for the readout card. + * + * A null rectangle means the camera could not resolve one (a fully oblique or + * space-level view); everything is then in scope rather than nothing, so the + * card degrades to the worst readings overall instead of going blank. + * + * @param {Array} readings + * @param {?{west:number, south:number, east:number, north:number}} rect - Degrees. + * @param {number} [limit=5] + * @returns {Array} + */ +export function readingsInView(readings, rect, limit = 5) { + if (!Array.isArray(readings)) return []; + const cap = Math.max(0, Math.floor(Number(limit) || 0)); + if (cap === 0) return []; + const inside = !rect + ? readings + : readings.filter((reading) => { + if (reading.lat < rect.south || reading.lat > rect.north) return false; + // A rectangle crossing the antimeridian has west > east. + return rect.west <= rect.east + ? reading.lon >= rect.west && reading.lon <= rect.east + : reading.lon >= rect.west || reading.lon <= rect.east; + }); + return inside + .slice() + .sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId)) + .slice(0, cap); +} + +/** + * Map one reading to a JSON-safe analyst record. Pure — no Cesium types. + * @param {object|null|undefined} raw + * @param {number} [index=0] + * @returns {object} + */ +export function mapAnalystRecord(raw, index = 0) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + return { + id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`, + provider: text(raw?.provider), + scale: text(raw?.scale), + value: num(raw?.value), + band: text(raw?.band), + name: text(raw?.name), + zone: text(raw?.zone), + risk: text(raw?.risk), + lat: num(raw?.lat), + lon: num(raw?.lon), + observedMs: num(raw?.observedMs), + }; +} diff --git a/src/layers/airQuality/model.test.mjs b/src/layers/airQuality/model.test.mjs new file mode 100644 index 000000000..56130c30e --- /dev/null +++ b/src/layers/airQuality/model.test.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + AIR_QUALITY_BANDS, + AIR_QUALITY_PROVIDERS, + AIR_QUALITY_UNKNOWN_COLOR, + OBSERVATION_MAX_AGE_MS, + airQualityColor, + aqhiDisplayValue, + bandForValue, + bandRiskText, + mapAnalystRecord, + normalizeAqhiObservations, + normalizeAqhiStations, + readingsInView, + selectAirQualityOverlayCohort, + valueLabel, +} from './model.js'; + +const NOW = Date.parse('2026-09-24T18:30:00Z'); +const RECENT = '2026-09-24T18:00:00Z'; +const collection = (features) => ({ type: 'FeatureCollection', features }); +const station = (id, name, zone, lon, lat) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { + location_id: id, + location_name_en: name, + 'eccc_administrative-zone': zone, + }, +}); +const observation = (id, aqhi, when = RECENT) => ({ + type: 'Feature', + properties: { location_id: id, aqhi, observation_datetime: when }, +}); + +const STATIONS = collection([ + station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458), + station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532), + station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488), +]); + +// ── provider-shaped records ──────────────────────────────────────────────── + +test('readings carry the provider-neutral {provider, scale, value, band} shape', () => { + // Indices are not interchangeable numbers: AQHI is 1-10+ on health risk, + // US AQI is 0-500 on concentration. The shape names which index a value is on + // so a second network can be added without inventing a shared meaning. + const stations = normalizeAqhiStations(STATIONS); + const [row] = normalizeAqhiObservations( + collection([observation('IAKID', 2.32)]), + stations, + NOW, + ); + assert.equal(row.provider, 'eccc'); + assert.equal(row.scale, 'AQHI'); + assert.equal(row.value, 2); + assert.equal(row.band, 'low'); +}); + +test('bands map from a value on a named provider scale', () => { + assert.equal(bandForValue('eccc', 1), 'low'); + assert.equal(bandForValue('eccc', 3), 'low'); + assert.equal(bandForValue('eccc', 4), 'moderate'); + assert.equal(bandForValue('eccc', 7), 'high'); + assert.equal(bandForValue('eccc', 11), 'very-high'); + // An unregistered provider cannot be banded rather than being guessed at. + assert.equal(bandForValue('unknown-network', 5), null); + assert.equal(bandForValue('eccc', null), null); +}); + +test('the provider states its coverage plainly', () => { + assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/); + assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI'); +}); + +test("values above ten collapse to the scale's open-ended label", () => { + assert.equal(valueLabel('eccc', 10), '10'); + assert.equal(valueLabel('eccc', 11), '10+'); + assert.equal(valueLabel('eccc', null), '--'); +}); + +test('every band has a distinct colour, and an unknown band is neutral', () => { + const colors = AIR_QUALITY_BANDS.map((b) => b.color); + assert.equal(new Set(colors).size, colors.length); + assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR); + assert.equal(bandRiskText('high'), 'High health risk'); + assert.equal(bandRiskText(null), null); +}); + +// ── published form ───────────────────────────────────────────────────────── + +test('AQHI rounds to the published integer form and floors at 1', () => { + assert.equal(aqhiDisplayValue(1.08), 1); + assert.equal(aqhiDisplayValue(3.5), 4); + assert.equal(aqhiDisplayValue(0), 1); + assert.equal(aqhiDisplayValue('3'), 3); +}); + +test('an absent reading never becomes a confident AQHI 1', () => { + // Number(null) and Number('') are both 0, which would floor to 1. + for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) { + assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`); + } +}); + +// ── snapshot validation ──────────────────────────────────────────────────── + +test('stations normalize into an id-keyed catalog carrying their zone', () => { + const stations = normalizeAqhiStations(STATIONS); + assert.equal(stations.size, 3); + assert.equal(stations.get('OAAAA').zone, 'ont'); +}); + +test('malformed payloads are rejected atomically', () => { + assert.equal(normalizeAqhiStations(null), null); + assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null); + const stations = normalizeAqhiStations(STATIONS); + assert.equal(normalizeAqhiObservations(null, stations, NOW), null); + assert.equal( + normalizeAqhiObservations(collection([]), 'not a map', NOW), + null, + ); +}); + +test('an observation with no matching station is dropped', () => { + const stations = normalizeAqhiStations(STATIONS); + const rows = normalizeAqhiObservations( + collection([observation('IAKID', 2), observation('ZZZZZ', 9)]), + stations, + NOW, + ); + assert.equal(rows.length, 1); +}); + +test('a stale reading is dropped rather than shown as current', () => { + const stations = normalizeAqhiStations(STATIONS); + const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString(); + const rows = normalizeAqhiObservations( + collection([observation('IAKID', 3, old), observation('OAAAA', 4)]), + stations, + NOW, + ); + assert.deepEqual( + rows.map((r) => r.stationId), + ['OAAAA'], + ); +}); + +test('the newest reading per station wins and worst air sorts first', () => { + const stations = normalizeAqhiStations(STATIONS); + const rows = normalizeAqhiObservations( + collection([ + observation('IAKID', 3, '2026-09-24T16:00:00Z'), + observation('IAKID', 7, '2026-09-24T18:00:00Z'), + observation('OAAAA', 5), + ]), + stations, + NOW, + ); + assert.deepEqual( + rows.map((r) => r.value), + [7, 5], + ); +}); + +// ── readout selection ────────────────────────────────────────────────────── + +const reading = (id, value, lat, lon) => ({ + id, + stationId: id, + value, + lat, + lon, + name: id, +}); + +test('the readout lists the worst readings inside the view rectangle', () => { + const rows = [ + reading('west', 9, 51.0, -114.0), + reading('east', 4, 43.6, -79.3), + reading('mild', 2, 51.1, -114.1), + ]; + const inView = readingsInView( + rows, + { west: -115, south: 50, east: -113, north: 52 }, + 5, + ); + assert.deepEqual( + inView.map((r) => r.id), + ['west', 'mild'], + ); +}); + +test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => { + // A space-level or fully oblique camera cannot produce a rectangle; the card + // should still say something useful. + const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)]; + assert.deepEqual( + readingsInView(rows, null, 5).map((r) => r.id), + ['b', 'a'], + ); + assert.deepEqual(readingsInView(rows, null, 0), []); + assert.deepEqual(readingsInView(null, null, 5), []); +}); + +test('a view rectangle crossing the antimeridian still selects correctly', () => { + // west > east is how a dateline-crossing rectangle is expressed. + const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)]; + const inView = readingsInView( + rows, + { west: 170, south: -10, east: -170, north: 10 }, + 5, + ); + assert.deepEqual( + inView.map((r) => r.id), + ['near'], + ); +}); + +test('the overlay cohort is capped and deterministic', () => { + const entries = Array.from({ length: 120 }, (_, i) => ({ + id: `s${i}`, + priority: i % 11, + })); + const cohort = selectAirQualityOverlayCohort(entries); + assert.ok(cohort.length <= 48); + assert.deepEqual( + cohort.map((e) => e.id), + selectAirQualityOverlayCohort(entries).map((e) => e.id), + ); +}); + +test('analyst records are JSON-safe and name the provider and scale', () => { + const stations = normalizeAqhiStations(STATIONS); + const [row] = normalizeAqhiObservations( + collection([observation('IAKID', 2)]), + stations, + NOW, + ); + const record = mapAnalystRecord(row, 0); + assert.deepEqual(JSON.parse(JSON.stringify(record)), record); + assert.equal(record.provider, 'eccc'); + assert.equal(record.scale, 'AQHI'); + const empty = mapAnalystRecord(undefined, 7); + assert.equal(empty.id, 'AIR-0007'); + for (const [key, value] of Object.entries(empty)) { + assert.notEqual(value, undefined, `${key} must not be undefined`); + } +}); diff --git a/src/layers/airQuality/ownership.test.mjs b/src/layers/airQuality/ownership.test.mjs new file mode 100644 index 000000000..5eeaae0e1 --- /dev/null +++ b/src/layers/airQuality/ownership.test.mjs @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAirQualityLayer } from './index.js'; +import { createEcccAirQualitySource } from './source.js'; +import { layerFeedState } from '../../data/feedState.js'; +import { STALE_AFTER_MS } from './model.js'; + +function harness(source) { + const sources = []; + const events = []; + const viewer = { + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createAirQualityLayer({ + source, + overlayHost: { + setEntries(...args) { + events.push(args); + }, + setVisible() {}, + clearSource() {}, + }, + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, events }; +} + +const reading = (id, value, overrides = {}) => ({ + id: `eccc:${id}`, + provider: 'eccc', + scale: 'AQHI', + value, + band: 'low', + stationId: id, + name: id, + zone: 'ont', + lat: 43.65, + lon: -79.38, + observedMs: 1_700_000_000_000, + color: '#4cc9f0', + label: String(value), + risk: 'Low health risk', + note: null, + ...overrides, +}); + +const staticSource = (rows) => ({ getSnapshot: async () => rows }); + +test('a layer requires both a snapshot source and an overlay host', () => { + assert.throws(() => createAirQualityLayer(), /snapshot source/); + assert.throws( + () => createAirQualityLayer({ source: staticSource([]) }), + /overlay host/, + ); +}); + +test('initialization is single-use and owns exactly one data source', () => { + const { layer, viewer, sources } = harness(staticSource([])); + assert.equal(sources.length, 1); + assert.throws(() => layer.init(viewer), /already initialized/); + layer.destroy(viewer); + assert.equal(sources.length, 0); +}); + +test('a refresh renders readings and publishes an overlay cohort', async () => { + const { layer, events } = harness( + staticSource([reading('A', 2), reading('B', 7)]), + ); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 2); + assert.equal(layer.getStats().error, null); + assert.equal(events.at(-1)[0], 'air-quality'); +}); + +// ── the abort + enabled guard ────────────────────────────────────────────── + +test('a response landing after disable cannot redraw the scene', async () => { + // The failure this prevents: disable() clears the overlay, then an in-flight + // response resolves and repopulates it behind the user's back. + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { layer, events } = harness({ + getSnapshot: async ({ signal } = {}) => { + await gate; + signal?.throwIfAborted(); + return [reading('A', 5)]; + }, + }); + const pending = layer.update(); + layer.disable(); + const publishedBefore = events.length; + release(); + assert.equal(await pending, false, 'a late response must not be applied'); + assert.equal( + events.length, + publishedBefore, + 'no overlay write after disable', + ); + assert.equal(layer.getStats().count, 0); +}); + +test('disable aborts the request in flight', async () => { + let seenSignal = null; + const { layer } = harness({ + getSnapshot: async ({ signal } = {}) => { + seenSignal = signal; + await new Promise((resolve) => setTimeout(resolve, 40)); + signal?.throwIfAborted(); + return []; + }, + }); + const pending = layer.update(); + layer.disable(); + await pending; + assert.ok(seenSignal, 'a signal was passed through to the source'); + assert.equal(seenSignal.aborted, true); +}); + +test('a superseded refresh does not clobber the newer one', async () => { + let call = 0; + const { layer } = harness({ + getSnapshot: async ({ signal } = {}) => { + call += 1; + const mine = call; + await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1)); + signal?.throwIfAborted(); + return [reading(`call-${mine}`, mine)]; + }, + }); + const first = layer.update(); + const second = layer.update(); + const [firstResult, secondResult] = await Promise.all([first, second]); + assert.equal(firstResult, false, 'the superseded call reports no update'); + assert.equal(secondResult, true); + assert.equal(layer.getAnalystRecords()[0].name, 'call-2'); +}); + +test('update is a no-op while the layer is disabled', async () => { + const { layer } = harness(staticSource([reading('A', 2)])); + layer.disable(); + assert.equal(await layer.update(), false); + assert.equal(layer.getStats().count, 0); +}); + +// ── readout descriptor ───────────────────────────────────────────────────── + +test('the readout descriptor carries the band legend and coverage', async () => { + const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)])); + await layer.update(); + const controls = layer.getRowControls(); + assert.equal(controls.readout, true); + assert.match(controls.summary.coverage, /Canada only/); + assert.equal(controls.legend.categorical, true); + assert.deepEqual(controls.legend.labels, [ + 'Low', + 'Moderate', + 'High', + 'Very High', + ]); + assert.equal(controls.legend.colors.length, controls.legend.labels.length); + // Worst first, and every item keyed. + assert.deepEqual( + controls.list.items.map((i) => i.label), + ['B', 'A'], + ); + assert.ok(controls.list.items.every((i) => i.id)); + assert.match(controls.summary.status, /2 stations reporting/); +}); + +test('the readout reports an error rather than a silent empty card', async () => { + const { layer } = harness({ + getSnapshot: async () => { + throw new Error('ECCC HTTP 503'); + }, + }); + await layer.update(); + assert.match(layer.getRowControls().summary.status, /503/); +}); + +test('row-control listeners are notified on refresh and teardown-safe', async () => { + const { layer, viewer } = harness(staticSource([reading('A', 2)])); + let calls = 0; + layer.setRowControlsListener(() => { + calls += 1; + }); + await layer.update(); + assert.ok(calls >= 1, 'a refresh notifies the card'); + layer.destroy(viewer); + const after = calls; + layer.setRowControlsListener(() => { + calls += 1; + }); + assert.equal(calls, after); +}); + +// ── source contract ──────────────────────────────────────────────────────── + +test('the source rejects a malformed station catalog and surfaces HTTP errors', async () => { + await assert.rejects( + () => + createEcccAirQualitySource({ + fetchImpl: async () => ({ + ok: true, + json: async () => ({ features: 'no' }), + }), + }).getSnapshot(), + /Malformed AQHI station/, + ); + await assert.rejects( + () => + createEcccAirQualitySource({ + fetchImpl: async () => ({ + ok: false, + status: 503, + json: async () => ({}), + }), + }).getSnapshot(), + /ECCC HTTP 503/, + ); +}); + +test('the station catalog is cached and observations ask for latest values only', async () => { + const urls = []; + const source = createEcccAirQualitySource({ + fetchImpl: async (url) => { + urls.push(url); + return { + ok: true, + json: async () => + url.includes('aqhi-stations') + ? { + features: [ + { + geometry: { type: 'Point', coordinates: [-79.38, 43.65] }, + properties: { location_id: 'A', location_name_en: 'A' }, + }, + ], + } + : { features: [] }, + }; + }, + now: () => 1_700_000_000_000, + }); + await source.getSnapshot(); + await source.getSnapshot(); + assert.equal(urls.filter((u) => u.includes('aqhi-stations')).length, 1); + // Without latest=true the collection returns the full history (~2,000 rows). + assert.match( + urls.find((u) => u.includes('observations')), + /latest=true/, + ); +}); + +// ── feed-state honesty ───────────────────────────────────────────────────── + +test('freshness is judged on observation time, not on when we last fetched', async () => { + // lastUpdate stays current every time the network answers, even when it + // answers with nothing new. Judging freshness on it would let the HUD and + // voice describe hours-old air quality as live. + const aged = Date.now() - STALE_AFTER_MS - 60_000; + const { layer } = harness( + staticSource([reading('A', 2, { observedMs: aged })]), + ); + await layer.update(); + const stats = layer.getStats(); + assert.ok( + stats.lastUpdate >= Date.now() - 5_000, + 'we did just receive a response', + ); + assert.equal(stats.observedAt, aged, 'but the observation itself is old'); + assert.equal(stats.stale, true); + assert.equal( + layerFeedState(stats), + 'stale', + 'provenance must not read this as nominal', + ); +}); + +test('a current reading reports nominal', async () => { + const { layer } = harness( + staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]), + ); + await layer.update(); + const stats = layer.getStats(); + assert.equal(stats.stale, false); + assert.equal(layerFeedState(stats), 'nominal'); +}); + +test('an empty feed is not called stale', async () => { + // Nothing to be stale about; the chip should not invent a fault. + const { layer } = harness(staticSource([])); + await layer.update(); + assert.equal(layer.getStats().stale, false); + assert.equal(layer.getStats().observedAt, null); +}); + +test('a failed refresh over good readings reports degraded, not nominal', async () => { + let fail = false; + const { layer } = harness({ + getSnapshot: async () => { + if (fail) throw new Error('ECCC HTTP 503'); + return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]; + }, + }); + await layer.update(); + assert.equal(layerFeedState(layer.getStats()), 'nominal'); + fail = true; + await layer.update(); + const stats = layer.getStats(); + assert.ok(stats.count > 0, 'the previous readings are still displayed'); + assert.equal( + layerFeedState(stats), + 'degraded', + 'showing old data after a failure is degraded', + ); +}); diff --git a/src/layers/airQuality/source.js b/src/layers/airQuality/source.js new file mode 100644 index 000000000..b5f98894c --- /dev/null +++ b/src/layers/airQuality/source.js @@ -0,0 +1,78 @@ +import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js'; + +const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections'; +const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`; +const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`; + +/** Comfortably above Canada's ~134 stations without inviting a huge page. */ +const STATION_LIMIT = 500; +const OBSERVATION_LIMIT = 500; +/** Station geometry is near-static — a few changes a year. Refetch twice a day. */ +const STATION_TTL_MS = 12 * 60 * 60 * 1000; + +/** + * Request and validate a complete ECCC AQHI snapshot before it can replace + * displayed readings. The first registered air-quality provider. + * + * Two collections are involved. `aqhi-stations` is the catalog — it supplies + * every reading its coordinates and is cached for twelve hours because it + * barely changes. `aqhi-observations-realtime` is the hourly reading feed, + * requested with `latest=true` so the response is current values rather than + * the full observation history, which is roughly 2,000 rows nationally. + * + * The station catalog is only replaced when a refresh actually returns + * stations, so a transient empty response cannot blank the layer. + * + * @param {object} [options] + * @param {Function} [options.fetchImpl] - Injected fetch, for tests. + * @param {() => number} [options.now] - Clock seam, for tests. + * @returns {{getSnapshot: Function}} + */ +export function createEcccAirQualitySource({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + /** @type {Map} */ + let stations = new Map(); + let stationsFetchedAt = 0; + + const getJson = async (url, signal) => { + signal?.throwIfAborted(); + const response = await fetchImpl(url, { + headers: { Accept: 'application/json' }, + signal, + }); + if (!response.ok) throw new Error(`ECCC HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + return payload; + }; + + return { + async getSnapshot({ signal } = {}) { + const clock = now(); + if (stations.size === 0 || clock - stationsFetchedAt > STATION_TTL_MS) { + const payload = await getJson( + `${STATIONS_URL}?f=json&limit=${STATION_LIMIT}`, + signal, + ); + const next = normalizeAqhiStations(payload); + if (!next) throw new Error('Malformed AQHI station response'); + // An empty refresh must not wipe a good catalog. + if (next.size > 0) { + stations = next; + stationsFetchedAt = clock; + } + } + if (stations.size === 0) throw new Error('No AQHI stations available'); + + const payload = await getJson( + `${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`, + signal, + ); + const readings = normalizeAqhiObservations(payload, stations, clock); + if (!readings) throw new Error('Malformed AQHI observation response'); + return readings; + }, + }; +} diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index bbaa4f168..e29785d48 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -11,6 +11,7 @@ import { EARTHQUAKE_OVERLAY_COHORT_LIMIT } from '../data/earthquakes.js'; import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT } from '../data/rocketLaunches.js'; import { RADIO_OVERLAY_COHORT_LIMIT } from '../data/radio.js'; import { CABLE_REFERENCE_LABEL_WINNER_CAP } from '../data/telegeographySubmarineCables.js'; +import { AIR_QUALITY_OVERLAY_COHORT_LIMIT } from '../layers/airQuality/model.js'; import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'; /** @@ -34,6 +35,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs' * ---------------------+---------+------------+---------+----------------+-------------+------------- * generic below cap | 60 | 60 | 60 | 3182 | 53.0 | 4,100 * generic above cap | 250 | 250 | 96 | 10022 | 40.1 | 13,000 + * air quality | 48 | 48 | 32 | 3900 | 81.3 | 5,200 * local infrastructure | 320 | 320 | 192 | 39478 | 123.4 | 49,000 * Phase 3 + FIRMS | 338 | 338 | 210 | 41220 | 122.0 | 53,600 * Phase 3 + vessels | 451 | 451 | 311 | 67252 | 149.1 | 87,500 @@ -259,6 +261,23 @@ const WORKLOADS = [ maxBytesPerFrame: 19_000, saturated: true, }, + { + // The air-quality station cohort on its own. Measured standalone rather + // than stacked on the Phase-5 host (the submarine-cable precedent): these + // stations are sparse and, with the single registered provider, + // continental, so what matters is its own cohort cost. + // + // Re-measured against the current worker: median 3,900 B/frame, max 4,114, + // and 85.7 B/candidate at the ceiling — inside the shared 154 B/candidate + // bound, so no per-source exception is taken. The 5,200 frame budget + // carries 26.4% headroom over the measured maximum. + name: 'with the air-quality station cohort live', + profile: 'air-quality', + entries: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + candidates: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + maxBytesPerFrame: 5_200, + saturated: true, + }, { name: 'with the Dense detection lane active over 5,000 observations', profile: 'phase6-detection', diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index ea463f080..0314ec41d 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -26,6 +26,12 @@ import { TRACKED_OVERLAY_SOURCE_OPTIONS, } from '../data/trackedReadout.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js'; +import { + AIR_QUALITY_OVERLAY_COHORT_LIMIT, + AIR_QUALITY_OVERLAY_COLLISION_CAPACITY, + AIR_QUALITY_OVERLAY_SOURCE_ID, + createAirQualityOverlayEntry, +} from '../layers/airQuality/model.js'; import { CCTV_OVERLAY_SOURCE_ID, createCctvThumbnailOverlayEntry, @@ -1101,6 +1107,68 @@ function buildSubmarineCablesWorkload(count) { ); } +/** + * The air-quality station cohort at full size. + * + * Measured standalone (the submarine-cable precedent) rather than stacked on + * the Phase-5 host: air-quality stations are sparse and, with the single + * currently-registered provider, continental — so the question a budget has to + * answer is what its own cohort costs, not what it adds to a worldwide worst + * case it never shares a viewport with. The entry shape is the same static + * `ambient-label` the earthquake layer publishes, so the per-candidate ceiling + * is the meaningful gate. + */ +function buildAirQualityWorkload(count) { + if (count !== AIR_QUALITY_OVERLAY_COHORT_LIMIT) { + throw new Error( + `air-quality requires ${AIR_QUALITY_OVERLAY_COHORT_LIMIT} entries`, + ); + } + const workload = { + entries: [], + positions: [], + drifts: [], + registrations: [], + }; + const columns = Math.max(1, Math.ceil(Math.sqrt((count * 16) / 9))); + const rows = Math.max(1, Math.ceil(count / columns)); + const spanX = columns > 1 ? 1.68 / (columns - 1) : 0; + const spanY = rows > 1 ? 1.56 / (rows - 1) : 0; + const stations = []; + for (let index = 0; index < count; index++) { + const column = index % columns; + const row = Math.floor(index / columns); + const baseX = -0.84 + column * spanX; + const baseY = -0.78 + row * spanY; + const position = new Cesium.Cartesian3(baseX, baseY, 0); + workload.positions.push(position); + workload.drifts.push({ + baseX, + baseY, + phase: index * 0.27, + rate: 0.32 + (index % 6) * 0.04, + }); + const entry = createAirQualityOverlayEntry({ + id: `air-${index}`, + position, + title: `Station ${index} ${1 + (index % 10)}`, + accent: '#4cc9f0', + value: 1 + (index % 10), + }); + entry.horizonCull = false; + stations.push(entry); + } + workload.registrations.push({ + sourceId: AIR_QUALITY_OVERLAY_SOURCE_ID, + entries: stations, + options: { + cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY, + }, + }); + return workload; +} + function buildPhase6DetectionWorkload(count) { const random = makeRandom(0xd37ec710); const positions = []; @@ -1206,9 +1274,13 @@ function main() { ? buildPhase5RocketMissionWorkload(ENTRY_COUNT) : PROFILE === 'all-live-radio' ? buildAllLiveRadioWorkload(ENTRY_COUNT) - : PROFILE === 'submarine-cables' - ? buildSubmarineCablesWorkload(ENTRY_COUNT) - : buildWorkload(ENTRY_COUNT); + : PROFILE === 'air-quality' + ? buildAirQualityWorkload(ENTRY_COUNT) + : PROFILE === 'submarine-cables' + ? buildSubmarineCablesWorkload( + ENTRY_COUNT, + ) + : buildWorkload(ENTRY_COUNT); const { entries, positions, drifts } = workload; const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125; const detectionActive = !!workload.detectionLayer; diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..34c776164 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,4 +1,5 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; +import { createEcccAirQualitySource } from '../layers/airQuality/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; @@ -6,6 +7,7 @@ import { createBundledCableSource } from '../layers/submarineCables/bundledSourc export function createReferenceSources() { return { earthquakes: createUsgsEarthquakeSource(), + 'air-quality': createEcccAirQualitySource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), }; diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..285ee6d66 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -14,13 +14,16 @@ test('reference factories retain compatibility without starting acquisition or s const second = createReferenceSources(); assert.deepEqual(Object.keys(first), [ 'earthquakes', + 'air-quality', 'fire-perimeters', 'cables', ]); assert.notEqual(first.earthquakes, second.earthquakes); + assert.notEqual(first['air-quality'], second['air-quality']); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); + assert.equal(typeof first['air-quality'].getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); assert.equal(requests, 0); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 57fd595f0..81ced6cf8 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -51,6 +51,7 @@ const PANEL_GROUPS = [ { label: 'Weather', ids: [ + 'air-quality', 'wind', 'weather-radar', 'weather-satellite',