pygnss 2.1.2__cp314-cp314t-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,484 @@
1
+ """pygnss.filter.particle
2
+ =================================
3
+
4
+ Lightweight particle filtering utilities for non-linear / non-Gaussian
5
+ state estimation.
6
+
7
+ This module provides:
8
+
9
+ - ``Particles``: a minimal container for particle states and weights.
10
+ - ``multinomial_resample``: a simple inverse-CDF resampler that returns
11
+ equally weighted copies of drawn particles.
12
+ - ``WeightEstimatorInterface`` / ``WeightEstimatorGaussian``: pluggable
13
+ likelihood estimators that map pre-fit residuals to non-negative weights.
14
+ - ``Filter``: a straightforward sequential Monte Carlo (particle) filter
15
+ that uses a user-supplied ``Model`` and a ``StateHandler`` to consume
16
+ state estimates.
17
+
18
+ Design notes
19
+ -----------
20
+ - User models must implement the ``Model`` contract (``propagate_state`` and
21
+ ``to_observations``).
22
+ - Weights passed into resampling functions are assumed to be non-negative;
23
+ the filter normalizes weights internally before sampling.
24
+ - The filter applies a post-resample "roughening" step to mitigate sample
25
+ impoverishment. Per-component roughening standard deviations are available
26
+ on the ``Filter`` instance as ``roughening_sigma_pos`` and
27
+ ``roughening_sigma_vel`` (position and velocity components respectively).
28
+
29
+ Examples
30
+ --------
31
+ See ``tests/filter/test_model.py`` for an end-to-end usage example with
32
+ synthetic observations.
33
+ """
34
+
35
+ from abc import ABC, abstractmethod
36
+ import copy
37
+ from dataclasses import dataclass
38
+ import logging
39
+ from typing import Callable, List, Tuple, Optional
40
+ import numpy as np
41
+
42
+ from . import Model, State, StateHandler, FilterInterface
43
+
44
+
45
+ @dataclass
46
+ class Particles:
47
+ """Container holding a set of particles and their associated weights.
48
+
49
+ Parameters
50
+ ----------
51
+ particles
52
+ List of candidate states (particles). Each state must be compatible
53
+ with the ``Model`` used by the filter (e.g., a NumPy array).
54
+ weights
55
+ List or array of non-negative weights, one per particle. In typical
56
+ usage these are normalized to sum to 1.
57
+
58
+ Notes
59
+ -----
60
+ The container is intentionally minimal. Helper methods are provided to
61
+ retrieve common summaries (e.g., the maximum-likelihood particle).
62
+ """
63
+
64
+ particles: List[State]
65
+ weights: List[float]
66
+
67
+ def get_max_likely(self) -> State:
68
+ """Return the particle with the highest weight.
69
+
70
+ Returns
71
+ -------
72
+ State
73
+ The particle corresponding to ``argmax(weights)``.
74
+ """
75
+
76
+ return self.particles[np.argmax(self.weights)]
77
+
78
+ def __len__(self) -> int:
79
+ """Return the number of particles contained."""
80
+
81
+ return len(self.particles)
82
+
83
+
84
+ ResampleFunctionSignature = Callable[
85
+ [List[State], np.ndarray, int], Tuple[List[State], np.ndarray]
86
+ ]
87
+ ParticleCallbackSignature = Callable[[List[State], np.ndarray], None]
88
+
89
+
90
+ def void_particle_callback(_particles: List[State], _weights: np.ndarray) -> None:
91
+ """No-op particle callback.
92
+
93
+ This function implements the ``ParticleCallbackSignature`` and performs no
94
+ action. It is provided as the default callback for ``Filter`` when the
95
+ user does not need any per-epoch particle inspection.
96
+ """
97
+
98
+
99
+ def multinomial_resample(
100
+ particles: List[State], weights: np.ndarray, new_n_particles: int | None = None
101
+ ) -> Tuple[List[State], np.ndarray]:
102
+ """Resample particles via inverse-CDF (multinomial) sampling.
103
+
104
+ The function draws ``new_n_particles`` indices from the discrete
105
+ distribution defined by ``weights`` using sorted uniform thresholds and
106
+ the cumulative distribution function (CDF). Returned particles are deep
107
+ copies of the selected inputs and are assigned equal weights
108
+ ``1/new_n_particles``.
109
+
110
+ Notes
111
+ -----
112
+ - Input weights are normalized internally. If the weight sum is zero a
113
+ uniform fallback distribution is used to avoid division by zero.
114
+ - This implementation is simple and clear; for large numbers of
115
+ particles consider using ``numpy.random.choice`` with the
116
+ ``p=weights`` argument for optimized sampling.
117
+
118
+ Parameters
119
+ ----------
120
+ particles : list[State]
121
+ Candidate particle states.
122
+ weights : numpy.ndarray
123
+ Non-negative weights matching ``particles``. They will be
124
+ normalized before sampling.
125
+ new_n_particles : int, optional
126
+ Number of particles to draw. Defaults to ``len(particles)``.
127
+
128
+ Returns
129
+ -------
130
+ (list[State], numpy.ndarray)
131
+ Tuple containing the resampled list of particle states (deep copies)
132
+ and an array of equal weights summing to 1.
133
+ """
134
+
135
+ # Normalize weights defensively and build CDF
136
+ w = np.asarray(weights, dtype=float)
137
+ w_sum = float(w.sum())
138
+ if w_sum <= 0:
139
+ # Avoid division by zero; fall back to uniform
140
+ w = np.full_like(w, 1.0 / len(w), dtype=float)
141
+ else:
142
+ w = w / w_sum
143
+
144
+ cumulative_distribution = np.cumsum(w)
145
+
146
+ if new_n_particles is None:
147
+ new_n_particles = len(particles)
148
+
149
+ q = np.sort(np.random.uniform(size=new_n_particles))
150
+
151
+ particle_indices = [int(np.argmax(cumulative_distribution > th)) for th in q]
152
+
153
+ new_particles = [copy.deepcopy(particles[i]) for i in particle_indices]
154
+
155
+ # New weights are set equal to avoid Degenerate particles after resampling
156
+ # As explained in Section 4.2.1 of https://pmc.ncbi.nlm.nih.gov/articles/PMC7826670/pdf/sensors-21-00438.pdf
157
+ new_weights = np.full(new_n_particles, 1.0 / new_n_particles, dtype=float)
158
+
159
+ return new_particles, new_weights
160
+
161
+
162
+ class WeightEstimatorInterface(ABC):
163
+ """
164
+ Interface for computing particle weights (likelihoods).
165
+
166
+ Concrete implementations should map a vector of pre-fit residuals to a
167
+ non-negative weight (likelihood). Larger values indicate more plausible
168
+ particles given the observations.
169
+ """
170
+
171
+ @abstractmethod
172
+ def compute(self, prefits: np.ndarray, **kwargs) -> float:
173
+ """Compute a non-negative likelihood weight from pre-fit residuals.
174
+
175
+ Parameters
176
+ ----------
177
+ prefits : numpy.ndarray
178
+ Array of pre-fit residuals (measured - modelled observations)
179
+ for the candidate particle.
180
+ **kwargs : dict
181
+ Optional algorithm-specific parameters (for example measurement
182
+ noise statistics) that concrete implementations may accept.
183
+
184
+ Returns
185
+ -------
186
+ float
187
+ A non-negative scalar proportional to the particle's likelihood.
188
+ The filter will normalize these weights across particles before
189
+ resampling.
190
+ """
191
+
192
+
193
+ class WeightEstimatorGaussian(WeightEstimatorInterface):
194
+ """
195
+ Gaussian likelihood weight estimator.
196
+
197
+ The weight is computed as the product of per-measurement Gaussian
198
+ likelihoods assuming i.i.d. errors. A crude bias estimate (the mean
199
+ of the pre-fits) is removed before evaluating the likelihood.
200
+
201
+ Notes
202
+ -----
203
+ The resulting product can suffer from numerical underflow for long
204
+ measurement vectors. In practical applications a log-likelihood sum
205
+ is preferred; this implementation trades numerical robustness for
206
+ simplicity.
207
+ """
208
+
209
+ def __init__(self):
210
+ pass
211
+
212
+ def compute(self, prefits: np.ndarray, **kwargs) -> float:
213
+ r"""Compute a Gaussian product likelihood weight.
214
+
215
+ Parameters
216
+ ----------
217
+ prefits : numpy.ndarray
218
+ Vector of pre-fit residuals. A constant bias term is estimated
219
+ and removed before computing the likelihood.
220
+
221
+ Returns
222
+ -------
223
+ float
224
+ Product of per-measurement Gaussian likelihoods.
225
+
226
+ Math
227
+ ----
228
+ The weight corresponds to
229
+
230
+ .. math::
231
+ w = \prod_{i=1}^{N} \frac{1}{\sigma \sqrt{2\pi}} \exp\left(-\tfrac{1}{2}\left(\tfrac{x_i-\mu}{\sigma}\right)^2\right)
232
+
233
+ where :math:`x_i` are the bias-corrected pre-fits and :math:`\mu,\sigma`
234
+ are respectively the sample mean and standard deviation of the original
235
+ pre-fits.
236
+ """
237
+
238
+ # Rough estimation of the hardware bias to remove it from the measurements
239
+ bias = np.average(prefits)
240
+ std = np.std(prefits)
241
+
242
+ # Use Gaussian PDF with mean=bias and sigma=std
243
+ likelihoods = gaussian(prefits, bias, std)
244
+
245
+ weight = np.prod(likelihoods)
246
+
247
+ return weight
248
+
249
+
250
+ class Filter(FilterInterface):
251
+ """Simple particle filter (sequential Monte Carlo).
252
+
253
+ The ``Filter`` implements a basic particle filter algorithm that relies on
254
+ a user-supplied ``Model`` to propagate states and synthesize expected
255
+ observations, a ``WeightEstimatorInterface`` to convert residuals into
256
+ likelihood weights, and a ``StateHandler`` to consume the selected state
257
+ estimate at each epoch.
258
+
259
+ The class performs the standard steps each epoch: propagate particles,
260
+ compute weights from pre-fit residuals, normalize weights, optionally
261
+ resample, apply a configurable post-resample roughening noise, and
262
+ forward the chosen state to the handler.
263
+
264
+ The constructor accepts several optional hooks to customize resampling
265
+ and roughening behavior; see ``__init__`` for parameter details.
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ initial_states: List[State],
271
+ weight_estimator: WeightEstimatorInterface,
272
+ model: Model,
273
+ state_handler: StateHandler,
274
+ resample_threshold: Optional[float] = None,
275
+ resample_function: ResampleFunctionSignature = multinomial_resample,
276
+ particle_callback: ParticleCallbackSignature = void_particle_callback,
277
+ roughening_std: Optional[List[float]] = None,
278
+ logger: Optional[logging.Logger] = None,
279
+ ):
280
+ """Create a Filter instance.
281
+
282
+ Parameters
283
+ ----------
284
+ initial_states : list[State] or numpy.ndarray
285
+ Initial particle set. Can be a list of state arrays or an ndarray
286
+ with shape (N, state_dim). The constructor will convert an
287
+ ndarray into a list of per-particle arrays.
288
+ weight_estimator : WeightEstimatorInterface or type
289
+ Either an instance implementing ``WeightEstimatorInterface`` or
290
+ the class (e.g., ``WeightEstimatorGaussian``). If a class is
291
+ provided the ctor will instantiate it.
292
+ model : Model
293
+ System model providing ``propagate_state`` and ``to_observations``.
294
+ state_handler : StateHandler
295
+ Consumer of the selected state estimate via
296
+ ``state_handler.process_state``.
297
+ resample_threshold : float | None, optional
298
+ If provided, resampling will be triggered when the effective
299
+ sample size falls below ``resample_threshold * n_particles``.
300
+ If ``None`` (default) resampling is performed every epoch.
301
+ resample_function : callable, optional
302
+ Resampling function implementing the ``ResampleFunctionSignature``.
303
+ Defaults to ``multinomial_resample``.
304
+ particle_callback : callable, optional
305
+ Callback invoked after weight normalization with signature
306
+ ``(particles, weights)`` for visualization or diagnostics.
307
+ roughening_std : list[float] | None, optional
308
+ If provided, a per-state-dimension standard-deviation vector used
309
+ to add zero-mean Gaussian noise to particles after resampling.
310
+ Its length must match the state dimension. If ``None`` no
311
+ roughening is applied.
312
+ logger : logging.Logger | None, optional
313
+ Logger for debug/tracing messages. If ``None`` the module logger
314
+ is used.
315
+
316
+ Notes
317
+ -----
318
+ The constructor accepts either a ``WeightEstimator`` instance or the
319
+ class and will instantiate the latter for backward compatibility.
320
+ """
321
+
322
+ self.particles = initial_states
323
+
324
+ self.weight_estimator = weight_estimator
325
+ self.model = model
326
+ self.state_handler = state_handler
327
+ self._resample_threshold = resample_threshold
328
+ self._resample: ResampleFunctionSignature = resample_function
329
+ self._particle_callback: ParticleCallbackSignature = particle_callback
330
+ self._roughening_std = roughening_std
331
+ self.logger = logger or logging.getLogger(__name__)
332
+
333
+ # Initial set of weights for the particles. Assuming equally weighted
334
+ n_particles = len(self.particles)
335
+ self.weights = np.array([1.0 / n_particles] * n_particles)
336
+
337
+ # Check that the roughening std is compatible with the state dimension
338
+ if self._roughening_std is not None:
339
+ state_dim = len(self.particles[0])
340
+ if len(self._roughening_std) != state_dim:
341
+ raise ValueError(
342
+ f"Roughening std length {len(self._roughening_std)} does not match "
343
+ f"state dimension {state_dim}."
344
+ )
345
+
346
+ def process(self, y_k: np.ndarray, R: np.ndarray, **kwargs):
347
+ """Process one epoch: propagate, weight, resample, roughen, and report.
348
+
349
+ Performs the sequential Monte Carlo steps for a single observation
350
+ epoch:
351
+
352
+ 1. Propagate particles using ``model.propagate_state`` (time update).
353
+ 2. Compute pre-fit residuals and map them to weights using
354
+ ``weight_estimator.compute``.
355
+ 3. Normalize weights and invoke ``particle_callback`` for monitoring
356
+ or visualization.
357
+ 4. Resample particles when needed and apply post-resample roughening
358
+ noise (if configured) to mitigate sample impoverishment.
359
+ 5. Compute the selected state estimate and forward it to
360
+ ``state_handler.process_state`` along with diagnostics.
361
+
362
+ Parameters
363
+ ----------
364
+ y_k : numpy.ndarray
365
+ Observation vector at the current epoch.
366
+ R : numpy.ndarray
367
+ Measurement noise covariance matrix (forwarded via ``**kwargs``
368
+ to estimators that may need it).
369
+ **kwargs : dict
370
+ Additional keyword arguments forwarded to ``model.to_observations``
371
+ and to ``weight_estimator.compute``.
372
+
373
+ Notes
374
+ -----
375
+ The method updates ``self.particles`` and ``self.weights`` in-place
376
+ (resampling replaces the particle set). The selected state that is
377
+ passed to ``state_handler`` is produced by ``_get_solution`` (by
378
+ default the maximum-weight particle).
379
+ """
380
+
381
+ # Time update ----------------------------------------------------------
382
+ particles = [
383
+ self.model.propagate_state(particle) for particle in self.particles
384
+ ]
385
+
386
+ for i, particle in enumerate(particles):
387
+ prefits = y_k - self.model.to_observations(particle).y_m
388
+ self.weights[i] = self.weight_estimator.compute(prefits, **kwargs)
389
+
390
+ # Normalize the weights
391
+ self.weights = self.weights / sum(self.weights)
392
+
393
+ self._particle_callback(particles, self.weights, **kwargs)
394
+
395
+ # Commit the propagated particle set before resampling
396
+ # Resampling only if the effective number of particles is low (Algorithm 3 of
397
+ # https://pmc.ncbi.nlm.nih.gov/articles/PMC7826670/pdf/sensors-21-00438.pdf)
398
+ if self._needs_resample(self.weights):
399
+ self.particles, self.weights = self._resample(
400
+ particles, self.weights, len(particles)
401
+ )
402
+
403
+ # Perform particle "roughening" if need be
404
+ n_particles = len(self.particles)
405
+ noise = np.zeros((n_particles, len(self.particles[0])))
406
+
407
+ if self._roughening_std is not None:
408
+ for dim in range(len(self.particles[0])):
409
+ noise[:, dim] = np.random.normal(
410
+ 0.0, self._roughening_std[dim], size=(n_particles,)
411
+ )
412
+
413
+ self.particles = self.particles + noise
414
+
415
+ # Compute postfit residuals
416
+ x = self._get_solution()
417
+
418
+ r = y_k - self.model.to_observations(x, **kwargs).y_m
419
+
420
+ self.state_handler.process_state(x, np.eye(len(x)), postfits=r, **kwargs)
421
+
422
+ def _needs_resample(self, weights: np.ndarray) -> bool:
423
+ """Determine if resampling is needed based on the effective number of particles.
424
+
425
+ Parameters
426
+ ----------
427
+ weights : numpy.ndarray
428
+ Array of particle weights.
429
+ threshold : float
430
+ Resampling threshold as a fraction of the total number of particles.
431
+ Default is 0.25.
432
+ Returns
433
+ -------
434
+ bool
435
+ True if resampling is needed, False otherwise.
436
+ """
437
+
438
+ if self._resample_threshold is None:
439
+ return True
440
+
441
+ else:
442
+ n_particles = len(weights)
443
+ effective_n = 1.0 / np.sum(np.square(weights))
444
+ return effective_n < self._resample_threshold * n_particles
445
+
446
+ def _get_solution(self) -> State:
447
+ """Return the current state estimate from the particle set.
448
+
449
+ The default implementation returns the maximum-weight particle.
450
+ Alternative selection strategies can be implemented if needed.
451
+
452
+ Returns
453
+ -------
454
+ State
455
+ The selected state estimate.
456
+ """
457
+
458
+ return self.particles[np.argmax(self.weights)]
459
+
460
+
461
+ def gaussian(x, mu, sigma):
462
+ """
463
+ Evaluates a Gaussian function (normal distribution).
464
+
465
+ :param x: The input value(s) (scalar or NumPy array).
466
+ :type x: float or numpy.ndarray
467
+ :param mu: The mean (center) of the Gaussian.
468
+ :type mu: float
469
+ :param sigma: The standard deviation (width) of the Gaussian.
470
+ :type sigma: float
471
+ :return: The value(s) of the Gaussian function at x.
472
+ :rtype: float or numpy.ndarray
473
+
474
+ Example
475
+ -------
476
+ >>> import numpy as np
477
+ >>> mean = 0
478
+ >>> std_dev = 1
479
+ >>> x_values = np.linspace(-3, 3, 100)
480
+ >>> y_values = gaussian(x_values, mean, std_dev)
481
+ >>> print(y_values[0:5])
482
+ [0.00443185 0.00530579 0.00632878 0.00752133 0.00890582]
483
+ """
484
+ return (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)