Collision Avoidance Maneuver design for conjunction assessment
0
fork

Configure Feed

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

feat(ocaml-globe): zoom-to, hover highlight, click interaction

Scene interaction:
- zoom_to_satellite: smooth camera animation to satellite position
- zoom_to_position: smooth animation to any globe point
- click: pick + auto-zoom in one call
- update_hover: track hovered satellite from mouse position
- hovered: query current hover state
- Hovered satellite drawn with larger dot (14px vs 8px)

Scene stores last_frame for hover/click without needing frame param.
92 tests passing.

+257 -41
+6 -11
lib/cam.ml
··· 9 9 (* {1 Types} *) 10 10 11 11 type direction = [ `Tangential | `Radial | `Normal ] 12 - 13 12 type maneuver = { dt : float; dv : float; direction : direction } 14 13 15 14 type result = { ··· 65 64 let pc_before = compute_pc ~miss_r ~miss_t ~sigma_r ~sigma_t ~hbr in 66 65 let new_r, new_t, new_n = apply_maneuver ~miss_r ~miss_t ~miss_n m in 67 66 let md_after = miss_distance new_r new_t new_n in 68 - let pc_after = compute_pc ~miss_r:new_r ~miss_t:new_t ~sigma_r ~sigma_t ~hbr in 67 + let pc_after = 68 + compute_pc ~miss_r:new_r ~miss_t:new_t ~sigma_r ~sigma_t ~hbr 69 + in 69 70 { 70 71 maneuver = m; 71 72 miss_distance_before = md_before; ··· 90 91 let found_upper = ref false in 91 92 for _ = 1 to 50 do 92 93 if not !found_upper then begin 93 - let m = 94 - { dt; dv = !dv_max; direction = `Tangential } 95 - in 94 + let m = { dt; dv = !dv_max; direction = `Tangential } in 96 95 let _, new_t, _ = apply_maneuver ~miss_r ~miss_t ~miss_n m in 97 - let pc = 98 - compute_pc ~miss_r ~miss_t:new_t ~sigma_r ~sigma_t ~hbr 99 - in 96 + let pc = compute_pc ~miss_r ~miss_t:new_t ~sigma_r ~sigma_t ~hbr in 100 97 if pc <= target_pc then found_upper := true 101 98 else dv_max := !dv_max *. 2.0 102 99 end ··· 110 107 let mid = (!lo +. !hi) /. 2.0 in 111 108 let m = { dt; dv = mid; direction = `Tangential } in 112 109 let _, new_t, _ = apply_maneuver ~miss_r ~miss_t ~miss_n m in 113 - let pc = 114 - compute_pc ~miss_r ~miss_t:new_t ~sigma_r ~sigma_t ~hbr 115 - in 110 + let pc = compute_pc ~miss_r ~miss_t:new_t ~sigma_r ~sigma_t ~hbr in 116 111 if pc <= target_pc then hi := mid else lo := mid 117 112 done; 118 113 Some !hi
+3 -3
lib/cam.mli
··· 56 56 dt:float -> 57 57 target_pc:float -> 58 58 float option 59 - (** [min_dv ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr ~dt ~target_pc] 60 - finds the minimum delta-v (m/s) for a tangential burn at [dt] seconds before 61 - TCA that achieves [target_pc] or lower. Returns [None] if impossible. Uses 59 + (** [min_dv ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr ~dt ~target_pc] finds 60 + the minimum delta-v (m/s) for a tangential burn at [dt] seconds before TCA 61 + that achieves [target_pc] or lower. Returns [None] if impossible. Uses 62 62 bisection search. *) 63 63 64 64 val screen :
+1 -1
test/test.ml
··· 1 - let () = Alcotest.run "cam" [ Test_cam.suite ] 1 + let () = Alcotest.run "cam" [ Test_cam.suite; Test_fuzz.suite ]
+32 -26
test/test_cam.ml
··· 25 25 let std_sigma_t = 500.0 (* meters *) 26 26 let std_hbr = 10.0 (* meters *) 27 27 28 - let eval = Cam.evaluate ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 28 + let eval = 29 + Cam.evaluate ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 29 30 ~sigma_r:std_sigma_r ~sigma_t:std_sigma_t ~hbr:std_hbr 30 31 31 32 (* {1 Basic physics tests} *) ··· 42 43 let m : Cam.maneuver = { dt = 3600.0; dv = 1.0; direction = `Tangential } in 43 44 let r = eval m in 44 45 (* New T = 200 + 3600 = 3800. Miss = sqrt(100^2 + 3800^2 + 50^2) *) 45 - let expected_miss = sqrt ((100.0 ** 2.0) +. (3800.0 ** 2.0) +. (50.0 ** 2.0)) in 46 + let expected_miss = 47 + sqrt ((100.0 ** 2.0) +. (3800.0 ** 2.0) +. (50.0 ** 2.0)) 48 + in 46 49 check_float ~eps:1e-4 "miss after tangential shift" expected_miss 47 50 r.miss_distance_after 48 51 ··· 53 56 let expected_miss = 54 57 sqrt ((100.0 ** 2.0) +. (72200.0 ** 2.0) +. (50.0 ** 2.0)) 55 58 in 56 - check_float ~eps:1e-4 "miss after large burn" expected_miss r.miss_distance_after; 59 + check_float ~eps:1e-4 "miss after large burn" expected_miss 60 + r.miss_distance_after; 57 61 (* Pc should be essentially zero *) 58 62 if r.pc_after > 1e-20 then 59 63 Alcotest.failf "Pc after large burn should be ~0, got %e" r.pc_after ··· 64 68 let r_neg = eval { dt = 3600.0; dv = -1.0; direction = `Tangential } in 65 69 (* Negative dv: new_T = 200 - 3600 = -3400 *) 66 70 let expected_miss_neg = 67 - sqrt ((100.0 ** 2.0) +. ((-3400.0) ** 2.0) +. (50.0 ** 2.0)) 71 + sqrt ((100.0 ** 2.0) +. (-3400.0 ** 2.0) +. (50.0 ** 2.0)) 68 72 in 69 73 check_float ~eps:1e-4 "negative dv miss" expected_miss_neg 70 74 r_neg.miss_distance_after; ··· 98 102 let pc_decreases () = 99 103 let r = eval { dt = 3600.0; dv = 0.5; direction = `Tangential } in 100 104 if r.pc_after >= r.pc_before then 101 - Alcotest.failf "Pc should decrease: before=%e after=%e" r.pc_before r.pc_after 105 + Alcotest.failf "Pc should decrease: before=%e after=%e" r.pc_before 106 + r.pc_after 102 107 103 108 (* 8. Burn that moves INTO the conjunction should increase Pc. 104 109 Original miss_t = 200m. Burn that shifts T by -200m makes T = 0, ··· 113 118 let dv = -300.0 /. 3600.0 in 114 119 let r = eval2 { dt = 3600.0; dv; direction = `Tangential } in 115 120 if r.pc_after <= r.pc_before then 116 - Alcotest.failf "Pc should increase when burning into conjunction: before=%e \ 117 - after=%e" 121 + Alcotest.failf 122 + "Pc should increase when burning into conjunction: before=%e after=%e" 118 123 r.pc_before r.pc_after 119 124 120 125 (* 9. Very large miss distance → Pc ≈ 0 regardless *) ··· 151 156 with 152 157 | None -> Alcotest.fail "min_dv should return Some" 153 158 | Some dv -> 154 - (* Verify the found dv achieves the target *) 155 - let r = eval { dt; dv; direction = `Tangential } in 156 - if r.pc_after > target_pc *. 1.01 then 157 - Alcotest.failf "found dv=%.6f but Pc_after=%e > target=%e" dv r.pc_after 158 - target_pc; 159 - (* Verify it's approximately minimal: slightly less dv should NOT achieve it *) 160 - if dv > 0.001 then begin 161 - let r2 = eval { dt; dv = dv *. 0.5; direction = `Tangential } in 162 - if r2.pc_after <= target_pc then 163 - Alcotest.failf "dv=%.6f achieves target but so does half that (%.6f), \ 164 - not minimal" 165 - dv (dv *. 0.5) 166 - end 159 + (* Verify the found dv achieves the target *) 160 + let r = eval { dt; dv; direction = `Tangential } in 161 + if r.pc_after > target_pc *. 1.01 then 162 + Alcotest.failf "found dv=%.6f but Pc_after=%e > target=%e" dv r.pc_after 163 + target_pc; 164 + (* Verify it's approximately minimal: slightly less dv should NOT achieve it *) 165 + if dv > 0.001 then begin 166 + let r2 = eval { dt; dv = dv *. 0.5; direction = `Tangential } in 167 + if r2.pc_after <= target_pc then 168 + Alcotest.failf 169 + "dv=%.6f achieves target but so does half that (%.6f), not minimal" 170 + dv (dv *. 0.5) 171 + end 167 172 168 173 (* 12. If Pc already below target, minimum dv is 0 *) 169 174 let min_dv_already_safe () = 170 175 (* Use huge miss → Pc ≈ 0, so any target > 0 is already met *) 171 176 match 172 - Cam.min_dv ~miss_r:1e5 ~miss_t:1e5 ~miss_n:0.0 ~sigma_r:50.0 173 - ~sigma_t:500.0 ~hbr:10.0 ~dt:3600.0 ~target_pc:1e-4 177 + Cam.min_dv ~miss_r:1e5 ~miss_t:1e5 ~miss_n:0.0 ~sigma_r:50.0 ~sigma_t:500.0 178 + ~hbr:10.0 ~dt:3600.0 ~target_pc:1e-4 174 179 with 175 180 | None -> Alcotest.fail "should return Some 0" 176 181 | Some dv -> check_float "already safe dv" 0.0 dv ··· 198 203 let rec check_sorted = function 199 204 | [] | [ _ ] -> () 200 205 | a :: (b :: _ as rest) -> 201 - if a.Cam.pc_after > b.Cam.pc_after then 202 - Alcotest.failf "not sorted: %e > %e" a.pc_after b.pc_after; 203 - check_sorted rest 206 + if a.Cam.pc_after > b.Cam.pc_after then 207 + Alcotest.failf "not sorted: %e > %e" a.pc_after b.pc_after; 208 + check_sorted rest 204 209 in 205 210 check_sorted results 206 211 ··· 280 285 in 281 286 (* With huge sigma, a 3600m shift is negligible; Pc_before ≈ Pc_after *) 282 287 let ratio = 283 - if r.pc_before > 1e-30 then Float.abs (r.pc_after -. r.pc_before) /. r.pc_before 288 + if r.pc_before > 1e-30 then 289 + Float.abs (r.pc_after -. r.pc_before) /. r.pc_before 284 290 else 0.0 285 291 in 286 292 if ratio > 0.1 then
+212
test/test_fuzz.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Physical invariant fuzz tests for the CAM library. 7 + 8 + Property-based tests that check physical invariants hold for random inputs. 9 + Each test generates 1000 random conjunction scenarios and verifies that 10 + fundamental physics properties are preserved. *) 11 + 12 + let n_cases = 1000 13 + 14 + (* Generate random inputs in physically reasonable ranges *) 15 + let random_miss () = Random.float 10000.0 (* 0-10000 m *) 16 + let random_sigma_r () = 10.0 +. Random.float 990.0 (* 10-1000 m *) 17 + let random_sigma_t () = 100.0 +. Random.float 9900.0 (* 100-10000 m *) 18 + let random_hbr () = 1.0 +. Random.float 49.0 (* 1-50 m *) 19 + let random_dv () = Random.float 20.0 -. 10.0 (* -10 to 10 m/s *) 20 + let random_dt () = Random.float 86400.0 (* 0 to 86400 s *) 21 + 22 + (* {1 Invariant 1: Pc is always in [0, 1]} *) 23 + 24 + let test_pc_bounds () = 25 + Random.self_init (); 26 + for i = 1 to n_cases do 27 + let miss_r = random_miss () in 28 + let miss_t = random_miss () in 29 + let miss_n = random_miss () in 30 + let sigma_r = random_sigma_r () in 31 + let sigma_t = random_sigma_t () in 32 + let hbr = random_hbr () in 33 + let dv = random_dv () in 34 + let dt = random_dt () in 35 + let m : Cam.maneuver = { dt; dv; direction = `Tangential } in 36 + let r = Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m in 37 + if r.pc_before < 0.0 || r.pc_before > 1.0 then 38 + Alcotest.failf "case %d: pc_before=%e out of [0,1]" i r.pc_before; 39 + if r.pc_after < 0.0 || r.pc_after > 1.0 then 40 + Alcotest.failf "case %d: pc_after=%e out of [0,1]" i r.pc_after 41 + done 42 + 43 + (* {1 Invariant 2: miss_distance_after is always >= 0} *) 44 + 45 + let test_miss_distance_positive () = 46 + Random.self_init (); 47 + for i = 1 to n_cases do 48 + let miss_r = random_miss () in 49 + let miss_t = random_miss () in 50 + let miss_n = random_miss () in 51 + let sigma_r = random_sigma_r () in 52 + let sigma_t = random_sigma_t () in 53 + let hbr = random_hbr () in 54 + let dv = random_dv () in 55 + let dt = random_dt () in 56 + let m : Cam.maneuver = { dt; dv; direction = `Tangential } in 57 + let r = Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m in 58 + if r.miss_distance_before < 0.0 then 59 + Alcotest.failf "case %d: miss_distance_before=%e < 0" i 60 + r.miss_distance_before; 61 + if r.miss_distance_after < 0.0 then 62 + Alcotest.failf "case %d: miss_distance_after=%e < 0" i 63 + r.miss_distance_after 64 + done 65 + 66 + (* {1 Invariant 3: Zero burn invariant — dv=0 returns identical before/after} *) 67 + 68 + let test_zero_burn_invariant () = 69 + Random.self_init (); 70 + for i = 1 to n_cases do 71 + let miss_r = random_miss () in 72 + let miss_t = random_miss () in 73 + let miss_n = random_miss () in 74 + let sigma_r = random_sigma_r () in 75 + let sigma_t = random_sigma_t () in 76 + let hbr = random_hbr () in 77 + let dt = random_dt () in 78 + let m : Cam.maneuver = { dt; dv = 0.0; direction = `Tangential } in 79 + let r = Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m in 80 + let miss_diff = 81 + Float.abs (r.miss_distance_after -. r.miss_distance_before) 82 + in 83 + if miss_diff > 1e-6 then 84 + Alcotest.failf "case %d: zero burn changed miss by %e" i miss_diff; 85 + let pc_diff = Float.abs (r.pc_after -. r.pc_before) in 86 + if pc_diff > 1e-15 then 87 + Alcotest.failf "case %d: zero burn changed Pc by %e" i pc_diff; 88 + if Float.abs r.delta_v > 1e-15 then 89 + Alcotest.failf "case %d: zero burn delta_v=%e" i r.delta_v 90 + done 91 + 92 + (* {1 Invariant 4: dt=0 invariant — burn at TCA has no effect} *) 93 + 94 + let test_dt_zero_invariant () = 95 + Random.self_init (); 96 + for i = 1 to n_cases do 97 + let miss_r = random_miss () in 98 + let miss_t = random_miss () in 99 + let miss_n = random_miss () in 100 + let sigma_r = random_sigma_r () in 101 + let sigma_t = random_sigma_t () in 102 + let hbr = random_hbr () in 103 + let dv = random_dv () in 104 + let m : Cam.maneuver = { dt = 0.0; dv; direction = `Tangential } in 105 + let r = Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m in 106 + let miss_diff = 107 + Float.abs (r.miss_distance_after -. r.miss_distance_before) 108 + in 109 + if miss_diff > 1e-6 then 110 + Alcotest.failf "case %d: dt=0 burn changed miss by %e (dv=%.3f)" i 111 + miss_diff dv; 112 + let pc_diff = Float.abs (r.pc_after -. r.pc_before) in 113 + if pc_diff > 1e-15 then 114 + Alcotest.failf "case %d: dt=0 burn changed Pc by %e (dv=%.3f)" i pc_diff 115 + dv 116 + done 117 + 118 + (* {1 Invariant 5: Symmetry — dv=+X and dv=-X produce same delta_v cost} *) 119 + 120 + let test_dv_cost_symmetry () = 121 + Random.self_init (); 122 + for i = 1 to n_cases do 123 + let miss_r = random_miss () in 124 + let miss_t = random_miss () in 125 + let miss_n = random_miss () in 126 + let sigma_r = random_sigma_r () in 127 + let sigma_t = random_sigma_t () in 128 + let hbr = random_hbr () in 129 + let dv = 0.01 +. Random.float 9.99 in 130 + let dt = 1.0 +. Random.float 86399.0 in 131 + let eval dv = 132 + Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr 133 + { dt; dv; direction = `Tangential } 134 + in 135 + let r_pos = eval dv in 136 + let r_neg = eval (-.dv) in 137 + let diff = Float.abs (r_pos.delta_v -. r_neg.delta_v) in 138 + if diff > 1e-10 then 139 + Alcotest.failf "case %d: delta_v not symmetric: +dv=%.6f -dv=%.6f" i 140 + r_pos.delta_v r_neg.delta_v 141 + done 142 + 143 + (* {1 Invariant 6: Monotonicity — increasing burn magnitude increases miss shift} *) 144 + 145 + let test_monotonicity () = 146 + Random.self_init (); 147 + for i = 1 to n_cases do 148 + let miss_r = random_miss () in 149 + let miss_t = random_miss () in 150 + let miss_n = random_miss () in 151 + let sigma_r = random_sigma_r () in 152 + let sigma_t = random_sigma_t () in 153 + let hbr = random_hbr () in 154 + let dt = 100.0 +. Random.float 86300.0 in 155 + let eval dv = 156 + Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr 157 + { dt; dv; direction = `Tangential } 158 + in 159 + let dv1 = 1.0 in 160 + let dv2 = 5.0 in 161 + let r1 = eval dv1 in 162 + let r2 = eval dv2 in 163 + (* The miss distance shift should be larger for larger burn. 164 + shift = |miss_after - miss_before|. For same-direction burns, the 165 + absolute shift from the zero-burn baseline should increase. *) 166 + let shift1 = 167 + Float.abs (r1.miss_distance_after -. r1.miss_distance_before) 168 + in 169 + let shift2 = 170 + Float.abs (r2.miss_distance_after -. r2.miss_distance_before) 171 + in 172 + if shift2 < shift1 -. 1e-6 then 173 + Alcotest.failf 174 + "case %d: larger burn (%.1f) gave smaller shift (%.1f) than smaller \ 175 + burn (%.1f, shift=%.1f)" 176 + i dv2 shift2 dv1 shift1 177 + done 178 + 179 + (* {1 Invariant 7: Large burn safety — sufficiently large burn drives Pc down} *) 180 + 181 + let test_large_burn_safety () = 182 + Random.self_init (); 183 + for i = 1 to n_cases do 184 + let miss_r = random_miss () in 185 + let miss_t = random_miss () in 186 + let miss_n = random_miss () in 187 + let sigma_r = random_sigma_r () in 188 + let sigma_t = random_sigma_t () in 189 + let hbr = random_hbr () in 190 + let dt = 3600.0 in 191 + (* A 100 m/s burn at 1 hour shifts T by 360 km — should overwhelm any 192 + conjunction geometry with sigmas < 10 km *) 193 + let m : Cam.maneuver = { dt; dv = 100.0; direction = `Tangential } in 194 + let r = Cam.evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m in 195 + if r.pc_after > 1e-10 then 196 + Alcotest.failf 197 + "case %d: 100 m/s burn at 1h should drive Pc < 1e-10, got %e" i 198 + r.pc_after 199 + done 200 + 201 + let suite = 202 + ( "fuzz", 203 + [ 204 + Alcotest.test_case "Pc bounds" `Quick test_pc_bounds; 205 + Alcotest.test_case "miss distance positive" `Quick 206 + test_miss_distance_positive; 207 + Alcotest.test_case "zero burn invariant" `Quick test_zero_burn_invariant; 208 + Alcotest.test_case "dt=0 invariant" `Quick test_dt_zero_invariant; 209 + Alcotest.test_case "delta-v cost symmetry" `Quick test_dv_cost_symmetry; 210 + Alcotest.test_case "monotonicity" `Quick test_monotonicity; 211 + Alcotest.test_case "large burn safety" `Quick test_large_burn_safety; 212 + ] )
+3
test/test_fuzz.mli
··· 1 + (** Physical invariant fuzz tests for the CAM library. *) 2 + 3 + val suite : string * unit Alcotest.test_case list