asp-plot 1.12.0__tar.gz → 1.13.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 (30) hide show
  1. {asp_plot-1.12.0 → asp_plot-1.13.0}/.gitignore +2 -0
  2. {asp_plot-1.12.0 → asp_plot-1.13.0}/CHANGELOG.md +32 -0
  3. {asp_plot-1.12.0 → asp_plot-1.13.0}/PKG-INFO +1 -1
  4. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/altimetry.py +415 -64
  5. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/asp_plot.py +180 -16
  6. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/report.py +256 -36
  7. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/utils.py +72 -0
  8. {asp_plot-1.12.0 → asp_plot-1.13.0}/pyproject.toml +1 -1
  9. {asp_plot-1.12.0 → asp_plot-1.13.0}/.flake8 +0 -0
  10. {asp_plot-1.12.0 → asp_plot-1.13.0}/.github/workflows/release.yml +0 -0
  11. {asp_plot-1.12.0 → asp_plot-1.13.0}/.github/workflows/run-tests.yml +0 -0
  12. {asp_plot-1.12.0 → asp_plot-1.13.0}/.pre-commit-config.yaml +0 -0
  13. {asp_plot-1.12.0 → asp_plot-1.13.0}/.readthedocs.yaml +0 -0
  14. {asp_plot-1.12.0 → asp_plot-1.13.0}/LICENSE +0 -0
  15. {asp_plot-1.12.0 → asp_plot-1.13.0}/README.md +0 -0
  16. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/__init__.py +0 -0
  17. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/alignment.py +0 -0
  18. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/bundle_adjust.py +0 -0
  19. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/__init__.py +0 -0
  20. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  21. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  22. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/stereo_geom.py +0 -0
  23. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/csm_camera.py +0 -0
  24. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/processing_parameters.py +0 -0
  25. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/scenes.py +0 -0
  26. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/stereo.py +0 -0
  27. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/stereo_geometry.py +0 -0
  28. {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/stereopair_metadata_parser.py +0 -0
  29. {asp_plot-1.12.0 → asp_plot-1.13.0}/conda-forge-recipe/meta.yaml +0 -0
  30. {asp_plot-1.12.0 → asp_plot-1.13.0}/environment.yml +0 -0
@@ -142,3 +142,5 @@ CLAUDE*
142
142
  .claude/**
143
143
  scripts/
144
144
  /*.parquet
145
+ reports/regenerate_reports.sh
146
+ *.csv
@@ -5,6 +5,38 @@ 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
+ ## [1.13.0] - 2026-04-20
9
+
10
+ ### Added
11
+ - **Automatic `pc_align` step in the Earth altimetry block**, gated by a new `--pc_align` CLI flag (default `True`; disabled automatically when `--plot_altimetry` / `--plot_icesat` is `False`). Runs `pc_align` against ICESat-2 ATL06-SR, evaluates whether the aligned DEM is worth keeping, and appends the outcome as one or more report pages:
12
+ - Always: an **alignment report page** with the parameters table, a single-row horizontal stats table (p16/p50/p84 beg/end, north/east/down shifts, translation magnitude, values to 2 sig figs), a description explaining what `pc_align` does and the meaning of every column in the tables above, and a bold status line for the outcome of this run.
13
+ - On success (p50 drops toward 0 by more than `improvement_threshold_pct`, default 5%, **and** `pc_align` actually wrote an aligned DEM): three additional full-page diagnostic figures against the aligned DEM — a pre-/post-alignment landcover histogram, the full profile, and the best/worst 1 km segments.
14
+ - On insufficient ATL06-SR coverage or no meaningful improvement: the aligned DEM on disk is cleaned up so its presence is a truthy signal that the alignment is worth using.
15
+ - **`Altimetry.align_and_evaluate(...)`** (new method) returning a plain `AlignmentResult` dataclass (`status ∈ {"insufficient_points", "no_improvement", "success"}`, `alignment_report_df`, `aligned_dem_fn`, `improvement_pct`, `message`, `parameters_used`). Does not import any `fpdf` / report dependencies, so it is safe to call from notebooks.
16
+ - **`plot_aligned` kwargs** on `Altimetry.histogram_by_landcover` and `Altimetry.plot_best_worst_segments`:
17
+ - `histogram_by_landcover(plot_aligned=True)` overlays the pre- and post-alignment distributions using shared bin edges and renders two vertically stacked per-landcover stats text boxes whose outline colors match the bar colors (color = legend).
18
+ - `plot_best_worst_segments(plot_aligned=True)` keeps segment selection fixed (based on the unaligned `dh` so segments are comparable), overlays aligned DEM heights on each segment, and appends aligned Median/NMAD to the segment titles.
19
+ - **`AlignmentReportPage`** dataclass in `asp_plot.report`: a report-section type that renders a kwargs table + single-row stats table + description + bold status line + optional figure with caption. Body text blocks render left-aligned to avoid justified word-spacing gaps.
20
+
21
+ ### Changed
22
+ - **Processing Parameters is now page 2 of the PDF**, immediately after the DEM Summary on the title page, instead of the trailing appendix. Page order is now: title + DEM summary → processing parameters → diagnostic figures → (if any) alignment results.
23
+ - **`plot_atl06sr_dem_profile(plot_aligned=True)`**: the lower `dh` panel now plots the post-alignment residuals (`icesat_minus_aligned_dem`) with Med/NMAD recomputed against the aligned DEM, with the legend entry tagged `"(Aligned DEM)"`. The upper elevation panel still overlays both the unaligned and aligned DEM for comparison. `plot_aligned=False` behavior is unchanged.
24
+
25
+ ## [1.12.1] - 2026-04-14
26
+
27
+ ### Changed
28
+ - **Report panel order**: Disparity maps now follow the Bundle Adjust panels, and DEM Results precedes Detailed Hillshade (was: Hillshade → DEM Results → Disparity).
29
+ - **Input Scenes caption** clarifies that mapprojected scenes are RPC-orthorectified against a reference DEM to roughly pre-align the stereo pair prior to correlation (reducing disparity search range), which addresses confusion for readers coming from non-ASP photogrammetry workflows.
30
+ - **Match Points caption** notes these come from `stereo_corr`'s initial interest point matching step (used to set search windows), not dense correlation.
31
+
32
+ ### Added
33
+ - **Acquisition Date(s)** row on the DEM Summary title-page table, populated when recoverable from scene metadata.
34
+ - `get_acquisition_dates()` helper in `utils.py`: reads `FIRSTLINETIME` from WorldView/Maxar XMLs and parses the capture timestamp from `AST_L1A_...` file/directory names. Deduplicates and sorts; returns an empty list if no date can be found, in which case the summary-table row is omitted.
35
+ - Unit tests for `get_acquisition_dates()` covering WorldView XMLs, ASTER filenames (top-level and in subdirectories), dedupe, and sorting of multi-date pairs.
36
+
37
+ ### Fixed
38
+ - `--report_filename` accepts absolute and relative paths (not just bare filenames), and `~` in CLI path arguments is expanded ([#113](https://github.com/uw-cryo/asp_plot/pull/113)).
39
+
8
40
  ## [1.12.0] - 2026-04-10
9
41
 
10
42
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 1.12.0
3
+ Version: 1.13.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
@@ -1,7 +1,9 @@
1
1
  import json
2
2
  import logging
3
3
  import os
4
+ from dataclasses import dataclass, field
4
5
  from datetime import datetime, timedelta, timezone
6
+ from typing import Optional
5
7
 
6
8
  import contextily as ctx
7
9
  import geopandas as gpd
@@ -43,6 +45,49 @@ def _nmad(a, c=1.4826):
43
45
  return np.nanmedian(np.fabs(a - np.nanmedian(a))) * c
44
46
 
45
47
 
48
+ @dataclass
49
+ class AlignmentResult:
50
+ """Outcome of :meth:`Altimetry.align_and_evaluate`.
51
+
52
+ Plain dataclass — importing this or the method does not pull in any
53
+ report/fpdf dependencies, so it is safe to use from notebooks.
54
+
55
+ Attributes
56
+ ----------
57
+ status : str
58
+ One of:
59
+ - ``"insufficient_points"``: not enough ATL06-SR points for
60
+ pc_align to run (the aligned DEM is removed if one was written).
61
+ - ``"no_improvement"``: pc_align ran but p50 did not improve
62
+ toward 0 by more than the ``improvement_threshold_pct``; the
63
+ aligned DEM has been removed.
64
+ - ``"success"``: p50 improved by more than the threshold; the
65
+ aligned DEM is retained and ``Altimetry.aligned_dem_fn`` points
66
+ to it.
67
+ alignment_report_df : pandas.DataFrame
68
+ The alignment report table produced by
69
+ :meth:`Altimetry.alignment_report`. Empty for ``insufficient_points``.
70
+ aligned_dem_fn : str or None
71
+ Path to the aligned DEM on success; None otherwise (the file is
72
+ cleaned up on non-success branches).
73
+ improvement_pct : float or None
74
+ ``(p50_beg - p50_end) / p50_beg * 100`` when computable, else None.
75
+ message : str
76
+ Short human-readable summary suitable for a status line in the
77
+ PDF report.
78
+ parameters_used : dict
79
+ The kwargs used for the alignment attempt (e.g. processing_level,
80
+ minimum_points, improvement_threshold_pct). Echoed into the report.
81
+ """
82
+
83
+ status: str
84
+ alignment_report_df: Optional[pd.DataFrame] = None
85
+ aligned_dem_fn: Optional[str] = None
86
+ improvement_pct: Optional[float] = None
87
+ message: str = ""
88
+ parameters_used: dict = field(default_factory=dict)
89
+
90
+
46
91
  # --- ODE GDS REST API (for LOLA/MOLA planetary altimetry) ---
47
92
 
48
93
  GDS_BASE_URL = "https://oderest.rsl.wustl.edu/livegds"
@@ -1124,6 +1169,173 @@ class Altimetry:
1124
1169
 
1125
1170
  self.alignment_report_df = alignment_report_df
1126
1171
 
1172
+ def align_and_evaluate(
1173
+ self,
1174
+ processing_level="all",
1175
+ improvement_threshold_pct=5.0,
1176
+ min_translation_threshold=0.1,
1177
+ minimum_points=500,
1178
+ agreement_threshold=0.25,
1179
+ ):
1180
+ """
1181
+ Run pc_align against ICESat-2 and evaluate whether to keep the result.
1182
+
1183
+ Wraps :meth:`alignment_report` with a decision step so the aligned
1184
+ DEM is only retained when it represents a meaningful improvement.
1185
+ Returns an :class:`AlignmentResult`; notebook callers can inspect
1186
+ ``result.status`` to decide what to display.
1187
+
1188
+ Decision logic:
1189
+
1190
+ 1. If the alignment report is empty (fewer than ``minimum_points``
1191
+ ICESat-2 points, or the pc_align log is missing), delete any
1192
+ aligned DEM file and return ``status="insufficient_points"``.
1193
+ 2. Otherwise compute
1194
+ ``improvement_pct = (p50_beg - p50_end) / p50_beg * 100``. If
1195
+ ``p50_end >= p50_beg`` or
1196
+ ``improvement_pct <= improvement_threshold_pct``, delete the
1197
+ aligned DEM, clear ``self.aligned_dem_fn``, and return
1198
+ ``status="no_improvement"``.
1199
+ 3. Otherwise re-run :meth:`atl06sr_to_dem_dh` so the
1200
+ ``icesat_minus_aligned_dem`` column is populated, and return
1201
+ ``status="success"`` with the aligned DEM retained.
1202
+
1203
+ Parameters
1204
+ ----------
1205
+ processing_level : str, optional
1206
+ ATL06-SR processing level key to align against. Default "all".
1207
+ improvement_threshold_pct : float, optional
1208
+ Minimum required ``(p50_beg - p50_end) / p50_beg * 100`` for the
1209
+ aligned DEM to be kept. Default 5.0.
1210
+ min_translation_threshold : float, optional
1211
+ Forwarded to :meth:`alignment_report`. Default 0.1.
1212
+ minimum_points : int, optional
1213
+ Forwarded to :meth:`alignment_report`. Default 500.
1214
+ agreement_threshold : float, optional
1215
+ Forwarded to :meth:`alignment_report`. Default 0.25.
1216
+
1217
+ Returns
1218
+ -------
1219
+ AlignmentResult
1220
+ """
1221
+ parameters_used = {
1222
+ "processing_level": processing_level,
1223
+ "minimum_points": minimum_points,
1224
+ "agreement_threshold": agreement_threshold,
1225
+ "min_translation_threshold": min_translation_threshold,
1226
+ "improvement_threshold_pct": improvement_threshold_pct,
1227
+ }
1228
+
1229
+ self.alignment_report(
1230
+ processing_level=processing_level,
1231
+ key_for_aligned_dem=processing_level,
1232
+ minimum_points=minimum_points,
1233
+ agreement_threshold=agreement_threshold,
1234
+ min_translation_threshold=min_translation_threshold,
1235
+ write_out_aligned_dem=True,
1236
+ )
1237
+ df = getattr(self, "alignment_report_df", None)
1238
+
1239
+ if df is None or df.empty:
1240
+ self._remove_aligned_dem_if_present()
1241
+ return AlignmentResult(
1242
+ status="insufficient_points",
1243
+ alignment_report_df=df if df is not None else pd.DataFrame(),
1244
+ aligned_dem_fn=None,
1245
+ improvement_pct=None,
1246
+ message=(
1247
+ f"Alignment skipped: fewer than {minimum_points} "
1248
+ f"ATL06-SR points available for processing_level="
1249
+ f"'{processing_level}', or pc_align did not produce a "
1250
+ "usable log."
1251
+ ),
1252
+ parameters_used=parameters_used,
1253
+ )
1254
+
1255
+ row = df.iloc[0]
1256
+ p50_beg = float(row.get("p50_beg", float("nan")))
1257
+ p50_end = float(row.get("p50_end", float("nan")))
1258
+ if not np.isfinite(p50_beg) or not np.isfinite(p50_end) or p50_beg == 0:
1259
+ improvement_pct = None
1260
+ else:
1261
+ improvement_pct = (p50_beg - p50_end) / p50_beg * 100.0
1262
+
1263
+ # alignment_report may decline to write the aligned DEM if the
1264
+ # translation magnitude is under min_translation_threshold × GSD
1265
+ # (self.aligned_dem_fn stays None in that case). Without a written
1266
+ # aligned DEM we cannot render the success-path plots, so treat
1267
+ # that as no_improvement even if p50 happened to drop > threshold.
1268
+ translation_too_small = self.aligned_dem_fn is None
1269
+
1270
+ if (
1271
+ improvement_pct is None
1272
+ or p50_end >= p50_beg
1273
+ or improvement_pct <= improvement_threshold_pct
1274
+ or translation_too_small
1275
+ ):
1276
+ self._remove_aligned_dem_if_present()
1277
+ improvement_repr = (
1278
+ f"{improvement_pct:.1f}%" if improvement_pct is not None else "n/a"
1279
+ )
1280
+ if (
1281
+ translation_too_small
1282
+ and improvement_pct is not None
1283
+ and (p50_end < p50_beg and improvement_pct > improvement_threshold_pct)
1284
+ ):
1285
+ reason = (
1286
+ f"Translation magnitude is below {min_translation_threshold*100:.0f}% "
1287
+ "of the DEM GSD, so no aligned DEM was written despite a "
1288
+ f"{improvement_repr} p50 reduction."
1289
+ )
1290
+ else:
1291
+ reason = (
1292
+ f"p50 {p50_beg:.2f} m -> {p50_end:.2f} m, "
1293
+ f"{improvement_repr} <= {improvement_threshold_pct:.1f}% "
1294
+ "threshold. Aligned DEM removed."
1295
+ )
1296
+ return AlignmentResult(
1297
+ status="no_improvement",
1298
+ alignment_report_df=df,
1299
+ aligned_dem_fn=None,
1300
+ improvement_pct=improvement_pct,
1301
+ message=f"No significant improvement: {reason}",
1302
+ parameters_used=parameters_used,
1303
+ )
1304
+
1305
+ # Populate icesat_minus_aligned_dem without re-running the 3σ
1306
+ # outlier filter (the unaligned column is already 3σ-clean from the
1307
+ # initial atl06sr_to_dem_dh call, and we do not want the aligned-DEM
1308
+ # plots to operate on a different sample than the unaligned ones).
1309
+ # This does not re-request ICESat-2 data; it only interpolates DEM
1310
+ # heights at the existing ATL06-SR point locations.
1311
+ self.atl06sr_to_dem_dh(n_sigma=None)
1312
+ return AlignmentResult(
1313
+ status="success",
1314
+ alignment_report_df=df,
1315
+ aligned_dem_fn=self.aligned_dem_fn,
1316
+ improvement_pct=improvement_pct,
1317
+ message=(
1318
+ f"p50 improved from {p50_beg:.2f} m -> {p50_end:.2f} m "
1319
+ f"({improvement_pct:.1f}% reduction). Aligned DEM written to "
1320
+ f"{self.aligned_dem_fn}."
1321
+ ),
1322
+ parameters_used=parameters_used,
1323
+ )
1324
+
1325
+ def _remove_aligned_dem_if_present(self):
1326
+ """Delete the aligned DEM file if pc_align created one.
1327
+
1328
+ Safe to call when no aligned DEM exists. Clears
1329
+ ``self.aligned_dem_fn`` on success.
1330
+ """
1331
+ aligned = getattr(self, "aligned_dem_fn", None)
1332
+ if aligned and os.path.exists(aligned):
1333
+ try:
1334
+ os.remove(aligned)
1335
+ except OSError as e:
1336
+ logger.warning(f"\nCould not remove aligned DEM {aligned}: {e}\n")
1337
+ self.aligned_dem_fn = None
1338
+
1127
1339
  # ------------------------------------------------------------------ #
1128
1340
  # Planetary altimetry: LOLA (Moon) and MOLA (Mars) via ODE GDS API #
1129
1341
  # ------------------------------------------------------------------ #
@@ -2110,6 +2322,7 @@ class Altimetry:
2110
2322
  top_n=4,
2111
2323
  title="ICESat-2 ATL06-SR vs DEM",
2112
2324
  xlim=None,
2325
+ plot_aligned=False,
2113
2326
  save_dir=None,
2114
2327
  fig_fn=None,
2115
2328
  ):
@@ -2120,6 +2333,11 @@ class Altimetry:
2120
2333
  ATL06-SR data and the DEM, with a text annotation showing overall
2121
2334
  and per-landcover-class statistics (count, median, NMAD).
2122
2335
 
2336
+ When ``plot_aligned=True`` and an aligned DEM is available,
2337
+ overlays the pre- and post-alignment distributions and renders two
2338
+ vertically stacked stats text boxes whose outline colors match the
2339
+ bar colors (color serves as the legend).
2340
+
2123
2341
  Parameters
2124
2342
  ----------
2125
2343
  key : str, optional
@@ -2131,6 +2349,10 @@ class Altimetry:
2131
2349
  xlim : tuple or None, optional
2132
2350
  Symmetric x-axis limits as (min, max). If None, uses ±3σ
2133
2351
  range (data is already 3σ-filtered in atl06sr_to_dem_dh).
2352
+ plot_aligned : bool, optional
2353
+ Whether to overlay the aligned-DEM distribution alongside the
2354
+ unaligned one. Requires ``self.aligned_dem_fn`` and the
2355
+ ``icesat_minus_aligned_dem`` column. Default is False.
2134
2356
  save_dir : str or None, optional
2135
2357
  Directory to save figure, default is None
2136
2358
  fig_fn : str or None, optional
@@ -2142,32 +2364,131 @@ class Altimetry:
2142
2364
  self.atl06sr_to_dem_dh()
2143
2365
  atl06sr = self.atl06sr_processing_levels_filtered[key]
2144
2366
 
2145
- dh = atl06sr["icesat_minus_dem"].dropna()
2146
- if dh.empty:
2367
+ distributions = [("icesat_minus_dem", "steelblue", "DEM")]
2368
+ if plot_aligned:
2369
+ if not self.aligned_dem_fn:
2370
+ logger.warning(
2371
+ "\nplot_aligned=True but no aligned DEM is available; "
2372
+ "plotting unaligned distribution only.\n"
2373
+ )
2374
+ elif "icesat_minus_aligned_dem" not in atl06sr.columns:
2375
+ logger.warning(
2376
+ "\n'icesat_minus_aligned_dem' column missing; call "
2377
+ "atl06sr_to_dem_dh() after setting aligned_dem_fn.\n"
2378
+ )
2379
+ else:
2380
+ distributions.append(
2381
+ ("icesat_minus_aligned_dem", "darkorange", "Aligned DEM")
2382
+ )
2383
+
2384
+ # Preserve the pre-existing "All" header label when there is only
2385
+ # one distribution (no plot_aligned overlay). This keeps reports
2386
+ # generated with plot_aligned=False textually identical to prior
2387
+ # versions.
2388
+ if len(distributions) == 1:
2389
+ col, color, _ = distributions[0]
2390
+ distributions = [(col, color, "All")]
2391
+
2392
+ dh_series = [
2393
+ (col, color, label, atl06sr[col].dropna())
2394
+ for col, color, label in distributions
2395
+ ]
2396
+ dh_series = [t for t in dh_series if not t[3].empty]
2397
+ if not dh_series:
2147
2398
  logger.warning(f"\nNo valid dh values for key: {key}\n")
2148
2399
  return
2149
2400
 
2150
- overall_med = np.nanmedian(dh.values)
2151
- overall_nmad = _nmad(dh.values)
2152
- overall_n = len(dh)
2401
+ if xlim is not None:
2402
+ xmin, xmax = xlim
2403
+ xlabel_note = ""
2404
+ else:
2405
+ abs_max = max(
2406
+ max(abs(dv.min()), abs(dv.max())) for _, _, _, dv in dh_series
2407
+ )
2408
+ xmin, xmax = -abs_max, abs_max
2409
+ xlabel_note = " [±3σ]"
2410
+
2411
+ fig, ax = plt.subplots(1, 1, figsize=(8, 5), dpi=220)
2153
2412
 
2154
- stats_lines = [
2155
- f"All: n={overall_n}, Med={overall_med:+.2f} m, NMAD={overall_nmad:.2f} m"
2413
+ # Shared bin edges so pre- and post-alignment bars line up exactly
2414
+ # (default bins=128 recomputes edges per call, which leaves the
2415
+ # two distributions with incompatible binning).
2416
+ shared_bins = np.linspace(xmin, xmax, 129)
2417
+ for _, color, _, dv in dh_series:
2418
+ ax.hist(dv.values, bins=shared_bins, alpha=0.55, color=color)
2419
+ ax.set_xlim(xmin, xmax)
2420
+
2421
+ stats_blocks = [
2422
+ (
2423
+ self._build_landcover_stats_text(atl06sr, col, label, top_n=top_n),
2424
+ color,
2425
+ )
2426
+ for col, color, label, _ in dh_series
2427
+ ]
2428
+
2429
+ # Stack text boxes vertically at top-left. Approximate each box's
2430
+ # height from its line count so the second box doesn't overlap the
2431
+ # first. Empirical constants tuned for fontsize=8 monospace in an
2432
+ # 8x5 inch axes.
2433
+ line_h_axes = 0.030
2434
+ pad_axes = 0.04
2435
+ gap_axes = 0.02
2436
+ box_y = 0.98
2437
+ for text, color in stats_blocks:
2438
+ ax.text(
2439
+ 0.02,
2440
+ box_y,
2441
+ text,
2442
+ transform=ax.transAxes,
2443
+ verticalalignment="top",
2444
+ fontsize=8,
2445
+ fontfamily="monospace",
2446
+ bbox=dict(
2447
+ boxstyle="round,pad=0.4",
2448
+ facecolor="white",
2449
+ edgecolor=color,
2450
+ linewidth=1.5,
2451
+ alpha=0.9,
2452
+ ),
2453
+ )
2454
+ nlines = text.count("\n") + 1
2455
+ box_y -= nlines * line_h_axes + pad_axes + gap_axes
2456
+
2457
+ ax.set_xlabel(f"ICESat-2 - DEM (m){xlabel_note}")
2458
+ ax.set_ylabel("Count")
2459
+ overall_n = len(dh_series[0][3])
2460
+ suptitle = f"{title}\n{key} (n={overall_n})"
2461
+ if self._time_range_label:
2462
+ suptitle += f"\n{self._time_range_label}"
2463
+ fig.suptitle(suptitle, size=10)
2464
+ fig.tight_layout()
2465
+ if save_dir and fig_fn:
2466
+ save_figure(fig, save_dir, fig_fn)
2467
+
2468
+ def _build_landcover_stats_text(self, atl06sr, dh_col, label, top_n=4):
2469
+ """Build the multi-line stats text for one dh column.
2470
+
2471
+ Includes an "all" header line (n, Med, NMAD) and up to ``top_n``
2472
+ per-landcover-class rows, each requiring at least 10 points.
2473
+ """
2474
+ dv = atl06sr[dh_col].dropna()
2475
+ overall_n = len(dv)
2476
+ overall_med = np.nanmedian(dv.values)
2477
+ overall_nmad = _nmad(dv.values)
2478
+ lines = [
2479
+ f"{label}: n={overall_n}, Med={overall_med:+.2f} m, NMAD={overall_nmad:.2f} m"
2156
2480
  ]
2157
2481
 
2158
2482
  wc_col = "esa_worldcover.value"
2159
2483
  if wc_col in atl06sr.columns:
2160
- valid = atl06sr.dropna(subset=["icesat_minus_dem"])
2161
- valid_wc = valid.dropna(subset=[wc_col])
2162
-
2484
+ valid_wc = atl06sr.dropna(subset=[dh_col, wc_col])
2163
2485
  if not valid_wc.empty:
2164
2486
  valid_wc = valid_wc.copy()
2165
- valid_wc["lc_name"] = valid_wc[wc_col].map(WORLDCOVER_NAMES)
2166
- valid_wc["lc_name"] = valid_wc["lc_name"].fillna("Unknown")
2167
-
2168
- grouped = valid_wc.groupby("lc_name")["icesat_minus_dem"]
2487
+ valid_wc["lc_name"] = (
2488
+ valid_wc[wc_col].map(WORLDCOVER_NAMES).fillna("Unknown")
2489
+ )
2169
2490
  class_stats = []
2170
- for name, group in grouped:
2491
+ for name, group in valid_wc.groupby("lc_name")[dh_col]:
2171
2492
  if len(group) >= 10:
2172
2493
  class_stats.append(
2173
2494
  {
@@ -2177,52 +2498,16 @@ class Altimetry:
2177
2498
  "nmad": _nmad(group.values),
2178
2499
  }
2179
2500
  )
2180
-
2181
2501
  class_stats.sort(key=lambda x: x["n"], reverse=True)
2182
2502
  class_stats = class_stats[:top_n]
2183
-
2184
2503
  if class_stats:
2185
- stats_lines.append("─" * 35)
2504
+ lines.append("─" * 35)
2186
2505
  for cs in class_stats:
2187
- stats_lines.append(
2188
- f"{cs['name']}: n={cs['n']}, Med={cs['med']:+.2f}, NMAD={cs['nmad']:.2f}"
2506
+ lines.append(
2507
+ f"{cs['name']}: n={cs['n']}, Med={cs['med']:+.2f}, "
2508
+ f"NMAD={cs['nmad']:.2f}"
2189
2509
  )
2190
-
2191
- fig, ax = plt.subplots(1, 1, figsize=(8, 5), dpi=220)
2192
-
2193
- if xlim is not None:
2194
- xmin, xmax = xlim
2195
- xlabel_note = ""
2196
- else:
2197
- # Symmetric ±3σ centered on 0 (data was already 3σ-filtered in
2198
- # atl06sr_to_dem_dh; stats above are on the filtered data)
2199
- abs_max = max(abs(dh.min()), abs(dh.max()))
2200
- xmin, xmax = -abs_max, abs_max
2201
- xlabel_note = " [±3σ]"
2202
- ax.hist(dh.values, bins=128, alpha=0.7, color="steelblue")
2203
- ax.set_xlim(xmin, xmax)
2204
-
2205
- stats_text = "\n".join(stats_lines)
2206
- ax.text(
2207
- 0.02,
2208
- 0.98,
2209
- stats_text,
2210
- transform=ax.transAxes,
2211
- verticalalignment="top",
2212
- fontsize=8,
2213
- fontfamily="monospace",
2214
- bbox=dict(boxstyle="round,pad=0.4", facecolor="white", alpha=0.9),
2215
- )
2216
-
2217
- ax.set_xlabel(f"ICESat-2 - DEM (m){xlabel_note}")
2218
- ax.set_ylabel("Count")
2219
- suptitle = f"{title}\n{key} (n={overall_n})"
2220
- if self._time_range_label:
2221
- suptitle += f"\n{self._time_range_label}"
2222
- fig.suptitle(suptitle, size=10)
2223
- fig.tight_layout()
2224
- if save_dir and fig_fn:
2225
- save_figure(fig, save_dir, fig_fn)
2510
+ return "\n".join(lines)
2226
2511
 
2227
2512
  def _resolve_best_track(self, key="all", rgt=None, cycle=None, spot=None):
2228
2513
  """
@@ -2567,17 +2852,32 @@ class Altimetry:
2567
2852
  plt.setp(ax_elev.get_xticklabels(), visible=False)
2568
2853
 
2569
2854
  # ===================== Row 2: dh profile =====================
2570
- if not dh_vals.empty:
2571
- med = np.nanmedian(dh_vals.values)
2572
- nmad_val = _nmad(dh_vals.values)
2855
+ # When plot_aligned is active, show the *post*-alignment dh and
2856
+ # recompute Med/NMAD against the aligned DEM so the bottom panel
2857
+ # reflects what the aligned-DEM page is actually evaluating.
2858
+ use_aligned_dh = (
2859
+ plot_aligned
2860
+ and self.aligned_dem_fn
2861
+ and "icesat_minus_aligned_dem" in track.columns
2862
+ )
2863
+ if use_aligned_dh:
2864
+ dh_plot = track["icesat_minus_aligned_dem"].dropna()
2865
+ dh_label_suffix = " (Aligned DEM)"
2866
+ else:
2867
+ dh_plot = dh_vals
2868
+ dh_label_suffix = ""
2869
+
2870
+ if not dh_plot.empty:
2871
+ med = np.nanmedian(dh_plot.values)
2872
+ nmad_val = _nmad(dh_plot.values)
2573
2873
  ax_dh.scatter(
2574
- dist.loc[dh_vals.index],
2575
- dh_vals,
2874
+ dist.loc[dh_plot.index],
2875
+ dh_plot,
2576
2876
  color="gray",
2577
2877
  s=4,
2578
2878
  alpha=0.6,
2579
2879
  zorder=2,
2580
- label=f"Med={med:+.2f} m, NMAD={nmad_val:.2f} m",
2880
+ label=f"Med={med:+.2f} m, NMAD={nmad_val:.2f} m{dh_label_suffix}",
2581
2881
  )
2582
2882
  ax_dh.axhline(0, color="black", linewidth=0.5, linestyle="--", zorder=1)
2583
2883
 
@@ -2624,6 +2924,7 @@ class Altimetry:
2624
2924
  rgt=None,
2625
2925
  cycle=None,
2626
2926
  spot=None,
2927
+ plot_aligned=False,
2627
2928
  save_dir=None,
2628
2929
  fig_fn=None,
2629
2930
  ):
@@ -2635,7 +2936,9 @@ class Altimetry:
2635
2936
  - Column 2: Worse agreement segment (highest score)
2636
2937
 
2637
2938
  Segment score is ``3·|median(dh)| + NMAD(dh)`` (see
2638
- ``_find_best_worst_segments``).
2939
+ ``_find_best_worst_segments``). Segment selection is based on the
2940
+ unaligned dh so best/worst segments remain comparable across the
2941
+ pre- and post-alignment variants of this plot.
2639
2942
 
2640
2943
  Parameters
2641
2944
  ----------
@@ -2647,6 +2950,11 @@ class Altimetry:
2647
2950
  Cycle number (auto-selected if None)
2648
2951
  spot : int or None, optional
2649
2952
  Spot number (auto-selected if None)
2953
+ plot_aligned : bool, optional
2954
+ Whether to overlay the aligned DEM heights and include aligned
2955
+ Median/NMAD in each segment title. Requires
2956
+ ``self.aligned_dem_fn`` and the ``aligned_dem_height`` /
2957
+ ``icesat_minus_aligned_dem`` columns. Default False.
2650
2958
  save_dir : str or None, optional
2651
2959
  Directory to save figure, default is None
2652
2960
  fig_fn : str or None, optional
@@ -2668,6 +2976,24 @@ class Altimetry:
2668
2976
  seg_best_color = "tab:blue"
2669
2977
  seg_worst_color = "tab:red"
2670
2978
 
2979
+ show_aligned = False
2980
+ if plot_aligned:
2981
+ if not self.aligned_dem_fn:
2982
+ logger.warning(
2983
+ "\nplot_aligned=True but no aligned DEM is available; "
2984
+ "showing unaligned segments only.\n"
2985
+ )
2986
+ elif (
2987
+ "aligned_dem_height" not in track.columns
2988
+ or "icesat_minus_aligned_dem" not in track.columns
2989
+ ):
2990
+ logger.warning(
2991
+ "\nAligned DEM columns missing from track; call "
2992
+ "atl06sr_to_dem_dh() after setting aligned_dem_fn.\n"
2993
+ )
2994
+ else:
2995
+ show_aligned = True
2996
+
2671
2997
  # --- 1×2 layout: better agreement | worse agreement ---
2672
2998
  fig, axes = plt.subplots(
2673
2999
  1,
@@ -2698,6 +3024,20 @@ class Altimetry:
2698
3024
  label="DEM",
2699
3025
  )
2700
3026
 
3027
+ if show_aligned:
3028
+ seg_ald = seg["aligned_dem_height"].dropna()
3029
+ if not seg_ald.empty:
3030
+ seg_ald_dist = (
3031
+ seg.loc[seg_ald.index, "x_atc"].values - seg["x_atc"].values[0]
3032
+ )
3033
+ ax_seg.plot(
3034
+ seg_ald_dist,
3035
+ seg_ald.values,
3036
+ color="darkorange",
3037
+ linewidth=1,
3038
+ label="Aligned DEM",
3039
+ )
3040
+
2701
3041
  # COP30 in segment
2702
3042
  cop30_col = "cop30.value"
2703
3043
  if cop30_col in seg.columns:
@@ -2731,8 +3071,19 @@ class Altimetry:
2731
3071
  seg_dh = seg[dh_col].dropna()
2732
3072
  seg_med = np.nanmedian(seg_dh.values) if not seg_dh.empty else 0
2733
3073
  seg_nmad = _nmad(seg_dh.values) if len(seg_dh) >= 3 else 0
3074
+ title_parts = [
3075
+ f"{label_prefix} (Med={seg_med:+.1f} m, NMAD={seg_nmad:.1f} m)"
3076
+ ]
3077
+ if show_aligned:
3078
+ seg_dh_ald = seg["icesat_minus_aligned_dem"].dropna()
3079
+ if not seg_dh_ald.empty:
3080
+ med_ald = np.nanmedian(seg_dh_ald.values)
3081
+ nmad_ald = _nmad(seg_dh_ald.values) if len(seg_dh_ald) >= 3 else 0
3082
+ title_parts.append(
3083
+ f"Aligned (Med={med_ald:+.1f} m, NMAD={nmad_ald:.1f} m)"
3084
+ )
2734
3085
  ax_seg.set_title(
2735
- f"{label_prefix} (Med={seg_med:+.1f} m, NMAD={seg_nmad:.1f} m)",
3086
+ "\n".join(title_parts),
2736
3087
  fontsize=9,
2737
3088
  color=color,
2738
3089
  )