PyGeoFetch 2.6.1__py3-none-any.whl → 2.6.2.1__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 +1 -1
- pygeofetch/cli/auth_commands.py +6 -3
- pygeofetch/cli/main.py +10 -6
- pygeofetch/cli/monitor_commands.py +141 -0
- pygeofetch/cli/search_commands.py +1 -1
- pygeofetch/core/authenticator.py +5 -3
- pygeofetch/core/downloader.py +31 -13
- pygeofetch/core/engine.py +1 -1
- pygeofetch/core/monitor.py +164 -0
- pygeofetch/core/state.py +246 -0
- pygeofetch/insar/__init__.py +175 -48
- pygeofetch/insar/analysis.py +62 -40
- pygeofetch/insar/annotation.py +116 -70
- pygeofetch/insar/atmosphere.py +168 -70
- pygeofetch/insar/coregister.py +126 -58
- pygeofetch/insar/deburst.py +39 -12
- pygeofetch/insar/esd.py +65 -28
- pygeofetch/insar/extraction.py +126 -67
- pygeofetch/insar/flatearth.py +45 -18
- pygeofetch/insar/geolocation.py +176 -27
- pygeofetch/insar/gpu.py +3 -1
- pygeofetch/insar/interferogram.py +502 -193
- pygeofetch/insar/ionosphere.py +118 -45
- pygeofetch/insar/offset_geometry.py +203 -0
- pygeofetch/insar/offset_tracking.py +440 -0
- pygeofetch/insar/preflight.py +288 -169
- pygeofetch/insar/provenance.py +2 -1
- pygeofetch/insar/ps_selection.py +359 -0
- pygeofetch/insar/splitspectrum_diagnostic.py +12 -4
- pygeofetch/insar/stack_selection.py +179 -77
- pygeofetch/insar/synthetic.py +53 -17
- pygeofetch/insar/timeseries.py +663 -268
- pygeofetch/insar/unwrap.py +408 -156
- pygeofetch/insar/validate.py +146 -9
- pygeofetch/insar/visualize.py +15 -5
- pygeofetch/insar/workflow.py +102 -29
- pygeofetch/meta.yaml +1 -1
- pygeofetch/models/satellite_data.py +5 -3
- pygeofetch/processing/indices.py +5 -0
- pygeofetch/processing/preprocessor.py +55 -25
- pygeofetch/processing/sar.py +13 -4
- pygeofetch/processor/indices.py +4 -0
- pygeofetch/processor/stacker.py +4 -0
- pygeofetch/processor/timeseries.py +41 -13
- pygeofetch/providers/airbus_oneatlas.py +17 -4
- pygeofetch/providers/alaska_satellite_facility.py +5 -2
- pygeofetch/providers/aws_earth.py +1 -1
- pygeofetch/providers/copernicus.py +19 -8
- pygeofetch/providers/copernicus_nodes.py +73 -23
- pygeofetch/providers/digitalglobe.py +5 -2
- pygeofetch/providers/earth_explorer_additional.py +5 -2
- pygeofetch/providers/element84.py +1 -1
- pygeofetch/providers/eodag_provider.py +9 -5
- pygeofetch/providers/esa_scihub.py +17 -4
- pygeofetch/providers/geoserver_generic.py +5 -2
- pygeofetch/providers/google_earth_engine.py +17 -4
- pygeofetch/providers/inpe_cbers.py +5 -2
- pygeofetch/providers/isro_bhuvan.py +5 -2
- pygeofetch/providers/jaxa_earth.py +5 -2
- pygeofetch/providers/maxar_gbdx.py +5 -2
- pygeofetch/providers/nasa_earthdata.py +6 -4
- pygeofetch/providers/nasa_earthdata_cloud.py +2 -2
- pygeofetch/providers/noaa_big_data.py +5 -2
- pygeofetch/providers/opentopography.py +6 -2
- pygeofetch/providers/planet.py +4 -3
- pygeofetch/providers/planetary_computer.py +1 -1
- pygeofetch/providers/sentinel_hub.py +11 -7
- pygeofetch/providers/terrabotics.py +4 -3
- pygeofetch/providers/usgs.py +20 -5
- pygeofetch/sar/__init__.py +1 -1
- pygeofetch/sar/_native.py +1 -1
- pygeofetch/sar/extraction.py +8 -4
- pygeofetch/utils/gcp_recovery.py +1 -3
- pygeofetch/viz/map.py +82 -33
- pygeofetch/viz/plot.py +91 -38
- {pygeofetch-2.6.1.dist-info → pygeofetch-2.6.2.1.dist-info}/METADATA +25 -28
- pygeofetch-2.6.2.1.dist-info/RECORD +122 -0
- pygeofetch-2.6.1.dist-info/RECORD +0 -116
- {pygeofetch-2.6.1.dist-info → pygeofetch-2.6.2.1.dist-info}/WHEEL +0 -0
- {pygeofetch-2.6.1.dist-info → pygeofetch-2.6.2.1.dist-info}/entry_points.txt +0 -0
- {pygeofetch-2.6.1.dist-info → pygeofetch-2.6.2.1.dist-info}/licenses/LICENSE +0 -0
- {pygeofetch-2.6.1.dist-info → pygeofetch-2.6.2.1.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py
CHANGED
pygeofetch/cli/auth_commands.py
CHANGED
|
@@ -221,8 +221,11 @@ def auth_remove(provider: str, yes: bool) -> None:
|
|
|
221
221
|
click.confirm(f"Remove credentials for {provider!r}?", abort=True)
|
|
222
222
|
|
|
223
223
|
mgr = AuthManager()
|
|
224
|
-
mgr.
|
|
225
|
-
|
|
224
|
+
removed = mgr.remove(provider)
|
|
225
|
+
if removed:
|
|
226
|
+
console.print(f"[green]Credentials for [bold]{provider}[/] removed.[/]")
|
|
227
|
+
else:
|
|
228
|
+
console.print(f"[yellow]No stored credentials found for {provider!r}.[/]")
|
|
226
229
|
|
|
227
230
|
|
|
228
231
|
@auth.command(name="export")
|
|
@@ -250,7 +253,7 @@ def auth_export(provider: str | None, output: str) -> None:
|
|
|
250
253
|
from pygeofetch.core.authenticator import AuthManager
|
|
251
254
|
|
|
252
255
|
mgr = AuthManager()
|
|
253
|
-
data = mgr.export_credentials(
|
|
256
|
+
data = mgr.export_credentials(providers=[provider] if provider else None)
|
|
254
257
|
|
|
255
258
|
with open(output, "w") as f:
|
|
256
259
|
json.dump(data, f, indent=2, default=str)
|
pygeofetch/cli/main.py
CHANGED
|
@@ -484,9 +484,11 @@ def providers_info(provider_id: str, as_json: bool) -> None:
|
|
|
484
484
|
table.add_row("Auth type", caps.auth_type)
|
|
485
485
|
table.add_row(
|
|
486
486
|
"Auth status",
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
487
|
+
(
|
|
488
|
+
f"[green]{auth_status}[/]"
|
|
489
|
+
if "✓" in auth_status or "🌐" in auth_status
|
|
490
|
+
else f"[red]{auth_status}[/]"
|
|
491
|
+
),
|
|
490
492
|
)
|
|
491
493
|
table.add_row("Satellites", ", ".join(caps.satellites) if caps.satellites else "—")
|
|
492
494
|
table.add_row("SAR support", "[green]Yes[/]" if caps.supports_sar else "No")
|
|
@@ -495,9 +497,11 @@ def providers_info(provider_id: str, as_json: bool) -> None:
|
|
|
495
497
|
table.add_row("CQL2 filter", "[green]Yes[/]" if caps.supports_cql2 else "No")
|
|
496
498
|
table.add_row(
|
|
497
499
|
"Resolution",
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
500
|
+
(
|
|
501
|
+
f"{caps.resolution_min_m}m – {caps.resolution_max_m}m"
|
|
502
|
+
if caps.resolution_min_m
|
|
503
|
+
else "Varies"
|
|
504
|
+
),
|
|
501
505
|
)
|
|
502
506
|
table.add_row("Regions", ", ".join(caps.regions) if caps.regions else "global")
|
|
503
507
|
table.add_row("Endpoint", caps.endpoint_url or "—")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pygeofetch monitor — real CLI entry point for Feature 4 (Automated
|
|
3
|
+
Monitoring).
|
|
4
|
+
|
|
5
|
+
HONEST SCOPE, stated plainly rather than implied: this command wires
|
|
6
|
+
together the real, tested pieces already built --
|
|
7
|
+
pygeofetch.core.state.ProjectState and
|
|
8
|
+
pygeofetch.core.monitor.plan_monitoring_run -- to do real search and
|
|
9
|
+
real incremental planning (what's new, what pairs are needed). It
|
|
10
|
+
does NOT itself call download, coregistration, interferogram
|
|
11
|
+
formation, or invert_weighted() end to end here; those are the
|
|
12
|
+
existing, separately-tested real pipeline stages, and wiring all of
|
|
13
|
+
them into one CLI command is a real, substantial integration task of
|
|
14
|
+
its own that deserves the same direct verification already given to
|
|
15
|
+
each individual piece, not a rushed final assembly. What this command
|
|
16
|
+
does today: real search, real state-aware new-date detection, real
|
|
17
|
+
incremental pair planning, real persistence of results — genuinely
|
|
18
|
+
useful on its own for seeing what a scheduled run would actually do,
|
|
19
|
+
and the concrete foundation the remaining wiring builds on next.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
import click
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
|
|
29
|
+
from pygeofetch.core.monitor import plan_monitoring_run
|
|
30
|
+
from pygeofetch.core.state import ProjectState, RunSummary
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
logger = logging.getLogger("pygeofetch.cli.monitor")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.group(
|
|
37
|
+
name="monitor", help="Automated, incremental InSAR monitoring (Feature 4)."
|
|
38
|
+
)
|
|
39
|
+
def monitor():
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@monitor.command(
|
|
44
|
+
name="run",
|
|
45
|
+
help="Run one real monitoring cycle: search for new scenes, plan incremental processing.",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--state-db",
|
|
49
|
+
required=True,
|
|
50
|
+
type=click.Path(),
|
|
51
|
+
help="Path to this project's real state database.",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--bbox",
|
|
55
|
+
required=True,
|
|
56
|
+
type=str,
|
|
57
|
+
help="Real AOI bounding box, 'minlon,minlat,maxlon,maxlat'.",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--satellites",
|
|
61
|
+
default="Sentinel-1A,Sentinel-1B",
|
|
62
|
+
help="Comma-separated real satellite names.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--n-neighbors",
|
|
66
|
+
default=3,
|
|
67
|
+
type=int,
|
|
68
|
+
help="Real neighbors each new date connects to (spec default: 3).",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--cron-daily",
|
|
72
|
+
is_flag=True,
|
|
73
|
+
help="Marker flag for scheduled invocation (real cron job); does not change behavior, documents intent in logs.",
|
|
74
|
+
)
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def monitor_run(ctx, state_db, bbox, satellites, n_neighbors, cron_daily):
|
|
77
|
+
client = ctx.obj.get("client") if ctx.obj else None
|
|
78
|
+
if client is None:
|
|
79
|
+
from pygeofetch import PyGeoFetch
|
|
80
|
+
|
|
81
|
+
client = PyGeoFetch()
|
|
82
|
+
|
|
83
|
+
state = ProjectState(state_db)
|
|
84
|
+
|
|
85
|
+
minlon, minlat, maxlon, maxlat = (float(x) for x in bbox.split(","))
|
|
86
|
+
sat_list = [s.strip() for s in satellites.split(",")]
|
|
87
|
+
|
|
88
|
+
from pygeofetch.models.search_query import SearchQuery
|
|
89
|
+
|
|
90
|
+
query = SearchQuery(
|
|
91
|
+
bbox=(minlon, minlat, maxlon, maxlat),
|
|
92
|
+
satellites=sat_list,
|
|
93
|
+
product_type="SLC",
|
|
94
|
+
)
|
|
95
|
+
if state.last_download_timestamp:
|
|
96
|
+
query.start_date = state.last_download_timestamp
|
|
97
|
+
|
|
98
|
+
console.print(
|
|
99
|
+
f"[bold]pygeofetch monitor[/bold] — {'scheduled' if cron_daily else 'manual'} run"
|
|
100
|
+
)
|
|
101
|
+
console.print(f"State: {state_db}")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
results = client.search(query)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
state.record_run(RunSummary(status="failed", detail=f"search failed: {exc}"))
|
|
107
|
+
console.print(f"[red]Search failed:[/red] {exc}")
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
available_dates = sorted(
|
|
111
|
+
{str(r.datetime)[:10] for r in results if getattr(r, "datetime", None)}
|
|
112
|
+
)
|
|
113
|
+
plan = plan_monitoring_run(state, available_dates, n_neighbors=n_neighbors)
|
|
114
|
+
|
|
115
|
+
console.print(plan.message)
|
|
116
|
+
if plan.new_dates:
|
|
117
|
+
console.print(f" New dates: {plan.new_dates}")
|
|
118
|
+
console.print(f" New pairs needed: {len(plan.new_pairs)}")
|
|
119
|
+
|
|
120
|
+
state.record_run(
|
|
121
|
+
RunSummary(
|
|
122
|
+
status="success" if plan.network_changed else "no_new_scenes",
|
|
123
|
+
n_new_scenes=len(plan.new_dates),
|
|
124
|
+
detail=plan.message,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return plan
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@monitor.command(name="history", help="Show real run history for a monitoring project.")
|
|
132
|
+
@click.option("--state-db", required=True, type=click.Path(exists=True))
|
|
133
|
+
@click.option("--limit", default=20, type=int)
|
|
134
|
+
def monitor_history(state_db, limit):
|
|
135
|
+
state = ProjectState(state_db)
|
|
136
|
+
runs = state.run_history(limit=limit)
|
|
137
|
+
if not runs:
|
|
138
|
+
console.print("No real runs recorded yet.")
|
|
139
|
+
return
|
|
140
|
+
for run in runs:
|
|
141
|
+
console.print(f"[{run['status']}] {run['started_at']} — {run['detail']}")
|
|
@@ -67,7 +67,7 @@ def search() -> None:
|
|
|
67
67
|
@click.option(
|
|
68
68
|
"--polarisation",
|
|
69
69
|
default=None,
|
|
70
|
-
help=
|
|
70
|
+
help="SAR polarisation e.g. VV, VH, HH, HV. Same Copernicus-only caveat as --product-type.",
|
|
71
71
|
)
|
|
72
72
|
@click.option("--max-results", "-n", default=100, show_default=True)
|
|
73
73
|
@click.option(
|
pygeofetch/core/authenticator.py
CHANGED
|
@@ -447,9 +447,11 @@ class AuthManager:
|
|
|
447
447
|
{
|
|
448
448
|
"provider": provider,
|
|
449
449
|
"has_session": session is not None and session.is_valid,
|
|
450
|
-
"session_expires":
|
|
451
|
-
|
|
452
|
-
|
|
450
|
+
"session_expires": (
|
|
451
|
+
session.expires_at.isoformat()
|
|
452
|
+
if session and session.expires_at
|
|
453
|
+
else None
|
|
454
|
+
),
|
|
453
455
|
}
|
|
454
456
|
)
|
|
455
457
|
return result
|
pygeofetch/core/downloader.py
CHANGED
|
@@ -25,7 +25,7 @@ import hashlib
|
|
|
25
25
|
import time
|
|
26
26
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
27
27
|
from pathlib import Path
|
|
28
|
-
from typing import TYPE_CHECKING, Any, Callable, Dict
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, cast
|
|
29
29
|
|
|
30
30
|
from pygeofetch.core.logging import DownloadProgress, get_logger
|
|
31
31
|
from pygeofetch.models.download_task import (
|
|
@@ -365,16 +365,19 @@ class AdaptiveDownloader:
|
|
|
365
365
|
sum(1 for r in results if r and r.success)
|
|
366
366
|
sum(1 for r in results if r and not r.success)
|
|
367
367
|
dp.__exit__(None, None, None)
|
|
368
|
-
assert len(results) == len(
|
|
369
|
-
|
|
370
|
-
)
|
|
368
|
+
assert len(results) == len(
|
|
369
|
+
data_list
|
|
370
|
+
), f"BUG: result count {len(results)} != input count {len(data_list)}"
|
|
371
371
|
sum(
|
|
372
372
|
r.bytes_downloaded / (1024 * 1024)
|
|
373
373
|
for r in results
|
|
374
374
|
if r and r.success and r.bytes_downloaded
|
|
375
375
|
)
|
|
376
376
|
# summary printed by DownloadProgress footer
|
|
377
|
-
|
|
377
|
+
# The loop above has already replaced every None slot with a
|
|
378
|
+
# real DownloadResult (failure placeholder), so this is
|
|
379
|
+
# genuinely list[DownloadResult] at this point.
|
|
380
|
+
return cast(List[DownloadResult], results)
|
|
378
381
|
|
|
379
382
|
def _get_provider(self, provider_id: str) -> Any:
|
|
380
383
|
"""Get a provider instance with a fresh authenticated session."""
|
|
@@ -889,7 +892,7 @@ class AdaptiveDownloader:
|
|
|
889
892
|
from pygeofetch.processor import SpectralIndex
|
|
890
893
|
except ImportError as exc:
|
|
891
894
|
raise ImportError(
|
|
892
|
-
f
|
|
895
|
+
f"{index_name} requires the processor extra: "
|
|
893
896
|
f'pip install "pygeofetch[processor]"'
|
|
894
897
|
) from exc
|
|
895
898
|
|
|
@@ -904,7 +907,10 @@ class AdaptiveDownloader:
|
|
|
904
907
|
(
|
|
905
908
|
p
|
|
906
909
|
for p in result.output_paths
|
|
907
|
-
if any(
|
|
910
|
+
if any(
|
|
911
|
+
pat in p.name or pat.lower() in p.name.lower()
|
|
912
|
+
for pat in patterns
|
|
913
|
+
)
|
|
908
914
|
),
|
|
909
915
|
None,
|
|
910
916
|
)
|
|
@@ -914,8 +920,15 @@ class AdaptiveDownloader:
|
|
|
914
920
|
f"{band_name} (looked for {patterns} in filenames)"
|
|
915
921
|
)
|
|
916
922
|
with rasterio.open(path) as src:
|
|
917
|
-
arr =
|
|
918
|
-
|
|
923
|
+
arr = (
|
|
924
|
+
src.read(
|
|
925
|
+
1,
|
|
926
|
+
out_shape=ref_shape,
|
|
927
|
+
resampling=rasterio.enums.Resampling.bilinear,
|
|
928
|
+
)
|
|
929
|
+
if ref_shape
|
|
930
|
+
else src.read(1)
|
|
931
|
+
)
|
|
919
932
|
if ref_shape is None:
|
|
920
933
|
ref_shape = arr.shape
|
|
921
934
|
profile = src.profile.copy()
|
|
@@ -929,6 +942,12 @@ class AdaptiveDownloader:
|
|
|
929
942
|
arr = np.where(arr == src.nodata, np.nan, arr)
|
|
930
943
|
band_arrays[band_name] = arr
|
|
931
944
|
|
|
945
|
+
assert ref_shape is not None and profile is not None, (
|
|
946
|
+
f"{index_name}: required_bands was empty or the loop never "
|
|
947
|
+
f"ran -- ref_shape/profile must be set by the first band "
|
|
948
|
+
f"processed above."
|
|
949
|
+
)
|
|
950
|
+
|
|
932
951
|
si = SpectralIndex(prefer_spyndex=False)
|
|
933
952
|
index_arr = np.asarray(si.compute(index_name, **band_arrays), dtype=np.float32)
|
|
934
953
|
# Any band being NaN at a pixel must make the index NaN there too,
|
|
@@ -950,7 +969,6 @@ class AdaptiveDownloader:
|
|
|
950
969
|
logger.info(f"{index_name} computed → {out_path}")
|
|
951
970
|
return result
|
|
952
971
|
|
|
953
|
-
|
|
954
972
|
def _find_existing_download(
|
|
955
973
|
self, data: "SatelliteData", destination: Path
|
|
956
974
|
) -> Path | None:
|
|
@@ -1230,7 +1248,8 @@ class AdaptiveDownloader:
|
|
|
1230
1248
|
"%s has no direct CRS/transform, using its %d "
|
|
1231
1249
|
"ground control points for reprojection instead "
|
|
1232
1250
|
"(common for raw SAR/GRD delivery).",
|
|
1233
|
-
source_path.name,
|
|
1251
|
+
source_path.name,
|
|
1252
|
+
len(src_gcps),
|
|
1234
1253
|
)
|
|
1235
1254
|
elif src_crs is None:
|
|
1236
1255
|
raise RuntimeError(
|
|
@@ -1301,7 +1320,6 @@ class AdaptiveDownloader:
|
|
|
1301
1320
|
)
|
|
1302
1321
|
raise RuntimeError(msg)
|
|
1303
1322
|
|
|
1304
|
-
|
|
1305
1323
|
def _backoff(self, attempt: int, options: DownloadOptions) -> float:
|
|
1306
1324
|
"""Calculate retry delay based on strategy."""
|
|
1307
1325
|
import random
|
|
@@ -1334,4 +1352,4 @@ class AdaptiveDownloader:
|
|
|
1334
1352
|
@property
|
|
1335
1353
|
def _result_total_size_mb(self) -> float:
|
|
1336
1354
|
"""Property used by result objects."""
|
|
1337
|
-
return 0.0
|
|
1355
|
+
return 0.0
|
pygeofetch/core/engine.py
CHANGED
|
@@ -484,4 +484,4 @@ class PyGeoFetch:
|
|
|
484
484
|
|
|
485
485
|
def __repr__(self) -> str:
|
|
486
486
|
authed = [item["provider"] for item in self.auth.list()]
|
|
487
|
-
return f"PyGeoFetch(authenticated={authed}, cache_entries={len(self.searcher.cache._cache)})"
|
|
487
|
+
return f"PyGeoFetch(authenticated={authed}, cache_entries={len(self.searcher.cache._cache)})"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real incremental-monitoring orchestration for pygeofetch's InSAR
|
|
3
|
+
pipeline — Feature 4's "incremental ingestion" and "incremental
|
|
4
|
+
inversion" pieces, built on top of the already-real, already-tested
|
|
5
|
+
ProjectState (state.py).
|
|
6
|
+
|
|
7
|
+
Deliberately does NOT implement a true incremental linear-algebra
|
|
8
|
+
update to the SBAS design matrix (e.g. a real rank-1 Cholesky update).
|
|
9
|
+
The spec's own real requirement is narrower and more honest: don't
|
|
10
|
+
reprocess the entire historical archive's raw SLC/interferogram
|
|
11
|
+
formation when new scenes arrive, only form the new pairs actually
|
|
12
|
+
needed to connect new dates to the existing network, then re-run the
|
|
13
|
+
already-verified, already-tested invert_weighted() on the combined
|
|
14
|
+
pair list. For the realistic problem sizes this pipeline already
|
|
15
|
+
handles (Berardino et al. 2002's own formulation, N < 200 or so real
|
|
16
|
+
dates), re-solving the full linear system is genuinely fast — the
|
|
17
|
+
expensive part this feature actually saves is search, download,
|
|
18
|
+
coregistration, and interferogram formation for scenes that were
|
|
19
|
+
already processed, not the linear algebra itself. Building a real,
|
|
20
|
+
correct incremental linear solver would be new numerical work with
|
|
21
|
+
its own correctness risk, for a part of the pipeline that was never
|
|
22
|
+
actually the bottleneck.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, List, Sequence, Tuple
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("pygeofetch.core.monitor")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_incremental_pairs(
|
|
35
|
+
new_dates: Sequence[str],
|
|
36
|
+
existing_dates: Sequence[str],
|
|
37
|
+
n_neighbors: int = 3,
|
|
38
|
+
) -> List[Tuple[str, str]]:
|
|
39
|
+
"""
|
|
40
|
+
Real, precise implementation of the spec's own stated requirement:
|
|
41
|
+
"generate only the new interferogram pairs connecting [a new
|
|
42
|
+
scene] to the 3 most recent temporal neighbors in the existing
|
|
43
|
+
network" — deliberately narrower than the existing
|
|
44
|
+
generate_candidate_pairs() (which takes every pair within a
|
|
45
|
+
temporal-baseline window), because a monitoring run's real goal is
|
|
46
|
+
connecting new dates into the network cheaply, not re-deriving the
|
|
47
|
+
full candidate pool.
|
|
48
|
+
|
|
49
|
+
Each new date is paired with its n_neighbors chronologically
|
|
50
|
+
nearest dates from the REAL UNION of existing_dates and any other
|
|
51
|
+
new_dates that sort before it — so a batch of several new dates
|
|
52
|
+
arriving together correctly connects to each other too, not just
|
|
53
|
+
to the old, already-processed archive.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
new_dates: Real new acquisition dates not yet in the network.
|
|
57
|
+
existing_dates: Real dates already processed and in the
|
|
58
|
+
existing network (ProjectState.processed_dates()).
|
|
59
|
+
n_neighbors: How many nearest existing/earlier dates to pair
|
|
60
|
+
each new date with (spec default: 3).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Real list of (earlier_date, later_date) pairs, each pair
|
|
64
|
+
genuinely new (never duplicating an existing_dates-only pair,
|
|
65
|
+
since both existing dates were already connected in a prior run).
|
|
66
|
+
"""
|
|
67
|
+
all_dates_sorted = sorted(set(existing_dates) | set(new_dates))
|
|
68
|
+
date_to_idx = {d: i for i, d in enumerate(all_dates_sorted)}
|
|
69
|
+
|
|
70
|
+
pairs: List[Tuple[str, str]] = []
|
|
71
|
+
seen = set()
|
|
72
|
+
|
|
73
|
+
for new_date in sorted(new_dates):
|
|
74
|
+
idx = date_to_idx[new_date]
|
|
75
|
+
# Real candidates: every OTHER real date in the combined,
|
|
76
|
+
# chronologically sorted list, ranked by real temporal distance
|
|
77
|
+
candidates = [d for d in all_dates_sorted if d != new_date]
|
|
78
|
+
candidates_by_distance = sorted(
|
|
79
|
+
candidates,
|
|
80
|
+
key=lambda d: abs(date_to_idx[d] - idx),
|
|
81
|
+
)
|
|
82
|
+
neighbors = candidates_by_distance[:n_neighbors]
|
|
83
|
+
for neighbor in neighbors:
|
|
84
|
+
a, b = sorted([new_date, neighbor])
|
|
85
|
+
pair: Tuple[str, str] = (a, b)
|
|
86
|
+
if pair not in seen:
|
|
87
|
+
seen.add(pair)
|
|
88
|
+
pairs.append(pair)
|
|
89
|
+
|
|
90
|
+
return pairs
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class MonitoringRunResult:
|
|
95
|
+
"""Real, honest outcome of one monitor() call."""
|
|
96
|
+
|
|
97
|
+
new_dates: List[str]
|
|
98
|
+
new_pairs: List[Tuple[str, str]]
|
|
99
|
+
network_changed: bool
|
|
100
|
+
ran_inversion: bool
|
|
101
|
+
message: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def plan_monitoring_run(
|
|
105
|
+
state: Any, # ProjectState
|
|
106
|
+
available_dates: Sequence[str],
|
|
107
|
+
n_neighbors: int = 3,
|
|
108
|
+
) -> MonitoringRunResult:
|
|
109
|
+
"""
|
|
110
|
+
Real, idempotent planning step for one monitoring run: given the
|
|
111
|
+
real dates a fresh search actually returned and the real state
|
|
112
|
+
from all prior runs, decides exactly what's new and exactly which
|
|
113
|
+
new pairs are needed — without downloading, processing, or
|
|
114
|
+
inverting anything itself. Separated from execution deliberately,
|
|
115
|
+
so this planning logic can be tested on its own without needing a
|
|
116
|
+
real Copernicus connection, real SLC data, or a real inversion —
|
|
117
|
+
matching how the rest of this project keeps orchestration logic
|
|
118
|
+
testable independent of expensive I/O.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
state: A real ProjectState for this monitoring project.
|
|
122
|
+
available_dates: Real dates a fresh search returned right now
|
|
123
|
+
(e.g. from client.search() against the real Copernicus API).
|
|
124
|
+
n_neighbors: Passed through to generate_incremental_pairs().
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
MonitoringRunResult describing exactly what this run would do.
|
|
128
|
+
network_changed=False and ran_inversion=False with a real,
|
|
129
|
+
honest message when there's genuinely nothing new — this is
|
|
130
|
+
the case the spec's own idempotency requirement is really
|
|
131
|
+
about: running twice on the same day with no new scenes must
|
|
132
|
+
do nothing the second time, not silently re-download or
|
|
133
|
+
re-invert.
|
|
134
|
+
"""
|
|
135
|
+
already_processed = set(state.processed_dates())
|
|
136
|
+
new_dates = sorted(set(available_dates) - already_processed)
|
|
137
|
+
|
|
138
|
+
if not new_dates:
|
|
139
|
+
return MonitoringRunResult(
|
|
140
|
+
new_dates=[],
|
|
141
|
+
new_pairs=[],
|
|
142
|
+
network_changed=False,
|
|
143
|
+
ran_inversion=False,
|
|
144
|
+
message="No new dates since last run -- nothing to do.",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
new_pairs = generate_incremental_pairs(
|
|
148
|
+
new_dates, sorted(already_processed), n_neighbors
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
"Monitoring run: %d new date(s), %d new pair(s) needed (%d neighbors each).",
|
|
153
|
+
len(new_dates),
|
|
154
|
+
len(new_pairs),
|
|
155
|
+
n_neighbors,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return MonitoringRunResult(
|
|
159
|
+
new_dates=new_dates,
|
|
160
|
+
new_pairs=new_pairs,
|
|
161
|
+
network_changed=True,
|
|
162
|
+
ran_inversion=False,
|
|
163
|
+
message=f"{len(new_dates)} new date(s), {len(new_pairs)} new pair(s) planned.",
|
|
164
|
+
)
|