gri-multitrack
Multi-target geolocation tracking. gri-multitrack combines the gri per-target
Kalman-IMM (gri-kalman, fed by gri-obs observables) with a multi-target policy
layer: ingest/routing of the feed-in data tiers, measurement-space scoring,
per-scan association, track lifecycle, existence (Labeled Multi-Bernoulli), and
a Poisson-binomial count distribution.
The governing seam is "gri scores, gri-multitrack assigns": gri-kalman owns
per-target estimation and the measurement-space likelihoods; gri-multitrack owns
everything multi-target. The multi-target layer is DIY on numpy/scipy --
gri-multitrack is Stone-Soup-free (see the "Architecture pivot" in CLAUDE.md).
The primary tracker class is MultiTracker (the "Crucible" name now belongs to
the companion 3D app).
See PLAN.md for the live program plan (state, backlog, decisions) and
CLAUDE.md for repo guidance. Scenario generation, replay harnesses, scoring,
and the replay viewer live in the sibling gri-tracksim repo, which
depends on this engine and serializes its outputs (the engine itself is
serialization-free).
Status
v1 of the original build plan is complete (see PLAN.md): ingest ->
score -> associate -> gri IMM update -> lifecycle -> LMB output, on geos, raw
TDOAs, and presence events, plus the outlier stream (clutter floors,
per-observation dispositions, the extensible variant catalog), the stationary
convolve resolver, split/merge with lineage, and batch RTS retrospectives.
Implemented:
- Ingest + router for the feed-in tiers (geo
Ell, observables, presence). - Per-track adapter over any gri
Tracker(defaultSmartSegmentedIMM; CV / CoordinatedTurn / Static bank). - Measurement-space scoring seam (Gaussian innovation + chi-squared gate).
- GNN scaffold associator (local scipy Hungarian) for bring-up.
- MFA tracker (
MfaTracker): a local hypothesis-oriented MHT that defers at ambiguous crossings and resolves via accumulated kinematic likelihood -- the v1 associator (loose-coupled; not Stone Soup's MFA). Standalone engine mirroringMultiTracker. - Track lifecycle: birth from geos, M-of-N confirm, patient deletion.
- Existence r_i + Poisson-binomial count distribution.
- Presence ("is it on") ->
coast(t)+ existence bump. - LMB output: labeled tracks + count distribution + top-level per-observation
dispositions+ the per-scanassociationdiagnostic (gates / marginals / hypotheses), associator-agnostic. See Output.
Also implemented since the skeleton:
- Per-kind clutter likelihood floors and per-observation DISPOSITIONS
(assigned / birthed / clutter / healed); the user-extensible
VariantSourceProtocol (competing readings of one observation). - Capability envelopes (
CapabilityEnvelope): per-platform kinematic limits -- speed, acceleration, turn rate, climb rate, altitude -- applied as a third association test after the chi-squared gate and the clutter floor. The gate is scale-relative and the floor is about measurement density, so this is the only term that asks whether the target could physically have got there. It is not a motion model and carries no predictive power: it bounds the admissible set, the IMM still predicts inside it. Attach one per dwell (SolutionSet.envelope), per observation (a fourthroute()tuple element), or per run (default_envelope). Charges are relieved by a 3-sigma slack built from BOTH endpoints' uncertainty; because implied speed is a finite difference, position error enters divided by the interval. So an envelope is nearly inert on densely sampled data (its wall sits inside the noise) and bites on sparse or coasted tracks, which is where the chi-squared gate is weakest. - Fragment rejoin (
joinable_fragments): a batch pass proposing which completed track fragments could be one platform across a data gap. Under an envelope the question has an answer; without one it has no criterion beyond proximity, which a gap defeats. Proposes only -- never mutates. - Exclusive solution sets (
SolutionSet): an upstream may emit several candidate solutions for ONE dwell with per-candidate prior weights, exactly one of which is true. The set occupies one scan slot under oneobs_id; the MFA branches a world per candidate and resolves them across scans, and a set that fits no track births ONE track rather than one per candidate. - The stationary resolver (convolve as the live estimator of a locked track;
the cluster answer as
resolved), split/merge withTrackEventlineage, andsmoothed_tracks()batch retrospectives. - Serialization, GOSPA/OSPA metrics, and the replay viewer live in
gri-tracksim(the engine stays serialization-free).
Notable design choices
- The per-track estimator is any gri-kalman
Tracker; the default isSmartSegmentedIMM. gri-kalman exposes one uniform interface (update(ell, t)/update_observable/predict/coast/smoothed_track/result/is_initialized) acrossIMM,SmartIMM,SegmentedIMM, andSmartSegmentedIMM. gri-multitrack defaults to the maneuver-segmenting, outlier-rejectingSmartSegmentedIMMthe design calls for; passtracker_factory=make_imm(or anyTrackerfactory) to swap it. The choice is isolated togri_multitrack/track.py. - Stone-Soup-free; multi-target is DIY on numpy/scipy. Both associators are
local (GNN over scipy
linear_sum_assignment; MFA a local hypothesis-oriented MHT). Stone Soup's MFA is filter-coupled and would cost the gri IMM, and is heavy (~48 MB of deps +ortools); see theCLAUDE.md"Architecture pivot". If LAP speed ever matters, addlapsolver/lap(tiny) -- notortools. Stone Soup remains only as an optional dev-time GOSPA/OSPA cross-check.
Install
Uses uv with editable path dependencies on the sibling gri repos (in
../../foss/).
uv sync # core (Stone-Soup-free)
uv sync --extra crosscheck # optional: Stone Soup, for a dev-time GOSPA/OSPA check only
Run
uv run python examples/two_target_demo.py # end-to-end demo
uv run pytest # tests
uv run ruff check gri_multitrack test # lint
uv run ty check # type check
Quick use
from gri_multitrack import MultiTracker, SolutionSet
tracker = MultiTracker()
# each item is (payload, time_s); payload is an Ell, a gri-obs observable,
# a PresenceObs, or a SolutionSet of mutually exclusive candidates.
outputs = tracker.process([(ell0, 0.0), (tdoa1, 1.0), (presence, 2.0)])
# one dwell, three candidate geos, exactly one of them true:
dwell = SolutionSet([ell_a, ell_b, ell_c], [0.5, 0.3, 0.2])
outputs = tracker.process([(dwell, 3.0)])
final = outputs[-1]
for t in final.tracks:
print(t.label, t.existence, t.is_stationary, t.mode_probabilities)
print(final.count_distribution) # Poisson-binomial P(N=k)
Output
A TrackerOutput per scan, in the same unified surface a single-target
gri-kalman tracker reports (a single-target tracker is the degenerate
one-track case), so the two are read interchangeably.
-
output.tracks—LabeledTrackrecords (a gri-kalmanTrackEstimateplus the stationary fields). Each carriesstateas anEllVel(position + velocity + 6x6 covariance;.ellfor the position-onlyEll),mode_probabilities,existence(r_i),confirmed,hits,parent(split lineage),is_stationary/stationary_locked/resolved(the convolver's cluster answer), and a boundpredict. -
Where are the unused / outlier observations? Top-level
output.dispositions, oneDispositionper observation. Each hasindex,used,verdict,track,confidence. The outlier bucket is:outliers = [d for d in output.dispositions if not d.used]
Verdicts:
assigned(absorbed by a track),birthed(seeded a new track),clutter(explained better as clutter —used=False),healed(absorbed under an alternative READING;d.variant_namenames it,d.variantis the measurement actually used). The committal GNN reportsconfidence=1.0; the MFA reports the world-agreement mass withprovisional=True.For a
SolutionSet,d.solution_idnames the candidate that was used -- echoed even when the highest-weight candidate won, so upstream confidence stays scorable. It is orthogonal tovariant_name: selecting a non-primary candidate is not a heal, and onlyvariant_namebears onhealed. -
output.count_distribution/expected_count/most_likely_count— the Poisson-binomial cardinality over the existences. -
output.events— this scan's split / mergeTrackEvents. -
output.association— the per-scan diagnostic (gates / marginals / hypotheses);Nonewhen not computed. EachWorldHypothesiscarriesassignandbirths(obs_id -> newborn label): worlds that disagree about which candidate of a dwell is real have identicalassignmaps and differ only inbirths, so for a dwell that starts a track rather than continuing one,birthsis the whole hypothesis surface.VariantMarginalkeyed onsolution_idgives the per-candidate world mass. -
output.worlds— the MFA's surviving global hypotheses with weights (the MHT-only confidence surface); empty for the committal GNN. -
Prediction:
track.predict(dt_s)returns aPredictedStateat any horizon (a locked-stationary track predicts its convolved fix). Smoothing is opt-in:tracker.smoothed_tracks()returns the per-label RTS retrospective (best given all data, refining the past); the livetracksare the filtered best-given-data-so-far.
Layout
gri_multitrack/ingest.py-- feed-in types, routing, scan grouping.gri_multitrack/track.py-- per-track adapter over the gri IMM.gri_multitrack/scoring.py-- measurement-space likelihood + gate ("gri scores").gri_multitrack/association.py-- GNN scaffold +Associatorprotocol.gri_multitrack/lifecycle.py-- birth / confirm / delete / existence.gri_multitrack/cardinality.py-- Poisson-binomial count distribution.gri_multitrack/output.py-- Labeled Multi-Bernoulli output records.gri_multitrack/tracker.py-- theMultiTrackerorchestrator.
Release files for gri-multitrack 0.3.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| gri_multitrack-0.3.3.tar.gz | 166.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| gri_multitrack-0.3.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 273.5 kB
Release files / gri_multitrack-0.3.3.tar.gz
| Download URL | gri_multitrack-0.3.3.tar.gz |
|---|---|
| Size | 166.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8ecb79bf85c9704afcf07d278403bb5e224bd49d0b6c53ecc5370b196784eba0
|
|
BLAKE2b-256 checksum How to use checksums |
52a57b12301f571c0c97adccc6660423c213450597d5f09e0fbc0647df19b6b4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / gri_multitrack-0.3.3-py3-none-any.whl
| Download URL | gri_multitrack-0.3.3-py3-none-any.whl |
|---|---|
| Size | 106.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
41bcfe326f39832781044a6f497b83a4ea6f07876daf8b365d51bf87915f5221
|
|
BLAKE2b-256 checksum How to use checksums |
550fa882e58015cd91b3b0f115f4ec305a3115c97aecbe98106011606745219f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|