Typed Configs and Sessions

Two related pieces of the tracker/filter API live here: pytcl.trackers.configs and pytcl.dynamic_estimation.configs (typed msgspec.Struct configuration objects), and pytcl.io.session (full state snapshot and resume, built on top of those same config types). Together they answer “what parameters built this object” and “how do I stop a running tracker/filter and pick it back up later, exactly where it left off”.

Six classes have session support: SingleTargetTracker, MultiTargetTracker, MHTTracker, IMMEstimator, GaussianSumFilter, and RBPFFilter. No other class in the library can be saved this way – see What Session Support Does Not Cover at the bottom of this page.

The Session Envelope

save_session wraps whatever it snapshots in a SessionEnvelope: a schema_version (the wire format described below), the pytcl.__version__ that produced it, and the tagged snapshot itself. load_session decodes that envelope strictly – malformed or truncated bytes, or bytes carrying a schema_version newer than this install supports, raise FormatError rather than returning a partially-reconstructed object. SESSION_SCHEMA_VERSION (currently 1) is exported from pytcl.io for anyone who wants to check it before decoding.

Typed Configs

Five msgspec.Struct types carry a class’s constructor arguments as a single, serializable object: SingleTargetConfig, MultiTargetConfig (pytcl.trackers.configs), and IMMConfig, GaussianSumConfig, RBPFConfig (pytcl.dynamic_estimation.configs). Each is accepted by its matching constructor via a keyword-only config= argument, mutually exclusive with the individual keyword arguments it replaces – passing both raises ConfigurationError.

SingleTargetConfig, MultiTargetConfig and IMMConfig also carry a from_arrays classmethod that accepts plain arrays/lists (rather than nested Python lists) and normalizes them, matching how the tracker itself accepts F/H/Q/R/transition_matrix.

Struct

Constructor

Carries matrices?

SingleTargetConfig

SingleTargetTracker

F/Q are None when built with callable dynamics

MultiTargetConfig

MultiTargetTracker

Same convention as SingleTargetConfig

IMMConfig

IMMEstimator

Only transition_matrix; per-mode F/Q/H/R are set separately via set_mode_model/set_measurement_model

GaussianSumConfig

GaussianSumFilter

None – component-count/merge/prune parameters only

RBPFConfig

RBPFFilter

None – particle-count/resample/merge parameters only

import numpy as np

from pytcl.core.exceptions import ConfigurationError
from pytcl.dynamic_estimation.configs import GaussianSumConfig, IMMConfig, RBPFConfig
from pytcl.trackers import SingleTargetTracker
from pytcl.trackers.configs import MultiTargetConfig, SingleTargetConfig

F4 = np.eye(4)
H24 = np.eye(2, 4)
Q4 = 0.01 * np.eye(4)
R2 = 0.1 * np.eye(2)

single_cfg = SingleTargetConfig.from_arrays(
    state_dim=4, meas_dim=2, H=H24, R=R2, F=F4, Q=Q4, gate_threshold=9.21,
)
print(single_cfg.state_dim, single_cfg.meas_dim)  # 4 2

multi_cfg = MultiTargetConfig.from_arrays(
    state_dim=4, meas_dim=2, H=H24, R=R2, F=F4, Q=Q4, confirm_hits=2,
)
print(multi_cfg.confirm_hits, multi_cfg.gate_probability)  # 2 0.99

imm_cfg = IMMConfig.from_arrays(
    n_modes=2, state_dim=4, transition_matrix=[[0.95, 0.05], [0.05, 0.95]],
)
gsf_cfg = GaussianSumConfig(max_components=8, prune_threshold=1e-4)
rbpf_cfg = RBPFConfig(max_particles=200)
print(imm_cfg.n_modes, gsf_cfg.max_components, rbpf_cfg.max_particles)  # 2 8 200

# config= and individual arguments are mutually exclusive.
try:
    SingleTargetTracker(state_dim=4, meas_dim=2, config=single_cfg)
except ConfigurationError as exc:
    print("rejected:", "not both" in str(exc))  # True

tracker = SingleTargetTracker(config=single_cfg)
print(tracker.state_dim, tracker.meas_dim, tracker.gate_threshold)  # 4 2 9.21

MHTConfig (pytcl.trackers.mht) is a sixth, related Struct, but it does not follow the pattern above: it carries only MHT’s own algorithm parameters (n_scan, max_hypotheses, detection_prob, and so on), never F/H/Q/R, so MHTTracker takes it as a plain optional config argument with no individual-argument equivalents to conflict with. As of this release MHTConfig is a frozen msgspec.Struct rather than a NamedTuple: attribute access and keyword construction are unchanged, but indexing, unpacking and _replace no longer work, and assigning an attribute raises AttributeError rather than silently succeeding.

from pytcl.trackers import MHTConfig

mht_cfg = MHTConfig(n_scan=2, max_hypotheses=50)
print(mht_cfg.n_scan, mht_cfg.max_hypotheses)  # 2 50

try:
    mht_cfg.n_scan = 5
except AttributeError:
    print("frozen: True")

Saving and Restoring a Tracker

save_session snapshots one of the six supported objects to bytes; load_session reconstructs it. save_session_file/load_session_file are the same pair against a path instead of bytes. A resumed object is not just state-equal to the original – calling predict/update (or process) on it after resume produces results bit-identical to calling the same sequence on the original, uninterrupted object, for SingleTargetTracker, MultiTargetTracker, MHTTracker and IMMEstimator. GaussianSumFilter and RBPFFilter carry the same guarantee only when constructed with an instance rng=; built with the default global RNG, they still resume, but their random draws diverge from an uninterrupted run’s – see RNG Reproducibility below.

import numpy as np

from pytcl.io import load_session, save_session
from pytcl.trackers import SingleTargetTracker

F4 = np.eye(4)
H24 = np.eye(2, 4)
Q4 = 0.01 * np.eye(4)
R2 = 0.1 * np.eye(2)

tracker = SingleTargetTracker(4, 2, F4, H24, Q4, R2)
tracker.initialize(np.array([0.0, 1.0, 0.0, 0.5]), np.eye(4))
tracker.predict(1.0)

data = save_session(tracker)          # bytes, msgpack by default
resumed = load_session(data)
print(type(resumed).__name__, resumed.is_initialized)  # SingleTargetTracker True

z = np.array([1.1, 0.6])
tracker.predict(1.0)
tracker.update(z)
resumed.predict(1.0)
resumed.update(z)
print(np.array_equal(tracker.state.state, resumed.state.state))            # True
print(np.array_equal(tracker.state.covariance, resumed.state.covariance))  # True

MultiTargetTracker sessions round-trip the full track table – every track’s id, state, covariance, status, hit/miss counters, and the tracker’s own next_id counter (so a resumed tracker never reissues a track id that already exists):

from pytcl.trackers import MultiTargetTracker

mt_tracker = MultiTargetTracker(4, 2, F4, H24, Q4, R2, confirm_hits=1)
mt_tracker.process([np.array([0.0, 0.0]), np.array([10.0, 10.0])], dt=1.0)

mt_resumed = load_session(save_session(mt_tracker))
print(len(mt_tracker.tracks), len(mt_resumed.tracks))  # 2 2

z2 = [np.array([0.1, 0.2]), np.array([10.1, 10.2])]
original_ids = [t.id for t in mt_tracker.process(z2, dt=1.0)]
resumed_ids = [t.id for t in mt_resumed.process(z2, dt=1.0)]
print(original_ids == resumed_ids)  # True

MHTTracker sessions carry the same construction recipe (state_dim, meas_dim, H, R, plus the config) as SingleTargetSnapshot/ MultiTargetSnapshot, along with the hypothesis tree’s tracks, hypotheses and id counters – restoring it uses the same load_session call, and the same rehydrate pattern described next.

Rehydrating Callable Dynamics

SingleTargetTracker, MultiTargetTracker and MHTTracker all accept F/Q as either fixed matrices or callables F(dt) -> ndarray (a time-varying model). A snapshot can serialize a fixed matrix, but not a Python callable, so a tracker built with callable dynamics saves a config where F/Q are None – and load_session needs the callable back from the caller to finish reconstructing it. This is the one place load_session takes keyword arguments: pass F=/Q= for exactly the matrices the snapshot’s config lacks.

The rule is checked one matrix at a time and in both directions: omitting F=/Q= when the snapshot needs it raises ConfigurationError rather than restoring a tracker that cannot predict, and passing F=/Q= when the snapshot already carries that matrix also raises – silently overriding saved dynamics would be worse than refusing.

from pytcl.core.exceptions import ConfigurationError

callable_tracker = SingleTargetTracker(4, 2, lambda dt: F4, H24, Q4, R2)
callable_tracker.initialize(np.zeros(4), np.eye(4))
callable_data = save_session(callable_tracker)

try:
    load_session(callable_data)  # no F= given
except ConfigurationError as exc:
    print("needs F=:", "callable" in str(exc))  # True

restored = load_session(callable_data, F=lambda dt: F4)
print(restored.is_initialized)  # True

Self-Contained Snapshots: IMM, Gaussian Sum, RBPF

IMMEstimator, GaussianSumFilter and RBPFFilter have no callable-dynamics escape hatch at all: their models arrive per call (predict(f, F, Q) and friends), not at construction, so every snapshot of these three is fully self-contained. load_session rejects any keyword argument for these snapshot types, including F=/Q= – there is nothing for them to rehydrate.

from pytcl.dynamic_estimation import IMMEstimator

imm = IMMEstimator(2, 2, [[0.9, 0.1], [0.1, 0.9]])
imm.set_mode_model(0, np.eye(2), 0.01 * np.eye(2))
imm.set_mode_model(1, np.eye(2), 0.01 * np.eye(2))
imm.set_measurement_model(np.eye(2), 0.1 * np.eye(2))
imm.initialize(np.zeros(2), np.eye(2))

imm_data = save_session(imm)
try:
    load_session(imm_data, F=np.eye(2))
except ConfigurationError as exc:
    print("self-contained:", "no rehydration" in str(exc))  # True

RNG Reproducibility

RBPFFilter and GaussianSumFilter both accept an optional rng: numpy.random.Generator at construction. When an instance rng is given, its PCG64 bit-generator state is captured in the session snapshot, so a resumed filter continues drawing from exactly the same random stream as an uninterrupted one – every particle/component draw after resume matches bit-for-bit. Session support for instance RNGs is PCG64-only, matching numpy.random.Generator’s default: constructing a filter with a different bit-generator (MT19937, Philox, SFC64, …) and then calling save_session raises ConfigurationError at save time, naming the offending bit-generator class, rather than producing a session that cannot be restored. Restoring a saved PCG64 state onto a mismatched generator is a separate, restore-time failure that surfaces via numpy’s own state-assignment validation.

When rng is omitted (the default), the filter falls back to the legacy global numpy.random state. That state is not captured by a session – a resumed filter falls back to that same global state, which has moved on by however much other code in the process consumed it since the session was saved. Stated plainly: global-RNG filters resume, but their random draws diverge from what an uninterrupted run would have produced. Pass an instance rng whenever bit-reproducible resume matters.

from pytcl.dynamic_estimation import RBPFFilter

def build_rbpf():
    f = RBPFFilter(max_particles=8, rng=np.random.Generator(np.random.PCG64(42)))
    f.initialize(np.zeros(2), np.zeros(2), np.eye(2), num_particles=8)
    return f

uninterrupted = build_rbpf()
resumed_rbpf = load_session(save_session(build_rbpf()))

g_mat, f_mat = np.eye(2), np.eye(2)
Qy = Qx = 0.01 * np.eye(2)
for filt in (uninterrupted, resumed_rbpf):
    filt.predict(lambda y: g_mat @ y, Qy, lambda x, y: f_mat @ x, f_mat, Qx)

print(all(
    np.array_equal(pa.y, pb.y) and np.array_equal(pa.x, pb.x)
    for pa, pb in zip(uninterrupted.get_particles(), resumed_rbpf.get_particles())
))  # True

GaussianSumFilter follows the identical contract (construct with rng=, resume bit-reproducibly; omit it and fall back to the non-reproducible global state).

Format Notes: msgpack vs JSON

save_session/load_session share the same two wire formats as pytcl.io.serialize (see Results I/O), selected by fmt:

Property

fmt="msgpack" (default)

fmt="json"

Encoding

Compact binary

Human-readable text

NaN / Inf anywhere in the snapshot

Preserved exactly (bit-identical float64)

Not representable – save_session raises ValueError before writing anything

Finite-value round-trip

Bit-exact

Bit-exact

The non-finite check walks the entire snapshot – nested configs, per-track lists, RNG state – not just the top-level fields, so a stray NaN deep in an MultiTargetTracker’s track table is caught the same way a NaN in a SingleTargetTracker’s state vector is.

nan_tracker = SingleTargetTracker(4, 2, F4, H24, Q4, R2)
nan_tracker.initialize(np.array([1.0, np.nan, 0.0, 0.0]), np.eye(4))

back = load_session(save_session(nan_tracker, fmt="msgpack"), fmt="msgpack")
print(back.state.state.tobytes() == nan_tracker.state.state.tobytes())  # True

try:
    save_session(nan_tracker, fmt="json")
except ValueError as exc:
    print("json rejects non-finite:", "non-finite" in str(exc))  # True

What Session Support Does Not Cover

Two things in the library look like candidates for save_session but are not supported:

The CuPy-backed batch classes in pytcl.gpu (batch Kalman/EKF/UKF and particle-filter state living on a GPU device array) have no snapshotter registered. Their state is a device array, not the host ndarray values every snapshot type here assumes, and batching changes what “one saved object” even means (one snapshot per batch element, or one for the whole batch). Neither question is answered by this module – move the relevant tracks to a CPU-side tracker first if you need to persist and resume them.

ConstrainedEKF (pytcl.dynamic_estimation.kalman.constrained) is not one of the six supported classes either. Its constraints are ConstraintFunction callables added via add_constraint after construction, the same category of unpicklable Python object that the rehydrate pattern above works around for F/Q – but load_session has no rehydration hook for constraint callables, so a ConstrainedEKF snapshot could not fully reconstruct the filter’s behavior even if one were added.