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

1"""pairs - rank NAC stereo pairs, or pick illumination-diverse images for SfS.""" 

2 

3import argparse 

4import sys 

5from pathlib import Path 

6 

7COMMAND = "pairs" 

8 

9 

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 

24 

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 ) 

106 

107 

108def run(args: argparse.Namespace) -> int: 

109 from ..lroc.cumindex import CumIndex 

110 from ._utils import region_args 

111 

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") 

131 

132 if args.sfs: 

133 from ..selection import select_for_coverage, select_illumination_diverse 

134 

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 ] 

201 

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 

208 

209 

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 ) 

221 

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