asp-plot 1.12.1__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.1 → asp_plot-1.13.0}/.gitignore +1 -0
  2. {asp_plot-1.12.1 → asp_plot-1.13.0}/CHANGELOG.md +17 -0
  3. {asp_plot-1.12.1 → asp_plot-1.13.0}/PKG-INFO +1 -1
  4. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/altimetry.py +415 -64
  5. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/asp_plot.py +158 -1
  6. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/report.py +246 -35
  7. {asp_plot-1.12.1 → asp_plot-1.13.0}/pyproject.toml +1 -1
  8. {asp_plot-1.12.1 → asp_plot-1.13.0}/.flake8 +0 -0
  9. {asp_plot-1.12.1 → asp_plot-1.13.0}/.github/workflows/release.yml +0 -0
  10. {asp_plot-1.12.1 → asp_plot-1.13.0}/.github/workflows/run-tests.yml +0 -0
  11. {asp_plot-1.12.1 → asp_plot-1.13.0}/.pre-commit-config.yaml +0 -0
  12. {asp_plot-1.12.1 → asp_plot-1.13.0}/.readthedocs.yaml +0 -0
  13. {asp_plot-1.12.1 → asp_plot-1.13.0}/LICENSE +0 -0
  14. {asp_plot-1.12.1 → asp_plot-1.13.0}/README.md +0 -0
  15. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/__init__.py +0 -0
  16. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/alignment.py +0 -0
  17. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/bundle_adjust.py +0 -0
  18. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/__init__.py +0 -0
  19. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  20. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  21. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/stereo_geom.py +0 -0
  22. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/csm_camera.py +0 -0
  23. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/processing_parameters.py +0 -0
  24. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/scenes.py +0 -0
  25. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/stereo.py +0 -0
  26. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/stereo_geometry.py +0 -0
  27. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/stereopair_metadata_parser.py +0 -0
  28. {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/utils.py +0 -0
  29. {asp_plot-1.12.1 → asp_plot-1.13.0}/conda-forge-recipe/meta.yaml +0 -0
  30. {asp_plot-1.12.1 → asp_plot-1.13.0}/environment.yml +0 -0
@@ -143,3 +143,4 @@ CLAUDE*
143
143
  scripts/
144
144
  /*.parquet
145
145
  reports/regenerate_reports.sh
146
+ *.csv
@@ -5,6 +5,23 @@ 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
+
8
25
  ## [1.12.1] - 2026-04-14
9
26
 
10
27
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 1.12.1
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
  )
@@ -9,7 +9,12 @@ import contextily as ctx
9
9
  from asp_plot.altimetry import Altimetry
10
10
  from asp_plot.bundle_adjust import PlotBundleAdjustFiles, ReadBundleAdjustFiles
11
11
  from asp_plot.processing_parameters import ProcessingParameters
12
- from asp_plot.report import ReportMetadata, ReportSection, compile_report
12
+ from asp_plot.report import (
13
+ AlignmentReportPage,
14
+ ReportMetadata,
15
+ ReportSection,
16
+ compile_report,
17
+ )
13
18
  from asp_plot.scenes import ScenePlotter
14
19
  from asp_plot.stereo import StereoPlotter
15
20
  from asp_plot.stereo_geometry import StereoGeometryPlotter
@@ -84,6 +89,12 @@ from asp_plot.utils import Raster, detect_planetary_body, get_acquisition_dates
84
89
  type=click.Path(exists=True),
85
90
  help="Path to a LOLA/MOLA *_topo_csv.csv file from the ODE GDS API. Required for planetary altimetry plots. Obtain via: request_planetary_altimetry --dem <dem> --email <email>, then download and unzip the result.",
86
91
  )
92
+ @click.option(
93
+ "--pc_align",
94
+ prompt=False,
95
+ default=True,
96
+ help="If True and --plot_altimetry is True, run pc_align against ICESat-2 (Earth only) and append the alignment-report pages. Disabled automatically when --plot_altimetry / --plot_icesat is False. Default: True.",
97
+ )
87
98
  @click.option(
88
99
  "--plot_geometry",
89
100
  prompt=False,
@@ -126,6 +137,7 @@ def main(
126
137
  plot_altimetry,
127
138
  plot_icesat,
128
139
  altimetry_csv,
140
+ pc_align,
129
141
  plot_geometry,
130
142
  subset_km,
131
143
  atl06sr_time_range,
@@ -574,6 +586,151 @@ def main(
574
586
  )
575
587
  )
576
588
 
589
+ # ---- pc_align + ICESat-2 alignment report (Earth only) ----
590
+ if pc_align:
591
+ align_result = icesat.align_and_evaluate(
592
+ processing_level="all",
593
+ improvement_threshold_pct=5.0,
594
+ )
595
+ stats_row = {}
596
+ if (
597
+ align_result.alignment_report_df is not None
598
+ and not align_result.alignment_report_df.empty
599
+ ):
600
+ row = align_result.alignment_report_df.iloc[0].to_dict()
601
+ row.pop("key", None)
602
+ stats_row = row
603
+
604
+ align_title = "DEM Alignment with ICESat-2"
605
+ alignment_description = (
606
+ "ASP's pc_align estimates a rigid 3D translation that "
607
+ "minimizes the height residuals between the ASP DEM and "
608
+ "ICESat-2 ATL06-SR ground-track points used as the "
609
+ "reference point cloud. The translation is applied to "
610
+ "the DEM directly (geotransform + pixel-value shift, no "
611
+ "resampling) to produce the aligned DEM.\n\n"
612
+ "Alignment Parameters (above):\n"
613
+ " - processing_level: ATL06-SR filter key used as the "
614
+ "reference; 'all' uses every filtered point.\n"
615
+ " - minimum_points: minimum ATL06-SR point count "
616
+ "required; fewer points skips the alignment.\n"
617
+ " - agreement_threshold: maximum relative disagreement "
618
+ "across temporal sub-filters before the aligned DEM is "
619
+ "flagged as inconsistent.\n"
620
+ " - min_translation_threshold: minimum translation "
621
+ "magnitude (as a fraction of the DEM GSD) required to "
622
+ "write out an aligned DEM.\n"
623
+ " - improvement_threshold_pct: minimum percentage "
624
+ "reduction in p50 required to keep the aligned DEM on "
625
+ "disk; below this, the aligned DEM is removed.\n\n"
626
+ "Alignment Statistics (above, in meters):\n"
627
+ " - p16_beg / p50_beg / p84_beg: 16th / 50th / 84th "
628
+ "percentile of the DEM-vs-ICESat absolute height "
629
+ "residuals before alignment.\n"
630
+ " - p16_end / p50_end / p84_end: same percentiles "
631
+ "after alignment.\n"
632
+ " - N_shift / E_shift / D_shift: north / east / down "
633
+ "components of the applied translation vector.\n"
634
+ " - |T|: magnitude of the translation vector."
635
+ )
636
+
637
+ if align_result.status == "insufficient_points":
638
+ sections.append(
639
+ AlignmentReportPage(
640
+ title=align_title,
641
+ parameters=align_result.parameters_used,
642
+ description=alignment_description,
643
+ status_message=align_result.message,
644
+ )
645
+ )
646
+ elif align_result.status == "no_improvement":
647
+ sections.append(
648
+ AlignmentReportPage(
649
+ title=align_title,
650
+ parameters=align_result.parameters_used,
651
+ stats_row=stats_row,
652
+ description=alignment_description,
653
+ status_message=align_result.message,
654
+ )
655
+ )
656
+ elif align_result.status == "success":
657
+ # Page A: alignment parameters + stats + description
658
+ # (the histogram gets its own full page below so the
659
+ # figure isn't squeezed into the remaining space)
660
+ sections.append(
661
+ AlignmentReportPage(
662
+ title=align_title,
663
+ parameters=align_result.parameters_used,
664
+ stats_row=stats_row,
665
+ description=alignment_description,
666
+ status_message=align_result.message,
667
+ )
668
+ )
669
+
670
+ # Page B: pre/post landcover histogram
671
+ fig_fn = f"{next(figure_counter):02}.png"
672
+ icesat.histogram_by_landcover(
673
+ key="all",
674
+ plot_aligned=True,
675
+ save_dir=plots_directory,
676
+ fig_fn=fig_fn,
677
+ )
678
+ sections.append(
679
+ ReportSection(
680
+ title="ICESat-2 ATL06-SR Histogram (Aligned DEM)",
681
+ image_path=os.path.join(plots_directory, fig_fn),
682
+ caption=(
683
+ "Pre- (steelblue) and post-alignment (orange) "
684
+ "distributions of ICESat-2 minus DEM height "
685
+ "differences, with per-landcover statistics in "
686
+ "the two stacked text boxes. Box outline color "
687
+ "matches the bar color."
688
+ ),
689
+ )
690
+ )
691
+
692
+ # Page C: profile with aligned DEM
693
+ fig_fn = f"{next(figure_counter):02}.png"
694
+ icesat.plot_atl06sr_dem_profile(
695
+ key="all",
696
+ plot_aligned=True,
697
+ save_dir=plots_directory,
698
+ fig_fn=fig_fn,
699
+ )
700
+ sections.append(
701
+ ReportSection(
702
+ title="ICESat-2 ATL06-SR Profile (Aligned DEM)",
703
+ image_path=os.path.join(plots_directory, fig_fn),
704
+ caption=(
705
+ "Elevation profile along the ICESat-2 track after "
706
+ "pc_align. The aligned DEM is overlaid on the "
707
+ "profile and used to recompute the height "
708
+ "differences shown in the lower panel."
709
+ ),
710
+ )
711
+ )
712
+
713
+ # Page D: best/worst segments with aligned DEM
714
+ fig_fn = f"{next(figure_counter):02}.png"
715
+ icesat.plot_best_worst_segments(
716
+ key="all",
717
+ plot_aligned=True,
718
+ save_dir=plots_directory,
719
+ fig_fn=fig_fn,
720
+ )
721
+ sections.append(
722
+ ReportSection(
723
+ title="ICESat-2 ATL06-SR Agreement Segments (Aligned DEM)",
724
+ image_path=os.path.join(plots_directory, fig_fn),
725
+ caption=(
726
+ "The same better- and worse-agreement segments "
727
+ "as above, now with the aligned DEM overlaid. "
728
+ "Segment selection is held fixed so Median/NMAD "
729
+ "can be compared directly."
730
+ ),
731
+ )
732
+ )
733
+
577
734
  elif body in ("moon", "mars"):
578
735
  instrument = {"moon": "LOLA", "mars": "MOLA"}[body]
579
736
 
@@ -1,7 +1,9 @@
1
1
  import logging
2
+ import math
2
3
  import os
3
4
  import textwrap
4
5
  from dataclasses import dataclass, field
6
+ from typing import Optional
5
7
 
6
8
  from fpdf import FPDF
7
9
  from PIL import Image
@@ -31,6 +33,54 @@ class ReportSection:
31
33
  figure_number: int = 0
32
34
 
33
35
 
36
+ @dataclass
37
+ class AlignmentReportPage:
38
+ """A PDF page for the pc_align-vs-ICESat-2 alignment workflow.
39
+
40
+ Each page carries (optionally) a small kwargs table, a single-row
41
+ alignment-stats table, a description paragraph, a status/message block,
42
+ and an optional figure with caption below. Rendered by compile_report
43
+ alongside ReportSection entries. Body text blocks are rendered
44
+ left-aligned (not justified) to avoid large inter-word gaps.
45
+
46
+ Attributes
47
+ ----------
48
+ title : str
49
+ Page heading.
50
+ parameters : dict
51
+ Alignment kwargs passed to ``Altimetry.align_and_evaluate``. Rendered
52
+ as a small two-column table above the stats table. Use an empty dict
53
+ to skip.
54
+ stats_row : dict
55
+ Single-row alignment statistics (e.g. p16_beg/p50_beg/... from
56
+ ``pc_align_report``). Rendered as a horizontal 1-row table with
57
+ column headers. Values are formatted to two significant figures.
58
+ Use an empty dict to skip.
59
+ description : str
60
+ Long-form explanation of pc_align and the meaning of each column in
61
+ the parameters and stats tables. Rendered between the stats table
62
+ and the status message. Empty string to skip.
63
+ status_message : str
64
+ Short status paragraph (e.g. path to aligned DEM, or a note that
65
+ alignment was skipped / produced no significant improvement).
66
+ image_path : str or None
67
+ Optional absolute path to a PNG figure rendered below the tables.
68
+ caption : str
69
+ Optional caption shown below the figure when ``image_path`` is set.
70
+ figure_number : int
71
+ Auto-assigned by compile_report().
72
+ """
73
+
74
+ title: str
75
+ parameters: dict = field(default_factory=dict)
76
+ stats_row: dict = field(default_factory=dict)
77
+ description: str = ""
78
+ status_message: str = ""
79
+ image_path: Optional[str] = None
80
+ caption: str = ""
81
+ figure_number: int = 0
82
+
83
+
34
84
  @dataclass
35
85
  class ReportMetadata:
36
86
  """Metadata about the output DEM for the report title page.
@@ -186,9 +236,16 @@ def compile_report(
186
236
  if report_metadata is not None:
187
237
  _add_metadata_table(pdf, report_metadata)
188
238
 
239
+ # ---- Processing Parameters page (page 2) ----
240
+ _add_processing_parameters_page(pdf, processing_parameters_dict, report_command)
241
+
189
242
  # ---- Figure sections ----
190
243
  for i, section in enumerate(sections, start=1):
191
244
  section.figure_number = i
245
+ if isinstance(section, AlignmentReportPage):
246
+ _render_alignment_report_page(pdf, section)
247
+ continue
248
+
192
249
  if not os.path.exists(section.image_path):
193
250
  logger.warning(f"Image not found, skipping: {section.image_path}")
194
251
  continue
@@ -198,48 +255,204 @@ def compile_report(
198
255
  pdf.cell(0, 10, section.title, new_x="LMARGIN", new_y="NEXT")
199
256
  pdf.ln(2)
200
257
 
201
- usable_width = pdf.w - pdf.l_margin - pdf.r_margin
202
- # Reserve space for caption below the image and bottom margin.
203
- # Caption font is 9pt with ~80 chars/line at usable_width; 5mm per line + spacing.
204
- if section.caption:
205
- caption_text = f"Figure {section.figure_number}: {section.caption}"
206
- estimated_lines = max(1, -(-len(caption_text) // 80)) # ceil division
207
- caption_reserve = estimated_lines * 5 + 8
208
- else:
209
- caption_reserve = 0
210
- usable_height = pdf.h - pdf.get_y() - pdf.b_margin - caption_reserve
211
-
212
- # Determine image dimensions that fit within usable area
213
- with Image.open(section.image_path) as img:
214
- img_w, img_h = img.size
215
- aspect = img_h / img_w
216
- render_w = usable_width
217
- render_h = render_w * aspect
218
- if render_h > usable_height:
219
- render_h = usable_height
220
- render_w = render_h / aspect
221
-
222
- pdf.image(
223
- section.image_path,
224
- x=pdf.l_margin + (usable_width - render_w) / 2,
225
- w=render_w,
258
+ _render_figure_with_caption(
259
+ pdf, section.image_path, section.caption, section.figure_number
226
260
  )
227
261
 
228
- if section.caption:
229
- pdf.ln(3)
230
- pdf.set_font("Helvetica", "I", 9)
231
- pdf.multi_cell(0, 5, f"Figure {section.figure_number}: {section.caption}")
262
+ pdf.output(report_pdf_path)
263
+
264
+
265
+ def _render_figure_with_caption(pdf, image_path, caption, figure_number):
266
+ """Render a figure scaled to the remaining page, with optional caption.
267
+
268
+ Parameters
269
+ ----------
270
+ pdf : ASPReportPDF
271
+ image_path : str
272
+ caption : str
273
+ figure_number : int
274
+ """
275
+ usable_width = pdf.w - pdf.l_margin - pdf.r_margin
276
+ if caption:
277
+ caption_text = f"Figure {figure_number}: {caption}"
278
+ estimated_lines = max(1, -(-len(caption_text) // 80))
279
+ caption_reserve = estimated_lines * 5 + 8
280
+ else:
281
+ caption_reserve = 0
282
+ usable_height = pdf.h - pdf.get_y() - pdf.b_margin - caption_reserve
283
+
284
+ with Image.open(image_path) as img:
285
+ img_w, img_h = img.size
286
+ aspect = img_h / img_w
287
+ render_w = usable_width
288
+ render_h = render_w * aspect
289
+ if render_h > usable_height:
290
+ render_h = usable_height
291
+ render_w = render_h / aspect
292
+
293
+ pdf.image(
294
+ image_path,
295
+ x=pdf.l_margin + (usable_width - render_w) / 2,
296
+ w=render_w,
297
+ )
298
+
299
+ if caption:
300
+ pdf.ln(3)
301
+ pdf.set_font("Helvetica", "I", 9)
302
+ pdf.multi_cell(0, 5, f"Figure {figure_number}: {caption}")
232
303
 
233
- # ---- Processing Parameters page ----
304
+
305
+ def _render_alignment_report_page(pdf, page):
306
+ """Render an AlignmentReportPage: header + optional tables + status + figure.
307
+
308
+ Parameters
309
+ ----------
310
+ pdf : ASPReportPDF
311
+ page : AlignmentReportPage
312
+ """
313
+ pdf.add_page()
314
+ pdf.set_font("Helvetica", "B", 14)
315
+ pdf.cell(0, 10, page.title, new_x="LMARGIN", new_y="NEXT")
316
+ pdf.ln(2)
317
+
318
+ if page.parameters:
319
+ _add_alignment_parameters_table(pdf, page.parameters)
320
+ pdf.ln(3)
321
+
322
+ if page.stats_row:
323
+ _add_alignment_stats_row_table(pdf, page.stats_row)
324
+ pdf.ln(3)
325
+
326
+ if page.description:
327
+ pdf.set_font("Helvetica", "", 9)
328
+ pdf.multi_cell(0, 4.5, page.description, align="L")
329
+ pdf.ln(2)
330
+
331
+ if page.status_message:
332
+ pdf.set_font("Helvetica", "B", 10)
333
+ pdf.multi_cell(0, 5, page.status_message, align="L")
334
+ pdf.ln(3)
335
+
336
+ if page.image_path and os.path.exists(page.image_path):
337
+ _render_figure_with_caption(
338
+ pdf, page.image_path, page.caption, page.figure_number
339
+ )
340
+
341
+
342
+ def _fmt_sig(x):
343
+ """Format a numeric value compactly with roughly two significant figures.
344
+
345
+ Uses fixed-point notation with 2 decimals for |x| < 10, 1 decimal for
346
+ 10 <= |x| < 100, and 0 decimals above. Returns "n/a" for non-finite
347
+ values.
348
+ """
349
+ try:
350
+ xf = float(x)
351
+ except (TypeError, ValueError):
352
+ return str(x)
353
+ if not math.isfinite(xf):
354
+ return "n/a"
355
+ if xf == 0:
356
+ return "0"
357
+ ax = abs(xf)
358
+ if ax < 10:
359
+ return f"{xf:.2f}"
360
+ if ax < 100:
361
+ return f"{xf:.1f}"
362
+ return f"{xf:.0f}"
363
+
364
+
365
+ def _add_alignment_parameters_table(pdf, parameters):
366
+ """Render the alignment kwargs table (two columns: parameter, value).
367
+
368
+ Parameters
369
+ ----------
370
+ pdf : ASPReportPDF
371
+ parameters : dict
372
+ """
373
+ pdf.set_font("Helvetica", "B", 11)
374
+ pdf.cell(0, 8, "Alignment Parameters", new_x="LMARGIN", new_y="NEXT")
375
+ pdf.ln(1)
376
+
377
+ col_w = (pdf.w - pdf.l_margin - pdf.r_margin) / 2
378
+ pdf.set_font("Helvetica", "B", 9)
379
+ pdf.set_fill_color(220, 220, 220)
380
+ pdf.cell(col_w, 6, "Parameter", border=1, fill=True)
381
+ pdf.cell(col_w, 6, "Value", border=1, fill=True, new_x="LMARGIN", new_y="NEXT")
382
+
383
+ pdf.set_font("Helvetica", "", 9)
384
+ for key, val in parameters.items():
385
+ pdf.cell(col_w, 6, str(key), border=1)
386
+ pdf.cell(col_w, 6, str(val), border=1, new_x="LMARGIN", new_y="NEXT")
387
+
388
+
389
+ _ALIGNMENT_STATS_DISPLAY_LABELS = {
390
+ "north_shift": "N_shift",
391
+ "east_shift": "E_shift",
392
+ "down_shift": "D_shift",
393
+ "translation_magnitude": "|T|",
394
+ }
395
+
396
+
397
+ def _add_alignment_stats_row_table(pdf, stats_row):
398
+ """Render a single-row horizontal alignment stats table.
399
+
400
+ Each key becomes a column header; the corresponding value becomes the
401
+ single data row (formatted to two significant figures). Long pc_align
402
+ field names are shortened via ``_ALIGNMENT_STATS_DISPLAY_LABELS`` so the
403
+ headers fit inside the table columns.
404
+
405
+ Parameters
406
+ ----------
407
+ pdf : ASPReportPDF
408
+ stats_row : dict
409
+ Ordered dict-like of ``{column_name: value}``.
410
+ """
411
+ pdf.set_font("Helvetica", "B", 11)
412
+ pdf.cell(0, 8, "Alignment Statistics (m)", new_x="LMARGIN", new_y="NEXT")
413
+ pdf.ln(1)
414
+
415
+ keys = list(stats_row.keys())
416
+ if not keys:
417
+ return
418
+
419
+ usable_w = pdf.w - pdf.l_margin - pdf.r_margin
420
+ col_w = usable_w / len(keys)
421
+
422
+ pdf.set_font("Helvetica", "B", 7)
423
+ pdf.set_fill_color(220, 220, 220)
424
+ for k in keys:
425
+ label = _ALIGNMENT_STATS_DISPLAY_LABELS.get(k, str(k))
426
+ pdf.cell(col_w, 6, label, border=1, fill=True, align="C")
427
+ pdf.ln(6)
428
+
429
+ pdf.set_font("Helvetica", "", 8)
430
+ for k in keys:
431
+ pdf.cell(col_w, 6, _fmt_sig(stats_row[k]), border=1, align="C")
432
+ pdf.ln(6)
433
+
434
+
435
+ def _add_processing_parameters_page(pdf, params, report_command):
436
+ """Add the Processing Parameters page (runtime table + commands).
437
+
438
+ Parameters
439
+ ----------
440
+ pdf : ASPReportPDF
441
+ The PDF document.
442
+ params : dict
443
+ Processing parameters dictionary from ProcessingParameters.from_log_files().
444
+ report_command : str or None
445
+ The asp_plot CLI command used to generate this report.
446
+ """
234
447
  pdf.add_page()
235
448
  pdf.set_font("Helvetica", "B", 16)
236
449
  pdf.cell(0, 10, "Processing Parameters", new_x="LMARGIN", new_y="NEXT")
237
450
  pdf.ln(4)
238
451
 
239
- _add_runtime_table(pdf, processing_parameters_dict)
452
+ _add_runtime_table(pdf, params)
240
453
  pdf.ln(6)
241
454
 
242
- ref_dem = processing_parameters_dict.get("reference_dem", "")
455
+ ref_dem = params.get("reference_dem", "")
243
456
  if ref_dem:
244
457
  pdf.set_font("Helvetica", "B", 10)
245
458
  pdf.cell(0, 7, "Reference DEM:", new_x="LMARGIN", new_y="NEXT")
@@ -252,7 +465,7 @@ def compile_report(
252
465
  ("stereo", "Stereo"),
253
466
  ("point2dem", "point2dem"),
254
467
  ]:
255
- cmd = processing_parameters_dict.get(key, "")
468
+ cmd = params.get(key, "")
256
469
  if cmd:
257
470
  pdf.set_font("Helvetica", "B", 10)
258
471
  pdf.cell(0, 7, f"{label} Command:", new_x="LMARGIN", new_y="NEXT")
@@ -269,8 +482,6 @@ def compile_report(
269
482
  pdf.multi_cell(0, 4, wrapped)
270
483
  pdf.ln(4)
271
484
 
272
- pdf.output(report_pdf_path)
273
-
274
485
 
275
486
  def _add_metadata_table(pdf, metadata):
276
487
  """Add DEM metadata summary table to the PDF.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asp_plot"
7
- version = "1.12.1"
7
+ version = "1.13.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