Coverage for lunar_toolkit/cli/ode_search.py: 45%
52 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"""ode-search - search ShadowCam / LROC / Kaguya TC products via ODE REST."""
3import argparse
4import sys
5from pathlib import Path
7COMMAND = "ode-search"
10def add_parser(sub: argparse._SubParsersAction) -> None:
11 from ..ode import INSTRUMENTS
13 types = "; ".join(f"{k}: {', '.join(v['product_types'])}" for k, v in INSTRUMENTS.items())
14 p = sub.add_parser(
15 COMMAND,
16 help="Search lunar products (ShadowCam, LROC NAC, Kaguya TC) via the ODE REST API.",
17 formatter_class=argparse.RawDescriptionHelpFormatter,
18 epilog=f"""
19Examples
20--------
21 lunar ode-search --instrument shadowcam --bbox 0 -90 360 -89 --inc 80 90
22 lunar ode-search --instrument shadowcam --point -89.67 129.8 --radius-km 15
23 lunar ode-search --instrument lroc --product-type cdr --bbox 355 -1 5 1 --csv hits.csv
24 lunar ode-search --instrument shadowcam --point -89.67 129.8 --radius-km 5 \\
25 --download ./shadowcam --kind product
26 lunar ode-search --instrument shadowcam --aoi aoi.geojson # QuickMap polygon
28Product types - {types}
29(raw ODE pt codes such as EDRNAC4 are accepted too).
30--bbox is WEST SOUTH EAST NORTH, longitudes positive east; WEST > EAST crosses 0/360.
31--aoi takes a GeoJSON / WKT file or a WKT string; ODE is queried with its bounding
32box and footprints are then intersected with the polygon.
33""",
34 )
35 p.add_argument(
36 "--instrument",
37 "-i",
38 default="shadowcam",
39 help=f"Instrument: {', '.join(INSTRUMENTS)} (default: shadowcam).",
40 )
41 p.add_argument(
42 "--product-type",
43 "-t",
44 default=None,
45 metavar="PT",
46 help="Product type alias or ODE pt code (default: instrument's default).",
47 )
48 region = p.add_mutually_exclusive_group()
49 region.add_argument(
50 "--bbox",
51 nargs=4,
52 type=float,
53 metavar=("WEST", "SOUTH", "EAST", "NORTH"),
54 help="Bounding box in degrees.",
55 )
56 region.add_argument(
57 "--point",
58 nargs=2,
59 type=float,
60 metavar=("LAT", "LON"),
61 help="Search around a point (use with --radius-km).",
62 )
63 region.add_argument(
64 "--aoi", metavar="FILE_OR_WKT", help="Area of interest: GeoJSON / WKT file or WKT polygon string."
65 )
66 p.add_argument(
67 "--radius-km", type=float, default=1.0, metavar="KM", help="Radius for --point (default: 1 km)."
68 )
69 p.add_argument("--inc", nargs=2, type=float, metavar=("MIN", "MAX"), help="Incidence angle range.")
70 p.add_argument("--emi", nargs=2, type=float, metavar=("MIN", "MAX"), help="Emission angle range.")
71 p.add_argument("--phase", nargs=2, type=float, metavar=("MIN", "MAX"), help="Phase angle range.")
72 p.add_argument(
73 "--time",
74 nargs=2,
75 metavar=("START", "END"),
76 help="Observation time range (UTC, e.g. 2023-01-01 2023-12-31).",
77 )
78 p.add_argument(
79 "--limit",
80 type=int,
81 default=200,
82 metavar="N",
83 help="Maximum number of products to return (default: 200).",
84 )
85 p.add_argument("--count", action="store_true", help="Only print the number of matches.")
86 p.add_argument("--csv", metavar="FILE", help="Write results (common schema) to CSV.")
87 p.add_argument("--download", metavar="DIR", help="Download the selected files to DIR.")
88 p.add_argument(
89 "--kind",
90 default="product",
91 help="Files to download: product, label, browse, referenced, derived, all.",
92 )
93 p.add_argument(
94 "--match",
95 default=None,
96 metavar="TEXT",
97 help="Only download files whose name contains TEXT (e.g. _cog.tif).",
98 )
99 p.add_argument(
100 "--workers", type=int, default=4, metavar="N", help="Parallel download workers (default: 4)."
101 )
104def run(args: argparse.Namespace) -> int:
105 from ..ode import OdeClient, OdeError
106 from ._utils import ode_filters, region_args
108 region, filters = region_args(args), ode_filters(args)
110 client = OdeClient()
111 try:
112 if args.count:
113 print(client.count(args.instrument, args.product_type, **region, **filters))
114 return 0
115 df = client.search(args.instrument, args.product_type, max_results=args.limit, **region, **filters)
116 except (OdeError, ValueError) as exc:
117 print(f"Error: {exc}", file=sys.stderr)
118 return 1
120 print(f"{len(df)} product(s)")
121 if not df.empty:
122 cols = [
123 "product_id",
124 "product_type",
125 "start_time",
126 "center_lat",
127 "center_lon",
128 "incidence",
129 "emission",
130 "phase",
131 "pixel_scale",
132 ]
133 print(df[cols].to_string(index=False, max_rows=50))
134 if args.csv:
135 df.drop(columns=["files"]).to_csv(args.csv, index=False)
136 print(f"Wrote {args.csv}")
137 if args.download and not df.empty:
138 results = client.download(
139 df, Path(args.download), kind=args.kind, match=args.match, workers=args.workers
140 )
141 failed = sum(1 for p in results.values() if p is None)
142 print(f"Downloaded {len(results) - failed}/{len(results)} file(s).")
143 return 1 if failed else 0
144 return 0