Coverage for lunar_toolkit/cli/pairs.py: 83%
89 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
1"""pairs - rank NAC stereo pairs, or pick illumination-diverse images for SfS."""
3import argparse
4import sys
5from pathlib import Path
7COMMAND = "pairs"
10def add_parser(sub: argparse._SubParsersAction) -> None:
11 p = sub.add_parser(
12 COMMAND,
13 help="Rank NAC stereo pairs (or select SfS images with --sfs) over a box, point+radius or AOI polygon.",
14 formatter_class=argparse.RawDescriptionHelpFormatter,
15 epilog="""
16Examples
17--------
18 lunar pairs --point -89.67 129.8 --radius-km 3 --top 20
19 lunar pairs --bbox 355 -5 5 5 --conv 15 45 --csv pairs.csv
20 lunar pairs --point -89.67 129.8 --radius-km 3 --sfs --inc 80 95 --list sfs_images.txt
21 lunar pairs --point -89.67 129.8 --radius-km 3 --sfs --coverage --views 3
22 lunar pairs --aoi aoi.geojson --sfs --coverage
23 lunar pairs --point -89.5 0 --radius-km 30 --engine duckdb --workspace work/pole
25--bbox is WEST SOUTH EAST NORTH (degrees, positive east; WEST > EAST crosses 0/360).
26--aoi takes a GeoJSON / WKT file or a WKT string (lon lat, degrees, positive east).
27The search region is also the AOI that overlaps / coverage are measured in.
28--sfs output (and --list) is sorted by sub-solar azimuth, as ASP SfS expects.
29--engine duckdb ranks pairs with a DuckDB spatial self-join (same pairs and metrics as
30the Python engine); auto uses it from 300 images when the spatial extension loads.
31--workspace keeps the images and pairs tables in DIR/lunar.duckdb (see lunar workspace).
32""",
33 )
34 p.add_argument("--dataset", choices=["edr", "cdr"], default="edr", help="Index to search (default: edr).")
35 region = p.add_mutually_exclusive_group(required=True)
36 region.add_argument("--bbox", nargs=4, type=float, metavar=("WEST", "SOUTH", "EAST", "NORTH"))
37 region.add_argument("--point", nargs=2, type=float, metavar=("LAT", "LON"))
38 region.add_argument(
39 "--aoi", metavar="FILE_OR_WKT", help="Area of interest: GeoJSON / WKT file or WKT polygon string."
40 )
41 p.add_argument("--radius-km", type=float, default=1.0, metavar="KM")
42 p.add_argument("--inc", nargs=2, type=float, metavar=("MIN", "MAX"), help="Incidence angle range.")
43 p.add_argument("--emi-max", type=float, default=None, metavar="DEG", help="Maximum emission angle.")
44 p.add_argument(
45 "--conv",
46 nargs=2,
47 type=float,
48 default=(10.0, 60.0),
49 metavar=("MIN", "MAX"),
50 help="Convergence angle limits (default: 10 60).",
51 )
52 p.add_argument("--max-inc-diff", type=float, default=20.0, metavar="DEG")
53 p.add_argument(
54 "--max-az-diff",
55 type=float,
56 default=30.0,
57 metavar="DEG",
58 help="Maximum sub-solar azimuth difference within a pair.",
59 )
60 p.add_argument("--min-overlap", type=float, default=0.1, metavar="FRAC")
61 p.add_argument(
62 "--sfs", action="store_true", help="Select illumination-diverse images instead of stereo pairs."
63 )
64 p.add_argument(
65 "--az-bin",
66 type=float,
67 default=30.0,
68 metavar="DEG",
69 help="--sfs: azimuth bin width / minimum separation (default: 30).",
70 )
71 p.add_argument("--per-bin", type=int, default=1, metavar="N")
72 p.add_argument("--max-images", type=int, default=None, metavar="N")
73 p.add_argument(
74 "--greedy",
75 action="store_true",
76 help="--sfs: enforce --az-bin separation between all picks instead of fixed bins.",
77 )
78 p.add_argument(
79 "--coverage", action="store_true", help="--sfs: per-location greedy selection over an AOI grid."
80 )
81 p.add_argument(
82 "--views",
83 type=int,
84 default=3,
85 metavar="N",
86 help="--sfs --coverage: target distinct Sun azimuths per location (default: 3).",
87 )
88 p.add_argument(
89 "--engine",
90 choices=["auto", "python", "duckdb"],
91 default="auto",
92 help="Stereo pair engine (default: auto = duckdb from 300 images).",
93 )
94 p.add_argument(
95 "--workspace",
96 type=Path,
97 metavar="DIR",
98 help="Write the images and pairs tables to DIR/lunar.duckdb (implies --engine duckdb).",
99 )
100 p.add_argument("--top", type=int, default=30, metavar="N", help="Rows to print (default: 30).")
101 p.add_argument("--csv", metavar="FILE", help="Write the full result to CSV.")
102 p.add_argument("--list", metavar="FILE", help="--sfs: write selected product IDs, one per line.")
103 p.add_argument(
104 "--data-dir", type=Path, default=None, metavar="DIR", help="Override Parquet data directory."
105 )
108def run(args: argparse.Namespace) -> int:
109 from ..lroc.cumindex import CumIndex
110 from ._utils import region_args
112 ci = CumIndex(dataset=args.dataset, data_dir=args.data_dir)
113 region = region_args(args)
114 aoi = region.get("aoi", region)
115 try:
116 df = ci.search(
117 **region,
118 incidence_angle=tuple(args.inc) if args.inc else None,
119 emission_angle=(0.0, args.emi_max) if args.emi_max is not None else None,
120 nac_only=True,
121 wkt=True,
122 )
123 except FileNotFoundError as exc:
124 print(exc, file=sys.stderr)
125 return 1
126 except ValueError as exc:
127 print(f"Error: {exc}", file=sys.stderr)
128 return 1
129 obs = ci.to_observations(df)
130 print(f"{len(obs)} image(s) in region")
132 if args.sfs:
133 from ..selection import select_for_coverage, select_illumination_diverse
135 if args.coverage: 135 ↛ 150line 135 didn't jump to line 150 because the condition on line 135 was always true
136 res = select_for_coverage(
137 obs,
138 aoi,
139 azimuth_sep_deg=args.az_bin,
140 views_per_cell=args.views,
141 max_images=args.max_images,
142 )
143 vf = res.attrs.get("view_fractions", {})
144 if vf: 144 ↛ 158line 144 didn't jump to line 158 because the condition on line 144 was always true
145 print(
146 "AOI fraction by number of distinct Sun azimuths: "
147 + ", ".join(f"{k}: {v:.0%}" for k, v in vf.items())
148 )
149 else:
150 res = select_illumination_diverse(
151 obs,
152 azimuth_bin_deg=args.az_bin,
153 per_bin=args.per_bin,
154 max_images=args.max_images,
155 aoi=aoi,
156 method="greedy" if args.greedy else "bins",
157 )
158 print(f"{len(res)} image(s) selected (sorted by Sun azimuth)")
159 cols = [
160 c
161 for c in (
162 "product_id",
163 "start_time",
164 "sub_solar_azimuth",
165 "incidence",
166 "emission",
167 "pixel_scale",
168 "aoi_coverage",
169 "selection_score",
170 )
171 if c in res.columns
172 ]
173 if args.list: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 Path(args.list).write_text("".join(f"{p}\n" for p in res["product_id"]))
175 print(f"Wrote {args.list}")
176 else:
177 res = _stereo_pairs(
178 obs,
179 args,
180 aoi=aoi,
181 convergence=tuple(args.conv),
182 max_incidence_diff=args.max_inc_diff,
183 max_azimuth_diff=args.max_az_diff,
184 min_overlap=args.min_overlap,
185 )
186 if res is None: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 return 1
188 print(f"{len(res)} pair(s)")
189 cols = [
190 "product_id_1",
191 "product_id_2",
192 "convergence",
193 "b_h",
194 "incidence_diff",
195 "azimuth_diff",
196 "resolution_ratio",
197 "aoi_coverage",
198 "dt_days",
199 "score",
200 ]
202 if not res.empty: 202 ↛ 204line 202 didn't jump to line 204 because the condition on line 202 was always true
203 print(res[cols].head(args.top).to_string(index=False, float_format=lambda v: f"{v:.3g}"))
204 if args.csv:
205 res.drop(columns=[c for c in ("footprint_wkt",) if c in res.columns]).to_csv(args.csv, index=False)
206 print(f"Wrote {args.csv}")
207 return 0
210def _stereo_pairs(obs, args: argparse.Namespace, **kwargs):
211 """find_stereo_pairs, or its DuckDB engine (--engine / --workspace); None on error."""
212 from ..stereo import find_stereo_pairs
213 from ..workspace import (
214 PAIRS_ENGINE_THRESHOLD,
215 Workspace,
216 WorkspaceError,
217 find_stereo_pairs_duckdb,
218 spatial_available,
219 workspace_path,
220 )
222 engine = "duckdb" if args.workspace else args.engine
223 if engine == "auto":
224 engine = "duckdb" if len(obs) >= PAIRS_ENGINE_THRESHOLD and spatial_available() else "python"
225 if engine == "python":
226 return find_stereo_pairs(obs, **kwargs)
227 try:
228 if args.workspace:
229 with Workspace(args.workspace) as ws:
230 res = find_stereo_pairs_duckdb(obs, workspace=ws, **kwargs)
231 print(f"Wrote images and pairs to {workspace_path(args.workspace)}")
232 return res
233 return find_stereo_pairs_duckdb(obs, **kwargs)
234 except WorkspaceError as exc:
235 print(f"Error: {exc}", file=sys.stderr)
236 return None