PyGeoFetch 2.0.2__py3-none-any.whl → 2.0.3__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
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch v2.0.2 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v2.0.3 — Universal Satellite Data Pipeline.
3
3
 
4
4
  Unified access to 22+ satellite data providers with a consistent
5
5
  Python API and CLI. Supports authentication management, federated search,
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.0.2"
33
+ __version__ = "2.0.3"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
pygeofetch/cli/main.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v2.0.2 main entry point.
2
+ PyGeoFetch CLI v2.0.3 main entry point.
3
3
 
4
4
  Full command reference:
5
5
  pygeofetch auth — credential management
@@ -71,7 +71,7 @@ def cli(
71
71
  ) -> None:
72
72
  """
73
73
  \b
74
- PyGeoFetch v2.0.2 — Universal Satellite Data Pipeline
74
+ PyGeoFetch v2.0.3 — Universal Satellite Data Pipeline
75
75
  ===========================================================
76
76
  Unified access to 22+ satellite data providers.
77
77
 
pygeofetch/insar/esd.py CHANGED
@@ -18,6 +18,7 @@ for IW mode).
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ from datetime import datetime, timedelta
21
22
  import logging
22
23
  from typing import List, Optional, Tuple
23
24
 
@@ -205,157 +206,342 @@ def _estimate_shift_from_overlap(
205
206
  return float(shift_seconds), median_coh
206
207
 
207
208
 
208
- def estimate_esd_shift_per_burst_overlap(
209
- ref_complex,
210
- sec_complex,
211
- swath_timing: SwathTiming,
212
- azimuth_time_interval_s: float,
213
- row_offset: int = 0,
214
- delta_f_ovl_hz: float = SENTINEL1_IW_DELTA_F_OVL_HZ,
215
- coherence_threshold: float = 0.3,
216
- ) -> Tuple[Optional[float], List[Optional[float]]]:
209
+ from typing import Dict, List, Optional, Tuple
210
+
211
+ def overlap_time_windows(
212
+ swath_timing: SwathTiming, azimuth_time_interval_s: float
213
+ ) -> List[Tuple[int, "datetime", "datetime"]]:
217
214
  """
218
- Real per-burst-overlap ESD: estimate azimuth misregistration from
219
- the actual burst overlap regions, replacing the previous whole-image
220
- single-shift approximation.
215
+ Absolute azimuth-time window of every adjacent-burst overlap -- the
216
+ ground area imaged by BOTH burst i (its trailing lines) and burst
217
+ i+1 (its leading lines) in THIS scene.
221
218
 
222
- Uses WINDOWED coherence estimation (7x7 window) and MEDIAN coherence
223
- for the quality check, matching SNAP's approach. This is critical for
224
- full-scene processing where the overlap spans the entire range width
225
- (~23,000 pixels) including ocean, vegetation, and other low-coherence
226
- terrain that would drag a global-mean coherence below threshold.
219
+ Returns:
220
+ List of (overlap_index, window_start, window_end) as absolute
221
+ azimuth datetimes.
222
+ """
223
+ bursts = swath_timing.bursts
224
+ lines_per_burst = swath_timing.lines_per_burst
225
+ out = []
226
+ for i in range(len(bursts) - 1):
227
+ start = bursts[i + 1].azimuth_time
228
+ end = bursts[i].azimuth_time + timedelta(
229
+ seconds=(lines_per_burst - 1) * azimuth_time_interval_s
230
+ )
231
+ out.append((i, start, end))
232
+ return out
227
233
 
228
- Memory optimization: subsamples in range to max 4096 columns per
229
- overlap, reducing peak memory from ~600 MB to ~100 MB per overlap
230
- while maintaining statistical robustness.
231
234
 
232
- Args:
233
- ref_complex, sec_complex: Real, coregistered (at least coarsely)
234
- complex SLC arrays, same shape -- full burst stack, or an
235
- already-cropped extract (see row_offset).
236
- swath_timing: Real burst metadata from annotation.parse_burst_info().
237
- azimuth_time_interval_s: Real per-line time spacing.
238
- row_offset: Real full-scene row that ref_complex/sec_complex's
239
- row 0 corresponds to. Default 0 assumes the arrays start at
240
- the full-scene row 0.
241
- delta_f_ovl_hz: Doppler centroid difference in the overlap.
242
- coherence_threshold: Minimum MEDIAN windowed coherence for an
243
- overlap to be considered usable.
244
235
 
245
- Returns:
246
- (shift_seconds, per_overlap_shifts): shift_seconds is the real,
247
- median azimuth timing shift (seconds) across all valid burst
248
- overlaps (None if no overlap gave a usable estimate);
249
- per_overlap_shifts is one entry per adjacent burst pair (None
250
- where that overlap's coherence was too low or fell entirely
251
- outside the given arrays).
236
+ def compute_common_ground_overlaps(
237
+ ref_timing: SwathTiming,
238
+ sec_timing: SwathTiming,
239
+ azimuth_time_interval_s: float,
240
+ ) -> List[Dict]:
252
241
  """
253
- import numpy as np
242
+ Per-reference-overlap diagnostic of how much COMMON double-covered
243
+ ground (in azimuth lines) this date pair actually shares.
254
244
 
255
- if not isinstance(swath_timing, SwathTiming):
256
- raise TypeError(
257
- f"estimate_esd_shift_per_burst_overlap expects a SwathTiming "
258
- f"object, got {type(swath_timing).__name__}. Pass the result of "
259
- f"parse_burst_info(), not a raw bursts list."
260
- )
245
+ For each reference overlap window, intersect it with every secondary
246
+ overlap window and keep the largest intersection. That intersection
247
+ length is the only ground on which the ESD double-difference is
248
+ physically meaningful for this pair.
261
249
 
262
- overlaps = compute_overlap_row_ranges(swath_timing, azimuth_time_interval_s)
263
- per_overlap_shifts: List[Optional[float]] = []
250
+ This is the number that cleanly separates the two regimes:
251
+ - same-family pairs (identical burst timing): common lines ~= the
252
+ full overlap length (tens of lines per overlap);
253
+ - cross-family pairs (8 vs 9 bursts, shifted timing): common lines
254
+ shrink toward 0, which is exactly why ESD finds few/no usable
255
+ overlaps for them -- a physical limitation, not a bug.
264
256
 
265
- n_outside = 0
266
- n_below_coh = 0
267
- n_usable = 0
257
+ Returns:
258
+ List of dicts, one per reference overlap, each with:
259
+ ref_overlap_index, ref_window, best_sec_overlap_index,
260
+ sec_window, common_lines, common_window
261
+ """
262
+ dt = azimuth_time_interval_s
263
+ ref_wins = overlap_time_windows(ref_timing, dt)
264
+ sec_wins = overlap_time_windows(sec_timing, dt)
265
+
266
+ report = []
267
+ for i, r_start, r_end in ref_wins:
268
+ best_j, best_lines, best_win = None, 0, None
269
+ for j, s_start, s_end in sec_wins:
270
+ t0, t1 = max(r_start, s_start), min(r_end, s_end)
271
+ if t1 <= t0:
272
+ continue # no intersection for this secondary overlap
273
+ n_lines = int(round((t1 - t0).total_seconds() / dt)) + 1
274
+ if n_lines > best_lines:
275
+ best_j, best_lines, best_win = j, n_lines, (t0, t1)
276
+ report.append({
277
+ "ref_overlap_index": i,
278
+ "ref_window": (r_start, r_end),
279
+ "best_sec_overlap_index": best_j,
280
+ "sec_window": best_win,
281
+ "common_lines": best_lines,
282
+ "common_window": best_win,
283
+ })
284
+ return report
268
285
 
269
- h, w = ref_complex.shape
286
+ def estimate_esd_shift_per_burst_overlap(
287
+ ref_complex, sec_complex,
288
+ swath_timing, azimuth_time_interval_s,
289
+ row_offset: int = 0,
290
+ sec_swath_timing=None, # NEW: secondary burst timing
291
+ delta_f_ovl_hz=SENTINEL1_IW_DELTA_F_OVL_HZ,
292
+ coherence_threshold: float = 0.2,
293
+ min_common_lines: int = 3,
294
+ ):
295
+ import numpy as np
296
+ from scipy.ndimage import uniform_filter
270
297
 
271
- for i, ((bw_start, bw_end), (fw_start, fw_end)) in enumerate(overlaps):
272
- # Map full-scene rows to local array rows
273
- local_bw_start = bw_start - row_offset
274
- local_bw_end = bw_end - row_offset
275
- local_fw_start = fw_start - row_offset
276
- local_fw_end = fw_end - row_offset
298
+ dt = azimuth_time_interval_s
299
+ L = swath_timing.lines_per_burst
300
+ ref_wins = overlap_time_windows(swath_timing, dt)
301
+ # If no secondary timing given, fall back to ref-only (old behaviour)
302
+ sec_wins = (overlap_time_windows(sec_swath_timing, dt)
303
+ if sec_swath_timing is not None else ref_wins)
304
+
305
+ per_overlap_shifts = []
306
+ skip_reasons = []
307
+
308
+ for (i, r_start, r_end) in ref_wins:
309
+ # Find the secondary overlap with the largest time intersection
310
+ best = None
311
+ for (j, s_start, s_end) in sec_wins:
312
+ t0, t1 = max(r_start, s_start), min(r_end, s_end)
313
+ n_lines = int((t1 - t0).total_seconds() / dt) + 1
314
+ if best is None or n_lines > best[0]:
315
+ best = (n_lines, t0, t1)
316
+ n_lines, t0, t1 = best
317
+
318
+ if n_lines < min_common_lines:
319
+ per_overlap_shifts.append(None)
320
+ skip_reasons.append("no_common_ground")
321
+ continue
277
322
 
278
- # Check bounds
279
- if (local_bw_start < 0 or local_bw_end >= h
280
- or local_fw_start < 0 or local_fw_end >= h):
281
- n_outside += 1
323
+ # Map the common time window to reference rows (bw=burst i tail,
324
+ # fw=burst i+1 head). Secondary is coregistered to the ref grid,
325
+ # so it is indexed at the same rows.
326
+ bw_rows, fw_rows = [], []
327
+ for k in range(n_lines):
328
+ tt = t0 + timedelta(seconds=k * dt)
329
+ bw_rows.append(i * L + round((tt - swath_timing.bursts[i].azimuth_time).total_seconds() / dt) - row_offset)
330
+ fw_rows.append((i + 1) * L + round((tt - swath_timing.bursts[i + 1].azimuth_time).total_seconds() / dt) - row_offset)
331
+
332
+ bw = np.array([r for r in bw_rows if 0 <= r < ref_complex.shape[0]])
333
+ fw = np.array([r for r in fw_rows if 0 <= r < ref_complex.shape[0]])
334
+ if len(bw) < min_common_lines or len(fw) < min_common_lines:
282
335
  per_overlap_shifts.append(None)
283
- logger.debug(
284
- "Overlap %d<->%d: outside array bounds "
285
- "(local rows bw=[%d,%d], fw=[%d,%d], array height=%d)",
286
- i, i + 1, local_bw_start, local_bw_end,
287
- local_fw_start, local_fw_end, h,
288
- )
336
+ skip_reasons.append("outside_array_bounds")
289
337
  continue
290
338
 
291
- # Extract overlap regions
292
- ref_bw = ref_complex[local_bw_start:local_bw_end + 1, :]
293
- sec_bw = sec_complex[local_bw_start:local_bw_end + 1, :]
294
- ref_fw = ref_complex[local_fw_start:local_fw_end + 1, :]
295
- sec_fw = sec_complex[local_fw_start:local_fw_end + 1, :]
296
-
297
- # Estimate shift with windowed coherence
298
- shift_s, median_coh = _estimate_shift_from_overlap(
299
- ref_bw, sec_bw, ref_fw, sec_fw,
300
- delta_f_ovl_hz=delta_f_ovl_hz,
301
- coherence_threshold=coherence_threshold,
302
- )
339
+ ref_bw, sec_bw = ref_complex[bw], sec_complex[bw]
340
+ ref_fw, sec_fw = ref_complex[fw], sec_complex[fw]
341
+ igram_bw = ref_bw * np.conj(sec_bw)
342
+ igram_fw = ref_fw * np.conj(sec_fw)
343
+
344
+ # Windowed coherence gate (same as before)
345
+ n_common = len(bw)
346
+ coh_window = min(5, n_common) if n_common >= 3 else 1
347
+ if coh_window >= 3:
348
+ num = np.abs(uniform_filter(igram_bw.real, size=coh_window)
349
+ + 1j * uniform_filter(igram_bw.imag, size=coh_window))
350
+ denom = np.sqrt(uniform_filter(np.abs(ref_bw)**2, size=coh_window)
351
+ * uniform_filter(np.abs(sec_bw)**2, size=coh_window) + 1e-10)
352
+ valid = np.isfinite(num / denom) & ((num / denom) >= coherence_threshold)
353
+ else:
354
+ valid = np.ones(igram_bw.shape, dtype=bool)
303
355
 
304
- if shift_s is None:
305
- n_below_coh += 1
356
+ if valid.mean() < 0.5:
306
357
  per_overlap_shifts.append(None)
307
- logger.debug(
308
- "Overlap %d<->%d: median windowed coherence %.3f < threshold %.2f",
309
- i, i + 1, median_coh, coherence_threshold,
310
- )
311
- else:
312
- n_usable += 1
313
- per_overlap_shifts.append(shift_s)
314
- logger.debug(
315
- "Overlap %d<->%d: shift=%.6f ms, median_coh=%.3f",
316
- i, i + 1, shift_s * 1000, median_coh,
317
- )
358
+ skip_reasons.append(f"low_coherence({100*valid.mean():.0f}%)")
359
+ continue
318
360
 
319
- # Report diagnostics
320
- if n_usable == 0:
321
- if n_outside > 0 and n_below_coh > 0:
322
- logger.warning(
323
- "Real per-burst-overlap ESD found no usable burst overlaps "
324
- "out of %d total: %d fell outside the given (possibly "
325
- "cropped) arrays, %d were below coherence_threshold=%.2f. "
326
- "A mix of both -- see the per-overlap detail at DEBUG level.",
327
- len(overlaps), n_outside, n_below_coh, coherence_threshold,
328
- )
329
- elif n_outside > 0:
330
- logger.warning(
331
- "Real per-burst-overlap ESD found no usable burst overlaps "
332
- "out of %d total: all %d fell outside the given (possibly "
333
- "cropped) arrays.",
334
- len(overlaps), n_outside,
335
- )
336
- else:
337
- logger.warning(
338
- "Real per-burst-overlap ESD found no usable burst overlaps "
339
- "out of %d total: 0 fell outside the given (possibly "
340
- "cropped) arrays, %d were below coherence_threshold=%.2f. "
341
- "Every real, in-bounds overlap was tested and found "
342
- "genuinely below the coherence threshold -- this reflects "
343
- "real decorrelation in the data, not something a larger "
344
- "AOI would fix.",
345
- len(overlaps), n_below_coh, coherence_threshold,
346
- )
347
- return None, per_overlap_shifts
361
+ double_diff = igram_fw * np.conj(igram_bw)
362
+ mean_phase = np.angle(np.mean(double_diff[valid]))
363
+ per_overlap_shifts.append(float(mean_phase / (2 * np.pi * delta_f_ovl_hz)))
364
+ skip_reasons.append("used")
348
365
 
349
- # Combine valid shifts via median (robust to outliers)
350
366
  valid_shifts = [s for s in per_overlap_shifts if s is not None]
351
- combined_shift = float(np.median(valid_shifts))
352
-
353
- logger.info(
354
- "Real per-burst-overlap ESD: %d/%d overlaps usable, "
355
- "combined shift=%.6f ms (%.4f px)",
356
- n_usable, len(overlaps),
357
- combined_shift * 1000,
358
- combined_shift / azimuth_time_interval_s,
359
- )
367
+ if not valid_shifts:
368
+ n_outside = skip_reasons.count("outside_array_bounds")
369
+ n_nocommon = skip_reasons.count("no_common_ground")
370
+ n_low = sum(1 for r in skip_reasons if r.startswith("low_coherence"))
371
+ logger.warning(
372
+ "ESD: no usable overlaps of %d (%d no common ground, %d outside, %d low coh)",
373
+ len(ref_wins), n_nocommon, n_outside, n_low)
374
+ return None, per_overlap_shifts
360
375
 
361
- return combined_shift, per_overlap_shifts
376
+ combined = float(np.median(valid_shifts))
377
+ logger.info("ESD common-ground: %d/%d usable, shift=%.6f ms (%.4f px)",
378
+ len(valid_shifts), len(ref_wins), combined*1000, combined/dt)
379
+ return combined, per_overlap_shifts
380
+
381
+
382
+ # def estimate_esd_shift_per_burst_overlap(
383
+ # ref_complex,
384
+ # sec_complex,
385
+ # swath_timing: SwathTiming,
386
+ # azimuth_time_interval_s: float,
387
+ # row_offset: int = 0,
388
+ # delta_f_ovl_hz: float = SENTINEL1_IW_DELTA_F_OVL_HZ,
389
+ # coherence_threshold: float = 0.3,
390
+ # ) -> Tuple[Optional[float], List[Optional[float]]]:
391
+ # """
392
+ # Real per-burst-overlap ESD: estimate azimuth misregistration from
393
+ # the actual burst overlap regions, replacing the previous whole-image
394
+ # single-shift approximation.
395
+
396
+ # Uses WINDOWED coherence estimation (7x7 window) and MEDIAN coherence
397
+ # for the quality check, matching SNAP's approach. This is critical for
398
+ # full-scene processing where the overlap spans the entire range width
399
+ # (~23,000 pixels) including ocean, vegetation, and other low-coherence
400
+ # terrain that would drag a global-mean coherence below threshold.
401
+
402
+ # Memory optimization: subsamples in range to max 4096 columns per
403
+ # overlap, reducing peak memory from ~600 MB to ~100 MB per overlap
404
+ # while maintaining statistical robustness.
405
+
406
+ # Args:
407
+ # ref_complex, sec_complex: Real, coregistered (at least coarsely)
408
+ # complex SLC arrays, same shape -- full burst stack, or an
409
+ # already-cropped extract (see row_offset).
410
+ # swath_timing: Real burst metadata from annotation.parse_burst_info().
411
+ # azimuth_time_interval_s: Real per-line time spacing.
412
+ # row_offset: Real full-scene row that ref_complex/sec_complex's
413
+ # row 0 corresponds to. Default 0 assumes the arrays start at
414
+ # the full-scene row 0.
415
+ # delta_f_ovl_hz: Doppler centroid difference in the overlap.
416
+ # coherence_threshold: Minimum MEDIAN windowed coherence for an
417
+ # overlap to be considered usable.
418
+
419
+ # Returns:
420
+ # (shift_seconds, per_overlap_shifts): shift_seconds is the real,
421
+ # median azimuth timing shift (seconds) across all valid burst
422
+ # overlaps (None if no overlap gave a usable estimate);
423
+ # per_overlap_shifts is one entry per adjacent burst pair (None
424
+ # where that overlap's coherence was too low or fell entirely
425
+ # outside the given arrays).
426
+ # """
427
+ # import numpy as np
428
+
429
+ # if not isinstance(swath_timing, SwathTiming):
430
+ # raise TypeError(
431
+ # f"estimate_esd_shift_per_burst_overlap expects a SwathTiming "
432
+ # f"object, got {type(swath_timing).__name__}. Pass the result of "
433
+ # f"parse_burst_info(), not a raw bursts list."
434
+ # )
435
+
436
+ # overlaps = compute_overlap_row_ranges(swath_timing, azimuth_time_interval_s)
437
+ # per_overlap_shifts: List[Optional[float]] = []
438
+
439
+ # n_outside = 0
440
+ # n_below_coh = 0
441
+ # n_usable = 0
442
+
443
+ # h, w = ref_complex.shape
444
+
445
+ # for i, ((bw_start, bw_end), (fw_start, fw_end)) in enumerate(overlaps):
446
+ # # Map full-scene rows to local array rows
447
+ # local_bw_start = bw_start - row_offset
448
+ # local_bw_end = bw_end - row_offset
449
+ # local_fw_start = fw_start - row_offset
450
+ # local_fw_end = fw_end - row_offset
451
+
452
+ # # Check bounds
453
+ # if (local_bw_start < 0 or local_bw_end >= h
454
+ # or local_fw_start < 0 or local_fw_end >= h):
455
+ # n_outside += 1
456
+ # per_overlap_shifts.append(None)
457
+ # logger.debug(
458
+ # "Overlap %d<->%d: outside array bounds "
459
+ # "(local rows bw=[%d,%d], fw=[%d,%d], array height=%d)",
460
+ # i, i + 1, local_bw_start, local_bw_end,
461
+ # local_fw_start, local_fw_end, h,
462
+ # )
463
+ # continue
464
+
465
+ # # Extract overlap regions
466
+ # ref_bw = ref_complex[local_bw_start:local_bw_end + 1, :]
467
+ # sec_bw = sec_complex[local_bw_start:local_bw_end + 1, :]
468
+ # ref_fw = ref_complex[local_fw_start:local_fw_end + 1, :]
469
+ # sec_fw = sec_complex[local_fw_start:local_fw_end + 1, :]
470
+
471
+ # # Estimate shift with windowed coherence
472
+ # shift_s, median_coh = _estimate_shift_from_overlap(
473
+ # ref_bw, sec_bw, ref_fw, sec_fw,
474
+ # delta_f_ovl_hz=delta_f_ovl_hz,
475
+ # coherence_threshold=coherence_threshold,
476
+ # )
477
+
478
+ # if shift_s is None:
479
+ # n_below_coh += 1
480
+ # per_overlap_shifts.append(None)
481
+ # logger.debug(
482
+ # "Overlap %d<->%d: median windowed coherence %.3f < threshold %.2f",
483
+ # i, i + 1, median_coh, coherence_threshold,
484
+ # )
485
+ # else:
486
+ # n_usable += 1
487
+ # per_overlap_shifts.append(shift_s)
488
+ # logger.debug(
489
+ # "Overlap %d<->%d: shift=%.6f ms, median_coh=%.3f",
490
+ # i, i + 1, shift_s * 1000, median_coh,
491
+ # )
492
+
493
+ # # Report diagnostics
494
+ # if n_usable == 0:
495
+ # if n_outside > 0 and n_below_coh > 0:
496
+ # logger.warning(
497
+ # "Real per-burst-overlap ESD found no usable burst overlaps "
498
+ # "out of %d total: %d fell outside the given (possibly "
499
+ # "cropped) arrays, %d were below coherence_threshold=%.2f. "
500
+ # "A mix of both -- see the per-overlap detail at DEBUG level.",
501
+ # len(overlaps), n_outside, n_below_coh, coherence_threshold,
502
+ # )
503
+ # elif n_outside > 0:
504
+ # logger.warning(
505
+ # "Real per-burst-overlap ESD found no usable burst overlaps "
506
+ # "out of %d total: all %d fell outside the given (possibly "
507
+ # "cropped) arrays.",
508
+ # len(overlaps), n_outside,
509
+ # )
510
+ # else:
511
+ # logger.warning(
512
+ # "Real per-burst-overlap ESD found no usable burst overlaps "
513
+ # "out of %d total: 0 fell outside the given (possibly "
514
+ # "cropped) arrays, %d were below coherence_threshold=%.2f. "
515
+ # "Every real, in-bounds overlap was tested and found "
516
+ # "genuinely below the coherence threshold -- this reflects "
517
+ # "real decorrelation in the data, not something a larger "
518
+ # "AOI would fix.",
519
+ # len(overlaps), n_below_coh, coherence_threshold,
520
+ # )
521
+ # return None, per_overlap_shifts
522
+
523
+ # # Combine valid shifts via median (robust to outliers)
524
+ # valid_shifts = [s for s in per_overlap_shifts if s is not None]
525
+ # combined_shift = float(np.median(valid_shifts))
526
+
527
+ # logger.info(
528
+ # "Real per-burst-overlap ESD: %d/%d overlaps usable, "
529
+ # "combined shift=%.6f ms (%.4f px)",
530
+ # n_usable, len(overlaps),
531
+ # combined_shift * 1000,
532
+ # combined_shift / azimuth_time_interval_s,
533
+ # )
534
+
535
+ # return combined_shift, per_overlap_shifts
536
+
537
+
538
+ # def overlap_time_windows(swath_timing, dt):
539
+ # """Absolute azimuth-time windows for each adjacent-burst overlap."""
540
+ # bursts = swath_timing.bursts
541
+ # L = swath_timing.lines_per_burst
542
+ # out = []
543
+ # for i in range(len(bursts) - 1):
544
+ # start = bursts[i + 1].azimuth_time
545
+ # end = bursts[i].azimuth_time + timedelta(seconds=(L - 1) * dt)
546
+ # out.append((i, start, end))
547
+ # return out
@@ -372,8 +372,28 @@ class InterferogramGenerator:
372
372
  except ImportError:
373
373
  raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
374
374
 
375
+ # with rasterio.open(path) as src:
376
+ # profile = src.profile.copy()
377
+ # dtype = src.dtypes[0]
378
+
375
379
  with rasterio.open(path) as src:
376
380
  profile = src.profile.copy()
381
+
382
+ # FIX: Full sub-swath files are GCP-georeferenced and lack an affine
383
+ # transform. Downstream steps (flat-earth, topo phase, AOI crop)
384
+ # require a transform. Fit an approximate one from the GCPs.
385
+ if profile.get("transform") is None or getattr(profile.get("transform"), "is_identity", False):
386
+ gcps, gcp_crs = src.gcps
387
+ if gcps and len(gcps) >= 4:
388
+ from rasterio.transform import from_gcps
389
+ profile["transform"] = from_gcps(gcps)
390
+ if gcp_crs:
391
+ profile["crs"] = gcp_crs
392
+ logger.info(
393
+ "Fitted approximate affine transform from %d GCPs for %s",
394
+ len(gcps), path.name
395
+ )
396
+
377
397
  dtype = src.dtypes[0]
378
398
  height = src.height
379
399
  width = src.width
@@ -481,9 +501,15 @@ class InterferogramGenerator:
481
501
 
482
502
  # Convert AOI corners to pixel coordinates
483
503
  # Note: rowcol expects (x, y) = (lon, lat)
504
+ # Convert AOI corners to pixel coordinates
505
+ # FIX: Sentinel-1 grids are often rotated. We must check all 4 corners
506
+ # to find the true pixel bounding box, not just two opposite corners.
484
507
  try:
485
- row_min, col_min = rowcol(transform, aoi_bbox.min_lon, aoi_bbox.max_lat)
486
- row_max, col_max = rowcol(transform, aoi_bbox.max_lon, aoi_bbox.min_lat)
508
+ lons = [aoi_bbox.min_lon, aoi_bbox.max_lon, aoi_bbox.min_lon, aoi_bbox.max_lon]
509
+ lats = [aoi_bbox.min_lat, aoi_bbox.min_lat, aoi_bbox.max_lat, aoi_bbox.max_lat]
510
+ rows, cols = rowcol(transform, lons, lats)
511
+ row_min, row_max = min(rows), max(rows)
512
+ col_min, col_max = min(cols), max(cols)
487
513
  except Exception as exc:
488
514
  logger.warning(
489
515
  "Failed to convert AOI to pixel coordinates (%s) — "
@@ -499,9 +525,9 @@ class InterferogramGenerator:
499
525
 
500
526
  # Add margin
501
527
  row_min = max(0, row_min - margin_px)
502
- row_max = min(ref_debursted.shape[0], row_max + margin_px)
528
+ row_max = min(ref_debursted.shape[0], row_max + margin_px + 1) # +1 because slice end is exclusive
503
529
  col_min = max(0, col_min - margin_px)
504
- col_max = min(ref_debursted.shape[1], col_max + margin_px)
530
+ col_max = min(ref_debursted.shape[1], col_max + margin_px + 1) # +1 because slice end is exclusive
505
531
 
506
532
  n_rows = row_max - row_min
507
533
  n_cols = col_max - col_min
@@ -850,6 +876,51 @@ class InterferogramGenerator:
850
876
  f"parse_burst_info returned {type(sec_burst_info).__name__} "
851
877
  f"instead of SwathTiming for secondary — check annotation.py"
852
878
  )
879
+
880
+ # FIX: Define this BEFORE the common-ground diagnostic uses it
881
+ azimuth_time_interval_s = ref_geom.azimuth_time_interval_s
882
+
883
+ # ── Common-ground diagnostic ─────────────────────────────────
884
+
885
+ # ── Common-ground diagnostic ─────────────────────────────────
886
+ # Report, per pair, how many azimuth lines of double-covered
887
+ # ground each reference burst-overlap actually shares with the
888
+ # secondary. This is the unambiguous answer to "how much common
889
+ # ground does this pair really have for ESD?" -- the thing the
890
+ # old "outside the array / below coherence" message could not
891
+ # distinguish.
892
+ from pygeofetch.insar.esd import compute_common_ground_overlaps
893
+
894
+ common_report = compute_common_ground_overlaps(
895
+ ref_burst_info, sec_burst_info, azimuth_time_interval_s
896
+ )
897
+ common_lines = [r["common_lines"] for r in common_report]
898
+ total_common = sum(common_lines)
899
+
900
+ if total_common == 0:
901
+ logger.warning(
902
+ "ESD common-ground diagnostic: common double-covered "
903
+ "lines per ref overlap: [%s] (total %d) -- this pair "
904
+ "shares NO double-covered ground; ESD is physically "
905
+ "unmeasurable here (cross-family burst timing), not a "
906
+ "processing bug.",
907
+ ", ".join(str(n) for n in common_lines), total_common,
908
+ )
909
+ else:
910
+ logger.info(
911
+ "ESD common-ground diagnostic: common double-covered "
912
+ "lines per ref overlap: [%s] (total %d)",
913
+ ", ".join(str(n) for n in common_lines), total_common,
914
+ )
915
+
916
+ metadata["esd_common_ground_lines"] = common_lines
917
+ metadata["esd_common_ground_total"] = total_common
918
+
919
+
920
+ # ── ESD shift per burst overlap ────────────────────────────────────────
921
+ # Compute the ESD shift per reference overlap, using the
922
+ # per-reference-overlap diagnostic of how much COMMON double-covered
923
+ # ground this date pair actually shares.
853
924
  azimuth_time_interval_s = ref_geom.azimuth_time_interval_s
854
925
 
855
926
  ref_row_off, _ = (
@@ -881,6 +952,7 @@ class InterferogramGenerator:
881
952
  esd_shift_s, per_overlap = estimate_esd_shift_per_burst_overlap(
882
953
  ref_complex, sec_complex, ref_burst_info, azimuth_time_interval_s,
883
954
  row_offset=int(ref_row_off),
955
+ sec_swath_timing=sec_burst_info, # NEW
884
956
  )
885
957
  if esd_shift_s is not None:
886
958
  esd_shift_px = esd_shift_s / azimuth_time_interval_s
@@ -1278,14 +1350,35 @@ class InterferogramGenerator:
1278
1350
  ref_center_time_fe = ref_geom_fe.azimuth_time(ref_geom_fe.n_lines / 2)
1279
1351
  sec_center_time_fe = sec_geom_fe.azimuth_time(sec_geom_fe.n_lines / 2)
1280
1352
 
1281
- with rasterio.open(str(reference)) as _src:
1282
- b = _src.bounds
1283
- margin_lon = (b.right - b.left) * 0.2
1284
- margin_lat = (b.top - b.bottom) * 0.2
1353
+ # with rasterio.open(str(reference)) as _src:
1354
+ # b = _src.bounds
1355
+ # margin_lon = (b.right - b.left) * 0.2
1356
+ # margin_lat = (b.top - b.bottom) * 0.2
1357
+ # sample_bounds_fe = (
1358
+ # b.left - margin_lon, b.bottom - margin_lat,
1359
+ # b.right + margin_lon, b.top + margin_lat,
1360
+ # )
1361
+
1362
+ # FIX: Use the profile we already read (which now has the GCP-fitted
1363
+ # transform) instead of reopening the file. Reopening a GCP-only
1364
+ # file yields pixel-coordinate bounds, which breaks sampling.
1365
+ transform_fe = profile.get("transform")
1366
+ if transform_fe is not None:
1367
+ from rasterio.transform import array_bounds
1368
+ # array_bounds returns (left, bottom, right, top)
1369
+ left, bottom, right, top = array_bounds(
1370
+ profile.get("height", ref_complex_native.shape[0]),
1371
+ profile.get("width", ref_complex_native.shape[1]),
1372
+ transform_fe
1373
+ )
1374
+ margin_lon = (right - left) * 0.2
1375
+ margin_lat = (top - bottom) * 0.2
1285
1376
  sample_bounds_fe = (
1286
- b.left - margin_lon, b.bottom - margin_lat,
1287
- b.right + margin_lon, b.top + margin_lat,
1377
+ left - margin_lon, bottom - margin_lat,
1378
+ right + margin_lon, top + margin_lat,
1288
1379
  )
1380
+ else:
1381
+ sample_bounds_fe = None
1289
1382
 
1290
1383
  wavelength_m = 0.05546576 # real Sentinel-1 C-band wavelength
1291
1384
  flat_earth_phase = compute_flat_earth_phase(
@@ -1408,8 +1501,25 @@ class InterferogramGenerator:
1408
1501
 
1409
1502
  with rasterio.open(path) as src:
1410
1503
  profile = src.profile.copy()
1504
+
1505
+ # FIX: Full sub-swath files are GCP-georeferenced and lack an affine
1506
+ # transform. Fit an approximate one from the GCPs so downstream
1507
+ # steps (flat-earth, topo phase, AOI crop) have a valid transform.
1508
+ if profile.get("transform") is None or getattr(profile.get("transform"), "is_identity", False):
1509
+ gcps, gcp_crs = src.gcps
1510
+ if gcps and len(gcps) >= 4:
1511
+ from rasterio.transform import from_gcps
1512
+ profile["transform"] = from_gcps(gcps)
1513
+ if gcp_crs:
1514
+ profile["crs"] = gcp_crs
1515
+ logger.info(
1516
+ "Fitted approximate affine transform from %d GCPs for %s",
1517
+ len(gcps), path.name
1518
+ )
1519
+
1411
1520
  dtype = src.dtypes[0]
1412
1521
  if "complex" in dtype:
1522
+
1413
1523
  data = src.read(1)
1414
1524
  elif src.count >= 2:
1415
1525
  real = src.read(1).astype(np.float32)
@@ -1486,11 +1596,26 @@ class InterferogramGenerator:
1486
1596
  # full Sentinel-1 swath and completely misses the small clipped
1487
1597
  # AOI where the DEM actually exists.
1488
1598
  # ═══════════════════════════════════════════════════════════
1599
+ # sample_bounds = None
1600
+ # if reference_extracted_path and Path(reference_extracted_path).exists():
1601
+ # with rasterio.open(reference_extracted_path) as src:
1602
+ # b = src.bounds
1603
+ # sample_bounds = (b.left, b.bottom, b.right, b.top)
1604
+
1489
1605
  sample_bounds = None
1490
1606
  if reference_extracted_path and Path(reference_extracted_path).exists():
1491
1607
  with rasterio.open(reference_extracted_path) as src:
1492
- b = src.bounds
1493
- sample_bounds = (b.left, b.bottom, b.right, b.top)
1608
+ # FIX: Handle GCP-only files (full sub-swath). src.bounds
1609
+ # returns pixel coordinates for GCP-only files. Fit an
1610
+ # approximate transform from GCPs to get real lon/lat bounds.
1611
+ if src.transform.is_identity and src.gcps[0]:
1612
+ from rasterio.transform import from_gcps, array_bounds
1613
+ approx_transform = from_gcps(src.gcps[0])
1614
+ left, bottom, right, top = array_bounds(src.height, src.width, approx_transform)
1615
+ sample_bounds = (left, bottom, right, top)
1616
+ else:
1617
+ b = src.bounds
1618
+ sample_bounds = (b.left, b.bottom, b.right, b.top)
1494
1619
 
1495
1620
  grid_rows, grid_cols, off_rows, off_cols = compute_offset_field_from_dem(
1496
1621
  ref_geom, ref_orbit, sec_geom, sec_orbit, dem,
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.0.2" %}
1
+ {% set version = "2.0.3" %}
2
2
 
3
3
  package:
4
4
  name: pygeofetch
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Universal satellite data pipeline + geospatial processing platform
5
5
  Author-email: Samuel Appiah Kubi <appiahkubis14@gmail.com>
6
6
  License: MIT License
@@ -1,11 +1,11 @@
1
- pygeofetch/__init__.py,sha256=QP5l7_dx2tLIM0e1Y8zzGbtufvjttnytzFI5NEPlaHQ,1990
2
- pygeofetch/meta.yaml,sha256=2WXs92GWowAsSLiuioXK-QXLJUCxvguDF-EwNK-puHM,1280
1
+ pygeofetch/__init__.py,sha256=g7_nMbdOgkKQ2ey8F9Vne6cto0205qDjI-sfcj-JULs,1990
2
+ pygeofetch/meta.yaml,sha256=k9ZW4b5eJItOWjelJsCPU0XUp1-2KgXXiklYjo4vxSI,1280
3
3
  pygeofetch/cli/__init__.py,sha256=QZMIIHEgmjGFieucqq9fZ5uephxP9-jdu1s8Q4qqw8o,30
4
4
  pygeofetch/cli/auth_commands.py,sha256=YqbrN_BYcAYCaUIYLpSJ_VIw35tKFUiD9Kc55SWbDWg,7742
5
5
  pygeofetch/cli/config_commands.py,sha256=1Pps0HLCLPODMlM6SFV8ru3IKCDpexSTz8uKIXvIIF8,3918
6
6
  pygeofetch/cli/download_commands.py,sha256=reQ9vmA50mj1zqechjLHAOe_cjBfa1miqfWa8UiIlxU,11741
7
7
  pygeofetch/cli/index_commands.py,sha256=IoLGrsSU-lTcBitFFsg8EtucqSKZtNL-0ODj_I8Twew,10020
8
- pygeofetch/cli/main.py,sha256=i3fGxhiyNHClLDj0_Ll5hojXXgYWB5QzvpRSXwJBVkg,30335
8
+ pygeofetch/cli/main.py,sha256=6p8mrv2zmIF0NFV04v1gf4Gp0VU48xHmRC4XlXi1pW8,30335
9
9
  pygeofetch/cli/pipeline_process_commands.py,sha256=OvuouT35zZlrC-7hWAJRfaY_aUNk4ScK0Fia9WVB_Gw,6904
10
10
  pygeofetch/cli/postprocess_commands.py,sha256=kof_f-kjjvYl5eqPqrcNHSOUM_qVkGdSh6-N4vcx2y4,5825
11
11
  pygeofetch/cli/preprocess_commands.py,sha256=cFp1VR03Q1pmCwiBWYmS5lXF3NOnzVAq3XgrZh3rBdU,8696
@@ -28,12 +28,12 @@ pygeofetch/insar/annotation.py,sha256=kg7bjR9jJ1U_2cztbOBWNMDQA2KqY_LvfaJTI5nl35
28
28
  pygeofetch/insar/atmosphere.py,sha256=GO4qAKF4Rfrj-AfVQ_VprblyO9-7cGKOGPltOnSxusk,32475
29
29
  pygeofetch/insar/coregister.py,sha256=tyQ5V6WH0mOcDJt3B30ScpbwdXC484hABCD7zPN5RB4,57355
30
30
  pygeofetch/insar/deburst.py,sha256=yiFHc-Ht_avzNrU-qjFk0RkdKfvvvovr-VCoIKGO6IY,5611
31
- pygeofetch/insar/esd.py,sha256=pRuXwbNtAbP_UIZhojlV269Mnu93vbNagwfXybvyaUw,13584
31
+ pygeofetch/insar/esd.py,sha256=r4sHDnbFocKqD3gamzaNRodLK2WtmgxOmQ62utVn6Oc,21488
32
32
  pygeofetch/insar/extraction.py,sha256=4d7c_vSUpyzEiCP2qk41S9tX1UAN78BOhyIc4RCm74g,40610
33
33
  pygeofetch/insar/flatearth.py,sha256=N2S-ZI7yi-BiC3d6jv1w8lLHd47lAYDt-aTdLng5E-c,13141
34
34
  pygeofetch/insar/geolocation.py,sha256=-vwTYFhvutKjI4c_f-3BGkfV3XnnDUh5r7KwQbLlErU,27465
35
35
  pygeofetch/insar/gpu.py,sha256=E4Dx9-78s2GyKl2_dJrc6lXIHiy67tlqHlpkYue_418,3933
36
- pygeofetch/insar/interferogram.py,sha256=7c0mAgSlg6cy7wu5hyFPfLbxlz78E2m1vquZGJ1h-WY,87398
36
+ pygeofetch/insar/interferogram.py,sha256=tdX4lYilv-tAY5anr1aAeVO_7Zc-BEOCgGmOydtcRng,94264
37
37
  pygeofetch/insar/ionosphere.py,sha256=vG60pyUtxyoJqLCMb_KjFEJvkDE1Bl8fqTzTTbrTIX8,30101
38
38
  pygeofetch/insar/splitspectrum_diagnostic.py,sha256=_IlfBs__BW7Yz2RwdmWxfrOspe67jHMgJPyNyzK_rYE,7705
39
39
  pygeofetch/insar/stack_selection.py,sha256=ebFwO063cCT9kn3zLPlkYu6fpnfx-SRC90Ch18E7qJk,13157
@@ -104,9 +104,9 @@ pygeofetch/utils/validators.py,sha256=f-k3mGFP4byjimyDymhPEdsz5I16jZacx1HSnwrARj
104
104
  pygeofetch/viz/__init__.py,sha256=xf2C9IL4n3bv3oxk8RYkogq7nkuNyt18V-urAoXWRQY,449
105
105
  pygeofetch/viz/map.py,sha256=vYO99NvTumCAVXVtXcEzMx_cpmH-FvSVNWl8X0PNSgE,15494
106
106
  pygeofetch/viz/plot.py,sha256=I16rdOWb80gcjYDsDvTMzuyek7QHgUNOQxY2RejBAPs,53675
107
- pygeofetch-2.0.2.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
- pygeofetch-2.0.2.dist-info/METADATA,sha256=yazC3HM_hzZlD3xESN0Ei8zCFyFQC5ucnTKbxykG8do,43254
109
- pygeofetch-2.0.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
- pygeofetch-2.0.2.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
- pygeofetch-2.0.2.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
- pygeofetch-2.0.2.dist-info/RECORD,,
107
+ pygeofetch-2.0.3.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
+ pygeofetch-2.0.3.dist-info/METADATA,sha256=ogUCAYOSzmGFw-n2_2siFpF3CNsKg1uB5mhjFfXv0pQ,43254
109
+ pygeofetch-2.0.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
+ pygeofetch-2.0.3.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
+ pygeofetch-2.0.3.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
+ pygeofetch-2.0.3.dist-info/RECORD,,