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

1"""ode-search - search ShadowCam / LROC / Kaguya TC products via ODE REST.""" 

2 

3import argparse 

4import sys 

5from pathlib import Path 

6 

7COMMAND = "ode-search" 

8 

9 

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

11 from ..ode import INSTRUMENTS 

12 

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 

27 

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 ) 

102 

103 

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

105 from ..ode import OdeClient, OdeError 

106 from ._utils import ode_filters, region_args 

107 

108 region, filters = region_args(args), ode_filters(args) 

109 

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 

119 

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