Skip to main content

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