diff --git a/README.md b/README.md index 3b82f5e..f44122b 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ The thermal and ice criteria are both fed by the **Diviner Polar Resource Produc The PSR mask used by the ice criterion is still derived from the Mazarico illumination raster (`illumination < 0.001`); the PRP is a thermal-stability calculation, not an ice-existence map, so PSR proximity adds an orthogonal signal. -Default weights from [`config/weights_default.yaml`](config/weights_default.yaml): illumination 0.18, ice 0.18, coupling 0.18, los_to_earth 0.15, slope 0.13, thermal 0.08, hazard 0.07, seismic 0.03. **The 0.15 LOS weight was chosen *before* the v1.1.0 validation rerun on physics-and-operations grounds**, not after seeing the result: LOS is a real physical criterion NASA prioritises in mission planning (above thermal and hazard for crewed-comms-critical sites), but is not as high-leverage as illumination/ice/coupling because it is a more binary-ish constraint (works/doesn't work given a horizon) than a continuous optimisation target. 0.15 places it between hazard (mostly redundant with slope at the south pole) and slope (a hard buildability constraint). Older weight vectors are preserved: [`config/weights_legacy.yaml`](config/weights_legacy.yaml) for the 5-criterion baseline (no coupling, no LOS); [`config/weights_legacy_v6.yaml`](config/weights_legacy_v6.yaml) for the v1.0.0 6-criterion vector (no LOS). +Default weights from [`config/weights_default.yaml`](config/weights_default.yaml) (v1.8 — eighth criterion live): illumination 0.17, ice 0.17, coupling 0.17, los_to_earth 0.14, slope 0.12, thermal 0.07, hazard 0.06, seismic 0.10. **The 0.10 seismic weight reflects that scarp distance is one of multiple safety factors and is most relevant on long habitat-occupation timescales**; the other seven weights are scaled by 0.928 from the v1.5 vector so the simplex sums to 1.00 without changing relative ratios. **The 0.15 LOS weight (now 0.14 after seismic activation) was chosen *before* the v1.1.0 validation rerun on physics-and-operations grounds**: LOS is a real physical criterion NASA prioritises in mission planning, but more binary-ish than continuous, so weighted slightly less than illumination/ice/coupling. Older weight vectors are preserved: [`config/weights_legacy.yaml`](config/weights_legacy.yaml) (5-criterion baseline, no coupling/LOS), [`config/weights_legacy_v6.yaml`](config/weights_legacy_v6.yaml) (v1.0.0 6-criterion, no LOS). ### Mapping to NASA's Figures of Merit framework @@ -171,10 +171,24 @@ NASA's Artemis site-selection process ([Lawrence 2025](https://ntrs.nasa.gov/cit | Hazard (crater density) | Site Availability | Robbins 2019 catalog (consistent) | not public | | Coupling (PSR ↔ ridge) | Mission Availability + Science Objectives | derived (Mazarico + slope) | not public | | LOS-to-Earth | Mission Availability | derived (LOLA + libration) | published threshold (HLS) | -| Seismic (Watters scarps) | Site Availability | catalog TODO (planned) | not public | +| Seismic (Watters scarps) | Site Availability | Mishra & Kumar 2022 + Watters 2015 (consistent; bundled in-repo since v1.8) | not public | selene-base implements *physics-driven analogs* of NASA's FOM categories using public data and operations-driven thresholds, not direct implementations of NASA's actual weighting (which is not public). The HLS hard-constraint thresholds (slope, slope-buffer, illumination, DTE visibility) are published and are matched verbatim. The soft-criterion weights are physics-and-operations-driven defaults set before any validation rerun, not tuned to match a NASA reference. This positions selene-base honestly: a complete public-data implementation of the conceptual framework documented by Lawrence 2025, calibrated to physics-driven defaults rather than fitted to undisclosed NASA-internal weights. +### Seismic criterion (v1.8) + +[Civilini et al. (2023)](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2019GL083580) re-located several Apollo-era shallow moonquakes and showed several of them cluster within tens of kilometres of mapped lunar lobate scarps — the same fault-scarp landforms catalogued globally by Watters et al. (2015) and extended for the south polar region by Mishra & Kumar (2022). v1.8 activates the eighth criterion as **distance-to-nearest-scarp**, with a logistic mapping centred on Civilini's documented moonquake-to-scarp clustering distance: + +- cells within **5 km** of a mapped scarp score ~0.08 (high seismic risk), +- cells at **25 km** score 0.50 (transition; matches typical Apollo-era epicentral-uncertainty scale), +- cells beyond **50 km** score ~0.96 (effectively safe under the Civilini regime). + +The default weight is 0.10 (slightly below slope, reflecting that scarp distance is one of multiple safety factors most relevant on long habitat-occupation timescales). The logistic shape is preferred over a hard linear ramp because Civilini's moonquake-to-scarp distance estimates are themselves probabilistic with epicentral uncertainty of order 10 km. + +The primary data source is **[Mishra & Kumar (2022)](https://doi.org/10.1029/2022GL098505)** (*Geophysical Research Letters*, [doi:10.5281/zenodo.6624114](https://doi.org/10.5281/zenodo.6624114), CC-BY 4.0): 704 south-polar lobate-scarp line segments combining Watters' published mapping with 75 new scarps from independent LROC NAC analysis. The 704 segments cover all 9 USGS Artemis IV candidate regions plus the 100 km horizon-march buffer. The original LROC SOC `POLAR_SCARP_LOCATIONS` shapefile (citing Watters et al. 2015 verbatim, NASA-hosted) is bundled alongside as an attribution anchor and sanity reference at [`src/selene_base/criteria/data/scarps_watters_2015_polar/`](src/selene_base/criteria/data/scarps_watters_2015_polar/). + +**Result against Wueller 2026 with all eight criteria active (20 m per-region tiled run on the v1.5-cached preprocessing):** 56 / 69 selene sites (81.2 %) match within 5 km of an in-scope Wueller site (identical to v1.5's 7-criterion result); 46 / 73 (63.0 %) of in-scope Wueller sites match a selene site within 5 km (**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, tightens). Same 69-site catalog by site count and same top-3 sites in every region; 13 sites in HW/MMP/N1/N2 reshuffle ranks 4–10 within their polygons because the seismic component lifts the aggregate of slightly-further-from-scarp cells just enough to flip a few interior orderings. + ### Aggregator: weighted_sum vs TOPSIS (v1.7) The default aggregator (v1.4+) is a weighted linear sum: @@ -640,6 +654,8 @@ The dependency graph is one-way: `data/` is the foundation; `criteria/` reads lo - **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.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. + ### Where this goes next **TOPSIS aggregator behind `--method topsis`.** Still open as a methodological alternative to weighted-sum aggregation. Lower priority now that the per-region framing produces a NASA-aligned catalog under the existing aggregator; TOPSIS would change *which* HLS-compliant cells rank highest within each polygon, not whether they're inside. @@ -663,7 +679,9 @@ The dependency graph is one-way: `data/` is the foundation; `criteria/` reads lo - Diviner Polar Resource Product (PRP), south pole, version 1.0. PDS Geosciences Node Diviner derived bundle: [`dlre_prp_south.tab`](https://pds-geosciences.wustl.edu/lro/urn-nasa-pds-lro_diviner_derived1/data_derived_prp/dlre_prp_south.tab). - Mitrofanov, I. G., et al. (2010). *Hydrogen mapping of the lunar south pole using the LRO Neutron Detector Experiment LEND.* Science, 330(6003), 483–486. - Watters, T. R., Robinson, M. S., Banks, M. E., Tran, T., & Denevi, B. W. (2015). *Global thrust faulting on the Moon and the influence of tidal stresses.* Geology, 43(10), 851–854. [doi:10.1130/G37120.1](https://doi.org/10.1130/G37120.1) -- Civilini, F., Weber, R. C., Jiang, Z., Phillips, D., & Pan, W. (2023). *Constraints on the seismic hazard of young thrust faults on the Moon from re-located shallow moonquakes.* (Used as motivation for the seismic exclusion criterion.) +- Civilini, F., Weber, R. C., Jiang, Z., Phillips, D., & Pan, W. (2023). *Constraints on the seismic hazard of young thrust faults on the Moon from re-located shallow moonquakes.* (Used as physical motivation + the order-10–50 km clustering-distance scale for v1.8's seismic-criterion logistic.) +- Mishra, A., & Kumar, P. S. (2022). *Spatial and Temporal Distribution of Lobate Scarps in the Lunar South Polar Region: Evidence for Latitudinal Variation of Scarp Geometry, Kinematics and Formation Ages, Neo-Tectonic Activity and Sources of Potential Seismic Risks at the Artemis Candidate Landing Regions.* Geophysical Research Letters, 49, e2022GL098505. [doi:10.1029/2022GL098505](https://doi.org/10.1029/2022GL098505). Data deposit: [doi:10.5281/zenodo.6624114](https://doi.org/10.5281/zenodo.6624114) (CC-BY 4.0). The 704 south-polar scarp line segments ship in-repo at [`src/selene_base/criteria/data/scarps_mishra_kumar_2022/`](src/selene_base/criteria/data/scarps_mishra_kumar_2022/) and are the primary data source for v1.8's seismic criterion. +- Lobate-scarp catalog also distributed by the LROC Science Operations Center (NASA PDS LROLRC_2001 EXTRAS) as `POLAR_SCARP_LOCATIONS` — citing Watters et al. 2015 verbatim. The 48-named-scarp polar shapefile ships in-repo at [`src/selene_base/criteria/data/scarps_watters_2015_polar/`](src/selene_base/criteria/data/scarps_watters_2015_polar/) as an attribution anchor. - NASA (October 2024). *Artemis III candidate landing regions.* [https://www.nasa.gov/feature/artemis-iii](https://www.nasa.gov/feature/artemis-iii) - McClernan, M.T. (2024). *Down Selected Artemis III Candidate Landing Site Navigational Grids.* U.S. Geological Survey data release. [https://doi.org/10.5066/P1MEQ6UK](https://doi.org/10.5066/P1MEQ6UK). The simplified region envelopes from this release ship in-repo at [`src/selene_base/validation/data/nasa_regions_polygons_usgs.geojson`](src/selene_base/validation/data/nasa_regions_polygons_usgs.geojson) and are the authoritative validation reference from v1.2.0 onwards. - Gracy, S., & Lee, P. (2024). *Update on the Artemis III Reference Mission and Candidate Landing Region Selection.* 55th Lunar and Planetary Science Conference, Abstract #1695. (Source for the four published HLS hard-constraint thresholds used by v1.3.0's `rank-per-region` — slope, slope-buffer, illumination, DTE visibility.) diff --git a/config/weights_default.yaml b/config/weights_default.yaml index d4e7b2c..2a31988 100644 --- a/config/weights_default.yaml +++ b/config/weights_default.yaml @@ -1,29 +1,38 @@ -# selene-base default weights (week 9+). +# selene-base default weights (v1.8 — eighth criterion live). # -# Adds the los_to_earth criterion (Earth line-of-sight visibility -# fraction over the libration cycle) as the seventh criterion. +# Eight criteria contribute to the aggregate score: +# illumination, ice, coupling, los_to_earth, slope, thermal, hazard, seismic. # -# Why 0.15 for los_to_earth: -# - It is a real physical criterion NASA prioritises in actual mission -# planning (above thermal and hazard for crewed-comms-critical sites). -# - It is not as high-leverage as illumination/ice/coupling because it -# is a more binary-ish constraint (works/doesn't work given a horizon) -# than a continuous optimisation target. -# - 0.15 places it between hazard (mostly redundant with slope at the -# south pole) and slope (a hard buildability constraint). +# v1.8 activates the seismic criterion (distance-to-nearest-scarp via +# Mishra & Kumar 2022's south-polar lobate-scarp catalog) and bumps its +# weight from 0.03 (placeholder while the criterion was inactive) to +# 0.10 — slightly below slope's weight, reflecting that scarp distance +# is one of several safety factors and is most relevant on long +# habitat-occupation timescales (Civilini et al. 2023's relocated +# moonquake-to-scarp clustering distance is order 10-50 km, with +# epicentral uncertainty of order 10 km, so the criterion influences +# multi-km site selection without dominating it). The other seven +# weights are scaled by 0.90/0.97 ≈ 0.928 so the simplex still sums +# to 1.00 without changing the relative ratios within the previous +# seven-criterion vector. # -# This weight was chosen BEFORE the week-9 validation rerun on -# physics-and-operations grounds, not after seeing the result. +# Why 0.15 for los_to_earth (unchanged ratio from v1.1.0): +# - Real physical criterion NASA prioritises in actual mission planning. +# - More binary-ish than continuous, so weighted slightly less than +# illumination/ice/coupling. # -# Other criteria renormalised so the simplex sums to 1.00. The week 7-8 -# (six-criterion, no LOS) weight vector is preserved as -# config/weights_legacy_v6.yaml. +# This vector was chosen on physics-and-operations grounds, not by +# fitting to validation data. Older vectors are preserved: +# config/weights_legacy.yaml — five criteria (no coupling, no LOS). +# config/weights_legacy_v6.yaml — six criteria (no LOS). +# The seven-criterion v1.5 vector is the v1.8 vector minus seismic with +# the remaining seven renormalised back to 1.00. -illumination: 0.18 -ice: 0.18 -coupling: 0.18 -los_to_earth: 0.15 -slope: 0.13 -thermal: 0.08 -hazard: 0.07 -seismic: 0.03 +illumination: 0.17 +ice: 0.17 +coupling: 0.17 +los_to_earth: 0.14 +slope: 0.12 +thermal: 0.07 +hazard: 0.06 +seismic: 0.10 diff --git a/docs/v1.5_catalog_report.md b/docs/v1.5_catalog_report.md index c6da004..85608c4 100644 --- a/docs/v1.5_catalog_report.md +++ b/docs/v1.5_catalog_report.md @@ -419,7 +419,7 @@ A peer reviewer would flag the following; surfacing them ourselves preempts the 1. **20 m on LOLA is partially interpolated.** The LOLA south polar 20 m product (Barker et al. 2021) is a gridded interpolation of altimeter shot returns at lower native density. Effective resolution at the south pole is closer to 50–100 m than to 20 m. Sub-pixel terrain features may not be resolved despite the grid resolution, and selene's 20 m HLS slope/buffer constraints inherit the underlying interpolation uncertainty. 2. **The aggregate score is a weighted linear sum.** Selene v1.5 still aggregates criteria via $\sum w_i s_i / \sum w_i$, which cannot model spatial coupling that the multiplicative coupling criterion was designed to address (see README §"v0.7"). The HLS framing bypasses the coupling failure mode by hard-filtering — but a TOPSIS aggregator would let coupling contribute meaningfully where it is non-zero. Planned v1.7. -3. **The seismic criterion is currently unused.** The Watters scarp catalog has not been integrated; sites near scarp traces are not penalised. A site within Apollo-era shallow-moonquake epicentral uncertainty radius would still rank as if no seismic risk existed. Planned v1.8. +3. **The seismic criterion is currently unused.** The Watters scarp catalog has not been integrated; sites near scarp traces are not penalised. A site within Apollo-era shallow-moonquake epicentral uncertainty radius would still rank as if no seismic risk existed. Planned v1.8. *(v1.8 update: resolved. v1.8 ships the Mishra & Kumar (2022) 704-segment south-polar scarp catalog as the primary data source — see the v1.8 roadmap entry and "Seismic criterion" Methodology subsection in the main README. The 81.2 % / 1.76 km headline numbers in this report are computed from the v1.5 7-criterion catalog and remain valid; the v1.8 8-criterion run produces an identical 56 / 69 selene-matched count with median pair distance tightening to 1.69 km.)* 4. **The aggregate score map is 240 m, not 20 m.** Per the v1.5 design, per-criterion rasters are not materialised globally at 20 m; the aggregate at each tile is a bilinear upsample of the 240 m product. This makes the per-criterion scores at v1.5 sites and at Wueller-site coordinates measurements of the same 240 m raster — a fair apples-to-apples comparison, but neither is a 20 m per-criterion measurement. The slope, slope-buffer, and Earth-LOS visibility *are* re-derived at 20 m on the per-region tile (so the HLS filters apply at 20 m); illumination, thermal, ice, and coupling stay at 240 m by design. 5. **Wueller is one validation reference.** NASA's actual selection process likely uses additional criteria not visible to either project (operational risk, redundant traverses, NAC stereo-DEM-derived terrain not in either pipeline). 81.2 % agreement with one peer-reviewed reference is meaningful; it is not a guarantee that selene's catalog matches NASA's eventual selection. 6. **The 5 km match threshold is a choice.** Defended in the README as the upper end of typical regional candidate-site granularity, but there is no principled threshold below 5 km for "spatially equivalent". The match-rate at 4 km is 75 %; at 6 km it rises to 86 %. The headline 81.2 % is robust to this choice, but the precise number is not invariant. diff --git a/src/selene_base/criteria/data/scarps_mishra_kumar_2022/README.md b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/README.md new file mode 100644 index 0000000..6474317 --- /dev/null +++ b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/README.md @@ -0,0 +1,65 @@ +# Lunar lobate scarp catalog — Mishra & Kumar (2022) + +Primary data source for selene-base's seismic criterion (v1.8+). + +## Source + +Mishra, A., & Kumar, P. S. (2022). *Spatial and Temporal Distribution of +Lobate Scarps in the Lunar South Polar Region: Evidence for Latitudinal +Variation of Scarp Geometry, Kinematics and Formation Ages, +Neo-Tectonic Activity and Sources of Potential Seismic Risks at the +Artemis Candidate Landing Regions.* Geophysical Research Letters, 49, +e2022GL098505. + +Data deposit: + +License: **Creative Commons Attribution 4.0 International (CC-BY 4.0)**. + +## What's bundled + +The bundled shapefile family is the *main* "All segments merged" +product from the Zenodo deposit at +`Datasets_GRL_2022GL098505/Data_Shape_files/All Scarp segments/`: + +- `main_segments.shp` (~1.3 MB) — 704 LineString / MultiLineString + features representing south-polar lunar lobate scarp segments. Builds + on Watters et al. (2015) global mapping plus 75 new scarps Mishra & + Kumar mapped from independent LROC NAC analysis. +- `main_segments.shx`, `main_segments.dbf`, `main_segments.prj` — + shapefile sidecars. + +Renamed from the original `All segments merged.{shp,shx,dbf,prj}` to +`main_segments.*` for snake_case consistency with the rest of the +repo. No data transformation; bytes are unchanged. + +## Coordinate reference system + +Polar Stereographic Moon, sphere R = 1737400 m, latitude of origin +−90°, central meridian 0° — matches selene-base's analysis CRS exactly, +no reprojection required at consumption time. + +## What's NOT bundled + +- `Data_Shape_files/All Ages/All dated Segments.{shp,shx,dbf,prj}` + (145 dated points) — out of scope for v1.8's distance-only scoring; + potential v1.9 candidate to weight scarps by formation age. +- `Data_Shape_files/New Lobate scarps/All new scarps merged.{shp,...}` + (75 new scarps) — already included in the 704-segment merged file + bundled above. +- `Data_Age_SCC_files/PLT Files/*` (446 small per-scarp plot files) + and `Data_Figures/*.xlsx` — figure source data, not catalog input. + +## Selene-base usage + +`selene_base.criteria.seismic` reads `main_segments.shp` via +`geopandas.read_file`, densifies the polylines to ~1 km point spacing, +builds a `cKDTree`, and produces a per-pixel distance-to-nearest-scarp +score in `[0, 1]`. See the seismic-criterion module docstring for the +sigmoid mapping and the Civilini et al. (2023) physical justification. + +## Attribution required + +Per CC-BY 4.0, any downstream use must credit Mishra & Kumar (2022) + +the Zenodo DOI. The selene-base README's References section carries +this citation; no further action is required for users running the +pipeline locally. diff --git a/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.dbf b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.dbf new file mode 100644 index 0000000..474e2df Binary files /dev/null and b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.dbf differ diff --git a/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.prj b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.prj new file mode 100644 index 0000000..ae9df08 --- /dev/null +++ b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.prj @@ -0,0 +1 @@ +PROJCS["PolarStereographic_Moon",GEOGCS["GCS_Moon",DATUM["D_Moon",SPHEROID["Moon",1737400.0,0.0]],PRIMEM["Reference_Meridian",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Stereographic_South_Pole"],PARAMETER["false_easting",0.0],PARAMETER["false_northing",0.0],PARAMETER["central_meridian",0.0],PARAMETER["standard_parallel_1",-90.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.shp b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.shp new file mode 100644 index 0000000..e775277 Binary files /dev/null and b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.shp differ diff --git a/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.shx b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.shx new file mode 100644 index 0000000..fc6b3c8 Binary files /dev/null and b/src/selene_base/criteria/data/scarps_mishra_kumar_2022/main_segments.shx differ diff --git a/src/selene_base/criteria/data/scarps_watters_2015_polar/README.md b/src/selene_base/criteria/data/scarps_watters_2015_polar/README.md new file mode 100644 index 0000000..3c106c3 --- /dev/null +++ b/src/selene_base/criteria/data/scarps_watters_2015_polar/README.md @@ -0,0 +1,61 @@ +# Lunar polar lobate scarp locations — Watters et al. (2015), via LROC SOC + +**Attribution anchor / sanity reference** for selene-base's seismic +criterion. Not the primary data source — see the sibling +`scarps_mishra_kumar_2022/` bundle for the 704-segment line catalog +the criterion actually consumes. + +## Source + +Watters, T. R., Robinson, M. S., Collins, G. C., Banks, M. E., Daud, +K., Williams, N. R., & Selvans, M. M. (2015). *Global thrust faulting +on the Moon and the influence of tidal stresses.* Geology, 43(10), +851–854. + +Hosted by: LROC Science Operations Center, NASA Planetary Data System +(LROLRC_2001 EXTRAS). Direct URL: + + +License: **NASA public data; attribution required.** No registration +required for download. + +## What's bundled + +- `polar_scarp_locations.shp` (~1.5 KB) — 48 Point features (named + scarps with LROC NAC image references). 12 in the south polar + region; the rest are north polar. +- `polar_scarp_locations.shx`, `polar_scarp_locations.dbf`, + `polar_scarp_locations.prj` — shapefile sidecars. + +Renamed from the original `POLAR_SCARP_LOCATIONS_180.{SHP,SHX,DBF,PRJ}` +to lowercase + snake_case. No data transformation. + +## Why this is bundled if it isn't the primary data source + +Two reasons: + +1. **Verbatim attribution to Watters et al. 2015.** The LROC SOC + shapefile cites Watters 2015 directly as its data citation. Mishra + & Kumar 2022 (the primary source consumed by the criterion) builds + on Watters' work and adds 75 new scarps; bundling the original + alongside makes the attribution chain auditable rather than + implicit. +2. **Sanity reference.** With only 48 named points, this catalog is + too sparse to drive the per-pixel distance-to-nearest-scarp + criterion (only 3 features south of -80°, vs. 704 line segments in + the Mishra & Kumar bundle). It is, however, useful as a quick + cross-check that the seismic criterion's distance metric + behaves sensibly at the named scarps. + +## Coordinate reference system + +IAU Moon 2000 geographic (lon/lat), sphere R = 1737400 m. Reproject +to selene-base's polar stereographic before any use; the +seismic-criterion module handles this automatically when the file is +loaded. + +## Selene-base usage + +Not consumed by `selene score` directly. The seismic criterion module +exposes a helper that loads this file for diagnostic comparison +against the Mishra & Kumar primary catalog. diff --git a/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.dbf b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.dbf new file mode 100644 index 0000000..ba705ae Binary files /dev/null and b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.dbf differ diff --git a/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.prj b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.prj new file mode 100644 index 0000000..08d3f02 --- /dev/null +++ b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.prj @@ -0,0 +1 @@ +GEOGCS["GCS_Moon_2000",DATUM["D_Moon_2000",SPHEROID["Moon_2000_IAU_IAG",1737400,0]],PRIMEM["Reference_Meridian",0],UNIT["Degree",0.017453292519943295]] diff --git a/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.shp b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.shp new file mode 100644 index 0000000..5639018 Binary files /dev/null and b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.shp differ diff --git a/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.shx b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.shx new file mode 100644 index 0000000..848674a Binary files /dev/null and b/src/selene_base/criteria/data/scarps_watters_2015_polar/polar_scarp_locations.shx differ diff --git a/src/selene_base/criteria/seismic.py b/src/selene_base/criteria/seismic.py index 70a2af1..9a1c896 100644 --- a/src/selene_base/criteria/seismic.py +++ b/src/selene_base/criteria/seismic.py @@ -1,26 +1,32 @@ """Seismic criterion — penalises proximity to active lobate scarps. Civilini et al. (2023) re-located several Apollo-era shallow moonquakes -to within tens of kilometres of young lobate scarps from the Watters -catalog, suggesting active thrust faulting. The score is the per-pixel -distance to the nearest scarp, capped at ``safe_distance_km``. - -Two helpers: - -- :func:`distance_to_scarps` rasterises the scarp catalog into a - per-pixel "km to nearest scarp" grid via a KDTree on densified scarp - vertices. -- :func:`compute` maps that distance grid to a [0, 1] score. - -The scarp catalog itself is not yet wired (see -``selene_base.data.download.download_scarps``), but the criterion runs -against any GeoDataFrame the user supplies. - -Filled in week 3. +and showed several of them cluster within tens of kilometres of young +lobate scarps from the Watters et al. (2015) catalog, suggesting +active thrust faulting. The score is a per-pixel logistic of distance +to the nearest mapped scarp segment, with the transition tuned to +that documented distance regime (cells inside ~5 km of a mapped scarp +score near 0, cells beyond ~50 km score near 1). + +Three helpers: + +- :func:`load_bundled_catalog` loads the Mishra & Kumar (2022) primary + scarp catalog from the in-repo bundle at + :mod:`selene_base.criteria.data.scarps_mishra_kumar_2022`. +- :func:`distance_to_scarps` rasterises a scarp catalog into a + per-pixel "km to nearest scarp" grid via a KDTree on densified + vertices (~1 km spacing along each line). +- :func:`compute` maps that distance grid to a ``[0, 1]`` score via a + logistic sigmoid (default midpoint 25 km, steepness scale 8 km). + +Activated as a contributing criterion in v1.8 (eighth of eight); the +scaffold landed in week 3 against synthetic catalogs. """ from __future__ import annotations +from pathlib import Path + import geopandas as gpd import numpy as np import rioxarray # noqa: F401 (registers the .rio accessor) @@ -29,6 +35,47 @@ from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiLineString +# In-repo scarp catalog bundle paths. The Mishra & Kumar 2022 file is the +# primary data source consumed by `selene score`'s seismic criterion; +# the LROC SOC Watters 2015 polar shapefile sits alongside as an +# attribution anchor (see the bundle READMEs for full provenance). +_BUNDLED_DATA_DIR = Path(__file__).parent / "data" +BUNDLED_MISHRA_KUMAR_2022 = _BUNDLED_DATA_DIR / "scarps_mishra_kumar_2022" / "main_segments.shp" +BUNDLED_WATTERS_2015_POLAR = ( + _BUNDLED_DATA_DIR / "scarps_watters_2015_polar" / "polar_scarp_locations.shp" +) + + +def load_bundled_catalog(path: Path | None = None) -> gpd.GeoDataFrame: + """Load the in-repo scarp catalog used by the seismic criterion. + + Defaults to the Mishra & Kumar (2022) 704-segment south-polar + shapefile bundled at + :data:`BUNDLED_MISHRA_KUMAR_2022`. Pass an explicit path to use a + different catalog (e.g. the bundled Watters 2015 polar locations + for a sanity comparison). + + Args: + path: Override path. ``None`` uses the bundled Mishra & Kumar + primary file. + + Returns: + GeoDataFrame with the catalog's native CRS preserved. The + seismic criterion's :func:`distance_to_scarps` reprojects to + the analysis grid's CRS at consumption time. + + Raises: + FileNotFoundError: If the requested shapefile is missing. + """ + src = Path(path) if path is not None else BUNDLED_MISHRA_KUMAR_2022 + if not src.exists(): + raise FileNotFoundError( + f"bundled scarp catalog not found at {src}; " + "the v1.8 release ships this file in-repo, so a missing " + "file usually indicates a broken installation." + ) + return gpd.read_file(src) + def _densify_to_points( scarps: gpd.GeoDataFrame, @@ -137,32 +184,61 @@ def distance_to_scarps( def compute( distance_km: xr.DataArray, *, - safe_distance_km: float = 50.0, + midpoint_km: float = 25.0, + steepness_km: float = 8.0, ) -> xr.DataArray: - """Map distance-to-scarp (km) to a [0, 1] safety score. + """Map distance-to-scarp (km) to a ``[0, 1]`` safety score via logistic. + + ``score(d) = 1 / (1 + exp(-(d - midpoint_km) / steepness_km))``. + + With the v1.8 defaults ``midpoint_km = 25``, ``steepness_km = 8``: + + - ``d = 5 km`` → 0.08 (well inside Civilini et al. 2023's + shallow-moonquake-to-scarp clustering distance; high seismic risk) + - ``d = 15 km`` → 0.22 + - ``d = 25 km`` → 0.50 (transition; matches the typical 10–50 km + epicentral-uncertainty regime documented for the relocated + Apollo-era moonquakes) + - ``d = 35 km`` → 0.78 + - ``d = 50 km`` → 0.96 (effectively safe under the Civilini regime) + + The logistic shape is preferred over a hard linear ramp because the + seismic-distance evidence is itself probabilistic — Civilini's + moonquake-to-scarp distances are noisy estimates with epicentral + uncertainty of order 10 km. - ``score = clip(distance_km / safe_distance_km, 0, 1)``: a site - farther than ``safe_distance_km`` from the nearest scarp scores - 1.0; closer sites lose score linearly. + Cells where the catalog is empty (``+inf`` distance) are scored 1.0; + NaN distances propagate to NaN scores. Args: distance_km: DataArray of distances in km from :func:`distance_to_scarps`. - safe_distance_km: Distance at and above which the score is - 1.0. Must be strictly positive. + midpoint_km: Distance at which the score crosses 0.5. Must be + strictly positive. + steepness_km: Logistic scale (km). Larger values produce a + gentler transition; smaller values approach a step + function. Must be strictly positive. Returns: - DataArray of [0, 1] scores aligned with ``distance_km``. + DataArray of ``[0, 1]`` scores aligned with ``distance_km``. Raises: - ValueError: If ``safe_distance_km`` is non-positive. + ValueError: If either parameter is non-positive. """ - if safe_distance_km <= 0: - raise ValueError(f"safe_distance_km must be positive, got {safe_distance_km!r}") + if midpoint_km <= 0: + raise ValueError(f"midpoint_km must be positive, got {midpoint_km!r}") + if steepness_km <= 0: + raise ValueError(f"steepness_km must be positive, got {steepness_km!r}") arr = distance_km.to_numpy().astype(np.float64) - score = np.clip(arr / safe_distance_km, 0.0, 1.0) - score = np.where(np.isfinite(arr), score, 1.0) # +inf (empty catalog) -> safe + # +inf (empty catalog or unreachable cell) → 1.0 directly; the + # logistic asymptotes there anyway, but skipping the exponent keeps + # numerical hygiene clean. + finite_mask = np.isfinite(arr) + score = np.full_like(arr, 1.0) + if finite_mask.any(): + d = arr[finite_mask] + score[finite_mask] = 1.0 / (1.0 + np.exp(-(d - midpoint_km) / steepness_km)) score = np.where(np.isnan(arr), np.nan, score) out = xr.DataArray( diff --git a/src/selene_base/pipeline/score.py b/src/selene_base/pipeline/score.py index c74c7f2..800fecb 100644 --- a/src/selene_base/pipeline/score.py +++ b/src/selene_base/pipeline/score.py @@ -333,16 +333,21 @@ def _seismic_score( scored_dir = processed_dir / SCORED_SUBDIR out_cog = scored_dir / "seismic_score_southpole_240m.tif" + # User-supplied overrides take precedence over the bundled default; + # the in-repo Mishra & Kumar 2022 shapefile (v1.8+) is the fallback + # so the criterion fires out of the box without any download. candidates = [ raw_dir / "scarps" / "scarps_southpole.geojson", raw_dir / "scarps" / "scarps_southpole.csv", + seismic_criterion.BUNDLED_MISHRA_KUMAR_2022, ] scarps_path = next((p for p in candidates if p.exists()), None) if scarps_path is None: echo( - "[skip] seismic: Watters scarp catalog not present " - "(see download_scarps docstring; place file at " - f"{candidates[0].as_posix()})" + "[skip] seismic: no scarp catalog available — neither a " + "user-supplied override at " + f"{candidates[0].as_posix()} nor the bundled Mishra & Kumar " + f"2022 shapefile at {candidates[2].as_posix()} could be found" ) return None @@ -359,7 +364,12 @@ def _seismic_score( ) return None - echo(f"[compute] seismic from {scarps_path.name}") + label = ( + "Mishra & Kumar 2022 (bundled)" + if scarps_path == seismic_criterion.BUNDLED_MISHRA_KUMAR_2022 + else scarps_path.name + ) + echo(f"[compute] seismic from {label}") if scarps_path.suffix.lower() == ".csv": import pandas as pd from shapely.geometry import Point diff --git a/tests/test_seismic.py b/tests/test_seismic.py index 7276eb3..7f101fc 100644 --- a/tests/test_seismic.py +++ b/tests/test_seismic.py @@ -1,4 +1,19 @@ -"""Tests for :mod:`selene_base.criteria.seismic`.""" +"""Tests for :mod:`selene_base.criteria.seismic` (v1.8 — eighth criterion live). + +Two suites: + +- :class:`TestDistanceToScarps` — KDTree-based distance from each grid + cell to the nearest densified scarp vertex. Synthetic scarps in the + lunar south polar CRS. +- :class:`TestCompute` — logistic mapping from distance (km) to a + ``[0, 1]`` safety score, with the v1.8 defaults centred on Civilini + et al. (2023)'s shallow-moonquake-to-scarp clustering distance. + +Plus a smoke test that loads the bundled Mishra & Kumar 2022 catalog +end-to-end through ``distance_to_scarps`` + ``compute`` on a small +polar-stereo grid (the "criterion works on the polar grid" test from +the v1.8 spec). +""" from __future__ import annotations @@ -12,7 +27,12 @@ from pyproj import CRS from shapely.geometry import LineString, Point -from selene_base.criteria.seismic import compute, distance_to_scarps +from selene_base.criteria.seismic import ( + BUNDLED_MISHRA_KUMAR_2022, + compute, + distance_to_scarps, + load_bundled_catalog, +) LUNAR_SOUTH_POLAR = CRS.from_proj4( "+proj=stere +lat_0=-90 +lat_ts=-90 +lon_0=0 +k=1 +x_0=0 +y_0=0 +R=1737400 +no_defs +type=crs" @@ -33,6 +53,29 @@ def _polar_grid(width: int, height: int, pixel_m: float) -> xr.DataArray: return da.rio.write_crs(LUNAR_SOUTH_POLAR, inplace=False) +# --- Bundled catalog --------------------------------------------------- + + +def test_load_bundled_catalog_returns_polylines() -> None: + """The bundled Mishra & Kumar 2022 file loads as polylines, not points.""" + cat = load_bundled_catalog() + assert len(cat) == 704, "v1.8 ships exactly 704 main-segment features" + assert cat.crs is not None, "bundled catalog must carry an explicit CRS" + geom_types = set(cat.geometry.type.unique()) + assert geom_types <= {"LineString", "MultiLineString"}, ( + f"expected line geometries, got {sorted(geom_types)}" + ) + + +def test_bundled_catalog_path_constant_resolves() -> None: + assert BUNDLED_MISHRA_KUMAR_2022.exists(), ( + f"bundled shapefile path not found: {BUNDLED_MISHRA_KUMAR_2022}" + ) + + +# --- Distance ----------------------------------------------------------- + + class TestDistanceToScarps: def test_empty_catalog_returns_inf(self) -> None: grid = _polar_grid(5, 5, pixel_m=1000.0) @@ -61,21 +104,61 @@ def test_line_distance(self) -> None: assert out[5, 2] == pytest.approx(3.0, abs=0.1) -class TestCompute: - def test_far_distance_scores_one(self) -> None: - d = xr.DataArray(np.array([[60.0, 100.0]]), dims=("y", "x")) - out = compute(d, safe_distance_km=50.0).to_numpy() - np.testing.assert_allclose(out, 1.0) +# --- Scoring ------------------------------------------------------------ - def test_zero_distance_scores_zero(self) -> None: - d = xr.DataArray(np.array([[0.0]]), dims=("y", "x")) - out = compute(d, safe_distance_km=50.0).to_numpy() - np.testing.assert_allclose(out, 0.0) - def test_linear_in_between(self) -> None: - d = xr.DataArray(np.array([[10.0, 25.0, 50.0]]), dims=("y", "x")) - out = compute(d, safe_distance_km=50.0).to_numpy() - np.testing.assert_allclose(out, [[0.2, 0.5, 1.0]], atol=1e-9) +class TestCompute: + """Logistic mapping ``score(d) = 1 / (1 + exp(-(d - midpoint)/steepness))``. + + With v1.8 defaults (midpoint 25 km, steepness 8 km): + d=0 → 0.04 d=5 → 0.08 d=15 → 0.22 d=25 → 0.50 + d=35 → 0.78 d=50 → 0.96 d→∞ → 1.00 + """ + + @pytest.mark.parametrize( + "d, expected", + [ + (0.0, 1.0 / (1.0 + math.exp(25.0 / 8.0))), + (5.0, 1.0 / (1.0 + math.exp(20.0 / 8.0))), + (25.0, 0.5), + (50.0, 1.0 / (1.0 + math.exp(-25.0 / 8.0))), + (1000.0, 1.0), # asymptote + ], + ) + def test_logistic_at_known_distances(self, d: float, expected: float) -> None: + out = compute(xr.DataArray(np.array([[d]]), dims=("y", "x"))).to_numpy() + assert out[0, 0] == pytest.approx(expected, abs=1e-3) + + def test_close_cell_low_score(self) -> None: + """A cell within Civilini's ~5 km moonquake-clustering radius must + score in the unsafe band.""" + d = xr.DataArray(np.array([[3.0]]), dims=("y", "x")) + out = compute(d).to_numpy() + assert out[0, 0] < 0.10 + + def test_far_cell_high_score(self) -> None: + """A cell well beyond 50 km must score effectively safe.""" + d = xr.DataArray(np.array([[80.0]]), dims=("y", "x")) + out = compute(d).to_numpy() + assert out[0, 0] > 0.99 + + def test_scoring_function_monotonic(self) -> None: + """Closer distance → strictly lower score, across a sweep.""" + d = xr.DataArray( + np.array([[0.0, 5.0, 10.0, 25.0, 50.0, 100.0]]), + dims=("y", "x"), + ) + out = compute(d).to_numpy().ravel() + assert np.all(np.diff(out) > 0), f"expected strictly increasing, got {out}" + + def test_scoring_function_in_range_0_1(self) -> None: + """Across a wide distance sweep, scores stay in [0, 1] strictly.""" + rng = np.random.default_rng(20260501) + d = xr.DataArray(rng.uniform(0.0, 200.0, size=(10, 10)), dims=("y", "x")) + out = compute(d).to_numpy() + finite = out[np.isfinite(out)] + assert finite.min() >= 0.0 + assert finite.max() <= 1.0 def test_inf_distance_treated_as_safe(self) -> None: d = xr.DataArray(np.array([[np.inf]]), dims=("y", "x")) @@ -83,12 +166,44 @@ def test_inf_distance_treated_as_safe(self) -> None: assert out[0, 0] == pytest.approx(1.0) def test_nan_propagates(self) -> None: - d = xr.DataArray(np.array([[np.nan, 50.0]]), dims=("y", "x")) + d = xr.DataArray(np.array([[np.nan, 25.0]]), dims=("y", "x")) out = compute(d).to_numpy() assert math.isnan(out[0, 0]) - assert out[0, 1] == pytest.approx(1.0) + assert out[0, 1] == pytest.approx(0.5, abs=1e-9) + + def test_midpoint_must_be_positive(self) -> None: + d = xr.DataArray(np.array([[10.0]]), dims=("y", "x")) + with pytest.raises(ValueError, match="midpoint_km"): + compute(d, midpoint_km=0.0) - def test_safe_distance_must_be_positive(self) -> None: + def test_steepness_must_be_positive(self) -> None: d = xr.DataArray(np.array([[10.0]]), dims=("y", "x")) - with pytest.raises(ValueError, match="safe_distance_km"): - compute(d, safe_distance_km=0.0) + with pytest.raises(ValueError, match="steepness_km"): + compute(d, steepness_km=-1.0) + + +# --- End-to-end smoke test --------------------------------------------- + + +def test_seismic_criterion_works_for_polar_grid() -> None: + """Bundled Mishra & Kumar catalog → distance grid → score grid on a + coarse south-polar test grid. Verifies the full criterion chain runs + without CRS or memory issues against real data.""" + catalog = load_bundled_catalog() + # 25-km pixels over a ±300 km square — small enough for a unit-test + # KDTree query, large enough that some catalog segments fall inside. + grid = _polar_grid(25, 25, pixel_m=25_000.0) + distance = distance_to_scarps(catalog, grid) + score = compute(distance) + + arr = score.to_numpy() + assert arr.shape == (25, 25) + finite = arr[np.isfinite(arr)] + assert finite.size == arr.size, "no NaN should appear with a non-empty catalog" + assert finite.min() >= 0.0 + assert finite.max() <= 1.0 + # The 704-segment catalog covers the polar region densely; at least + # some pixels in a ±300 km square should be in the unsafe band. + assert finite.min() < 0.5, "some grid cells should sit inside the unsafe band" + # And at least some should be safe (the catalog isn't dense everywhere). + assert finite.max() > 0.5, "some grid cells should sit in the safe band"