Satellite pass prediction and contact window computation
0
fork

Configure Feed

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

Add contact CLI with tty.Table output

Two commands:
contact predict --tle <file> --lat 34.05 --lon=-118.25 --days 3
contact next --tle <file> --lat 34.05 --lon=-118.25

Produces a formatted table with AOS, LOS, max elevation, and duration.
Validated against GMAT: 15 passes vs GMAT's 14, AOS times within 30s.

+185
+4
bin/dune
··· 1 + (executable 2 + (name main) 3 + (public_name contact) 4 + (libraries contact sgp4 coordinate tty fmt cmdliner ptime))
+181
bin/main.ml
··· 1 + (** contact CLI — predict satellite passes over a ground station. 2 + 3 + Usage: contact predict --tle <file> --lat 34.05 --lon -118.25 --days 3 4 + contact predict --norad 25544 --lat 34.05 --lon -118.25 contact next --tle 5 + <file> --lat 34.05 --lon -118.25 *) 6 + 7 + open Cmdliner 8 + 9 + (* ── Helpers ────────────────────────────────────────────────────────── *) 10 + 11 + let read_file path = 12 + let ic = open_in path in 13 + let n = in_channel_length ic in 14 + let s = Bytes.create n in 15 + really_input ic s 0 n; 16 + close_in ic; 17 + Bytes.to_string s 18 + 19 + let parse_tle path = 20 + let s = read_file path in 21 + match Sgp4.parse_tle_string s with 22 + | Ok tle -> tle 23 + | Error e -> Fmt.failwith "TLE parse error: %a" Sgp4.pp_error e 24 + 25 + let format_time unix_t = 26 + match Ptime.of_float_s unix_t with 27 + | Some t -> 28 + let (y, m, d), ((hh, mm, ss), _) = Ptime.to_date_time ~tz_offset_s:0 t in 29 + Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d UTC" y m d hh mm ss 30 + | None -> Printf.sprintf "%.0f" unix_t 31 + 32 + let format_duration secs = 33 + let m = int_of_float secs / 60 in 34 + let s = int_of_float secs mod 60 in 35 + Printf.sprintf "%dm%02ds" m s 36 + 37 + let el_bar max_el = 38 + let n = int_of_float (max_el /. 90.0 *. 20.0) in 39 + String.make (max 1 n) '#' 40 + 41 + (* ── Predict command ────────────────────────────────────────────────── *) 42 + 43 + let predict_cmd tle_path lat lon alt days min_el = 44 + let tle = parse_tle tle_path in 45 + let gs = Contact.ground_station ~lat ~lon ~alt in 46 + let passes = 47 + Contact.predict ~min_elevation:min_el tle gs ~duration_days:days 48 + in 49 + if passes = [] then 50 + Fmt.pr "No passes found above %.0f deg in %.0f days.@." min_el days 51 + else begin 52 + let open Tty.Table in 53 + let table = 54 + v 55 + [ 56 + column ~align:`Left "AOS (UTC)" ~min_width:20; 57 + column ~align:`Left "LOS (UTC)" ~min_width:20; 58 + column ~align:`Right "Max El" ~min_width:7; 59 + column ~align:`Right "Duration" ~min_width:8; 60 + ] 61 + in 62 + let table = 63 + List.fold_left 64 + (fun t (p : Contact.pass) -> 65 + add_row_strings 66 + [ 67 + format_time p.aos_time; 68 + format_time p.los_time; 69 + Printf.sprintf "%.1f" p.max_elevation; 70 + format_duration p.duration; 71 + ] 72 + t) 73 + table passes 74 + in 75 + Fmt.pr "@."; 76 + render Format.std_formatter table; 77 + Fmt.pr "@.%d passes (%.2fN %.2fE, %.0f days, >%.0f deg)@." 78 + (List.length passes) lat lon days min_el 79 + end 80 + 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 88 + let lat = 89 + Arg.( 90 + required 91 + & opt (some float) None 92 + & info [ "lat" ] ~docv:"DEG" 93 + ~doc:"Ground station latitude (degrees, positive north).") 94 + in 95 + let lon = 96 + Arg.( 97 + required 98 + & opt (some float) None 99 + & info [ "lon" ] ~docv:"DEG" 100 + ~doc:"Ground station longitude (degrees, positive east).") 101 + in 102 + let alt = 103 + Arg.( 104 + value & opt float 0.0 105 + & info [ "alt" ] ~docv:"KM" 106 + ~doc:"Ground station altitude in km (default: 0).") 107 + in 108 + let days = 109 + Arg.( 110 + value & opt float 3.0 111 + & info [ "days"; "d" ] ~docv:"DAYS" 112 + ~doc:"Prediction window in days (default: 3).") 113 + in 114 + let min_el = 115 + Arg.( 116 + value & opt float 5.0 117 + & info [ "min-el" ] ~docv:"DEG" 118 + ~doc:"Minimum peak elevation in degrees (default: 5).") 119 + in 120 + let doc = "Predict satellite passes over a ground station." in 121 + let info = Cmd.info "predict" ~doc in 122 + Cmd.v info 123 + Term.(const predict_cmd $ tle_path $ lat $ lon $ alt $ days $ min_el) 124 + 125 + (* ── Next command ───────────────────────────────────────────────────── *) 126 + 127 + let next_cmd tle_path lat lon alt min_el = 128 + let tle = parse_tle tle_path in 129 + let gs = Contact.ground_station ~lat ~lon ~alt in 130 + let passes = 131 + Contact.predict ~min_elevation:min_el tle gs ~duration_days:7.0 132 + in 133 + match passes with 134 + | [] -> Fmt.pr "No passes found in the next 7 days.@." 135 + | p :: _ -> 136 + Fmt.pr "Next pass:@."; 137 + Fmt.pr " AOS: %s@." (format_time p.aos_time); 138 + Fmt.pr " LOS: %s@." (format_time p.los_time); 139 + Fmt.pr " Max El: %.1f deg@." p.max_elevation; 140 + Fmt.pr " Dur: %s@." (format_duration p.duration) 141 + 142 + let next_term = 143 + let tle_path = 144 + Arg.( 145 + required 146 + & opt (some string) None 147 + & info [ "tle" ] ~docv:"FILE" ~doc:"Path to TLE file.") 148 + in 149 + let lat = 150 + Arg.( 151 + required 152 + & opt (some float) None 153 + & info [ "lat" ] ~docv:"DEG" ~doc:"Ground station latitude.") 154 + in 155 + let lon = 156 + Arg.( 157 + required 158 + & opt (some float) None 159 + & info [ "lon" ] ~docv:"DEG" ~doc:"Ground station longitude.") 160 + in 161 + let alt = 162 + Arg.( 163 + value & opt float 0.0 164 + & info [ "alt" ] ~docv:"KM" ~doc:"Ground station altitude in km.") 165 + in 166 + let min_el = 167 + Arg.( 168 + value & opt float 5.0 169 + & info [ "min-el" ] ~docv:"DEG" ~doc:"Minimum peak elevation.") 170 + in 171 + let doc = "Show the next satellite pass." in 172 + let info = Cmd.info "next" ~doc in 173 + Cmd.v info Term.(const next_cmd $ tle_path $ lat $ lon $ alt $ min_el) 174 + 175 + (* ── Main ───────────────────────────────────────────────────────────── *) 176 + 177 + let () = 178 + let doc = "Satellite pass prediction." in 179 + let info = Cmd.info "contact" ~doc ~version:"0.1.0" in 180 + let cmd = Cmd.group info [ predict_term; next_term ] in 181 + exit (Cmd.eval cmd)