asp-plot 3.4.0__tar.gz → 3.6.0__tar.gz

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 (61) hide show
  1. {asp_plot-3.4.0 → asp_plot-3.6.0}/.gitignore +3 -1
  2. {asp_plot-3.4.0 → asp_plot-3.6.0}/AGENTS.md +30 -12
  3. {asp_plot-3.4.0 → asp_plot-3.6.0}/ARCHITECTURE.md +3 -1
  4. {asp_plot-3.4.0 → asp_plot-3.6.0}/CHANGELOG.md +31 -0
  5. {asp_plot-3.4.0 → asp_plot-3.6.0}/PKG-INFO +1 -1
  6. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/asp_report.py +16 -0
  7. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/dem_benchmark.py +31 -6
  8. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/report_pipeline.py +42 -6
  9. {asp_plot-3.4.0 → asp_plot-3.6.0}/pyproject.toml +1 -1
  10. {asp_plot-3.4.0 → asp_plot-3.6.0}/.flake8 +0 -0
  11. {asp_plot-3.4.0 → asp_plot-3.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {asp_plot-3.4.0 → asp_plot-3.6.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
  13. {asp_plot-3.4.0 → asp_plot-3.6.0}/.github/workflows/claude-code-review.yml +0 -0
  14. {asp_plot-3.4.0 → asp_plot-3.6.0}/.github/workflows/claude.yml +0 -0
  15. {asp_plot-3.4.0 → asp_plot-3.6.0}/.github/workflows/release.yml +0 -0
  16. {asp_plot-3.4.0 → asp_plot-3.6.0}/.github/workflows/run-tests.yml +0 -0
  17. {asp_plot-3.4.0 → asp_plot-3.6.0}/.pre-commit-config.yaml +0 -0
  18. {asp_plot-3.4.0 → asp_plot-3.6.0}/.readthedocs.yaml +0 -0
  19. {asp_plot-3.4.0 → asp_plot-3.6.0}/CLAUDE.md +0 -0
  20. {asp_plot-3.4.0 → asp_plot-3.6.0}/LICENSE +0 -0
  21. {asp_plot-3.4.0 → asp_plot-3.6.0}/README.md +0 -0
  22. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/__init__.py +0 -0
  23. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/alignment.py +0 -0
  24. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/altimetry.py +0 -0
  25. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/altimetry_plots.py +0 -0
  26. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/altimetry_source.py +0 -0
  27. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/asp_log.py +0 -0
  28. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/bodies.py +0 -0
  29. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/bundle_adjust.py +0 -0
  30. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/__init__.py +0 -0
  31. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/bundle_adjust_cameras.py +0 -0
  32. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  33. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/dem_benchmark.py +0 -0
  34. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/gallery.py +0 -0
  35. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  36. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/cli/stereo_geom.py +0 -0
  37. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/csm_analysis.py +0 -0
  38. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/csm_camera.py +0 -0
  39. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/csm_io.py +0 -0
  40. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/gallery.py +0 -0
  41. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/icesat2_source.py +0 -0
  42. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/mapproject.py +0 -0
  43. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/planetary_source.py +0 -0
  44. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/processing_parameters.py +0 -0
  45. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/report.py +0 -0
  46. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/report_captions.py +0 -0
  47. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/scenes.py +0 -0
  48. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/selections.py +0 -0
  49. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/__init__.py +0 -0
  50. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/aster.py +0 -0
  51. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/base.py +0 -0
  52. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/dimap.py +0 -0
  53. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/dimap_v1.py +0 -0
  54. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/rpc.py +0 -0
  55. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/sensors/worldview.py +0 -0
  56. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/stereo.py +0 -0
  57. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/stereo_geometry.py +0 -0
  58. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/stereopair_metadata_parser.py +0 -0
  59. {asp_plot-3.4.0 → asp_plot-3.6.0}/asp_plot/utils.py +0 -0
  60. {asp_plot-3.4.0 → asp_plot-3.6.0}/conda-forge-recipe/meta.yaml +0 -0
  61. {asp_plot-3.4.0 → asp_plot-3.6.0}/environment.yml +0 -0
@@ -140,7 +140,9 @@ notebooks/**/*.parquet
140
140
  notebooks/**/*.csv
141
141
  CLAUDE/
142
142
  .claude/**
143
- scripts/
143
+ # Study driver scripts live in a separate repo; scripts is often a symlink to it,
144
+ # which a trailing-slash pattern (directories only) does not match.
145
+ /scripts
144
146
  /*.parquet
145
147
  reports/regenerate_reports.sh
146
148
  # Figure-selections sidecars written next to each report by `asp_report`. These are
@@ -52,15 +52,35 @@ To publish a regenerated set:
52
52
 
53
53
  ```bash
54
54
  bash reports/regenerate_reports.sh # gitignored; needs ~/Desktop/asp-plot-examples
55
+
56
+ # Switch the Zenodo webhook off for the publish (needs repo admin; see below)
57
+ HOOK=$(gh api repos/uw-cryo/asp_plot/hooks \
58
+ --jq '.[] | select(.config.url | contains("zenodo.org")) | .id')
59
+ gh api -X PATCH repos/uw-cryo/asp_plot/hooks/$HOOK -F active=false --jq .active
60
+
55
61
  gh release create reports-$(date +%F) \
56
62
  reports/*.pdf reports/*_figure_selections.yml reports/regenerate_reports.sh \
57
63
  --target main --latest=false \
58
64
  --title "Example reports, $(date +%F)" --notes "What changed and why."
65
+
66
+ # Confirm nothing was delivered for the new release, then switch it back on
67
+ gh api "repos/uw-cryo/asp_plot/hooks/$HOOK/deliveries?per_page=3" \
68
+ --jq '.[] | "\(.delivered_at) \(.event) \(.action)"'
69
+ gh api -X PATCH repos/uw-cryo/asp_plot/hooks/$HOOK -F active=true --jq .active
59
70
  # then bump REPORTS_RELEASE in docs/fetch_example_reports.sh and commit that one line
60
71
  ```
61
72
 
62
- Four things matter here:
63
-
73
+ Five things matter here:
74
+
75
+ - **Keep the release off Zenodo.** The repository has a Zenodo webhook on
76
+ `release` events, and Zenodo archives every published release as a version of
77
+ the software: it cannot tell a reports release from a version release. The
78
+ `reports-2026-09-18` release was archived that way and, being the newest
79
+ record, became what the DOI badge in the README resolves to. Switch the
80
+ webhook off before publishing and back on afterwards, as above; GitHub does
81
+ not re-send the events it skipped. Always pass `--jq` when reading the hook,
82
+ because its URL embeds an access token that must not end up in a log or a
83
+ comment. A published Zenodo record can only be removed by Zenodo support.
64
84
  - **Publish the release before pushing the commit** that points at it. The docs
65
85
  build uses `curl -f`, so a tag that does not exist yet fails the build.
66
86
  - **`--latest=false`**, or the reports release takes the "Latest" badge from the
@@ -69,13 +89,11 @@ Four things matter here:
69
89
  hardcode absolute local paths) but they are what makes regeneration
70
90
  figure-stable and offline (issue #121), so keeping them with the PDFs they
71
91
  produced makes a release self-describing.
72
- - **Attach `regenerate_reports.sh` as well, for the same reason and one more.**
73
- It is gitignored too, which means it is in no clone *and in no backup* — a
74
- mirror of this repository contains zero objects for it, because git never
75
- tracked it. The release is therefore its only durable copy, and a release that
76
- carries the reports, the selections that shaped them, and the commands that
77
- produced them can be reproduced by someone who has the example datasets and
78
- nothing else.
92
+ - **Attach `regenerate_reports.sh` as well, for the same reason.** It is
93
+ gitignored too, so no clone of this repository contains it. Attaching it keeps
94
+ a copy with each set of reports, and a release that carries the reports, the
95
+ selections that shaped them, and the commands that produced them can be
96
+ reproduced by someone who has the example datasets and nothing else.
79
97
 
80
98
  Always regenerate with `--reuse-selections` so pages stay figure-for-figure
81
99
  comparable and no SlideRule request is made. Reports are capped at 200 effective
@@ -93,9 +111,9 @@ what keeps the assets small.
93
111
  - **There is no "unadjusted" CSM state on disk after a `bundle_adjust` run** — it writes only `*.adjusted_state.json` for the *optimized* cameras, while the originals exist solely as vendor XML. To get the before-camera for `csm_camera_summary_plot()`, re-run with an identity transform: write a 4x4 identity to `identity.txt` and pass `--initial-transform identity.txt --apply-initial-transform-only` with the same images/XMLs into a separate `-o` prefix. That skips iterations, matching, and reports (seconds, not minutes), and the resulting `.adjust` files are exactly zero translation + identity quaternion, so you can verify the cameras really are untouched. Demonstrated in `notebooks/WorldView/worldview_spacenet_ucsd_stereo.ipynb`.
94
112
  - **CSM camera *differences* deliberately diverge from ASP's `orbit_plot.py`** (#53): `csm_analysis.read_angles_common_frame()` puts both cameras in one satellite body frame estimated from the *original* ephemeris, instead of estimating a frame per camera from its own positions the way `csm_io.read_angles()` (the ASP mirror) does. A solver perturbs positions *and* resamples the ephemeris finer, and over the resulting ~140 m central-difference baseline a 2 m perturbation tilts a per-camera frame by ~0.8° — which is what made the example figure look full of outliers. Angle diffs are also wrapped to [-180, 180). Don't "re-sync" either back to `read_angles()`; `csm_io.py` stays a verbatim ASP mirror and is not where this belongs.
95
113
  - **Airbus DIMAP quaternions are scalar-first** (`Q0` = scalar); they are reordered to the scalar-last `q1..q4` layout the roll/pitch/yaw code expects in `PleiadesMetadata.getAtt_df()`. Don't "fix" the reorder.
96
- - **The altimetry outlier cut is a 30-NMAD gate plus 3σ; both values are deliberate.** A whole ICESat-2 pass can be cloud (over UCSD one pass was a marine-layer return 150–210 m above ground, 21 % of the sample), which defeats a plain mean ± 3σ cut by inflating σ; the gate removes it. Don't "simplify" the cut to median ± 3 NMAD: on the heavy-tailed residuals a DSM has against ICESat-2 in a city that removes a fifth of the real points and lowers every reported NMAD by a third (`_outlier_mask` in `altimetry_source.py`; the case is described in the benchmark notebook's UCSD section). If a benchmark or report shows an RMSE far above its NMAD, look for a cloud pass in the residual map before touching the DEM.
97
- - **`asp_plot.sensors.rpc.read_rpc()` refuses any raster that carries a CRS**, on purpose: an RPC describes the raw image grid, and ASP's `mapproject` copies RPC metadata onto its outputs. However, GDAL sometimes writes a lat/lon geotransform from a NITF's corner coordinates when converting it (one of five CORE3D scenes came out that way), and the reader then calls a raw scene "already map-projected". Strip it with `gdal_edit.py -unsetgt -a_srs "" scene.tif`.
98
- - **SpaceNet CORE3D NITF scenes are JPEG2000, and the ASP 3.8.0-alpha build ships GDAL's JP2OpenJPEG driver as a plugin it never loads**: `gdalinfo` and rasterio both fail with *No JPEG2000 capable driver is available*. `GDAL_DRIVER_PATH=<ASP root>/lib` for one `gdal_translate` to GeoTIFF is enough; the older 3.7.0 build had the driver built in, which is why earlier UCSD runs never hit this.
114
+ - **The altimetry outlier cut is a 30-NMAD gate plus 3σ; both values are deliberate.** A whole ICESat-2 pass can be cloud (over UCSD one pass was a marine-layer return 150–210 m above ground, 21 % of the sample), which defeats a plain mean ± 3σ cut by inflating σ; the gate removes it. Don't "simplify" the cut to median ± 3 NMAD: on the heavy-tailed residuals a DSM has against ICESat-2 in a city that removes a fifth of the real points and lowers every reported NMAD by a third (`_outlier_mask` in `altimetry_source.py`). If a benchmark or report shows an RMSE far above its NMAD, look for a cloud pass in the residual map before touching the DEM.
115
+ - **`asp_plot.sensors.rpc.read_rpc()` refuses any raster that carries a CRS**, on purpose: an RPC describes the raw image grid, and ASP's `mapproject` copies RPC metadata onto its outputs.
116
+ - **SpaceNet CORE3D NITF scenes are JPEG2000, and the ASP 3.8.0-alpha build ships GDAL's JP2OpenJPEG driver as a plugin it never loads**: `gdalinfo` and rasterio both fail with *No JPEG2000 capable driver is available*. `GDAL_DRIVER_PATH=<ASP root>/lib` for one `gdal_translate` to GeoTIFF is enough; the older 3.7.0 build had the driver built in, which is why earlier UCSD runs never hit this. The conda-forge `asp-3.7.0` env on NAS ships it as a plugin too, under `$ASP_ROOT/lib/gdalplugins`.
99
117
  - **ASP multiview triangulation of mapprojected images needs `ISISROOT`** (observed with ASP 3.8.0-alpha, non-ISIS `-t pleiades` session): the joint triangulation aborts with an uncatchable `Isis::IException` (`$ISISROOT/IsisPreferences was not found`) that surfaces as a generic "Failed to run"/killed job. Workaround: `export ISISROOT=<ASP install root>` (the release bundles `IsisPreferences` there). Pair runs and raw-image multiview runs are unaffected; full write-up in a PR #155 comment.
100
118
 
101
119
  ## External Data Sources
@@ -62,6 +62,7 @@ The package is organized by functionality, with each module focused on a specifi
62
62
  - A declarative section registry (`REPORT_SECTIONS`) of `ReportSpec`s replaces the old inline plot-and-append wall: each spec pairs an `enabled(ctx)` predicate with a `build(ctx)` function returning the sections to append. `--no-geometry` / `--no-altimetry` / `--no-pc-align` gating are predicates; figure numbering comes from a per-run counter on the shared `ReportContext`, so section order and numbering are data, not source-line position. The alignment "Page B/C/D" follow-ups are one spec emitting several sections
63
63
  - Section builders: `_build_input_scenes`, `_build_stereo_geometry`, `_build_match_points`, `_build_bundle_adjust`, `_build_disparity`, `_build_dem_results`, `_build_detailed_hillshade`, `_build_altimetry` (→ `_build_altimetry_earth` / `_build_altimetry_planetary`)
64
64
  - `_numbered_sections()`: shared helper turning a plotter's saved-filename list into one `ReportSection` per figure (first gets the caption, continuations are titled "... (continued)") — used by the scenes/geometry/match/disparity builders, whose plotters save one figure per pair on multi-view runs (issue #160)
65
+ - `_keep_figures()`: with `--figures-directory`, copies each section's PNG out of `tmp_asp_report_plots/` as `NN_<title-slug>.png` after `compile_report()` has numbered the sections, and before the plots directory is deleted. The title is in the name because figure numbers shift when a section is gated off
65
66
 
66
67
  **`report_captions.py`** - Caption + description text for the report sections (issue #128)
67
68
  - All section captions and the Earth/planetary alignment descriptions as module constants / small builder functions, moved out of the CLI as a data module (verified byte-identical to the pre-refactor strings via AST comparison)
@@ -246,6 +247,7 @@ All CLI tools are in `asp_plot/cli/` and use Click for argument parsing:
246
247
  - `--no-altimetry`: Skip the altimetry comparisons plotted by default. Auto-detects planetary body from DEM CRS: Earth → ICESat-2 (requires internet), Moon → LOLA, Mars → MOLA. For planetary DEMs, requires `--altimetry-csv`.
247
248
  - `--altimetry-csv`: Path to a LOLA/MOLA CSV from the ODE GDS API. **Mars: must be the `*_pts_csv.csv` (not `*_topo_csv.csv`) — the loader requires the `PLANET_RAD` column to avoid the oblate-areoid offset.** Moon accepts either the `*_topo_simple_csv.csv` (results=u) or the `*_pts_csv.csv` (results=p). Obtained via the `request_planetary_altimetry` CLI tool.
248
249
  - `--no-pc-align`: Skips the `pc_align` step; by default (when altimetry is on) the report runs `pc_align` against the reference altimetry (ICESat-2 for Earth, MOLA for Mars, LOLA for Moon) after the existing altimetry plots and appends an alignment report. **Earth success path**: adds four pages (alignment report page, pre/post landcover histogram, aligned profile, aligned best/worst segments). **Planetary success path**: adds three pages (alignment report page, pre/post mapview, pre/post histogram). `insufficient_points` and `no_improvement` outcomes emit a single alignment report page on either branch. Skipped automatically when `--no-altimetry` is set.
250
+ - `--pc-align-min-points`: Fewest altimetry points on the DEM for the `pc_align` step to run (`ReportConfig.pc_align_min_points`); `None`, the default, keeps the alignment methods' own minimums (500 ICESat-2 points, 20 LOLA/MOLA points). Below the minimum the alignment page says the step was skipped.
249
251
  - `--no-geometry`: Skip the stereo geometry plots drawn by default (used for planetary missions)
250
252
  - `--subset-km`: Hillshade subset size in km (default: 1.0)
251
253
  - `--atl06sr-time-range`: Time range for ICESat-2 ATL06-SR requests. `"all"` (default) for full mission, `"auto"` for scene metadata ±1 year, `"START,END"` for a custom range, or a single date (buffered by ±1 year).
@@ -366,7 +368,7 @@ Docs dependencies are in `pyproject.toml` under `[project.optional-dependencies]
366
368
  ## Example Notebooks
367
369
 
368
370
  Example notebooks demonstrating modular usage are organized by sensor type:
369
- - `notebooks/WorldView/` - WorldView examples (Earth-based). `worldview_spacenet_ucsd_stereo.ipynb` is the classic two-scene pair example; `worldview_utqiagvik_stereo.ipynb` (excluded from the docs build) and `worldview_uyuni_jitter_plots.ipynb` cover comprehensive modular usage and jitter plots; the two `*_scene_selection.ipynb` notebooks pick which scenes to process with `stereo_geom` before any ASP run. **`worldview_spacenet_benchmark.ipynb` is the scene-combination benchmark (issue #169)**: with `DEMBenchmark` on one ICESat-2 sample per site it scores every combination and flow — at Atlanta 21 DEMs (all ten pairs of the five same-pass scenes, six `dem_mosaic` blends incl. median / convergence-weighted / `VerticalStdDev`-weighted, six multi-view runs incl. two references) then at UCSD 17 DEMs (five multi-date WorldView-3 scenes over a steep crop of Mount Soledad: three multi-view references, ten pairs, four blends) (both sets of runs come from local driver scripts that are not in the repo; the notebook re-scores whatever is on disk). Crop and scene selection for UCSD are done in the notebook from metadata staged next to the data (`ucsd_mvs/selection/`). Its findings, in its two takeaways sections: at the same-pass flat site single-pair accuracy is set by convergence angle; at the steep multi-date site the point estimates depend more on which scene a pair contains than on its geometry, but a paired track bootstrap (#199) separates only the worst DEM from the rest, so those rules are suggested rather than established; at both sites the multi-view reference scene has the largest effect. The Atlanta example (`worldview_spacenet_atlanta_mvs.ipynb`) is a 3-scene same-pass multi-view run (`parallel_stereo --alignment-method affineepipolar` on the `wv_correct`ed `*_corr.tif` images, nadir13 reference) compared against the ASP-docs-recommended alternative: the three pairwise runs merged with `dem_mosaic` (coverage, DEM difference, ICESat-2 residuals). Two crop-window methods keep the ROIs matched: the reference crop window in mapprojected/ground coordinates converts to image pixels via GDAL's inverse RPC transform on the *original* `.tif` (the `wv_correct` `_corr.tif` carries no RPC metadata) — `gdal.Transformer(["METHOD=RPC", "RPC_HEIGHT=<m>"])` + `TransformPoint(1, lon, lat, h)` on the corners; and the `--left-image-crop-win` for a pair whose left image is *not* the reference is derived from the `bundle_adjust` clean match points that fall inside the reference crop window (no RPCs needed at all).
371
+ - `notebooks/WorldView/` - WorldView examples (Earth-based). `worldview_spacenet_ucsd_stereo.ipynb` is the classic two-scene pair example; `worldview_utqiagvik_stereo.ipynb` (excluded from the docs build) and `worldview_uyuni_jitter_plots.ipynb` cover comprehensive modular usage and jitter plots. The Atlanta example (`worldview_spacenet_atlanta_mvs.ipynb`) is a 3-scene same-pass multi-view run (`parallel_stereo --alignment-method affineepipolar` on the `wv_correct`ed `*_corr.tif` images, nadir13 reference) compared against the ASP-docs-recommended alternative: the three pairwise runs merged with `dem_mosaic` (coverage, DEM difference, ICESat-2 residuals). Two crop-window methods keep the ROIs matched: the reference crop window in mapprojected/ground coordinates converts to image pixels via GDAL's inverse RPC transform on the *original* `.tif` (the `wv_correct` `_corr.tif` carries no RPC metadata) — `gdal.Transformer(["METHOD=RPC", "RPC_HEIGHT=<m>"])` + `TransformPoint(1, lon, lat, h)` on the corners; and the `--left-image-crop-win` for a pair whose left image is *not* the reference is derived from the `bundle_adjust` clean match points that fall inside the reference crop window (no RPCs needed at all). Its benchmark cell, six DEMs scored with `DEMBenchmark` on one ICESat-2 sample, is the worked example of `DEMBenchmark`.
370
372
  - `notebooks/Pleiades/` - Airbus Pléiades Neo tri-stereo example over Marseille (Earth-based): DIMAP stereo-geometry analysis, bundle adjustment of all three scenes, and a 3-image multi-view stereo run (nadir reference, `--left-image-crop-win` derived from the DIMAP `Dataset_Extent` vertices; the ASP docs discourage MVS in favor of pairwise + `dem_mosaic`, which the notebook cites)
371
373
  - `notebooks/ASTER/` - ASTER examples with map-projection and jitter correction (Earth-based)
372
374
  - `notebooks/LRO_NAC/` - Lunar Reconnaissance Orbiter Narrow Angle Camera examples (Lunar)
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [3.6.0] - 2026-10-30
11
+
12
+ In this release:
13
+
14
+ - `asp_report --pc-align-min-points` sets how many altimetry points the `pc_align` step needs before it runs, so small DEMs that ICESat-2 crosses only a few times can still be aligned.
15
+
16
+ ### Added
17
+ - **`asp_report --pc-align-min-points N` sets how many altimetry points the `pc_align` step needs before it runs** (`ReportConfig.pc_align_min_points`). The step skipped itself below a fixed 500 ICESat-2 points (20 for LOLA/MOLA), so a report on a DEM of about 1 km², which ICESat-2 crosses only a few times, compared against the points but never aligned to them. The default is unchanged.
18
+
19
+ ## [3.5.0] - 2026-10-23
20
+
21
+ In this release:
22
+
23
+ - `asp_report --figures-directory` keeps a copy of each report figure, named by figure number and title, for use in notebooks and slides.
24
+ - `DEMBenchmark` reuses a translated DEM newer than its inputs instead of rewriting it on every run.
25
+ - Three study notebooks are no longer in the examples.
26
+ - The example reports are regenerated under 3.4.0, and a reports release is kept off Zenodo.
27
+
28
+ ### Added
29
+ - **`asp_report --figures-directory DIR` keeps a copy of each report figure** (`ReportConfig.figures_directory`). The report plots every figure to `tmp_asp_report_plots/` and deletes that directory once the PDF is written, so the only way to show a figure in a notebook or slides was to extract it from the PDF. With the option, each figure is also copied to `DIR` as `NN_<title>.png` (`05_dem_results.png`, `07_icesat_2_atl06_sr_map.png`), where `NN` is its figure number in the PDF. The title is in the name because the numbers shift when a section is skipped. Nothing is kept by default, so existing runs are unchanged.
30
+
31
+ ### Changed
32
+ - **The example reports are regenerated under 3.4.0** and published as [`reports-2026-09-29`](https://github.com/uw-cryo/asp_plot/releases/tag/reports-2026-09-29); the docs build fetches that set. All seven carry the intersection error captions of 3.4.0. `WorldView_Atlanta_MVS` is rebuilt for the first time since the 200 dpi cap, which the fix for deleted `*-L.tif` files ([#202](https://github.com/uw-cryo/asp_plot/issues/202)) made possible, and goes from 22.4 MB to 12.4 MB. Every report was rebuilt with `--reuse-selections`, so each page shows the same altimetry points and clips as before.
33
+ - **A reports release is kept off Zenodo.** Zenodo archives every published GitHub release as a version of the software, and the first reports release had become what the DOI badge resolves to. `AGENTS.md` now has the step that prevents it: the Zenodo webhook is switched off while a reports release is published.
34
+ - **`DEMBenchmark` reuses a translated DEM that is newer than its DEM and its `pc_align` output** instead of rewriting it on every run, so re-scoring from cached `pc_align` runs no longer writes a full-size copy of every DEM. A new copy is written under a temporary name and renamed, so an interrupted run cannot leave a partial file that a later run would reuse.
35
+
36
+ ### Removed
37
+ - **Three study notebooks are no longer examples: `worldview_spacenet_benchmark.ipynb`, `worldview_spacenet_atlanta_stereo_scene_selection.ipynb` and `worldview_spacenet_ucsd_stereo_scene_selection.ipynb`.** Each answered a research question (which scene combination or pair gives the best DEM) rather than showing how to use a feature, and each re-execution added a full new copy of the notebook to the repository's history. `worldview_spacenet_atlanta_mvs.ipynb` remains the worked example of `DEMBenchmark`; `docs/cli/stereo_geom.md` covers assessing the geometry of every pair in a multi-scene delivery; and the UCSD stereo and Atlanta MVS notebooks now say in their text how their pairs were chosen and how the candidates scored (markdown edits only; neither notebook was re-executed).
38
+
8
39
  ## [3.4.0] - 2026-09-29
9
40
 
10
41
  In this release:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asp_plot
3
- Version: 3.4.0
3
+ Version: 3.6.0
4
4
  Summary: Package for plotting outputs Ames Stereo Pipeline processing
5
5
  Project-URL: Homepage, https://github.com/uw-cryo/asp_plot
6
6
  Project-URL: Documentation, https://asp-plot.readthedocs.io
@@ -92,6 +92,12 @@ def _reconstruct_command():
92
92
  default=False,
93
93
  help="Skip the pc_align step (run by default against the reference altimetry -- ICESat-2 for Earth, MOLA for Mars, LOLA for Moon -- appending the alignment-report pages; skipped automatically with --no-altimetry).",
94
94
  )
95
+ @click.option(
96
+ "--pc-align-min-points",
97
+ type=int,
98
+ default=None,
99
+ help="Fewest altimetry points on the DEM for the pc_align step to run; below it the alignment page says the step was skipped. Default: 500 for ICESat-2, 20 for LOLA/MOLA.",
100
+ )
95
101
  @click.option(
96
102
  "--no-geometry",
97
103
  is_flag=True,
@@ -137,6 +143,12 @@ def _reconstruct_command():
137
143
  type=int,
138
144
  help="Resolution ceiling for figures embedded in the PDF, applied at the size each figure is placed on the page. Figures are plotted at 220 dpi and then scaled down to the page, so wide multi-panel figures otherwise land at 400+ effective dpi and inflate the file for detail no screen or printer shows. Pass 0 to embed the full-resolution figures.",
139
145
  )
146
+ @click.option(
147
+ "--figures-directory",
148
+ prompt=False,
149
+ default=None,
150
+ help="Directory to keep a copy of each report figure in, as NN_<title>.png (e.g. 05_dem_results.png), where NN is the figure number in the PDF. A relative path is taken from the current directory, not from --directory. Use this to show figures in a notebook or slides without extracting them from the PDF. Files of the same name are overwritten. Default: None, the figures are not kept.",
151
+ )
140
152
  def main(
141
153
  directory,
142
154
  bundle_adjust_prefix,
@@ -149,6 +161,7 @@ def main(
149
161
  no_altimetry,
150
162
  altimetry_csv,
151
163
  no_pc_align,
164
+ pc_align_min_points,
152
165
  no_geometry,
153
166
  subset_km,
154
167
  atl06sr_time_range,
@@ -156,6 +169,7 @@ def main(
156
169
  report_filename,
157
170
  report_title,
158
171
  figure_max_dpi,
172
+ figures_directory,
159
173
  ):
160
174
  """
161
175
  Generate a comprehensive report of ASP processing results.
@@ -176,6 +190,7 @@ def main(
176
190
  plot_altimetry=not no_altimetry,
177
191
  altimetry_csv=altimetry_csv,
178
192
  pc_align=not no_pc_align,
193
+ pc_align_min_points=pc_align_min_points,
179
194
  plot_geometry=not no_geometry,
180
195
  subset_km=subset_km,
181
196
  atl06sr_time_range=atl06sr_time_range,
@@ -183,6 +198,7 @@ def main(
183
198
  report_filename=report_filename,
184
199
  report_title=report_title,
185
200
  figure_max_dpi=figure_max_dpi,
201
+ figures_directory=figures_directory,
186
202
  report_command=_reconstruct_command(),
187
203
  )
188
204
  run_report(config)
@@ -54,6 +54,7 @@ Examples
54
54
  >>> bench.summary_plot(save_dir="atlanta_mvs", fig_fn="dem_benchmark.png")
55
55
  """
56
56
 
57
+ import glob
57
58
  import logging
58
59
  import os
59
60
  import re
@@ -235,6 +236,14 @@ def _safe_dirname(label):
235
236
  return re.sub(r"[^\w.-]+", "_", label).strip("_") or "dem"
236
237
 
237
238
 
239
+ def _is_newer_than(fn, sources):
240
+ """True if ``fn`` exists and is at least as new as every existing file in ``sources``."""
241
+ if not os.path.exists(fn):
242
+ return False
243
+ mtime = os.path.getmtime(fn)
244
+ return all(os.path.getmtime(s) <= mtime for s in sources if os.path.exists(s))
245
+
246
+
238
247
  def _dh_stats(dh, prefix):
239
248
  """median / NMAD / RMSE of a dh series as ``{prefix}_*_m`` keys (NaN when empty)."""
240
249
  values = np.asarray(dh, dtype=float)
@@ -363,8 +372,10 @@ class DEMBenchmark:
363
372
  directory : str
364
373
  Working folder. pc_align products, the pc_align CSV and the translated
365
374
  DEM copy for each candidate go under ``<directory>/dem_benchmark/<label>/``.
366
- The translated copy is rewritten on every run, so two benchmarks
367
- sharing a directory and a label must not run at the same time.
375
+ The translated copy is reused while it is newer than its DEM and the
376
+ pc_align output, and otherwise rewritten (under a temporary name, then
377
+ renamed), so two benchmarks sharing a directory and a label must not
378
+ run at the same time.
368
379
  dems : dict or list
369
380
  ``{label: dem_fn}`` (order preserved), or a list of paths /
370
381
  ``"label=path"`` strings (see :func:`parse_dem_specs`).
@@ -597,7 +608,8 @@ class DEMBenchmark:
597
608
  Also run ``pc_align --compute-translation-only`` per DEM and
598
609
  re-score against the translated copy. Default True. Existing
599
610
  pc_align outputs under the benchmark folder are reused, so a
600
- re-run makes no pc_align call and works offline.
611
+ re-run makes no pc_align call and works offline; so are translated
612
+ DEMs newer than their DEM and pc_align output.
601
613
  minimum_points : int, optional
602
614
  Skip pc_align for a DEM with fewer valid dh points, default 500
603
615
  (the report's threshold).
@@ -992,10 +1004,23 @@ class DEMBenchmark:
992
1004
  )
993
1005
  return None, None
994
1006
  stem = os.path.splitext(os.path.basename(dem_fn))[0]
995
- aligned_fn = alignment.apply_dem_translation(
996
- output_prefix=output_prefix,
997
- output_fn=os.path.join(label_dir, f"{stem}_pc_align_translated.tif"),
1007
+ aligned_fn = os.path.join(label_dir, f"{stem}_pc_align_translated.tif")
1008
+ if _is_newer_than(
1009
+ aligned_fn,
1010
+ [dem_fn]
1011
+ + glob.glob(os.path.join(label_dir, "pc_align", f"{prefix_name}-*")),
1012
+ ):
1013
+ print(f" Reusing translated DEM {aligned_fn}")
1014
+ return report, aligned_fn
1015
+ # Write under a temporary name and rename, so an interrupted run never
1016
+ # leaves a partial copy that the next run would take as current.
1017
+ partial_fn = os.path.join(label_dir, f"{stem}_pc_align_translated.partial.tif")
1018
+ written = alignment.apply_dem_translation(
1019
+ output_prefix=output_prefix, output_fn=partial_fn
998
1020
  )
1021
+ if written != partial_fn:
1022
+ return report, written
1023
+ os.replace(partial_fn, aligned_fn)
999
1024
  return report, aligned_fn
1000
1025
 
1001
1026
  def _reference_stats(self, label, dem_fn):
@@ -14,6 +14,7 @@ Captions live in :mod:`asp_plot.report_captions`.
14
14
  """
15
15
 
16
16
  import os
17
+ import re
17
18
  import shutil
18
19
  from dataclasses import dataclass, field
19
20
  from datetime import datetime, timezone
@@ -74,6 +75,9 @@ class ReportConfig:
74
75
  plot_altimetry: bool = True
75
76
  altimetry_csv: Optional[str] = None
76
77
  pc_align: bool = True
78
+ # Fewest altimetry points on the DEM for pc_align to run. None keeps the
79
+ # alignment methods' own defaults: 500 for ICESat-2, 20 for LOLA/MOLA.
80
+ pc_align_min_points: Optional[int] = None
77
81
  plot_geometry: bool = True
78
82
  subset_km: float = 1.0
79
83
  atl06sr_time_range: str = "all"
@@ -83,6 +87,9 @@ class ReportConfig:
83
87
  # Resolution ceiling for figures embedded in the PDF, applied at the size
84
88
  # each figure is placed on the page. See asp_plot.report.FIGURE_MAX_DPI.
85
89
  figure_max_dpi: Optional[int] = FIGURE_MAX_DPI
90
+ # Directory to keep a copy of each report figure in, as NN_<title>.png.
91
+ # None keeps nothing: the figures are deleted with the plots directory.
92
+ figures_directory: Optional[str] = None
86
93
  # Reconstructed "asp_report --flag ..." string recorded in the report. The
87
94
  # CLI builds this from the Click context; non-CLI callers may leave it None.
88
95
  report_command: Optional[str] = None
@@ -624,10 +631,10 @@ def _build_altimetry_earth(ctx: ReportContext, ctx_kwargs_altimetry: dict):
624
631
 
625
632
  # ---- pc_align + ICESat-2 alignment report (Earth only) ----
626
633
  if cfg.pc_align:
627
- align_result = icesat.align_and_evaluate(
628
- processing_level="all",
629
- improvement_threshold_pct=5.0,
630
- )
634
+ align_kwargs = {"processing_level": "all", "improvement_threshold_pct": 5.0}
635
+ if cfg.pc_align_min_points is not None:
636
+ align_kwargs["minimum_points"] = cfg.pc_align_min_points
637
+ align_result = icesat.align_and_evaluate(**align_kwargs)
631
638
  stats_row = _stats_row_from_result(align_result)
632
639
 
633
640
  align_title = "DEM Alignment with ICESat-2"
@@ -773,7 +780,10 @@ def _build_altimetry_planetary(ctx: ReportContext, body: str):
773
780
 
774
781
  # ---- pc_align + planetary alignment report (Moon/Mars) ----
775
782
  if cfg.pc_align:
776
- align_result = alt.align_and_evaluate_planetary()
783
+ align_kwargs = {}
784
+ if cfg.pc_align_min_points is not None:
785
+ align_kwargs["minimum_points"] = cfg.pc_align_min_points
786
+ align_result = alt.align_and_evaluate_planetary(**align_kwargs)
777
787
  stats_row = _stats_row_from_result(align_result)
778
788
 
779
789
  align_title = f"DEM Alignment with {instrument}"
@@ -1052,12 +1062,35 @@ def _write_figure_selections(ctx: ReportContext) -> None:
1052
1062
  print(f"\nCould not write figure selections: {e}\n")
1053
1063
 
1054
1064
 
1065
+ def _keep_figures(sections: List[object], figures_directory: str) -> None:
1066
+ """Copy each report figure to ``figures_directory`` as ``NN_<title>.png``.
1067
+
1068
+ ``NN`` is the figure number printed in the PDF and ``<title>`` the page
1069
+ heading in lowercase with runs of other characters replaced by ``_``
1070
+ (e.g. ``05_dem_results.png``), so a figure can be picked by name even when
1071
+ a section is skipped and the numbers shift. Files of the same name are
1072
+ overwritten; nothing else in the directory is touched.
1073
+ """
1074
+ figures_directory = os.path.expanduser(figures_directory)
1075
+ os.makedirs(figures_directory, exist_ok=True)
1076
+ kept = 0
1077
+ for section in sections:
1078
+ if not section.image_path or not os.path.exists(section.image_path):
1079
+ continue
1080
+ slug = re.sub(r"[^a-z0-9]+", "_", section.title.lower()).strip("_")
1081
+ fn = f"{section.figure_number:02}_{slug}.png"
1082
+ shutil.copy2(section.image_path, os.path.join(figures_directory, fn))
1083
+ kept += 1
1084
+ print(f"\nKept {kept} report figures in {figures_directory}\n")
1085
+
1086
+
1055
1087
  def run_report(config: ReportConfig) -> str:
1056
1088
  """Generate the ASP processing report described by ``config``.
1057
1089
 
1058
1090
  Builds the runtime context, iterates the declarative
1059
1091
  :data:`REPORT_SECTIONS` registry to assemble the report body, compiles the
1060
- PDF, writes the figure-selections sidecar, and cleans up. Returns the path
1092
+ PDF, writes the figure-selections sidecar, keeps a copy of the figures if
1093
+ ``config.figures_directory`` is set, and cleans up. Returns the path
1061
1094
  to the written PDF. Importable and callable without any Click context.
1062
1095
  """
1063
1096
  ctx = _setup_context(config)
@@ -1088,6 +1121,9 @@ def run_report(config: ReportConfig) -> str:
1088
1121
 
1089
1122
  _write_figure_selections(ctx)
1090
1123
 
1124
+ if config.figures_directory:
1125
+ _keep_figures(sections, config.figures_directory)
1126
+
1091
1127
  shutil.rmtree(ctx.plots_directory)
1092
1128
 
1093
1129
  print(f"\n\nReport saved to {ctx.report_pdf_path}\n\n")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asp_plot"
7
- version = "3.4.0"
7
+ version = "3.6.0"
8
8
  license = {text = "BSD-3-Clause"}
9
9
  authors = [
10
10
  { name="Ben Purinton", email="purinton@uw.edu" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes