mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: Kartverket ground heights for Statens vegvesen cameras
The Vegvesen camera feed is 2D, so every camera shared one flat 150 m ground prior and mountain-pass cameras could sit hundreds of metres below the terrain where neither the point-height prior nor the ground snap resolves. Each camera now gets its terrain height from Kartverket's keyless point-height API (CC BY 4.0): ~840 of ~845 cameras. - 50 points per request, six in flight; points a full batch answers null for are retried once in batches of 10 (the API drops points from large batches). - Sea-floor depths (dybdekurver / Havflate) under bridges and quays count as the water surface, 0 m. - Heights are cached by position, so the 15-minute 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. - CCTV_VEGVESEN_HEIGHTS=0 turns it off; Kartverket is credited beside Statens vegvesen in the attribution popover. Follow-up to #929. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a08a53cf94
commit
c43f58dc3f
8 files changed
+479
-11
No files matched your search
@@ -2,11 +2,17 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
applyVegvesenGroundHeights,
|
||||
clearKartverketHeightCache,
|
||||
fetchKartverketHeights,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
KARTVERKET_HEIGHT_URL,
|
||||
KARTVERKET_MAX_POINTS,
|
||||
KARTVERKET_RETRY_POINTS,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
@@ -104,6 +110,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
clearKartverketHeightCache();
|
||||
t.after(clearKartverketHeightCache);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
@@ -127,7 +135,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// The catalog first; the rest is the Kartverket height lookup.
|
||||
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
|
||||
assert.ok(
|
||||
requested.slice(1).every((url) => url.startsWith(KARTVERKET_HEIGHT_URL)),
|
||||
);
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
@@ -200,3 +212,212 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Answer a Kartverket request the way the live API does: echo every point,
|
||||
* with the height `heightFor(x, y)` returns (null = outside coverage). */
|
||||
const kartverketResponse = (url, heightFor) => {
|
||||
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
|
||||
});
|
||||
};
|
||||
|
||||
/** Cameras at distinct Norwegian positions. */
|
||||
const cameraAt = (i) => ({
|
||||
id: `no-vegvesen-${i}`,
|
||||
lat: 60 + i * 0.001,
|
||||
lon: 7 + i * 0.001,
|
||||
groundElevationM: 150,
|
||||
});
|
||||
|
||||
const withHeightsEnv = (t) => {
|
||||
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
clearKartverketHeightCache();
|
||||
t.after(() => {
|
||||
clearKartverketHeightCache();
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
};
|
||||
|
||||
test('Kartverket heights come in batches of 50, matched by echoed coordinates', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
|
||||
// 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),
|
||||
);
|
||||
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,
|
||||
);
|
||||
// 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.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.get('7.010000,60.010000'), 100);
|
||||
});
|
||||
|
||||
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) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
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' },
|
||||
),
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
test('implausible heights and points outside Norway are ignored', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return kartverketResponse(url, () => 99999);
|
||||
});
|
||||
const heights = await fetchKartverketHeights([
|
||||
cameraAt(1),
|
||||
{ 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cameras get their terrain height, and a refresh reuses the cache', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
calls += 1;
|
||||
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.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);
|
||||
});
|
||||
|
||||
test('a failed or disabled lookup keeps the flat prior', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 812);
|
||||
});
|
||||
const first = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
|
||||
assert.equal(first[0].groundElevationM, 150);
|
||||
release();
|
||||
// The lookup kept running and filled the cache for the next refresh.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
|
||||
assert.equal(next[0].groundElevationM, 812);
|
||||
});
|
||||
|
||||
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return kartverketResponse(url, () => 987.6);
|
||||
}
|
||||
return Response.json({ type: 'FeatureCollection', features: [feature()] });
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => [c.id, c.groundElevationM]),
|
||||
[['no-vegvesen-3000063_1', 987.6]],
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user