Coverage for lunar_toolkit/cli/reference.py: 79%

131 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""reference - Artemis III candidate regions, PGDA LOLA 5 m site DEMs and Bertone et al. (2026) SfS DEMs.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7import sys 

8from pathlib import Path 

9 

10COMMAND = "reference" 

11 

12 

13def add_parser(sub: argparse._SubParsersAction) -> None: 

14 p = sub.add_parser( 

15 COMMAND, 

16 help="Reference data for the Artemis III regions: crop windows, PGDA LOLA 5 m DEMs, Bertone SfS DEMs.", 

17 formatter_class=argparse.RawDescriptionHelpFormatter, 

18 epilog=""" 

19Examples 

20-------- 

21 lunar reference list 

22 lunar reference show malapert # bbox, ROI projwin, PGDA site, Zenodo zip 

23 lunar reference crop 11 --crop-km 2.5 # 2.5 km window at the ROI centre 

24 lunar reference crop malapert --center -85.98 -0.26 --format wkt 

25 lunar reference pgda malapert --kind surf toterr -o ref 

26 lunar reference pgda malapert --kind clones --clones 10 -o ref 

27 lunar reference pgda malapert --kind xyzi --csv --crop-km 2.5 -o ref 

28 lunar reference sdem malapert --list # zip members (remote central directory) 

29 lunar reference sdem malapert --remote --member GLDELEV GLDSIGM -o ref 

30 lunar reference sdem malapert --download -o ref --member GLDELEV 

31 

32REGION is an id (0-12), name, slug, unique part of a name or PGDA site 

33(11, "Malapert Massif", malapert-massif, malapert, Site23). The ROI is the 

34polar stereographic square the SfS DEM covers (= the PGDA site extent); 

35projwin is ULX ULY LRX LRY in metres (+proj=stere +lat_0=-90 +R=1737400). 

36Existing files are skipped (--force to redo); downloads resume. 

37""", 

38 ) 

39 sp = p.add_subparsers(dest="ref_cmd", required=True) 

40 

41 sp.add_parser("list", help="List the 13 regions.").add_argument( 

42 "--json", action="store_true", help="Print JSON instead of a table." 

43 ) 

44 

45 show = sp.add_parser("show", help="Show one region.") 

46 show.add_argument("region", metavar="REGION") 

47 show.add_argument("--crop-km", type=float, default=None, metavar="KM", help="Also print a crop window.") 

48 show.add_argument("--json", action="store_true", help="Print JSON.") 

49 

50 crop = sp.add_parser("crop", help="Print a square crop window centred on a region.") 

51 crop.add_argument("region", metavar="REGION") 

52 crop.add_argument("--crop-km", type=float, default=2.5, metavar="KM", help="Side length (default: 2.5).") 

53 crop.add_argument( 

54 "--center", nargs=2, type=float, metavar=("LAT", "LON"), help="Centre (default: ROI centre)." 

55 ) 

56 crop.add_argument( 

57 "--format", 

58 choices=("projwin", "bounds", "wkt", "geojson"), 

59 default="projwin", 

60 help="projwin (ULX ULY LRX LRY), bounds (XMIN YMIN XMAX YMAX), lon/lat WKT or GeoJSON.", 

61 ) 

62 

63 pg = sp.add_parser("pgda", help="Download a region's PGDA LOLA 5 m site files.") 

64 pg.add_argument("region", metavar="REGION") 

65 pg.add_argument( 

66 "--kind", 

67 nargs="+", 

68 default=["surf"], 

69 metavar="KIND", 

70 help="surf ldec toterr slp slperr xyzi clones rois (default: surf).", 

71 ) 

72 pg.add_argument("--clones", type=int, default=None, metavar="N", help="Only the first N clones.") 

73 pg.add_argument("--csv", action="store_true", help="Also write each .xyzi as an ASP CSV.") 

74 pg.add_argument("--crop-km", type=float, default=None, metavar="KM", help="Crop the --csv points.") 

75 pg.add_argument("--list", action="store_true", help="Print the URLs only.") 

76 pg.add_argument("--refresh", action="store_true", help="Re-read the PGDA product page.") 

77 _common(pg) 

78 

79 sd = sp.add_parser("sdem", help="List / download / extract a region's Bertone et al. Zenodo zip.") 

80 sd.add_argument("region", metavar="REGION") 

81 sd.add_argument( 

82 "--member", 

83 nargs="+", 

84 default=["GLDELEV"], 

85 metavar="PAT", 

86 help="Members to extract: product code / substring or glob (default: GLDELEV).", 

87 ) 

88 mode = sd.add_mutually_exclusive_group() 

89 mode.add_argument("--list", action="store_true", help="List zip members (reads only the directory).") 

90 mode.add_argument("--remote", action="store_true", help="Extract members remotely (no full zip).") 

91 mode.add_argument( 

92 "--download", action="store_true", help="Download the whole zip (1-4 GB), then extract." 

93 ) 

94 sd.add_argument("--zip", type=Path, default=None, metavar="ZIP", help="Extract from this local zip.") 

95 _common(sd) 

96 

97 

98def _common(p: argparse.ArgumentParser) -> None: 

99 p.add_argument( 

100 "--output-dir", "-o", type=Path, default=Path("."), metavar="DIR", help="Destination (default: .)." 

101 ) 

102 p.add_argument("--force", action="store_true", help="Download / extract again even if present.") 

103 

104 

105def _region_info(reg, crop_km: float | None = None) -> dict: 

106 from .. import reference as ref 

107 

108 roi = reg.roi 

109 info = { 

110 "id": reg.id, 

111 "name": reg.name, 

112 "slug": reg.slug, 

113 "center_lonlat": reg.center, 

114 "bbox": reg.bbox, 

115 "roi_projwin": roi.projwin, 

116 "roi_size_km": (roi.width / 1000.0, roi.height / 1000.0), 

117 "bbox_projwin": tuple(round(v, 1) for v in reg.bbox_projwin), 

118 "pgda_site": reg.pgda_site, 

119 "pgda_url": ref.PGDA_SITES[reg.pgda_site].url if reg.pgda_site else None, 

120 "zenodo_file": reg.zenodo_file, 

121 "zenodo_size_gb": round(reg.zenodo_size / 1e9, 2), 

122 "zenodo_md5": reg.zenodo_md5, 

123 "zenodo_url": reg.zenodo_url, 

124 } 

125 if crop_km: 125 ↛ 127line 125 didn't jump to line 127 because the condition on line 125 was always true

126 info["crop_projwin"] = ref.crop_window(reg, crop_km).projwin 

127 return info 

128 

129 

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

131 from .. import reference as ref 

132 

133 try: 

134 if args.ref_cmd == "list": 

135 return _list(args) 

136 reg = ref.get_region(args.region) 

137 if args.ref_cmd == "show": 

138 info = _region_info(reg, args.crop_km) 

139 if args.json: 139 ↛ 142line 139 didn't jump to line 142 because the condition on line 139 was always true

140 print(json.dumps(info, indent=2)) 

141 else: 

142 for k, v in info.items(): 

143 print(f"{k:15s} {' '.join(f'{x:g}' for x in v) if isinstance(v, tuple) else v}") 

144 return 0 

145 if args.ref_cmd == "crop": 

146 return _crop(args, reg) 

147 if args.ref_cmd == "pgda": 

148 return _pgda(args, reg) 

149 return _sdem(args, reg) 

150 except (KeyError, ValueError) as exc: 

151 print(f"Error: {exc.args[0] if exc.args else exc}", file=sys.stderr) 

152 return 1 

153 

154 

155def _list(args: argparse.Namespace) -> int: 

156 from .. import reference as ref 

157 

158 if args.json: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 print(json.dumps([_region_info(r) for r in ref.REGIONS], indent=2)) 

160 return 0 

161 print(f"{'id':>2} {'name':27s} {'pgda':9s} {'zip GB':>6} roi projwin (ULX ULY LRX LRY, m)") 

162 for r in ref.REGIONS: 

163 pw = " ".join(f"{v:.0f}" for v in r.projwin) 

164 print(f"{r.id:>2} {r.name:27s} {r.pgda_site or '-':9s} {r.zenodo_size / 1e9:6.2f} {pw}") 

165 return 0 

166 

167 

168def _crop(args: argparse.Namespace, reg) -> int: 

169 from .. import reference as ref 

170 

171 box = ref.crop_window(reg, args.crop_km, tuple(args.center) if args.center else None) 

172 if args.format == "projwin": 

173 print(" ".join(f"{v:.3f}".rstrip("0").rstrip(".") for v in box.projwin)) 

174 elif args.format == "bounds": 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true

175 print(" ".join(f"{v:.3f}".rstrip("0").rstrip(".") for v in box.bounds)) 

176 elif args.format == "wkt": 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 print(box.wkt()) 

178 else: 

179 print(json.dumps({"type": "Polygon", "coordinates": [box.ring_lonlat()]})) 

180 return 0 

181 

182 

183def _pgda(args: argparse.Namespace, reg) -> int: 

184 from .. import reference as ref 

185 

186 index = ref.pgda_index(refresh=args.refresh) 

187 if args.list: 187 ↛ 192line 187 didn't jump to line 192 because the condition on line 187 was always true

188 for kind, urls in ref.pgda_urls(reg, args.kind, args.clones, index).items(): 

189 for u in urls: 

190 print(f"{kind:7s} {u}") 

191 return 0 

192 paths = ref.download_pgda(reg, args.kind, args.output_dir, args.clones, args.force, index=index) 

193 for kind, files in paths.items(): 

194 for f in files: 

195 print(f"{kind:7s} {f}") 

196 if args.csv: 

197 box = ref.crop_window(reg, args.crop_km) if args.crop_km else None 

198 for f in paths.get("xyzi", []): 

199 out = f.with_suffix(f"{'_crop' if box else ''}.csv") 

200 ref.write_asp_csv(f, out, box) 

201 print(f"csv {out} (--csv-format '{ref.ASP_CSV_FORMAT}' --csv-srs '{ref.PS_SRS}')") 

202 return 0 

203 

204 

205def _sdem(args: argparse.Namespace, reg) -> int: 

206 from .. import reference as ref 

207 

208 if args.list: 

209 members = ref.sdem_members(args.zip or reg) 

210 for row in members.itertuples(): 

211 print(f"{row.size:>12,} {row.name}") 

212 print(f"{len(members)} members, {members['size'].sum() / 1e9:.2f} GB uncompressed") 

213 return 0 

214 if args.remote: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 source = reg 

216 elif args.zip: 

217 source = args.zip 

218 elif args.download: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true

219 source = ref.download_sdem(reg, args.output_dir, force=args.force) 

220 else: 

221 local = args.output_dir / reg.zenodo_file 

222 if not local.exists(): 222 ↛ 229line 222 didn't jump to line 229 because the condition on line 222 was always true

223 print( 

224 f"{local} not found: pass --download (whole zip, {reg.zenodo_size / 1e9:.1f} GB), " 

225 "--remote (selected members only) or --zip", 

226 file=sys.stderr, 

227 ) 

228 return 1 

229 source = local 

230 out_dir = args.output_dir / reg.zenodo_file.removesuffix(".zip") 

231 for path in ref.extract_sdem(source, args.member, out_dir, force=args.force): 

232 print(path) 

233 return 0