Metadata-Version: 2.5
Name: gri-multitrack
Version: 0.3.3
Summary: Multi-target tracking over gri per-target estimators: ingest/routing, measurement-space scoring, GNN + MFA/MHT association, track lifecycle, LMB existence, and Poisson-binomial cardinality
Project-URL: Homepage, https://geosolresearch.com
Project-URL: Repository, https://gitlab.com/geosol-foss/python/gri-multitrack
Project-URL: Issues, https://gitlab.com/geosol-foss/python/gri-multitrack/-/issues
Project-URL: Changelog, https://gitlab.com/geosol-foss/python/gri-multitrack/-/releases
Author-email: GeoSol Research Inc <contact@geosolresearch.com>
License-Expression: MIT
License-File: LICENSE
Keywords: MFA,MHT,geolocation,lmb,multi-target,tracking
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.12
Requires-Dist: gri-convolve>=0.2.0
Requires-Dist: gri-ell>=0.3.2
Requires-Dist: gri-geosim>=0.4.1
Requires-Dist: gri-kalman>=0.3.3
Requires-Dist: gri-obs>=0.3.0
Requires-Dist: gri-pos>=0.2.0
Requires-Dist: gri-trajectory>=0.1.0
Requires-Dist: gri-utils>=0.4.0
Requires-Dist: numpy>=2.3.3
Requires-Dist: scipy>=1.16.2
Provides-Extra: crosscheck
Requires-Dist: stonesoup>=1.8; extra == 'crosscheck'
Description-Content-Type: text/markdown

# 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` (default `SmartSegmentedIMM`; 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 mirroring
  `MultiTracker`.
- 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-scan `association` diagnostic (gates / marginals /
  hypotheses), associator-agnostic. See [Output](#output).

Also implemented since the skeleton:

- Per-kind clutter likelihood floors and per-observation DISPOSITIONS
  (assigned / birthed / clutter / healed); the user-extensible
  `VariantSource` Protocol (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 fourth `route()` 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 one `obs_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 with `TrackEvent` lineage,
  and `smoothed_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 is
  `SmartSegmentedIMM`.** gri-kalman exposes one uniform interface
  (`update(ell, t)` / `update_observable` / `predict` / `coast` /
  `smoothed_track` / `result` / `is_initialized`) across `IMM`, `SmartIMM`,
  `SegmentedIMM`, and `SmartSegmentedIMM`. gri-multitrack defaults to the maneuver-segmenting,
  outlier-rejecting `SmartSegmentedIMM` the design calls for; pass
  `tracker_factory=make_imm` (or any `Tracker` factory) to swap it. The choice
  is isolated to `gri_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 the `CLAUDE.md` "Architecture pivot".
  If LAP speed ever matters, add `lapsolver`/`lap` (tiny) -- not `ortools`. 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/`).

```bash
uv sync                     # core (Stone-Soup-free)
uv sync --extra crosscheck  # optional: Stone Soup, for a dev-time GOSPA/OSPA check only
```

## Run

```bash
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

```python
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` — `LabeledTrack` records (a gri-kalman `TrackEstimate` plus
  the stationary fields). Each carries `state` as an `EllVel` (position +
  velocity + 6x6 covariance; `.ell` for the position-only `Ell`),
  `mode_probabilities`, `existence` (r_i), `confirmed`, `hits`, `parent`
  (split lineage), `is_stationary` / `stationary_locked` / `resolved` (the
  convolver's cluster answer), and a bound `predict`.
- **Where are the unused / outlier observations?** Top-level
  `output.dispositions`, one `Disposition` per observation. Each has `index`,
  `used`, `verdict`, `track`, `confidence`. The outlier bucket is:

  ```python
  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_name` names it, `d.variant` is the
  measurement actually used). The committal GNN reports `confidence=1.0`; the MFA reports the
  world-agreement mass with `provisional=True`.

  For a `SolutionSet`, `d.solution_id` names the candidate that was used --
  echoed even when the highest-weight candidate won, so upstream confidence
  stays scorable. It is orthogonal to `variant_name`: selecting a non-primary
  candidate is not a heal, and only `variant_name` bears on `healed`.
- `output.count_distribution` / `expected_count` / `most_likely_count` — the
  Poisson-binomial cardinality over the existences.
- `output.events` — this scan's split / merge `TrackEvent`s.
- `output.association` — the per-scan diagnostic (gates / marginals /
  hypotheses); `None` when not computed. Each `WorldHypothesis` carries
  `assign` **and** `births` (`obs_id -> newborn label`): worlds that disagree
  about which candidate of a dwell is real have identical `assign` maps and
  differ only in `births`, so for a dwell that starts a track rather than
  continuing one, `births` is the whole hypothesis surface. `VariantMarginal`
  keyed on `solution_id` gives 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 a `PredictedState` at 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 live `tracks`
  are 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 + `Associator` protocol.
- `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` -- the `MultiTracker` orchestrator.
