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.
@@ -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)))
@@ -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)