Matched filter¶
Hyperspectral matched-filter detection pipeline. RTM stack.
The package resolves its public names lazily (PEP 562), so they are documented here from the submodules that define them.
matched_filter
¶
Matched filter for hyperspectral methane retrieval.
The matched filter (MF) is the maximum-SNR linear detector for a known target
signature t(ν) in additive Gaussian noise with covariance Σ. It is
also the one-CG-iteration limit of the dual / PSAS 3D-Var solver with a
flat (identity) prior — see notebooks/matched_filter/00_mf_derivation.md.
Score formula (per pixel)::
y(x) = (x − μ)ᵀ Σ⁻¹ t / (tᵀ Σ⁻¹ t)
where x is the observed spectrum, μ is the background mean, and t
is the target signature — typically the radiance Jacobian ∂L/∂VMR from
the Beer–Lambert forward model at the background state.
Architecture¶
- Estimation (
background,cluster,streaming) — statistical estimation uses scikit-learn (EmpiricalCovariance,LedoitWolf,TruncatedSVD,GaussianMixture). Not differentiable — that's fine, MF doesn't need it. - Application (
core) — the per-pixel score evaluation runs in JAX and dispatches on gaussx operator structure. Covariance estimators return a :class:lineax.AbstractLinearOperator(dense, low-rank + Tikhonov via :class:gaussx.LowRankUpdate, etc.) so the kernel is single-implementation. - Target signatures (
target) — derived from an existing :class:plumax.assimilation.RadianceObservationModelvia :func:jax.jvp(directional derivatives) or a finite-amplitude secant. No duplicate Beer–Lambert code. - I/O (
io) — xarray wrappers for multi-scene workflows.
core¶
Matched-filter kernel — one implementation for any covariance structure.
The classical matched-filter score is
.. math:: y(x) = \frac{(x - \mu)^\top \Sigma^{-1} t}{t^\top \Sigma^{-1} t}
with the special case :math:y(\mu + \alpha t) = \alpha (so y is an
unbiased linear estimator of the plume enhancement amplitude). The per-pixel
evaluation cost is dominated by :math:w = \Sigma^{-1} t, a single solve
that can be precomputed once per scene; after that each pixel is a dot
product of length n_bands.
This module wraps that into a single kernel that works for any
:class:lineax.AbstractLinearOperator covariance — dense, diagonal, low-rank
+ Tikhonov (Woodbury via :class:gaussx.LowRankUpdate), or Kronecker. The
solve is dispatched via :func:gaussx.solve, so the MF caller never sees the
structure.
validate_mf_inputs
¶
Public fail-fast check for (Σ, t) compatibility.
Runs the same tᵀ Σ⁻¹ t > 0 test that :func:apply_pixel and
:func:apply_image perform automatically, but without the rest of the
MF work — useful for catching user errors before jitting a pipeline
or batching over many pixels.
Raises¶
ValueError
If target is all zero, or if the target energy tᵀ Σ⁻¹ t is
not strictly positive (singular or indefinite covariance along the
target direction).
Source code in src/plumax/matched_filter/core.py
apply_pixel
¶
apply_pixel(pixel: Float[Array, B], mean: Float[Array, B], cov_op: LinearOperator, target: Float[Array, B]) -> Float[Array, '']
Matched-filter score for a single pixel spectrum.
Parameters¶
pixel
Observed radiance spectrum at one pixel, shape (n_bands,).
mean
Background mean spectrum, shape (n_bands,).
cov_op
Background covariance as a :class:lineax.AbstractLinearOperator. Any
structure is fine — :func:gaussx.solve dispatches on operator type
(Woodbury for :class:gaussx.LowRankUpdate, Cholesky for dense, etc.).
target
Target signature t(ν), shape (n_bands,) — typically the
radiance Jacobian ∂L/∂VMR at the background state.
Returns¶
Float[Array, ""] Scalar score — an unbiased linear estimator of the plume amplitude under the additive-Gaussian-noise model.
Source code in src/plumax/matched_filter/core.py
apply_image
¶
apply_image(cube: Float[Array, 'H W B'], mean: Float[Array, B], cov_op: LinearOperator, target: Float[Array, B]) -> Float[Array, 'H W']
Matched-filter score map for a hyperspectral cube.
Precomputes w = Σ⁻¹ t once, then evaluates
(cube − μ) · w / (tᵀ w) in a single einsum — no per-pixel Python loop.
Parameters¶
cube
Radiance cube with shape (H, W, n_bands).
mean, cov_op, target
See :func:apply_pixel.
Returns¶
Float[Array, "H W"]
Score image. Pixel (i, j) is the estimated plume amplitude at
that location.
Source code in src/plumax/matched_filter/core.py
matched_filter_snr
¶
matched_filter_snr(amplitude: float, cov_op: LinearOperator, target: Float[Array, B]) -> Float[Array, '']
Theoretical SNR of the matched filter at a given plume amplitude.
For x = μ + α t + ε with ε ∼ N(0, Σ), the MF score has
mean = α and variance = 1 / (tᵀ Σ⁻¹ t). So the detection SNR is
.. math:: \text{SNR} = \alpha \sqrt{t^\top \Sigma^{-1} t}.
Parameters¶
amplitude
The plume amplitude α at which to evaluate SNR.
cov_op, target
See :func:apply_pixel.
Returns¶
Float[Array, ""] Scalar SNR (dimensionless).
Source code in src/plumax/matched_filter/core.py
detection_threshold
¶
detection_threshold(false_alarm_rate: float, cov_op: LinearOperator, target: Float[Array, B]) -> Float[Array, '']
Gaussian detection threshold on the MF score for a given FAR.
Under the null (no plume), the score is :math:N(0, 1/(t^\top \Sigma^{-1} t)).
The threshold above which the false-alarm rate does not exceed FAR is
.. math:: \tau = \Phi^{-1}(1 - \text{FAR}) / \sqrt{t^\top \Sigma^{-1} t}.
Parameters¶
false_alarm_rate
Target FAR, in (0, 1) — e.g. 1e-6 for one false alarm per
million pixels under Gaussian assumptions.
cov_op, target
See :func:apply_pixel.
Returns¶
Float[Array, ""]
Threshold in score units (same units as amplitude in
:func:matched_filter_snr).
Source code in src/plumax/matched_filter/core.py
background¶
Background mean and covariance estimators for the matched filter.
Estimation is not on the gradient path — the matched filter treats the
background :math:(\mu, \Sigma) as known inputs. That lets us lean on
scikit-learn's mature estimators (which have careful numerics, shrinkage,
robust variants, etc.) without any JAX/autodiff constraints. The outputs
are wrapped as :mod:lineax / :mod:gaussx linear operators so that
:func:plumax.matched_filter.core.apply_image can use the same
gaussx.solve dispatch path for all covariance flavours:
============================== ====================================================
Estimator Operator returned
============================== ====================================================
estimate_cov_empirical :class:lineax.MatrixLinearOperator (dense)
estimate_cov_shrunk :class:lineax.MatrixLinearOperator (dense, shrunk)
estimate_cov_lowrank :class:gaussx.LowRankUpdate (λI + UDUᵀ)
============================== ====================================================
The low-rank path uses scikit-learn's randomised :class:TruncatedSVD, which
is O(n · k) on a (n_samples, n_bands) matrix and is typically 10–100×
faster than a full SVD for the small k a matched filter needs.
estimate_mean
¶
estimate_mean(cube: Float[Array, 'H W B'] | ndarray, *, method: _RobustMethod = 'mean', trim_proportion: float = 0.1, huber_c: float = 1.345) -> np.ndarray
Estimate the per-band background mean μ from a scene.
Parameters¶
cube
Hyperspectral scene of shape (H, W, n_bands).
method
- 'mean' — arithmetic mean (sensitive to anomalies).
- 'median' — per-band median (very robust).
- 'trimmed' — two-sided trimmed mean (scipy.stats.trim_mean).
- 'huber' — Huber M-estimator (sklearn's HuberRegressor on
a constant design).
trim_proportion
Fraction discarded from each tail for the trimmed mean. Ignored
otherwise.
huber_c
Huber influence threshold in units of the per-band MAD. Ignored
unless method='huber'.
Returns¶
np.ndarray
Mean spectrum of length n_bands.
Source code in src/plumax/matched_filter/background.py
estimate_cov_empirical
¶
estimate_cov_empirical(cube: Float[Array, 'H W B'] | ndarray, mean: ndarray | None = None, *, ridge: float = 0.0, mask: ndarray | None = None) -> LinearOperator
Empirical covariance via :class:sklearn.covariance.EmpiricalCovariance.
Returns a dense :class:lineax.MatrixLinearOperator. Optionally adds a
diagonal ridge for numerical PD-ness when n_samples ≲ n_bands.
mask (boolean exclude-pixels) drops flagged pixels — e.g. a detected
plume — so Σ is estimated from background only, consistent with a robust
masked μ.
Source code in src/plumax/matched_filter/background.py
estimate_cov_shrunk
¶
estimate_cov_shrunk(cube: Float[Array, 'H W B'] | ndarray, mean: ndarray | None = None, *, method: Literal['ledoit_wolf', 'oas'] = 'ledoit_wolf', mask: ndarray | None = None) -> LinearOperator
Shrinkage covariance estimator.
Uses Ledoit–Wolf (method='ledoit_wolf', default) or OAS
('oas') — both shrink the sample covariance toward a scaled identity
and are PD by construction even when n_samples < n_bands. mask
(boolean exclude-pixels) drops flagged pixels before fitting.
Source code in src/plumax/matched_filter/background.py
estimate_cov_lowrank
¶
estimate_cov_lowrank(cube: Float[Array, 'H W B'] | ndarray, mean: ndarray | None = None, *, rank: int, tikhonov: float, random_state: int | Array | None = 0, n_oversamples: int = 10, mask: ndarray | None = None, rank_rtol: float = 1e-10) -> LinearOperator
Low-rank + Tikhonov covariance: Σ = λI + V D Vᵀ.
Uses :func:gaussx.randomized_svd (Halko–Martinsson–Tropp, JAX, 5 power
iterations) to find the top rank spectral directions of
the sample covariance. Returned as a :class:gaussx.LowRankUpdate so that
:func:gaussx.solve routes through the Woodbury identity — the MF
precompute cost is O(n_bands · rank + rank³) instead of
O(n_bands³).
Parameters¶
cube
Hyperspectral scene (H, W, n_bands).
mean
Background mean. If None, subtracts the sample mean.
rank
Number of leading components to keep. Clamped to
min(rank, n_samples - 1, n_bands - 1).
tikhonov
Diagonal floor λ > 0 — ensures strict PD.
random_state
Seed for the randomised SVD: an int (converted with
jax.random.key), a JAX PRNG key, or None (seed 0).
n_oversamples
Extra random directions for the Halko sampling — higher is slower but
better-conditioned.
mask
Boolean exclude-pixels mask; flagged pixels (e.g. a detected plume) are
dropped before the SVD so Σ reflects background only.
rank_rtol
Components whose covariance eigenvalue d_i is below
rank_rtol · max(d) are dropped before building the operator. On a
rank-deficient scene (constant regions, duplicated spectra) those
d_i ≈ 0 would make gaussx's Woodbury capacitance diag(1/d) +
VᵀL⁻¹U blow up to inf/NaN; dropping them keeps every score finite.
Source code in src/plumax/matched_filter/background.py
target¶
Target signature construction from the radiance forward model.
The matched-filter target t(ν) is the spectral signature that a unit
plume enhancement would leave in the observed radiance. Because we already
have a JAX :class:plumax.assimilation.RadianceObservationModel,
we do not reimplement the Beer–Lambert derivation from scratch (1379 lines
in the original snippet). Instead:
- Linear target is a directional derivative
∂L/∂VMRat the background state, obtained by a single :func:jax.jvpcall — robust, cheap, and exactly consistent with the forward model used by the 3D-Var retrieval. - Nonlinear target is the finite-amplitude response
L(x_b + α·dx) − L(x_b), which captures Beer–Lambert saturation for strong plumes. For smallαthis reduces toα · t_linearand the MF score becomes scale-invariant to that choice.
linear_target_from_obs
¶
linear_target_from_obs(obs: RadianceObservationModel, vmr_background: Float[Array, 'H W'], *, pattern: Pattern | Float[Array, 'H W'] = 'uniform', pixel: tuple[int, int] | None = None, linear_forward: bool = False) -> Float[Array, B]
Matched-filter target via the tangent-linear of the forward operator.
Computes
.. math:: t = \frac{\partial}{\partial \alpha} H(x_b + \alpha \, \delta x) \Big|_{\alpha=0}
at a single output pixel. For the default pattern='uniform' this
captures the spectral signature of a uniform VMR enhancement across the
scene (appropriate when the plume fills several PSF footprints). For
pattern='impulse' it captures the instrument's point response to a
single-pixel enhancement (appropriate when the plume is subpixel or
compact compared to the PSF).
Parameters¶
obs
The JAX observation model.
vmr_background
Background VMR field, shape (H, W). Typically
jnp.full((H, W), x_ref) where x_ref == obs.vmr_reference.
pattern
'uniform' (default), 'impulse', or an explicit 2-D perturbation
pattern of the same shape as vmr_background.
pixel
Output pixel (i, j) at which to read the signature. Defaults to
the scene centre, or the impulse location if applicable.
linear_forward
If True, use the linearised forward 1 - Δτ instead of
exp(-Δτ). For small vmr_background the two agree to within
Beer–Lambert curvature.
Returns¶
Float[Array, "B"]
Target signature of length obs.n_bands.
Source code in src/plumax/matched_filter/target.py
nonlinear_target_from_obs
¶
nonlinear_target_from_obs(obs: RadianceObservationModel, vmr_background: Float[Array, 'H W'], *, amplitude: float = 1.0, pattern: Pattern | Float[Array, 'H W'] = 'uniform', pixel: tuple[int, int] | None = None) -> Float[Array, B]
Matched-filter target via a finite-amplitude difference.
Computes t = H(x_b + α·δx) − H(x_b) without linearising Beer–Lambert.
This is the "nonlinear" variant from the original snippets; it captures
the saturation curvature that matters for strong plumes
(α · σ · N · ΔX ≳ 0.1 optical depth).
For small amplitude this reduces to amplitude × linear_target.
Source code in src/plumax/matched_filter/target.py
cluster¶
Cluster-based and spatially-adaptive background estimators.
When a scene is heterogeneous (land/water, bright/dark surfaces, different
land-cover classes) a single global (μ, Σ) smears together statistically
distinct populations and the MF loses power. Two standard fixes:
- GMM clustering — fit a Gaussian mixture on the per-pixel spectra,
label each pixel, and run the MF with the local cluster's
(μ_k, Σ_k). :func:gmm_cluster_backgroundreturns the labels and a list of(mean, cov_operator)pairs, one per cluster. - Adaptive spatial windows — for each pixel, estimate
(μ_{ij}, \sigma^2_{ij})from a local neighbourhood. Only supports a diagonal local covariance (keeps memory atO(H·W·n_bands)) — for full local covariance, prefer the GMM route.
Both use scikit-learn / scipy under the hood; neither is on a JAX gradient path (the MF treats the background as a fixed input).
ClusterBackground
dataclass
¶
Per-cluster background model.
Attributes¶
labels
Cluster assignment per pixel, shape (H, W) with values in
[0, n_clusters).
means
List of per-cluster mean spectra.
cov_operators
List of per-cluster covariance operators (same structure across
clusters — dense from :func:estimate_cov_empirical or
:func:estimate_cov_shrunk).
gmm_cluster_background
¶
gmm_cluster_background(cube: ndarray, *, n_clusters: int, cov_estimator: str = 'ledoit_wolf', random_state: int | None = 0, bayesian: bool = False) -> ClusterBackground
Fit a Gaussian mixture and build per-cluster background models.
Uses :class:sklearn.mixture.GaussianMixture (or
:class:BayesianGaussianMixture when bayesian=True) on the per-pixel
spectra. Each cluster gets its own shrunk covariance estimate so that
clusters with few pixels remain well-conditioned.
Parameters¶
cube
Hyperspectral cube, shape (H, W, n_bands).
n_clusters
Number of mixture components (upper bound for the Bayesian variant).
cov_estimator
'ledoit_wolf' (default), 'oas', or 'empirical'.
random_state
Passed through to sklearn for reproducibility.
bayesian
If True, use Dirichlet-process-style :class:BayesianGaussianMixture,
which prunes unused components automatically.
Returns¶
ClusterBackground
Source code in src/plumax/matched_filter/cluster.py
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | |
adaptive_window_background
¶
adaptive_window_background(cube: ndarray, *, window_size: int, guard_size: int = 0) -> tuple[np.ndarray, np.ndarray]
Per-pixel local mean and per-band variance over a square window.
For each pixel (i, j), estimate μ_{ij} and a diagonal local
covariance diag(σ²_{ij,b}) using a window_size × window_size
uniform window centred on the pixel (reflecting at boundaries).
Full local covariances would be (H · W · n_bands²) — too big for
realistic scenes. For a dense local covariance, cluster with GMM instead.
Parameters¶
cube
Hyperspectral cube, shape (H, W, n_bands).
window_size
Edge length of the averaging window. Must be odd and >= 3.
guard_size
Edge length of a central guard region excluded from each pixel's
window (a guard annulus). Must be odd and 0 <= guard_size <
window_size. The default 0 includes the centre pixel — but then a
plume pixel contaminates its own local background; guard_size=1
excludes just the centre pixel, larger values exclude a bigger core.
Returns¶
local_mean
Per-pixel mean spectrum, shape (H, W, n_bands).
local_variance
Per-pixel per-band variance, shape (H, W, n_bands).
Source code in src/plumax/matched_filter/cluster.py
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | |
streaming¶
Streaming background estimation via Welford's algorithm.
For multi-scene MF workflows (e.g. aggregating background statistics across a
campaign of flights), the full (n_samples, n_bands) stack does not fit in
memory. The parallel Welford update [#welford1962] [#chan1979] maintains a
mean and (scaled) covariance in one pass, merges partial results from
separate chunks exactly, and is numerically stable:
.. math:: n \leftarrow n_A + n_B \delta = \mu_B - \mu_A \mu \leftarrow \mu_A + \delta \cdot n_B / n M_2 \leftarrow M_{2,A} + M_{2,B} + \delta \delta^\top \cdot n_A n_B / n
Covariance is then M_2 / (n - 1).
.. [#welford1962] Welford (1962), Note on a method for calculating corrected sums of squares and products. .. [#chan1979] Chan, Golub & LeVeque (1979), Updating formulae and a pairwise algorithm for computing sample variances.
WelfordAccumulator
dataclass
¶
Online mean and covariance for (n_samples, n_bands) streams.
Use :meth:update to feed one batch at a time; :meth:merge to combine
two accumulators computed on disjoint subsets; :meth:mean and
:meth:covariance to finalise.
Attributes¶
n_bands Number of spectral bands. count Running sample count.
update
¶
Ingest a batch of pixel spectra (shape (n_samples, n_bands)).
Source code in src/plumax/matched_filter/streaming.py
merge
¶
Combine with another accumulator on disjoint data.
Source code in src/plumax/matched_filter/streaming.py
streaming_background
¶
streaming_background(batches: Iterable[ndarray], *, n_bands: int, ridge: float = 0.0, ddof: int = 0) -> tuple[np.ndarray, LinearOperator]
Streamed (μ, Σ) from an iterable of batches.
Convenience wrapper for the common pattern of looping over scenes/tiles.
Returns the final mean and a dense lineax operator for the covariance —
pass the covariance through :func:~plumax.matched_filter.core.apply_image
exactly like any other cov_op.
Parameters¶
batches
Iterable yielding arrays of shape (n_samples, n_bands) or
(H, W, n_bands).
n_bands
Number of spectral bands (declared up front since the iterable can be
lazy).
ridge
Optional diagonal ridge added to the final covariance.
ddof
Delta degrees of freedom for the covariance denominator:
M_2 / (n - ddof). Defaults to 0 (MLE, matches sklearn's
EmpiricalCovariance, LedoitWolf, OAS and our
:func:estimate_cov_lowrank), so
:func:~plumax.matched_filter.core.matched_filter_snr and
:func:~plumax.matched_filter.core.detection_threshold
give the same scaling whether the covariance came from a streaming
pass or a batch estimator. Set ddof=1 to match np.cov /
sample-covariance conventions instead.
Source code in src/plumax/matched_filter/streaming.py
io¶
xarray glue: apply the matched filter to DataArrays and multi-file stores.
The MF kernel in :mod:.core is pure JAX and expects plain arrays. For real
data workflows the scene is usually an :class:xarray.DataArray with named
dims (y, x, band), coordinates, and possibly lazy Dask backing.
These helpers thread the xarray metadata through while the compute stays in
JAX:
- :func:
apply_image_xarray— wraps :func:apply_imagein :func:xarray.apply_ufuncso band-last DataArrays produce a score DataArray with(y, x)dims and preserved spatial coordinates. Dask-backed inputs are evaluated lazily with chunk-wise parallelism. - :func:
open_multi_scene— iterate over a sequence of scene files with :func:xarray.open_dataset, yielding(n_samples, n_bands)numpy batches withband_dimmoved to the last axis. Each dataset is closed as soon as its batch has been yielded (or when the generator is stopped early), so file handles do not leak even when iteration is partial. Feeds naturally into :class:~plumax.matched_filter.streaming.WelfordAccumulator.
apply_image_xarray
¶
apply_image_xarray(cube: DataArray, mean: ndarray | Float[Array, B], cov_op: LinearOperator, target: ndarray | Float[Array, B], *, band_dim: str = 'band', dask: str = 'parallelized') -> xr.DataArray
Apply the matched filter to an xarray DataArray.
Parameters¶
cube
Radiance cube with a band-last convention (one dim named band_dim).
Other dims are preserved on the output — typically y, x but any
additional dims (e.g. time) pass through unchanged.
mean, cov_op, target
Same semantics as :func:plumax.matched_filter.core.apply_image.
band_dim
Name of the spectral dimension. Default 'band'.
dask
Forwarded to :func:xarray.apply_ufunc:
'parallelized' (default — chunk-wise) or 'allowed'. When
'parallelized' is used, the spectral axis is transparently
rechunked into a single chunk per block (allow_rechunk=True)
because the MF kernel takes the full spectrum at each pixel; this
lets common zarr/netCDF files with a chunked spectral axis flow
through without manual rechunking.
Returns¶
xr.DataArray
Score DataArray with band_dim collapsed.
Source code in src/plumax/matched_filter/io.py
open_multi_scene
¶
open_multi_scene(paths: str | Iterable[str], *, band_dim: str = 'band', variable: str | None = None, **open_kwargs) -> Iterator[np.ndarray]
Yield (n_pixels, n_bands) batches from a sequence of scene files.
Iterates one file at a time with :func:xarray.open_dataset, closes each
dataset as soon as its scene has been yielded (try/finally-bracketed
so handles are released even if the generator is stopped early), and
yields a flattened 2-D numpy batch suitable for
:class:~plumax.matched_filter.streaming.WelfordAccumulator.update.
Note¶
Each yielded batch is a materialised numpy array — the current scene's
values are loaded eagerly. This is fine for scenes that individually fit
in memory (the Welford aggregator's usual operating regime); for truly
out-of-core per-scene processing, use the lazy DataArray directly with
:func:apply_image_xarray (which supports Dask chunks) instead of this
helper.
Parameters¶
paths
A glob string (e.g. "scenes/*.nc") or an iterable of paths.
band_dim
Name of the spectral dimension on the stored DataArray. Does not
have to be the last axis in the file — each scene is transposed so
band_dim becomes the last axis before flattening.
variable
If a file contains multiple variables, select this one. If None,
uses the first data variable.
**open_kwargs
Forwarded to :func:xarray.open_dataset.
Source code in src/plumax/matched_filter/io.py
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | |