asp-plot 1.13.0__tar.gz → 1.14.1__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.13.0 → asp_plot-1.14.1}/CHANGELOG.md +25 -0
  2. {asp_plot-1.13.0 → asp_plot-1.14.1}/PKG-INFO +1 -1
  3. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/alignment.py +108 -2
  4. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/altimetry.py +503 -127
  5. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/cli/asp_plot.py +116 -2
  6. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/utils.py +10 -0
  7. {asp_plot-1.13.0 → asp_plot-1.14.1}/pyproject.toml +1 -1
  8. {asp_plot-1.13.0 → asp_plot-1.14.1}/.flake8 +0 -0
  9. {asp_plot-1.13.0 → asp_plot-1.14.1}/.github/workflows/release.yml +0 -0
  10. {asp_plot-1.13.0 → asp_plot-1.14.1}/.github/workflows/run-tests.yml +0 -0
  11. {asp_plot-1.13.0 → asp_plot-1.14.1}/.gitignore +0 -0
  12. {asp_plot-1.13.0 → asp_plot-1.14.1}/.pre-commit-config.yaml +0 -0
  13. {asp_plot-1.13.0 → asp_plot-1.14.1}/.readthedocs.yaml +0 -0
  14. {asp_plot-1.13.0 → asp_plot-1.14.1}/LICENSE +0 -0
  15. {asp_plot-1.13.0 → asp_plot-1.14.1}/README.md +0 -0
  16. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/__init__.py +0 -0
  17. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/bundle_adjust.py +0 -0
  18. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/cli/__init__.py +0 -0
  19. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/cli/csm_camera_plot.py +0 -0
  20. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  21. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/cli/stereo_geom.py +0 -0
  22. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/csm_camera.py +0 -0
  23. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/processing_parameters.py +0 -0
  24. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/report.py +0 -0
  25. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/scenes.py +0 -0
  26. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/stereo.py +0 -0
  27. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/stereo_geometry.py +0 -0
  28. {asp_plot-1.13.0 → asp_plot-1.14.1}/asp_plot/stereopair_metadata_parser.py +0 -0
  29. {asp_plot-1.13.0 → asp_plot-1.14.1}/conda-forge-recipe/meta.yaml +0 -0
  30. {asp_plot-1.13.0 → asp_plot-1.14.1}/environment.yml +0 -0
@@ -5,6 +5,31 @@ 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.14.1] - 2026-06-05
9
+
10
+ ### Fixed
11
+ - **`Raster.get_epsg_code()` returned `None` for compound / 3D-promoted CRSs** (e.g. `"EPSG:32610+EPSG:4979"`, as written by [stereopipeline-quickstart's `fetch_cop_dem.py`](https://github.com/uw-cryo/stereopipeline-quickstart/blob/main/scripts/fetch_cop_dem.py) to assert ellipsoid heights on the COP30 DEM). PROJ represents such a CRS as a UTM CRS "promoted to 3D" with no exact EPSG match, so `rasterio`'s `to_epsg()` yields `None` and downstream `f"EPSG:{epsg}"` strings crash (e.g. passing the DEM as `dem_fn` to `Altimetry`, or `Raster.get_bounds(latlon=True)`). Now falls back to the EPSG code of the horizontal (2D) component via `pyproj`'s `CRS.to_2d()`.
12
+
13
+ ## [1.14.0] - 2026-04-28
14
+
15
+ ### Added
16
+ - **Automatic `pc_align` step in the planetary altimetry block** ([#119](https://github.com/uw-cryo/asp_plot/pull/119)). The existing `--pc_align` CLI flag now also runs against MOLA (Mars) and LOLA (Moon) — previously Earth/ICESat-2 only. Mirrors the Earth pipeline: a single alignment-report page on `insufficient_points` / `no_improvement`, plus a pre/post mapview and pre/post histogram on `success`.
17
+ - **`Altimetry.align_and_evaluate_planetary(...)`**: planetary sibling of `align_and_evaluate`. Returns the same `AlignmentResult` dataclass; defaults `max_displacement=500` m (per ASAP-Stereo's CTX cookbook) and `minimum_points=20` (planetary tracks are sparse).
18
+ - **`Alignment.pc_align_dem_to_planetary_csv(...)`**: invokes ASP `pc_align` with `--csv-format '1:lon 2:lat 3:radius_m'` and `--datum D_MARS`/`D_MOON` (aligned with the [ASP `next_steps` documentation on MOLA alignment](https://stereopipeline.readthedocs.io/en/latest/next_steps.html)).
19
+ - **`Altimetry.to_csv_for_pc_align_planetary()`**: writes `lon, lat, radius_m` from `self.planetary_points` to drive `pc_align`.
20
+ - **`plot_aligned` kwarg** on `Altimetry.mapview_plot_planetary_to_dem` and `Altimetry.histogram_planetary_to_dem`: pre/post panels share color/bin scales when an aligned DEM is available.
21
+ - **Module-level constants** `MARS_IAU_SPHERE_RADIUS = 3_396_190.0` and `MOON_IAU_SPHERE_RADIUS = 1_737_400.0` so callers can reconstruct ASP-style "height above sphere" without magic numbers.
22
+
23
+ ### Changed
24
+ - **MOLA loader switched to `PLANET_RAD`**. `_load_mola_csv()` now reads the absolute planetary radius from the ODE GDS `*_pts_csv.csv` and computes `height = PLANET_RAD - 3,396,190` (IAU 2000 Mars sphere). The `*_topo_csv.csv` (TOPOGRAPHY only) is **rejected** with an explanatory error: TOPOGRAPHY is referenced to the **oblate** MOLA areoid while ASP DEMs use the **spherical** IAU 2000 datum, so dh from TOPOGRAPHY carries a latitude-dependent offset of up to ~10 km that `pc_align` cannot remove. Verified on the MOC NA tutorial scene at lat 34°N: signed median dh dropped from +6,000 m (TOPOGRAPHY path) → +99.74 m (PLANET_RAD path) → +3.13 m (after `pc_align`). Reference: [MOLA PEDR Software Interface Specification (PDS Geosciences)](https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-3-pedr-l1a-v1/mgsl_21xx/document/pedrsis.pdf).
25
+ - **LOLA loader prefers `Pt_Radius` (km) when available**. The Point per Row LOLA RDR CSV (`results=p`) carries `Pt_Radius` in **kilometers**; the simple Topography CSV (`results=u`) carries Topography in meters. `_load_lola_csv()` auto-detects km by magnitude (< 10 000) and converts to meters, then writes both `height` (m above the IAU 1737.4 km lunar sphere) and `radius_m` to `self.planetary_points`. The Moon is essentially spherical (~1.4 km equatorial-vs-polar variation), so either CSV gives the same dh to ~1 m. Reference: [ODE GDS REST V2.0 manual](https://oderest.rsl.wustl.edu/GDS_REST_V2.0.pdf).
26
+ - **`Alignment.apply_dem_translation()` is body-aware**. Picks a body-centered geocentric "ECEF-equivalent" CRS from a new module-level `_GEOCENTRIC_PROJ` dict — Earth uses `EPSG:4978`; Mars/Moon use PROJ strings (`+proj=geocent +R=...`) because PROJ refuses to convert across celestial bodies. Without this fix, applying a `pc_align` translation to a Mars/Moon DEM raised `RuntimeError: Source and target ellipsoid do not belong to the same celestial body`.
27
+ - **`planetary_to_dem_dh()` also samples the aligned DEM** when `self.aligned_dem_fn` is set, populating `aligned_dem_height` and `altimetry_minus_aligned_dem` so pre/post plots share a single sample. Refactored shared interpolation into `_sample_dem_at_planetary_points()`.
28
+
29
+ ### Documentation
30
+ - **MOC NA notebook consolidated** into `notebooks/Mars_MGS/mars_mgs_orbital_camera.ipynb` covering both stereo variants of the M0100115 / E0201461 pair (the `mars_mgs_orbital_camera_narrow_angle.ipynb` notebook for a different scene pair was removed). Mirrors the ASTER mapproj/non-mapproj layout. Stereo commands match this repo's WorldView convention (`parallel_stereo --stereo-algorithm asp_mgm --subpixel-mode 9 --processes 2 --threads 4`, `--alignment-method affineepipolar` for non-mapprojected, `--alignment-method none` for mapprojected via `cam2map4stereo.py`). The notebook intro includes a callout explaining the spherical-vs-oblate elevation-range surprise. Reports: `MOC-asp-plot-report.pdf` and `MOC_mapproj-asp-plot-report.pdf`.
31
+ - **LRO NAC notebook reprocessed on the full 5000×5000 cubes** in [`LRONAC_example.tar`](https://github.com/NeoGeographyToolkit/StereoPipelineSolvedExamples/releases/download/LRONAC/LRONAC_example.tar) instead of the 900×973 sub-window the ASP "lightning fast" tutorial uses. Resulting DEM is 4720×4510 at 1.04 m GSD (~4.7 km × 4.7 km, vs the old ~1 km × 1 km), 95.88% valid pixels. LOLA query expanded to match: 1539 of 2044 LOLA points overlap the DEM (vs 12 of 19 on the old crop), enough for a meaningful `pc_align` and to bring out spacecraft jitter in the disparity panels.
32
+
8
33
  ## [1.13.0] - 2026-04-20
9
34
 
10
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 1.13.0
3
+ Version: 1.14.1
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
@@ -5,7 +5,23 @@ import re
5
5
  import numpy as np
6
6
  from osgeo import gdal, osr
7
7
 
8
- from asp_plot.utils import Raster, glob_file, run_subprocess_command
8
+ from asp_plot.utils import (
9
+ Raster,
10
+ detect_planetary_body,
11
+ glob_file,
12
+ run_subprocess_command,
13
+ )
14
+
15
+ # Body-centered geocentric ("ECEF-equivalent") CRS for each supported
16
+ # planet. Used by apply_dem_translation to convert pc_align's Cartesian
17
+ # translation vector into the DEM's projected coordinate system. The
18
+ # Earth case is keyed via EPSG; planets need a PROJ string because PROJ
19
+ # refuses to operate across celestial bodies.
20
+ _GEOCENTRIC_PROJ = {
21
+ "earth": None, # use EPSG:4978
22
+ "moon": "+proj=geocent +R=1737400 +units=m +no_defs",
23
+ "mars": "+proj=geocent +R=3396190 +units=m +no_defs",
24
+ }
9
25
 
10
26
  logging.basicConfig(level=logging.WARNING)
11
27
  logger = logging.getLogger(__name__)
@@ -134,6 +150,86 @@ class Alignment:
134
150
 
135
151
  run_subprocess_command(command)
136
152
 
153
+ def pc_align_dem_to_planetary_csv(
154
+ self,
155
+ planetary_csv,
156
+ body,
157
+ max_displacement=500,
158
+ max_source_points=10000000,
159
+ alignment_method="point-to-point",
160
+ output_prefix="pc_align/pc_align",
161
+ ):
162
+ """
163
+ Align DEM to MOLA/LOLA point cloud using pc_align.
164
+
165
+ Parameters
166
+ ----------
167
+ planetary_csv : str
168
+ Path to a CSV with ``lon``, ``lat``, ``radius_m`` columns
169
+ (planetary radius in meters from the body center). Produced by
170
+ :meth:`asp_plot.altimetry.Altimetry.to_csv_for_pc_align_planetary`.
171
+ body : str
172
+ ``"moon"`` or ``"mars"`` — selects the ASP ``--datum`` flag.
173
+ max_displacement : float, optional
174
+ Maximum expected displacement in meters. ASAP-Stereo's CTX
175
+ cookbook recommends ~500 m as a generic default for planetary
176
+ stereo where absolute pointing is uncertain.
177
+ max_source_points : int, optional
178
+ Maximum number of source DEM points pc_align will sample.
179
+ alignment_method : str, optional
180
+ ASP alignment method. Default ``point-to-point``.
181
+ output_prefix : str, optional
182
+ Prefix for pc_align output files, relative to ``self.directory``.
183
+
184
+ Notes
185
+ -----
186
+ Uses ``--csv-format '1:lon 2:lat 3:radius_m'`` so pc_align reads
187
+ the absolute planetary radius and avoids any datum/areoid
188
+ ambiguity. ``--datum`` is set to ``D_MARS`` (3,396,190 m) or
189
+ ``D_MOON`` (1,737,400 m) to match ASP DEM heights, which are
190
+ stored as ``radius - sphere``.
191
+ """
192
+ if not os.path.exists(planetary_csv):
193
+ raise ValueError(
194
+ f"\nPlanetary altimetry CSV not found: {planetary_csv}\n"
195
+ "Use Altimetry.to_csv_for_pc_align_planetary() to create it.\n"
196
+ )
197
+
198
+ datum = {"moon": "D_MOON", "mars": "D_MARS"}.get(body)
199
+ if datum is None:
200
+ raise ValueError(
201
+ f"Unsupported body for pc_align_dem_to_planetary_csv: {body}"
202
+ )
203
+
204
+ pc_align_folder = os.path.join(self.directory, output_prefix)
205
+
206
+ print(
207
+ f"Running pc_align on {self.dem_fn} and {planetary_csv}\n"
208
+ f" --datum {datum}, --max-displacement {max_displacement}\n"
209
+ f"Writing to {pc_align_folder}*"
210
+ )
211
+
212
+ command = [
213
+ "pc_align",
214
+ "--max-displacement",
215
+ str(max_displacement),
216
+ "--max-num-source-points",
217
+ str(max_source_points),
218
+ "--alignment-method",
219
+ alignment_method,
220
+ "--csv-format",
221
+ "1:lon 2:lat 3:radius_m",
222
+ "--datum",
223
+ datum,
224
+ "--compute-translation-only",
225
+ "--output-prefix",
226
+ pc_align_folder,
227
+ self.dem_fn,
228
+ planetary_csv,
229
+ ]
230
+
231
+ run_subprocess_command(command)
232
+
137
233
  def pc_align_report(self, output_prefix="pc_align/pc_align"):
138
234
  """
139
235
  Extract alignment statistics from pc_align log files.
@@ -281,8 +377,18 @@ class Alignment:
281
377
  llz_c = llz_c[i]
282
378
  llz_shift = llz_shift[i]
283
379
 
380
+ # pc_align's Cartesian translation lives in the body-centered
381
+ # frame defined by --datum, so the source CRS must match the
382
+ # body of the DEM. PROJ refuses to convert between celestial
383
+ # bodies, so use a body-specific geocentric PROJ string for
384
+ # Mars/Moon and EPSG:4978 for Earth.
385
+ body = detect_planetary_body(self.dem_fn)
284
386
  ecef_srs = osr.SpatialReference()
285
- ecef_srs.ImportFromEPSG(4978)
387
+ proj_string = _GEOCENTRIC_PROJ.get(body)
388
+ if proj_string is None:
389
+ ecef_srs.ImportFromEPSG(4978)
390
+ else:
391
+ ecef_srs.ImportFromProj4(proj_string)
286
392
 
287
393
  s_srs = ecef_srs
288
394
  src_c = ecef_c
@@ -25,6 +25,11 @@ logger = logging.getLogger(__name__)
25
25
 
26
26
  ICESAT2_MISSION_START = datetime(2018, 10, 14, tzinfo=timezone.utc)
27
27
 
28
+ # IAU 2000 mean equatorial radii used by ASP DEMs and pc_align.
29
+ # ASP DEM heights are stored as (planetary_radius - sphere_radius).
30
+ MARS_IAU_SPHERE_RADIUS = 3_396_190.0 # meters
31
+ MOON_IAU_SPHERE_RADIUS = 1_737_400.0 # meters
32
+
28
33
  WORLDCOVER_NAMES = {
29
34
  10: "Tree cover",
30
35
  20: "Shrubland",
@@ -1379,7 +1384,16 @@ class Altimetry:
1379
1384
  ]
1380
1385
  _LAT_CANDIDATES = ["pt_latitude", "lat_north", "latitude", "areocentric_latitude"]
1381
1386
  _TOPO_CANDIDATES = ["topography", "topo"]
1382
- _RADIUS_CANDIDATES = ["planet_rad", "radius", "planetary_radius"]
1387
+ # PLANET_RAD column names from ODE GDS PEDR (Mars) and LOLA RDR
1388
+ # "Point Per Row" CSV (Moon, has Pt_Radius). Order matters — names
1389
+ # earlier in the list are preferred.
1390
+ _RADIUS_CANDIDATES = [
1391
+ "planet_rad",
1392
+ "planet_rad (shot_planetary_radius)",
1393
+ "pt_radius",
1394
+ "planetary_radius",
1395
+ "radius",
1396
+ ]
1383
1397
 
1384
1398
  # Geographic CRS WKT strings for building GeoDataFrames
1385
1399
  _MOON_GEO_CRS = 'GEOGCRS["Moon",DATUM["D_MOON",ELLIPSOID["MOON",1737400,0]],PRIMEM["Reference_Meridian",0],CS[ellipsoidal,2],AXIS["latitude",north,ORDER[1],ANGLEUNIT["degree",0.0174532925199433]],AXIS["longitude",east,ORDER[2],ANGLEUNIT["degree",0.0174532925199433]]]'
@@ -1406,10 +1420,13 @@ class Altimetry:
1406
1420
  return cols_lower[c]
1407
1421
  return None
1408
1422
 
1409
- def _load_planetary_csv_common(self, csv_path, instrument):
1423
+ def _load_planetary_csv_common(self, csv_path, instrument, prefer="radius"):
1410
1424
  """Shared CSV loading logic for LOLA and MOLA.
1411
1425
 
1412
1426
  Reads the CSV, validates columns, converts longitude to -180/180.
1427
+ Picks PLANET_RAD over TOPOGRAPHY by default (``prefer="radius"``)
1428
+ because TOPOGRAPHY is referenced to the oblate areoid and produces
1429
+ a latitude-dependent offset against ASP's spherical-IAU DEMs.
1413
1430
 
1414
1431
  Parameters
1415
1432
  ----------
@@ -1417,12 +1434,14 @@ class Altimetry:
1417
1434
  Path to the CSV file.
1418
1435
  instrument : str
1419
1436
  ``"LOLA"`` or ``"MOLA"`` (for error messages).
1437
+ prefer : {"radius", "topo"}
1438
+ Which column family to try first.
1420
1439
 
1421
1440
  Returns
1422
1441
  -------
1423
- tuple of (pandas.DataFrame, str or None, bool)
1442
+ tuple of (pandas.DataFrame, bool)
1424
1443
  (df with ``lon``, ``lat``, ``height_raw`` columns,
1425
- height column name found, whether it was a radius column)
1444
+ ``is_radius`` flag True if the chosen column is a planetary radius)
1426
1445
  """
1427
1446
  df = pd.read_csv(csv_path)
1428
1447
 
@@ -1436,21 +1455,24 @@ class Altimetry:
1436
1455
 
1437
1456
  lon_col = self._find_csv_column(cols_lower, self._LON_CANDIDATES)
1438
1457
  lat_col = self._find_csv_column(cols_lower, self._LAT_CANDIDATES)
1439
- topo_col = self._find_csv_column(cols_lower, self._TOPO_CANDIDATES)
1440
1458
 
1441
- is_radius = False
1442
- height_col = topo_col
1443
- if height_col is None:
1444
- height_col = self._find_csv_column(cols_lower, self._RADIUS_CANDIDATES)
1445
- is_radius = height_col is not None
1459
+ if prefer == "radius":
1460
+ primary = self._find_csv_column(cols_lower, self._RADIUS_CANDIDATES)
1461
+ fallback = self._find_csv_column(cols_lower, self._TOPO_CANDIDATES)
1462
+ is_radius = primary is not None
1463
+ else:
1464
+ primary = self._find_csv_column(cols_lower, self._TOPO_CANDIDATES)
1465
+ fallback = self._find_csv_column(cols_lower, self._RADIUS_CANDIDATES)
1466
+ is_radius = primary is None and fallback is not None
1467
+
1468
+ height_col = primary if primary is not None else fallback
1446
1469
 
1447
1470
  if lon_col is None or lat_col is None or height_col is None:
1448
1471
  raise ValueError(
1449
1472
  f"{instrument} CSV does not have expected columns.\n"
1450
1473
  f" Found: {list(df.columns)}\n"
1451
- f" Expected longitude, latitude, and topography columns.\n\n"
1452
- f"Make sure you are using the '*_topo_csv.csv' file from the "
1453
- f"ODE GDS download, not the '*_pts_csv.csv' or label file."
1474
+ f" Expected longitude, latitude, and either a planetary "
1475
+ f"radius (PLANET_RAD / Pt_Radius) or topography column."
1454
1476
  )
1455
1477
 
1456
1478
  df = df.rename(
@@ -1463,15 +1485,44 @@ class Altimetry:
1463
1485
  return df, is_radius
1464
1486
 
1465
1487
  def _load_lola_csv(self, csv_path):
1466
- """Parse a LOLA simple-topography CSV into a GeoDataFrame.
1488
+ """Parse a LOLA topo CSV into a GeoDataFrame.
1489
+
1490
+ Stores height above the IAU 1737.4 km lunar sphere on
1491
+ ``self.planetary_points["height"]`` and the planetary radius on
1492
+ ``self.planetary_points["radius_m"]`` (used by pc_align).
1493
+
1494
+ The Moon is nearly spherical (1.4 km equatorial-vs-polar variation),
1495
+ so LOLA TOPOGRAPHY ≈ Pt_Radius − 1737.4 km. Either column gives the
1496
+ same dh against an ASP lunar DEM to <1 m.
1467
1497
 
1468
1498
  Parameters
1469
1499
  ----------
1470
1500
  csv_path : str
1471
- Path to the CSV file.
1501
+ Path to a LOLA RDR CSV from the ODE GDS API.
1472
1502
  """
1473
- df, _ = self._load_planetary_csv_common(csv_path, "LOLA")
1474
- df["height"] = df["height_raw"]
1503
+ df, is_radius = self._load_planetary_csv_common(
1504
+ csv_path, "LOLA", prefer="radius"
1505
+ )
1506
+
1507
+ if is_radius:
1508
+ # LOLA RDR Pt_Radius is in KILOMETERS (per the ODE GDS Point
1509
+ # per Row CSV header), unlike MOLA PEDR PLANET_RAD which is
1510
+ # in meters. Detect units by magnitude (~1737 vs ~1737000)
1511
+ # and normalize to meters.
1512
+ if df["height_raw"].median() < 10000:
1513
+ df["height_raw"] = df["height_raw"] * 1000.0
1514
+ unit_note = " (km → m)"
1515
+ else:
1516
+ unit_note = ""
1517
+ df["height"] = df["height_raw"] - MOON_IAU_SPHERE_RADIUS
1518
+ df["radius_m"] = df["height_raw"]
1519
+ source = f"Pt_Radius{unit_note}"
1520
+ else:
1521
+ # Topography path: the Moon is essentially spherical so LOLA
1522
+ # topography ≈ height above the IAU 1737.4 km sphere.
1523
+ df["height"] = df["height_raw"]
1524
+ df["radius_m"] = df["height_raw"] + MOON_IAU_SPHERE_RADIUS
1525
+ source = "Topography"
1475
1526
 
1476
1527
  gdf = gpd.GeoDataFrame(
1477
1528
  df,
@@ -1479,62 +1530,89 @@ class Altimetry:
1479
1530
  crs=self._MOON_GEO_CRS,
1480
1531
  )
1481
1532
  self.planetary_points = gdf
1482
- print(f"Loaded {len(gdf)} LOLA points")
1533
+ print(f"Loaded {len(gdf)} LOLA points (using {source} column)")
1483
1534
 
1484
1535
  def _load_mola_csv(self, csv_path):
1485
1536
  """Parse a MOLA PEDR CSV into a GeoDataFrame.
1486
1537
 
1487
- TOPOGRAPHY values are heights above the MOLA areoid (Mars
1488
- geoid). ASP DEMs store heights above the IAU sphere
1489
- (3,396,190 m). These are different vertical datums, so a
1490
- systematic offset equal to the local areoid height will be
1491
- present in the dh values. If only PLANET_RAD (radius) is
1492
- available, a -190 m correction converts from the MOLA sphere
1493
- (3,396,000 m) to the IAU sphere.
1538
+ Uses the PLANET_RAD column and converts to height above the IAU
1539
+ Mars sphere (3,396,190 m): ``height = PLANET_RAD - 3,396,190``.
1540
+ This is the same reference as ASP DEMs, so dh = MOLA − DEM is
1541
+ directly meaningful at all latitudes.
1542
+
1543
+ TOPOGRAPHY (in ``*_topo_csv.csv``) is referenced to the MOLA
1544
+ oblate areoid and produces a latitude-dependent offset of up to
1545
+ ~10 km against an ASP DEM, so it is rejected here. Pass the
1546
+ ``*_pts_csv.csv`` from the ODE GDS download instead.
1494
1547
 
1495
1548
  Parameters
1496
1549
  ----------
1497
1550
  csv_path : str
1498
- Path to the CSV file.
1551
+ Path to a MOLA PEDR CSV from the ODE GDS API. Must include
1552
+ a ``PLANET_RAD`` column (use the ``*_pts_csv.csv`` file).
1499
1553
  """
1500
- df, is_radius = self._load_planetary_csv_common(csv_path, "MOLA")
1554
+ df, is_radius = self._load_planetary_csv_common(
1555
+ csv_path, "MOLA", prefer="radius"
1556
+ )
1501
1557
 
1502
- MOLA_SPHERE_OFFSET = 190.0 # meters
1503
- if is_radius:
1504
- df["height"] = df["height_raw"] - MOLA_SPHERE_OFFSET
1505
- print(
1506
- f"Applied -{MOLA_SPHERE_OFFSET} m correction "
1507
- "(MOLA sphere IAU sphere)"
1508
- )
1509
- else:
1510
- # MOLA TOPOGRAPHY is height above the MOLA areoid (Mars geoid).
1511
- # ASP DEMs store height above the IAU sphere (3,396,190 m).
1512
- # These are different vertical datums, so a systematic offset
1513
- # (equal to the local areoid height) will be present in the
1514
- # dh values. This is a known limitation — correcting it
1515
- # requires the MOLA areoid grid or ASP's `dem_geoid --geoid MOLA`.
1516
- df["height"] = df["height_raw"]
1517
- print(
1518
- "Note: MOLA topography is referenced to the MOLA areoid. "
1519
- "ASP DEMs use the IAU sphere. A systematic vertical offset "
1520
- "may be present in dh values."
1558
+ if not is_radius:
1559
+ raise ValueError(
1560
+ f"MOLA CSV is missing the PLANET_RAD column: {csv_path}\n"
1561
+ "The ODE GDS '*_topo_csv.csv' only has TOPOGRAPHY, which is "
1562
+ "height above the oblate MOLA areoid. ASP DEMs use the "
1563
+ "spherical IAU 2000 datum (3,396,190 m), so dh from "
1564
+ "TOPOGRAPHY carries a latitude-dependent offset of up to "
1565
+ "~10 km that pc_align cannot remove.\n\n"
1566
+ "Pass the '*_pts_csv.csv' file from the same ODE GDS "
1567
+ "download instead it contains PLANET_RAD."
1521
1568
  )
1522
1569
 
1570
+ df["height"] = df["height_raw"] - MARS_IAU_SPHERE_RADIUS
1571
+ df["radius_m"] = df["height_raw"]
1572
+
1523
1573
  gdf = gpd.GeoDataFrame(
1524
1574
  df,
1525
1575
  geometry=gpd.points_from_xy(df["lon"], df["lat"]),
1526
1576
  crs=self._MARS_GEO_CRS,
1527
1577
  )
1528
1578
  self.planetary_points = gdf
1529
- print(f"Loaded {len(gdf)} MOLA points")
1579
+ print(
1580
+ f"Loaded {len(gdf)} MOLA points "
1581
+ f"(PLANET_RAD - {MARS_IAU_SPHERE_RADIUS:.0f} m → height above IAU sphere)"
1582
+ )
1583
+
1584
+ def _sample_dem_at_planetary_points(self, dem_fn, height_col, dh_col):
1585
+ """Interpolate DEM heights at the loaded altimetry points.
1586
+
1587
+ Adds ``height_col`` and ``dh_col`` (= altimetry height − DEM height)
1588
+ to ``self.planetary_points`` in-place. Used by
1589
+ :meth:`planetary_to_dem_dh` for both the raw and aligned DEMs.
1590
+ """
1591
+ if self.planetary_points is None or self.planetary_points.empty:
1592
+ return
1593
+
1594
+ dem = rioxarray.open_rasterio(dem_fn, masked=True).squeeze()
1595
+ dem_crs = dem.rio.crs
1596
+
1597
+ pts = self.planetary_points.to_crs(dem_crs)
1598
+ x = xr.DataArray(pts.geometry.x.values, dims="z")
1599
+ y = xr.DataArray(pts.geometry.y.values, dims="z")
1600
+ sample = dem.interp(x=x, y=y).values
1601
+
1602
+ self.planetary_points[height_col] = sample
1603
+ self.planetary_points[dh_col] = self.planetary_points["height"] - sample
1530
1604
 
1531
1605
  def planetary_to_dem_dh(self, n_sigma=3):
1532
1606
  """Compute height differences between planetary altimetry and DEM.
1533
1607
 
1534
1608
  Reprojects ``self.planetary_points`` to the DEM CRS, interpolates
1535
1609
  DEM heights at altimetry locations, and computes the difference
1536
- ``altimetry_minus_dem = height - dem_height``. Outliers beyond
1537
- ``n_sigma`` × std from the mean are removed by default.
1610
+ ``altimetry_minus_dem = height - dem_height``. When
1611
+ ``self.aligned_dem_fn`` is set, also populates
1612
+ ``aligned_dem_height`` and ``altimetry_minus_aligned_dem`` so
1613
+ pre/post-alignment plots can share a single sample. Outliers
1614
+ beyond ``n_sigma`` × std from the mean (computed on the
1615
+ unaligned dh) are removed by default.
1538
1616
 
1539
1617
  Parameters
1540
1618
  ----------
@@ -1548,23 +1626,15 @@ class Altimetry:
1548
1626
  logger.warning("No planetary altimetry points loaded.")
1549
1627
  return
1550
1628
 
1551
- dem = rioxarray.open_rasterio(self.dem_fn, masked=True).squeeze()
1552
- dem_crs = dem.rio.crs
1553
-
1554
- # Reproject points to the DEM CRS (use CRS object, not EPSG)
1555
- pts = self.planetary_points.to_crs(dem_crs)
1556
-
1557
- x = xr.DataArray(pts.geometry.x.values, dims="z")
1558
- y = xr.DataArray(pts.geometry.y.values, dims="z")
1559
- sample = dem.interp(x=x, y=y)
1560
-
1561
- pts["dem_height"] = sample.values
1562
- pts["altimetry_minus_dem"] = pts["height"] - pts["dem_height"]
1563
-
1564
- # Update geometry back to geographic CRS for storage
1565
- self.planetary_points = pts.to_crs(self.planetary_points.crs)
1566
- self.planetary_points["dem_height"] = pts["dem_height"].values
1567
- self.planetary_points["altimetry_minus_dem"] = pts["altimetry_minus_dem"].values
1629
+ self._sample_dem_at_planetary_points(
1630
+ self.dem_fn, "dem_height", "altimetry_minus_dem"
1631
+ )
1632
+ if self.aligned_dem_fn:
1633
+ self._sample_dem_at_planetary_points(
1634
+ self.aligned_dem_fn,
1635
+ "aligned_dem_height",
1636
+ "altimetry_minus_aligned_dem",
1637
+ )
1568
1638
 
1569
1639
  valid = self.planetary_points["altimetry_minus_dem"].dropna()
1570
1640
  print(f"Computed dh for {len(valid)} of {len(self.planetary_points)} points")
@@ -1587,28 +1657,272 @@ class Altimetry:
1587
1657
  f"(removed {n_before - n_after})"
1588
1658
  )
1589
1659
 
1660
+ def to_csv_for_pc_align_planetary(self, filename_prefix="planetary_for_pc_align"):
1661
+ """Export ``self.planetary_points`` to a CSV for pc_align.
1662
+
1663
+ Writes columns ``lon, lat, radius_m`` (planetary radius from the
1664
+ body center, in meters). Used as the ``planetary_csv`` argument
1665
+ to :meth:`asp_plot.alignment.Alignment.pc_align_dem_to_planetary_csv`.
1666
+
1667
+ Parameters
1668
+ ----------
1669
+ filename_prefix : str, optional
1670
+ Prefix for the output CSV filename. Saved in
1671
+ ``self.directory``.
1672
+
1673
+ Returns
1674
+ -------
1675
+ str
1676
+ Absolute path to the created CSV file.
1677
+ """
1678
+ if self.planetary_points is None or self.planetary_points.empty:
1679
+ raise ValueError("No planetary altimetry points loaded.")
1680
+ if "radius_m" not in self.planetary_points.columns:
1681
+ raise ValueError(
1682
+ "planetary_points has no radius_m column. Call "
1683
+ "load_planetary_csv() first to populate it."
1684
+ )
1685
+
1686
+ # Drop dh-NaN rows so pc_align doesn't reject the file. We also
1687
+ # restrict to points that fall on the DEM (have a finite
1688
+ # dem_height) when available — pc_align can use them as the
1689
+ # reference cloud only if they overlap the source.
1690
+ df = self.planetary_points.copy()
1691
+ df["lon"] = df.geometry.x
1692
+ df["lat"] = df.geometry.y
1693
+ df = df[["lon", "lat", "radius_m"]].dropna()
1694
+ if df.empty:
1695
+ raise ValueError("No valid planetary altimetry points after dropping NaNs.")
1696
+
1697
+ csv_fn = os.path.join(self.directory, f"{filename_prefix}.csv")
1698
+ df.to_csv(csv_fn, header=True, index=False)
1699
+ return csv_fn
1700
+
1701
+ def align_and_evaluate_planetary(
1702
+ self,
1703
+ max_displacement=500,
1704
+ improvement_threshold_pct=5.0,
1705
+ min_translation_threshold=0.1,
1706
+ minimum_points=20,
1707
+ ):
1708
+ """Run pc_align against MOLA/LOLA and evaluate whether to keep it.
1709
+
1710
+ Mirrors :meth:`align_and_evaluate` (the ICESat-2 path) but for
1711
+ planetary altimetry. Requires :meth:`load_planetary_csv` to have
1712
+ been called so ``self.planetary_points`` is populated.
1713
+
1714
+ Decision logic:
1715
+
1716
+ 1. If fewer than ``minimum_points`` valid planetary points are
1717
+ available, return ``status="insufficient_points"``.
1718
+ 2. Otherwise compute
1719
+ ``improvement_pct = (p50_beg - p50_end) / p50_beg * 100``. If
1720
+ ``p50_end >= p50_beg`` or
1721
+ ``improvement_pct <= improvement_threshold_pct`` or the
1722
+ translation magnitude is below ``min_translation_threshold ×
1723
+ DEM GSD``, delete the aligned DEM and return
1724
+ ``status="no_improvement"``.
1725
+ 3. Otherwise re-run :meth:`planetary_to_dem_dh` to populate
1726
+ ``altimetry_minus_aligned_dem`` and return ``status="success"``.
1727
+
1728
+ Parameters
1729
+ ----------
1730
+ max_displacement : float, optional
1731
+ ``--max-displacement`` for pc_align, in meters. Default 500
1732
+ (ASAP-Stereo's CTX cookbook recommendation).
1733
+ improvement_threshold_pct : float, optional
1734
+ Minimum p50 reduction (%) required to keep the aligned DEM.
1735
+ min_translation_threshold : float, optional
1736
+ Minimum translation magnitude as a fraction of the DEM GSD.
1737
+ minimum_points : int, optional
1738
+ Minimum number of valid altimetry points to attempt
1739
+ alignment. Planetary tracks are sparser than ICESat-2, so
1740
+ this defaults to a much smaller number than the Earth path.
1741
+
1742
+ Returns
1743
+ -------
1744
+ AlignmentResult
1745
+ """
1746
+ from asp_plot.utils import detect_planetary_body
1747
+
1748
+ body = detect_planetary_body(self.dem_fn)
1749
+ instrument = {"moon": "LOLA", "mars": "MOLA"}.get(body, "Altimetry")
1750
+
1751
+ parameters_used = {
1752
+ "max_displacement": max_displacement,
1753
+ "minimum_points": minimum_points,
1754
+ "min_translation_threshold": min_translation_threshold,
1755
+ "improvement_threshold_pct": improvement_threshold_pct,
1756
+ }
1757
+
1758
+ if self.planetary_points is None or self.planetary_points.empty:
1759
+ return AlignmentResult(
1760
+ status="insufficient_points",
1761
+ alignment_report_df=pd.DataFrame(),
1762
+ aligned_dem_fn=None,
1763
+ improvement_pct=None,
1764
+ message=(
1765
+ f"Alignment skipped: no {instrument} points loaded. "
1766
+ "Call load_planetary_csv() first."
1767
+ ),
1768
+ parameters_used=parameters_used,
1769
+ )
1770
+
1771
+ # planetary_to_dem_dh both samples and 3σ-filters; require enough
1772
+ # points whose dh is finite (i.e. fall inside the DEM extent).
1773
+ if "altimetry_minus_dem" not in self.planetary_points.columns:
1774
+ self.planetary_to_dem_dh()
1775
+ n_valid = int(self.planetary_points["altimetry_minus_dem"].notna().sum())
1776
+ if n_valid < minimum_points:
1777
+ self._remove_aligned_dem_if_present()
1778
+ return AlignmentResult(
1779
+ status="insufficient_points",
1780
+ alignment_report_df=pd.DataFrame(),
1781
+ aligned_dem_fn=None,
1782
+ improvement_pct=None,
1783
+ message=(
1784
+ f"Alignment skipped: only {n_valid} {instrument} points "
1785
+ f"overlap the DEM (need >= {minimum_points})."
1786
+ ),
1787
+ parameters_used=parameters_used,
1788
+ )
1789
+
1790
+ csv_fn = self.to_csv_for_pc_align_planetary(
1791
+ filename_prefix=f"{instrument.lower()}_for_pc_align"
1792
+ )
1793
+
1794
+ alignment = Alignment(self.directory, self.dem_fn)
1795
+ output_prefix = f"pc_align/pc_align_{instrument.lower()}"
1796
+ alignment.pc_align_dem_to_planetary_csv(
1797
+ planetary_csv=csv_fn,
1798
+ body=body,
1799
+ max_displacement=max_displacement,
1800
+ output_prefix=output_prefix,
1801
+ )
1802
+
1803
+ report = alignment.pc_align_report(output_prefix=output_prefix)
1804
+ if not report:
1805
+ self._remove_aligned_dem_if_present()
1806
+ return AlignmentResult(
1807
+ status="insufficient_points",
1808
+ alignment_report_df=pd.DataFrame(),
1809
+ aligned_dem_fn=None,
1810
+ improvement_pct=None,
1811
+ message=(
1812
+ "pc_align ran but produced no parseable log. Check "
1813
+ f"{output_prefix}-log-pc_align*.txt."
1814
+ ),
1815
+ parameters_used=parameters_used,
1816
+ )
1817
+
1818
+ df = pd.DataFrame([{"key": instrument.lower(), **report}])
1819
+
1820
+ gsd = Raster(self.dem_fn).get_gsd()
1821
+ translation_too_small = (
1822
+ df["translation_magnitude"].iloc[0] < min_translation_threshold * gsd
1823
+ )
1824
+
1825
+ p50_beg = float(report.get("p50_beg", float("nan")))
1826
+ p50_end = float(report.get("p50_end", float("nan")))
1827
+ if not np.isfinite(p50_beg) or not np.isfinite(p50_end) or p50_beg == 0:
1828
+ improvement_pct = None
1829
+ else:
1830
+ improvement_pct = (p50_beg - p50_end) / p50_beg * 100.0
1831
+
1832
+ if (
1833
+ improvement_pct is None
1834
+ or p50_end >= p50_beg
1835
+ or improvement_pct <= improvement_threshold_pct
1836
+ or translation_too_small
1837
+ ):
1838
+ self._remove_aligned_dem_if_present()
1839
+ improvement_repr = (
1840
+ f"{improvement_pct:.1f}%" if improvement_pct is not None else "n/a"
1841
+ )
1842
+ if translation_too_small:
1843
+ reason = (
1844
+ f"Translation magnitude is below {min_translation_threshold*100:.0f}% "
1845
+ f"of the DEM GSD ({gsd:.2f} m), so no aligned DEM was "
1846
+ f"written despite a {improvement_repr} p50 reduction."
1847
+ )
1848
+ else:
1849
+ reason = (
1850
+ f"p50 {p50_beg:.2f} m -> {p50_end:.2f} m, "
1851
+ f"{improvement_repr} <= {improvement_threshold_pct:.1f}% "
1852
+ "threshold."
1853
+ )
1854
+ return AlignmentResult(
1855
+ status="no_improvement",
1856
+ alignment_report_df=df,
1857
+ aligned_dem_fn=None,
1858
+ improvement_pct=improvement_pct,
1859
+ message=f"No significant improvement: {reason}",
1860
+ parameters_used=parameters_used,
1861
+ )
1862
+
1863
+ # Apply the translation and persist the aligned DEM
1864
+ aligned = alignment.apply_dem_translation(output_prefix=output_prefix)
1865
+ if aligned is None:
1866
+ self._remove_aligned_dem_if_present()
1867
+ return AlignmentResult(
1868
+ status="no_improvement",
1869
+ alignment_report_df=df,
1870
+ aligned_dem_fn=None,
1871
+ improvement_pct=improvement_pct,
1872
+ message=(
1873
+ "pc_align reported an improvement but the translation "
1874
+ "could not be applied. Aligned DEM not written."
1875
+ ),
1876
+ parameters_used=parameters_used,
1877
+ )
1878
+
1879
+ self.aligned_dem_fn = aligned
1880
+ # Re-run planetary_to_dem_dh with n_sigma=None so we don't filter
1881
+ # the already-clean sample again.
1882
+ self.planetary_to_dem_dh(n_sigma=None)
1883
+
1884
+ return AlignmentResult(
1885
+ status="success",
1886
+ alignment_report_df=df,
1887
+ aligned_dem_fn=self.aligned_dem_fn,
1888
+ improvement_pct=improvement_pct,
1889
+ message=(
1890
+ f"p50 improved from {p50_beg:.2f} m -> {p50_end:.2f} m "
1891
+ f"({improvement_pct:.1f}% reduction). Aligned DEM written to "
1892
+ f"{self.aligned_dem_fn}."
1893
+ ),
1894
+ parameters_used=parameters_used,
1895
+ )
1896
+
1590
1897
  def mapview_plot_planetary_to_dem(
1591
1898
  self,
1592
1899
  clim=None,
1593
1900
  save_dir=None,
1594
1901
  fig_fn=None,
1595
1902
  title=None,
1903
+ plot_aligned=False,
1596
1904
  ):
1597
1905
  """Map view of planetary altimetry vs DEM height differences.
1598
1906
 
1599
1907
  Plots the DEM hillshade as background with altimetry dh points
1600
- overlaid using a divergent colourmap.
1908
+ overlaid using a divergent colourmap. When ``plot_aligned=True``
1909
+ and ``self.aligned_dem_fn`` is set, renders pre/post panels side
1910
+ by side.
1601
1911
 
1602
1912
  Parameters
1603
1913
  ----------
1604
1914
  clim : tuple or None, optional
1605
- Colour limits ``(min, max)`` for dh. Default auto.
1915
+ Colour limits ``(min, max)`` for dh. Default auto (symmetric
1916
+ ±|max| around zero).
1606
1917
  save_dir : str or None, optional
1607
1918
  Directory to save figure.
1608
1919
  fig_fn : str or None, optional
1609
1920
  Filename for saved figure.
1610
1921
  title : str or None, optional
1611
1922
  Custom plot title. Auto-detected if None.
1923
+ plot_aligned : bool, optional
1924
+ Add a second panel showing dh against the aligned DEM.
1925
+ Requires that pc_align has been run successfully.
1612
1926
  """
1613
1927
  from asp_plot.utils import Raster, detect_planetary_body
1614
1928
 
@@ -1619,68 +1933,86 @@ class Altimetry:
1619
1933
  if "altimetry_minus_dem" not in self.planetary_points.columns:
1620
1934
  self.planetary_to_dem_dh()
1621
1935
 
1622
- gdf = self.planetary_points.dropna(subset=["altimetry_minus_dem"])
1623
- if gdf.empty:
1624
- logger.warning("No valid dh values for map view.")
1625
- return
1626
-
1627
- dh = gdf["altimetry_minus_dem"]
1628
- n = len(dh)
1629
- med = np.nanmedian(dh.values)
1630
- nmad = _nmad(dh.values)
1631
-
1632
1936
  body = detect_planetary_body(self.dem_fn)
1633
1937
  instrument = {"moon": "LOLA", "mars": "MOLA"}.get(body, "Altimetry")
1634
1938
  if title is None:
1635
1939
  title = f"{instrument} vs DEM"
1636
1940
 
1637
- # Generate hillshade
1638
- dem_raster = Raster(self.dem_fn, downsample=4)
1941
+ show_aligned = (
1942
+ plot_aligned
1943
+ and self.aligned_dem_fn
1944
+ and "altimetry_minus_aligned_dem" in self.planetary_points.columns
1945
+ )
1946
+
1947
+ # Generate hillshade — use the aligned DEM as the backdrop when
1948
+ # available so the post-alignment panel matches the dh sample.
1949
+ backdrop_dem = self.aligned_dem_fn if show_aligned else self.dem_fn
1950
+ dem_raster = Raster(backdrop_dem, downsample=4)
1639
1951
  hs = dem_raster.hillshade()
1640
1952
  extent = rioplot.plotting_extent(dem_raster.ds, transform=dem_raster.transform)
1641
-
1642
- # Reproject points to DEM CRS for plotting
1643
1953
  dem_crs = dem_raster.ds.crs
1644
- gdf_proj = gdf.to_crs(dem_crs)
1645
1954
 
1646
- fig, ax = plt.subplots(1, 1, figsize=(8, 6), dpi=220)
1647
- ax.imshow(hs, cmap="gray", extent=extent, alpha=0.7, interpolation="none")
1955
+ # Build the symmetric ±|max| color limits across all visible
1956
+ # panels so they are directly comparable.
1957
+ gdf_unaligned = self.planetary_points.dropna(subset=["altimetry_minus_dem"])
1958
+ if gdf_unaligned.empty:
1959
+ logger.warning("No valid dh values for map view.")
1960
+ return
1961
+ dh_arrays = [gdf_unaligned["altimetry_minus_dem"].values]
1962
+ if show_aligned:
1963
+ dh_arrays.append(
1964
+ self.planetary_points["altimetry_minus_aligned_dem"].dropna().values
1965
+ )
1648
1966
 
1649
- # Symmetric ±3σ centered on 0 (data is already 3σ-filtered in
1650
- # planetary_to_dem_dh, so min/max ≈ ±3σ from the mean)
1651
1967
  if clim is None:
1652
- abs_max = max(abs(dh.min()), abs(dh.max()))
1968
+ abs_max = max(np.nanmax(np.abs(a)) for a in dh_arrays if a.size)
1653
1969
  clim = (-abs_max, abs_max)
1654
- cbar_label = f"{instrument} - DEM (m)\n[±3σ]"
1655
- else:
1656
- cbar_label = f"{instrument} - DEM (m)"
1657
-
1658
- gdf_proj.plot(
1659
- ax=ax,
1660
- column="altimetry_minus_dem",
1661
- cmap="RdBu",
1662
- vmin=clim[0],
1663
- vmax=clim[1],
1664
- markersize=2,
1665
- legend=True,
1666
- legend_kwds={"label": cbar_label},
1667
- )
1668
1970
 
1669
- stats_text = f"n={n}\nMedian={med:+.2f} m\nNMAD={nmad:.2f} m"
1670
- ax.text(
1671
- 0.02,
1672
- 0.98,
1673
- stats_text,
1674
- transform=ax.transAxes,
1675
- verticalalignment="top",
1676
- fontsize=8,
1677
- fontfamily="monospace",
1678
- bbox=dict(boxstyle="round,pad=0.4", facecolor="white", alpha=0.9),
1971
+ ncols = 2 if show_aligned else 1
1972
+ fig, axes = plt.subplots(
1973
+ 1, ncols, figsize=(8 * ncols, 6), dpi=220, squeeze=False
1679
1974
  )
1975
+ axes = axes[0]
1976
+
1977
+ panels = [("altimetry_minus_dem", "ASP DEM")]
1978
+ if show_aligned:
1979
+ panels.append(("altimetry_minus_aligned_dem", "Aligned DEM"))
1980
+
1981
+ for ax, (column, label) in zip(axes, panels):
1982
+ gdf = self.planetary_points.dropna(subset=[column])
1983
+ dh = gdf[column]
1984
+ n = len(dh)
1985
+ med = np.nanmedian(dh.values)
1986
+ nmad = _nmad(dh.values)
1987
+
1988
+ ax.imshow(hs, cmap="gray", extent=extent, alpha=0.7, interpolation="none")
1989
+ cbar_label = f"{instrument} - {label} (m)\n[±|max|]"
1990
+ gdf.to_crs(dem_crs).plot(
1991
+ ax=ax,
1992
+ column=column,
1993
+ cmap="RdBu",
1994
+ vmin=clim[0],
1995
+ vmax=clim[1],
1996
+ markersize=2,
1997
+ legend=True,
1998
+ legend_kwds={"label": cbar_label},
1999
+ )
2000
+ stats_text = f"n={n}\nMedian={med:+.2f} m\nNMAD={nmad:.2f} m"
2001
+ ax.text(
2002
+ 0.02,
2003
+ 0.98,
2004
+ stats_text,
2005
+ transform=ax.transAxes,
2006
+ verticalalignment="top",
2007
+ fontsize=8,
2008
+ fontfamily="monospace",
2009
+ bbox=dict(boxstyle="round,pad=0.4", facecolor="white", alpha=0.9),
2010
+ )
2011
+ ax.set_title(label, size=9)
2012
+ ax.set_xticks([])
2013
+ ax.set_yticks([])
1680
2014
 
1681
- ax.set_xticks([])
1682
- ax.set_yticks([])
1683
- fig.suptitle(f"{title}\n(n={n})", size=10)
2015
+ fig.suptitle(title, size=10)
1684
2016
  fig.tight_layout()
1685
2017
  if save_dir and fig_fn:
1686
2018
  save_figure(fig, save_dir, fig_fn)
@@ -1690,6 +2022,7 @@ class Altimetry:
1690
2022
  save_dir=None,
1691
2023
  fig_fn=None,
1692
2024
  title=None,
2025
+ plot_aligned=False,
1693
2026
  ):
1694
2027
  """Histogram of planetary altimetry vs DEM height differences.
1695
2028
 
@@ -1701,6 +2034,9 @@ class Altimetry:
1701
2034
  Filename for saved figure.
1702
2035
  title : str or None, optional
1703
2036
  Custom plot title. Auto-detected if None.
2037
+ plot_aligned : bool, optional
2038
+ Overlay the post-alignment dh distribution on the same axes.
2039
+ Requires that pc_align has been run successfully.
1704
2040
  """
1705
2041
  from asp_plot.utils import detect_planetary_body
1706
2042
 
@@ -1716,25 +2052,64 @@ class Altimetry:
1716
2052
  logger.warning("No valid dh values for histogram.")
1717
2053
  return
1718
2054
 
1719
- n = len(dh)
1720
- med = np.nanmedian(dh.values)
1721
- nmad = _nmad(dh.values)
1722
-
1723
2055
  body = detect_planetary_body(self.dem_fn)
1724
2056
  instrument = {"moon": "LOLA", "mars": "MOLA"}.get(body, "Altimetry")
1725
2057
  if title is None:
1726
2058
  title = f"{instrument} vs ASP DEM"
1727
2059
 
1728
- fig, ax = plt.subplots(1, 1, figsize=(8, 5), dpi=220)
2060
+ show_aligned = (
2061
+ plot_aligned
2062
+ and self.aligned_dem_fn
2063
+ and "altimetry_minus_aligned_dem" in self.planetary_points.columns
2064
+ )
2065
+ if show_aligned:
2066
+ dh_aligned = self.planetary_points["altimetry_minus_aligned_dem"].dropna()
2067
+
2068
+ # Shared bin edges and xlim across both distributions
2069
+ if show_aligned:
2070
+ abs_max = max(
2071
+ abs(dh.min()),
2072
+ abs(dh.max()),
2073
+ abs(dh_aligned.min()),
2074
+ abs(dh_aligned.max()),
2075
+ )
2076
+ else:
2077
+ abs_max = max(abs(dh.min()), abs(dh.max()))
2078
+ bins = np.linspace(-abs_max, abs_max, 129)
1729
2079
 
1730
- ax.hist(dh.values, bins=128, alpha=0.7, color="steelblue")
2080
+ fig, ax = plt.subplots(1, 1, figsize=(8, 5), dpi=220)
2081
+ ax.hist(
2082
+ dh.values,
2083
+ bins=bins,
2084
+ alpha=0.6,
2085
+ color="steelblue",
2086
+ label=f"DEM (n={len(dh)})",
2087
+ )
2088
+ if show_aligned:
2089
+ ax.hist(
2090
+ dh_aligned.values,
2091
+ bins=bins,
2092
+ alpha=0.6,
2093
+ color="orange",
2094
+ label=f"Aligned DEM (n={len(dh_aligned)})",
2095
+ )
1731
2096
 
1732
- # Symmetric ±3σ centered on 0 (data is already 3σ-filtered in
1733
- # planetary_to_dem_dh; stats above are on the filtered data)
1734
- abs_max = max(abs(dh.min()), abs(dh.max()))
1735
2097
  ax.set_xlim(-abs_max, abs_max)
1736
2098
 
1737
- stats_text = f"n={n}\nMedian={med:+.2f} m\nNMAD={nmad:.2f} m"
2099
+ if show_aligned:
2100
+ med0 = np.nanmedian(dh.values)
2101
+ nmad0 = _nmad(dh.values)
2102
+ med1 = np.nanmedian(dh_aligned.values)
2103
+ nmad1 = _nmad(dh_aligned.values)
2104
+ stats_text = (
2105
+ f"DEM: Med={med0:+.2f} NMAD={nmad0:.2f} m\n"
2106
+ f"Aligned DEM: Med={med1:+.2f} NMAD={nmad1:.2f} m"
2107
+ )
2108
+ else:
2109
+ n = len(dh)
2110
+ med = np.nanmedian(dh.values)
2111
+ nmad = _nmad(dh.values)
2112
+ stats_text = f"n={n}\nMedian={med:+.2f} m\nNMAD={nmad:.2f} m"
1738
2113
  ax.text(
1739
2114
  0.02,
1740
2115
  0.98,
@@ -1746,9 +2121,10 @@ class Altimetry:
1746
2121
  bbox=dict(boxstyle="round,pad=0.4", facecolor="white", alpha=0.9),
1747
2122
  )
1748
2123
 
1749
- ax.set_xlabel(f"{instrument} - DEM (m) [±3σ]")
2124
+ ax.set_xlabel(f"{instrument} - DEM (m) [±|max|]")
1750
2125
  ax.set_ylabel("Count")
1751
- fig.suptitle(f"{title}\n(n={n})", size=10)
2126
+ ax.legend(loc="upper right", fontsize=8)
2127
+ fig.suptitle(title, size=10)
1752
2128
  fig.tight_layout()
1753
2129
  if save_dir and fig_fn:
1754
2130
  save_figure(fig, save_dir, fig_fn)
@@ -93,7 +93,7 @@ from asp_plot.utils import Raster, detect_planetary_body, get_acquisition_dates
93
93
  "--pc_align",
94
94
  prompt=False,
95
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.",
96
+ help="If True and --plot_altimetry is True, run pc_align against the reference altimetry (ICESat-2 for Earth, MOLA for Mars, LOLA for Moon) and append the alignment-report pages. Disabled automatically when --plot_altimetry / --plot_icesat is False. Default: True.",
97
97
  )
98
98
  @click.option(
99
99
  "--plot_geometry",
@@ -742,7 +742,7 @@ def main(
742
742
  f" 1. Run: request_planetary_altimetry --dem {asp_dem} --email <your_email>\n"
743
743
  f" 2. Wait for the email with a download link\n"
744
744
  f" 3. Download and unzip the result\n"
745
- f" 4. Re-run asp_plot with: --altimetry_csv <path_to_topo_csv.csv>\n"
745
+ f" 4. Re-run asp_plot with: --altimetry_csv <path_to_pts_csv.csv>\n"
746
746
  f"\nSkipping {instrument} altimetry plots.\n"
747
747
  f"{'='*60}\n"
748
748
  )
@@ -777,6 +777,120 @@ def main(
777
777
  )
778
778
  )
779
779
 
780
+ # ---- pc_align + planetary alignment report (Moon/Mars) ----
781
+ if pc_align:
782
+ align_result = alt.align_and_evaluate_planetary()
783
+ stats_row = {}
784
+ if (
785
+ align_result.alignment_report_df is not None
786
+ and not align_result.alignment_report_df.empty
787
+ ):
788
+ row = align_result.alignment_report_df.iloc[0].to_dict()
789
+ row.pop("key", None)
790
+ stats_row = row
791
+
792
+ align_title = f"DEM Alignment with {instrument}"
793
+ alignment_description = (
794
+ f"ASP's pc_align estimates a rigid 3D translation that "
795
+ f"minimizes the height residuals between the ASP DEM "
796
+ f"and the {instrument} planetary radii. The CSV is "
797
+ f"passed as the reference cloud with --csv-format "
798
+ f"'1:lon 2:lat 3:radius_m', and --datum is set to "
799
+ f"D_MARS or D_MOON to match the ASP DEM. The "
800
+ f"resulting translation is applied to the DEM "
801
+ f"directly (geotransform + pixel-value shift, no "
802
+ f"resampling) to produce the aligned DEM.\n\n"
803
+ f"Alignment Parameters (above):\n"
804
+ f" - max_displacement: pc_align upper bound on the "
805
+ f"translation magnitude (m).\n"
806
+ f" - minimum_points: minimum {instrument} points "
807
+ f"that overlap the DEM; below this the alignment is "
808
+ f"skipped.\n"
809
+ f" - min_translation_threshold: minimum translation "
810
+ f"magnitude (as a fraction of the DEM GSD) required "
811
+ f"to write out an aligned DEM.\n"
812
+ f" - improvement_threshold_pct: minimum percentage "
813
+ f"reduction in p50 required to keep the aligned DEM "
814
+ f"on disk; below this, the aligned DEM is removed.\n\n"
815
+ f"Alignment Statistics (above, in meters):\n"
816
+ f" - p16_beg / p50_beg / p84_beg: 16th / 50th / 84th "
817
+ f"percentile of the DEM-vs-{instrument} absolute "
818
+ f"height residuals before alignment.\n"
819
+ f" - p16_end / p50_end / p84_end: same percentiles "
820
+ f"after alignment.\n"
821
+ f" - N_shift / E_shift / D_shift: north / east / down "
822
+ f"components of the applied translation vector.\n"
823
+ f" - |T|: magnitude of the translation vector."
824
+ )
825
+
826
+ if align_result.status == "insufficient_points":
827
+ sections.append(
828
+ AlignmentReportPage(
829
+ title=align_title,
830
+ parameters=align_result.parameters_used,
831
+ description=alignment_description,
832
+ status_message=align_result.message,
833
+ )
834
+ )
835
+ elif align_result.status == "no_improvement":
836
+ sections.append(
837
+ AlignmentReportPage(
838
+ title=align_title,
839
+ parameters=align_result.parameters_used,
840
+ stats_row=stats_row,
841
+ description=alignment_description,
842
+ status_message=align_result.message,
843
+ )
844
+ )
845
+ elif align_result.status == "success":
846
+ sections.append(
847
+ AlignmentReportPage(
848
+ title=align_title,
849
+ parameters=align_result.parameters_used,
850
+ stats_row=stats_row,
851
+ description=alignment_description,
852
+ status_message=align_result.message,
853
+ )
854
+ )
855
+
856
+ fig_fn = f"{next(figure_counter):02}.png"
857
+ alt.mapview_plot_planetary_to_dem(
858
+ plot_aligned=True,
859
+ save_dir=plots_directory,
860
+ fig_fn=fig_fn,
861
+ )
862
+ sections.append(
863
+ ReportSection(
864
+ title=f"{instrument} Altimetry Map (Aligned DEM)",
865
+ image_path=os.path.join(plots_directory, fig_fn),
866
+ caption=(
867
+ f"Pre- (left) and post-alignment (right) "
868
+ f"map views of {instrument} elevation "
869
+ f"differences. The aligned-DEM hillshade "
870
+ f"is used as the backdrop for both panels."
871
+ ),
872
+ )
873
+ )
874
+
875
+ fig_fn = f"{next(figure_counter):02}.png"
876
+ alt.histogram_planetary_to_dem(
877
+ plot_aligned=True,
878
+ save_dir=plots_directory,
879
+ fig_fn=fig_fn,
880
+ )
881
+ sections.append(
882
+ ReportSection(
883
+ title=f"{instrument} Altimetry Histogram (Aligned DEM)",
884
+ image_path=os.path.join(plots_directory, fig_fn),
885
+ caption=(
886
+ f"Pre- (steelblue) and post-alignment "
887
+ f"(orange) distributions of {instrument} "
888
+ f"minus DEM height differences with shared "
889
+ f"bin edges."
890
+ ),
891
+ )
892
+ )
893
+
780
894
  # Compile report
781
895
  processing_parameters = ProcessingParameters(
782
896
  processing_directory=directory,
@@ -774,8 +774,18 @@ class Raster:
774
774
  -------
775
775
  int
776
776
  EPSG code
777
+
778
+ Notes
779
+ -----
780
+ If the CRS has no exact EPSG match (e.g. a compound or 3D-promoted
781
+ CRS such as "EPSG:32610+EPSG:4979"), falls back to the EPSG code
782
+ of the horizontal (2D) component.
777
783
  """
778
784
  epsg = self.ds.crs.to_epsg()
785
+ if epsg is None:
786
+ from pyproj import CRS
787
+
788
+ epsg = CRS(self.ds.crs).to_2d().to_epsg()
779
789
  return epsg
780
790
 
781
791
  def get_utm_epsg_code(self):
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asp_plot"
7
- version = "1.13.0"
7
+ version = "1.14.1"
8
8
  license = {text = "BSD-3-Clause"}
9
9
  authors = [
10
10
  { name="Ben Purinton", email="purinton@uw.edu" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes