diff --git a/map/package-lock.json b/map/package-lock.json index f8f2f94..75c8b54 100644 --- a/map/package-lock.json +++ b/map/package-lock.json @@ -38,6 +38,9 @@ "shadcn": "^4.23.0", "tailwindcss": "^4.3.5", "typescript": "^6.0.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@alloc/quick-lru": { @@ -3312,22 +3315,6 @@ "node": ">= 6" } }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@turf/boolean-clockwise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", @@ -3671,22 +3658,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.8.6", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", @@ -6209,9 +6180,9 @@ } }, "node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", "license": "MIT" }, "node_modules/figures": { @@ -6757,15 +6728,15 @@ } }, "node_modules/image-size": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.4.tgz", - "integrity": "sha512-GqPgxs+VkOr12aWwjSkyRzf5atzObWpFtiRuDgxCl2I/SDpZOKZFRD3iIAeAN6/usmn8SeLWRt7a8JRYK0Whbw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.1.0.tgz", + "integrity": "sha512-6Dbisa46RsgeAh3TMSFL2i8+uIFG+/LRmz6Q2oscSJJvXo1sOw3EgNO4M8jPl1P+gR1atJLDPrpclX4IHz63sA==", "license": "MIT", "bin": { "image-size": "bin/image-size.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, "node_modules/immediate": { diff --git a/map/package.json b/map/package.json index d5a62ac..f7345e8 100644 --- a/map/package.json +++ b/map/package.json @@ -44,5 +44,15 @@ }, "engines": { "node": ">=22" + }, + "overrides": { + "fflate": "^0.7.5", + "image-size": "^2.1.0", + "@typescript-eslint/typescript-estree": { + "minimatch": "^11.0.8" + }, + "@ts-morph/common": { + "minimatch": "^11.0.8" + } } } diff --git a/service/src/aggregation/coverage-store.ts b/service/src/aggregation/coverage-store.ts index f56dd0a..b6e9826 100644 --- a/service/src/aggregation/coverage-store.ts +++ b/service/src/aggregation/coverage-store.ts @@ -96,6 +96,9 @@ export class CoverageStore { } } + const stationLocated = Number.isFinite(stationLat) && Number.isFinite(stationLon) + && !(Math.abs(stationLat) < 0.01 && Math.abs(stationLon) < 0.01); + // 4. Validate target coordinates if present let distance = 0; let brng = 0; @@ -104,8 +107,12 @@ export class CoverageStore { if (event.hasTargetPosition && event.targetLat !== null && event.targetLon !== null) { const targetCheck = validateCoordinate(event.targetLat, event.targetLon); if (targetCheck.valid) { - distance = haversineDistance(stationLat, stationLon, event.targetLat, event.targetLon); - brng = bearing(stationLat, stationLon, event.targetLat, event.targetLon); + // Distance and bearing only mean something when we know where the station is. + // Measuring from (0,0) would fail the range check below and drop the event. + if (stationLocated) { + distance = haversineDistance(stationLat, stationLon, event.targetLat, event.targetLon); + brng = bearing(stationLat, stationLon, event.targetLat, event.targetLon); + } // Signal validation includes range plausibility const sigCheck = validateSignal( @@ -175,8 +182,9 @@ export class CoverageStore { } } - // 6. Update per-station directional coverage - this.updateStationCoverage(event, stationLat, stationLon, distance, brng, hasValidTarget); + // 6. Update per-station directional coverage. + // Without a station location there is no sector or range to record. + this.updateStationCoverage(event, stationLat, stationLon, distance, brng, hasValidTarget && stationLocated); } private updateStationCoverage( @@ -298,23 +306,28 @@ export class CoverageStore { if (ri === -1) continue; const window = this.windows[ri][checkWi]; const aggregated = window.getAggregated(); + + // Sum this window's cells per station on their own. Windows overlap, so + // totals from different windows must never be added together. + const windowCounts = new Map(); for (const [, cell] of aggregated) { for (const stationId of cell.stationIds) { + windowCounts.set(stationId, (windowCounts.get(stationId) ?? 0) + cell.messageCount); const existing = stationCounts.get(stationId); if (existing) { - // Use the max values (longer window should have more data) - if (checkWi === wi) { - existing.messageCount += cell.messageCount; - } existing.maxDistance = Math.max(existing.maxDistance, cell.maxDistance); } else { - stationCounts.set(stationId, { - messageCount: cell.messageCount, - maxDistance: cell.maxDistance, - }); + stationCounts.set(stationId, { messageCount: 0, maxDistance: cell.maxDistance }); } } } + + for (const [stationId, count] of windowCounts) { + const existing = stationCounts.get(stationId)!; + // The requested window is authoritative. A station seen only in shorter + // windows keeps the largest single-window count. + existing.messageCount = checkWi === wi ? count : Math.max(existing.messageCount, count); + } } // Also check station sliding windows (for persistence-loaded data) diff --git a/service/test/ingest.test.ts b/service/test/ingest.test.ts new file mode 100644 index 0000000..61c6424 --- /dev/null +++ b/service/test/ingest.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { CoverageStore } from '../src/aggregation/coverage-store.js'; +import type { CoverageEvent, StationMeta } from '../src/types/events.js'; + +function station(id: number, latitude: number, longitude: number): StationMeta { + return { + id, + uuid: `uuid-${id}`, + ident: `TEST-${id}`, + latitude, + longitude, + sourceType: 'vdl', + status: 'active', + flagged: false, + blocked: false, + trustScore: 0.5, + lastSyncedAt: new Date(), + }; +} + +function event(stationId: number, targetLat: number | null, targetLon: number | null): CoverageEvent { + return { + stationId, + stationUuid: `uuid-${stationId}`, + targetLat, + targetLon, + targetAlt: null, + transportType: 'aircraft', + sourceType: 'dumpvdl2', + source: 'dumpvdl2', + frequency: 136.975, + signalLevel: -20, + errorCount: 0, + timestamp: new Date(), + hasTargetPosition: targetLat !== null && targetLon !== null, + }; +} + +let store: CoverageStore; +afterEach(() => store.destroy()); + +describe('ingest', () => { + it('indexes a target position for a station with no known location', () => { + store = new CoverageStore(); + store.stationMap.set(1, station(1, 0, 0)); + + // Frankfurt. Measured from (0,0) this is ~5,600 km, far beyond VDL2 range. + store.ingest(event(1, 50.03, 8.57)); + + expect(store.eventsRejected).toBe(0); + expect(store.eventsProcessed).toBe(1); + expect(store.eventsWithPosition).toBe(1); + + const counts = store.getStationMessageCounts('1h').get(1); + expect(counts?.messageCount).toBe(1); + expect(counts?.maxDistance).toBe(0); + }); + + it('still rejects an implausible range when the station location is known', () => { + store = new CoverageStore(); + store.stationMap.set(2, station(2, 50.03, 8.57)); + + // New York is not reachable on VDL2 from Frankfurt. + store.ingest(event(2, 40.64, -73.78)); + + expect(store.eventsRejected).toBe(1); + expect(store.eventsProcessed).toBe(0); + }); + + it('records range for a located station and a nearby target', () => { + store = new CoverageStore(); + store.stationMap.set(3, station(3, 50.03, 8.57)); + + store.ingest(event(3, 50.9, 9.5)); + + expect(store.eventsProcessed).toBe(1); + const counts = store.getStationMessageCounts('1h').get(3); + expect(counts?.maxDistance).toBeGreaterThan(50); + }); +});