Collision Avoidance Maneuver design for conjunction assessment
0
fork

Configure Feed

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

Add ocaml-cam: Collision Avoidance Maneuver design

Computes optimal burns to avoid conjunctions. Given miss geometry
(RTN frame), covariance, and HBR, evaluates maneuvers and finds
minimum delta-v for a target Pc.

API: evaluate (single maneuver), min_dv (bisection search for
target Pc), screen (rank multiple options by post-maneuver Pc).

20 tests: physics (tangential/radial/normal shifts), Pc computation
(decrease, wrong direction, symmetric), min_dv search, screening,
realistic LEO/GEO scenarios, edge cases. Merlint clean.

Uses 2D Foster Pc approximation. Ref: CAMmary survey (SDC9 2025).

+620
+1
.ocamlformat
··· 1 + version = 0.28.1
+36
cam.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Collision Avoidance Maneuver design for conjunction assessment" 4 + description: 5 + "Compute optimal impulsive burns to avoid satellite conjunctions. Given miss distance, covariance, and hard-body radius, evaluates proposed maneuvers, finds minimum delta-v for a target Pc, and screens multiple burn options. Uses the Collision library for Pc computation." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "ISC" 9 + homepage: "https://tangled.org/gazagnaire.org/ocaml-cam" 10 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-cam/issues" 11 + depends: [ 12 + "dune" {>= "3.21"} 13 + "ocaml" {>= "4.14"} 14 + "collision" 15 + "cdm" 16 + "ptime" 17 + "fmt" 18 + "alcotest" {with-test} 19 + "odoc" {with-doc} 20 + ] 21 + build: [ 22 + ["dune" "subst"] {dev} 23 + [ 24 + "dune" 25 + "build" 26 + "-p" 27 + name 28 + "-j" 29 + jobs 30 + "@install" 31 + "@runtest" {with-test} 32 + "@doc" {with-doc} 33 + ] 34 + ] 35 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-cam" 36 + x-maintenance-intent: ["(latest)"]
+25
dune-project
··· 1 + (lang dune 3.21) 2 + (name cam) 3 + (source (tangled gazagnaire.org/ocaml-cam)) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (package 12 + (name cam) 13 + (synopsis "Collision Avoidance Maneuver design for conjunction assessment") 14 + (description 15 + "Compute optimal impulsive burns to avoid satellite conjunctions. Given \ 16 + miss distance, covariance, and hard-body radius, evaluates proposed \ 17 + maneuvers, finds minimum delta-v for a target Pc, and screens multiple \ 18 + burn options. Uses the Collision library for Pc computation.") 19 + (depends 20 + (ocaml (>= 4.14)) 21 + collision 22 + cdm 23 + ptime 24 + fmt 25 + (alcotest :with-test)))
+148
lib/cam.ml
··· 1 + (** Collision Avoidance Maneuver (CAM) design. 2 + 3 + Computes impulsive burns to mitigate conjunction risk. Uses the simplified 4 + 2D Foster Pc from the Collision library. 5 + 6 + All public API inputs are in meters and seconds. Internally we convert to km 7 + for the Collision library (which uses km throughout). *) 8 + 9 + (* {1 Types} *) 10 + 11 + type direction = [ `Tangential | `Radial | `Normal ] 12 + 13 + type maneuver = { dt : float; dv : float; direction : direction } 14 + 15 + type result = { 16 + maneuver : maneuver; 17 + miss_distance_before : float; 18 + miss_distance_after : float; 19 + pc_before : float; 20 + pc_after : float; 21 + delta_v : float; 22 + } 23 + 24 + (* {1 Internal helpers} *) 25 + 26 + (** Compute the miss components (R, T, N) in meters after applying a maneuver. 27 + For an impulsive burn at [dt] seconds before TCA: 28 + - Tangential: shifts T by [dv * dt] 29 + - Radial: shifts R by [dv * dt] 30 + - Normal: shifts N by [dv * dt] *) 31 + let apply_maneuver ~miss_r ~miss_t ~miss_n (m : maneuver) = 32 + let shift = m.dv *. m.dt in 33 + match m.direction with 34 + | `Tangential -> (miss_r, miss_t +. shift, miss_n) 35 + | `Radial -> (miss_r +. shift, miss_t, miss_n) 36 + | `Normal -> (miss_r, miss_t, miss_n +. shift) 37 + 38 + (** Compute miss distance from RTN components (meters). *) 39 + let miss_distance r t n = sqrt ((r *. r) +. (t *. t) +. (n *. n)) 40 + 41 + (** Compute Pc using the Collision library's Foster method. 42 + 43 + The encounter is constructed in the conjunction plane. We use a simplified 44 + projection: miss_x = miss_r, miss_y = miss_t (the conjunction plane is 45 + approximately the R-T plane for near-coplanar encounters). 46 + 47 + All inputs in meters; we convert to km for the Collision library. *) 48 + let compute_pc ~miss_r ~miss_t ~sigma_r ~sigma_t ~hbr = 49 + let m_to_km x = x *. 1e-3 in 50 + let enc : Collision.encounter = 51 + { 52 + miss_x = m_to_km miss_r; 53 + miss_y = m_to_km miss_t; 54 + sigma_x = m_to_km sigma_r; 55 + sigma_y = m_to_km sigma_t; 56 + hbr = m_to_km hbr; 57 + } 58 + in 59 + Collision.pc_foster enc 60 + 61 + (* {1 Public API} *) 62 + 63 + let evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m = 64 + let md_before = miss_distance miss_r miss_t miss_n in 65 + let pc_before = compute_pc ~miss_r ~miss_t ~sigma_r ~sigma_t ~hbr in 66 + let new_r, new_t, new_n = apply_maneuver ~miss_r ~miss_t ~miss_n m in 67 + 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 69 + { 70 + maneuver = m; 71 + miss_distance_before = md_before; 72 + miss_distance_after = md_after; 73 + pc_before; 74 + pc_after; 75 + delta_v = Float.abs m.dv; 76 + } 77 + 78 + let min_dv ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr ~dt ~target_pc = 79 + (* Check if already safe *) 80 + let pc0 = compute_pc ~miss_r ~miss_t ~sigma_r ~sigma_t ~hbr in 81 + if pc0 <= target_pc then Some 0.0 82 + else if target_pc <= 0.0 then 83 + (* Cannot achieve exactly zero Pc *) 84 + None 85 + else 86 + (* Bisection search for minimum dv (tangential burn). 87 + We search in [0, dv_max] where dv_max is large enough that Pc → 0. *) 88 + let dv_max = ref 1.0 in 89 + (* First, find an upper bound where Pc < target *) 90 + let found_upper = ref false in 91 + for _ = 1 to 50 do 92 + if not !found_upper then begin 93 + let m = 94 + { dt; dv = !dv_max; direction = `Tangential } 95 + in 96 + 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 100 + if pc <= target_pc then found_upper := true 101 + else dv_max := !dv_max *. 2.0 102 + end 103 + done; 104 + if not !found_upper then None 105 + else begin 106 + (* Bisect between 0 and dv_max *) 107 + let lo = ref 0.0 in 108 + let hi = ref !dv_max in 109 + for _ = 1 to 100 do 110 + let mid = (!lo +. !hi) /. 2.0 in 111 + let m = { dt; dv = mid; direction = `Tangential } in 112 + 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 116 + if pc <= target_pc then hi := mid else lo := mid 117 + done; 118 + Some !hi 119 + end 120 + 121 + let screen ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr ~dv_options ~dt = 122 + let results = 123 + List.map 124 + (fun dv -> 125 + let m = { dt; dv; direction = `Tangential } in 126 + evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m) 127 + dv_options 128 + in 129 + List.sort (fun a b -> Float.compare a.pc_after b.pc_after) results 130 + 131 + (* {1 Pretty-printing} *) 132 + 133 + let pp_direction ppf = function 134 + | `Tangential -> Fmt.pf ppf "tangential" 135 + | `Radial -> Fmt.pf ppf "radial" 136 + | `Normal -> Fmt.pf ppf "normal" 137 + 138 + let pp_maneuver ppf m = 139 + Fmt.pf ppf "@[<h>dv=%.4f m/s %a dt=%.1fs@]" m.dv pp_direction m.direction m.dt 140 + 141 + let pp ppf r = 142 + Fmt.pf ppf 143 + "@[<v>maneuver: %a@,\ 144 + miss before: %.1f m -> after: %.1f m@,\ 145 + Pc before: %.2e -> after: %.2e@,\ 146 + delta-v: %.4f m/s@]" 147 + pp_maneuver r.maneuver r.miss_distance_before r.miss_distance_after 148 + r.pc_before r.pc_after r.delta_v
+87
lib/cam.mli
··· 1 + (** Collision Avoidance Maneuver (CAM) design. 2 + 3 + Computes the optimal impulsive burn to avoid a conjunction. Given the 4 + conjunction geometry (miss distance, covariance, hard-body radius) and a 5 + proposed maneuver, evaluates the effect on miss distance and probability of 6 + collision (Pc). 7 + 8 + The simplest CAM strategy is an impulsive tangential burn applied at some 9 + time before TCA. The along-track position shift at TCA is approximately 10 + [dv * dt] (first-order approximation). *) 11 + 12 + (** {1 Types} *) 13 + 14 + type direction = [ `Tangential | `Radial | `Normal ] 15 + (** Burn direction in the RTN frame. *) 16 + 17 + type maneuver = { 18 + dt : float; (** Time before TCA in seconds. *) 19 + dv : float; (** Delta-v magnitude in m/s (positive = prograde). *) 20 + direction : direction; 21 + } 22 + (** An impulsive maneuver specification. *) 23 + 24 + type result = { 25 + maneuver : maneuver; 26 + miss_distance_before : float; (** Miss distance before maneuver (m). *) 27 + miss_distance_after : float; (** Miss distance after maneuver (m). *) 28 + pc_before : float; (** Probability of collision before (0-1). *) 29 + pc_after : float; (** Probability of collision after (0-1). *) 30 + delta_v : float; (** Delta-v cost in m/s. *) 31 + } 32 + (** Result of evaluating a maneuver. *) 33 + 34 + (** {1 Maneuver evaluation} *) 35 + 36 + val evaluate : 37 + miss_r:float -> 38 + miss_t:float -> 39 + miss_n:float -> 40 + sigma_r:float -> 41 + sigma_t:float -> 42 + hbr:float -> 43 + maneuver -> 44 + result 45 + (** [evaluate ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr m] evaluates a 46 + proposed maneuver [m]. Miss components in meters, sigma in meters, HBR in 47 + meters. Returns before/after Pc and miss distances. *) 48 + 49 + val min_dv : 50 + miss_r:float -> 51 + miss_t:float -> 52 + miss_n:float -> 53 + sigma_r:float -> 54 + sigma_t:float -> 55 + hbr:float -> 56 + dt:float -> 57 + target_pc:float -> 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 62 + bisection search. *) 63 + 64 + val screen : 65 + miss_r:float -> 66 + miss_t:float -> 67 + miss_n:float -> 68 + sigma_r:float -> 69 + sigma_t:float -> 70 + hbr:float -> 71 + dv_options:float list -> 72 + dt:float -> 73 + result list 74 + (** [screen ~miss_r ~miss_t ~miss_n ~sigma_r ~sigma_t ~hbr ~dv_options ~dt] 75 + evaluates multiple delta-v options and returns results sorted by [pc_after] 76 + ascending. *) 77 + 78 + (** {1 Pretty-printing} *) 79 + 80 + val pp_direction : direction Fmt.t 81 + (** Pretty-print a burn direction. *) 82 + 83 + val pp_maneuver : maneuver Fmt.t 84 + (** Pretty-print a maneuver. *) 85 + 86 + val pp : result Fmt.t 87 + (** Pretty-print a maneuver evaluation result. *)
+4
lib/dune
··· 1 + (library 2 + (name cam) 3 + (public_name cam) 4 + (libraries collision cdm ptime fmt))
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries cam collision alcotest fmt))
+1
test/test.ml
··· 1 + let () = Alcotest.run "cam" [ Test_cam.suite ]
+313
test/test_cam.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Comprehensive tests for the CAM (Collision Avoidance Maneuver) library. *) 7 + 8 + let check_float ?(eps = 1e-6) msg expected actual = 9 + let diff = Float.abs (expected -. actual) in 10 + let ok = 11 + if Float.abs expected < 1e-30 then diff < eps 12 + else diff /. Float.abs expected < eps 13 + in 14 + if not ok then 15 + Alcotest.failf "%s: expected %e, got %e (diff=%e)" msg expected actual diff 16 + 17 + (* {1 Standard conjunction parameters} 18 + 19 + LEO-like conjunction: miss mostly along-track, moderate covariance. *) 20 + 21 + let std_miss_r = 100.0 (* meters *) 22 + let std_miss_t = 200.0 (* meters *) 23 + let std_miss_n = 50.0 (* meters *) 24 + let std_sigma_r = 50.0 (* meters *) 25 + let std_sigma_t = 500.0 (* meters *) 26 + let std_hbr = 10.0 (* meters *) 27 + 28 + let eval = Cam.evaluate ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 29 + ~sigma_r:std_sigma_r ~sigma_t:std_sigma_t ~hbr:std_hbr 30 + 31 + (* {1 Basic physics tests} *) 32 + 33 + (* 1. Zero delta-v should not change miss distance or Pc *) 34 + let zero_dv () = 35 + let r = eval { dt = 3600.0; dv = 0.0; direction = `Tangential } in 36 + check_float "miss unchanged" r.miss_distance_before r.miss_distance_after; 37 + check_float "Pc unchanged" r.pc_before r.pc_after; 38 + check_float "delta-v is zero" 0.0 r.delta_v 39 + 40 + (* 2. Tangential shift: 1 m/s at 3600s shifts T by ~3600m *) 41 + let tangential_shift () = 42 + let m : Cam.maneuver = { dt = 3600.0; dv = 1.0; direction = `Tangential } in 43 + let r = eval m in 44 + (* 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 + check_float ~eps:1e-4 "miss after tangential shift" expected_miss 47 + r.miss_distance_after 48 + 49 + (* 3. Large burn: 10 m/s at 7200s shifts T by ~72000m, Pc ~ 0 *) 50 + let large_burn () = 51 + let m : Cam.maneuver = { dt = 7200.0; dv = 10.0; direction = `Tangential } in 52 + let r = eval m in 53 + let expected_miss = 54 + sqrt ((100.0 ** 2.0) +. (72200.0 ** 2.0) +. (50.0 ** 2.0)) 55 + in 56 + check_float ~eps:1e-4 "miss after large burn" expected_miss r.miss_distance_after; 57 + (* Pc should be essentially zero *) 58 + if r.pc_after > 1e-20 then 59 + Alcotest.failf "Pc after large burn should be ~0, got %e" r.pc_after 60 + 61 + (* 4. Negative dv shifts T in opposite direction *) 62 + let negative_dv () = 63 + let r_pos = eval { dt = 3600.0; dv = 1.0; direction = `Tangential } in 64 + let r_neg = eval { dt = 3600.0; dv = -1.0; direction = `Tangential } in 65 + (* Negative dv: new_T = 200 - 3600 = -3400 *) 66 + let expected_miss_neg = 67 + sqrt ((100.0 ** 2.0) +. ((-3400.0) ** 2.0) +. (50.0 ** 2.0)) 68 + in 69 + check_float ~eps:1e-4 "negative dv miss" expected_miss_neg 70 + r_neg.miss_distance_after; 71 + (* Both should move miss away from original *) 72 + if r_pos.miss_distance_after <= r_pos.miss_distance_before then 73 + Alcotest.fail "positive burn should increase miss"; 74 + if r_neg.miss_distance_after <= r_neg.miss_distance_before then 75 + Alcotest.fail "negative burn should increase miss" 76 + 77 + (* 5. Radial burn shifts R component, not T *) 78 + let radial_burn () = 79 + let r = eval { dt = 3600.0; dv = 1.0; direction = `Radial } in 80 + (* New R = 100 + 3600 = 3700, T and N unchanged *) 81 + let expected_miss = 82 + sqrt ((3700.0 ** 2.0) +. (200.0 ** 2.0) +. (50.0 ** 2.0)) 83 + in 84 + check_float ~eps:1e-4 "radial burn miss" expected_miss r.miss_distance_after 85 + 86 + (* 6. Normal burn shifts N component, not T *) 87 + let normal_burn () = 88 + let r = eval { dt = 3600.0; dv = 1.0; direction = `Normal } in 89 + (* New N = 50 + 3600 = 3650, R and T unchanged *) 90 + let expected_miss = 91 + sqrt ((100.0 ** 2.0) +. (200.0 ** 2.0) +. (3650.0 ** 2.0)) 92 + in 93 + check_float ~eps:1e-4 "normal burn miss" expected_miss r.miss_distance_after 94 + 95 + (* {1 Pc computation tests} *) 96 + 97 + (* 7. Any nonzero tangential burn should decrease Pc when miss is moderate *) 98 + let pc_decreases () = 99 + let r = eval { dt = 3600.0; dv = 0.5; direction = `Tangential } in 100 + if r.pc_after >= r.pc_before then 101 + Alcotest.failf "Pc should decrease: before=%e after=%e" r.pc_before r.pc_after 102 + 103 + (* 8. Burn that moves INTO the conjunction should increase Pc. 104 + Original miss_t = 200m. Burn that shifts T by -200m makes T = 0, 105 + reducing along-track miss to zero → Pc increases. *) 106 + let pc_increases_wrong_direction () = 107 + (* Use a scenario where T is the dominant miss component *) 108 + let eval2 = 109 + Cam.evaluate ~miss_r:10.0 ~miss_t:300.0 ~miss_n:10.0 ~sigma_r:50.0 110 + ~sigma_t:500.0 ~hbr:10.0 111 + in 112 + (* Burn that removes the T miss: dv * dt = -300 → dv = -300/3600 *) 113 + let dv = -300.0 /. 3600.0 in 114 + let r = eval2 { dt = 3600.0; dv; direction = `Tangential } in 115 + if r.pc_after <= r.pc_before then 116 + Alcotest.failf "Pc should increase when burning into conjunction: before=%e \ 117 + after=%e" 118 + r.pc_before r.pc_after 119 + 120 + (* 9. Very large miss distance → Pc ≈ 0 regardless *) 121 + let pc_zero_for_large_miss () = 122 + let r = 123 + Cam.evaluate ~miss_r:1e6 ~miss_t:1e6 ~miss_n:0.0 ~sigma_r:50.0 124 + ~sigma_t:500.0 ~hbr:10.0 125 + { dt = 3600.0; dv = 0.0; direction = `Tangential } 126 + in 127 + if r.pc_before > 1e-30 then 128 + Alcotest.failf "Pc should be ~0 for huge miss, got %e" r.pc_before 129 + 130 + (* 10. Symmetric burns: equal magnitude opposite directions give same Pc 131 + when initial miss is symmetric about T=0. *) 132 + let symmetric_burns () = 133 + (* Set miss_t = 0 so positive and negative dv are symmetric *) 134 + let eval_sym = 135 + Cam.evaluate ~miss_r:100.0 ~miss_t:0.0 ~miss_n:50.0 ~sigma_r:50.0 136 + ~sigma_t:500.0 ~hbr:10.0 137 + in 138 + let r_pos = eval_sym { dt = 3600.0; dv = 1.0; direction = `Tangential } in 139 + let r_neg = eval_sym { dt = 3600.0; dv = -1.0; direction = `Tangential } in 140 + check_float ~eps:1e-4 "symmetric Pc" r_pos.pc_after r_neg.pc_after 141 + 142 + (* {1 min_dv tests} *) 143 + 144 + (* 11. Find minimum dv for target Pc, verify it achieves it *) 145 + let min_dv_basic () = 146 + let target_pc = 1e-6 in 147 + let dt = 3600.0 in 148 + match 149 + Cam.min_dv ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 150 + ~sigma_r:std_sigma_r ~sigma_t:std_sigma_t ~hbr:std_hbr ~dt ~target_pc 151 + with 152 + | None -> Alcotest.fail "min_dv should return Some" 153 + | 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 167 + 168 + (* 12. If Pc already below target, minimum dv is 0 *) 169 + let min_dv_already_safe () = 170 + (* Use huge miss → Pc ≈ 0, so any target > 0 is already met *) 171 + 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 174 + with 175 + | None -> Alcotest.fail "should return Some 0" 176 + | Some dv -> check_float "already safe dv" 0.0 dv 177 + 178 + (* 13. If target is exactly 0, should return None *) 179 + let min_dv_impossible () = 180 + match 181 + Cam.min_dv ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 182 + ~sigma_r:std_sigma_r ~sigma_t:std_sigma_t ~hbr:std_hbr ~dt:3600.0 183 + ~target_pc:0.0 184 + with 185 + | None -> () 186 + | Some dv -> Alcotest.failf "expected None for target_pc=0, got Some %.6f" dv 187 + 188 + (* {1 screen tests} *) 189 + 190 + (* 14. Results should be sorted by Pc_after ascending *) 191 + let screen_sorted () = 192 + let results = 193 + Cam.screen ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 194 + ~sigma_r:std_sigma_r ~sigma_t:std_sigma_t ~hbr:std_hbr 195 + ~dv_options:[ 5.0; 0.1; 1.0; 0.5; 10.0 ] 196 + ~dt:3600.0 197 + in 198 + let rec check_sorted = function 199 + | [] | [ _ ] -> () 200 + | 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 204 + in 205 + check_sorted results 206 + 207 + (* 15. Screen multiple options, all return valid results *) 208 + let screen_multiple () = 209 + let options = [ 0.1; 0.5; 1.0; 5.0 ] in 210 + let results = 211 + Cam.screen ~miss_r:std_miss_r ~miss_t:std_miss_t ~miss_n:std_miss_n 212 + ~sigma_r:std_sigma_r ~sigma_t:std_sigma_t ~hbr:std_hbr ~dv_options:options 213 + ~dt:3600.0 214 + in 215 + if List.length results <> List.length options then 216 + Alcotest.failf "expected %d results, got %d" (List.length options) 217 + (List.length results); 218 + List.iter 219 + (fun (r : Cam.result) -> 220 + if r.delta_v < 0.0 then Alcotest.fail "negative delta_v"; 221 + if r.pc_after < 0.0 || r.pc_after > 1.0 then 222 + Alcotest.failf "Pc out of range: %e" r.pc_after) 223 + results 224 + 225 + (* {1 Realistic scenarios} *) 226 + 227 + (* 16. LEO conjunction *) 228 + let realistic_leo () = 229 + (* LEO: miss=500m, sigma_r=50m, sigma_t=500m, HBR=10m *) 230 + let r = 231 + Cam.evaluate ~miss_r:50.0 ~miss_t:500.0 ~miss_n:10.0 ~sigma_r:50.0 232 + ~sigma_t:500.0 ~hbr:10.0 233 + { dt = 5400.0; dv = 0.01; direction = `Tangential } 234 + in 235 + (* 0.01 m/s at 5400s = 54m shift. Should noticeably change Pc *) 236 + if r.pc_after >= r.pc_before then 237 + Alcotest.failf "LEO burn should reduce Pc: before=%e after=%e" r.pc_before 238 + r.pc_after; 239 + (* The delta-v should be 0.01 *) 240 + check_float "LEO delta-v" 0.01 r.delta_v 241 + 242 + (* 17. GEO conjunction: larger miss, smaller relative velocity effects *) 243 + let realistic_geo () = 244 + (* GEO: larger covariance, larger miss *) 245 + let r = 246 + Cam.evaluate ~miss_r:200.0 ~miss_t:1000.0 ~miss_n:100.0 ~sigma_r:200.0 247 + ~sigma_t:2000.0 ~hbr:15.0 248 + { dt = 21600.0; dv = 0.005; direction = `Tangential } 249 + in 250 + (* 0.005 m/s at 6 hours = 108m shift *) 251 + if r.miss_distance_after <= r.miss_distance_before then 252 + Alcotest.fail "GEO burn should increase miss distance"; 253 + if r.delta_v < 0.0 then Alcotest.fail "delta-v should be non-negative" 254 + 255 + (* {1 Edge cases} *) 256 + 257 + (* 18. Burn at TCA (dt=0) should have no effect *) 258 + let zero_dt () = 259 + let r = eval { dt = 0.0; dv = 100.0; direction = `Tangential } in 260 + check_float "zero dt miss unchanged" r.miss_distance_before 261 + r.miss_distance_after; 262 + check_float "zero dt Pc unchanged" r.pc_before r.pc_after 263 + 264 + (* 19. Tiny covariance with large miss → Pc ≈ 0 *) 265 + let very_small_sigma () = 266 + let r = 267 + Cam.evaluate ~miss_r:1000.0 ~miss_t:1000.0 ~miss_n:0.0 ~sigma_r:1.0 268 + ~sigma_t:1.0 ~hbr:10.0 269 + { dt = 3600.0; dv = 0.0; direction = `Tangential } 270 + in 271 + if r.pc_before > 1e-30 then 272 + Alcotest.failf "tiny sigma + large miss → Pc≈0, got %e" r.pc_before 273 + 274 + (* 20. Huge covariance → Pc stays elevated even after burn *) 275 + let very_large_sigma () = 276 + let r = 277 + Cam.evaluate ~miss_r:100.0 ~miss_t:200.0 ~miss_n:50.0 ~sigma_r:1e5 278 + ~sigma_t:1e5 ~hbr:10.0 279 + { dt = 3600.0; dv = 1.0; direction = `Tangential } 280 + in 281 + (* With huge sigma, a 3600m shift is negligible; Pc_before ≈ Pc_after *) 282 + let ratio = 283 + if r.pc_before > 1e-30 then Float.abs (r.pc_after -. r.pc_before) /. r.pc_before 284 + else 0.0 285 + in 286 + if ratio > 0.1 then 287 + Alcotest.failf "huge sigma: burn should barely change Pc, ratio=%e" ratio 288 + 289 + let suite = 290 + ( "cam", 291 + [ 292 + Alcotest.test_case "zero_dv" `Quick zero_dv; 293 + Alcotest.test_case "tangential_shift" `Quick tangential_shift; 294 + Alcotest.test_case "large_burn" `Quick large_burn; 295 + Alcotest.test_case "negative_dv" `Quick negative_dv; 296 + Alcotest.test_case "radial_burn" `Quick radial_burn; 297 + Alcotest.test_case "normal_burn" `Quick normal_burn; 298 + Alcotest.test_case "pc_decreases" `Quick pc_decreases; 299 + Alcotest.test_case "pc_increases_wrong_direction" `Quick 300 + pc_increases_wrong_direction; 301 + Alcotest.test_case "pc_zero_for_large_miss" `Quick pc_zero_for_large_miss; 302 + Alcotest.test_case "symmetric_burns" `Quick symmetric_burns; 303 + Alcotest.test_case "min_dv_basic" `Quick min_dv_basic; 304 + Alcotest.test_case "min_dv_already_safe" `Quick min_dv_already_safe; 305 + Alcotest.test_case "min_dv_impossible" `Quick min_dv_impossible; 306 + Alcotest.test_case "screen_sorted" `Quick screen_sorted; 307 + Alcotest.test_case "screen_multiple" `Quick screen_multiple; 308 + Alcotest.test_case "realistic_leo" `Quick realistic_leo; 309 + Alcotest.test_case "realistic_geo" `Quick realistic_geo; 310 + Alcotest.test_case "zero_dt" `Quick zero_dt; 311 + Alcotest.test_case "very_small_sigma" `Quick very_small_sigma; 312 + Alcotest.test_case "very_large_sigma" `Quick very_large_sigma; 313 + ] )
+2
test/test_cam.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** CAM test suite. *)