ballistics_engine/request_roundtrip.rs
1//! Reverse conversion from the canonical resolved request back into a solvable request
2//! (Phase 0 of the 0.33.0 decision-support train).
3//!
4//! The resolved request was otherwise output-only. The perturbation kernel needs to take a
5//! resolved request, change one input, and re-solve, which is impossible without this
6//! direction. Every resolved value is carried across explicitly: a silently lossy
7//! conversion would misattribute the dropped field's effect to whatever the caller happened
8//! to be perturbing.
9//!
10//! Two fields are deliberately NOT carried straight across, because the resolved sibling
11//! they'd ride along with is already the post-transform value, and re-supplying the
12//! original input mode would apply that transform a second time:
13//!
14//! - `atmosphere.pressure_reference`: when the original request declared `"qnh"`,
15//! [`ResolvedAtmosphereV1::pressure_pa`] is already the REDUCED absolute station pressure
16//! (see `resolve_atmosphere`'s QNH branch) -- echoing `"qnh"` back alongside that
17//! already-reduced value would reduce it a second time. The rebuilt request always states
18//! `pressure_pa` as absolute (the omitted-field default), which is what the resolved
19//! value already is.
20//! - `wind.wind_reference`: when the original request declared `"compass"`, every resolved
21//! wind direction is already converted to shooter-relative (see `resolve_wind`'s
22//! `to_relative`) -- echoing `"compass"` back alongside an already-relative direction
23//! would re-reference it against the shot azimuth a second time. The rebuilt request
24//! always states directions as shooter-relative (the omitted-field default), which is
25//! what the resolved values already are.
26//!
27//! `ResolvedShotV1` carries both `zero_distance_m` (caller intent) and `muzzle_angle_rad`
28//! (the effective angle after zeroing). Both are carried onto the rebuilt request: an
29//! explicit `muzzle_angle_rad` always takes priority over `zero_distance_m` at resolve time
30//! (see `resolve_shot` and `solve_v1`'s zero-search gate), so supplying both reproduces the
31//! exact original angle -- bit-identical, not just numerically re-converged -- while still
32//! preserving the original zeroing intent as metadata rather than dropping it.
33
34use crate::solve_json::*;
35
36impl From<&ResolvedSolveRequestV1> for SolveRequestV1 {
37 fn from(r: &ResolvedSolveRequestV1) -> Self {
38 SolveRequestV1 {
39 schema_version: SchemaVersionV1,
40 projectile: ProjectileV1 {
41 mass_kg: r.projectile.mass_kg,
42 diameter_m: r.projectile.diameter_m,
43 length_m: r.projectile.length_m,
44 drag_model: r.projectile.drag_model,
45 ballistic_coefficient: r.projectile.ballistic_coefficient,
46 drag_table: r.projectile.drag_table.clone(),
47 bc_segments: r.projectile.bc_segments.clone(),
48 },
49 rifle: RifleV1 {
50 muzzle_velocity_mps: r.rifle.muzzle_velocity_mps,
51 sight_height_m: Some(r.rifle.sight_height_m),
52 muzzle_height_m: Some(r.rifle.muzzle_height_m),
53 twist_rate_m_per_turn: Some(r.rifle.twist_rate_m_per_turn),
54 twist_direction: Some(r.rifle.twist_direction),
55 sight_offset_lateral_m: r.rifle.sight_offset_lateral_m,
56 },
57 shot: ShotV1 {
58 max_range_m: r.shot.max_range_m,
59 zero_distance_m: r.shot.zero_distance_m,
60 // Both are carried: zero_distance_m is caller intent, muzzle_angle_rad is
61 // the angle actually integrated after zeroing. An explicit muzzle_angle_rad
62 // always wins at resolve time (resolve_shot / solve_v1), so this reproduces
63 // the exact original angle with no re-zero, whether or not zero_distance_m
64 // is also present.
65 muzzle_angle_rad: Some(r.shot.muzzle_angle_rad),
66 aim_azimuth_rad: Some(r.shot.aim_azimuth_rad),
67 shot_azimuth_rad: Some(r.shot.shot_azimuth_rad),
68 shooting_angle_rad: Some(r.shot.shooting_angle_rad),
69 cant_angle_rad: Some(r.shot.cant_angle_rad),
70 target_height_m: Some(r.shot.target_height_m),
71 ground_threshold_m: Some(r.shot.ground_threshold_m),
72 zero_poi_up_m: r.shot.zero_poi_up_m,
73 zero_poi_right_m: r.shot.zero_poi_right_m,
74 drops_reference: r.shot.drops_reference,
75 },
76 atmosphere: AtmosphereV1 {
77 altitude_m: Some(r.atmosphere.altitude_m),
78 temperature_k: Some(r.atmosphere.temperature_k),
79 pressure_pa: Some(r.atmosphere.pressure_pa),
80 // See the module doc: pressure_pa above is already absolute station
81 // pressure; echoing a "qnh" reference back would reduce it a second time.
82 pressure_reference: None,
83 relative_humidity: Some(r.atmosphere.relative_humidity),
84 latitude_rad: r.atmosphere.latitude_rad,
85 },
86 wind: wind_from_resolved(&r.wind),
87 solver: SolverV1 {
88 method: Some(r.solver.method),
89 time_step_s: Some(r.solver.time_step_s),
90 },
91 effects: EffectsV1 {
92 magnus: Some(r.effects.magnus),
93 coriolis: Some(r.effects.coriolis),
94 enhanced_spin_drift: Some(r.effects.enhanced_spin_drift),
95 // Carried straight across, unlike the two reference modes above: the shear
96 // model is not a declaration about how a sibling value was ENTERED, it is
97 // the model itself, and the resolved echo is already the canonical name the
98 // solver ran. Re-supplying it re-applies the same shear rather than
99 // compounding a transform. `None` stays `None`, so a rebuilt request that
100 // never asked for shear still does not.
101 wind_shear_model: r.effects.wind_shear_model,
102 // Carried straight across, including a supplied `false`: re-submitting the
103 // round-tripped request has to reproduce the same correction, and an echo that
104 // collapsed `Some(false)` to `None` would be a request that says something
105 // slightly different from the one that produced it.
106 aerodynamic_jump: r.effects.aerodynamic_jump,
107 },
108 sampling: SamplingV1 {
109 interval_m: Some(r.sampling.interval_m),
110 },
111 reticle: r.reticle.clone(),
112 // Carried straight across: segments are always regenerated from the PUBLISHED
113 // ballistic_coefficient (see `apply_bc5d_correction`), so re-applying the
114 // table on a re-solve reproduces — never compounds — the correction. Dropping
115 // it instead would misattribute the correction's whole effect to whatever a
116 // perturbation caller happened to be perturbing.
117 corrections: r.corrections.clone(),
118 }
119 }
120}
121
122fn wind_from_resolved(w: &ResolvedWindV1) -> WindV1 {
123 match w {
124 ResolvedWindV1::Constant(c) => WindV1 {
125 speed_mps: Some(c.speed_mps),
126 direction_from_rad: Some(c.direction_from_rad),
127 vertical_speed_mps: Some(c.vertical_speed_mps),
128 segments: None,
129 // See the module doc: direction_from_rad above is already shooter-relative;
130 // echoing a "compass" reference back would re-reference it a second time.
131 wind_reference: None,
132 },
133 ResolvedWindV1::Segmented(s) => WindV1 {
134 speed_mps: None,
135 direction_from_rad: None,
136 vertical_speed_mps: None,
137 segments: Some(
138 s.segments
139 .iter()
140 .map(|g| WindSegmentV1 {
141 until_distance_m: g.until_distance_m,
142 speed_mps: g.speed_mps,
143 direction_from_rad: g.direction_from_rad,
144 vertical_speed_mps: Some(g.vertical_speed_mps),
145 })
146 .collect(),
147 ),
148 // See the module doc and the constant-wind arm above.
149 wind_reference: None,
150 },
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use crate::solve_json::decode_solve_request_v1;
157 use crate::solve_json::{
158 PressureReferenceV1, ResolvedWindV1, SolveRequestV1, WindReferenceV1, WindShearModelV1,
159 };
160 use crate::solve_v1::solve_v1;
161
162 fn sample_json() -> String {
163 serde_json::json!({
164 "schema_version": 1,
165 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
166 "ballistic_coefficient": 0.243},
167 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
168 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
169 "atmosphere": {"temperature_k": 288.0, "pressure_pa": 101325.0},
170 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
171 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
172 })
173 .to_string()
174 }
175
176 /// Resolution is idempotent through a round-trip: re-solving a request
177 /// rebuilt from a resolved request must resolve to exactly the same values.
178 /// This is the acceptance gate for Phase 0.
179 ///
180 /// Compares the whole success envelope, not just `resolved_request`: some effects (the
181 /// windage-zero convergence bias, see `roundtrip_preserves_the_windage_zero_bias` below)
182 /// never appear in `resolved_request` at all -- on the very first solve, not only after a
183 /// round-trip -- so `resolved_request` equality alone cannot catch every regression.
184 #[test]
185 fn resolution_is_idempotent_through_roundtrip() {
186 let first = solve_v1(decode_solve_request_v1(&sample_json()).unwrap()).unwrap();
187 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
188 let second = solve_v1(rebuilt).unwrap();
189 assert_eq!(
190 serde_json::to_value(&first.resolved_request).unwrap(),
191 serde_json::to_value(&second.resolved_request).unwrap(),
192 "resolved request changed after a round-trip"
193 );
194 assert_eq!(
195 first.summary, second.summary,
196 "summary changed after a round-trip"
197 );
198 assert_eq!(
199 first.samples, second.samples,
200 "samples changed after a round-trip"
201 );
202 }
203
204 /// The windage-zero convergence bias (`sight_offset_lateral_m` / `zero_poi_right_m`,
205 /// applied via `BallisticInputs::windage_zero_bias_rad`) is a term
206 /// `calculate_and_set_zero_angle` adds to azimuth ALONGSIDE the elevation search -- it is
207 /// not carried by `muzzle_angle_rad`, and (unlike the elevation) it never appears in
208 /// `resolved_request` at all, on the first solve or any later one. Skipping the elevation
209 /// search on a round-tripped request must not also skip this separate term, or an
210 /// offset-mounted sight / deliberate horizontal zero bias would silently stop converging
211 /// the moment a resolved request round-trips. `resolved_request` alone cannot see this
212 /// (compare `first.resolved_request` above with `second.resolved_request` below: they are
213 /// byte-identical even when this regresses), so this compares the solved trajectory too.
214 #[test]
215 fn roundtrip_preserves_the_windage_zero_bias() {
216 let json = serde_json::json!({
217 "schema_version": 1,
218 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
219 "ballistic_coefficient": 0.243},
220 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
221 "sight_offset_lateral_m": 0.03},
222 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "zero_poi_right_m": 0.02},
223 "atmosphere": {"temperature_k": 288.0, "pressure_pa": 101325.0},
224 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
225 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
226 })
227 .to_string();
228 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
229 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
230 let second = solve_v1(rebuilt).unwrap();
231
232 assert_eq!(
233 serde_json::to_value(&first.resolved_request).unwrap(),
234 serde_json::to_value(&second.resolved_request).unwrap(),
235 "resolved request changed after a round-trip"
236 );
237 assert_eq!(
238 first.summary, second.summary,
239 "summary changed after a round-trip -- the windage-zero bias may have been dropped"
240 );
241 assert_eq!(
242 first.samples, second.samples,
243 "samples changed after a round-trip -- the windage-zero bias may have been dropped"
244 );
245 // Sanity check: the bias is actually nonzero in this fixture, so the comparisons
246 // above are exercising the real thing rather than two agreeing zeros.
247 let windage_m = first.samples.last().expect("at least one sample").windage_m;
248 assert!(
249 windage_m.abs() > 0.1,
250 "fixture must produce a non-negligible windage-zero bias to be a meaningful test, \
251 got {windage_m} m"
252 );
253 }
254
255 /// A zeroed solve must not silently re-zero on the way back.
256 #[test]
257 fn roundtrip_preserves_the_effective_muzzle_angle() {
258 let first = solve_v1(decode_solve_request_v1(&sample_json()).unwrap()).unwrap();
259 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
260 assert_eq!(
261 rebuilt.shot.muzzle_angle_rad,
262 Some(first.resolved_request.shot.muzzle_angle_rad)
263 );
264 assert_eq!(
265 rebuilt.shot.zero_distance_m,
266 first.resolved_request.shot.zero_distance_m
267 );
268 }
269
270 /// A QNH-declared pressure is reduced to absolute station pressure exactly once.
271 /// `ResolvedAtmosphereV1::pressure_pa` is already that reduced value; the rebuilt
272 /// request deliberately does not echo `pressure_reference: "qnh"` back alongside it
273 /// (see the module doc), so the round-tripped resolved echo differs in exactly that one
274 /// field. What must NOT differ is the reduced `pressure_pa` value itself: if the
275 /// rebuilt request echoed the mode back too, the second resolve would reduce it a
276 /// second time and silently corrupt it.
277 #[test]
278 fn roundtrip_does_not_double_reduce_a_qnh_pressure() {
279 let json = serde_json::json!({
280 "schema_version": 1,
281 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
282 "ballistic_coefficient": 0.243},
283 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
284 "shot": {"max_range_m": 900.0},
285 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
286 "pressure_reference": "qnh"},
287 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
288 })
289 .to_string();
290 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
291 assert_eq!(
292 first.resolved_request.atmosphere.pressure_reference,
293 Some(PressureReferenceV1::Qnh)
294 );
295
296 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
297 let second = solve_v1(rebuilt).unwrap();
298
299 // The one deliberate difference: the reference mode is not echoed back (its
300 // transform is already baked into pressure_pa, so re-supplying it would be a
301 // second application, not a no-op).
302 assert_eq!(second.resolved_request.atmosphere.pressure_reference, None);
303 // What actually matters -- the physical quantity -- is unchanged.
304 assert_eq!(
305 second.resolved_request.atmosphere.pressure_pa,
306 first.resolved_request.atmosphere.pressure_pa,
307 "a round-tripped QNH pressure must not be reduced a second time"
308 );
309 assert_eq!(
310 second.resolved_request.atmosphere.altitude_m,
311 first.resolved_request.atmosphere.altitude_m
312 );
313 assert_eq!(
314 second.resolved_request.atmosphere.temperature_k,
315 first.resolved_request.atmosphere.temperature_k
316 );
317 }
318
319 /// A compass-declared wind direction is converted to shooter-relative exactly once.
320 /// The resolved direction is already that converted value; the rebuilt request
321 /// deliberately does not echo `wind_reference: "compass"` back alongside it (see the
322 /// module doc), so the round-tripped resolved echo differs in exactly that one field.
323 /// What must NOT differ is the converted `direction_from_rad` value itself: if the
324 /// rebuilt request echoed the mode back too, the second resolve would re-reference it
325 /// against the shot azimuth a second time and silently corrupt it.
326 #[test]
327 fn roundtrip_does_not_double_reference_a_compass_wind() {
328 let json = serde_json::json!({
329 "schema_version": 1,
330 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
331 "ballistic_coefficient": 0.243},
332 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
333 "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
334 "atmosphere": {},
335 "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
336 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
337 })
338 .to_string();
339 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
340 let ResolvedWindV1::Constant(first_wind) = &first.resolved_request.wind else {
341 panic!("constant wind expected");
342 };
343 assert_eq!(first_wind.wind_reference, Some(WindReferenceV1::Compass));
344 // Sanity check: compass mode actually converted the direction (it is not simply
345 // echoing the 1.0 rad bearing the request supplied).
346 assert_ne!(first_wind.direction_from_rad, 1.0);
347 let first_direction_from_rad = first_wind.direction_from_rad;
348
349 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
350 let second = solve_v1(rebuilt).unwrap();
351 let ResolvedWindV1::Constant(second_wind) = &second.resolved_request.wind else {
352 panic!("constant wind expected");
353 };
354
355 // The one deliberate difference: the reference mode is not echoed back.
356 assert_eq!(second_wind.wind_reference, None);
357 // What actually matters -- the physical quantity -- is unchanged.
358 assert_eq!(
359 second_wind.direction_from_rad, first_direction_from_rad,
360 "a round-tripped compass wind must not be re-referenced a second time"
361 );
362 }
363
364 /// `effects.wind_shear_model` (0.36.0) is carried straight across, unlike the two
365 /// reference modes above. It is not a declaration about how a sibling value was entered,
366 /// so re-supplying it re-applies the same shear rather than compounding a transform --
367 /// and dropping it would misattribute the whole of the shear's effect to whatever a
368 /// perturbation caller happened to be perturbing.
369 ///
370 /// The trajectory is compared, not just the resolved request: the shear only shows up in
371 /// the numbers, so a rebuilt request that quietly lost the model would still produce an
372 /// identical-looking `resolved_request` if the echo alone were carried.
373 #[test]
374 fn roundtrip_preserves_the_wind_shear_model() {
375 // Lofted well above the 10 m boundary-layer reference height so the profile is
376 // actually engaged; a flat shot would agree either way. `muzzle_angle_rad` is given
377 // directly so no zero search runs between the two solves.
378 let json = serde_json::json!({
379 "schema_version": 1,
380 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
381 "ballistic_coefficient": 0.243},
382 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
383 "shot": {"max_range_m": 2000.0, "muzzle_angle_rad": 0.15,
384 "ground_threshold_m": -2000.0},
385 "atmosphere": {},
386 "wind": {"speed_mps": 4.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
387 "solver": {}, "effects": {"wind_shear_model": "logarithmic"},
388 "sampling": {"interval_m": 250.0}
389 })
390 .to_string();
391
392 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
393 assert_eq!(
394 first.resolved_request.effects.wind_shear_model,
395 Some(WindShearModelV1::Logarithmic)
396 );
397
398 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
399 assert_eq!(
400 rebuilt.effects.wind_shear_model,
401 Some(WindShearModelV1::Logarithmic),
402 "the shear model must be carried onto the rebuilt request"
403 );
404
405 let second = solve_v1(rebuilt).unwrap();
406 assert_eq!(
407 second.resolved_request.effects.wind_shear_model,
408 Some(WindShearModelV1::Logarithmic)
409 );
410 assert_eq!(
411 first.samples, second.samples,
412 "a round-tripped request must re-apply the same shear, not drop it"
413 );
414
415 // Sanity check that the assertion above has teeth: this shot really is one where the
416 // model changes the answer, so "identical samples" is not vacuously true.
417 let unsheared_json = json.replace(r#""wind_shear_model":"logarithmic""#, "");
418 let unsheared = solve_v1(decode_solve_request_v1(&unsheared_json).unwrap()).unwrap();
419 assert_ne!(
420 unsheared.samples, first.samples,
421 "the fixture must be a shot where wind shear actually changes the trajectory"
422 );
423 }
424}