Results I/O ============ :mod:`pytcl.io` and :mod:`pytcl.transponders` cover the two halves of a tracking pipeline's I/O: reading measurements in (CSV/Parquet), and getting tracks and filter states back out (polars DataFrames, msgspec bytes, ASDF, HDF5). :mod:`pytcl.transponders.ais` decodes real-world AIS traffic into the same measurement shapes. Every function below that touches an optional dependency (``polars``, ``pyais``, ``asdf``) raises :class:`~pytcl.core.exceptions.DependencyError` naming the extra to install if it is missing -- ``h5py`` and ``msgspec`` are core dependencies and need nothing extra. This page covers persisting track *histories* -- results already produced by a tracking run. To snapshot a *running* tracker or filter's full internal state and resume it later, including a bit-exact resumed random stream, see :doc:`sessions` instead. Reading Measurements (CSV/Parquet) ------------------------------------ ``read_measurements_csv`` and ``read_measurements_parquet`` (:mod:`pytcl.io.readers`, the ``dataframe`` extra) turn a flat table -- one row per measurement report -- into a `MeasurementSet`: measurements grouped into scans by the exact value of a timestamp column, ascending. Three keyword-only arguments do the column mapping: .. list-table:: :header-rows: 1 :widths: 22 78 * - Parameter - Meaning * - ``time_column`` - Column whose exact value groups rows into the same scan * - ``measurement_columns`` - Columns stacked, in order, into each scan's ``(n_k, n_cols)`` matrix * - ``id_column`` - Optional column threaded through as ``MeasurementSet.ids`` A missing column raises ``ValueError`` listing every available column, so a typo in the mapping fails immediately rather than silently dropping data. .. code-block:: python import tempfile from pathlib import Path from pytcl.io import read_measurements_csv csv_text = ( "t,x,y,sensor_id\n" "0.0,10.0,20.0,S1\n" "0.0,10.3,19.8,S2\n" "1.0,11.1,21.4,S1\n" ) tmpdir = tempfile.TemporaryDirectory() csv_path = Path(tmpdir.name) / "detections.csv" csv_path.write_text(csv_text) ms = read_measurements_csv( csv_path, time_column="t", measurement_columns=["x", "y"], id_column="sensor_id", ) print(ms.times.tolist()) # [0.0, 1.0] print(ms.scans[0].tolist()) # 2 rows at t=0.0 print(ms.ids[0].tolist()) # ['S1', 'S2'] ``read_measurements_parquet`` has the identical signature and grouping contract, parsed with ``polars.read_parquet`` instead of ``polars.read_csv`` -- a CSV and a Parquet file holding the same rows produce bitwise-identical `MeasurementSet` values. Track Histories as DataFrames -------------------------------- ``tracks_to_polars`` (:mod:`pytcl.io.dataframes`, the ``dataframe`` extra) flattens a per-scan track history -- the same ``list[list[Track]]`` shape `MultiTargetTracker.process` accumulates -- into a **long** table: one row per ``(scan, track)`` pair. .. list-table:: :header-rows: 1 :widths: 18 22 60 * - Column - Type - Contents * - ``track_id`` - Int64 - Track identifier * - ``t`` - Float64 - Scan timestamp * - ``status`` - String - Track status (``TrackStatus.value``) * - ``state`` - List[Float64] - State estimate vector * - ``covariance`` - List[Float64] - Row-major flattened covariance, length ``len(state) ** 2`` ``explode_state_columns`` widens the ``state`` list column into one named Float64 column per component, for a layout you know ahead of time; it raises ``ValueError`` if ``layout``'s length does not match the state dimension -- except for a zero-row ``df``, where the dimension can't be read from empty data, so the check is skipped and the ``layout``-named columns are added empty instead. .. code-block:: python import numpy as np from pytcl.dynamic_models import f_constant_velocity from pytcl.io import explode_state_columns, tracks_to_polars from pytcl.trackers import MultiTargetTracker F = f_constant_velocity(1.0, num_dims=2) # [x, vx, y, vy] layout H = np.array([[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]) tracker = MultiTargetTracker( state_dim=4, meas_dim=2, F=F, H=H, Q=np.eye(4) * 0.01, R=np.eye(2) * 4.0, confirm_hits=1, ) history, times = [], [] for k in range(3): history.append(tracker.process([np.array([float(k), 2.0 * k])], dt=1.0)) times.append(float(k)) df = tracks_to_polars(history, times) print(df.columns) # ['track_id', 't', 'status', 'state', 'covariance'] print(df.height) # one row per (scan, track) wide = explode_state_columns(df, ["x", "vx", "y", "vy"]) print(wide.select(["track_id", "t", "x", "y"]).height) ``metrics_to_polars(times, **series)`` builds an unrelated, simpler table -- one ``t`` column plus one Float64 column per named 1-D series (e.g. ``metrics_to_polars(t, ospa=ospa_values)``) -- for scalar-per-scan evaluation metrics rather than per-track state. Both DataFrame kinds write to Parquet with the ordinary ``df.write_parquet(path)`` and read back with ``pl.read_parquet`` or, for a metrics-style table, `read_measurements_csv` / `read_measurements_parquet` on the far side of a pipeline (the export and ingest sides share a column-oriented convention by design). Serialization Fidelity: MessagePack vs JSON ----------------------------------------------- ``encode_tracks`` / ``decode_tracks`` and ``encode_states`` / ``decode_states`` (:mod:`pytcl.io.serialize`, msgspec -- a core dependency) serialize the same track-history and single-state shapes to bytes, in one of two wire formats selected by ``fmt``: .. list-table:: :header-rows: 1 :widths: 22 39 39 * - Property - ``fmt="msgpack"`` (default) - ``fmt="json"`` * - Encoding - Compact binary - Human-readable text * - NaN / Inf - Preserved exactly (bit-identical ``float64``) - Not representable -- ``encode_*`` raises ``ValueError`` *before* writing anything * - Finite-value round-trip - Bit-exact (``tobytes()`` equal) - Bit-exact (``tobytes()`` equal) * - Best for - Archival, wire transport, anything that might carry a coasting track's inflated (but finite) or degenerate covariance - Interop with non-Python tooling, human inspection, diffing Decoding is strict either way: msgspec validates the bytes against the target ``Struct`` and raises rather than returning a partially-populated result. .. code-block:: python import numpy as np from pytcl.io import decode_states, encode_states x = np.array([100.0, 5.0, np.nan]) # a NaN slipped into a coasting track P = np.eye(3) blob = encode_states(x, P, fmt="msgpack") x2, P2 = decode_states(blob, fmt="msgpack") print(x2.tobytes() == x.tobytes()) # True -- bit-exact, NaN included try: encode_states(x, P, fmt="json") except ValueError as exc: print("json rejected it:", "non-finite" in str(exc)) AIS Decoding and Position Reports ------------------------------------- :mod:`pytcl.transponders.ais` (the ``ais`` extra, via `pyais `_) is this port's counterpart to the MATLAB TCL's ``Transponders/decodeAISString``, which wraps libais. ``decode_ais`` reassembles and decodes ``!AIVDM``/``!AIVDO`` NMEA sentences, skipping lines it cannot decode rather than raising (a batch decode over potentially noisy logs). ``ais_position_reports`` pulls out message types 1, 2, 3, 18, 19 (Class A/B position reports) as parallel arrays, converting ITU-R M.1371's "not available" sentinels (91 deg lat, 181 deg lon, 102.3 kn SOG, 360 deg COG, heading 511) to NaN. **Units matter here**: ``lat``, ``lon``, ``cog`` and ``heading`` come back in **radians** (this library's angle convention at every API boundary, degrees nowhere), and ``sog`` in **m/s** -- pyais itself reports degrees and knots, so both conversions happen inside `ais_position_reports`. .. code-block:: python import numpy as np from pytcl.transponders.ais import ais_position_reports, decode_ais # A widely published type-1 (Class A position report) test sentence. vdm = "!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C" messages = decode_ais(vdm) print(messages[0].msg_type, messages[0].mmsi) # 1 366053209 rep = ais_position_reports(vdm) print(rep.lat.dtype) # float64 print(round(float(np.degrees(rep.lat[0])), 4)) # degrees, for a human to read print(round(float(rep.sog[0]), 3)) # m/s, already converted Feeding ``times`` (one receiver timestamp per decoded message, before the position-report filter) threads a ``t`` column through `PositionReports`, ready for `tracks_to_polars`-style downstream handling or a per-ship constant-velocity filter keyed on ``mmsi``. **Validated against real traffic**: ``tests/validation/test_ais_tracking.py`` runs a per-ship constant-velocity Kalman filter, positions only, over 6,808 position reports from 299 real ships -- captured from Kystverket's open AIS feed off the Norwegian coast -- and scores the recovered speed against each ship's self-broadcast SOG, a quantity the filter is never given: median error 0.0134 m/s against a calibrated 0.03 m/s envelope. Reproduce: .. code-block:: bash uv run pytest tests/validation/test_ais_tracking.py -q The capture provenance, message-type histogram, independence argument and full calibration record (including the ``PROCESS_VAR`` sweep the 0.03 m/s envelope was derived from) are in ``tests/fixtures/ais/SOURCES.md``. ASDF ----- ``save_tracks_asdf`` / `load_tracks_asdf` and ``save_states_asdf`` / `load_states_asdf` (:mod:`pytcl.io.asdf_io`, the ``asdf`` extra) write the same track-history and single-state shapes as `pytcl.io.serialize` to an `ASDF `_ file: a self-describing, schema-versioned ndarray tree, useful for archiving tracking results alongside other ASDF-native metadata (WCS, provenance). States must be a uniform dimension within one history; `save_tracks_asdf` raises ``ValueError`` naming the offending scan and track otherwise. .. code-block:: python import tempfile from pathlib import Path import numpy as np from pytcl.io import load_tracks_asdf, save_tracks_asdf from pytcl.trackers import Track, TrackStatus track = Track( id=1, state=np.array([1.0, 2.0]), covariance=np.eye(2), status=TrackStatus.CONFIRMED, hits=1, misses=0, time=0.0, ) tmpdir = tempfile.TemporaryDirectory() path = Path(tmpdir.name) / "tracks.asdf" save_tracks_asdf(path, [[track]], [0.0]) times, history = load_tracks_asdf(path) print(times) # [0.0] print(history[0][0].state.tolist()) # [1.0, 2.0] HDF5 Compression ------------------- ``TrackHDF5Storage`` (:mod:`pytcl.io.hdf5_track_storage`, ``h5py`` -- a core dependency) is the archival backend for large tracking scenarios. Its default configuration (``chunk_size=1000``, ``compression="gzip"``, ``compression_level=4``, ``shuffle=True``) was measured, not assumed: on a 100-track x 500-scan, 6-D benchmark whose covariances come from a *real* converged constant-velocity Kalman filter (not random noise -- a converged filter's covariance settles to a near-constant matrix, which is what makes it compressible), the shipped configuration reaches **4.73x** compression, up from a 4.42x baseline with the byte-shuffle filter off (+7.1%). Time-aligned chunk shapes were evaluated too and found to already be the existing behavior (measured 0.0% additional gain). Reproduce the full measurement: .. code-block:: bash uv run pytest tests/unit/test_hdf5_compression.py -q .. code-block:: python import tempfile from pathlib import Path import numpy as np from pytcl.io import TrackHDF5Storage rng = np.random.default_rng(0) n_scans = 50 states = np.cumsum(rng.normal(0, 0.1, size=(n_scans, 4)), axis=0) covariances = np.tile(np.eye(4) * 2.0, (n_scans, 1, 1)) timestamps = np.arange(n_scans, dtype=np.float64) tmpdir = tempfile.TemporaryDirectory() path = Path(tmpdir.name) / "tracks.h5" with TrackHDF5Storage(str(path)) as store: # shuffle=True by default store.open(mode="w") store.store_track("trk_001", states, covariances, timestamps) with TrackHDF5Storage(str(path)) as store: store.open(mode="r") traj = store.retrieve_track("trk_001") print(traj["states"].shape) # (50, 4) raw_bytes = 8 * (states.size + covariances.size + timestamps.size) print(f"on-disk vs raw: {path.stat().st_size} / {raw_bytes} bytes")