Skip to content

Commit 87be946

Browse files
committed
parallelize ESA scrapes/KML I/O and add nested progress bars for overpass fetching
1 parent d603f19 commit 87be946

10 files changed

Lines changed: 301 additions & 79 deletions

next_pass.py

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def find_next_overpass(args: argparse.Namespace, timestamp_dir: Path) -> dict:
148148
from utils.cloudiness import api_limit_reached
149149
from utils.landsat_pass import next_landsat_pass
150150
from utils.nisar_pass import next_nisar_pass
151+
from utils.progress import overpass_progress
151152
from utils.sentinel_pass import next_sentinel_pass
152153
from utils.utils import bbox_to_geometry, bbox_type
153154

@@ -177,31 +178,54 @@ def find_next_overpass(args: argparse.Namespace, timestamp_dir: Path) -> dict:
177178
pred_cloudiness and "sentinel-1" in selected and "sentinel-2" in selected
178179
)
179180

180-
# Fetch conditionally
181-
if "sentinel-1" in selected:
182-
LOGGER.info("Fetching Sentinel-1 data...")
183-
sentinel1 = next_sentinel_pass(
184-
"sentinel1", geometry, n_day_past, pred_cloudiness, pred_tide
185-
)
186-
187-
if "sentinel-2" in selected:
188-
LOGGER.info("Fetching Sentinel-2 data...")
189-
190-
if needs_weather_backoff and not api_limit_reached():
191-
LOGGER.info("Waiting 1 min to avoid hitting cumulative weather API quota.")
192-
time.sleep(60)
181+
# Fetch conditionally, wrapped in a master progress bar (one sub bar per
182+
# satellite). The bar is disabled on non-TTY runs; the LOGGER.info lines
183+
# below remain the feedback path in that case.
184+
with overpass_progress(len(selected)) as progress:
185+
if "sentinel-1" in selected:
186+
LOGGER.info("Fetching Sentinel-1 data...")
187+
with progress.satellite("Sentinel-1") as step_cb:
188+
sentinel1 = next_sentinel_pass(
189+
"sentinel1",
190+
geometry,
191+
n_day_past,
192+
pred_cloudiness,
193+
pred_tide,
194+
step_cb=step_cb,
195+
)
193196

194-
sentinel2 = next_sentinel_pass(
195-
"sentinel2", geometry, n_day_past, pred_cloudiness, pred_tide
196-
)
197+
if "sentinel-2" in selected:
198+
LOGGER.info("Fetching Sentinel-2 data...")
199+
with progress.satellite("Sentinel-2") as step_cb:
200+
if needs_weather_backoff and not api_limit_reached():
201+
LOGGER.info(
202+
"Waiting 1 min to avoid hitting cumulative weather API quota."
203+
)
204+
step_cb("Waiting on weather API quota")
205+
time.sleep(60)
206+
207+
sentinel2 = next_sentinel_pass(
208+
"sentinel2",
209+
geometry,
210+
n_day_past,
211+
pred_cloudiness,
212+
pred_tide,
213+
step_cb=step_cb,
214+
)
197215

198-
if "nisar" in selected:
199-
LOGGER.info("Fetching NISAR data...")
200-
nisar = next_nisar_pass(geometry, n_day_past, arg_tide=pred_tide)
216+
if "nisar" in selected:
217+
LOGGER.info("Fetching NISAR data...")
218+
with progress.satellite("NISAR") as step_cb:
219+
nisar = next_nisar_pass(
220+
geometry, n_day_past, arg_tide=pred_tide, step_cb=step_cb
221+
)
201222

202-
if "landsat" in selected:
203-
LOGGER.info("Fetching Landsat data...")
204-
landsat = next_landsat_pass(lat_min, lon_min, geometry, n_day_past, pred_tide)
223+
if "landsat" in selected:
224+
LOGGER.info("Fetching Landsat data...")
225+
with progress.satellite("Landsat") as step_cb:
226+
landsat = next_landsat_pass(
227+
lat_min, lon_min, geometry, n_day_past, pred_tide, step_cb=step_cb
228+
)
205229

206230
return {
207231
"sentinel-1": sentinel1,
@@ -329,6 +353,10 @@ def main(cli_args: Any = None):
329353
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
330354
)
331355

356+
# Silence noisy third-party INFO chatter (e.g. pyogrio "Created N records")
357+
for noisy in ("pyogrio", "pyogrio._io", "fiona", "fiona._env"):
358+
logging.getLogger(noisy).setLevel(logging.WARNING)
359+
332360
from utils.opera_products import (
333361
export_opera_products,
334362
find_print_available_opera_products,

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ tabulate
1111
yagmail
1212
openpyxl>=3.1.5
1313
timezonefinder
14+
rich
1415
pre-commit>=4.6.1
1516
black>=26.5.1
1617
isort>=8.0.1

tests/test_collection_builder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def test_build_sentinel_collection_uses_cached_and_parsed_files(monkeypatch, tmp
5353
(),
5454
{
5555
"info": lambda *args, **kwargs: None,
56+
"debug": lambda *args, **kwargs: None,
5657
"warning": lambda *args, **kwargs: None,
5758
"error": lambda *args, **kwargs: None,
5859
},
@@ -112,6 +113,7 @@ def test_build_sentinel_collection_returns_empty_path_when_no_frames(
112113
(),
113114
{
114115
"info": lambda *args, **kwargs: None,
116+
"debug": lambda *args, **kwargs: None,
115117
"warning": lambda *args, **kwargs: None,
116118
"error": lambda *args, **kwargs: None,
117119
},

tests/test_next_pass_cli.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,17 @@ def fake_make_opera_granule_map(results_dict, bbox, timestamp_dir):
8585
cloudiness.api_limit_reached = lambda: True
8686
utils_mod.bbox_type = lambda bbox: bbox
8787
utils_mod.bbox_to_geometry = fake_bbox_to_geometry
88-
sentinel_pass.next_sentinel_pass = lambda sat, geometry, n_day_past, pred_cloudiness, pred_tide=False: {{
88+
sentinel_pass.next_sentinel_pass = lambda sat, geometry, n_day_past, pred_cloudiness, pred_tide=False, **kwargs: {{
8989
"next_collect_info": sat,
9090
"next_collect_geometry": [geometry],
9191
"next_collect_summary": [sat],
9292
}}
93-
nisar_pass.next_nisar_pass = lambda geometry, n_day_past, arg_tide=False: {{
93+
nisar_pass.next_nisar_pass = lambda geometry, n_day_past, arg_tide=False, **kwargs: {{
9494
"next_collect_info": "nisar",
9595
"next_collect_geometry": [geometry],
9696
"next_collect_summary": ["nisar"],
9797
}}
98-
landsat_pass.next_landsat_pass = lambda lat, lon, geometry, n_day_past, arg_tide=False: {{
98+
landsat_pass.next_landsat_pass = lambda lat, lon, geometry, n_day_past, arg_tide=False, **kwargs: {{
9999
"next_collect_info": "landsat",
100100
"next_collect_geometry": [geometry],
101101
"next_collect_summary": ["landsat"],
@@ -233,22 +233,22 @@ def test_find_next_overpass_routes_all_satellites(monkeypatch, tmp_path):
233233
monkeypatch.setattr(
234234
sentinel_pass,
235235
"next_sentinel_pass",
236-
lambda sat, geometry, n_day_past, pred_cloudiness, pred_tide=False: sentinel_calls.append(
236+
lambda sat, geometry, n_day_past, pred_cloudiness, pred_tide=False, **kwargs: sentinel_calls.append(
237237
(sat, geometry.name, n_day_past, pred_cloudiness)
238238
)
239239
or {"next_collect_info": sat},
240240
)
241241
monkeypatch.setattr(
242242
nisar_pass,
243243
"next_nisar_pass",
244-
lambda geometry, n_day_past, arg_tide=False: {
244+
lambda geometry, n_day_past, arg_tide=False, **kwargs: {
245245
"next_collect_info": f"nisar-{geometry.name}-{n_day_past}"
246246
},
247247
)
248248
monkeypatch.setattr(
249249
landsat_pass,
250250
"next_landsat_pass",
251-
lambda lat, lon, geometry, n_day_past, arg_tide=False: {
251+
lambda lat, lon, geometry, n_day_past, arg_tide=False, **kwargs: {
252252
"next_collect_info": f"landsat-{lat}-{lon}-{n_day_past}"
253253
},
254254
)
@@ -288,7 +288,7 @@ def test_find_next_overpass_routes_single_satellite(monkeypatch, tmp_path):
288288
monkeypatch.setattr(
289289
landsat_pass,
290290
"next_landsat_pass",
291-
lambda lat, lon, geometry, n_day_past, arg_tide=False: {
291+
lambda lat, lon, geometry, n_day_past, arg_tide=False, **kwargs: {
292292
"lat": lat,
293293
"lon": lon,
294294
"name": geometry.name,

utils/collection_builder.py

Lines changed: 70 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from concurrent.futures import ThreadPoolExecutor
23
from datetime import datetime, timedelta, timezone
34
from pathlib import Path
45
from typing import List
@@ -52,20 +53,40 @@ def sync_scratch_directory(
5253
except Exception as e:
5354
logger.error("Failed to delete %s: %s", file_path, e)
5455

55-
# Download missing files
56-
local_kml_paths: List[Path] = []
57-
for url in urls:
58-
filename = f"{mission_name}_{Path(url).stem}.kml"
59-
file_path = scratch_dir / filename
56+
# Map each url to its target path, preserving order
57+
url_paths = [
58+
(url, scratch_dir / f"{mission_name}_{Path(url).stem}.kml") for url in urls
59+
]
6060

61-
if file_path.name in missing_files or not file_path.exists():
61+
# Determine which files are missing and need downloading
62+
to_download = [
63+
(url, file_path)
64+
for url, file_path in url_paths
65+
if file_path.name in missing_files or not file_path.exists()
66+
]
67+
68+
# Download missing files concurrently (network-bound)
69+
failed: set = set()
70+
if to_download:
71+
72+
def _download(item):
73+
url, file_path = item
6274
try:
6375
download_kml(url, str(file_path))
76+
return None
6477
except Exception as e:
6578
logger.error("Failed downloading %s: %s", url, e)
66-
continue
79+
return file_path
6780

68-
local_kml_paths.append(file_path)
81+
with ThreadPoolExecutor(max_workers=min(len(to_download), 8)) as executor:
82+
for result in executor.map(_download, to_download):
83+
if result is not None:
84+
failed.add(result)
85+
86+
# Return local paths in original url order, skipping failed downloads
87+
local_kml_paths: List[Path] = [
88+
file_path for _, file_path in url_paths if file_path not in failed
89+
]
6990

7091
return local_kml_paths
7192

@@ -101,51 +122,63 @@ def build_sentinel_collection(
101122
if platforms:
102123
platform_by_name = {Path(u).stem.lower(): p for u, p in zip(urls, platforms)}
103124

104-
gdfs: list[gpd.GeoDataFrame] = []
105-
106-
for kml_path in local_kml_paths:
125+
def _resolve_platform(kml_path: Path) -> str | None:
126+
if not platform_by_name:
127+
return None
128+
stem = kml_path.stem.lower()
129+
# first attempt: direct match
130+
platform = platform_by_name.get(stem)
131+
# second attempt: drop leading token
132+
if platform is None and "_" in stem:
133+
stem_id = "_".join(stem.split("_")[1:])
134+
platform = platform_by_name.get(stem_id)
135+
# last resort: partial match
136+
if platform is None:
137+
for key, value in platform_by_name.items():
138+
if key in stem:
139+
platform = value
140+
break
141+
return platform
142+
143+
def _load_kml(kml_path: Path) -> gpd.GeoDataFrame | None:
144+
"""Read cached geojson or parse KML (CPU-bound), tag with platform."""
107145
collection_path = SCRATCH_DIR / f"{kml_path.stem}.geojson"
108-
platform = None
109-
110-
if platform_by_name:
111-
stem = kml_path.stem.lower()
112-
# first attempt: direct match
113-
platform = platform_by_name.get(stem)
114-
115-
# second attempt: drop leading token
116-
if platform is None and "_" in stem:
117-
stem_id = "_".join(stem.split("_")[1:])
118-
platform = platform_by_name.get(stem_id)
119-
120-
# last resort: partial match
121-
if platform is None:
122-
for key, value in platform_by_name.items():
123-
if key in stem:
124-
platform = value
125-
break
126146

127147
if collection_path.exists():
128-
logger.info("Using cached file: %s", collection_path)
148+
logger.debug("Using cached file: %s", collection_path)
129149
try:
130150
gdf = gpd.read_file(collection_path)
131151
except Exception as e:
132152
logger.error("Failed reading %s: %s", collection_path, e)
133-
continue
153+
return None
134154
else:
135-
logger.info("Parsing new file: %s", kml_path)
155+
logger.debug("Parsing new file: %s", kml_path)
136156
try:
137157
gdf = parse_kml(kml_path)
138158
if not gdf.empty:
139159
gdf.to_file(collection_path)
140160
else:
141161
logger.warning("No valid data in file: %s", kml_path)
142-
continue
162+
return None
143163
except Exception as e:
144164
logger.error("Failed parsing %s: %s", kml_path, e)
145-
continue
146-
147-
gdf["platform"] = platform
148-
gdfs.append(gdf)
165+
return None
166+
167+
gdf["platform"] = _resolve_platform(kml_path)
168+
return gdf
169+
170+
# Parse/read each KML concurrently. Order is irrelevant: the results are
171+
# concatenated and re-sorted by begin_date below. Each writes a distinct
172+
# geojson path, so there is no write collision.
173+
if local_kml_paths:
174+
with ThreadPoolExecutor(max_workers=min(len(local_kml_paths), 8)) as executor:
175+
gdfs = [
176+
gdf
177+
for gdf in executor.map(_load_kml, local_kml_paths)
178+
if gdf is not None
179+
]
180+
else:
181+
gdfs = []
149182

150183
if not gdfs:
151184
logger.error("No valid GeoDataFrames created.")

utils/landsat_pass.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ def next_landsat_pass(
451451
geometryAOI,
452452
n_day_past: float,
453453
arg_tide: bool = False,
454+
step_cb=None,
454455
) -> dict | None:
455456
"""
456457
Retrieve and format the next Landsat passes for a given location.
@@ -462,6 +463,7 @@ def next_landsat_pass(
462463
intersection percentage.
463464
n_day_past (float): Number of days in the past to search cycles JSON.
464465
arg_tide (bool): Whether to compute NOAA tide predictions per overpass.
466+
step_cb: Optional callable(label: str) for coarse progress reporting.
465467
466468
Returns:
467469
dict or None: Dictionary containing next Landsat passes information
@@ -470,14 +472,20 @@ def next_landsat_pass(
470472
session = requests.Session()
471473

472474
try:
475+
if step_cb:
476+
step_cb("Resolving path/row")
473477
results = ll2pr(geometryAOI, session=session)
478+
if step_cb:
479+
step_cb("Downloading schedule")
474480
schedule_source = load_landsat_schedule_source(session)
475481
aggregated_data = defaultdict(
476482
lambda: {"rows": set(), "overlap_pct": 0.0, "dates": None, "warnings": []}
477483
)
478484
geometry_groups = defaultdict(list)
479485

480486
# First pass: collect all features by key
487+
if step_cb:
488+
step_cb("Finding overpasses")
481489
features_by_key = defaultdict(list)
482490
for direction, features in results.items():
483491
if features:
@@ -506,6 +514,8 @@ def next_landsat_pass(
506514
)
507515

508516
# Second pass: aggregate features with proper geometry union
517+
if step_cb:
518+
step_cb("Computing intersection")
509519
for key, features in features_by_key.items():
510520
for feature in features:
511521
aggregated_data[key]["rows"].add(feature["row"])
@@ -543,6 +553,8 @@ def next_landsat_pass(
543553
noaa_stations = None
544554
tide_data_by_key = {}
545555
if arg_tide:
556+
if step_cb:
557+
step_cb("Predicting tides")
546558
try:
547559
noaa_stations = get_stations_in_aoi(geometryAOI)
548560
if not noaa_stations:
@@ -652,6 +664,8 @@ def next_landsat_pass(
652664
filtered_aggregated_data[key] = data
653665
aggregated_data = filtered_aggregated_data
654666

667+
if step_cb:
668+
step_cb("Formatting")
655669
row_data_with_keys = []
656670
header_time_str = ""
657671
for key, data in aggregated_data.items():

0 commit comments

Comments
 (0)