Satellite pass prediction and contact window computation
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add --here, --location CITY, and Arg.enum to contact CLI

Location can be specified three ways:
--lat 34.05 --lon=-118.25 (coordinates)
--location la (city preset via Arg.enum)
--here (auto-detect via IP geolocation)

Known cities: la, sf, nyc, london, paris, tokyo, ksc, kourou,
baikonur, bangalore, sydney. Cmdliner provides tab completion
and error messages for invalid city names.

+158 -44
+158 -44
bin/main.ml
··· 6 6 7 7 open Cmdliner 8 8 9 + (* ── Location ─────────────────────────────────────────────────────── *) 10 + 11 + type city = 12 + | LA 13 + | SF 14 + | NYC 15 + | London 16 + | Paris 17 + | Tokyo 18 + | KSC 19 + | Kourou 20 + | Baikonur 21 + | Bangalore 22 + | Sydney 23 + 24 + type location = 25 + | Here 26 + | City of city 27 + | Coordinates of { lat : float; lon : float } 28 + 29 + let city_coords = function 30 + | LA -> (34.05, -118.25, "Los Angeles") 31 + | SF -> (37.77, -122.42, "San Francisco") 32 + | NYC -> (40.71, -74.01, "New York") 33 + | London -> (51.51, -0.13, "London") 34 + | Paris -> (48.86, 2.35, "Paris") 35 + | Tokyo -> (35.68, 139.69, "Tokyo") 36 + | KSC -> (28.57, -80.65, "Kennedy Space Center") 37 + | Kourou -> (5.24, -52.77, "Guiana Space Centre") 38 + | Baikonur -> (45.96, 63.31, "Baikonur") 39 + | Bangalore -> (12.97, 77.59, "Bangalore") 40 + | Sydney -> (-33.87, 151.21, "Sydney") 41 + 42 + (* ── Location resolution ───────────────────────────────────────────── *) 43 + 44 + let geolocate_ip () = 45 + let ic = 46 + Unix.open_process_in 47 + "curl -sf 'http://ip-api.com/json/?fields=lat,lon,city' 2>/dev/null" 48 + in 49 + let s = try input_line ic with End_of_file -> "" in 50 + let _ = Unix.close_process_in ic in 51 + (* Minimal JSON parsing: {"city":"X","lat":N,"lon":N} *) 52 + let find_float key = 53 + match String.split_on_char '"' s |> List.find_index (fun x -> x = key) with 54 + | Some i -> ( 55 + let after = String.split_on_char '"' s in 56 + let rec nth n = function 57 + | [] -> None 58 + | x :: rest -> if n = 0 then Some x else nth (n - 1) rest 59 + in 60 + match nth (i + 1) after with 61 + | Some chunk -> 62 + (* chunk is like ":34.029," — extract the number *) 63 + let num = 64 + String.to_seq chunk 65 + |> Seq.filter (fun c -> 66 + (c >= '0' && c <= '9') || c = '.' || c = '-') 67 + |> String.of_seq 68 + in 69 + float_of_string_opt num 70 + | None -> None) 71 + | None -> None 72 + in 73 + let find_string key = 74 + match String.split_on_char '"' s |> List.find_index (fun x -> x = key) with 75 + | Some i -> 76 + let parts = String.split_on_char '"' s in 77 + let rec nth n = function 78 + | [] -> None 79 + | x :: rest -> if n = 0 then Some x else nth (n - 1) rest 80 + in 81 + nth (i + 2) parts 82 + | None -> None 83 + in 84 + match (find_float "lat", find_float "lon") with 85 + | Some lat, Some lon -> 86 + let city = Option.value ~default:"Unknown" (find_string "city") in 87 + Some (lat, lon, city) 88 + | _ -> None 89 + 90 + let resolve_location = function 91 + | Coordinates { lat; lon } -> (lat, lon) 92 + | City c -> 93 + let la, lo, name = city_coords c in 94 + Fmt.pr "Using %s (%.2fN, %.2fE)@." name la lo; 95 + (la, lo) 96 + | Here -> ( 97 + match geolocate_ip () with 98 + | Some (la, lo, name) -> 99 + Fmt.pr "Detected: %s (%.4fN, %.4fE)@." name la lo; 100 + (la, lo) 101 + | None -> Fmt.failwith "Could not detect location (needs curl + network)") 102 + 9 103 (* ── Helpers ────────────────────────────────────────────────────────── *) 10 104 11 105 let read_file path = ··· 40 134 41 135 (* ── Predict command ────────────────────────────────────────────────── *) 42 136 43 - let predict_cmd tle_path lat lon alt days min_el () = 137 + let predict_cmd tle_path loc alt days min_el () = 138 + let lat, lon = resolve_location loc in 44 139 let tle = parse_tle tle_path in 45 140 let gs = Contact.ground_station ~lat ~lon ~alt in 46 141 let passes = ··· 78 173 (List.length passes) lat lon days min_el 79 174 end 80 175 81 - let predict_term = 82 - let tle_path = 83 - Arg.( 84 - required 85 - & opt (some string) None 86 - & info [ "tle" ] ~docv:"FILE" ~doc:"Path to TLE file.") 87 - in 176 + let city_enum = 177 + Arg.enum 178 + [ 179 + ("la", LA); 180 + ("sf", SF); 181 + ("nyc", NYC); 182 + ("london", London); 183 + ("paris", Paris); 184 + ("tokyo", Tokyo); 185 + ("ksc", KSC); 186 + ("kourou", Kourou); 187 + ("baikonur", Baikonur); 188 + ("bangalore", Bangalore); 189 + ("sydney", Sydney); 190 + ] 191 + 192 + let location_term : location Term.t = 88 193 let lat = 89 194 Arg.( 90 - required 195 + value 91 196 & opt (some float) None 92 - & info [ "lat" ] ~docv:"DEG" 93 - ~doc:"Ground station latitude (degrees, positive north).") 197 + & info [ "lat" ] ~docv:"DEG" ~doc:"Latitude (degrees north).") 94 198 in 95 199 let lon = 96 200 Arg.( 201 + value 202 + & opt (some float) None 203 + & info [ "lon" ] ~docv:"DEG" ~doc:"Longitude (degrees east).") 204 + in 205 + let here = 206 + Arg.(value & flag & info [ "here" ] ~doc:"Auto-detect via IP geolocation.") 207 + in 208 + let city = 209 + Arg.( 210 + value 211 + & opt (some city_enum) None 212 + & info [ "location"; "l" ] ~docv:"CITY" ~doc:"Known city.") 213 + in 214 + let combine lat lon here city = 215 + match (lat, lon, here, city) with 216 + | Some la, Some lo, _, _ -> Coordinates { lat = la; lon = lo } 217 + | _, _, _, Some c -> City c 218 + | _, _, true, _ -> Here 219 + | _ -> Fmt.failwith "Specify --lat/--lon, --location CITY, or --here" 220 + in 221 + Term.(const combine $ lat $ lon $ here $ city) 222 + 223 + let predict_term = 224 + let tle_path = 225 + Arg.( 97 226 required 98 - & opt (some float) None 99 - & info [ "lon" ] ~docv:"DEG" 100 - ~doc:"Ground station longitude (degrees, positive east).") 227 + & opt (some string) None 228 + & info [ "tle" ] ~docv:"FILE" ~doc:"TLE file.") 101 229 in 102 230 let alt = 103 231 Arg.( 104 232 value & opt float 0.0 105 - & info [ "alt" ] ~docv:"KM" 106 - ~doc:"Ground station altitude in km (default: 0).") 233 + & info [ "alt" ] ~docv:"KM" ~doc:"Altitude in km (default: 0).") 107 234 in 108 235 let days = 109 236 Arg.( 110 237 value & opt float 3.0 111 - & info [ "days"; "d" ] ~docv:"DAYS" 112 - ~doc:"Prediction window in days (default: 3).") 238 + & info [ "days"; "d" ] ~docv:"N" ~doc:"Days to predict (default: 3).") 113 239 in 114 240 let min_el = 115 241 Arg.( 116 242 value & opt float 5.0 117 - & info [ "min-el" ] ~docv:"DEG" 118 - ~doc:"Minimum peak elevation in degrees (default: 5).") 243 + & info [ "min-el" ] ~docv:"DEG" ~doc:"Min peak elevation (default: 5).") 244 + in 245 + let doc = 246 + "Predict satellite passes. Location via --lat/--lon, --location NAME, or \ 247 + --here." 119 248 in 120 - let doc = "Predict satellite passes over a ground station." in 121 - let info = Cmd.info "predict" ~doc in 122 - Cmd.v info 249 + Cmd.v (Cmd.info "predict" ~doc) 123 250 Term.( 124 - const predict_cmd $ tle_path $ lat $ lon $ alt $ days $ min_el 251 + const predict_cmd $ tle_path $ location_term $ alt $ days $ min_el 125 252 $ Vlog.setup "contact") 126 253 127 254 (* ── Next command ───────────────────────────────────────────────────── *) 128 255 129 - let next_cmd tle_path lat lon alt min_el () = 256 + let next_cmd tle_path loc alt min_el () = 257 + let lat, lon = resolve_location loc in 130 258 let tle = parse_tle tle_path in 131 259 let gs = Contact.ground_station ~lat ~lon ~alt in 132 260 let passes = ··· 146 274 Arg.( 147 275 required 148 276 & opt (some string) None 149 - & info [ "tle" ] ~docv:"FILE" ~doc:"Path to TLE file.") 150 - in 151 - let lat = 152 - Arg.( 153 - required 154 - & opt (some float) None 155 - & info [ "lat" ] ~docv:"DEG" ~doc:"Ground station latitude.") 156 - in 157 - let lon = 158 - Arg.( 159 - required 160 - & opt (some float) None 161 - & info [ "lon" ] ~docv:"DEG" ~doc:"Ground station longitude.") 277 + & info [ "tle" ] ~docv:"FILE" ~doc:"TLE file.") 162 278 in 163 279 let alt = 164 280 Arg.( 165 - value & opt float 0.0 166 - & info [ "alt" ] ~docv:"KM" ~doc:"Ground station altitude in km.") 281 + value & opt float 0.0 & info [ "alt" ] ~docv:"KM" ~doc:"Altitude in km.") 167 282 in 168 283 let min_el = 169 284 Arg.( 170 285 value & opt float 5.0 171 - & info [ "min-el" ] ~docv:"DEG" ~doc:"Minimum peak elevation.") 286 + & info [ "min-el" ] ~docv:"DEG" ~doc:"Min peak elevation.") 172 287 in 173 288 let doc = "Show the next satellite pass." in 174 - let info = Cmd.info "next" ~doc in 175 - Cmd.v info 289 + Cmd.v (Cmd.info "next" ~doc) 176 290 Term.( 177 - const next_cmd $ tle_path $ lat $ lon $ alt $ min_el 291 + const next_cmd $ tle_path $ location_term $ alt $ min_el 178 292 $ Vlog.setup "contact") 179 293 180 294 (* ── Main ───────────────────────────────────────────────────────────── *)