Migrating from v1.x to v2.0.0
v2.0.0 breaks compatibility in a small number of places. There is no deprecation cycle: the removed names are gone and the changed signatures raise rather than warn. That is deliberate — a shim that quietly accepts an old call and does something slightly different is the failure mode this release exists to remove — but it means an upgrade needs a read-through rather than a bump.
Every change below was made because the old behaviour could give a confidently wrong answer, and each entry says what that answer was.
Spatial containers
query(k) rejects k larger than the index
All five indexes — KD-tree, ball tree, R-tree, VP-tree, cover tree — used to
pad a too-large k with index 0 and an infinite distance. Zero is a
valid index, so code that read result.indices without also reading
result.distances silently treated point 0 as a neighbour, once per
overshoot.
# v1.x: returns indices [0, 1, 2, 0, 0] for a 3-point index
result = tree.query(points, k=5)
# v2.0.0: raises ValueError
result = tree.query(points, k=min(5, tree.n_samples))
k == n_samples remains valid. If you want “up to k”, clamp it as above;
the error message says so.
BoundingBox.volume is zero for a degenerate box
A flat box [0,0]-[2,0] used to report 2.0, because the property
multiplied only the nonzero extents. It now reports 0.0, which is the
volume it encloses.
If you relied on the old value you wanted the R-tree insertion measure, not a volume; that behaviour is retained privately for the tree’s own use.
Signal processing and statistics
detection_probability no longer takes swerling_case
All five branches evaluated the same expression, so the argument selected nothing — a caller asking for a non-fluctuating target got the Swerling 1 answer, which at SNR 10 and Pfa 1e-6 is 0.62 against a true 0.90.
# v1.x: argument accepted and ignored
pd = detection_probability(snr, pfa, n_ref, swerling_case=0)
# v2.0.0: for a real choice of target model
from pytcl.mathematical_functions.special_functions import (
swerling_detection_probability,
)
pd = swerling_detection_probability(snr, pfa, n_pulses=1, swerling_case=0)
Note the two answer slightly different questions: detection_probability
accounts for threshold estimation from n_ref reference cells,
swerling_detection_probability for integration over n_pulses.
snr_loss requires pfa and covers CA only
CFAR loss depends on the operating point, so the old 1 + c/n_ref
heuristics — which took no pfa at all — could not express it. They
understated the loss roughly fourfold.
# v1.x
loss = snr_loss(32, method="ca")
# v2.0.0
loss = snr_loss(32, pfa=1e-6, pd=0.5, method="ca")
'go', 'so' and 'os' now raise NotImplementedError. The loss is
defined through the detection probability and no closed form is available here
for those three; the previous numbers were not derived from anything.
nuttall_q is now rician_cdf
The function computes 1 - Q_1(a, b), the Rician CDF, and always did so
correctly. The Nuttall Q function is a different integral. nuttall_q
warned as a deprecated alias through v2.7.x and was removed in v2.8.0.
Values that changed without a signature change
These take the same arguments and return different numbers. If you have baselines recorded against v1.x, they will move:
matched_filter(...).snr_gainNow
sum(t^2) / max(t^2)rather thanlen(template). Identical for a constant-modulus template; a 64-point Hann window has 24 effective samples, so the old figure was 4.3 dB optimistic.optimal_filterNow a linear correlation. The old circular one wrapped a target at the start of a record into a phantom at the end reaching 94% of the true peak, across samples whose correct value is exactly zero.
mle_gaussian(multivariate)fisher_infoandcovarianceare now the exact expressions; they used to beeye(n) * nandeye(n) / n, independent of the data.q_discrete_white_noisewithdim > 4Now the same discrete gain-vector model used for dims 2–4. It previously fell through to a continuous white-noise discretization, off by roughly a factor of four in the leading term at dim 5.
tria_sqrtReturns the documented
(n, n)factor. It previously returned(n, k)when the product was rank deficient.S @ S.Tis unchanged.viewshedMarks the nearest cell rather than the one to the south-west. Results shift by up to half a cell in each axis.
- MHT
track.score Now the standard log-likelihood-ratio increment. It carried a factor of 0.5 that the missed-detection branch did not, so hits and misses accumulated on different scales. Nothing reads this field — confirm and delete go by M-of-N — so tracking behaviour is unchanged.
GPU filters
Filter callbacks take the whole batch
The three GPU filters took three different callback contracts.
batch_ekf_predict converted the states to NumPy and called f once per
track; batch_ukf_predict looped per sigma point of per track;
CuPyParticleFilter.predict passed the whole population through as a device
array. A callback written for one could not be handed to another, and nothing
documented the difference.
They now share one rule: a callback receives (N, dim) on the active
backend and returns (N, out_dim). Jacobian callbacks return
(N, out_dim, dim). N is whatever batch the filter holds – tracks for
the EKF, n_tracks * (2 * state_dim + 1) sigma points for the UKF, particles
for the particle filter – so one callable serves all three.
# v1.x: called once per track with a 1-D NumPy state
def f(x):
return np.array([x[0] + x[1], x[1] * 0.99])
def F_jacobian(x):
return np.array([[1.0, 1.0], [0.0, 0.99]])
# v2.0.0: called once with the whole batch, on the active backend
from pytcl.gpu.utils import get_array_module
def f(x):
xp = get_array_module(x)
return xp.stack([x[:, 0] + x[:, 1], x[:, 1] * 0.99], axis=1)
def F_jacobian(x):
xp = get_array_module(x)
J = xp.array([[1.0, 1.0], [0.0, 0.99]])
return xp.broadcast_to(J, (x.shape[0], 2, 2))
Write callbacks against pytcl.gpu.utils.get_array_module() rather than
NumPy directly. Mixing a host NumPy array into the expression raises
TypeError on CuPy, which refuses implicit conversion of a device array;
MLX permits it, so a callback tested only on Apple Silicon can still fail on
CUDA.
CuPyParticleFilter already followed this contract and is unchanged.
Two consequences beyond consistency. The callback is invoked once rather than once per item: a 200-track UKF prediction went from 1000 invocations to 1, and a numerical Jacobian over the same batch from 1000 to 5. And the state no longer round-trips to the host in the middle of the filter.
The numerical-Jacobian step now follows the backend’s precision
When F_jacobian or H_jacobian is None, the central-difference step
defaults to 1e-7 on a float64 backend and 1e-3 on float32. The old code
used 1e-7 unconditionally, which is below what float32 can resolve – on
MLX it returned rounding noise rather than a derivative. Pass eps
explicitly if you need the old value.
Storage and I/O
SQLStorage no longer takes db_type
Any value other than 'sqlite' made open() do nothing at all, after
which every method raised RuntimeError. It advertised backends that did not
exist.
store = SQLStorage() # v2.0.0: no arguments
open(mode="r") does not create a database
Opening a nonexistent path for reading used to create an empty file, after
which reads failed with sqlite3.OperationalError about a missing table
rather than the documented KeyError. It now raises FileNotFoundError
and leaves no file behind. Use mode='w' or 'a' to create.
store_array replaces on both backends
SQLStorage replaced an existing name; HDF5Storage let h5py raise
ValueError. Both now replace, which is the contract StorageBackend
states. Metadata is replaced wholesale along with the array.
get_track_history residuals are row-aligned
Residuals were read from the first row only, so a window beginning with a
prediction reported residuals=None even when later rows had them — and a
mixed window returned an array shorter than timestamps, pairing every
residual with the wrong time.
history = db.get_track_history(track_id)
residuals = history["residuals"] # (N, meas_dim), aligned, or None
has_residual = ~np.isnan(residuals).any(axis=1)
Rows without a residual now hold NaN. None means no row in the range
has one.
Unknown track ids raise
update_track_state used to insert the state row and update zero tracks,
leaving history belonging to no track — retrievable by id, so a typo produced
something that looked like a track in every respect but the one that counts.
It and merge_tracks now raise KeyError.
Atmosphere
NRLMSISE00 is SimplifiedThermosphere, and was never NRLMSISE-00
The class was named NRLMSISE00 and documented as “a comprehensive
thermosphere model covering altitudes from approximately -5 km to 1000 km”.
NRLMSISE-00 requires harmonic coefficient tables from NOAA which this library
does not distribute. What the code computes is a set of per-species exponential
profiles with temperature-dependent scale heights, each clamped to a floor –
the source said so in a comment, and nothing said so to a caller.
NRLMSISE00 becomes SimplifiedThermosphere, nrlmsise00 becomes
simplified_thermosphere, and NRLMSISE00Output becomes
ThermosphereState:
from pytcl.atmosphere import (
SimplifiedThermosphere,
simplified_thermosphere,
ThermosphereState,
)
The module moved from pytcl.atmosphere.nrlmsise00 to
pytcl.atmosphere.thermosphere. Arguments and return fields are unchanged;
only the names move.
The numbers did not change, but what you should trust did. Above roughly
200 km the model agrees with published NRLMSISE-00 to within a factor of two:
at 400 km with F10.7 = 150 it gives 2.9e-12 kg/m^3 against a published 2-4e-12.
Below that it is wrong, by 44% at sea level and by a factor of 50 at 80 km.
Use pytcl.atmosphere.us_standard_atmosphere_1976() below about 86 km.
Several species densities are floors rather than measurements: h_density
returns 1e8 m^-3 from 0 to 400 km and he_density 1e10 m^-3 at 0 and 100 km,
because the computed profile falls below the clamp.
These limits are now stated in the class docstring and pinned by tests in
tests/validation/test_thermosphere_limits.py, so they cannot drift away
from the documentation unnoticed.
Removed modules
pytcl.logging_configOffered hierarchical loggers, a
@timeddecorator and aTimingContext. Nothing in the library ever used them; the thirteen modules that log calllogging.getLoggerfrom the standard library directly. Replaceget_logger(__name__)withlogging.getLogger("pytcl.<subpackage>")— the hierarchy it configured is what the standard library gives you anyway.As of v2.1.0, pytcl.diagnostics is the redesigned successor for opt-in diagnostic logging (silent by default,
enable_debug_logging()); it shares no API with the removed module.pytcl.assignment_algorithms.network_simplexSuperseded by the Dijkstra-with-potentials implementation in v1.8.0, and its one function was separately incorrect. Min-cost flow remains available through
min_cost_flow_successive_shortest_paths,min_cost_flow_simplexandmin_cost_assignment_via_flow; the surviving solver is validated against a linear-programming oracle.
Neither module came from the NRL Tracker Component Library — both were additions made by this port and never wired into anything.
Checking your upgrade
The library’s own public-API gate is a reasonable model for what to check on your side: every exported function reached by at least one test, and no standing exemptions. If you have a test suite, run it against 2.0.0 before reading this page — the breaks above are designed to be loud, so most of them will surface as an exception rather than as a changed number.
The ones that will not raise are in Values that changed without a signature change. Those need a baseline comparison.