fix: address review on Kartverket ground heights

- Send 20 points per request, not 50: above ~20 Kartverket answers the
  extra points from contour lines (hoydekurver) or not at all. At 20,
  nearly every camera resolves from dtm1, so the null-retry pass is gone.
  Eight requests in flight keep a cold lookup ~6 s.
- Cache misses: positions with no height are not asked again for 6 h.
- Transport failures: the first 429, 5xx or network error stops the
  lookup with no retries, and no lookup starts until the backoff ends
  (Retry-After when sent, else 10 min; bounded to 1 min-1 h).
- Budget the 12 s wait from the start of the loader, catalog fetch
  included.
- Record answers only for the points each request asked about.
- Send the CCTV User-Agent; link CC BY 4.0 in the credit.
- Docs: bridge cameras sit at the water surface, below the deck.
- Tests: batching, 8-in-flight cap, request count on failure, the shared
  deadline, Retry-After bounds, null TTL, backoff, the -20 m bound and
  each water marker on its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Trygve HemminghyttandClaude Opus 5.5 committed 2026-10-06 19:21:27 +02:00
1 parent c43f58dc3f
commit 34d53edc91
6 files changed
+407 -201

No files matched your search

+10 -5
View File
@@ -6,11 +6,16 @@
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
mountain-pass cameras no longer sit hundreds of metres below the terrain
where neither the point-height prior nor the ground snap resolves. About
840 of ~845 cameras get a height; the rest keep the old prior. Bridge and
quay cameras over water use the sea surface, not the sea-floor depth the
API returns there. A catalog load waits at most 12 s for heights, and a
lookup still running then fills a cache the next refresh applies.
`CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. Follow-up to #929.
850 of ~855 cameras get a height; the rest keep the old prior. Requests
carry 20 points, the most the API answers from its 1 m terrain model, eight
at a time (~6 s cold). Cameras over water use the water surface rather than
the sea-floor depth the API returns there; for bridge cameras that is still
below the deck. Heights are cached for good and misses for 6 h, so a
refresh only asks about new or moved cameras. After a 429, a 5xx or a
network failure the lookup stops and backs off (Retry-After, else 10 min).
The lane waits at most 12 s from its start; a slower lookup fills the cache
for the next refresh. `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to
#929.
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
list is one keyless GeoJSON request to the agency's OGC API view of its
+1 -1
View File
@@ -91,7 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera, 50 points per request with up to six in flight (about 17 requests, ~5 s, for the whole country). Points a full batch answers `null` for are retried once in batches of 10, since the API sometimes drops points from large batches; points still without data keep the flat 150 m prior. Over water the API returns the sea-floor depth (`dybdekurver` / `Havflate`), which is replaced by 0 m, the water surface. Heights are cached in memory by position, so the 15-minute catalog refresh only asks about new or moved cameras. A catalog load waits at most 12 s; a slower lookup keeps filling the cache for the next refresh. Only coordinates are sent. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit is shown beside the Statens vegvesen entry in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country (~6 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first 429, 5xx or network failure stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 10 min; bounded to 1 min–1 h). The lane waits at most 12 s, counted from the start of the loader; a slower lookup keeps filling the cache for the next refresh. Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit is shown beside the Statens vegvesen entry in the Data attribution popover.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
+24 -15
View File
@@ -268,28 +268,37 @@ export const NORWAY_ANCHORS = [
];
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
* sea level (NN2000) for up to 50 points per request, used as the Vegvesen
* cameras' ground prior because the camera feed's points are 2D. */
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
* camera feed's points are 2D. */
export const KARTVERKET_HEIGHT_URL =
'https://ws.geonorge.no/hoydedata/v1/punkt';
export const KARTVERKET_MAX_POINTS = 50;
/** Batch size for the one retry of points a full batch answered null for. */
export const KARTVERKET_RETRY_POINTS = 10;
/** Batches in flight at once. The whole country is ~17 batches of ~2 s each
* (spread-out points are slow to resolve), so six keep a cold lookup ~6 s. */
export const KARTVERKET_CONCURRENCY = 6;
/** Deadline for one complete lookup, shared by every batch. */
/** Points per request. The API takes up to 50, but above ~20 it answers the
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
export const KARTVERKET_MAX_POINTS = 20;
/** Requests in flight at once. The whole country is ~36 requests of ~1-2 s,
* so a cold lookup takes ~6 s. */
export const KARTVERKET_CONCURRENCY = 8;
/** Deadline for one complete lookup, shared by every request. */
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
/** How long a catalog load waits for heights. Kept under the per-provider
* fetch timeout so this pack is never the catalog's slowest lane; a lookup
* still running after this keeps filling the cache, and the next refresh
* applies the rest. At app start, with every pack loading at once, a cold
* lookup takes ~10 s. */
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
* timeout so this pack is never the catalog's slowest lane. A lookup still
* running after it keeps filling the cache for the next refresh. */
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
/** A point Kartverket has no height for (some ferry quays and bridges) is not
* asked about again for this long. */
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
/** After a 429, a 5xx or a network failure, no lookup starts for this long,
* or for the Retry-After the service sent, bounded to [1 min, 1 h]. */
export const KARTVERKET_FAILURE_BACKOFF_MS = 10 * 60 * 1000;
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
* camera sits above ~1,500 m. Anything outside is treated as no answer. */
* camera sits above ~1,500 m. Anything outside is treated as no height. */
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
/** Same identity the other CCTV upstream requests send. */
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
+169 -98
View File
@@ -67,10 +67,13 @@ import {
NORWAY_ANCHORS,
KARTVERKET_HEIGHT_URL,
KARTVERKET_MAX_POINTS,
KARTVERKET_RETRY_POINTS,
KARTVERKET_CONCURRENCY,
KARTVERKET_LOOKUP_TIMEOUT_MS,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_BACKOFF_MS,
KARTVERKET_USER_AGENT,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_HEIGHT_RANGE_M,
} from './constants.js';
@@ -1811,6 +1814,7 @@ export function vegvesenCameraToSource(feature) {
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadVegvesenSourcesFromOpenData() {
const startedAt = Date.now();
try {
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
const resp = await fetch(endpoint, {
@@ -1856,7 +1860,12 @@ export async function loadVegvesenSourcesFromOpenData() {
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
: DEFAULT_VEGVESEN_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
await applyVegvesenGroundHeights(prioritized);
await applyVegvesenGroundHeights(prioritized, {
waitMs: Math.max(
0,
KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt),
),
});
console.log(
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
@@ -1871,49 +1880,79 @@ export async function loadVegvesenSourcesFromOpenData() {
}
/**
* Terrain heights already resolved, keyed by rounded position. Camera
* positions rarely change, so after the first catalog load a 15-minute refresh
* asks Kartverket only for new or moved cameras. Bounded well above the ~900
* cameras the pack can load.
* Kartverket answers by rounded position: `{z, expires}`, where z is metres or
* null for "no height there". Heights never expire (camera positions barely
* change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog
* refresh therefore only asks about new or moved cameras. Bounded well above
* the ~900 cameras the pack can load.
*/
const kartverketHeightCache = new Map();
const KARTVERKET_CACHE_LIMIT = 5000;
/** The lookup in flight, so overlapping catalog loads never start a second. */
let kartverketPending = null;
/** No lookup starts before this time (epoch ms) after a transport failure. */
let kartverketBackoffUntil = 0;
/** Cache key for one position, at the ~0.1 m precision the request sends. */
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
/** Forget cached heights and any lookup in flight. Tests only. */
/** Forget cached heights, the backoff and any lookup in flight. Tests only. */
export function clearKartverketHeightCache() {
kartverketHeightCache.clear();
kartverketPending = null;
kartverketBackoffUntil = 0;
}
/**
* How long to back off after a failed request: the Retry-After the service
* sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h].
*
* @param {?string} retryAfter - The Retry-After header, if any.
* @param {number} now - Epoch ms.
* @returns {number} Milliseconds.
*/
export function kartverketBackoffMs(retryAfter, now = Date.now()) {
let ms = KARTVERKET_FAILURE_BACKOFF_MS;
const text = String(retryAfter ?? '').trim();
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
else if (text && Number.isFinite(Date.parse(text))) {
ms = Date.parse(text) - now;
}
return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms));
}
/**
* Terrain heights above sea level (NN2000) from Kartverket's keyless
* point-height API, in batches of up to 50 points. Answers are matched back by
* the coordinates the API echoes, not by position in the list. Each answer is
* written to `into` as its batch lands, so a caller that stops waiting still
* keeps what has arrived. One deadline covers every request.
* point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is
* matched back by the coordinates the API echoes, and only for points that
* request asked about, then reported through `into.set(key, z)` as its
* request lands, so a caller that stops waiting keeps what has arrived. `z` is
* metres, or null when the API has no usable height there.
*
* Two quirks of the live API are handled here:
* - A large batch sometimes answers `z: null` for a point it resolves when
* asked again (Valdresflye: null in a 50-point batch, 1,390.7 m alone), so
* every point still missing after the first pass is retried once in batches
* of KARTVERKET_RETRY_POINTS. A point still null then has no data.
* - Over water `/punkt` returns the depth of the sea floor (`dybdekurver`,
* terrain `Havflate`; −547 m under Nordhordlandsbrua). A camera on a bridge
* or quay stands over the water surface, so those answers count as 0 m.
* Over water `/punkt` returns the depth of the sea floor (`datakilde:
* dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either
* marker counts as 0 m, the water surface. For the ~14 bridge cameras that is
* below the deck, but far closer than the sea floor or a flat prior.
*
* The first transport failure (a non-200 answer, a network error or the
* shared deadline) stops the lookup: no further requests start, and the
* failure is returned so the caller can back off. Points in failed requests
* are not reported, so they are asked again after the backoff.
*
* @param {Array<{lon:number, lat:number}>} points
* @param {{into?: Map<string, number>, timeoutMs?: number}} [options]
* @returns {Promise<Map<string, number>>} kartverketKey(lon, lat) → metres.
* @param {{into?: {set: Function}, timeoutMs?: number}} [options]
* @returns {Promise<{heights: Map<string, ?number>, failure: ?{status: ?number, retryAfter: ?string}}>}
* `heights` is `into` when it is a Map, else a Map of what was reported.
*/
export async function fetchKartverketHeights(
points,
{ into = new Map(), timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS } = {},
) {
const heights = into instanceof Map ? into : new Map();
const report = (key, z) => {
if (heights !== into) heights.set(key, z);
into.set(key, z);
};
const unique = Array.from(
new Map(
(points || [])
@@ -1921,9 +1960,15 @@ export async function fetchKartverketHeights(
.map((p) => [kartverketKey(p.lon, p.lat), p]),
).values(),
);
if (!unique.length) return into;
let failure = null;
if (!unique.length) return { heights, failure };
const batches = [];
for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) {
batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS));
}
const signal = AbortSignal.timeout(timeoutMs);
const fetchBatch = async (batch) => {
const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat)));
const url = new URL(KARTVERKET_HEIGHT_URL);
url.searchParams.set(
'punkter',
@@ -1933,7 +1978,10 @@ export async function fetchKartverketHeights(
);
url.searchParams.set('koordsys', '4258');
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/json',
'User-Agent': KARTVERKET_USER_AGENT,
},
redirect: 'error',
signal,
});
@@ -1943,7 +1991,10 @@ export async function fetchKartverketHeights(
} catch {
/* no-op */
}
throw new Error(`HTTP ${resp.status}`);
const error = new Error(`HTTP ${resp.status}`);
error.status = resp.status;
error.retryAfter = resp.headers.get('retry-after');
throw error;
}
const payload = await readResponseJsonCapped(
resp,
@@ -1952,102 +2003,120 @@ export async function fetchKartverketHeights(
for (const point of Array.isArray(payload?.punkter)
? payload.punkter
: []) {
// `z: null` means no answer for that point; Number(null) would read 0.
if (point?.z === null || point?.z === undefined || point?.z === '')
continue;
const x = toFiniteNumber(point?.x);
const y = toFiniteNumber(point?.y);
let z = toFiniteNumber(point?.z);
if (![x, y, z].every(Number.isFinite)) continue;
if (point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
z = 0;
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
const key = kartverketKey(x, y);
// Only the points this request asked about: an odd answer can neither
// grow the cache nor overwrite another camera.
if (!asked.has(key)) continue;
asked.delete(key);
// `z: null` means no height there; Number(null) would read 0.
const raw = point?.z;
let z =
raw === null || raw === undefined || raw === ''
? NaN
: toFiniteNumber(raw);
if (
z < KARTVERKET_HEIGHT_RANGE_M.min ||
z > KARTVERKET_HEIGHT_RANGE_M.max
)
continue;
into.set(kartverketKey(x, y), z);
Number.isFinite(z) &&
(point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
) {
z = 0;
}
const usable =
Number.isFinite(z) &&
z >= KARTVERKET_HEIGHT_RANGE_M.min &&
z <= KARTVERKET_HEIGHT_RANGE_M.max;
report(key, usable ? z : null);
}
};
let failed = 0;
const runPass = async (list, size) => {
const batches = [];
for (let i = 0; i < list.length; i += size) {
batches.push(list.slice(i, i + size));
}
let next = 0;
const worker = async () => {
while (next < batches.length && !signal.aborted) {
const batch = batches[next++];
try {
await fetchBatch(batch);
} catch (error) {
failed += 1;
if (failed === 1) {
console.warn(
'[CCTV] Kartverket height lookup failed:',
error?.message || error,
);
}
let next = 0;
const worker = async () => {
while (next < batches.length && !failure) {
const batch = batches[next++];
try {
await fetchBatch(batch);
} catch (error) {
if (!failure) {
failure = {
status: Number.isFinite(error?.status) ? error.status : null,
retryAfter: error?.retryAfter ?? null,
};
console.warn(
'[CCTV] Kartverket height lookup failed:',
error?.message || error,
);
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
worker,
),
);
}
};
await runPass(unique, KARTVERKET_MAX_POINTS);
const missing = unique.filter((p) => !into.has(kartverketKey(p.lon, p.lat)));
if (missing.length && !signal.aborted) {
await runPass(missing, KARTVERKET_RETRY_POINTS);
}
return into;
await Promise.all(
Array.from(
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
worker,
),
);
return { heights, failure };
}
/**
* Give each Vegvesen camera its own terrain height as the ground prior, in
* place. Waits at most `waitMs` for heights it does not have cached; a lookup
* place. Waits at most `waitMs` for positions it has no answer for; a lookup
* still running then keeps filling the cache for the next catalog refresh.
* Cameras without a height keep the flat prior. Never throws: a failed lookup
* only means the old behavior. CCTV_VEGVESEN_HEIGHTS=0 turns it off.
* Cameras without a height keep the flat prior. After a transport failure no
* lookup starts until the backoff ends, so a failing Kartverket costs one
* attempt, not one per refresh. Never throws. CCTV_VEGVESEN_HEIGHTS=0 turns it
* off.
*
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
* @param {{waitMs?: number}} [options]
* @param {{waitMs?: number, now?: () => number}} [options]
* @returns {Promise<number>} How many cameras got a terrain height.
*/
export async function applyVegvesenGroundHeights(
cameras,
{ waitMs = KARTVERKET_CATALOG_WAIT_MS } = {},
{ waitMs = KARTVERKET_CATALOG_WAIT_MS, now = Date.now } = {},
) {
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
const list = Array.isArray(cameras) ? cameras : [];
const missing = list.filter(
(c) => !kartverketHeightCache.has(kartverketKey(c.lon, c.lat)),
);
if (missing.length) {
if (!kartverketPending) {
if (
kartverketHeightCache.size + missing.length >
KARTVERKET_CACHE_LIMIT
) {
kartverketHeightCache.clear();
}
kartverketPending = fetchKartverketHeights(missing, {
into: kartverketHeightCache,
})
.catch((error) => {
console.warn(
'[CCTV] Kartverket height lookup error:',
error?.message || error,
);
})
.finally(() => {
kartverketPending = null;
});
const startedAt = now();
const missing = list.filter((c) => {
const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat));
return !entry || entry.expires <= startedAt;
});
if (
missing.length &&
!kartverketPending &&
startedAt >= kartverketBackoffUntil
) {
if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) {
kartverketHeightCache.clear();
}
const sink = {
set: (key, z) =>
kartverketHeightCache.set(key, {
z,
expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity,
}),
};
kartverketPending = fetchKartverketHeights(missing, { into: sink })
.then(({ failure }) => {
if (failure) {
kartverketBackoffUntil =
now() + kartverketBackoffMs(failure.retryAfter, now());
}
})
.catch((error) => {
kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS;
console.warn(
'[CCTV] Kartverket height lookup error:',
error?.message || error,
);
})
.finally(() => {
kartverketPending = null;
});
}
if (missing.length && kartverketPending && waitMs > 0) {
let timer;
await Promise.race([
kartverketPending,
@@ -2060,7 +2129,9 @@ export async function applyVegvesenGroundHeights(
}
let applied = 0;
for (const camera of list) {
const z = kartverketHeightCache.get(kartverketKey(camera.lon, camera.lat));
const z = kartverketHeightCache.get(
kartverketKey(camera.lon, camera.lat),
)?.z;
if (!Number.isFinite(z)) continue;
camera.groundElevationM = Math.round(z * 10) / 10;
applied += 1;
+201 -81
View File
@@ -5,14 +5,18 @@ import {
applyVegvesenGroundHeights,
clearKartverketHeightCache,
fetchKartverketHeights,
kartverketBackoffMs,
loadVegvesenSourcesFromOpenData,
vegvesenCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_VEGVESEN_CCTV_URL,
KARTVERKET_HEIGHT_URL,
KARTVERKET_CONCURRENCY,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_POINTS,
KARTVERKET_RETRY_POINTS,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_USER_AGENT,
VEGVESEN_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -244,145 +248,261 @@ const withHeightsEnv = (t) => {
t.mock.method(console, 'warn', () => {});
};
test('Kartverket heights come in batches of 50, matched by echoed coordinates', async (t) => {
const sentPoints = (url) =>
JSON.parse(new URL(url).searchParams.get('punkter'));
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push(String(url));
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
// Reply in reverse order: matching must not depend on list position.
const response = kartverketResponse(url, (x) =>
x > 7.05 ? null : Math.round((x - 7) * 10000),
x > 7.04 ? null : Math.round((x - 7) * 10000),
);
const body = await response.json();
body.punkter.reverse();
return Response.json(body);
});
const points = Array.from({ length: 120 }, (_, i) => cameraAt(i));
const heights = await fetchKartverketHeights(points);
const sizes = requested.map(
(url) => JSON.parse(new URL(url).searchParams.get('punkter')).length,
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
);
// Three full batches, then one retry pass over the 69 points with no height.
assert.deepEqual(sizes.slice(0, 3), [KARTVERKET_MAX_POINTS, 50, 20]);
assert.equal(
sizes.slice(3).reduce((a, b) => a + b, 0),
69,
assert.equal(failure, null);
// One pass, no retries: 20 + 20 + 10.
assert.deepEqual(
requested.map((url) => sentPoints(url).length),
[KARTVERKET_MAX_POINTS, 20, 10],
);
assert.ok(sizes.slice(3).every((n) => n <= KARTVERKET_RETRY_POINTS));
for (const url of requested) {
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
}
// Points past x = 7.05 have no height (outside coverage) and are left out.
assert.equal(heights.size, 51);
assert.equal(heights.size, 50);
assert.equal(heights.get('7.010000,60.010000'), 100);
// Past x = 7.04 the API has no height: recorded as null, never as 0.
assert.equal(heights.get('7.049000,60.049000'), null);
});
test('points a full batch answers null for are retried once in small batches', async (t) => {
withHeightsEnv(t);
const sizes = [];
t.mock.method(globalThis, 'fetch', async (url) => {
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
sizes.push(points.length);
// A full batch drops the first two points; a small batch answers them,
// except the one point that has no data at all.
const full = points.length > KARTVERKET_RETRY_POINTS;
return kartverketResponse(url, (x) => {
if (Math.abs(x - 7.002) < 1e-9) return null;
if (full && x < 7.002) return null;
return 500;
});
});
const heights = await fetchKartverketHeights(
Array.from({ length: 60 }, (_, i) => cameraAt(i)),
);
assert.deepEqual(sizes, [50, 10, 3]);
assert.equal(heights.size, 59);
assert.equal(heights.get('7.000000,60.000000'), 500);
assert.equal(heights.has('7.002000,60.002000'), false);
});
test('a sea-floor depth counts as the water surface', async (t) => {
test('only the points a request asked about are recorded', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y], i) =>
i === 0
? { x, y, z: -547.1, datakilde: 'dybdekurver', terreng: 'Havflate' }
: { x, y, z: -0.2, datakilde: 'dtm1', terreng: 'ÅpentOmråde' },
),
punkter: [
...points.map(([x, y]) => ({ x, y, z: 10 })),
{ x: 7.5, y: 60.5, z: 999 }, // never asked
],
});
});
const heights = await fetchKartverketHeights([cameraAt(0), cameraAt(1)]);
// Under a bridge: the surface, not the sea floor. A quay just below the
// datum on land data stays as measured.
assert.equal(heights.get('7.000000,60.000000'), 0);
assert.equal(heights.get('7.001000,60.001000'), -0.2);
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
});
test('implausible heights and points outside Norway are ignored', async (t) => {
test('either water marker counts as the surface', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const [a, b, c] = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
{
x: a[0],
y: a[1],
z: -547.1,
datakilde: 'dybdekurver',
terreng: null,
},
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
{
x: c[0],
y: c[1],
z: -0.2,
datakilde: 'dtm1',
terreng: 'ÅpentOmråde',
},
],
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
]);
assert.equal(heights.get('7.000000,60.000000'), 0);
assert.equal(heights.get('7.001000,60.001000'), 0);
// A quay just below the datum on land data stays as measured.
assert.equal(heights.get('7.002000,60.002000'), -0.2);
});
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return kartverketResponse(url, () => 99999);
const zs = [-20, -20.5, 2500, 2500.5];
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
});
});
const heights = await fetchKartverketHeights([
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
cameraAt(3),
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
]);
assert.equal(heights.size, 0);
// The first pass and its one retry, each carrying only the Norway point.
assert.equal(requested.length, 2);
for (const url of requested) {
assert.equal(
JSON.parse(new URL(url).searchParams.get('punkter')).length,
1,
);
}
assert.equal(requested.length, 1);
assert.equal(sentPoints(requested[0]).length, 4);
assert.deepEqual(
[0, 1, 2, 3].map((i) =>
heights.get(
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
),
),
[-20, null, 2500, null],
);
});
test('cameras get their terrain height, and a refresh reuses the cache', async (t) => {
test('no more than eight requests are in flight', async (t) => {
withHeightsEnv(t);
let inFlight = 0;
let peak = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return kartverketResponse(url, () => 1);
});
const { heights } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(heights.size, 400);
assert.equal(peak, KARTVERKET_CONCURRENCY);
});
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('busy', {
status: 429,
headers: { 'Retry-After': '120' },
});
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
// The first wave is already in flight when the first failure lands; nothing
// after it starts, and no point is resent.
assert.equal(calls, KARTVERKET_CONCURRENCY);
assert.equal(heights.size, 0);
assert.deepEqual(failure, { status: 429, retryAfter: '120' });
});
test('the lookup deadline bounds a hanging service', async (t) => {
withHeightsEnv(t);
t.mock.method(
globalThis,
'fetch',
(url, init) =>
new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason));
}),
);
const started = Date.now();
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 30,
});
assert.ok(Date.now() - started < 2000);
assert.equal(heights.size, 0);
assert.equal(failure.status, null);
});
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
const now = Date.parse('2026-10-06T12:00:00Z');
assert.equal(kartverketBackoffMs('120', now), 120_000);
assert.equal(kartverketBackoffMs('5', now), 60_000);
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
assert.equal(
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
300_000,
);
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
});
test('heights are cached, and misses are not asked again until they expire', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(sentPoints(url).length);
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
});
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
assert.equal(await applyVegvesenGroundHeights(cameras), 2);
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
assert.deepEqual(
cameras.map((c) => c.groundElevationM),
[1043.3, 1043.3, 150],
);
// One full pass plus one retry for the point with no height.
assert.equal(calls, 2);
// Same positions on the next catalog refresh: answered from the cache.
const again = [cameraAt(0), cameraAt(1)];
assert.equal(await applyVegvesenGroundHeights(again), 2);
assert.equal(calls, 2);
// The next refresh asks nothing: heights and the miss are both cached.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
now,
}),
2,
);
assert.deepEqual(asked, [3]);
// Once the miss expires, only that point is asked again.
clock += KARTVERKET_NULL_TTL_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
assert.deepEqual(asked, [3, 1]);
});
test('a failed or disabled lookup keeps the flat prior', async (t) => {
test('after a failure no lookup starts until the backoff ends', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('offline');
});
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 1);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
// Refreshes inside the backoff cost nothing.
clock += KARTVERKET_FAILURE_BACKOFF_MS - 1;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
assert.equal(fetchMock.mock.callCount(), 1);
// After it, the failed point is asked again.
clock += 2;
fetchMock.mock.mockImplementation(async (url) =>
kartverketResponse(url, () => 321),
);
const later = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
assert.equal(later[0].groundElevationM, 321);
assert.equal(fetchMock.mock.callCount(), 2);
});
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 5),
);
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(cameras[0].groundElevationM, 150);
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
fetchMock.mock.resetCalls();
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(fetchMock.mock.callCount(), 0);
});
+2 -1
View File
@@ -246,7 +246,8 @@ export const DATA_CREDITS = [
'Road cameras (Norway): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen · camera ground heights © ' +
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> (CC BY 4.0)',
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
},
{
key: 'gbfs',