1use 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 },
47 rifle: RifleV1 {
48 muzzle_velocity_mps: r.rifle.muzzle_velocity_mps,
49 sight_height_m: Some(r.rifle.sight_height_m),
50 muzzle_height_m: Some(r.rifle.muzzle_height_m),
51 twist_rate_m_per_turn: Some(r.rifle.twist_rate_m_per_turn),
52 twist_direction: Some(r.rifle.twist_direction),
53 sight_offset_lateral_m: r.rifle.sight_offset_lateral_m,
54 },
55 shot: ShotV1 {
56 max_range_m: r.shot.max_range_m,
57 zero_distance_m: r.shot.zero_distance_m,
58 muzzle_angle_rad: Some(r.shot.muzzle_angle_rad),
64 aim_azimuth_rad: Some(r.shot.aim_azimuth_rad),
65 shot_azimuth_rad: Some(r.shot.shot_azimuth_rad),
66 shooting_angle_rad: Some(r.shot.shooting_angle_rad),
67 cant_angle_rad: Some(r.shot.cant_angle_rad),
68 target_height_m: Some(r.shot.target_height_m),
69 ground_threshold_m: Some(r.shot.ground_threshold_m),
70 zero_poi_up_m: r.shot.zero_poi_up_m,
71 zero_poi_right_m: r.shot.zero_poi_right_m,
72 drops_reference: r.shot.drops_reference,
73 },
74 atmosphere: AtmosphereV1 {
75 altitude_m: Some(r.atmosphere.altitude_m),
76 temperature_k: Some(r.atmosphere.temperature_k),
77 pressure_pa: Some(r.atmosphere.pressure_pa),
78 pressure_reference: None,
81 relative_humidity: Some(r.atmosphere.relative_humidity),
82 latitude_rad: r.atmosphere.latitude_rad,
83 },
84 wind: wind_from_resolved(&r.wind),
85 solver: SolverV1 {
86 method: Some(r.solver.method),
87 time_step_s: Some(r.solver.time_step_s),
88 },
89 effects: EffectsV1 {
90 magnus: Some(r.effects.magnus),
91 coriolis: Some(r.effects.coriolis),
92 enhanced_spin_drift: Some(r.effects.enhanced_spin_drift),
93 },
94 sampling: SamplingV1 {
95 interval_m: Some(r.sampling.interval_m),
96 },
97 reticle: r.reticle.clone(),
98 }
99 }
100}
101
102fn wind_from_resolved(w: &ResolvedWindV1) -> WindV1 {
103 match w {
104 ResolvedWindV1::Constant(c) => WindV1 {
105 speed_mps: Some(c.speed_mps),
106 direction_from_rad: Some(c.direction_from_rad),
107 vertical_speed_mps: Some(c.vertical_speed_mps),
108 segments: None,
109 wind_reference: None,
112 },
113 ResolvedWindV1::Segmented(s) => WindV1 {
114 speed_mps: None,
115 direction_from_rad: None,
116 vertical_speed_mps: None,
117 segments: Some(
118 s.segments
119 .iter()
120 .map(|g| WindSegmentV1 {
121 until_distance_m: g.until_distance_m,
122 speed_mps: g.speed_mps,
123 direction_from_rad: g.direction_from_rad,
124 vertical_speed_mps: Some(g.vertical_speed_mps),
125 })
126 .collect(),
127 ),
128 wind_reference: None,
130 },
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use crate::solve_json::decode_solve_request_v1;
137 use crate::solve_json::{PressureReferenceV1, ResolvedWindV1, SolveRequestV1, WindReferenceV1};
138 use crate::solve_v1::solve_v1;
139
140 fn sample_json() -> String {
141 serde_json::json!({
142 "schema_version": 1,
143 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
144 "ballistic_coefficient": 0.243},
145 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
146 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
147 "atmosphere": {"temperature_k": 288.0, "pressure_pa": 101325.0},
148 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
149 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
150 })
151 .to_string()
152 }
153
154 #[test]
163 fn resolution_is_idempotent_through_roundtrip() {
164 let first = solve_v1(decode_solve_request_v1(&sample_json()).unwrap()).unwrap();
165 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
166 let second = solve_v1(rebuilt).unwrap();
167 assert_eq!(
168 serde_json::to_value(&first.resolved_request).unwrap(),
169 serde_json::to_value(&second.resolved_request).unwrap(),
170 "resolved request changed after a round-trip"
171 );
172 assert_eq!(
173 first.summary, second.summary,
174 "summary changed after a round-trip"
175 );
176 assert_eq!(
177 first.samples, second.samples,
178 "samples changed after a round-trip"
179 );
180 }
181
182 #[test]
193 fn roundtrip_preserves_the_windage_zero_bias() {
194 let json = serde_json::json!({
195 "schema_version": 1,
196 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
197 "ballistic_coefficient": 0.243},
198 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
199 "sight_offset_lateral_m": 0.03},
200 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "zero_poi_right_m": 0.02},
201 "atmosphere": {"temperature_k": 288.0, "pressure_pa": 101325.0},
202 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
203 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
204 })
205 .to_string();
206 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
207 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
208 let second = solve_v1(rebuilt).unwrap();
209
210 assert_eq!(
211 serde_json::to_value(&first.resolved_request).unwrap(),
212 serde_json::to_value(&second.resolved_request).unwrap(),
213 "resolved request changed after a round-trip"
214 );
215 assert_eq!(
216 first.summary, second.summary,
217 "summary changed after a round-trip -- the windage-zero bias may have been dropped"
218 );
219 assert_eq!(
220 first.samples, second.samples,
221 "samples changed after a round-trip -- the windage-zero bias may have been dropped"
222 );
223 let windage_m = first
226 .samples
227 .last()
228 .expect("at least one sample")
229 .windage_m;
230 assert!(
231 windage_m.abs() > 0.1,
232 "fixture must produce a non-negligible windage-zero bias to be a meaningful test, \
233 got {windage_m} m"
234 );
235 }
236
237 #[test]
239 fn roundtrip_preserves_the_effective_muzzle_angle() {
240 let first = solve_v1(decode_solve_request_v1(&sample_json()).unwrap()).unwrap();
241 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
242 assert_eq!(
243 rebuilt.shot.muzzle_angle_rad,
244 Some(first.resolved_request.shot.muzzle_angle_rad)
245 );
246 assert_eq!(
247 rebuilt.shot.zero_distance_m,
248 first.resolved_request.shot.zero_distance_m
249 );
250 }
251
252 #[test]
260 fn roundtrip_does_not_double_reduce_a_qnh_pressure() {
261 let json = serde_json::json!({
262 "schema_version": 1,
263 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
264 "ballistic_coefficient": 0.243},
265 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
266 "shot": {"max_range_m": 900.0},
267 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
268 "pressure_reference": "qnh"},
269 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
270 })
271 .to_string();
272 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
273 assert_eq!(
274 first.resolved_request.atmosphere.pressure_reference,
275 Some(PressureReferenceV1::Qnh)
276 );
277
278 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
279 let second = solve_v1(rebuilt).unwrap();
280
281 assert_eq!(second.resolved_request.atmosphere.pressure_reference, None);
285 assert_eq!(
287 second.resolved_request.atmosphere.pressure_pa,
288 first.resolved_request.atmosphere.pressure_pa,
289 "a round-tripped QNH pressure must not be reduced a second time"
290 );
291 assert_eq!(
292 second.resolved_request.atmosphere.altitude_m,
293 first.resolved_request.atmosphere.altitude_m
294 );
295 assert_eq!(
296 second.resolved_request.atmosphere.temperature_k,
297 first.resolved_request.atmosphere.temperature_k
298 );
299 }
300
301 #[test]
309 fn roundtrip_does_not_double_reference_a_compass_wind() {
310 let json = serde_json::json!({
311 "schema_version": 1,
312 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
313 "ballistic_coefficient": 0.243},
314 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
315 "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
316 "atmosphere": {},
317 "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
318 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
319 })
320 .to_string();
321 let first = solve_v1(decode_solve_request_v1(&json).unwrap()).unwrap();
322 let ResolvedWindV1::Constant(first_wind) = &first.resolved_request.wind else {
323 panic!("constant wind expected");
324 };
325 assert_eq!(first_wind.wind_reference, Some(WindReferenceV1::Compass));
326 assert_ne!(first_wind.direction_from_rad, 1.0);
329 let first_direction_from_rad = first_wind.direction_from_rad;
330
331 let rebuilt: SolveRequestV1 = (&first.resolved_request).into();
332 let second = solve_v1(rebuilt).unwrap();
333 let ResolvedWindV1::Constant(second_wind) = &second.resolved_request.wind else {
334 panic!("constant wind expected");
335 };
336
337 assert_eq!(second_wind.wind_reference, None);
339 assert_eq!(
341 second_wind.direction_from_rad, first_direction_from_rad,
342 "a round-tripped compass wind must not be re-referenced a second time"
343 );
344 }
345}