diff --git a/README.md b/README.md index 06fed1e..78f3e26 100644 --- a/README.md +++ b/README.md @@ -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 --help` shows its options. @@ -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 @@ -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_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. diff --git a/src/selene_base/cli.py b/src/selene_base/cli.py index aadea27..5635ddb 100644 --- a/src/selene_base/cli.py +++ b/src/selene_base/cli.py @@ -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. @@ -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)) @@ -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, diff --git a/src/selene_base/pipeline/preprocess_tiled.py b/src/selene_base/pipeline/preprocess_tiled.py index 749d81e..0690bc4 100644 --- a/src/selene_base/pipeline/preprocess_tiled.py +++ b/src/selene_base/pipeline/preprocess_tiled.py @@ -101,10 +101,14 @@ 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. @@ -112,19 +116,42 @@ def resolve_lola_source( → ``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" @@ -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}") diff --git a/tests/test_lola_source_override.py b/tests/test_lola_source_override.py new file mode 100644 index 0000000..d7977f2 --- /dev/null +++ b/tests/test_lola_source_override.py @@ -0,0 +1,159 @@ +"""Tests for the explicit ``--lola-source`` override (v1.8.3). + +The auto-detect path of :func:`resolve_lola_source` is hardcoded to +the PDS naming convention (``ldem_80s_{resolution_m}m.lbl``) and would +silently pick a coarser file when a non-PDS source (PGDA mosaic, +NAC stereo DEM, custom DEM) is on disk. The ``override=`` parameter +and the equivalent ``--lola-source`` CLI flag bypass the priority +list with caller-supplied path validation. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import numpy as np +import pytest +import rioxarray # noqa: F401 (registers .rio accessor) +import xarray as xr +from typer.testing import CliRunner + +from selene_base.cli import app +from selene_base.data.load import LUNAR_SOUTH_POLAR_CRS +from selene_base.pipeline import preprocess_tiled + +POLAR_CRS = str(LUNAR_SOUTH_POLAR_CRS) + +# CI runners preserve ANSI colour codes and Rich's panel/line-wrapping that +# local terminals strip; normalise both before substring-matching CLI output. +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") + + +def _normalise_cli_output(text: str) -> str: + """Strip ANSI escapes and collapse all whitespace runs to single spaces.""" + return " ".join(_ANSI_RE.sub("", text).split()) + + +def _tiny_synthetic_tif(path: Path, resolution_m: float = 240.0) -> Path: + """Write a 32×32 synthetic GeoTIFF on the south-polar grid for resolver tests. + + Resolver tests only need a file whose suffix and CRS are valid; the + pixel content is irrelevant. Kept small (~4 KB) so the test stays + fast even on cold-cache fixtures. + """ + size = 32 + half = (size * resolution_m) / 2.0 + xs = np.linspace(-half + resolution_m / 2, half - resolution_m / 2, size) + ys = np.linspace(half - resolution_m / 2, -half + resolution_m / 2, size) + z = np.zeros((size, size), dtype=np.float32) + da = xr.DataArray(z, dims=("y", "x"), coords={"y": ys, "x": xs}, name="elevation_m") + da = da.rio.write_crs(POLAR_CRS, inplace=False) + path.parent.mkdir(parents=True, exist_ok=True) + da.rio.to_raster(path, driver="GTiff", compress="DEFLATE") + return path + + +def test_resolve_lola_source_override_uses_explicit_path(tmp_path: Path) -> None: + src = _tiny_synthetic_tif(tmp_path / "custom" / "pgda_like.tif") + + resolved = preprocess_tiled.resolve_lola_source(raw_dir=tmp_path / "raw", override=src) + + assert resolved == src + + +def test_resolve_lola_source_override_bypasses_priority_list(tmp_path: Path) -> None: + """Override wins even when an auto-detectable PDS file is on disk.""" + raw = tmp_path / "raw" + pds_like = raw / "lola" / "ldem_80s_20m.lbl" + pds_like.parent.mkdir(parents=True, exist_ok=True) + pds_like.write_text("") # would be picked by the priority list + + custom = _tiny_synthetic_tif(tmp_path / "custom" / "pgda_like.tif") + + resolved = preprocess_tiled.resolve_lola_source(raw_dir=raw, override=custom) + + assert resolved == custom + + +def test_resolve_lola_source_override_missing_path_raises(tmp_path: Path) -> None: + bogus = tmp_path / "does_not_exist.tif" + with pytest.raises(FileNotFoundError, match="--lola-source path does not exist"): + preprocess_tiled.resolve_lola_source(raw_dir=tmp_path / "raw", override=bogus) + + +def test_resolve_lola_source_override_rejects_directory(tmp_path: Path) -> None: + a_dir = tmp_path / "lola_dir" + a_dir.mkdir() + with pytest.raises(FileNotFoundError, match="not a file"): + preprocess_tiled.resolve_lola_source(raw_dir=tmp_path / "raw", override=a_dir) + + +def test_resolve_lola_source_override_rejects_unknown_suffix(tmp_path: Path) -> None: + bad = tmp_path / "elevation.txt" + bad.write_text("not a raster") + with pytest.raises(ValueError, match="suffix '.txt' not recognised"): + preprocess_tiled.resolve_lola_source(raw_dir=tmp_path / "raw", override=bad) + + +def test_resolve_lola_source_no_override_preserves_priority(tmp_path: Path) -> None: + """Default (no override) still uses the auto-detect priority list.""" + raw = tmp_path / "raw" + pds_20m = raw / "lola" / "ldem_80s_20m.lbl" + pds_20m.parent.mkdir(parents=True, exist_ok=True) + pds_20m.write_text("") + pds_80m = raw / "lola" / "ldem_80s_80m.lbl" + pds_80m.write_text("") + + resolved = preprocess_tiled.resolve_lola_source(raw_dir=raw) + + assert resolved == pds_20m + + +def test_resolve_lola_source_no_override_no_files_raises(tmp_path: Path) -> None: + raw = tmp_path / "raw" + (raw / "lola").mkdir(parents=True, exist_ok=True) + with pytest.raises(FileNotFoundError, match="No LOLA source"): + preprocess_tiled.resolve_lola_source(raw_dir=raw) + + +def test_run_tiled_per_region_routes_source_path_through_resolver( + tmp_path: Path, +) -> None: + """Passing source_path= to run_tiled_per_region triggers override validation.""" + bogus = tmp_path / "missing.tif" + with pytest.raises(FileNotFoundError, match="--lola-source path does not exist"): + preprocess_tiled.run_tiled_per_region( + resolution_m=240.0, + region_codes=["SP"], + processed_dir=tmp_path / "processed", + source_path=bogus, + use_gpu=False, + echo=lambda _msg: None, + ) + + +def test_cli_lola_source_flag_outside_tiled_mode_errors(tmp_path: Path) -> None: + """--lola-source without --tiled-per-region is rejected with a clear message.""" + src = _tiny_synthetic_tif(tmp_path / "custom" / "src.tif") + + result = CliRunner().invoke(app, ["preprocess", "--lola-source", str(src)]) + + assert result.exit_code != 0 + # Rich renders the error inside a bordered panel that wraps long lines and + # injects ANSI styling on CI terminals; check distinctive tokens that + # cannot straddle a wrap or escape-sequence boundary after normalisation. + flat = _normalise_cli_output(result.output) + assert "Invalid value" in flat + assert "lola-source" in flat + assert "tiled-per-region" in flat + + +def test_cli_lola_source_flag_appears_in_help() -> None: + """The CLI surface advertises the flag so users can discover it.""" + result = CliRunner().invoke(app, ["preprocess", "--help"]) + assert result.exit_code == 0, result.output + # Rich's help renderer may colourise option names and wrap the help body, + # so look for the bare flag stem after stripping ANSI + whitespace. + flat = _normalise_cli_output(result.output) + assert "lola-source" in flat