asp-plot 1.12.0__tar.gz → 1.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asp_plot-1.12.0 → asp_plot-1.13.0}/.gitignore +2 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/CHANGELOG.md +32 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/PKG-INFO +1 -1
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/altimetry.py +415 -64
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/asp_plot.py +180 -16
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/report.py +256 -36
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/utils.py +72 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/pyproject.toml +1 -1
- {asp_plot-1.12.0 → asp_plot-1.13.0}/.flake8 +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/.github/workflows/release.yml +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/.pre-commit-config.yaml +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/.readthedocs.yaml +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/LICENSE +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/README.md +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/__init__.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/alignment.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/csm_camera.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/scenes.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/stereo.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/conda-forge-recipe/meta.yaml +0 -0
- {asp_plot-1.12.0 → asp_plot-1.13.0}/environment.yml +0 -0
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.13.0] - 2026-04-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Automatic `pc_align` step in the Earth altimetry block**, gated by a new `--pc_align` CLI flag (default `True`; disabled automatically when `--plot_altimetry` / `--plot_icesat` is `False`). Runs `pc_align` against ICESat-2 ATL06-SR, evaluates whether the aligned DEM is worth keeping, and appends the outcome as one or more report pages:
|
|
12
|
+
- Always: an **alignment report page** with the parameters table, a single-row horizontal stats table (p16/p50/p84 beg/end, north/east/down shifts, translation magnitude, values to 2 sig figs), a description explaining what `pc_align` does and the meaning of every column in the tables above, and a bold status line for the outcome of this run.
|
|
13
|
+
- On success (p50 drops toward 0 by more than `improvement_threshold_pct`, default 5%, **and** `pc_align` actually wrote an aligned DEM): three additional full-page diagnostic figures against the aligned DEM — a pre-/post-alignment landcover histogram, the full profile, and the best/worst 1 km segments.
|
|
14
|
+
- On insufficient ATL06-SR coverage or no meaningful improvement: the aligned DEM on disk is cleaned up so its presence is a truthy signal that the alignment is worth using.
|
|
15
|
+
- **`Altimetry.align_and_evaluate(...)`** (new method) returning a plain `AlignmentResult` dataclass (`status ∈ {"insufficient_points", "no_improvement", "success"}`, `alignment_report_df`, `aligned_dem_fn`, `improvement_pct`, `message`, `parameters_used`). Does not import any `fpdf` / report dependencies, so it is safe to call from notebooks.
|
|
16
|
+
- **`plot_aligned` kwargs** on `Altimetry.histogram_by_landcover` and `Altimetry.plot_best_worst_segments`:
|
|
17
|
+
- `histogram_by_landcover(plot_aligned=True)` overlays the pre- and post-alignment distributions using shared bin edges and renders two vertically stacked per-landcover stats text boxes whose outline colors match the bar colors (color = legend).
|
|
18
|
+
- `plot_best_worst_segments(plot_aligned=True)` keeps segment selection fixed (based on the unaligned `dh` so segments are comparable), overlays aligned DEM heights on each segment, and appends aligned Median/NMAD to the segment titles.
|
|
19
|
+
- **`AlignmentReportPage`** dataclass in `asp_plot.report`: a report-section type that renders a kwargs table + single-row stats table + description + bold status line + optional figure with caption. Body text blocks render left-aligned to avoid justified word-spacing gaps.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Processing Parameters is now page 2 of the PDF**, immediately after the DEM Summary on the title page, instead of the trailing appendix. Page order is now: title + DEM summary → processing parameters → diagnostic figures → (if any) alignment results.
|
|
23
|
+
- **`plot_atl06sr_dem_profile(plot_aligned=True)`**: the lower `dh` panel now plots the post-alignment residuals (`icesat_minus_aligned_dem`) with Med/NMAD recomputed against the aligned DEM, with the legend entry tagged `"(Aligned DEM)"`. The upper elevation panel still overlays both the unaligned and aligned DEM for comparison. `plot_aligned=False` behavior is unchanged.
|
|
24
|
+
|
|
25
|
+
## [1.12.1] - 2026-04-14
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **Report panel order**: Disparity maps now follow the Bundle Adjust panels, and DEM Results precedes Detailed Hillshade (was: Hillshade → DEM Results → Disparity).
|
|
29
|
+
- **Input Scenes caption** clarifies that mapprojected scenes are RPC-orthorectified against a reference DEM to roughly pre-align the stereo pair prior to correlation (reducing disparity search range), which addresses confusion for readers coming from non-ASP photogrammetry workflows.
|
|
30
|
+
- **Match Points caption** notes these come from `stereo_corr`'s initial interest point matching step (used to set search windows), not dense correlation.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Acquisition Date(s)** row on the DEM Summary title-page table, populated when recoverable from scene metadata.
|
|
34
|
+
- `get_acquisition_dates()` helper in `utils.py`: reads `FIRSTLINETIME` from WorldView/Maxar XMLs and parses the capture timestamp from `AST_L1A_...` file/directory names. Deduplicates and sorts; returns an empty list if no date can be found, in which case the summary-table row is omitted.
|
|
35
|
+
- Unit tests for `get_acquisition_dates()` covering WorldView XMLs, ASTER filenames (top-level and in subdirectories), dedupe, and sorting of multi-date pairs.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- `--report_filename` accepts absolute and relative paths (not just bare filenames), and `~` in CLI path arguments is expanded ([#113](https://github.com/uw-cryo/asp_plot/pull/113)).
|
|
39
|
+
|
|
8
40
|
## [1.12.0] - 2026-04-10
|
|
9
41
|
|
|
10
42
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: asp_plot
|
|
3
|
-
Version: 1.
|
|
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
|
)
|