Collision Avoidance Maneuver design for conjunction assessment
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
12let n_cases = 1000
13
14(* Generate random inputs in physically reasonable ranges *)
15let random_miss () = Random.float 10000.0 (* 0-10000 m *)
16let random_sigma_r () = 10.0 +. Random.float 990.0 (* 10-1000 m *)
17let random_sigma_t () = 100.0 +. Random.float 9900.0 (* 100-10000 m *)
18let random_hbr () = 1.0 +. Random.float 49.0 (* 1-50 m *)
19let random_dv () = Random.float 20.0 -. 10.0 (* -10 to 10 m/s *)
20let random_dt () = Random.float 86400.0 (* 0 to 86400 s *)
21
22(* {1 Invariant 1: Pc is always in [0, 1]} *)
23
24let 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
45let 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
68let 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
94let 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
120let 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
145let 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
181let 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
201let suite =
202 ( "cam",
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 ] )