Diagnostics Guide
Overview
pytcl.diagnostics is pytcl’s opt-in logging, instrumentation, and
progress-reporting layer. It is the redesigned successor to the
pytcl.logging_config module removed in v2.0.0 – there is no
compatibility shim, and no code should import the old module.
Two rules govern the whole module:
Silent by default. Importing
pytcldisables thepytclloguru namespace and installs no handlers. A fresh interpreter that importspytcland runs a filter step prints nothing, to either stream.Behaviorally neutral. Enabling diagnostics never changes a numerical result. Every instrumentation call sits behind a guard and reads values already computed for the algorithm itself – it never causes an extra matrix inversion or perturbs a cache.
Enable and Disable
import pytcl
pytcl.enable_debug_logging() # DEBUG-level output to stderr, from here on
...
pytcl.disable_debug_logging() # back to complete silence
Both functions are re-exported at the top level and are idempotent:
calling enable_debug_logging() twice replaces the handler rather than
stacking a second one, and disable_debug_logging() is a no-op if
diagnostics are already off.
from pytcl.diagnostics import diagnostics_enabled
pytcl.enable_debug_logging(level="INFO") # raise the floor above DEBUG
assert diagnostics_enabled() is True
diagnostics_enabled() is the guard every instrumentation site checks
before doing any work, so the disabled path costs one boolean check and
nothing else – no string formatting, no payload construction.
The Silence Guarantee
The guarantee is tested at the process level, not just at the API surface:
a subprocess that imports pytcl and calls kf_predict with no prior
enable_debug_logging() call produces zero bytes on stdout and stderr,
and the loguru handler count before and after import is identical. Nothing
pytcl does at import time or during normal operation writes to either
stream unless diagnostics have been explicitly enabled.
What Each Family Logs
Four instrumentation families exist, all bound with a site tag so a
handler can filter or route by category. Enable logging first:
import numpy as np
import pytcl
from pytcl.trackers import MultiTargetTracker
pytcl.enable_debug_logging()
tracker = MultiTargetTracker(
state_dim=4, meas_dim=2,
F=np.array([[1, 1, 0, 0], [0, 1, 0, 0], [0, 0, 1, 1], [0, 0, 0, 1]]),
H=np.array([[1.0, 0, 0, 0], [0, 0, 1.0, 0]]),
Q=np.eye(4) * 0.01, R=np.eye(2) * 1.0,
)
tracker.process([np.array([0.0, 0.0])], dt=1.0)
tracker.process([np.array([0.1, 0.1]), np.array([500.0, 500.0])], dt=1.0)
Sample output (timestamps trimmed):
DEBUG | pytcl.trackers.multi_target - track 0: gated out 1 of 2 measurements: m1 d=482.13>thr=9.21
DEBUG | pytcl.trackers.multi_target - gnn: assigned 1 pair(s) [(0, 0)], total_cost=0.0142
DEBUG | pytcl.trackers.multi_target - track 0: nis=0.3821 (window_mean=0.3821, n=1) cov_condition=1.2400e+01
Each line appears exactly once: the handler installed by
enable_debug_logging() removes loguru’s default stderr handler first,
so records are never double-printed. {name} in the format string
renders the emitting module’s full dotted path (pytcl.trackers.multi_target
above), not the bare string "pytcl".
Every record also carries a site field (bound via logger.bind(site=...))
that a handler can filter or route on – this is the filtering contract
between the log format and the four families below, and it is a single
field with underscores throughout: "data_files", "gating",
"association", "filter_health". The association family adds a
second bound field, algo, naming which algorithm produced the record.
Gating (site="gating") – MultiTargetTracker logs which
measurements were rejected by the gate for each track before association
runs, with the Mahalanobis distance and threshold that rejected them.
The module-level gate_measurements()
helper logs the same way when called directly. ellipsoidal_gate itself
stays uninstrumented – it is a low-level per-measurement kernel called in
a tight loop by both of the above.
Association (site="association") – the resulting GNN
track-to-measurement pairing and its total cost, bound with
algo="gnn"; jpda_probabilities()
logs the top marginal probability per track (algo="jpda"), and
MHTTracker logs per-scan hypothesis counts, how many were pruned, and
the best surviving hypothesis score (algo="mht").
Filter health (site="filter_health") – log_filter_health()
logs a per-update NIS (normalized innovation squared) and covariance
condition number snapshot for every track, at DEBUG. It escalates to
WARNING when either symptom of a diverging filter appears: the current
NIS exceeds three times the mean of its recent window, or the covariance
condition number exceeds 1e12. MultiTargetTracker calls it
automatically after every track update, reusing the innovation covariance
inverse already computed for the Kalman gain – no extra matrix inversion.
Data-file resolution (site="data_files") – get_data_dir()
and the terrain/magnetism/gravity coefficient loaders log every candidate
path they try, whether PYTCL_DATA_DIR is overriding the default, and
which candidate (if any) was found, at DEBUG. This is the fastest way to
see why a loader raised FileNotFoundError: enable diagnostics and the
log shows exactly which directories and filename patterns were tried.
Progress Bars and Track Tables
progress_bar() wraps an iterable in an ASCII
progress display on stderr, independent of enable_debug_logging() –
it is a UI element, not a log record:
from pytcl.diagnostics import progress_bar
for item in progress_bar(range(1000), description="processing"):
...
The real loaders need the GEBCO/Earth2014 data files in
get_data_dir() (up to ~7.5 GB for GEBCO, ~455 MB
per Earth2014 layer), which this guide cannot assume are present, so it
demonstrates the progress flag against
create_test_gebco_dem() and
create_test_earth2014_dem() instead – synthetic
DEMs with the same shape as the real data, no download required:
from pytcl.terrain import create_test_earth2014_dem, create_test_gebco_dem
grid = create_test_earth2014_dem()
dem = create_test_gebco_dem()
The synthetic generators are pure NumPy with no I/O, so they have no
progress parameter to accept. Against real data, the loaders take a
progress flag that wires progress_bar in directly:
from pytcl.terrain.loaders import load_earth2014, load_gebco
grid = load_earth2014(lat_min, lat_max, lon_min, lon_max, progress=True)
dem = load_gebco(lat_min, lat_max, lon_min, lon_max, progress=True)
load_earth2014 shows a genuine row-by-row bar; load_gebco reads
its region in one NetCDF slice with no natural loop to attach a bar to,
so progress=True instead logs DEBUG start/finish markers around the
read (visible only with diagnostics enabled). Either way, passing
progress=True routes around the lru_cache-backed default load
path – it neither reads from the cache nor populates it, so toggling the
flag never forces a redundant re-parse of the underlying file on the
default, non-progress path.
track_table() renders a summary table of a
tracker’s current tracks (id, status, position, speed) to the console:
from pytcl.diagnostics import track_table
tracks = tracker.process([np.array([0.15, 0.15])], dt=1.0)
track_table(tracks)
+----+-----------+---------------+-------+
| id | status | position | speed |
|----+-----------+---------------+-------|
| 0 | CONFIRMED | (0.1, 0.1) | 0.14 |
+----+-----------+---------------+-------+
ASCII-Only Output
Every character pytcl.diagnostics writes – log lines, progress
bars, and track tables – is restricted to what encodes cleanly under
cp1252. This is not a style preference: Windows crashes on the
default console encoding when a redirected stdout/stderr receives a
character outside that codec, so a library that wants to be usable from a
Windows batch file or CI runner cannot emit box-drawing glyphs or Unicode
block characters. Concretely:
track_tablerenders withrich.box.ASCIIandsafe_box=Trueinstead of rich’s default Unicode box-drawing characters.progress_baruses plain text columns (completed/total, percentage) rather than rich’s defaultBarColumn, which renders with Unicode block characters.The log format string uses only ASCII punctuation.
tests/contract/test_console_encoding.py asserts this for the whole
package, not just this module.
See Also
Troubleshooting Guide - general debugging guidance
GPU Acceleration Guide - GPU backend selection and diagnostics
Kalman Filter Tuning Guide - interpreting NIS and filter-health symptoms