@aeroswarm/live-server 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,64 @@ wss.on("connection", (ws, req) => {
75
75
  });
76
76
  ```
77
77
 
78
+ ## Several sensors, one aircraft (fusion)
79
+
80
+ Radar, MLAT and ADS-B often see the same aircraft. Each feed makes its own
81
+ track id, and `TrackState` replaces a track whenever another id arrives for the
82
+ same `icao24`, so two feeds knock each other out on every update. Put the fuser
83
+ between the feeds and `state.ingest`:
84
+
85
+ ```ts
86
+ import { createTrackFuser } from "@aeroswarm/live-server";
87
+
88
+ const fuser = createTrackFuser(); // or createTrackFuser({ spatial: true, ... })
89
+
90
+ trackSource.start((ev) => {
91
+ if (ev.kind !== "track") return;
92
+ const fused = fuser.ingest(ev.event as FusedTrackEvent); // null = nothing new to show
93
+ if (fused) { persist(fused); state.ingest(fused); }
94
+ });
95
+ state.on("removed", ({ trackId }) => fuser.forget(trackId));
96
+ setInterval(() => fuser.sweep(), 30_000); // forget sensors that went silent
97
+ ```
98
+
99
+ What you get: one canonical `trackId` per aircraft, `sources` listing every
100
+ contributing sensor (`lifecycle: "merged"`), and the position of the preferred
101
+ sensor. A sensor that lets go of the track (`lifecycle: "dropped"`) does not
102
+ remove the aircraft while another sensor still sees it. With one sensor the
103
+ output is the same as without the fuser, with a stable track id.
104
+
105
+ **Matching.** By Mode-S address (`icao24`). Targets without one (surface
106
+ vehicles, SSR-only radar) get a synthetic id from sensor + track number
107
+ (`r50-176-42`), which means nothing to another sensor; they are only joined when
108
+ `spatial` is on, by distance and time. A candidate that is ambiguous (more than
109
+ one), or that carries a different Mode-S address, is never merged.
110
+
111
+ **Which sensor's position is shown.** The first match in the priority list for
112
+ the current state, where "on the ground" means some fresh report says
113
+ `onGround`. Entries are a protocol (`asterix-cat010`) or a feed's source id.
114
+ The default prefers MLAT in both lists, then ADS-B, the system track and radar.
115
+ A sensor silent for `maxSourceAgeMs` stops competing.
116
+
117
+ | Option | Default | |
118
+ |---|---|---|
119
+ | `surfacePriority` | MLAT (CAT010/011/020), CAT021, CAT062, CAT048 | best first |
120
+ | `airbornePriority` | MLAT (CAT020/010/011), CAT021, CAT062, CAT048 | best first |
121
+ | `maxSourceAgeMs` | `5000` | silent sensor stops competing |
122
+ | `forgetAfterMs` | `600000` | sensor forgotten after this long silent |
123
+ | `spatial` | `false` | also match tracks that share no Mode-S address |
124
+ | `matchRadiusM` | `150` | spatial matching: distance |
125
+ | `matchMaxSkewMs` | `3000` | spatial matching: time difference |
126
+ | `disagreeM` | `300` | sensors further apart are counted as disagreeing |
127
+ | `now` | `Date.now` | clock, for tests |
128
+
129
+ `fuser.stats()` returns `{ aircraft, merged, spatialMatches, ambiguous,
130
+ disagreements }`. `confidence.sourceAgreement` on a merged track falls from 1 to
131
+ 0 as its sensors drift apart.
132
+
133
+ Fusion applies to what the host feeds it: events that arrive some other way
134
+ (HTTP, Kafka) are not fused unless the host passes them through `ingest` too.
135
+
78
136
  ## Protocol
79
137
 
80
138
  JSON envelopes `{ type, seq, serverTime, payload }`, `seq` strictly increasing across the server.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CurrentTrack, FusedTrackEvent, FlightPlan, FlightPlanEvent } from '@aeroswarm/core/types';
2
+ import { FusedTrackEvent as FusedTrackEvent$1 } from '@aeroswarm/core';
2
3
 
3
4
  /** Minimal socket surface. Satisfied by Bun's `ServerWebSocket` and the `ws` library. */
4
5
  interface LiveSocket {
@@ -189,6 +190,70 @@ declare class MovementDetector {
189
190
  }
190
191
  declare function createMovementDetector(): MovementDetector;
191
192
 
193
+ /**
194
+ * Several sensors (radar, MLAT, ...) often see the same aircraft. Each feed
195
+ * produces its own track id, so without this layer they replace one another
196
+ * on every update. The fuser keeps ONE canonical track per aircraft and picks
197
+ * which sensor's position to show.
198
+ *
199
+ * feed events ──► fuser.ingest ──► one event per aircraft ──► storage, live picture
200
+ *
201
+ * Matching: by Mode-S address when both have one. Tracks without it (surface
202
+ * vehicles, SSR-only radar: ids like "r50-176-42") only match across sensors
203
+ * when `spatial` is on, by position and time.
204
+ */
205
+ interface FusionOptions {
206
+ /** Preferred sources while the aircraft is on the ground, best first. Each entry is a protocol ("asterix-cat010") or a source id. */
207
+ surfacePriority: string[];
208
+ /** Same, for an aircraft in the air. */
209
+ airbornePriority: string[];
210
+ /** A sensor silent for longer than this stops competing for the position. */
211
+ maxSourceAgeMs: number;
212
+ /** A sensor's contribution is forgotten after this long without an update. */
213
+ forgetAfterMs: number;
214
+ /** Also match tracks that share no Mode-S address, by distance and time. */
215
+ spatial: boolean;
216
+ matchRadiusM: number;
217
+ matchMaxSkewMs: number;
218
+ /** Two sensors further apart than this are reported as disagreeing. */
219
+ disagreeM: number;
220
+ now?: () => number;
221
+ }
222
+ /** MLAT first (CAT010 surface, CAT020 wide-area), then ADS-B, the system track, radar. */
223
+ declare const DEFAULT_SURFACE_PRIORITY: readonly string[];
224
+ /** MLAT is preferred in the air too. Put "asterix-cat062"/"asterix-cat048" first to prefer radar. */
225
+ declare const DEFAULT_AIRBORNE_PRIORITY: readonly string[];
226
+ /** Every option, with the defaults a host gets when it passes none. */
227
+ declare function defaultFusionOptions(): FusionOptions;
228
+ interface FusionStats {
229
+ /** Canonical tracks held. */
230
+ aircraft: number;
231
+ /** Of those, how many are fed by more than one sensor right now. */
232
+ merged: number;
233
+ /** Tracks joined by position because they shared no id. */
234
+ spatialMatches: number;
235
+ /** New tracks with several possible partners, left unmerged. */
236
+ ambiguous: number;
237
+ /** Emitted updates where the sensors were further apart than `disagreeM`. */
238
+ disagreements: number;
239
+ }
240
+ interface TrackFuser {
241
+ /** The event to store and show, or null when it adds nothing new. */
242
+ ingest(event: FusedTrackEvent$1): FusedTrackEvent$1 | null;
243
+ /** Forget a canonical track, e.g. when the live picture removed it. */
244
+ forget(trackId: string): void;
245
+ /** Drop sensors that went silent and tracks with none left. */
246
+ sweep(): void;
247
+ stats(): FusionStats;
248
+ }
249
+ /** Ids the ingest package builds from sensor + track number when there is no Mode-S address. */
250
+ declare const isSyntheticId: (icao24: string) => boolean;
251
+ /**
252
+ * Options not given (or `undefined`) take their defaults, so a host passes only
253
+ * what it wants to change: `createTrackFuser({ spatial: true })`.
254
+ */
255
+ declare function createTrackFuser(options?: Partial<FusionOptions>): TrackFuser;
256
+
192
257
  /** Result of `authenticate`. `airportId` pins the connection to that airport
193
258
  * (a sensor token); leave it undefined for viewer tokens that may request any. */
194
259
  interface LiveAuth {
@@ -233,4 +298,4 @@ interface LiveServer {
233
298
  }
234
299
  declare function createLiveServer(opts: LiveServerOptions): LiveServer;
235
300
 
236
- export { type Bbox, type FlightPlanFilter, FlightPlanStore, type FlightPlanStoreOptions, type LiveAuth, type LiveMessage, type LiveOpenContext, type LiveServer, type LiveServerOptions, type LiveServerStats, type LiveSocket, type Logger, MovementDetector, type MovementEvent, type MovementSample, type MovementType, type RemoveReason, type RemovedListener, type TrackFilter, type TrackListener, TrackState, type TrackStateOptions, type UpdateInfo, createFlightPlanStore, createLiveServer, createMovementDetector, createTrackState };
301
+ export { type Bbox, DEFAULT_AIRBORNE_PRIORITY, DEFAULT_SURFACE_PRIORITY, type FlightPlanFilter, FlightPlanStore, type FlightPlanStoreOptions, type FusionOptions, type FusionStats, type LiveAuth, type LiveMessage, type LiveOpenContext, type LiveServer, type LiveServerOptions, type LiveServerStats, type LiveSocket, type Logger, MovementDetector, type MovementEvent, type MovementSample, type MovementType, type RemoveReason, type RemovedListener, type TrackFilter, type TrackFuser, type TrackListener, TrackState, type TrackStateOptions, type UpdateInfo, createFlightPlanStore, createLiveServer, createMovementDetector, createTrackFuser, createTrackState, defaultFusionOptions, isSyntheticId };
package/dist/index.js CHANGED
@@ -567,6 +567,245 @@ function createMovementDetector() {
567
567
  return new MovementDetector();
568
568
  }
569
569
 
570
+ // src/fusion.ts
571
+ var DEFAULT_SURFACE_PRIORITY = [
572
+ "asterix-cat010",
573
+ "asterix-cat011",
574
+ "asterix-cat020",
575
+ "asterix-cat021",
576
+ "asterix-cat062",
577
+ "asterix-cat048"
578
+ ];
579
+ var DEFAULT_AIRBORNE_PRIORITY = [
580
+ "asterix-cat020",
581
+ "asterix-cat010",
582
+ "asterix-cat011",
583
+ "asterix-cat021",
584
+ "asterix-cat062",
585
+ "asterix-cat048"
586
+ ];
587
+ function defaultFusionOptions() {
588
+ return {
589
+ surfacePriority: [...DEFAULT_SURFACE_PRIORITY],
590
+ airbornePriority: [...DEFAULT_AIRBORNE_PRIORITY],
591
+ maxSourceAgeMs: 5e3,
592
+ forgetAfterMs: 6e5,
593
+ spatial: false,
594
+ matchRadiusM: 150,
595
+ matchMaxSkewMs: 3e3,
596
+ disagreeM: 300
597
+ };
598
+ }
599
+ var SYNTHETIC_ID = /^[rtv]\d+-\d+-\d+$/;
600
+ var EARTH_M = 6371e3;
601
+ var isSyntheticId = (icao24) => SYNTHETIC_ID.test(icao24);
602
+ function distanceM(a, b) {
603
+ const rad = Math.PI / 180;
604
+ const dLat = (b.lat - a.lat) * rad;
605
+ const dLon = (b.lon - a.lon) * rad;
606
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
607
+ return 2 * EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)));
608
+ }
609
+ var sourceOf = (event) => event.sources[0];
610
+ var keyFor = (event) => {
611
+ const icao24 = event.identity.icao24;
612
+ return isSyntheticId(icao24) ? `s:${sourceOf(event)}:${icao24}` : `m:${icao24}`;
613
+ };
614
+ function rank(list, event) {
615
+ const idx = list.findIndex(
616
+ (s) => s === event.protocol || s === sourceOf(event)
617
+ );
618
+ return idx < 0 ? list.length : idx;
619
+ }
620
+ function pickPrimary(fresh, list) {
621
+ const sorted = [...fresh].sort(
622
+ (a, b) => rank(list, a.event) - rank(list, b.event) || b.at - a.at
623
+ );
624
+ return sorted[0] ?? null;
625
+ }
626
+ function mergeIdentity(primary, others) {
627
+ const out = { ...primary.event.identity };
628
+ for (const other of others) {
629
+ const id = other.event.identity;
630
+ out.callsign ??= id.callsign;
631
+ out.squawk ??= id.squawk;
632
+ out.targetType ??= id.targetType;
633
+ out.vehicleFleet ??= id.vehicleFleet;
634
+ if (isSyntheticId(out.icao24) && !isSyntheticId(id.icao24)) {
635
+ out.icao24 = id.icao24;
636
+ }
637
+ }
638
+ return out;
639
+ }
640
+ function maxSpreadM(primary, others) {
641
+ const base = primary.event.position;
642
+ if (!base) return 0;
643
+ let max = 0;
644
+ for (const other of others) {
645
+ const pos = other.event.position;
646
+ if (pos) max = Math.max(max, distanceM(base, pos));
647
+ }
648
+ return max;
649
+ }
650
+ var Fuser = class {
651
+ opts;
652
+ now;
653
+ byKey = /* @__PURE__ */ new Map();
654
+ byId = /* @__PURE__ */ new Map();
655
+ spatialMatches = 0;
656
+ ambiguous = 0;
657
+ disagreements = 0;
658
+ constructor(opts) {
659
+ this.opts = opts;
660
+ this.now = opts.now ?? (() => Date.now());
661
+ }
662
+ ingest(event) {
663
+ const now = this.now();
664
+ const src = sourceOf(event);
665
+ const key = keyFor(event);
666
+ const known = this.byKey.get(key);
667
+ if (event.lifecycle === "dropped") return this.drop(known, event, src, now);
668
+ const entry = known ?? this.matchSpatially(event, src, now) ?? this.create(event);
669
+ this.link(entry, key, event);
670
+ entry.contributions.set(src, { event, sourceId: src, at: now });
671
+ return this.output(entry, src, now, false);
672
+ }
673
+ forget(trackId) {
674
+ const entry = this.byId.get(trackId);
675
+ if (entry) this.remove(entry);
676
+ }
677
+ sweep() {
678
+ const now = this.now();
679
+ for (const entry of [...this.byId.values()]) {
680
+ for (const [src, c] of entry.contributions) {
681
+ if (now - c.at > this.opts.forgetAfterMs)
682
+ entry.contributions.delete(src);
683
+ }
684
+ if (entry.contributions.size === 0) this.remove(entry);
685
+ }
686
+ }
687
+ stats() {
688
+ const now = this.now();
689
+ let merged = 0;
690
+ for (const entry of this.byId.values()) {
691
+ if (this.fresh(entry, now).length > 1) merged++;
692
+ }
693
+ return {
694
+ aircraft: this.byId.size,
695
+ merged,
696
+ spatialMatches: this.spatialMatches,
697
+ ambiguous: this.ambiguous,
698
+ disagreements: this.disagreements
699
+ };
700
+ }
701
+ fresh(entry, now) {
702
+ return [...entry.contributions.values()].filter(
703
+ (c) => now - c.at <= this.opts.maxSourceAgeMs
704
+ );
705
+ }
706
+ create(event) {
707
+ const entry = {
708
+ id: event.trackId,
709
+ keys: /* @__PURE__ */ new Set(),
710
+ contributions: /* @__PURE__ */ new Map(),
711
+ realIcao24: null,
712
+ lastPrimary: "",
713
+ lastSources: ""
714
+ };
715
+ this.byId.set(entry.id, entry);
716
+ return entry;
717
+ }
718
+ link(entry, key, event) {
719
+ entry.keys.add(key);
720
+ this.byKey.set(key, entry);
721
+ if (!isSyntheticId(event.identity.icao24)) {
722
+ entry.realIcao24 = event.identity.icao24;
723
+ }
724
+ }
725
+ remove(entry) {
726
+ for (const key of entry.keys) this.byKey.delete(key);
727
+ this.byId.delete(entry.id);
728
+ }
729
+ drop(entry, event, src, now) {
730
+ if (!entry) return event;
731
+ entry.contributions.delete(src);
732
+ if (entry.contributions.size > 0) return this.output(entry, src, now, true);
733
+ this.remove(entry);
734
+ return { ...event, trackId: entry.id };
735
+ }
736
+ /** The partner for a track nobody else shares an id with, or null. */
737
+ matchSpatially(event, src, now) {
738
+ const pos = event.position;
739
+ if (!this.opts.spatial || !pos) return null;
740
+ const hasMode = !isSyntheticId(event.identity.icao24);
741
+ const found = [];
742
+ for (const entry of this.byId.values()) {
743
+ if (entry.contributions.has(src) || hasMode && entry.realIcao24)
744
+ continue;
745
+ if (this.isNear(entry, pos, now)) found.push(entry);
746
+ }
747
+ if (found.length > 1) this.ambiguous++;
748
+ if (found.length !== 1) return null;
749
+ this.spatialMatches++;
750
+ return found[0] ?? null;
751
+ }
752
+ isNear(entry, pos, now) {
753
+ for (const c of entry.contributions.values()) {
754
+ const other = c.event.position;
755
+ if (!other || now - c.at > this.opts.matchMaxSkewMs) continue;
756
+ if (distanceM(pos, other) <= this.opts.matchRadiusM) return true;
757
+ }
758
+ return false;
759
+ }
760
+ /**
761
+ * The event for the aircraft as it stands now. Null when `src` is not the
762
+ * sensor being shown and nothing else changed, so a lower-priority sensor
763
+ * does not make the picture jump.
764
+ */
765
+ output(entry, src, now, force) {
766
+ const fresh = this.fresh(entry, now);
767
+ const onGround = fresh.some((c) => c.event.position?.onGround === true);
768
+ const list = onGround ? this.opts.surfacePriority : this.opts.airbornePriority;
769
+ const primary = pickPrimary(fresh, list);
770
+ if (!primary) return null;
771
+ const sources = fresh.map((c) => c.sourceId).sort().join(",");
772
+ const changed = force || primary.sourceId === src || primary.sourceId !== entry.lastPrimary || sources !== entry.lastSources;
773
+ entry.lastPrimary = primary.sourceId;
774
+ entry.lastSources = sources;
775
+ return changed ? this.build(entry, primary, fresh) : null;
776
+ }
777
+ build(entry, primary, fresh) {
778
+ const others = fresh.filter((c) => c !== primary).sort((a, b) => b.at - a.at);
779
+ const { event } = primary;
780
+ const out = {
781
+ ...event,
782
+ trackId: entry.id,
783
+ sources: [primary.sourceId, ...others.map((c) => c.sourceId)],
784
+ identity: mergeIdentity(primary, others),
785
+ lifecycle: others.length > 0 ? "merged" : event.lifecycle
786
+ };
787
+ const velocity = event.velocity ?? others.find((c) => c.event.velocity)?.event.velocity;
788
+ if (velocity) out.velocity = velocity;
789
+ if (others.length > 0) this.scoreAgreement(out, primary, others);
790
+ return out;
791
+ }
792
+ scoreAgreement(out, primary, others) {
793
+ const spread = maxSpreadM(primary, others);
794
+ if (spread > this.opts.disagreeM) this.disagreements++;
795
+ out.confidence = {
796
+ ...out.confidence,
797
+ sourceAgreement: Math.max(0, 1 - spread / this.opts.disagreeM)
798
+ };
799
+ }
800
+ };
801
+ function createTrackFuser(options = {}) {
802
+ const base = defaultFusionOptions();
803
+ const given = Object.fromEntries(
804
+ Object.entries(options).filter(([, v]) => v !== void 0)
805
+ );
806
+ return new Fuser({ ...base, ...given });
807
+ }
808
+
570
809
  // ../core/dist/chunk-ZYX6U3ZK.js
571
810
  import { z } from "zod";
572
811
  var positionSchema = z.object({
@@ -919,11 +1158,16 @@ function createLiveServer(opts) {
919
1158
  };
920
1159
  }
921
1160
  export {
1161
+ DEFAULT_AIRBORNE_PRIORITY,
1162
+ DEFAULT_SURFACE_PRIORITY,
922
1163
  FlightPlanStore,
923
1164
  MovementDetector,
924
1165
  TrackState,
925
1166
  createFlightPlanStore,
926
1167
  createLiveServer,
927
1168
  createMovementDetector,
928
- createTrackState
1169
+ createTrackFuser,
1170
+ createTrackState,
1171
+ defaultFusionOptions,
1172
+ isSyntheticId
929
1173
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeroswarm/live-server",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",