Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ selene download diviner # ~605 MB Diviner Polar Resource Product (PRP)
# criterion scarp catalog (Mishra & Kumar 2022) ships bundled in-repo as of
# v1.8 — no separate download step needed.
selene preprocess && selene score && selene rank-per-region

# v1.5+ tiled mode at 20 m (CUDA GPU + ~140 GB unified memory, see "Resolution analysis"):
selene preprocess --tiled-per-region --resolution 20
selene rank-per-region --tiled-per-region --resolution 20

# Tiled mode against a non-PDS LOLA source (PGDA mosaic, NAC stereo DEM, custom DEM)
# bypasses the auto-detect priority list (v1.8.3+):
selene preprocess --tiled-per-region --resolution 10 \
--lola-source data/raw/lola/ldem_83s_10mpp_adj.tif
```

`selene --help` lists every subcommand; `selene <cmd> --help` shows its options.
Expand Down Expand Up @@ -638,7 +647,7 @@ selene-base/

The dependency graph is one-way: `data/` is the foundation; `criteria/` reads loaded rasters; `scoring/` aggregates criterion outputs; `validation/` and `viz/` consume scoring outputs; `pipeline/` orchestrates; `cli.py` exposes the orchestrators. Tests follow the same layering.

**381 tests collected** (372 passed, 9 skipped: LEND + Robbins data not present), ~80 % combined branch coverage, all running synthetically in CI on Python 3.11 and 3.12. Real-data tests are guarded with `pytest.mark.skipif(not Path(...).exists())` so the suite stays green without ~900 MB of cached LRO data. CI runs a separate `pipeline-smoke` job on push to `main` that downloads the bundled ~12 MB sample tarball, runs `preprocess -> score -> rank-per-region -> validate-per-region -> compare`, and asserts every output file is on disk and schema-valid.
**391 tests collected** (382 passed, 9 skipped: LEND + Robbins data not present), ~80 % combined branch coverage, all running synthetically in CI on Python 3.11 and 3.12. Real-data tests are guarded with `pytest.mark.skipif(not Path(...).exists())` so the suite stays green without ~900 MB of cached LRO data. CI runs a separate `pipeline-smoke` job on push to `main` that downloads the bundled ~12 MB sample tarball, runs `preprocess -> score -> rank-per-region -> validate-per-region -> compare`, and asserts every output file is on disk and schema-valid.

## Roadmap

Expand All @@ -660,11 +669,13 @@ The dependency graph is one-way: `data/` is the foundation; `criteria/` reads lo
- **v1.4.2 — `n_per_region` default raised to 10 (matches Wueller's per-region site density).** ✅ Default `n_per_region` changed from 3 to 10. The selene methodology converges at any per-region density: selene-to-Wueller match rate is essentially flat across n (78 % at n=3, 78 % at n=5, 80 % at n=10), but Wueller-to-selene match rate climbs from 41 % to 63 % as more selene sites become available to pair with Wueller's 73-site in-scope catalog. **Result at the new default: 56/70 selene sites (80 %) match within 5 km of an in-scope Wueller site; median match distance 1.88 km; 46/73 (63 %) of in-scope Wueller sites match a selene site within 5 km.** Two regions (Mons Mouton, Peak Near Cabeus B) reach 100 % match-within-region at n=10; Nobile Rim 1 and Nobile Rim 2 reach 80 %+. de Gerlache Rim 2 stays at 0/2 (the polygon's HLS-eligible area caps at 2 sites at 2km NMS — its terrain is genuinely far from Wueller's dGR2 cluster, independent of n). Per-region site counts (NMS-capped at 2km separation): HW 10, MM 10, MMP 10, NR1 10, NR2 10, PCB 9, SP 9, dGR2 2 — total 70.
- **v1.5 — 20 m Wueller-class resolution with GPU acceleration.** ✅ Per-region tiled driver runs the HLS filters at 20 m, the same native LOLA resolution Wueller et al. 2026 use. New CLI flags `--tiled-per-region --resolution 20` on `selene preprocess` and `selene rank-per-region`; new GPU path through `derive_horizon_profile` and `compute_earth_visibility_fraction` via CuPy + `cupyx.scipy.ndimage.map_coordinates`; new `pipeline/preprocess_tiled.py` and `pipeline/rank_per_region_tiled.py` covering each USGS polygon's bbox + 100 km horizon buffer with explicit cupy memory-pool drain between tiles. The 240 m global path is unchanged. **Result against Wueller 2026 at 20 m: 56/69 selene sites (81 %) match within 5 km, median matched-pair distance 1.76 km — tighter than v1.4.2's 1.88 km at 240 m by 6.5 %; selene-to-Wueller match rate flat (80 % → 81 %); Wueller-to-selene rate slips slightly (63 % → 60 %) because the finer slope/buffer re-ranks sites within MMP, SP, and CB to a different cell within the same polygon.** Six of eight regions tighten or hold their median matched-pair distance at 20 m; Malapert Massif still has zero HLS-compliant cells (genuine steep terrain, not a 240 m sampling effect); de Gerlache Rim 2 still produces only 2 sites with 0/2 within 5 km of Wueller's dGR2 cluster (genuine terrain divergence, persists at fine resolution). The methodology converges at peer-reviewed resolution; the residual disagreements at v1.4.2 are not 240 m sampling artefacts. v1.5 is opt-in and requires a CUDA GPU plus ~140 GB unified-memory headroom (developed on NVIDIA GB10 / DGX Spark; full 9-region run ~32 min preprocess + ~22 min rank). See "Resolution analysis (v1.5 — 20 m Wueller-class)" above for the per-region table and side-by-side plots.
- **v1.5.1 — Artemis IV mission designation update + NASA Figures-of-Merit framework citation.** ✅ Docs-only patch release. NASA restructured the Artemis program in February 2026: the first crewed lunar landing at the south pole was reassigned from Artemis III to Artemis IV (target early 2028), Artemis III became an Earth-orbit test mission, and Lunar Gateway was canceled in March 2026. v1.5.1 reframes the project's forward-looking copy from "Artemis III" to "Artemis IV (formerly Artemis III)" across the README, the v1.5 catalog report, and user-facing CLI / module docstrings, while leaving citation titles, the USGS dataset name, NASA's October 2024 announcement URL, the Wueller 2026 paper's own framing, and the historical roadmap entries unchanged. Adds [Lawrence 2025](https://ntrs.nasa.gov/citations/20250008952) (NASA NTRS 20250008952) as a reference and a new "Mapping to NASA's Figures of Merit framework" subsection in the Methodology section that maps selene's seven criteria to NASA's published FOM categories. **The methodology, data, validation, and 69-site catalog are identical to v1.5; the headline numbers (56/69 = 81.2 % within 5 km, median 1.76 km) are unchanged.**
- **v1.6 — 10 m exploratory analysis (deferred — public mosaic coverage insufficient).** ⏸ Originally scoped as a partial-coverage rerun of the per-region pipeline at 10 m on PGDA's `LDEM_83S_10MPP_ADJ` mosaic (~432.8 km × 432.8 km square, ±216.4 km from the south pole in polar-stereographic Moon coordinates). Buffer-fits-inside-mosaic check against the 9 USGS polygons + 100 km horizon-march buffer (the LOS-to-Earth criterion's physical horizon-search radius, not a tunable knob): only **3 of 9 polygons (HW, SP, G2) clean**. **CB**'s buffered bbox over-runs the mosaic's west edge by 74 km; **N2**'s over-runs the east edge by 50 km; **N1, MM, MA, MP** all over-run the north edge by 4–60 km. Truncating the buffer would bias the LOS visibility fraction asymmetrically on the truncated side, contaminating every cell within ~100 km of the mosaic edge in the affected region. A 3-region release (HW + SP + G2) was technically possible but thin — G2 contributes 0/2 matched-within-5 km in v1.5, collapsing the comparison effectively to HW + SP. Not enough material for a release, and not a buffer-truncation methodology that holds against v1.5's clean 9-region 100 km horizon-march. Deferred indefinitely pending wider public mosaic coverage or NAC stereo DEM integration; see "Where this goes next" below for the coverage analysis. The 10 m work is genuinely blocked, not a bandwidth or scheduling issue.
- **v1.6 — 10 m exploratory analysis (deferred — public mosaic coverage insufficient).** ⏸ Originally scoped as a partial-coverage rerun of the per-region pipeline at 10 m on PGDA's `LDEM_83S_10MPP_ADJ` mosaic (~432.8 km × 432.8 km square, ±216.4 km from the south pole in polar-stereographic Moon coordinates). Buffer-fits-inside-mosaic check against the 9 USGS polygons + 100 km horizon-march buffer (the LOS-to-Earth criterion's physical horizon-search radius, not a tunable knob): only **3 of 9 polygons (HW, SP, G2) clean**. **CB**'s buffered bbox over-runs the mosaic's west edge by 74 km; **N2**'s over-runs the east edge by 50 km; **N1, MM, MA, MP** all over-run the north edge by 4–60 km. Truncating the buffer would bias the LOS visibility fraction asymmetrically on the truncated side, contaminating every cell within ~100 km of the mosaic edge in the affected region. A 3-region release (HW + SP + G2) was technically possible but thin — G2 contributes 0/2 matched-within-5 km in v1.5, collapsing the comparison effectively to HW + SP. Not enough material for a release, and not a buffer-truncation methodology that holds against v1.5's clean 9-region 100 km horizon-march. Deferred indefinitely pending wider public mosaic coverage or NAC stereo DEM integration; see "Where this goes next" below for the coverage analysis. The 10 m work is genuinely blocked, not a bandwidth or scheduling issue. (v1.8.3 separately closes the file-resolver gap by adding `--lola-source` to `selene preprocess`, so a future caller with a wider mosaic can run the tiled pipeline against any LOLA source without renaming files; the polygon-coverage blocker remains.)
- **v1.7 — TOPSIS aggregator as opt-in alternative to weighted_sum.** ✅ Adds [`scoring/aggregate.topsis`](src/selene_base/scoring/aggregate.py) (Hwang & Yoon 1981) as a sibling of `weighted_sum`, with `selene score --method topsis` and `selene rank-per-region --method topsis` exposing it. Same input contract (per-criterion [0, 1] score grids + weights), same renormalise-on-missing-criteria semantics, same NaN propagation. Default stays `weighted_sum` — v1.5's 81.2 % / 1.76 km headline is unchanged. **TOPSIS result on the v1.5 20 m catalog: same 69 sites (HLS filters identical), same top-5 by score, 55 / 69 (79.7 %) matched within 5 km of an in-scope Wueller site (vs 56 at weighted_sum), median matched-pair distance 1.84 km vs 1.76 km globally** — but **three of seven active regions tighten significantly** (Haworth -0.51 km, Mons Mouton Plateau -0.40 km, Nobile Rim 1 -0.43 km) and **max pair distance improves -0.22 km**. Both aggregators agree on which cells are top-tier; they disagree at the 1.5–4 km margin. TOPSIS outputs land under `data/outputs/topsis/` so the two catalogs sit side-by-side. See "Aggregator: weighted_sum vs TOPSIS" in Methodology for the full table.

- **v1.8 — Activate seismic criterion (eight of eight criteria live).** ✅ Adds distance-to-nearest-scarp as the eighth criterion via a logistic of distance to the nearest mapped lunar lobate scarp (midpoint 25 km, steepness 8 km — tuned to Civilini et al. 2023's documented shallow-moonquake-to-scarp clustering distance). Bundles the **Mishra & Kumar (2022)** south-polar scarp catalog ([GRL doi:10.1029/2022GL098505](https://doi.org/10.1029/2022GL098505), Zenodo [doi:10.5281/zenodo.6624114](https://doi.org/10.5281/zenodo.6624114), CC-BY 4.0) — 704 line segments combining Watters et al. (2015) global mapping with 75 new south-polar scarps Mishra & Kumar mapped from independent LROC NAC analysis — at `src/selene_base/criteria/data/scarps_mishra_kumar_2022/`, with the original LROC SOC `POLAR_SCARP_LOCATIONS` shapefile (citing Watters 2015 verbatim) bundled alongside as an attribution anchor at `scarps_watters_2015_polar/`. Default seismic weight 0.10, with the other seven weights scaled by 0.928 so the simplex still sums to 1.00. Methodology fully realised — eight of eight planned criteria contribute to the aggregate score. **Result: 56 / 69 selene sites (81.2 %) match within 5 km of an in-scope Wueller site — identical to v1.5's headline; 46 / 73 (63.0 %) Wueller-side match (up from 44 / 73 in v1.5, +2.7 pp); median matched-pair distance 1.69 km vs 1.76 km in v1.5 (-0.07 km).** Same site count, same top-3 in every region; 13 sites in HW/MMP/N1/N2 reshuffle within ranks 4-10. The criterion is most active where the Mishra/Kumar catalog has dense south-polar coverage and least active for cells far from any mapped scarp (most of the polar grid). See "Seismic criterion (v1.8)" in Methodology for the logistic scoring details and per-region table.

- **v1.8.3 — `--lola-source` flag for arbitrary LOLA source files.** ✅ Adds an explicit source-path override to `selene preprocess --tiled-per-region` and to `pipeline.preprocess_tiled.run_tiled_per_region`, bypassing the PDS-naming priority list in `resolve_lola_source` (which previously hardcoded `ldem_80s_<resolution>m.lbl` and would not pick up alternative-format files). Enables the tiled pipeline to run against PGDA mosaics (e.g. `ldem_83s_10mpp_adj.tif`), NAC stereo DEMs, and arbitrary user-supplied DEMs without renaming files into the PDS naming convention. The override path is validated up front (must exist, be a file, and have a `.tif`/`.lbl`/`.img` suffix); when omitted, the existing auto-detect behaviour runs unchanged. Closes one of the v1.6 deferral blockers (the file-resolver gap surfaced when the 10 m PGDA mosaic was tried against the existing pipeline); the polygon-coverage blocker remains. Backward compatible — no methodology, data, or validation changes; v1.8 headline numbers (56/69 = 81.2 %, median 1.69 km) are unchanged.

### Where this goes next

**Wueller 2026 deposit — non-point layers.** The Zenodo deposit also contains an 884 MB HLS slope raster (`HLS_LandingAreas_8°_-100m_buffer.tif`), 2 km exploration-area polygons (`ExplorationArea_2km.*`), and an illumination-modelling layer set. None are bundled through v1.8 (point catalog only); folding any of them in (e.g. comparing selene's HLS-eligible mask against Wueller's 8°-buffer raster) is a near-term candidate.
Expand Down
19 changes: 19 additions & 0 deletions src/selene_base/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,19 @@ def preprocess(
"(e.g. 'SP' for Slater Plain). Repeatable. Default: all 9 regions."
),
),
lola_source: Path | None = typer.Option(
None,
"--lola-source",
help=(
"Tiled mode only: explicit path to a LOLA elevation source file "
"(.tif/.lbl/.img). Bypasses the PDS-naming auto-detect, enabling "
"PGDA mosaics (e.g. ldem_83s_10mpp_adj.tif) and arbitrary "
"user-supplied DEMs. When omitted, the finest-resolution source "
"in data/raw/lola/ is picked automatically."
),
exists=False,
dir_okay=False,
),
) -> None:
"""Reproject every available raw raster onto the common 240 m grid.

Expand All @@ -194,6 +207,7 @@ def preprocess(
region_codes=region_code if region_code else None,
processed_dir=processed_dir,
overwrite=overwrite,
source_path=lola_source,
)
typer.echo("")
typer.echo(_preprocess_tiled.format_summary(tiled_results))
Expand All @@ -210,6 +224,11 @@ def preprocess(
"--region-code is only honoured in --tiled-per-region mode.",
param_hint="--region-code",
)
if lola_source is not None:
raise typer.BadParameter(
"--lola-source is only honoured in --tiled-per-region mode.",
param_hint="--lola-source",
)

results = _preprocess.run(
region_config=region_config,
Expand Down
35 changes: 31 additions & 4 deletions src/selene_base/pipeline/preprocess_tiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,57 @@ class TiledHorizonResult:
elapsed_s: float


_VALID_LOLA_SUFFIXES: frozenset[str] = frozenset({".tif", ".lbl", ".img"})


def resolve_lola_source(
raw_dir: Path = DEFAULT_RAW_DIR,
*,
prefer_resolution_m: int | None = None,
override: Path | str | None = None,
) -> Path:
"""Pick the best available LOLA source IMG/LBL/TIF on disk.

The on-disk priority is finest-resolution first (``ldem_80s_20m.lbl``
→ ``ldem_80s_80m.lbl`` → ``sample_lola.tif``). Pass
``prefer_resolution_m`` to require a specific resolution; the call
raises :class:`FileNotFoundError` if that file is missing rather
than silently falling back to a coarser source.
than silently falling back to a coarser source. Pass ``override``
to bypass the priority list entirely with a caller-supplied path —
used for non-PDS-named sources like PGDA mosaics
(``ldem_83s_10mpp_adj.tif``) and arbitrary user-supplied DEMs.

Args:
raw_dir: Root of ``data/raw``.
prefer_resolution_m: Require this native resolution (m). When
``None``, any available source is acceptable.
``None``, any available source is acceptable. Ignored when
``override`` is supplied.
override: Explicit path to a LOLA source file. Bypasses both
the priority list and ``prefer_resolution_m``. The file
must exist and have a recognised suffix
(``.tif``/``.lbl``/``.img``).

Returns:
Absolute path to the LOLA source the loaders should open.

Raises:
FileNotFoundError: If no acceptable source is on disk.
FileNotFoundError: If no acceptable source is on disk, or if
``override`` is supplied but the file does not exist.
ValueError: If ``override`` is supplied with an unrecognised
file suffix.
"""
if override is not None:
path = Path(override)
if not path.exists():
raise FileNotFoundError(f"--lola-source path does not exist: {path}")
if not path.is_file():
raise FileNotFoundError(f"--lola-source path is not a file: {path}")
if path.suffix.lower() not in _VALID_LOLA_SUFFIXES:
raise ValueError(
f"--lola-source suffix {path.suffix!r} not recognised; "
f"expected one of {sorted(_VALID_LOLA_SUFFIXES)}"
)
return path
lola_dir = Path(raw_dir) / "lola"
if prefer_resolution_m is not None:
path = lola_dir / f"ldem_80s_{prefer_resolution_m}m.lbl"
Expand Down Expand Up @@ -315,7 +342,7 @@ def run_tiled_per_region(

processed_dir = Path(processed_dir)
processed_dir.mkdir(parents=True, exist_ok=True)
src_path = source_path if source_path is not None else resolve_lola_source(raw_dir)
src_path = resolve_lola_source(raw_dir, override=source_path)
echo(f"[load] LOLA source from {src_path}")
elevation = _load_lola_source(src_path)
echo(f"[load] elevation shape={elevation.shape!r} dtype={elevation.dtype!s}")
Expand Down
Loading
Loading