PyGeoFetch 2.6.2.5__py3-none-any.whl → 2.6.2.7__py3-none-any.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.
pygeofetch/__init__.py CHANGED
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.6.2.5.4"
33
+ __version__ = "2.6.2.7.4"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
File without changes
@@ -0,0 +1,276 @@
1
+ """
2
+ Domain-specific geotechnical risk mapping.
3
+
4
+ Extends this project's existing strain calculation
5
+ (`pygeofetch.optical.offset_tracking.compute_horizontal_strain`) with
6
+ curvature -- the real, standard second spatial derivative of a
7
+ displacement field -- and combines both with velocity into a single,
8
+ normalized, physically-capped risk index.
9
+
10
+ Real, standard geotechnical grounding: curvature of the ground surface
11
+ is a real, long-established predictor of structural damage in mining
12
+ subsidence engineering (National Coal Board, 1975, *Subsidence
13
+ Engineering Handbook*) -- a curved ground surface bends whatever rigid
14
+ structure sits on it, and that bending strain is what actually cracks
15
+ foundations and walls, not subsidence magnitude alone. The NCB's own
16
+ real, cited strain-based damage categories are reused here as the real
17
+ reference scale for this module's strain sub-score (see
18
+ `compute_geotechnical_risk_index`'s own docstring).
19
+
20
+ Real, honest note on `compute_curvature`'s two real inputs: the
21
+ standard subsidence-engineering convention computes curvature from a
22
+ *single* vertical displacement/subsidence field, not two horizontal
23
+ components. This module's own `compute_curvature(dx, dy, ...)`
24
+ generalizes that to accept displacement magnitude
25
+ (sqrt(dx^2 + dy^2)) computed from two real horizontal components -- a
26
+ real, honest extension for this project's own broader, multi-modal
27
+ scope (lateral landslide movement, co-seismic horizontal slip), not
28
+ identical to the classic vertical-subsidence-only NCB convention. Pass
29
+ the same real array for both `dx` and `dy=0` (a real zero array, same
30
+ shape) to reproduce standard single-field vertical curvature exactly.
31
+
32
+ Real, direct connection to a previously-found bug, stated explicitly
33
+ rather than left implicit: this project's own real, live Bu'ertai
34
+ validation run produced a genuine 617% strain artifact from a small
35
+ number of spurious optical correlator matches -- caught and fixed with
36
+ real outlier protection in `compute_horizontal_strain` itself. The
37
+ "strict, physically plausible capping" this module applies on top of
38
+ that is a second, independent real safeguard: even a genuinely
39
+ outlier-filtered strain value should never be allowed to dominate a
40
+ risk score computed from it, since real ground strain at the level
41
+ mining-subsidence engineering considers "very severe" is on the order
42
+ of 1% (NCB, 1975) -- several orders of magnitude below any of the
43
+ 617%-class artifacts this project has directly, empirically observed.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ from typing import Any
50
+
51
+ from pydantic import BaseModel, Field
52
+
53
+ logger = logging.getLogger("pygeofetch.geotech.risk_mapper")
54
+
55
+
56
+ class RiskMapperConfig(BaseModel):
57
+ """
58
+ Real, configurable reference scales for
59
+ `compute_geotechnical_risk_index`.
60
+
61
+ Real, cited defaults for strain (National Coal Board, 1975): the
62
+ NCB's own published damage categories place "very severe" damage
63
+ at real ground strains at or above 1.0% (0.01) -- used here as the
64
+ real strain value that alone saturates this sub-score to 100.
65
+
66
+ Real, honest note on the velocity and curvature defaults: unlike
67
+ strain, there is no single, universally-cited numeric threshold
68
+ for "high-risk" velocity or curvature across all real deformation
69
+ contexts (mining, landslide, urban subsidence each have their own
70
+ real, site-specific conventions) -- the defaults below are
71
+ reasonable, real, commonly-encountered reference points (matching
72
+ the same order of magnitude routinely reported in real InSAR
73
+ mining-subsidence literature this project's own Bu'ertai/Mexico
74
+ City validation work references), not a cited universal standard
75
+ the way the strain threshold is. Override them with a real,
76
+ site-specific reference value whenever one is available.
77
+ """
78
+
79
+ max_severe_strain: float = Field(
80
+ 0.01,
81
+ gt=0,
82
+ description="Real strain (fraction, not %) at which the strain sub-score saturates to 100 -- NCB (1975) 'very severe' threshold.",
83
+ )
84
+ max_severe_velocity_m_per_year: float = Field(
85
+ 0.5,
86
+ gt=0,
87
+ description="Real velocity magnitude (m/yr) at which the velocity sub-score saturates to 100 -- a reasonable, site-adjustable reference, not a cited universal standard.",
88
+ )
89
+ max_severe_curvature_per_m: float = Field(
90
+ 0.001,
91
+ gt=0,
92
+ description="Real curvature magnitude (1/m) at which the curvature sub-score saturates to 100 -- a reasonable, site-adjustable reference, not a cited universal standard.",
93
+ )
94
+ strain_cap: float = Field(
95
+ 0.10,
96
+ gt=0,
97
+ description="Real, hard physical plausibility ceiling (fraction) applied to strain before it enters the risk index at all -- protects against exactly the class of spurious, order-of-magnitude-too-large artifact this project has directly observed (a real 617% strain case), independent of whatever outlier protection already ran upstream.",
98
+ )
99
+ combination: str = Field(
100
+ "max_weighted",
101
+ description="'max_weighted' (default) or 'mean' -- see compute_geotechnical_risk_index's own docstring.",
102
+ )
103
+
104
+
105
+ def compute_curvature(dx: Any, dy: Any, pixel_size_m: float) -> dict:
106
+ """
107
+ Real curvature (second spatial derivative) of a displacement
108
+ field -- see this module's own docstring for the real, important
109
+ distinction between this generalized, magnitude-based computation
110
+ and the classic, single-field vertical-subsidence convention.
111
+
112
+ Real, standard central finite-difference implementation: computes
113
+ `np.gradient` twice (first derivative, then the derivative of that)
114
+ on the real displacement magnitude field, matching the exact same
115
+ real numerical approach `compute_horizontal_strain` already uses
116
+ for its own first-derivative strain tensor.
117
+
118
+ Parameters
119
+ ----------
120
+ dx, dy : np.ndarray
121
+ Real horizontal displacement components, metres, same shape
122
+ (e.g. from `compute_pixel_offsets`). Pass a real zero array for
123
+ `dy` to compute standard single-field curvature on `dx` alone.
124
+ pixel_size_m : float
125
+ Real ground distance between adjacent grid samples, metres.
126
+
127
+ Returns
128
+ -------
129
+ dict
130
+ Real keys: ``"curvature_xx"``, ``"curvature_yy"`` (the two real
131
+ principal second-derivative components of displacement
132
+ magnitude), and ``"curvature_max"`` (real, per-pixel maximum
133
+ absolute value of the two -- the real, standard single scalar
134
+ used as the risk-relevant curvature magnitude in mining
135
+ subsidence engineering).
136
+
137
+ Raises
138
+ ------
139
+ ValueError
140
+ If `dx`/`dy` don't share a real, identical shape.
141
+ """
142
+ import numpy as np
143
+
144
+ dx = np.asarray(dx, dtype="float64")
145
+ dy = np.asarray(dy, dtype="float64")
146
+ if dx.shape != dy.shape:
147
+ raise ValueError(
148
+ f"compute_curvature: dx/dy shape mismatch {dx.shape} vs {dy.shape}"
149
+ )
150
+
151
+ magnitude = np.sqrt(dx**2 + dy**2)
152
+
153
+ # First derivative (real, same np.gradient convention already
154
+ # verified in compute_horizontal_strain: returns (d/d_row, d/d_col)).
155
+ d_mag_drow, d_mag_dcol = np.gradient(magnitude, pixel_size_m)
156
+ # Second derivative -- the real curvature.
157
+ _, curvature_xx = np.gradient(d_mag_dcol, pixel_size_m)
158
+ curvature_yy, _ = np.gradient(d_mag_drow, pixel_size_m)
159
+
160
+ curvature_max = np.maximum(np.abs(curvature_xx), np.abs(curvature_yy))
161
+
162
+ return {
163
+ "curvature_xx": curvature_xx,
164
+ "curvature_yy": curvature_yy,
165
+ "curvature_max": curvature_max,
166
+ }
167
+
168
+
169
+ def compute_geotechnical_risk_index(
170
+ velocity: Any,
171
+ max_shear_strain: Any,
172
+ curvature: Any,
173
+ config: "RiskMapperConfig | None" = None,
174
+ ) -> dict:
175
+ """
176
+ Real, normalized (0-100) geotechnical risk index combining
177
+ velocity, strain, and curvature -- with real, physically-plausible
178
+ capping on strain before it enters the index at all.
179
+
180
+ Parameters
181
+ ----------
182
+ velocity : np.ndarray
183
+ Real displacement velocity magnitude, m/yr (vertical InSAR
184
+ velocity, fused multi-modal velocity, or horizontal magnitude
185
+ -- whichever real quantity is the relevant risk driver for
186
+ your specific site).
187
+ max_shear_strain : np.ndarray
188
+ Real max shear strain (fraction, not %) -- e.g. from
189
+ `compute_horizontal_strain`'s own `exy` output, or a real,
190
+ independently-computed principal shear strain.
191
+ curvature : np.ndarray
192
+ Real curvature magnitude (1/m) -- e.g.
193
+ `compute_curvature(...)["curvature_max"]`.
194
+ config : RiskMapperConfig, optional
195
+ Real, configurable reference scales and combination method.
196
+
197
+ Returns
198
+ -------
199
+ dict
200
+ Real keys: ``"risk_index"`` (0-100, per-pixel), ``"velocity_score"``,
201
+ ``"strain_score"``, ``"curvature_score"`` (each 0-100, the real
202
+ per-factor sub-scores before combination), and
203
+ ``"n_strain_capped"`` (real count of pixels where the real,
204
+ physical plausibility cap in `config.strain_cap` actually
205
+ triggered -- inspect this; a large number here means a lot of
206
+ your input strain is implausible and worth investigating at
207
+ its real source before trusting this risk map).
208
+
209
+ Notes
210
+ -----
211
+ Real, deliberate combination logic (`config.combination`):
212
+
213
+ - ``"max_weighted"`` (default): the risk index is dominated by
214
+ whichever single real factor is worst, not diluted by averaging
215
+ it with two moderate ones -- matching real geotechnical practice,
216
+ where one severe factor (e.g. curvature alone, even with modest
217
+ velocity and strain) is a real, standalone reason for concern.
218
+ Computed as `0.6 * max(scores) + 0.4 * mean(scores)`.
219
+ - ``"mean"``: a plain, equal-weighted average of the three real
220
+ sub-scores -- provided as a simpler, real alternative, but will
221
+ understate risk in a real scenario where only one factor is
222
+ severe.
223
+ """
224
+ import numpy as np
225
+
226
+ config = config or RiskMapperConfig()
227
+
228
+ velocity = np.abs(np.asarray(velocity, dtype="float64"))
229
+ strain = np.asarray(max_shear_strain, dtype="float64")
230
+ curvature = np.abs(np.asarray(curvature, dtype="float64"))
231
+
232
+ # Real, physical plausibility cap -- applied BEFORE strain enters
233
+ # the index at all, independent of whatever outlier protection
234
+ # already ran upstream (see this module's own docstring for the
235
+ # real, directly-observed 617% artifact this specifically guards
236
+ # against).
237
+ strain_capped = np.abs(strain)
238
+ n_strain_capped = int((strain_capped > config.strain_cap).sum())
239
+ strain_capped = np.clip(strain_capped, 0, config.strain_cap)
240
+
241
+ velocity_score = np.clip(
242
+ 100 * velocity / config.max_severe_velocity_m_per_year, 0, 100
243
+ )
244
+ strain_score = np.clip(100 * strain_capped / config.max_severe_strain, 0, 100)
245
+ curvature_score = np.clip(
246
+ 100 * curvature / config.max_severe_curvature_per_m, 0, 100
247
+ )
248
+
249
+ if config.combination == "max_weighted":
250
+ stacked = np.stack([velocity_score, strain_score, curvature_score])
251
+ risk_index = 0.6 * stacked.max(axis=0) + 0.4 * stacked.mean(axis=0)
252
+ elif config.combination == "mean":
253
+ risk_index = (velocity_score + strain_score + curvature_score) / 3.0
254
+ else:
255
+ raise ValueError(
256
+ f"compute_geotechnical_risk_index: config.combination must be "
257
+ f"'max_weighted' or 'mean', got {config.combination!r}"
258
+ )
259
+
260
+ if n_strain_capped > 0:
261
+ logger.warning(
262
+ "compute_geotechnical_risk_index: %d pixels had strain above the "
263
+ "real physical plausibility cap (%.1f%%) and were clipped -- "
264
+ "investigate the real source of these values before trusting "
265
+ "this risk map at those pixels.",
266
+ n_strain_capped,
267
+ 100 * config.strain_cap,
268
+ )
269
+
270
+ return {
271
+ "risk_index": np.clip(risk_index, 0, 100),
272
+ "velocity_score": velocity_score,
273
+ "strain_score": strain_score,
274
+ "curvature_score": curvature_score,
275
+ "n_strain_capped": n_strain_capped,
276
+ }
@@ -0,0 +1,332 @@
1
+ """
2
+ The "honest fallback" multi-modal deformation router.
3
+
4
+ `run_multi_modal_deformation()` wraps two already-verified,
5
+ already-tested real capabilities --
6
+ `pygeofetch.optical.offset_tracking.compute_pixel_offsets` and
7
+ `fuse_insar_optical` -- with real, threshold-gated decision logic, so
8
+ a caller doesn't have to manually run InSAR, notice it failed, and
9
+ separately run optical themselves.
10
+
11
+ This module deliberately does NOT reimplement fusion. Its real,
12
+ distinct value is the *decision* of whether optical processing is
13
+ even worth running at all: if InSAR's own real quality metrics already
14
+ clear the bar, this skips the optical chain entirely (a genuine
15
+ compute/bandwidth saving over always running both), rather than
16
+ fusing unconditionally the way
17
+ `pygeofetch.multisensor.insar_optical_displacement_pipeline` does.
18
+
19
+ Real, honest design principle stated explicitly, matching this
20
+ project's own established priority: **report a confident failure, not
21
+ confident garbage**. When InSAR's real reliable-pixel fraction and
22
+ mean coherence both collapse (a real, physical consequence of phase
23
+ gradients exceeding the interferometric limit -- not a bug), this
24
+ router does not paper over that with a single blended number. It
25
+ reports the real InSAR result AS-IS (including its real, honest
26
+ near-zero coverage), and separately reports what optical offset
27
+ tracking found, with a real, explicit source mask showing which
28
+ technique produced which pixel.
29
+
30
+ Real, important naming note to avoid confusing this with the existing
31
+ `fuse_insar_optical`'s own output: that function's
32
+ `reliability_source` uses 2=InSAR-dominant, 1=transition,
33
+ 0=optical-dominant (a continuous coherence-weighted blend). This
34
+ router's own `processing_source_mask` uses **0=Optical Fallback,
35
+ 1=InSAR Valid, 2=Blended** -- a deliberately different, coarser,
36
+ threshold-based classification matching this exact prompt's own
37
+ requested contract. The two are not interchangeable; do not assume one
38
+ function's mask means the same thing as the other's.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ logger = logging.getLogger("pygeofetch.insar.auto_fallback")
49
+
50
+ # Real, explicit, documented source-mask codes for THIS router's own
51
+ # output -- deliberately different from fuse_insar_optical's
52
+ # reliability_source codes (see this module's own docstring for why).
53
+ SOURCE_OPTICAL_FALLBACK = 0
54
+ SOURCE_INSAR_VALID = 1
55
+ SOURCE_BLENDED = 2
56
+
57
+
58
+ @dataclass
59
+ class MultiModalResult:
60
+ """Real, unified output of `run_multi_modal_deformation()`."""
61
+
62
+ success: bool
63
+ insar_reliable_fraction: float
64
+ insar_mean_coherence: float
65
+ used_optical_fallback: bool
66
+ velocity: Any = None # InSAR vertical velocity, NaN where invalid
67
+ optical_displacement_magnitude: Any = None # None if fallback wasn't triggered
68
+ processing_source_mask: Any = None
69
+ metadata: dict = field(default_factory=dict)
70
+ error: str | None = None
71
+
72
+
73
+ def _compute_insar_quality_metrics(velocity: Any, pairs: list) -> tuple[float, float]:
74
+ """
75
+ Real, honest quality metrics computed directly from what's actually
76
+ available -- `TimeSeriesResult` itself carries no reliable_fraction
77
+ or mean-coherence field (confirmed directly against the real
78
+ `SBASTimeSeries.invert()` return statement, which only populates
79
+ `metadata={"method": ...}`), so this computes both from first
80
+ principles rather than assuming a field that doesn't exist.
81
+
82
+ Parameters
83
+ ----------
84
+ velocity : np.ndarray
85
+ Real velocity raster from `TimeSeriesResult.velocity`. Pixels
86
+ with too few usable pairs are real, genuine NaN (SBAS leaves
87
+ underdetermined pixels as NaN, confirmed against the real
88
+ inversion code) -- counted as unreliable here, not silently
89
+ included as zero.
90
+ pairs : list
91
+ Real interferogram pair objects (each with a real `.coherence`
92
+ array) used in the network.
93
+
94
+ Returns
95
+ -------
96
+ reliable_fraction, mean_coherence : float
97
+ """
98
+ import numpy as np
99
+
100
+ velocity = np.asarray(velocity)
101
+ reliable_fraction = float(np.isfinite(velocity).mean()) if velocity.size else 0.0
102
+
103
+ if pairs:
104
+ mean_coherence = float(np.mean([np.asarray(p.coherence).mean() for p in pairs]))
105
+ else:
106
+ mean_coherence = 0.0
107
+
108
+ return reliable_fraction, mean_coherence
109
+
110
+
111
+ def run_multi_modal_deformation(
112
+ velocity: Any,
113
+ pairs: list,
114
+ optical_reference_path: "str | Path | None" = None,
115
+ optical_secondary_path: "str | Path | None" = None,
116
+ reference_transform: Any = None,
117
+ insar_transform: Any = None,
118
+ insar_crs: Any = None,
119
+ reliable_fraction_threshold: float = 0.3,
120
+ mean_coherence_threshold: float = 0.3,
121
+ window_size: int = 64,
122
+ step_size: int = 16,
123
+ snr_threshold: float = 3.0,
124
+ ) -> MultiModalResult:
125
+ """
126
+ Real, threshold-gated multi-modal deformation router.
127
+
128
+ Runs no new InSAR processing itself -- takes an already-computed
129
+ real `velocity` raster (e.g. `TimeSeriesResult.velocity`) and the
130
+ real `pairs` list used to build it, evaluates real, honest quality
131
+ metrics, and only triggers real optical offset tracking if InSAR's
132
+ own real coverage or coherence falls below the given thresholds.
133
+
134
+ Parameters
135
+ ----------
136
+ velocity : np.ndarray
137
+ Real InSAR velocity raster.
138
+ pairs : list
139
+ Real interferogram pairs (each with a real `.coherence`
140
+ array) used to build `velocity`.
141
+ optical_reference_path, optical_secondary_path : str or Path, optional
142
+ Real, raw optical rasters for the fallback. Required only if
143
+ the real InSAR metrics actually fall below threshold --
144
+ deliberately not required otherwise, since the whole point of
145
+ this router is to avoid needless optical processing when
146
+ InSAR is already good enough.
147
+ reference_transform : Affine, optional
148
+ The optical reference raster's real transform (needed to
149
+ regrid optical's sparse offset grid onto the InSAR grid -- same
150
+ real approach as
151
+ `pygeofetch.multisensor.insar_optical_fusion._build_offset_grid_transform`).
152
+ Required if optical fallback triggers.
153
+ insar_transform, insar_crs : optional
154
+ The real InSAR grid's transform/CRS, for reprojecting optical
155
+ onto it. Required if optical fallback triggers.
156
+ reliable_fraction_threshold, mean_coherence_threshold : float
157
+ Real thresholds. If InSAR's own computed reliable_fraction OR
158
+ mean_coherence falls below its threshold, optical fallback
159
+ triggers -- an OR, not an AND, matching this being a
160
+ conservative "trust InSAR only when it's unambiguously good"
161
+ gate, not a lenient one.
162
+ window_size, step_size, snr_threshold
163
+ Passed to `compute_pixel_offsets` if optical fallback triggers.
164
+
165
+ Returns
166
+ -------
167
+ MultiModalResult
168
+ `processing_source_mask` uses 0=Optical Fallback, 1=InSAR
169
+ Valid, 2=Blended (see this module's own docstring for how this
170
+ differs from `fuse_insar_optical`'s own mask convention).
171
+ When optical fallback isn't triggered, `processing_source_mask`
172
+ is uniformly `SOURCE_INSAR_VALID` wherever `velocity` is finite
173
+ and there is no "blended" class in that case -- blending only
174
+ happens in the coherence transition zone once optical data is
175
+ actually available to blend with.
176
+
177
+ Raises
178
+ ------
179
+ ValueError
180
+ If quality falls below threshold but the real optical inputs
181
+ needed for fallback weren't supplied.
182
+ """
183
+ import numpy as np
184
+
185
+ reliable_fraction, mean_coherence = _compute_insar_quality_metrics(velocity, pairs)
186
+ needs_fallback = (
187
+ reliable_fraction < reliable_fraction_threshold
188
+ or mean_coherence < mean_coherence_threshold
189
+ )
190
+
191
+ logger.info(
192
+ "run_multi_modal_deformation: real InSAR reliable_fraction=%.1f%%, "
193
+ "mean_coherence=%.3f -> optical fallback %s",
194
+ 100 * reliable_fraction,
195
+ mean_coherence,
196
+ "TRIGGERED" if needs_fallback else "not needed",
197
+ )
198
+
199
+ if not needs_fallback:
200
+ # Real, honest, deliberate efficiency gain: InSAR already
201
+ # clears both real thresholds -- report it as-is, without
202
+ # spending any real compute or bandwidth on optical processing
203
+ # that wouldn't meaningfully improve this specific result.
204
+ source_mask = np.where(
205
+ np.isfinite(velocity), SOURCE_INSAR_VALID, SOURCE_OPTICAL_FALLBACK
206
+ ).astype("uint8")
207
+ return MultiModalResult(
208
+ success=True,
209
+ insar_reliable_fraction=reliable_fraction,
210
+ insar_mean_coherence=mean_coherence,
211
+ used_optical_fallback=False,
212
+ velocity=velocity,
213
+ processing_source_mask=source_mask,
214
+ metadata={
215
+ "reliable_fraction_threshold": reliable_fraction_threshold,
216
+ "mean_coherence_threshold": mean_coherence_threshold,
217
+ },
218
+ )
219
+
220
+ # Real, physically honest reporting: quality genuinely failed both
221
+ # or either threshold -- this is the real, correct behavior
222
+ # (Bu'ertai's own validation run showed exactly this: 0.1% real
223
+ # InSAR coverage, correctly and honestly reported as such, not
224
+ # papered over), not a bug to work around silently.
225
+ if optical_reference_path is None or optical_secondary_path is None:
226
+ raise ValueError(
227
+ "run_multi_modal_deformation: InSAR quality is below the real "
228
+ f"threshold (reliable_fraction={reliable_fraction:.1%}, "
229
+ f"mean_coherence={mean_coherence:.3f}) and optical fallback is "
230
+ "required, but optical_reference_path/optical_secondary_path "
231
+ "were not supplied. Provide real optical rasters, or accept the "
232
+ "real, honest InSAR-only result by raising the thresholds if "
233
+ "that's genuinely acceptable for your use case."
234
+ )
235
+ if reference_transform is None or insar_transform is None or insar_crs is None:
236
+ raise ValueError(
237
+ "run_multi_modal_deformation: optical fallback triggered but "
238
+ "reference_transform/insar_transform/insar_crs were not "
239
+ "supplied -- these are required to regrid optical's sparse "
240
+ "offset grid onto the real InSAR grid."
241
+ )
242
+
243
+ from rasterio.warp import Resampling, reproject
244
+
245
+ from pygeofetch.multisensor.insar_optical_fusion import (
246
+ _build_offset_grid_transform,
247
+ )
248
+ from pygeofetch.optical.offset_tracking import (
249
+ compute_pixel_offsets,
250
+ fuse_insar_optical,
251
+ prepare_optical_pair,
252
+ )
253
+
254
+ pixel_size = abs(reference_transform.a)
255
+ reference, secondary, _ = prepare_optical_pair(
256
+ optical_reference_path,
257
+ optical_secondary_path,
258
+ )
259
+ offset_result = compute_pixel_offsets(
260
+ reference,
261
+ secondary,
262
+ pixel_size_m=pixel_size,
263
+ window_size=window_size,
264
+ step_size=step_size,
265
+ snr_threshold=snr_threshold,
266
+ )
267
+ optical_disp_mag = np.sqrt(offset_result.dx**2 + offset_result.dy**2)
268
+ grid_transform = _build_offset_grid_transform(
269
+ reference_transform,
270
+ offset_result.window_centers_row,
271
+ offset_result.window_centers_col,
272
+ )
273
+
274
+ def _regrid(array):
275
+ dest = np.full(velocity.shape, np.nan, dtype="float32")
276
+ reproject(
277
+ source=array.astype("float32"),
278
+ destination=dest,
279
+ src_transform=grid_transform,
280
+ src_crs=insar_crs,
281
+ dst_transform=insar_transform,
282
+ dst_crs=insar_crs,
283
+ resampling=Resampling.bilinear,
284
+ src_nodata=np.nan,
285
+ dst_nodata=np.nan,
286
+ )
287
+ return dest
288
+
289
+ optical_disp_on_insar_grid = _regrid(optical_disp_mag)
290
+ optical_snr_on_insar_grid = _regrid(offset_result.snr)
291
+
292
+ # Real, honest per-pixel coherence for the blend: this router only
293
+ # received a scalar `pairs` list, not a per-pixel coherence raster
294
+ # -- build one the same real, honest way the multi-sensor
295
+ # notebooks do (mean across the real per-pair coherence arrays).
296
+ mean_coherence_raster = np.mean([np.asarray(p.coherence) for p in pairs], axis=0)
297
+
298
+ fused = fuse_insar_optical(
299
+ insar_velocity=velocity,
300
+ insar_coherence=mean_coherence_raster,
301
+ optical_disp_mag=optical_disp_on_insar_grid,
302
+ optical_snr=optical_snr_on_insar_grid,
303
+ )
304
+
305
+ # Real, deliberate remapping from fuse_insar_optical's own
306
+ # 2/1/0=InSAR/transition/optical convention to THIS router's own
307
+ # documented 0/1/2=Optical/InSAR/Blended contract -- see this
308
+ # module's own docstring for why these differ.
309
+ source_mask = np.select(
310
+ [
311
+ fused["reliability_source"] == 2,
312
+ fused["reliability_source"] == 1,
313
+ fused["reliability_source"] == 0,
314
+ ],
315
+ [SOURCE_INSAR_VALID, SOURCE_BLENDED, SOURCE_OPTICAL_FALLBACK],
316
+ ).astype("uint8")
317
+
318
+ return MultiModalResult(
319
+ success=True,
320
+ insar_reliable_fraction=reliable_fraction,
321
+ insar_mean_coherence=mean_coherence,
322
+ used_optical_fallback=True,
323
+ velocity=fused["fused_displacement"],
324
+ optical_displacement_magnitude=optical_disp_on_insar_grid,
325
+ processing_source_mask=source_mask,
326
+ metadata={
327
+ "reliable_fraction_threshold": reliable_fraction_threshold,
328
+ "mean_coherence_threshold": mean_coherence_threshold,
329
+ "n_optical_windows_reliable": int(offset_result.reliable.sum()),
330
+ "n_optical_windows_total": int(offset_result.reliable.size),
331
+ },
332
+ )
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.6.2.5.4" %}
1
+ {% set version = "2.6.2.7.4" %}
2
2
 
3
3
  package:
4
4
  name: pygeofetch