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.
- {asp_plot-1.12.1 → asp_plot-1.13.0}/.gitignore +1 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/CHANGELOG.md +17 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/PKG-INFO +1 -1
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/altimetry.py +415 -64
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/asp_plot.py +158 -1
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/report.py +246 -35
- {asp_plot-1.12.1 → asp_plot-1.13.0}/pyproject.toml +1 -1
- {asp_plot-1.12.1 → asp_plot-1.13.0}/.flake8 +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/.github/workflows/release.yml +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/.pre-commit-config.yaml +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/.readthedocs.yaml +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/LICENSE +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/README.md +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/__init__.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/alignment.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/csm_camera.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/scenes.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/stereo.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/asp_plot/utils.py +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/conda-forge-recipe/meta.yaml +0 -0
- {asp_plot-1.12.1 → asp_plot-1.13.0}/environment.yml +0 -0
|
@@ -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.
|
|
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
|
-
|
|
2146
|
-
if
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
2155
|
-
|
|
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
|
-
|
|
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"] =
|
|
2166
|
-
|
|
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
|
|
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
|
-
|
|
2504
|
+
lines.append("─" * 35)
|
|
2186
2505
|
for cs in class_stats:
|
|
2187
|
-
|
|
2188
|
-
f"{cs['name']}: n={cs['n']}, Med={cs['med']:+.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
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
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[
|
|
2575
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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,
|
|
452
|
+
_add_runtime_table(pdf, params)
|
|
240
453
|
pdf.ln(6)
|
|
241
454
|
|
|
242
|
-
ref_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 =
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|