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
« 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."""
3from __future__ import annotations
5import argparse
6import json
7import sys
8from pathlib import Path
10COMMAND = "reference"
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
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)
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 )
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.")
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 )
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)
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)
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.")
105def _region_info(reg, crop_km: float | None = None) -> dict:
106 from .. import reference as ref
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
130def run(args: argparse.Namespace) -> int:
131 from .. import reference as ref
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
155def _list(args: argparse.Namespace) -> int:
156 from .. import reference as ref
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
168def _crop(args: argparse.Namespace, reg) -> int:
169 from .. import reference as ref
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
183def _pgda(args: argparse.Namespace, reg) -> int:
184 from .. import reference as ref
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
205def _sdem(args: argparse.Namespace, reg) -> int:
206 from .. import reference as ref
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