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