Library Architecture
Overview
pytcl is 145 modules across 22 implemented packages, exporting about
1046 public names. There is no framework object to inherit from: the library is a
set of composable functions and small result types, so a tracker is something you
assemble rather than something you subclass.
Three placeholder packages — misc, physical_values and scheduling
— used to exist with no modules, mirroring the MATLAB directory layout. They
were removed before 2.0.0: an importable-but-empty package implied support
that does not exist, which is worse than the honest ImportError.
pytcl.transponders was one of them until v2.2.0, when AIS decoding (see
pytcl.transponders.ais) gave it real content; if the remaining three
are ported (see MATLAB TCL parity inventory), they return the same way.
Subsystem Map
Packages grouped by the role they play. Arrows point from a consumer toward what it depends on; only the structurally significant edges are drawn.
graph TD
subgraph Estimation
DE["dynamic_estimation<br/>17 modules"]
DM["dynamic_models<br/>7 modules"]
SE["static_estimation<br/>4 modules"]
end
subgraph Association_and_Tracking
AA["assignment_algorithms<br/>9 modules"]
TR["trackers<br/>5 modules"]
CL["clustering<br/>4 modules"]
CO["containers<br/>8 modules"]
end
subgraph Geometry_and_Navigation
CS["coordinate_systems<br/>6 modules"]
NA["navigation<br/>5 modules"]
AS["astronomical<br/>9 modules"]
end
subgraph Environment_Models
GR["gravity<br/>5 modules"]
MA["magnetism<br/>3 modules"]
AT["atmosphere<br/>5 modules"]
TE["terrain<br/>3 modules"]
end
subgraph Foundation
MF["mathematical_functions<br/>26 modules"]
CR["core<br/>7 modules"]
end
subgraph Support
IO["io<br/>12 modules"]
GP["gpu<br/>7 modules"]
PL["plotting<br/>4 modules"]
PE["performance_evaluation<br/>2 modules"]
end
TR --> DE
TR --> AA
TR --> CO
DE --> DM
DE --> MF
AA --> MF
CL --> CO
NA --> CS
NA --> GR
AS --> CS
SE --> MF
CS --> MF
PE --> MF
IO --> CO
GP --> MF
PL --> CO
DM --> CR
MF --> CR
Tracking Pipeline
The order in which these subsystems are normally composed. Every stage is a function call over plain arrays and small result objects, so any one of them can be replaced without touching the others.
flowchart LR
M["Raw measurements<br/>range, bearing, elevation"] --> C
C["coordinate_systems<br/>convert to a common frame"] --> G
G["assignment_algorithms<br/>gating"] --> A
A["assignment_algorithms<br/>data association"] --> F
F["dynamic_estimation<br/>predict and update"] --> TM
TM["trackers<br/>initiation, confirmation, deletion"] --> P
P["io<br/>persist tracks"] --> E
E["performance_evaluation<br/>NEES, NIS, OSPA"]
DM["dynamic_models<br/>F and Q"] -.-> F
E -.->|tune| DM
Per-detection Measurement Covariance
SingleTargetTracker.update and MultiTargetTracker.process accept a
covariance per detection, not just the fixed R given to the constructor:
# each detection carries the covariance that actually applies to it
tracks = tracker.process(detections, dt, measurement_covariances=covariances)
This matters whenever the measurement error is not the same for every
detection, and a converted polar detection is the common case. Its Cartesian
covariance is J R_polar J.T, which is anisotropic and grows with range:
down-range spread stays at sigma_range while cross-range spread is
r * sigma_bearing.
No single R describes that. Size it to the down-range term and the gate is
too tight at long range – true detections fall outside it and the tracker
starts duplicate tracks. Size it to the cross-range term and cardinality is
right, but the covariance is inflated and the filter under-reports its own
accuracy. Supplying each detection’s covariance avoids the choice; both the
gate and the Kalman gain then use the covariance that applies.
Estimator Families
What dynamic_estimation actually provides. These are exposed as
*_predict / *_update function pairs rather than filter classes, which is
why the examples below thread state through explicitly.
graph LR
KF["Linear Kalman<br/>kf_predict / kf_update"]
KF --> EKF["Extended<br/>ekf_predict / ekf_update"]
KF --> UKF["Unscented<br/>ukf_predict / ukf_update"]
KF --> SR["Square-root and UD<br/>numerically robust"]
KF --> CE["Constrained<br/>ConstrainedEKF"]
KF --> INF["Information and SRIF"]
KF --> IMM["IMM<br/>maneuver switching"]
PF["Particle filters"] --> RBPF["Rao-Blackwellised"]
GSF["Gaussian sum"]
KF --> SM["Smoothers<br/>RTS, fixed-lag, two-filter"]
PF --> SM
Package Reference
Counts are measured from the packages themselves rather than asserted;
tests/test_docs_architecture.py fails if this table drifts.
Package |
Modules |
Public |
Purpose |
|---|---|---|---|
|
26 |
73 |
Special functions, statistics, transforms, signal processing, geometry, polynomials |
|
17 |
88 |
Kalman variants, particle filters, smoothers, information filters |
|
9 |
52 |
2-D, 3-D and N-D assignment, gating, JPDA, k-best, network flow |
|
9 |
149 |
Orbital mechanics, SGP4/SDP4, TLEs, ephemerides, reference frames |
|
8 |
35 |
Spatial indices (k-d tree, R-tree, VP-tree, cover tree) and track sets |
|
7 |
72 |
Constants, exceptions, validation, array helpers, data paths |
|
7 |
37 |
Motion models and process-noise matrices, continuous and discrete |
|
7 |
38 |
CuPy and MLX backends for array-heavy routines |
|
12 |
37 |
Track and measurement persistence, including HDF5 |
|
6 |
77 |
Frame conversions, u-v direction cosines, rotations, Jacobians, map projections |
|
5 |
58 |
Spherical-harmonic gravity, EGM, solid Earth tides |
|
5 |
101 |
Geodesy, great circle and rhumb line, INS and INS/GNSS |
|
4 |
28 |
k-means, DBSCAN, hierarchical, Gaussian mixtures |
|
4 |
30 |
Track, uncertainty and coverage visualization |
|
5 |
19 |
End-to-end single- and multi-target trackers |
|
5 |
60 |
Simplified thermosphere, standard atmospheres, ionosphere, humidity, refractivity |
|
3 |
36 |
WMM, WMMHR, IGRF, EMM |
|
4 |
50 |
Least squares, robust estimation, model selection, localization |
|
3 |
30 |
DEM handling, line of sight, horizon and viewshed |
|
2 |
21 |
NEES, NIS, OSPA, Cramer-Rao bounds |
|
1 |
10 |
Opt-in loguru logging, ASCII-safe rich progress bars and track tables |
|
1 |
5 |
AIS NMEA decoding, checksum validation, and position-report extraction (pyais) |
Composition Examples
Each of these runs as written; the same test that checks the table also checks that every import on this page resolves.
Filtering a constant-velocity target
import numpy as np
from pytcl.dynamic_models import f_constant_velocity, q_constant_velocity
from pytcl.dynamic_estimation import kf_predict, kf_update
T = 0.1
F = f_constant_velocity(T, num_dims=2)
Q = q_constant_velocity(T, sigma_a=0.1, num_dims=2)
# f_constant_velocity is block diagonal -- one (position, velocity) pair
# per spatial dimension -- so the state is [x, vx, y, vy], not
# [x, y, vx, vy]. Getting this backwards silently measures velocity.
x = np.array([0.0, 1.0, 0.0, 0.5]) # x, vx, y, vy
P = np.eye(4) * 0.1
pred = kf_predict(x, P, F, Q)
H = np.array([[1.0, 0.0, 0.0, 0.0], # observe x
[0.0, 0.0, 1.0, 0.0]]) # observe y
R = np.eye(2) * 0.05
upd = kf_update(pred.x, pred.P, np.array([0.1, 0.05]), H, R)
Associating measurements to tracks
import numpy as np
from pytcl.assignment_algorithms import assign2d
# cost[i, j] is the cost of assigning track i to measurement j
cost = np.array([[4.0, 1.0, 3.0],
[2.0, 0.0, 5.0],
[3.0, 2.0, 2.0]])
result = assign2d(cost)
# result.col_indices[i] is the measurement assigned to track i
Geodetic conversion and range
import numpy as np
from pytcl.navigation import geodetic_to_ecef, inverse_geodetic
dc_lat, dc_lon = np.radians(38.9), np.radians(-77.0)
ny_lat, ny_lon = np.radians(40.7), np.radians(-74.0)
x, y, z = geodetic_to_ecef(dc_lat, dc_lon, 0.0)
distance, azimuth, back_azimuth = inverse_geodetic(dc_lat, dc_lon, ny_lat, ny_lon)
Optional Dependencies
A core install pulls only numpy, scipy, numba, h5py,
loguru, msgspec and rich.
Everything else is opt-in, so an import failing inside one of these packages
usually means a missing extra rather than a bug.
graph LR
CORE["core install<br/>numpy, scipy, numba, h5py,<br/>loguru, msgspec, rich"]
CORE --> AST["astronomy<br/>astropy, jplephem"]
CORE --> GEO["geodesy<br/>pyproj, geographiclib"]
CORE --> TER["terrain<br/>netCDF4"]
CORE --> VIS["visualization<br/>plotly"]
CORE --> SIG["signal<br/>pywavelets"]
CORE --> GPU["gpu, gpu-apple<br/>cupy, mlx"]
AST --> ALL["all<br/>everything except gpu"]
GEO --> ALL
TER --> ALL
VIS --> ALL
SIG --> ALL
Angles and Units
Every angle at an API boundary is in radians and every distance in
meters, unless a function documents otherwise. Passing degrees where radians
are expected is the most common way to get results that look plausible and are
wrong: as a latitude in radians, -5 is -286 degrees, which is past the poles.
See Also
Getting Started - installation and first steps
Performance Optimization Guide - profiling and GPU acceleration
Examples - runnable example scripts