PyGeoFetch 2.6.2.3__py3-none-any.whl → 2.6.2.5__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.
Files changed (48) hide show
  1. pygeofetch/__init__.py +1 -1
  2. pygeofetch/cli/main.py +4 -0
  3. pygeofetch/cli/multisensor_commands.py +243 -0
  4. pygeofetch/cli/optical_commands.py +105 -0
  5. pygeofetch/cli/search_commands.py +121 -8
  6. pygeofetch/core/data_organizer.py +364 -0
  7. pygeofetch/core/engine.py +8 -4
  8. pygeofetch/insar/advanced_safeguards.py +370 -0
  9. pygeofetch/insar/preflight.py +136 -1
  10. pygeofetch/meta.yaml +1 -1
  11. pygeofetch/models/satellite_data.py +49 -0
  12. pygeofetch/multisensor/dem_differencing.py +201 -0
  13. pygeofetch/multisensor/flood_mapping.py +215 -0
  14. pygeofetch/multisensor/insar_optical_fusion.py +269 -0
  15. pygeofetch/multisensor/multisensor_init.py +55 -0
  16. pygeofetch/multisensor/terrain_corrected_change.py +285 -0
  17. pygeofetch/multisensor/vegetation_disturbance.py +220 -0
  18. pygeofetch/optical/__init__.py +35 -0
  19. pygeofetch/optical/offset_tracking.py +789 -0
  20. pygeofetch/processor/indices.py +143 -11
  21. pygeofetch/providers/__init__.py +2 -4
  22. pygeofetch/providers/airbus_oneatlas.py +1 -1
  23. pygeofetch/providers/alaska_satellite_facility.py +202 -97
  24. pygeofetch/providers/digitalglobe.py +281 -112
  25. pygeofetch/providers/earth_explorer_additional.py +95 -245
  26. pygeofetch/providers/geoserver_generic.py +212 -91
  27. pygeofetch/providers/inpe_cbers.py +114 -96
  28. pygeofetch/providers/isro_bhuvan.py +201 -98
  29. pygeofetch/providers/jaxa_earth.py +170 -150
  30. pygeofetch/providers/maxar_gbdx.py +122 -90
  31. pygeofetch/providers/nasa_earthdata.py +26 -6
  32. pygeofetch/providers/nasa_earthdata_cloud.py +114 -38
  33. pygeofetch/providers/opentopography.py +203 -7
  34. pygeofetch/providers/planet.py +23 -2
  35. pygeofetch/providers/sentinel_hub.py +50 -25
  36. pygeofetch/sar/__init__.py +28 -1
  37. pygeofetch/sar/math.py +100 -0
  38. pygeofetch/sar/pipelines.py +573 -0
  39. pygeofetch/sar/processor.py +371 -97
  40. pygeofetch/utils/sar_math.py +100 -0
  41. pygeofetch/validation/optical_validator.py +69 -33
  42. pygeofetch/viz/plot.py +24 -5
  43. {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/METADATA +5 -10
  44. {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/RECORD +48 -33
  45. {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/WHEEL +0 -0
  46. {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/entry_points.txt +0 -0
  47. {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/licenses/LICENSE +0 -0
  48. {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py CHANGED
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.6.2.3"
33
+ __version__ = "2.6.2.5.4"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
pygeofetch/cli/main.py CHANGED
@@ -26,6 +26,8 @@ from pygeofetch.cli.auth_commands import auth
26
26
  from pygeofetch.cli.config_commands import config
27
27
  from pygeofetch.cli.download_commands import download
28
28
  from pygeofetch.cli.index_commands import index
29
+ from pygeofetch.cli.multisensor_commands import multisensor
30
+ from pygeofetch.cli.optical_commands import optical
29
31
  from pygeofetch.cli.pipeline_process_commands import proc_pipeline
30
32
  from pygeofetch.cli.postprocess_commands import post
31
33
  from pygeofetch.cli.preprocess_commands import preprocess
@@ -103,6 +105,8 @@ cli.add_command(index)
103
105
  cli.add_command(post)
104
106
  cli.add_command(sar)
105
107
  cli.add_command(proc_pipeline)
108
+ cli.add_command(optical)
109
+ cli.add_command(multisensor)
106
110
 
107
111
 
108
112
  # ---------------------------------------------------------------------------
@@ -0,0 +1,243 @@
1
+ """CLI access to pygeofetch's 5 real multi-sensor pipelines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+
10
+ @click.group()
11
+ def multisensor() -> None:
12
+ """Multi-sensor fusion pipelines: InSAR+optical, SAR+optical flood
13
+ mapping, terrain-corrected change, DEM differencing, vegetation
14
+ disturbance. See docs/processing/multi-sensor-pipelines.md."""
15
+
16
+
17
+ def _print_result(result) -> None:
18
+ if result.success:
19
+ click.echo(f"✓ Success -> {result.final_output}")
20
+ if result.metadata:
21
+ click.echo(
22
+ json.dumps(
23
+ {k: str(v) for k, v in result.metadata.items()},
24
+ indent=2,
25
+ )
26
+ )
27
+ else:
28
+ click.echo(f"✗ Failed: {result.error}", err=True)
29
+ raise SystemExit(1)
30
+
31
+
32
+ @multisensor.command("insar-optical-fusion")
33
+ @click.option("--insar-velocity", required=True, type=click.Path(exists=True))
34
+ @click.option("--insar-coherence", required=True, type=click.Path(exists=True))
35
+ @click.option("--optical-ref", required=True, type=click.Path(exists=True))
36
+ @click.option("--optical-sec", required=True, type=click.Path(exists=True))
37
+ @click.option("--output-dir", required=True, type=click.Path())
38
+ @click.option("--window-size", default=64, show_default=True, type=int)
39
+ @click.option("--step-size", default=16, show_default=True, type=int)
40
+ @click.option("--snr-threshold", default=3.0, show_default=True, type=float)
41
+ @click.option("--coherence-low", default=0.4, show_default=True, type=float)
42
+ @click.option("--coherence-high", default=0.6, show_default=True, type=float)
43
+ @click.option("--cloud-mask", default=None, type=click.Path(exists=True))
44
+ def insar_optical_fusion_cmd(
45
+ insar_velocity,
46
+ insar_coherence,
47
+ optical_ref,
48
+ optical_sec,
49
+ output_dir,
50
+ window_size,
51
+ step_size,
52
+ snr_threshold,
53
+ coherence_low,
54
+ coherence_high,
55
+ cloud_mask,
56
+ ):
57
+ """Fuse InSAR displacement with optical pixel offset tracking."""
58
+ from pygeofetch.multisensor import insar_optical_displacement_pipeline
59
+
60
+ result = insar_optical_displacement_pipeline(
61
+ insar_velocity,
62
+ insar_coherence,
63
+ optical_ref,
64
+ optical_sec,
65
+ output_dir,
66
+ cloud_mask_path=cloud_mask,
67
+ window_size=window_size,
68
+ step_size=step_size,
69
+ snr_threshold=snr_threshold,
70
+ coherence_low=coherence_low,
71
+ coherence_high=coherence_high,
72
+ )
73
+ _print_result(result)
74
+
75
+
76
+ @multisensor.command("flood-map")
77
+ @click.option("--sar", "sar_input", required=True, type=click.Path(exists=True))
78
+ @click.option("--optical-green", required=True, type=click.Path(exists=True))
79
+ @click.option("--optical-nir", required=True, type=click.Path(exists=True))
80
+ @click.option("--output-dir", required=True, type=click.Path())
81
+ @click.option("--sar-reference", default=None, type=click.Path(exists=True))
82
+ @click.option("--sar-threshold", default=-15.0, show_default=True, type=float)
83
+ @click.option("--ndwi-threshold", default=0.0, show_default=True, type=float)
84
+ @click.option("--cloud-mask", default=None, type=click.Path(exists=True))
85
+ @click.option(
86
+ "--fusion-mode",
87
+ default="cloud_aware",
88
+ show_default=True,
89
+ type=click.Choice(["cloud_aware", "union", "intersection"]),
90
+ )
91
+ def flood_map_cmd(
92
+ sar_input,
93
+ optical_green,
94
+ optical_nir,
95
+ output_dir,
96
+ sar_reference,
97
+ sar_threshold,
98
+ ndwi_threshold,
99
+ cloud_mask,
100
+ fusion_mode,
101
+ ):
102
+ """Fuse SAR and optical NDWI for flood/water extent mapping."""
103
+ from pygeofetch.multisensor import multi_sensor_flood_pipeline
104
+ from pygeofetch.sar import SARProcessor
105
+
106
+ result = multi_sensor_flood_pipeline(
107
+ SARProcessor(),
108
+ sar_input,
109
+ optical_green,
110
+ optical_nir,
111
+ output_dir,
112
+ sar_reference_path=sar_reference,
113
+ sar_threshold=sar_threshold,
114
+ optical_ndwi_threshold=ndwi_threshold,
115
+ cloud_mask_path=cloud_mask,
116
+ fusion_mode=fusion_mode,
117
+ )
118
+ _print_result(result)
119
+
120
+
121
+ @multisensor.command("terrain-change")
122
+ @click.option("--pre", "pre_path", required=True, type=click.Path(exists=True))
123
+ @click.option("--post", "post_path", required=True, type=click.Path(exists=True))
124
+ @click.option("--dem", "dem_path", required=True, type=click.Path(exists=True))
125
+ @click.option(
126
+ "--pre-datetime",
127
+ required=True,
128
+ help="Real UTC ISO datetime, e.g. 2024-01-15T10:30:00",
129
+ )
130
+ @click.option("--post-datetime", required=True, help="Real UTC ISO datetime")
131
+ @click.option("--lat", "latitude", required=True, type=float)
132
+ @click.option("--lon", "longitude", required=True, type=float)
133
+ @click.option("--output-dir", required=True, type=click.Path())
134
+ @click.option("--change-threshold", default=0.1, show_default=True, type=float)
135
+ def terrain_change_cmd(
136
+ pre_path,
137
+ post_path,
138
+ dem_path,
139
+ pre_datetime,
140
+ post_datetime,
141
+ latitude,
142
+ longitude,
143
+ output_dir,
144
+ change_threshold,
145
+ ):
146
+ """Terrain-corrected optical change detection (removes real
147
+ illumination artifacts using each date's own solar position)."""
148
+ from datetime import datetime, timezone
149
+
150
+ from pygeofetch.multisensor import terrain_corrected_change_pipeline
151
+
152
+ def _parse(dt_str):
153
+ dt = datetime.fromisoformat(dt_str)
154
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
155
+
156
+ result = terrain_corrected_change_pipeline(
157
+ pre_path,
158
+ post_path,
159
+ dem_path,
160
+ _parse(pre_datetime),
161
+ _parse(post_datetime),
162
+ latitude,
163
+ longitude,
164
+ output_dir,
165
+ change_threshold=change_threshold,
166
+ )
167
+ _print_result(result)
168
+
169
+
170
+ @multisensor.command("dem-diff")
171
+ @click.option("--dem-old", required=True, type=click.Path(exists=True))
172
+ @click.option("--dem-new", required=True, type=click.Path(exists=True))
173
+ @click.option("--output-dir", required=True, type=click.Path())
174
+ @click.option(
175
+ "--old-rmse",
176
+ default=1.0,
177
+ show_default=True,
178
+ type=float,
179
+ help="Real vertical RMSE of the old DEM, metres.",
180
+ )
181
+ @click.option(
182
+ "--new-rmse",
183
+ default=1.0,
184
+ show_default=True,
185
+ type=float,
186
+ help="Real vertical RMSE of the new DEM, metres.",
187
+ )
188
+ @click.option("--confidence-t", default=1.96, show_default=True, type=float)
189
+ def dem_diff_cmd(dem_old, dem_new, output_dir, old_rmse, new_rmse, confidence_t):
190
+ """DEM-of-Difference volumetric change analysis."""
191
+ from pygeofetch.multisensor import dem_differencing_pipeline
192
+
193
+ result = dem_differencing_pipeline(
194
+ dem_old,
195
+ dem_new,
196
+ output_dir,
197
+ dem_old_vertical_rmse_m=old_rmse,
198
+ dem_new_vertical_rmse_m=new_rmse,
199
+ confidence_t_value=confidence_t,
200
+ )
201
+ _print_result(result)
202
+
203
+
204
+ @multisensor.command("vegetation-disturbance")
205
+ @click.option("--red-pre", required=True, type=click.Path(exists=True))
206
+ @click.option("--nir-pre", required=True, type=click.Path(exists=True))
207
+ @click.option("--red-post", required=True, type=click.Path(exists=True))
208
+ @click.option("--nir-post", required=True, type=click.Path(exists=True))
209
+ @click.option("--slc-pre", required=True, type=click.Path(exists=True))
210
+ @click.option("--slc-post", required=True, type=click.Path(exists=True))
211
+ @click.option("--output-dir", required=True, type=click.Path())
212
+ @click.option("--ndvi-drop-threshold", default=0.15, show_default=True, type=float)
213
+ @click.option("--coherence-threshold", default=0.3, show_default=True, type=float)
214
+ @click.option("--coherence-window", default=7, show_default=True, type=int)
215
+ def vegetation_disturbance_cmd(
216
+ red_pre,
217
+ nir_pre,
218
+ red_post,
219
+ nir_post,
220
+ slc_pre,
221
+ slc_post,
222
+ output_dir,
223
+ ndvi_drop_threshold,
224
+ coherence_threshold,
225
+ coherence_window,
226
+ ):
227
+ """Classify visible canopy loss vs. real sub-canopy ground
228
+ disturbance, from optical NDVI + SAR coherence."""
229
+ from pygeofetch.multisensor import vegetation_disturbance_pipeline
230
+
231
+ result = vegetation_disturbance_pipeline(
232
+ red_pre,
233
+ nir_pre,
234
+ red_post,
235
+ nir_post,
236
+ slc_pre,
237
+ slc_post,
238
+ output_dir,
239
+ ndvi_drop_threshold=ndvi_drop_threshold,
240
+ coherence_threshold=coherence_threshold,
241
+ coherence_window=coherence_window,
242
+ )
243
+ _print_result(result)
@@ -0,0 +1,105 @@
1
+ """CLI commands for optical pixel offset tracking (POT)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def optical() -> None:
15
+ """Optical pixel offset tracking — measures large 2D ground
16
+ displacement via image correlation, complementing InSAR for
17
+ motion that exceeds phase InSAR's coherence limit."""
18
+
19
+
20
+ @optical.command("offset-track")
21
+ @click.option("--ref", "reference_path", required=True, type=click.Path(exists=True), help="Reference-date raster.")
22
+ @click.option("--sec", "secondary_path", required=True, type=click.Path(exists=True), help="Secondary-date raster.")
23
+ @click.option("--output", "-o", required=True, type=click.Path(), help="Output GeoTIFF (dx/dy/snr, 3 bands).")
24
+ @click.option("--band", default=1, show_default=True, type=int, help="1-based band index to correlate.")
25
+ @click.option("--window-size", default=64, show_default=True, type=int, help="Search window size, pixels.")
26
+ @click.option("--step-size", default=16, show_default=True, type=int, help="Spacing between window centers, pixels.")
27
+ @click.option("--chip-size", default=None, type=int, help="Reference template size, pixels. Default: window-size // 2.")
28
+ @click.option("--snr-threshold", default=3.0, show_default=True, type=float, help="Minimum SNR to mark a window reliable.")
29
+ @click.option("--cloud-mask", default=None, type=click.Path(exists=True), help="Real Sentinel-2 SCL raster, already aligned to --ref.")
30
+ @click.option("--water-mask-threshold", default=None, type=float, help="Mask pixels where NDWI exceeds this value (needs --green-band/--nir-band).")
31
+ @click.option("--green-band", default=None, type=int, help="1-based green band index, for --water-mask-threshold.")
32
+ @click.option("--nir-band", default=None, type=int, help="1-based NIR band index, for --water-mask-threshold.")
33
+ def offset_track_cmd(
34
+ reference_path,
35
+ secondary_path,
36
+ output,
37
+ band,
38
+ window_size,
39
+ step_size,
40
+ chip_size,
41
+ snr_threshold,
42
+ cloud_mask,
43
+ water_mask_threshold,
44
+ green_band,
45
+ nir_band,
46
+ ):
47
+ """
48
+ Measure 2D ground displacement between two optical scenes via
49
+ image correlation.
50
+
51
+ \b
52
+ Examples:
53
+ # Basic: two co-registered scenes, real cloud masking via SCL
54
+ pygeofetch optical offset-track \\
55
+ --ref before.tif --sec after.tif \\
56
+ --cloud-mask before_SCL.tif \\
57
+ --output displacement.tif
58
+
59
+ # Mining-scale displacement, larger search window
60
+ pygeofetch optical offset-track \\
61
+ --ref jan.tif --sec jun.tif \\
62
+ --window-size 96 --step-size 24 \\
63
+ --output mine_displacement.tif
64
+ """
65
+ from pygeofetch.optical.offset_tracking import compute_pixel_offsets, prepare_optical_pair
66
+
67
+ try:
68
+ ref_arr, sec_arr, ref_profile = prepare_optical_pair(
69
+ reference_path,
70
+ secondary_path,
71
+ band_index=band,
72
+ cloud_mask_path=cloud_mask,
73
+ water_mask_threshold=water_mask_threshold,
74
+ green_band_index=green_band,
75
+ nir_band_index=nir_band,
76
+ )
77
+ except ValueError as exc:
78
+ console.print(f"[red]✗[/] {exc}")
79
+ sys.exit(1)
80
+
81
+ pixel_size_m = abs(ref_profile["transform"].a)
82
+ result = compute_pixel_offsets(
83
+ ref_arr,
84
+ sec_arr,
85
+ pixel_size_m=pixel_size_m,
86
+ window_size=window_size,
87
+ step_size=step_size,
88
+ chip_size=chip_size,
89
+ snr_threshold=snr_threshold,
90
+ )
91
+
92
+ out_transform = ref_profile["transform"] * ref_profile["transform"].scale(step_size, step_size)
93
+ out_profile = dict(ref_profile)
94
+ out_profile.update(
95
+ height=result.dx.shape[0],
96
+ width=result.dx.shape[1],
97
+ transform=out_transform,
98
+ )
99
+ written = result.export_geotiff(output, out_profile)
100
+
101
+ n_reliable = int(result.reliable.sum())
102
+ n_total = int(result.reliable.size)
103
+ console.print(f"[green]✓[/] offset-track → {written}")
104
+ console.print(f" {n_reliable}/{n_total} windows reliable ({100 * n_reliable / n_total:.1f}%)")
105
+ console.print(f" window={window_size}px step={step_size}px pixel_size={pixel_size_m:.2f}m")
@@ -282,6 +282,7 @@ def search_run(
282
282
 
283
283
  sb = PyGeoFetch()
284
284
 
285
+ optical_config = None
285
286
  if validate_optical and (
286
287
  optical_max_cloud_cover,
287
288
  optical_min_coverage,
@@ -298,22 +299,48 @@ def search_run(
298
299
  overrides["required_bands"] = [
299
300
  b.strip() for b in optical_required_bands.split(",")
300
301
  ]
301
- sb.optical_validation_config = OpticalValidationConfig(**overrides)
302
+ optical_config = OpticalValidationConfig(**overrides)
303
+ sb.optical_validation_config = optical_config
302
304
 
303
305
  with console.status(
304
306
  f"[cyan]Searching {len(provider_list or [])} provider(s)...[/]"
305
307
  ):
306
- results = sb.search(
307
- query,
308
- providers=provider_list,
309
- use_cache=not no_cache,
310
- validate_optical=validate_optical,
311
- )
308
+ if validate_optical:
309
+ # Real, deliberate choice: search WITHOUT filtering here, then
310
+ # validate directly in this CLI layer, rather than calling
311
+ # search(validate_optical=True) and losing visibility into
312
+ # what got rejected and why. sb.search()'s own contract
313
+ # (returns only the passed scenes) is left untouched for
314
+ # every other caller -- this is a CLI-only reporting path,
315
+ # not a change to the library's real filtering behavior.
316
+ raw_results = sb.search(
317
+ query,
318
+ providers=provider_list,
319
+ use_cache=not no_cache,
320
+ validate_optical=False,
321
+ )
322
+ results, validation_summary = _validate_and_summarize(
323
+ sb, raw_results, query, optical_config
324
+ )
325
+ else:
326
+ results = sb.search(
327
+ query,
328
+ providers=provider_list,
329
+ use_cache=not no_cache,
330
+ validate_optical=False,
331
+ )
332
+ validation_summary = None
312
333
 
313
334
  if not results:
314
335
  console.print("[yellow]No results found.[/]")
315
336
  return
316
337
 
338
+ # Real, clean validation info + full results table, organized so the
339
+ # summary always appears before the detailed table -- not interleaved
340
+ # with save/format output below.
341
+ if validation_summary is not None:
342
+ _display_validation_summary(validation_summary)
343
+
317
344
  # Save if requested
318
345
  if output:
319
346
  out_path = Path(output)
@@ -365,6 +392,92 @@ def search_run(
365
392
  click.echo(r.id)
366
393
 
367
394
 
395
+ def _validate_and_summarize(sb, raw_results, query, optical_config):
396
+ """
397
+ Real validation pass over every raw candidate, returning both the
398
+ scenes that actually pass and a real, structured summary of what got
399
+ rejected and why -- the information sb.search(validate_optical=True)
400
+ alone discards once it returns only the filtered list.
401
+ """
402
+ from collections import Counter
403
+
404
+ from pygeofetch.validation import OpticalPreflightValidator, OpticalValidationConfig
405
+
406
+ validator = OpticalPreflightValidator(optical_config or OpticalValidationConfig())
407
+ aoi = sb._derive_aoi_polygon(query)
408
+
409
+ passed_results = []
410
+ rejection_reasons: Counter = Counter()
411
+ warning_reasons: Counter = Counter()
412
+ n_warned_but_passed = 0
413
+
414
+ for scene in raw_results:
415
+ report = validator.validate_scene(scene, aoi, query.start_date, query.end_date)
416
+ if report.passed:
417
+ passed_results.append(scene)
418
+ if report.issues:
419
+ n_warned_but_passed += 1
420
+ for issue in report.issues:
421
+ warning_reasons[issue.code] += 1
422
+ else:
423
+ for issue in report.issues:
424
+ if issue.severity == "ERROR":
425
+ rejection_reasons[issue.code] += 1
426
+
427
+ summary = {
428
+ "n_total": len(raw_results),
429
+ "n_passed": len(passed_results),
430
+ "n_rejected": len(raw_results) - len(passed_results),
431
+ "n_warned_but_passed": n_warned_but_passed,
432
+ "rejection_reasons": rejection_reasons,
433
+ "warning_reasons": warning_reasons,
434
+ }
435
+ return passed_results, summary
436
+
437
+
438
+ def _display_validation_summary(summary: dict) -> None:
439
+ """Clean, real info block summarizing optical validation -- shown
440
+ before the detailed results table, not interleaved with it."""
441
+ n_total = summary["n_total"]
442
+ n_passed = summary["n_passed"]
443
+ n_rejected = summary["n_rejected"]
444
+ pct = (100 * n_passed / n_total) if n_total else 0.0
445
+
446
+ console.print(
447
+ f"\n[bold]Optical validation:[/] {n_passed}/{n_total} scenes passed "
448
+ f"({pct:.0f}%)"
449
+ + (f", [yellow]{n_rejected} rejected[/]" if n_rejected else "")
450
+ + (
451
+ f", [cyan]{summary['n_warned_but_passed']} passed with warnings[/]"
452
+ if summary["n_warned_but_passed"]
453
+ else ""
454
+ )
455
+ )
456
+
457
+ if summary["rejection_reasons"]:
458
+ reasons_table = Table(
459
+ title="Rejection reasons", header_style="bold red", show_lines=False
460
+ )
461
+ reasons_table.add_column("Code", style="red")
462
+ reasons_table.add_column("Count", justify="right")
463
+ for code, count in summary["rejection_reasons"].most_common():
464
+ reasons_table.add_row(code, str(count))
465
+ console.print(reasons_table)
466
+
467
+ if summary["warning_reasons"]:
468
+ warn_table = Table(
469
+ title="Warnings on passed scenes",
470
+ header_style="bold yellow",
471
+ show_lines=False,
472
+ )
473
+ warn_table.add_column("Code", style="yellow")
474
+ warn_table.add_column("Count", justify="right")
475
+ for code, count in summary["warning_reasons"].most_common():
476
+ warn_table.add_row(code, str(count))
477
+ console.print(warn_table)
478
+ console.print() # real spacing before the full results table below
479
+
480
+
368
481
  def _display_table(results) -> None:
369
482
  table = Table(title=f"{len(results)} Results", header_style="bold blue")
370
483
  table.add_column("ID", style="cyan", max_width=30)
@@ -441,4 +554,4 @@ def _save_csv(results, path: Path) -> None:
441
554
  )
442
555
  writer.writeheader()
443
556
  for r in results:
444
- writer.writerow(_item_to_dict(r))
557
+ writer.writerow(_item_to_dict(r))