gri-multitrack 0.1.0__py3-none-any.whl
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.
- gri_multitrack/__init__.py +100 -0
- gri_multitrack/association.py +196 -0
- gri_multitrack/cardinality.py +68 -0
- gri_multitrack/ingest.py +232 -0
- gri_multitrack/lifecycle.py +262 -0
- gri_multitrack/mfa.py +436 -0
- gri_multitrack/output.py +255 -0
- gri_multitrack/scoring.py +165 -0
- gri_multitrack/track.py +303 -0
- gri_multitrack/tracker.py +186 -0
- gri_multitrack-0.1.0.dist-info/METADATA +146 -0
- gri_multitrack-0.1.0.dist-info/RECORD +14 -0
- gri_multitrack-0.1.0.dist-info/WHEEL +4 -0
- gri_multitrack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""gri-multitrack -- multi-target geolocation tracking.
|
|
2
|
+
|
|
3
|
+
gri-multitrack combines the gri per-target Kalman-IMM (``gri-kalman``, fed by
|
|
4
|
+
``gri-obs`` observables) with a multi-target policy layer: ingest/routing of the
|
|
5
|
+
feed-in data tiers, measurement-space scoring, per-scan association, track
|
|
6
|
+
lifecycle, existence (Labeled Multi-Bernoulli), and a Poisson-binomial count
|
|
7
|
+
distribution.
|
|
8
|
+
|
|
9
|
+
The governing seam is "gri scores, gri-multitrack assigns": gri-kalman owns
|
|
10
|
+
per-track estimation and the measurement-space likelihoods; gri-multitrack owns
|
|
11
|
+
everything multi-target. v1 is loose coupling with a GNN scaffold associator (a
|
|
12
|
+
local scipy Hungarian); the local-MHT ``MfaTracker`` is the v1 deferred-decision
|
|
13
|
+
associator. The layer is DIY on numpy/scipy (Stone-Soup-free); an optional
|
|
14
|
+
``crosscheck`` extra pulls Stone Soup only as a dev-time GOSPA/OSPA cross-check.
|
|
15
|
+
|
|
16
|
+
The primary tracker class is ``MultiTracker`` (formerly ``Crucible``; the
|
|
17
|
+
"Crucible" name now belongs to the companion 3D app).
|
|
18
|
+
|
|
19
|
+
Typical use::
|
|
20
|
+
|
|
21
|
+
from gri_multitrack import MultiTracker
|
|
22
|
+
tracker = MultiTracker()
|
|
23
|
+
outputs = tracker.process([(ell, 0.0), (ell2, 1.0), ...])
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from .association import (
|
|
29
|
+
AssociationResult,
|
|
30
|
+
Associator,
|
|
31
|
+
GnnAssociator,
|
|
32
|
+
Hypothesis,
|
|
33
|
+
score_matrix,
|
|
34
|
+
)
|
|
35
|
+
from .cardinality import count_distribution, expected_count, most_likely_count
|
|
36
|
+
from .ingest import (
|
|
37
|
+
Channel,
|
|
38
|
+
PresenceObs,
|
|
39
|
+
RoutedItem,
|
|
40
|
+
Scan,
|
|
41
|
+
group_into_scans,
|
|
42
|
+
ingest,
|
|
43
|
+
route,
|
|
44
|
+
)
|
|
45
|
+
from .lifecycle import LifecycleConfig, TrackManager
|
|
46
|
+
from .mfa import MfaConfig, MfaTracker
|
|
47
|
+
from .output import (
|
|
48
|
+
AssociationDiagnostic,
|
|
49
|
+
GateEntry,
|
|
50
|
+
JointHypothesis,
|
|
51
|
+
LabeledTrack,
|
|
52
|
+
MarginalEntry,
|
|
53
|
+
PredictedState,
|
|
54
|
+
TrackerOutput,
|
|
55
|
+
)
|
|
56
|
+
from .scoring import GateScore, score
|
|
57
|
+
from .track import (
|
|
58
|
+
Track,
|
|
59
|
+
default_motion_models,
|
|
60
|
+
make_imm,
|
|
61
|
+
make_smart_segmented,
|
|
62
|
+
)
|
|
63
|
+
from .tracker import MultiTracker, MultiTrackerConfig
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"AssociationDiagnostic",
|
|
67
|
+
"AssociationResult",
|
|
68
|
+
"Associator",
|
|
69
|
+
"Channel",
|
|
70
|
+
"GateEntry",
|
|
71
|
+
"GateScore",
|
|
72
|
+
"GnnAssociator",
|
|
73
|
+
"Hypothesis",
|
|
74
|
+
"JointHypothesis",
|
|
75
|
+
"LabeledTrack",
|
|
76
|
+
"LifecycleConfig",
|
|
77
|
+
"MarginalEntry",
|
|
78
|
+
"MfaConfig",
|
|
79
|
+
"MfaTracker",
|
|
80
|
+
"MultiTracker",
|
|
81
|
+
"MultiTrackerConfig",
|
|
82
|
+
"PredictedState",
|
|
83
|
+
"PresenceObs",
|
|
84
|
+
"RoutedItem",
|
|
85
|
+
"Scan",
|
|
86
|
+
"Track",
|
|
87
|
+
"TrackManager",
|
|
88
|
+
"TrackerOutput",
|
|
89
|
+
"count_distribution",
|
|
90
|
+
"default_motion_models",
|
|
91
|
+
"expected_count",
|
|
92
|
+
"group_into_scans",
|
|
93
|
+
"ingest",
|
|
94
|
+
"make_imm",
|
|
95
|
+
"make_smart_segmented",
|
|
96
|
+
"most_likely_count",
|
|
97
|
+
"route",
|
|
98
|
+
"score",
|
|
99
|
+
"score_matrix",
|
|
100
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Per-scan track<->observation association (the "Stone Soup assigns" half).
|
|
2
|
+
|
|
3
|
+
This module owns the ASSIGNMENT half of the seam. v1 ships a Global Nearest
|
|
4
|
+
Neighbor (GNN) scaffold: a one-to-one assignment that minimizes total cost
|
|
5
|
+
(negative log-likelihood) over the gated score matrix, solved with a local
|
|
6
|
+
Hungarian (``scipy.optimize.linear_sum_assignment``) so the Phase-1 skeleton
|
|
7
|
+
needs no Stone Soup install.
|
|
8
|
+
|
|
9
|
+
GNN is a BRING-UP SCAFFOLD and debug baseline only. It commits one best match
|
|
10
|
+
per scan and so will visibly mishandle the crossing / ambiguous-TDOA case (a
|
|
11
|
+
single TDOA gates to many tracks). The real v1 associator is Stone Soup's MFA
|
|
12
|
+
(multi-frame assignment), which defers ambiguous assignments across a window;
|
|
13
|
+
the :class:`Associator` protocol keeps that a component swap, not a rewrite.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING, Protocol
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from scipy.optimize import linear_sum_assignment
|
|
23
|
+
|
|
24
|
+
from .scoring import GateScore, score
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
from gri_ell import Ell
|
|
30
|
+
from gri_obs import Observation
|
|
31
|
+
|
|
32
|
+
from .track import Track
|
|
33
|
+
|
|
34
|
+
Measurement = Ell | Observation
|
|
35
|
+
|
|
36
|
+
# Sentinel cost for a pair that is not a valid candidate (outside the gate).
|
|
37
|
+
# Large enough that the assignment never prefers it over any real pairing.
|
|
38
|
+
_NO_MATCH_COST = 1e12
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Hypothesis:
|
|
43
|
+
"""One joint assignment hypothesis over a scan.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
weight: Normalized weight of this hypothesis in [0, 1] (weights over the
|
|
47
|
+
reported hypotheses sum to ~1).
|
|
48
|
+
assign: Mapping ``measurement_index -> track_index`` for the
|
|
49
|
+
measurements this hypothesis assigns (unassigned measurements /
|
|
50
|
+
tracks are simply absent).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
weight: float
|
|
54
|
+
assign: dict[int, int] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class AssociationResult:
|
|
59
|
+
"""Outcome of associating one scan's measurements to the track set.
|
|
60
|
+
|
|
61
|
+
The committed one-to-one ``assignments`` are what the tracker applies this
|
|
62
|
+
scan. ``marginals`` and ``hypotheses`` are the associator-agnostic
|
|
63
|
+
diagnostic: a GNN fills them degenerately (weight-1 hypothesis, marginal 1.0
|
|
64
|
+
to the winner); an MFA fills them richly (spread marginals, several surviving
|
|
65
|
+
window hypotheses). Indices here are translated to obs-ids / track labels at
|
|
66
|
+
the output boundary.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
assignments: ``(track_index, measurement_index)`` pairs that matched.
|
|
70
|
+
unassigned_tracks: Indices of tracks that got no measurement.
|
|
71
|
+
unassigned_measurements: Indices of measurements that matched no track
|
|
72
|
+
(candidates for birth).
|
|
73
|
+
scores: The full ``tracks x measurements`` score matrix (for inspection
|
|
74
|
+
and metrics).
|
|
75
|
+
marginals: ``(measurement_index, track_index) -> probability`` that the
|
|
76
|
+
measurement is assigned to the track.
|
|
77
|
+
hypotheses: Top-K joint assignment hypotheses with weights.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
assignments: list[tuple[int, int]] = field(default_factory=list)
|
|
81
|
+
unassigned_tracks: list[int] = field(default_factory=list)
|
|
82
|
+
unassigned_measurements: list[int] = field(default_factory=list)
|
|
83
|
+
scores: list[list[GateScore]] = field(default_factory=list)
|
|
84
|
+
marginals: dict[tuple[int, int], float] = field(default_factory=dict)
|
|
85
|
+
hypotheses: list[Hypothesis] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def score_matrix(
|
|
89
|
+
tracks: Sequence[Track],
|
|
90
|
+
measurements: Sequence[Measurement],
|
|
91
|
+
time_s: float,
|
|
92
|
+
gate_prob: float = 0.99,
|
|
93
|
+
) -> list[list[GateScore]]:
|
|
94
|
+
"""Compute the ``tracks x measurements`` gated score matrix for a scan.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
tracks: Current tracks.
|
|
98
|
+
measurements: This scan's kinematic measurements.
|
|
99
|
+
time_s: Scan time in seconds.
|
|
100
|
+
gate_prob: Chi-squared gate probability passed to the scorer.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A list of rows, one per track; each row holds a :class:`GateScore` per
|
|
104
|
+
measurement.
|
|
105
|
+
"""
|
|
106
|
+
return [
|
|
107
|
+
[score(track, meas, time_s, gate_prob) for meas in measurements]
|
|
108
|
+
for track in tracks
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Associator(Protocol):
|
|
113
|
+
"""Per-scan associator interface (GNN now, MFA later)."""
|
|
114
|
+
|
|
115
|
+
def associate(
|
|
116
|
+
self,
|
|
117
|
+
tracks: Sequence[Track],
|
|
118
|
+
measurements: Sequence[Measurement],
|
|
119
|
+
time_s: float,
|
|
120
|
+
) -> AssociationResult:
|
|
121
|
+
"""Assign this scan's measurements to tracks."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class GnnAssociator:
|
|
126
|
+
"""Global Nearest Neighbor associator over the gated score matrix.
|
|
127
|
+
|
|
128
|
+
Minimizes total negative-log-likelihood across a one-to-one matching, with
|
|
129
|
+
ungated pairs blocked by a sentinel cost so they are never chosen.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, gate_prob: float = 0.99) -> None:
|
|
133
|
+
"""Initialize the GNN associator.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
gate_prob: Chi-squared gate probability for candidate pairs.
|
|
137
|
+
"""
|
|
138
|
+
self._gate_prob = gate_prob
|
|
139
|
+
|
|
140
|
+
def associate(
|
|
141
|
+
self,
|
|
142
|
+
tracks: Sequence[Track],
|
|
143
|
+
measurements: Sequence[Measurement],
|
|
144
|
+
time_s: float,
|
|
145
|
+
) -> AssociationResult:
|
|
146
|
+
"""Assign measurements to tracks by minimum total cost.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
tracks: Current tracks.
|
|
150
|
+
measurements: This scan's kinematic measurements.
|
|
151
|
+
time_s: Scan time in seconds.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The :class:`AssociationResult` for this scan.
|
|
155
|
+
"""
|
|
156
|
+
n_t = len(tracks)
|
|
157
|
+
n_m = len(measurements)
|
|
158
|
+
if n_t == 0 or n_m == 0:
|
|
159
|
+
return AssociationResult(
|
|
160
|
+
unassigned_tracks=list(range(n_t)),
|
|
161
|
+
unassigned_measurements=list(range(n_m)),
|
|
162
|
+
scores=score_matrix(tracks, measurements, time_s, self._gate_prob),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
scores = score_matrix(tracks, measurements, time_s, self._gate_prob)
|
|
166
|
+
cost = np.full((n_t, n_m), _NO_MATCH_COST, dtype=np.float64)
|
|
167
|
+
for i, row in enumerate(scores):
|
|
168
|
+
for j, gs in enumerate(row):
|
|
169
|
+
if gs.gated and np.isfinite(gs.log_likelihood):
|
|
170
|
+
cost[i, j] = -gs.log_likelihood
|
|
171
|
+
|
|
172
|
+
rows, cols = linear_sum_assignment(cost)
|
|
173
|
+
|
|
174
|
+
assignments: list[tuple[int, int]] = []
|
|
175
|
+
assigned_t: set[int] = set()
|
|
176
|
+
assigned_m: set[int] = set()
|
|
177
|
+
for i, j in zip(rows, cols, strict=True):
|
|
178
|
+
if cost[i, j] < _NO_MATCH_COST:
|
|
179
|
+
assignments.append((int(i), int(j)))
|
|
180
|
+
assigned_t.add(int(i))
|
|
181
|
+
assigned_m.add(int(j))
|
|
182
|
+
|
|
183
|
+
# GNN commits one hypothesis: every assigned pair gets marginal 1.0 and
|
|
184
|
+
# the single joint hypothesis carries all of weight. MFA fills these
|
|
185
|
+
# with spread marginals and several window hypotheses (same shape).
|
|
186
|
+
marginals = {(j, i): 1.0 for i, j in assignments}
|
|
187
|
+
hypotheses = [Hypothesis(weight=1.0, assign={j: i for i, j in assignments})]
|
|
188
|
+
|
|
189
|
+
return AssociationResult(
|
|
190
|
+
assignments=assignments,
|
|
191
|
+
unassigned_tracks=[i for i in range(n_t) if i not in assigned_t],
|
|
192
|
+
unassigned_measurements=[j for j in range(n_m) if j not in assigned_m],
|
|
193
|
+
scores=scores,
|
|
194
|
+
marginals=marginals,
|
|
195
|
+
hypotheses=hypotheses,
|
|
196
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Count confidence as the Poisson-binomial distribution of {r_i}.
|
|
2
|
+
|
|
3
|
+
The Labeled Multi-Bernoulli output model carries a per-track existence
|
|
4
|
+
probability r_i. The confidence on the TOTAL emitter count is then the
|
|
5
|
+
Poisson-binomial distribution of those independent existences -- the
|
|
6
|
+
distribution of the number of "successes" among Bernoulli trials with
|
|
7
|
+
different probabilities. This is consistent with the per-track numbers by
|
|
8
|
+
construction, so the two confidences never disagree.
|
|
9
|
+
|
|
10
|
+
For independent tracks this is a few lines of exact dynamic programming; full
|
|
11
|
+
GLMB/CPHD machinery is not needed for v1.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def count_distribution(existences: Sequence[float]) -> np.ndarray:
|
|
25
|
+
"""Poisson-binomial distribution of independent existence probabilities.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
existences: Per-track existence probabilities r_i, each in [0, 1].
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Array ``p`` of length ``n + 1`` where ``p[k]`` is the probability that
|
|
32
|
+
exactly ``k`` of the ``n`` tracks exist. Sums to 1. For ``n == 0`` this
|
|
33
|
+
is ``[1.0]`` (zero emitters with certainty).
|
|
34
|
+
"""
|
|
35
|
+
probs = np.asarray(existences, dtype=np.float64)
|
|
36
|
+
dist = np.zeros(len(probs) + 1, dtype=np.float64)
|
|
37
|
+
dist[0] = 1.0
|
|
38
|
+
for p in probs:
|
|
39
|
+
shifted = np.zeros_like(dist)
|
|
40
|
+
shifted[1:] = dist[:-1]
|
|
41
|
+
dist = dist * (1.0 - p) + shifted * p
|
|
42
|
+
return dist
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def expected_count(existences: Sequence[float]) -> float:
|
|
46
|
+
"""Expected number of emitters, ``sum(r_i)``.
|
|
47
|
+
|
|
48
|
+
Equal to the mean of the Poisson-binomial distribution.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
existences: Per-track existence probabilities r_i.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The expected emitter count.
|
|
55
|
+
"""
|
|
56
|
+
return float(np.sum(np.asarray(existences, dtype=np.float64)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def most_likely_count(existences: Sequence[float]) -> int:
|
|
60
|
+
"""Mode of the count distribution (the single most probable count).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
existences: Per-track existence probabilities r_i.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The count ``k`` with the highest Poisson-binomial probability.
|
|
67
|
+
"""
|
|
68
|
+
return int(np.argmax(count_distribution(existences)))
|
gri_multitrack/ingest.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Ingest and routing for the MultiTracker feed-in data types.
|
|
2
|
+
|
|
3
|
+
MultiTracker accepts five feed-in types (see ``CLAUDE.md`` "Feed-in data types"):
|
|
4
|
+
|
|
5
|
+
1. geo -- a ``gri_ell.Ell`` (3D position + covariance), externally solved.
|
|
6
|
+
2. one or two TDOAs (or any ``gri_obs`` observable) -- a partial constraint.
|
|
7
|
+
3. an altitude constraint (``gri_obs.AltitudeObs``) -- an optional modifier
|
|
8
|
+
applied alongside the partials at the same epoch.
|
|
9
|
+
4. presence ("is it on") -- a MultiTracker-native existence event, no geometry.
|
|
10
|
+
|
|
11
|
+
Types 1-3 carry geometry and route to the KINEMATIC channel (a per-track
|
|
12
|
+
filter update). Type 4 routes to the EXISTENCE channel (coast + r_i bump).
|
|
13
|
+
Well-determined solving (4+ satellites, mixed receivers, ground-bounce + 2
|
|
14
|
+
TDOAs) happens OUTSIDE MultiTracker and arrives as a type-1 geo; MultiTracker does not
|
|
15
|
+
solve those.
|
|
16
|
+
|
|
17
|
+
This module normalizes a stream of timestamped, mixed-type inputs into
|
|
18
|
+
time-ordered scans the tracker consumes.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from gri_ell import Ell
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Iterable, Sequence
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PresenceObs:
|
|
38
|
+
"""A presence ("is it on") event: existence evidence with no geometry.
|
|
39
|
+
|
|
40
|
+
A presence event names an emitter that was observed to be transmitting but
|
|
41
|
+
carries no kinematic information. The tracker responds by coasting the
|
|
42
|
+
matched track to this time and raising its existence probability -- it is
|
|
43
|
+
NOT a gri observable and never updates kinematic state.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
time: Timestamp of the event in seconds.
|
|
47
|
+
emitter_id: Identifier of the emitter that was detected. Used to match
|
|
48
|
+
the event to an existing track; presence cannot birth a track.
|
|
49
|
+
sensor_id: Identifier of the source that reported the detection.
|
|
50
|
+
confidence: Detection confidence in [0, 1] (probability the emitter is
|
|
51
|
+
truly present given this report). Defaults to 1.0.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
time: float
|
|
55
|
+
emitter_id: str
|
|
56
|
+
sensor_id: str = "presence"
|
|
57
|
+
confidence: float = 1.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Channel(Enum):
|
|
61
|
+
"""Routing channel for a feed-in item."""
|
|
62
|
+
|
|
63
|
+
KINEMATIC = "kinematic"
|
|
64
|
+
EXISTENCE = "existence"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class RoutedItem:
|
|
69
|
+
"""A single feed-in item normalized to a channel and timestamp.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
time: Timestamp in seconds.
|
|
73
|
+
channel: KINEMATIC (geometry -> filter update) or EXISTENCE (presence).
|
|
74
|
+
payload: The original object (``Ell``, a gri-obs observable, or
|
|
75
|
+
``PresenceObs``).
|
|
76
|
+
kind: Short label for the payload type (``"geo"``, ``"tdoa"``,
|
|
77
|
+
``"altitude"``, ``"presence"``, ...).
|
|
78
|
+
id: Stable observation id (e.g. ``"obs_0007"``). Lets the output's
|
|
79
|
+
association diagnostics point back at the exact observation gated /
|
|
80
|
+
assigned. Supplied by the caller or auto-generated in input order.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
time: float
|
|
84
|
+
channel: Channel
|
|
85
|
+
payload: Any
|
|
86
|
+
kind: str
|
|
87
|
+
id: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Scan:
|
|
92
|
+
"""All feed-in items sharing one epoch.
|
|
93
|
+
|
|
94
|
+
A scan is the unit the tracker processes: it predicts every track to the
|
|
95
|
+
scan time, scores and associates the kinematic measurements, then applies
|
|
96
|
+
the existence events.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
time: Epoch timestamp in seconds.
|
|
100
|
+
kinematic: Geometry payloads (``Ell`` or gri-obs observables) at this
|
|
101
|
+
epoch.
|
|
102
|
+
kinematic_ids: Stable observation ids parallel to ``kinematic`` (same
|
|
103
|
+
order), used to key the association diagnostics back to inputs.
|
|
104
|
+
existence: Presence events at this epoch.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
time: float
|
|
108
|
+
kinematic: list[Any] = field(default_factory=list)
|
|
109
|
+
kinematic_ids: list[str] = field(default_factory=list)
|
|
110
|
+
existence: list[PresenceObs] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_observable(payload: object) -> bool:
|
|
114
|
+
"""Return True if ``payload`` satisfies the gri-obs EKF Protocol.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
payload: Candidate object.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if it exposes the EKF Protocol: ``predicted`` / ``jacobian``
|
|
121
|
+
methods plus a ``noise_covariance`` attribute (a property in gri-obs).
|
|
122
|
+
"""
|
|
123
|
+
return (
|
|
124
|
+
callable(getattr(payload, "predicted", None))
|
|
125
|
+
and callable(getattr(payload, "jacobian", None))
|
|
126
|
+
and hasattr(payload, "noise_covariance")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def route_one(payload: object, time_s: float, obs_id: str) -> RoutedItem:
|
|
131
|
+
"""Normalize a single payload to a :class:`RoutedItem`.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
payload: An ``Ell`` (geo), a gri-obs observable, or a ``PresenceObs``.
|
|
135
|
+
time_s: Timestamp in seconds for this item.
|
|
136
|
+
obs_id: Stable observation id for this item.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The routed item with its channel and kind resolved.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
TypeError: If the payload is not a recognized feed-in type.
|
|
143
|
+
"""
|
|
144
|
+
if isinstance(payload, Ell):
|
|
145
|
+
return RoutedItem(time_s, Channel.KINEMATIC, payload, "geo", obs_id)
|
|
146
|
+
if isinstance(payload, PresenceObs):
|
|
147
|
+
return RoutedItem(time_s, Channel.EXISTENCE, payload, "presence", obs_id)
|
|
148
|
+
if _is_observable(payload):
|
|
149
|
+
kind = type(payload).__name__.removesuffix("Obs").lower()
|
|
150
|
+
return RoutedItem(time_s, Channel.KINEMATIC, payload, kind, obs_id)
|
|
151
|
+
raise TypeError(
|
|
152
|
+
f"Unrecognized feed-in payload of type {type(payload).__name__!r}; "
|
|
153
|
+
"expected Ell, a gri-obs observable, or PresenceObs",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def route(items: Iterable[tuple[Any, ...]]) -> list[RoutedItem]:
|
|
158
|
+
"""Route a stream of timestamped inputs into time-ordered items.
|
|
159
|
+
|
|
160
|
+
Each input is ``(payload, time_s)`` or ``(payload, time_s, obs_id)``. When
|
|
161
|
+
no id is supplied, a stable id is generated from the INPUT order
|
|
162
|
+
(``obs_0000``, ``obs_0001``, ...) so ids do not shift when items are sorted
|
|
163
|
+
by time.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
items: Iterable of ``(payload, time_s)`` or ``(payload, time_s, obs_id)``
|
|
167
|
+
tuples. ``payload`` is an ``Ell``, a gri-obs observable, or a
|
|
168
|
+
``PresenceObs``.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Routed items sorted ascending by time (stable for equal times).
|
|
172
|
+
"""
|
|
173
|
+
routed: list[RoutedItem] = []
|
|
174
|
+
for i, item in enumerate(items):
|
|
175
|
+
payload, time_s = item[0], item[1]
|
|
176
|
+
obs_id = item[2] if len(item) > 2 else f"obs_{i:04d}" # noqa: PLR2004
|
|
177
|
+
routed.append(route_one(payload, time_s, obs_id))
|
|
178
|
+
routed.sort(key=lambda r: r.time)
|
|
179
|
+
return routed
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def group_into_scans(
|
|
183
|
+
routed: Sequence[RoutedItem],
|
|
184
|
+
time_tol_s: float = 0.0,
|
|
185
|
+
) -> list[Scan]:
|
|
186
|
+
"""Group time-ordered routed items into per-epoch scans.
|
|
187
|
+
|
|
188
|
+
Items whose timestamps differ by at most ``time_tol_s`` from the scan's
|
|
189
|
+
first item are placed in the same scan. This is how an altitude constraint
|
|
190
|
+
is paired with the TDOA(s) it modifies: same epoch -> same scan -> applied
|
|
191
|
+
together by the tracker.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
routed: Routed items, assumed sorted ascending by time (as returned by
|
|
195
|
+
:func:`route`).
|
|
196
|
+
time_tol_s: Maximum timestamp spread within a single scan, in seconds.
|
|
197
|
+
Defaults to 0.0 (exact-time grouping).
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Scans in ascending time order; each scan's ``time`` is its first item's
|
|
201
|
+
timestamp.
|
|
202
|
+
"""
|
|
203
|
+
scans: list[Scan] = []
|
|
204
|
+
current: Scan | None = None
|
|
205
|
+
for item in routed:
|
|
206
|
+
if current is None or item.time - current.time > time_tol_s:
|
|
207
|
+
current = Scan(time=item.time)
|
|
208
|
+
scans.append(current)
|
|
209
|
+
if item.channel is Channel.EXISTENCE:
|
|
210
|
+
current.existence.append(item.payload)
|
|
211
|
+
else:
|
|
212
|
+
current.kinematic.append(item.payload)
|
|
213
|
+
current.kinematic_ids.append(item.id)
|
|
214
|
+
return scans
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def ingest(
|
|
218
|
+
items: Iterable[tuple[Any, ...]],
|
|
219
|
+
time_tol_s: float = 0.0,
|
|
220
|
+
) -> list[Scan]:
|
|
221
|
+
"""Route and group a raw input stream into scans (the full ingest path).
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
items: Iterable of ``(payload, time_s)`` or ``(payload, time_s, obs_id)``
|
|
225
|
+
tuples.
|
|
226
|
+
time_tol_s: Epoch grouping tolerance in seconds (see
|
|
227
|
+
:func:`group_into_scans`).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Time-ordered scans ready for the tracker.
|
|
231
|
+
"""
|
|
232
|
+
return group_into_scans(route(items), time_tol_s=time_tol_s)
|