Source code for pytcl.assignment_algorithms.jpda

"""
Joint Probabilistic Data Association (JPDA) algorithm.

JPDA computes association probabilities between tracks and measurements
by considering all possible joint association hypotheses, then performs
a probabilistically weighted update for each track.

This is more sophisticated than GNN which makes hard assignment decisions,
as JPDA can handle measurement origin uncertainty in cluttered environments.
"""

import warnings
from typing import Any, List, NamedTuple, Optional

import numpy as np
from numba import njit
from numpy.linalg import LinAlgError
from numpy.typing import ArrayLike, NDArray
from scipy.linalg import cho_factor, cho_solve
from scipy.stats import chi2

from pytcl.assignment_algorithms.gating import mahalanobis_batch, mahalanobis_distance
from pytcl.diagnostics import diagnostics_enabled, logger

# An innovation covariance is symmetric by construction; its asymmetry is
# float roundoff (~1e-16 relative), so sqrt(eps) sits ~8 orders above that.
_SYMMETRY_REL_TOL = float(np.sqrt(np.finfo(np.float64).eps))


def _is_finite_symmetric(S: NDArray[Any]) -> bool:
    # Cholesky reads one triangle only, and which one differs between
    # scipy.linalg.cho_factor (upper) and np.linalg.cholesky (lower), so an
    # asymmetric S would be tested as two different matrices.
    if not np.all(np.isfinite(S)):
        return False
    return bool(
        np.max(np.abs(S - S.T), initial=0.0)
        <= _SYMMETRY_REL_TOL * np.max(np.abs(S), initial=0.0)
    )


[docs] class JPDAResult(NamedTuple): """Result of JPDA algorithm. Attributes ---------- association_probs : ndarray[Any] Association probability matrix of shape (n_tracks, n_measurements + 1). association_probs[i, j] is the probability that track i is associated with measurement j. The last column (j = n_measurements) represents the probability that track i has no measurement. marginal_probs : list of ndarray List of marginal association probabilities for each track. marginal_probs[i][j] = P(measurement j originated from track i). likelihood_matrix : ndarray[Any] Measurement likelihood matrix of shape (n_tracks, n_measurements). gated : ndarray[Any] Boolean matrix indicating which track-measurement pairs passed gating. """ association_probs: NDArray[np.floating] marginal_probs: List[NDArray[np.floating]] likelihood_matrix: NDArray[np.floating] gated: NDArray[np.bool_]
[docs] class JPDAUpdate(NamedTuple): """Result of JPDA-based track update. Attributes ---------- states : list of ndarray Updated state estimates for each track. covariances : list of ndarray Updated covariances for each track (includes spread of means). association_probs : ndarray[Any] Association probability matrix. innovations : list of ndarray Combined weighted innovations for each track. """ states: List[NDArray[np.floating]] covariances: List[NDArray[np.floating]] association_probs: NDArray[np.floating] innovations: List[NDArray[np.floating]]
[docs] def compute_measurement_likelihood( innovation: NDArray[Any], innovation_cov: NDArray[Any], detection_prob: float = 1.0, ) -> float: """ Compute measurement likelihood for a track-measurement pair. Parameters ---------- innovation : ndarray[Any] Measurement innovation (residual), shape (m,). innovation_cov : ndarray[Any] Innovation covariance, shape (m, m). detection_prob : float Probability of detection (Pd). Returns ------- likelihood : float Measurement likelihood. """ m = len(innovation) # det(S) > 0 alone does not imply positive-definiteness: an even number # of negative eigenvalues also multiplies to a positive determinant # (e.g. diag(-1, -1, 1) has det = 1), so a determinant-only test admits # negative-definite and indefinite S. Cholesky fails whenever any # leading principal minor is non-positive, which is the actual PD test. if not _is_finite_symmetric(innovation_cov): warnings.warn( "compute_measurement_likelihood: innovation covariance is not " "symmetric or contains non-finite values; likelihood set to 0.0 " "(numerical failure, not evidence). Check R and the covariance " "conditioning.", RuntimeWarning, stacklevel=2, ) return 0.0 try: S_cho = cho_factor(innovation_cov, check_finite=False) except LinAlgError: warnings.warn( "compute_measurement_likelihood: innovation covariance is not " "positive definite; likelihood set to 0.0 (numerical failure, " "not evidence). Check R and the covariance conditioning.", RuntimeWarning, stacklevel=2, ) return 0.0 mahal_sq = innovation @ cho_solve(S_cho, innovation, check_finite=False) log_det_S = 2 * np.sum(np.log(np.diag(S_cho[0]))) likelihood = detection_prob * np.exp( -0.5 * (mahal_sq + log_det_S + m * np.log(2 * np.pi)) ) return likelihood
[docs] def compute_likelihood_matrix( track_states: list[NDArray[Any]], track_covariances: list[NDArray[Any]], measurements: NDArray[Any], H: NDArray[Any], R: NDArray[Any], detection_prob: float = 1.0, gate_threshold: Optional[float] = None, ) -> tuple[NDArray[Any], NDArray[Any]]: """ Compute likelihood matrix for all track-measurement pairs. Parameters ---------- track_states : list of ndarray State estimates for each track. track_covariances : list of ndarray Covariances for each track. measurements : ndarray[Any] Measurements, shape (n_meas, m). H : ndarray[Any] Measurement matrix, shape (m, n). R : ndarray[Any] Measurement noise covariance, shape (m, m). detection_prob : float Probability of detection. gate_threshold : float, optional Mahalanobis distance threshold for gating. Returns ------- likelihood_matrix : ndarray[Any] Likelihood values, shape (n_tracks, n_meas). gated : ndarray[Any] Boolean gating matrix, shape (n_tracks, n_meas). Examples -------- >>> import numpy as np >>> # Two tracks, two measurements >>> states = [np.array([0.0, 1.0]), np.array([5.0, 0.0])] >>> covs = [np.eye(2) * 0.5, np.eye(2) * 0.5] >>> measurements = np.array([[0.1], [5.2]]) >>> H = np.array([[1, 0]]) >>> R = np.array([[0.1]]) >>> L, gated = compute_likelihood_matrix(states, covs, measurements, H, R) >>> L.shape (2, 2) >>> # Track 0 should have high likelihood for measurement 0 >>> L[0, 0] > L[0, 1] True """ n_tracks = len(track_states) # Coerce eagerly: the per-track fast path below does # `measurements - z_pred` unconditionally, which raises for a bare # Python list (numpy's binary-op fallback converts `[]` to a shape-(0,) # array, not (0, m), so it fails to broadcast against z_pred's shape # (m,) for m != 1). The old per-pair loop only ever did `measurements[j]` # inside `for j in range(n_meas)`, so n_meas == 0 skipped touching # `measurements` at all and a bare `[]` worked by accident. measurements = np.asarray(measurements, dtype=np.float64) n_meas = len(measurements) likelihood_matrix = np.zeros((n_tracks, n_meas)) gated = np.zeros((n_tracks, n_meas), dtype=bool) if n_meas == 0: return likelihood_matrix, gated for i in range(n_tracks): # Predicted measurement and innovation covariance z_pred = H @ track_states[i] S = H @ track_covariances[i] @ H.T + R m = S.shape[0] # det(S) > 0 does not imply positive-definiteness (an even number of # negative eigenvalues also multiplies to a positive determinant), # so gate on Cholesky success rather than the sign of det(S). This # row's likelihoods and gating stay at their zero/False # initialization; other tracks' rows are untouched. if not _is_finite_symmetric(S): warnings.warn( f"compute_likelihood_matrix: innovation covariance for track " f"{i} is not symmetric or contains non-finite values; that " "track's likelihoods and gating are set to 0.0 (numerical " "failure, not evidence). Check R and the covariance " "conditioning.", RuntimeWarning, stacklevel=2, ) continue try: np.linalg.cholesky(S) except LinAlgError: warnings.warn( f"compute_likelihood_matrix: innovation covariance for track " f"{i} is not positive definite; that track's likelihoods and " "gating are set to 0.0 (numerical failure, not evidence). " "Check R and the covariance conditioning.", RuntimeWarning, stacklevel=2, ) continue # S depends only on the track, not the measurement: invert it once # and reuse the inverse (and det(S)) across every measurement, # instead of re-solving and re-computing det(S) per (track, # measurement) pair. An earlier version of this restructure used # scipy.linalg.cho_factor/cho_solve here; measured (Apple M3 Max, # 2026-08-18, this benchmark) it was ~2x SLOWER than the original # per-pair loop -- cho_solve's Python-level wrapper overhead # (finite-value checks, array-API dispatch) dominates at the m=2 # sizes this benchmark exercises, the same LAPACK-generic-dispatch # tax the gating.py njit kernels were built to avoid (see # mahalanobis_distance's docstring). np.linalg.inv, called once per # track, plus the already-exported (previously unwired) # `mahalanobis_batch` njit kernel amortizing the quadratic form # over all of a track's measurements in one call, measured ~14-28x # faster than both the original loop and the cho_factor attempt. try: S_inv = np.linalg.inv(S) except LinAlgError: # S already passed the Cholesky PD check above, so np.linalg.inv # should never raise here; this is a defensive fallback to the # original per-pair path for the pathological case where it # does anyway (mahalanobis_distance's own dispatch raises the # same error only when its own route is also singular; # compute_measurement_likelihood's own Cholesky guard still # zeroes the likelihood if reached with a non-PD S). for j in range(n_meas): innovation = measurements[j] - z_pred mahal_dist = mahalanobis_distance(innovation, S) if gate_threshold is None or mahal_dist <= gate_threshold: gated[i, j] = True likelihood_matrix[i, j] = compute_measurement_likelihood( innovation, S, detection_prob ) continue det_S = np.linalg.det(S) innovations = measurements - z_pred mahal_sq = np.empty(n_meas) mahalanobis_batch(innovations, S_inv, mahal_sq) if gate_threshold is None: track_gated = np.ones(n_meas, dtype=bool) else: # mahalanobis_batch, like mahalanobis_distance, returns the # *squared* Mahalanobis distance -- gate_threshold (e.g. # chi2.ppf(...)) is on that same squared scale, so this # comparison mirrors the old per-pair gate exactly. track_gated = mahal_sq <= gate_threshold gated[i, :] = track_gated if det_S > 0 and n_meas: # S already passed the Cholesky PD check above, so det_S > 0 # holds mathematically; this is a defensive floor against det() # underflowing to exactly 0.0 for a technically-PD but severely # ill-conditioned S. norm_const = detection_prob / np.sqrt((2 * np.pi) ** m * det_S) likelihood_matrix[i, track_gated] = norm_const * np.exp( -0.5 * mahal_sq[track_gated] ) return likelihood_matrix, gated
[docs] def jpda_probabilities( likelihood_matrix: NDArray[Any], gated: NDArray[Any], detection_prob: float = 1.0, clutter_density: float = 1e-6, ) -> NDArray[Any]: """ Compute JPDA association probabilities. Uses an efficient algorithm that avoids explicit enumeration of all joint hypotheses when possible. Parameters ---------- likelihood_matrix : ndarray[Any] Likelihood values, shape (n_tracks, n_meas). gated : ndarray[Any] Boolean gating matrix, shape (n_tracks, n_meas). detection_prob : float Probability of detection (Pd). clutter_density : float Spatial density of clutter (lambda_c). Returns ------- beta : ndarray[Any] Association probability matrix, shape (n_tracks, n_meas + 1). beta[i, j] = P(measurement j is from track i) for j < n_meas. beta[i, n_meas] = P(track i has no measurement). Examples -------- >>> import numpy as np >>> # Likelihood matrix: 2 tracks, 2 measurements >>> # Track 0 has high likelihood for meas 0 >>> # Track 1 has high likelihood for meas 1 >>> likelihood = np.array([[0.9, 0.1], ... [0.1, 0.8]]) >>> gated = np.array([[True, True], ... [True, True]]) >>> beta = jpda_probabilities(likelihood, gated, detection_prob=0.9) >>> beta.shape # 2 tracks, 3 columns (2 meas + 1 miss) (2, 3) >>> # Track 0 most likely associated with measurement 0 >>> np.argmax(beta[0, :2]) 0 """ n_tracks, n_meas = likelihood_matrix.shape # Initialize beta matrix (last column is for "no measurement") beta = np.zeros((n_tracks, n_meas + 1)) if n_meas == 0: # No measurements - all tracks have no association beta[:, 0] = 1.0 elif n_tracks == 0: pass elif n_tracks <= 5 and n_meas <= 5: # For small problems, use exact enumeration beta = _jpda_exact(likelihood_matrix, gated, detection_prob, clutter_density) else: # For larger problems, use approximate method beta = _jpda_approximate( likelihood_matrix, gated, detection_prob, clutter_density ) if diagnostics_enabled(): top_marginals = [ (i, int(np.argmax(beta[i])), float(np.max(beta[i]))) for i in range(n_tracks) ] logger.bind(site="association", algo="jpda").debug( "jpda: {} track(s), top marginal per track: {}", n_tracks, "; ".join( f"track{i} -> {'miss' if j == n_meas else f'm{j}'} p={p:.3f}" for i, j, p in top_marginals ), ) return beta
def _jpda_exact( likelihood_matrix: NDArray[Any], gated: NDArray[Any], detection_prob: float, clutter_density: float, ) -> NDArray[Any]: """ Exact JPDA computation via hypothesis enumeration. This is exponential in complexity but exact for small problems. """ n_tracks, n_meas = likelihood_matrix.shape beta = np.zeros((n_tracks, n_meas + 1)) # Generate all valid joint association hypotheses # A hypothesis is a mapping from measurements to tracks (or clutter) # measurement j -> track assignment[j] where -1 means clutter # We enumerate by considering all possible measurement-to-track assignments # where each measurement can go to at most one track that gates it def generate_hypotheses( meas_idx: int, current_assignment: List[int], used_tracks: set[Any], ) -> Any: """Recursively generate valid hypotheses.""" if meas_idx == n_meas: yield current_assignment.copy() return # Option 1: measurement is clutter current_assignment.append(-1) yield from generate_hypotheses(meas_idx + 1, current_assignment, used_tracks) current_assignment.pop() # Option 2: measurement is from a track (if gated and track not used) for track_idx in range(n_tracks): if gated[track_idx, meas_idx] and track_idx not in used_tracks: current_assignment.append(track_idx) used_tracks.add(track_idx) yield from generate_hypotheses( meas_idx + 1, current_assignment, used_tracks ) used_tracks.remove(track_idx) current_assignment.pop() # Compute hypothesis probabilities hypothesis_probs = [] hypothesis_assignments = [] for assignment in generate_hypotheses(0, [], set[Any]()): # Compute probability of this hypothesis prob = 1.0 detected_tracks = set[Any]() for j, track_idx in enumerate(assignment): if track_idx == -1: # Measurement j is clutter prob *= clutter_density else: # Measurement j is from track track_idx prob *= likelihood_matrix[track_idx, j] detected_tracks.add(track_idx) # Account for non-detected tracks for i in range(n_tracks): if i in detected_tracks: prob *= detection_prob else: prob *= 1.0 - detection_prob hypothesis_probs.append(prob) hypothesis_assignments.append(assignment) # Normalize total_prob = sum(hypothesis_probs) if total_prob > 0: hypothesis_probs = [p / total_prob for p in hypothesis_probs] # Compute marginal association probabilities for h_idx, (assignment, prob) in enumerate( zip(hypothesis_assignments, hypothesis_probs) ): detected_tracks = set[Any]() for j, track_idx in enumerate(assignment): if track_idx >= 0: beta[track_idx, j] += prob detected_tracks.add(track_idx) # Tracks with no measurement for i in range(n_tracks): if i not in detected_tracks: beta[i, n_meas] += prob return beta @njit(cache=True) def _jpda_approximate_core( likelihood_matrix: np.ndarray[Any, Any], gated: np.ndarray[Any, Any], detection_prob: float, clutter_density: float, ) -> np.ndarray[Any, Any]: """JIT-compiled core of approximate JPDA computation.""" n_tracks = likelihood_matrix.shape[0] n_meas = likelihood_matrix.shape[1] beta = np.zeros((n_tracks, n_meas + 1), dtype=np.float64) # Likelihood ratio for each measurement given each track L = np.zeros((n_tracks, n_meas), dtype=np.float64) for i in range(n_tracks): for j in range(n_meas): if gated[i, j] and clutter_density > 0: L[i, j] = likelihood_matrix[i, j] / clutter_density elif gated[i, j]: L[i, j] = likelihood_matrix[i, j] * 1e10 # Compute delta factors (accounts for other tracks) delta = np.ones((n_tracks, n_meas), dtype=np.float64) for j in range(n_meas): sum_L = 0.0 for i in range(n_tracks): sum_L += L[i, j] for i in range(n_tracks): if sum_L > 0: delta[i, j] = 1.0 / (1.0 + sum_L - L[i, j]) else: delta[i, j] = 1.0 # Compute association probabilities for i in range(n_tracks): denom = 1.0 - detection_prob for j in range(n_meas): if gated[i, j]: beta[i, j] = detection_prob * L[i, j] * delta[i, j] denom += beta[i, j] if denom > 0: for j in range(n_meas): beta[i, j] /= denom beta[i, n_meas] = (1.0 - detection_prob) / denom else: beta[i, n_meas] = 1.0 return beta def _jpda_approximate( likelihood_matrix: NDArray[Any], gated: NDArray[Any], detection_prob: float, clutter_density: float, ) -> NDArray[Any]: """ Approximate JPDA using parametric approach. Uses the approach from [1] which is O(n_tracks * n_meas^2). References ---------- - Fitzgerald, R.J., "Development of Practical PDA Logic for Multitarget Tracking by Microprocessor", American Control Conference, 1986. """ return _jpda_approximate_core( likelihood_matrix.astype(np.float64), gated.astype(np.bool_), detection_prob, clutter_density, )
[docs] def jpda_update( track_states: List[ArrayLike], track_covariances: List[ArrayLike], measurements: ArrayLike, H: ArrayLike, R: ArrayLike, detection_prob: float = 0.9, clutter_density: float = 1e-6, gate_probability: float = 0.99, ) -> JPDAUpdate: """ Perform JPDA-based track update. Parameters ---------- track_states : list of array_like Predicted state estimates for each track. track_covariances : list of array_like Predicted covariances for each track. measurements : array_like Measurements, shape (n_meas, m). H : array_like Measurement matrix, shape (m, n). R : array_like Measurement noise covariance, shape (m, m). detection_prob : float Probability of detection (Pd). Default 0.9. clutter_density : float Spatial density of clutter. Default 1e-6. gate_probability : float Probability for chi-squared gate. Default 0.99. Returns ------- result : JPDAUpdate Updated states and covariances with association probabilities. Examples -------- >>> import numpy as np >>> # Two tracks, three measurements >>> x1 = np.array([0., 1.]) # [position, velocity] >>> x2 = np.array([5., -1.]) >>> P = np.eye(2) * 0.1 >>> measurements = np.array([[0.1], [5.2], [10.0]]) >>> H = np.array([[1., 0.]]) # Measure position >>> R = np.array([[0.1]]) >>> result = jpda_update([x1, x2], [P, P], measurements, H, R) >>> len(result.states) 2 Notes ----- The JPDA update consists of: 1. Compute measurement likelihoods and gating 2. Compute association probabilities 3. Compute combined innovations for each track 4. Update each track with weighted innovation 5. Compute covariance with spread of means term References ---------- - Bar-Shalom, Y. and Fortmann, T.E., "Tracking and Data Association", Academic Press, 1988. """ # Convert inputs track_states = [np.asarray(x, dtype=np.float64).flatten() for x in track_states] track_covariances = [np.asarray(P, dtype=np.float64) for P in track_covariances] measurements = np.asarray(measurements, dtype=np.float64) H = np.asarray(H, dtype=np.float64) R = np.asarray(R, dtype=np.float64) n_tracks = len(track_states) n_meas = len(measurements) m = H.shape[0] # Compute gate threshold gate_threshold = chi2.ppf(gate_probability, df=m) # Compute likelihood matrix and gating. # Pass detection_prob=1.0 so the likelihoods are pure Gaussian densities; # jpda_probabilities applies Pd itself (passing Pd here would count it twice). likelihood_matrix, gated = compute_likelihood_matrix( track_states, track_covariances, measurements, H, R, 1.0, gate_threshold, ) # Compute association probabilities beta = jpda_probabilities(likelihood_matrix, gated, detection_prob, clutter_density) # Update each track updated_states = [] updated_covs = [] innovations = [] for i in range(n_tracks): x = track_states[i] P = track_covariances[i] # Innovation covariance S = H @ P @ H.T + R # Kalman gain K = P @ H.T @ np.linalg.inv(S) # Predicted measurement z_pred = H @ x # Compute combined innovation (weighted sum of innovations) combined_innovation = np.zeros(m) for j in range(n_meas): if gated[i, j]: innovation_j = measurements[j] - z_pred combined_innovation += beta[i, j] * innovation_j innovations.append(combined_innovation) # Updated state x_upd = x + K @ combined_innovation # Updated covariance with spread of means term # P_upd = beta_0 * P + (1 - beta_0) * P_c + P_spread # where P_c = P - K @ S @ K.T (covariance after update) # and P_spread = K @ E[y*y'] @ K.T - K @ E[y] @ E[y]' @ K.T beta_0 = beta[i, n_meas] # Probability of no measurement # Covariance reduction from update P_c = P - K @ S @ K.T # Spread of innovations (Bar-Shalom): # P_spread = K (sum_j beta_j y_j y_j^T - y_comb y_comb^T) K^T y_outer_sum = np.zeros((m, m)) for j in range(n_meas): if gated[i, j]: innovation_j = measurements[j] - z_pred y_outer_sum += beta[i, j] * np.outer(innovation_j, innovation_j) y_outer_sum -= np.outer(combined_innovation, combined_innovation) P_spread = K @ y_outer_sum @ K.T # Combined covariance P_upd = beta_0 * P + (1 - beta_0) * P_c + P_spread # Ensure symmetry P_upd = (P_upd + P_upd.T) / 2 updated_states.append(x_upd) updated_covs.append(P_upd) return JPDAUpdate( states=updated_states, covariances=updated_covs, association_probs=beta, innovations=innovations, )
[docs] def jpda( track_states: List[ArrayLike], track_covariances: List[ArrayLike], measurements: ArrayLike, H: ArrayLike, R: ArrayLike, detection_prob: float = 0.9, clutter_density: float = 1e-6, gate_probability: float = 0.99, ) -> JPDAResult: """ Compute JPDA association probabilities. This is a convenience function that computes association probabilities without performing the state update. Parameters ---------- track_states : list of array_like Predicted state estimates for each track. track_covariances : list of array_like Predicted covariances for each track. measurements : array_like Measurements, shape (n_meas, m). H : array_like Measurement matrix, shape (m, n). R : array_like Measurement noise covariance, shape (m, m). detection_prob : float Probability of detection. Default 0.9. clutter_density : float Spatial density of clutter. Default 1e-6. gate_probability : float Probability for chi-squared gate. Default 0.99. Returns ------- result : JPDAResult Association probabilities and related information. Examples -------- Compute association probabilities for 2 tracks and 3 measurements: >>> import numpy as np >>> # Two tracks with [x, vx] state >>> states = [np.array([0.0, 1.0]), np.array([10.0, -0.5])] >>> covariances = [np.eye(2) * 0.5, np.eye(2) * 0.5] >>> # Three position measurements >>> measurements = np.array([[0.1], [9.8], [5.0]]) >>> H = np.array([[1, 0]]) # measure position only >>> R = np.array([[0.1]]) >>> result = jpda(states, covariances, measurements, H, R) >>> result.association_probs.shape # (2 tracks, 4 columns: 3 meas + miss) (2, 4) >>> # Track 0 should have high prob for measurement 0 >>> result.association_probs[0, 0] > 0.5 True See Also -------- jpda_update : JPDA with state update. """ # Convert inputs track_states = [np.asarray(x, dtype=np.float64).flatten() for x in track_states] track_covariances = [np.asarray(P, dtype=np.float64) for P in track_covariances] measurements = np.asarray(measurements, dtype=np.float64) H = np.asarray(H, dtype=np.float64) R = np.asarray(R, dtype=np.float64) m = H.shape[0] # Compute gate threshold gate_threshold = chi2.ppf(gate_probability, df=m) # Compute likelihood matrix and gating. # Pass detection_prob=1.0 so the likelihoods are pure Gaussian densities; # jpda_probabilities applies Pd itself (passing Pd here would count it twice). likelihood_matrix, gated = compute_likelihood_matrix( track_states, track_covariances, measurements, H, R, 1.0, gate_threshold, ) # Compute association probabilities beta = jpda_probabilities(likelihood_matrix, gated, detection_prob, clutter_density) # Compute marginal probabilities for each track n_tracks = len(track_states) n_meas = len(measurements) marginal_probs = [] for i in range(n_tracks): marginal_probs.append(beta[i, :n_meas].copy()) return JPDAResult( association_probs=beta, marginal_probs=marginal_probs, likelihood_matrix=likelihood_matrix, gated=gated, )
__all__ = [ "JPDAResult", "JPDAUpdate", "compute_measurement_likelihood", "compute_likelihood_matrix", "jpda_probabilities", "jpda_update", "jpda", ]