Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 10 additions & 39 deletions map/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
37 changes: 25 additions & 12 deletions service/src/aggregation/coverage-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<number, number>();
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)
Expand Down
80 changes: 80 additions & 0 deletions service/test/ingest.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading