ballistics_engine/perturbation/access.rs
1//! Reading and writing one taxonomy axis on a canonical request.
2//!
3//! `with_axis` produces a full `SolveRequestV1` via the Phase 0 reverse conversion
4//! (`request_roundtrip.rs`) and then overwrites exactly one field, so every other input is
5//! carried across unchanged.
6//!
7//! Two axes are enum-valued rather than scalar or boolean (`DragModel`:
8//! G1/G2/G5/G6/G7/G8/GI/GS/RA4; `TwistDirection`: left/right), so [`AxisValue`] carries a
9//! dedicated variant for each instead of round-tripping through a string -- a string round-trip
10//! would turn a typo into a runtime [`KernelError::TypeMismatch`] instead of a compile error.
11//!
12//! `with_axis` also refuses two axis/request combinations that would otherwise silently build
13//! a physically WRONG counterfactual, because [the reverse conversion](crate::solve_json)
14//! (see `request_roundtrip.rs`'s module doc) always emits absolute station pressure and
15//! shooter-relative wind regardless of how the original request entered them:
16//!
17//! - `Altitude` when the original request declared a QNH pressure: perturbing altitude would
18//! change air density-by-altitude without moving the QNH-referenced station pressure the
19//! way the caller means -- the opposite of the intended counterfactual.
20//! - `ShotAzimuth` when the original request declared compass-referenced wind: perturbing the
21//! shot azimuth would rotate the wind WITH the rifle instead of keeping it earth-fixed --
22//! physically inverted.
23//!
24//! Both are detected from the ORIGINAL resolved request's echoed reference mode (added in
25//! Task 1 precisely so this is detectable) and rejected with
26//! [`KernelError::AxisUnsupportedForRequest`] rather than producing a silently-wrong request.
27//! See `taxonomy.rs`'s "KNOWN LIMITATIONS" comment, items (a) and (b).
28//!
29//! A third check keeps `with_axis` consistent with `read_axis` rather than physics-aware: the
30//! three wind axes have no single scalar to write when the resolved wind is
31//! [`ResolvedWindV1::Segmented`] (taxonomy.rs Known Limitation (c)). `read_axis` already
32//! returns `None` for that combination; `with_axis` now returns [`KernelError::AxisAbsent`]
33//! for the same combination instead of silently writing a constant-wind field (`speed_mps`
34//! etc.) alongside the still-present `wind.segments`, which `solve_v1`'s `resolve_wind` would
35//! otherwise reject downstream as an opaque `$.wind` "segments cannot be combined with
36//! constant-wind fields" error.
37//!
38//! The zero-affecting-axis rezero clear (see `with_axis`'s body) is evaluated from
39//! `req.shot.zero_distance_m` **after** the axis has been written, not before: for the
40//! `ZeroDistance` axis itself, the value that matters is the NEW distance being written, not
41//! whatever `zero_distance_m` was on the original request (which may have been absent).
42//! Gating on the pre-write value would let a request originally specified purely by
43//! `muzzle_angle_rad` silently keep that stale angle after a `ZeroDistance` write, so
44//! `solve_v1` would skip the elevation search entirely (it takes an explicit angle over a
45//! zero distance whenever both are present) and the new zero distance would have no effect on
46//! elevation at all.
47
48use crate::perturbation::taxonomy::{axis_meta, InputAxis};
49use crate::solve_json::{
50 DragModelV1, PressureReferenceV1, ResolvedSolveRequestV1, ResolvedWindV1, SolveErrorCodeV1,
51 SolveRequestV1, TwistDirectionV1, WindReferenceV1,
52};
53use crate::trajectory_observation::TrajectoryObservationError;
54
55/// One taxonomy axis's value, read from or written to a request.
56///
57/// `Scalar`/`Flag` cover every continuous and boolean axis. `DragModel`/`TwistDirection`
58/// cover the two enum-valued axes losslessly -- see the module doc for why a `String` variant
59/// would be the wrong choice.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum AxisValue {
62 Scalar(f64),
63 Flag(bool),
64 DragModel(DragModelV1),
65 TwistDirection(TwistDirectionV1),
66}
67
68#[derive(Debug, Clone, PartialEq)]
69pub enum KernelError {
70 /// Reserved for a later (differentiation) task: an axis whose `axis_meta(axis).kind` is
71 /// `AxisKind::Categorical` was asked to be treated as continuous. Not constructed by
72 /// `read_axis`/`with_axis` themselves, which accept categorical axes just like any other.
73 CategoricalAxis(InputAxis),
74 /// The axis is structurally unavailable on THIS request's resolved shape -- currently only
75 /// the three wind axes when the resolved wind is `ResolvedWindV1::Segmented`, mirroring
76 /// `read_axis` returning `None` for the identical condition (see the module doc).
77 AxisAbsent(InputAxis),
78 /// The `AxisValue` variant supplied to `with_axis` does not match what `axis` expects
79 /// (e.g. a `Scalar` for `DragModel`, or a `Flag` for any continuous axis).
80 TypeMismatch(InputAxis),
81 /// The axis is well-formed and present, but this particular request's OTHER inputs make
82 /// perturbing it physically wrong rather than merely unrepresentable -- see the guards
83 /// documented on `with_axis`.
84 AxisUnsupportedForRequest {
85 axis: InputAxis,
86 reason: &'static str,
87 },
88 /// The rebuilt request failed to resolve or solve. Not constructed here -- `with_axis` only
89 /// builds the request, it does not solve it; [`crate::perturbation::evaluate`] (0.33.0
90 /// decision-support Task 6) is the constructor, uniformly for every stage that can fail
91 /// (`solve_v1::prepare_request`, `solve_v1::build_zeroed_solver`, and
92 /// `TrajectorySolver::solve`).
93 ///
94 /// `code` is carried alongside `message` (review fix I4(a), `derive.rs`) specifically so a
95 /// caller -- most notably `central_difference`'s one-sided fallback -- can tell a domain
96 /// rejection (`SolveErrorCodeV1::InvalidValue`, produced by `require_range`/
97 /// `require_non_negative`/`require_positive` in `solve_v1.rs`) apart from a genuine solver
98 /// failure (`SolveFailed`, `ResourceLimit`, `InternalError`) or a malformed-request bug
99 /// (`ConflictingFields`, `MissingField`, `UnknownField`, ...) that merely happened to be
100 /// triggered by one particular perturbed value. Collapsing this to a `String` (the previous
101 /// shape) made that distinction unrecoverable except by parsing the message.
102 Solve {
103 code: SolveErrorCodeV1,
104 message: String,
105 },
106 /// Post-solve observation extraction failed -- most notably a requested range outside the
107 /// computed trajectory. Not constructed here for the same reason as `Solve`;
108 /// [`crate::perturbation::evaluate`] constructs it directly from a
109 /// [`crate::trajectory_observation::TrajectoryObservationError`], preserved WHOLE (not
110 /// collapsed to its `Display` string) for the same reason as `Solve`'s `code` above: only
111 /// [`TrajectoryObservationError::OutOfRange`] is a domain rejection eligible for
112 /// `central_difference`'s one-sided fallback -- `NonMonotonicTrajectory`, `NonFiniteState`,
113 /// `SampleLimitExceeded`, `AllocationFailed`, and the rest are genuine bugs that must
114 /// propagate, not be silently reinterpreted as "this side left the domain."
115 Observation(TrajectoryObservationError),
116 /// A `Scalar` value supplied to `with_axis` was not finite (NaN or infinite).
117 NonFinite(InputAxis),
118 /// Both perturbed sides of a central difference failed to produce a usable observation --
119 /// neither `x + attempted` nor `x - attempted` evaluates -- so not even a one-sided
120 /// difference can be built (`central_difference`'s domain fallback,
121 /// `src/perturbation/derive.rs`). Distinct from a derivative of zero: this means
122 /// "undifferentiable with this step," never "no effect."
123 StepOutOfDomain { axis: InputAxis, attempted: f64 },
124 /// The same axis was declared more than once in a caller-supplied source list. Not
125 /// constructed by `read_axis`/`with_axis`/`central_difference` (none of which see more than
126 /// one axis at a time) -- `crate::error_budget::error_budget` (0.33.0 decision-support Task
127 /// 10, MBA-1347 review) constructs this from its own up-front validation, because two
128 /// entries for the same axis would double-count that axis's variance and corrupt its
129 /// leave-one-out counterfactual (removing "the" declaration for that axis is ambiguous when
130 /// there are two). `KernelError` had not shipped on any released version when this variant
131 /// was added, so there is no compatibility concern in extending it.
132 DuplicateAxis(InputAxis),
133 /// The domain supplied to `crate::tolerance::tolerance_envelope` for one axis is missing, or
134 /// does not have the shape a one-variable bisection needs: both bounds finite, the lower
135 /// bound strictly less than the upper, and the axis's own current value strictly between
136 /// them. A domain where the current value sits AT (not strictly inside) one edge would make
137 /// that search direction a zero-width probe -- `bisect_axis` would report `Ok(None)` for it
138 /// (the two identical endpoints trivially agree), and that `None` would be indistinguishable
139 /// from a genuine "stays inside throughout" result even though nothing beyond the current
140 /// value was ever actually searched. 0.33.0 decision-support Task 12 (MBA-1350): never
141 /// constructed by `with_axis`/`evaluate`/`bisect_axis` themselves -- `tolerance_envelope`
142 /// validates its own domain argument up front, before any solve, the same way
143 /// `crate::error_budget::error_budget` validates its `ranges_m`/`sources` arguments.
144 InvalidDomain {
145 axis: InputAxis,
146 reason: &'static str,
147 },
148}
149
150impl std::fmt::Display for KernelError {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 match self {
153 KernelError::CategoricalAxis(a) => {
154 write!(f, "axis {a:?} is categorical and cannot be differentiated")
155 }
156 KernelError::AxisAbsent(a) => write!(f, "axis {a:?} is not present in this request"),
157 KernelError::TypeMismatch(a) => write!(f, "value type does not match axis {a:?}"),
158 KernelError::AxisUnsupportedForRequest { axis, reason } => {
159 write!(
160 f,
161 "axis {axis:?} is not supported for this request: {reason}"
162 )
163 }
164 KernelError::Solve { code, message } => write!(f, "solve failed ({code:?}): {message}"),
165 KernelError::Observation(e) => write!(f, "observation failed: {e}"),
166 KernelError::NonFinite(a) => write!(f, "axis {a:?} produced a non-finite result"),
167 KernelError::StepOutOfDomain { axis, attempted } => write!(
168 f,
169 "axis {axis:?} could not be differentiated with step {attempted}: both the \
170 forward and backward perturbed values failed to evaluate"
171 ),
172 KernelError::DuplicateAxis(a) => {
173 write!(f, "axis {a:?} was declared more than once")
174 }
175 KernelError::InvalidDomain { axis, reason } => {
176 write!(
177 f,
178 "invalid tolerance-envelope domain for axis {axis:?}: {reason}"
179 )
180 }
181 }
182 }
183}
184impl std::error::Error for KernelError {}
185
186impl KernelError {
187 /// True when this error means the specific perturbed VALUE fell outside the axis's physical
188 /// domain -- an `InvalidValue` solve rejection (from `require_range`/`require_non_negative`/
189 /// `require_positive` in `solve_v1.rs`), or an `OutOfRange` observation query (a requested
190 /// range that fell outside a trajectory shrunk by the perturbation) -- as opposed to a
191 /// genuine solver or trajectory failure that merely happened to be triggered by one
192 /// particular perturbed value.
193 ///
194 /// This is the ONLY thing [`crate::perturbation::central_difference`]'s one-sided fallback
195 /// (`derive.rs`) may gate on. Review fix I4(a): an earlier revision gated on `Err(_)`
196 /// (any failure at all on one side), which silently reinterpreted genuine bugs -- a
197 /// non-convergent zero search, a non-finite trajectory state, a sample-limit overrun -- as
198 /// if they were domain boundaries, answering with a fabricated derivative instead of
199 /// reporting the real failure.
200 pub fn is_domain_rejection(&self) -> bool {
201 matches!(
202 self,
203 KernelError::Solve {
204 code: SolveErrorCodeV1::InvalidValue,
205 ..
206 } | KernelError::Observation(TrajectoryObservationError::OutOfRange { .. })
207 )
208 }
209}
210
211/// The request's wind-reference echo, whichever wind shape it resolved to.
212///
213/// `pub(crate)` (0.33.0 decision-support Task 9, review round 4): `explain.rs`'s
214/// `is_compass_referenced` needs the identical lookup to detect the `WindDirection` derived-
215/// value hazard under compass wind (a different axis than the `ShotAzimuth` guard just below,
216/// which is what this function was written for), so it shares this one instead of duplicating
217/// the two-arm match a second time.
218pub(crate) fn wind_reference_of(w: &ResolvedWindV1) -> Option<WindReferenceV1> {
219 match w {
220 ResolvedWindV1::Constant(c) => c.wind_reference,
221 ResolvedWindV1::Segmented(s) => s.wind_reference,
222 }
223}
224
225/// Read the current value of one taxonomy axis off a resolved request.
226///
227/// Returns `None` in two DIFFERENT situations a caller should not conflate:
228/// - the axis is an optional request field that was never supplied (`Length`, `Latitude`,
229/// `ZeroPoiUp`/`ZeroPoiRight`, `SightOffsetLateral`) -- the axis exists, it just has no
230/// value on this particular request; or
231/// - the axis is structurally unavailable given how a sibling field resolved -- currently only
232/// `WindSpeed`/`WindDirection`/`WindVertical` under `ResolvedWindV1::Segmented`, where there
233/// is no single scalar wind value to read at all (taxonomy.rs Known Limitation (c)).
234///
235/// Either way, `None` means "there is nothing to perturb here": a caller sweeping
236/// `InputAxis::ALL` should skip the axis, never invent a value for it.
237pub fn read_axis(r: &ResolvedSolveRequestV1, axis: InputAxis) -> Option<AxisValue> {
238 use InputAxis::*;
239 let wind_speed = match &r.wind {
240 ResolvedWindV1::Constant(c) => Some(c.speed_mps),
241 ResolvedWindV1::Segmented(_) => None,
242 };
243 let wind_dir = match &r.wind {
244 ResolvedWindV1::Constant(c) => Some(c.direction_from_rad),
245 ResolvedWindV1::Segmented(_) => None,
246 };
247 let wind_vert = match &r.wind {
248 ResolvedWindV1::Constant(c) => Some(c.vertical_speed_mps),
249 ResolvedWindV1::Segmented(_) => None,
250 };
251 Some(match axis {
252 Mass => AxisValue::Scalar(r.projectile.mass_kg),
253 Diameter => AxisValue::Scalar(r.projectile.diameter_m),
254 Length => AxisValue::Scalar(r.projectile.length_m?),
255 BallisticCoefficient => AxisValue::Scalar(r.projectile.ballistic_coefficient),
256 TwistRate => AxisValue::Scalar(r.rifle.twist_rate_m_per_turn),
257 TwistDirection => AxisValue::TwistDirection(r.rifle.twist_direction),
258 DragModel => AxisValue::DragModel(r.projectile.drag_model),
259 MuzzleVelocityMps => AxisValue::Scalar(r.rifle.muzzle_velocity_mps),
260 SightHeight => AxisValue::Scalar(r.rifle.sight_height_m),
261 ZeroDistance => AxisValue::Scalar(r.shot.zero_distance_m?),
262 ZeroPoiUp => AxisValue::Scalar(r.shot.zero_poi_up_m?),
263 ZeroPoiRight => AxisValue::Scalar(r.shot.zero_poi_right_m?),
264 SightOffsetLateral => AxisValue::Scalar(r.rifle.sight_offset_lateral_m?),
265 MuzzleHeight => AxisValue::Scalar(r.rifle.muzzle_height_m),
266 MuzzleAngle => AxisValue::Scalar(r.shot.muzzle_angle_rad),
267 Altitude => AxisValue::Scalar(r.atmosphere.altitude_m),
268 Temperature => AxisValue::Scalar(r.atmosphere.temperature_k),
269 Pressure => AxisValue::Scalar(r.atmosphere.pressure_pa),
270 RelativeHumidity => AxisValue::Scalar(r.atmosphere.relative_humidity),
271 Latitude => AxisValue::Scalar(r.atmosphere.latitude_rad?),
272 WindSpeed => AxisValue::Scalar(wind_speed?),
273 WindDirection => AxisValue::Scalar(wind_dir?),
274 WindVertical => AxisValue::Scalar(wind_vert?),
275 TargetDistance => AxisValue::Scalar(r.shot.max_range_m),
276 ShootingAngle => AxisValue::Scalar(r.shot.shooting_angle_rad),
277 Cant => AxisValue::Scalar(r.shot.cant_angle_rad),
278 ShotAzimuth => AxisValue::Scalar(r.shot.shot_azimuth_rad),
279 AimAzimuth => AxisValue::Scalar(r.shot.aim_azimuth_rad),
280 TargetHeight => AxisValue::Scalar(r.shot.target_height_m),
281 MagnusEnabled => AxisValue::Flag(r.effects.magnus),
282 CoriolisEnabled => AxisValue::Flag(r.effects.coriolis),
283 EnhancedSpinDriftEnabled => AxisValue::Flag(r.effects.enhanced_spin_drift),
284 })
285}
286
287/// Rebuild `r` as a solvable [`SolveRequestV1`] with exactly one axis overwritten to `v`.
288///
289/// Every other input is carried across unchanged via the reverse conversion
290/// (`request_roundtrip.rs`). Writing a `requires_rezero` axis (see
291/// [`crate::perturbation::axis_meta`]) while a `zero_distance_m` is present on the REBUILT
292/// request clears the carried effective `muzzle_angle_rad`, so the next solve re-zeroes at
293/// the (possibly just-changed) distance instead of reusing a stale angle -- see the module
294/// doc for why this is checked after the axis is written, not before.
295///
296/// # Errors
297///
298/// - [`KernelError::AxisUnsupportedForRequest`] if `axis` is well-formed and present, but this
299/// request's OTHER inputs make perturbing it physically wrong (the two guards in the module
300/// doc: `Altitude` under QNH pressure, `ShotAzimuth` under compass wind).
301/// - [`KernelError::AxisAbsent`] if `axis` is structurally unavailable on this request (the
302/// three wind axes under segmented wind), mirroring `read_axis` returning `None` for the
303/// same condition.
304/// - [`KernelError::TypeMismatch`] if `v`'s kind does not match `axis` (e.g. a `Scalar` for
305/// `DragModel`).
306/// - [`KernelError::NonFinite`] if `v` is a non-finite `Scalar`.
307pub fn with_axis(
308 r: &ResolvedSolveRequestV1,
309 axis: InputAxis,
310 v: AxisValue,
311) -> Result<SolveRequestV1, KernelError> {
312 // Physics guards (see the module doc): detected from the ORIGINAL resolved request's
313 // echoed reference mode, before anything else, so a dangerous combination never reaches
314 // the reverse conversion below.
315 if axis == InputAxis::Altitude
316 && r.atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh)
317 {
318 return Err(KernelError::AxisUnsupportedForRequest {
319 axis,
320 reason: "the original request declared a QNH pressure_reference; the rebuilt \
321 request always carries absolute station pressure (request_roundtrip.rs \
322 cannot re-derive the original altimeter setting), so perturbing altitude \
323 would change air density-by-altitude without moving the QNH-referenced \
324 station pressure the way the caller means",
325 });
326 }
327 if axis == InputAxis::ShotAzimuth
328 && wind_reference_of(&r.wind) == Some(WindReferenceV1::Compass)
329 {
330 return Err(KernelError::AxisUnsupportedForRequest {
331 axis,
332 reason: "the original request declared compass-referenced wind; the rebuilt \
333 request always carries shooter-relative wind (request_roundtrip.rs \
334 cannot re-derive the original earth-fixed bearing), so perturbing the \
335 shot azimuth would rotate the wind WITH the rifle instead of keeping it \
336 earth-fixed",
337 });
338 }
339 // Third check (see the module doc): keeps with_axis consistent with read_axis, which
340 // already returns None for these three axes under segmented wind. Without this, writing
341 // e.g. WindSpeed here would set req.wind.speed_mps alongside the still-present
342 // req.wind.segments, which solve_v1's resolve_wind rejects downstream as an opaque
343 // "segments cannot be combined with constant-wind fields" error instead of this specific,
344 // named one.
345 if matches!(
346 axis,
347 InputAxis::WindSpeed | InputAxis::WindDirection | InputAxis::WindVertical
348 ) && matches!(r.wind, ResolvedWindV1::Segmented(_))
349 {
350 return Err(KernelError::AxisAbsent(axis));
351 }
352
353 let mut req: SolveRequestV1 = r.into();
354 let scalar = |v: AxisValue| -> Result<f64, KernelError> {
355 match v {
356 AxisValue::Scalar(x) if x.is_finite() => Ok(x),
357 AxisValue::Scalar(_) => Err(KernelError::NonFinite(axis)),
358 _ => Err(KernelError::TypeMismatch(axis)),
359 }
360 };
361 let flag = |v: AxisValue| -> Result<bool, KernelError> {
362 match v {
363 AxisValue::Flag(b) => Ok(b),
364 _ => Err(KernelError::TypeMismatch(axis)),
365 }
366 };
367 let drag_model = |v: AxisValue| -> Result<DragModelV1, KernelError> {
368 match v {
369 AxisValue::DragModel(m) => Ok(m),
370 _ => Err(KernelError::TypeMismatch(axis)),
371 }
372 };
373 let twist_direction = |v: AxisValue| -> Result<TwistDirectionV1, KernelError> {
374 match v {
375 AxisValue::TwistDirection(d) => Ok(d),
376 _ => Err(KernelError::TypeMismatch(axis)),
377 }
378 };
379 use InputAxis::*;
380 match axis {
381 Mass => req.projectile.mass_kg = scalar(v)?,
382 Diameter => req.projectile.diameter_m = scalar(v)?,
383 Length => req.projectile.length_m = Some(scalar(v)?),
384 BallisticCoefficient => {
385 let bc = scalar(v)?;
386 req.projectile.ballistic_coefficient = bc;
387 // A one-band ladder's BC must equal the scalar (MBA-1598), and it is the BC that
388 // flies at every speed, so the axis moves the band with the scalar. A longer
389 // ladder's bands are not an axis (see `axes_in_group`).
390 if let Some([band]) = req.projectile.bc_segments.as_deref_mut() {
391 band.ballistic_coefficient = bc;
392 }
393 }
394 TwistRate => req.rifle.twist_rate_m_per_turn = Some(scalar(v)?),
395 TwistDirection => req.rifle.twist_direction = Some(twist_direction(v)?),
396 DragModel => req.projectile.drag_model = drag_model(v)?,
397 MuzzleVelocityMps => req.rifle.muzzle_velocity_mps = scalar(v)?,
398 SightHeight => req.rifle.sight_height_m = Some(scalar(v)?),
399 ZeroDistance => req.shot.zero_distance_m = Some(scalar(v)?),
400 ZeroPoiUp => req.shot.zero_poi_up_m = Some(scalar(v)?),
401 ZeroPoiRight => req.shot.zero_poi_right_m = Some(scalar(v)?),
402 SightOffsetLateral => req.rifle.sight_offset_lateral_m = Some(scalar(v)?),
403 MuzzleHeight => req.rifle.muzzle_height_m = Some(scalar(v)?),
404 MuzzleAngle => req.shot.muzzle_angle_rad = Some(scalar(v)?),
405 Altitude => req.atmosphere.altitude_m = Some(scalar(v)?),
406 Temperature => req.atmosphere.temperature_k = Some(scalar(v)?),
407 Pressure => req.atmosphere.pressure_pa = Some(scalar(v)?),
408 RelativeHumidity => req.atmosphere.relative_humidity = Some(scalar(v)?),
409 Latitude => req.atmosphere.latitude_rad = Some(scalar(v)?),
410 WindSpeed => req.wind.speed_mps = Some(scalar(v)?),
411 WindDirection => req.wind.direction_from_rad = Some(scalar(v)?),
412 WindVertical => req.wind.vertical_speed_mps = Some(scalar(v)?),
413 TargetDistance => req.shot.max_range_m = scalar(v)?,
414 ShootingAngle => req.shot.shooting_angle_rad = Some(scalar(v)?),
415 Cant => req.shot.cant_angle_rad = Some(scalar(v)?),
416 ShotAzimuth => req.shot.shot_azimuth_rad = Some(scalar(v)?),
417 AimAzimuth => req.shot.aim_azimuth_rad = Some(scalar(v)?),
418 TargetHeight => req.shot.target_height_m = Some(scalar(v)?),
419 MagnusEnabled => req.effects.magnus = Some(flag(v)?),
420 CoriolisEnabled => req.effects.coriolis = Some(flag(v)?),
421 EnhancedSpinDriftEnabled => req.effects.enhanced_spin_drift = Some(flag(v)?),
422 }
423 // Changing a zero-affecting axis invalidates the stored effective angle: drop it so the
424 // service re-zeroes from zero_distance_m rather than reusing a stale angle. Checked AFTER
425 // the match (not before, which was a bug -- see the module doc): for the ZeroDistance axis
426 // itself, this must react to the NEW distance just written, not whatever zero_distance_m
427 // was on the original request.
428 if axis_meta(axis).requires_rezero && req.shot.zero_distance_m.is_some() {
429 req.shot.muzzle_angle_rad = None;
430 }
431 Ok(req)
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::perturbation::InputAxis;
438
439 /// Non-default values for every axis this file's tests touch, INCLUDING the five
440 /// previously-omitted optional fields (`length_m`, `zero_poi_up_m`, `zero_poi_right_m`,
441 /// `sight_offset_lateral_m`, `latitude_rad`) so that all 32 taxonomy axes -- not just 27
442 /// of them -- read back `Some(..)` here. Without these five, `read_axis` returns `None`
443 /// for `Length`/`ZeroPoiUp`/`ZeroPoiRight`/`SightOffsetLateral`/`Latitude` on this fixture
444 /// and a naive per-axis loop (see `every_present_axis_round_trips_without_disturbing_any_other_field`
445 /// below) would silently skip exactly the pair -- `ZeroPoiUp`/`ZeroPoiRight` -- most likely
446 /// to be transposed by a future edit.
447 fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
448 let json = serde_json::json!({
449 "schema_version": 1,
450 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
451 "ballistic_coefficient": 0.243, "length_m": 0.032},
452 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
453 "muzzle_height_m": 0.02, "twist_rate_m_per_turn": 0.2794,
454 "twist_direction": "left", "sight_offset_lateral_m": 0.03},
455 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "target_height_m": 0.3,
456 "zero_poi_up_m": 0.01, "zero_poi_right_m": 0.02},
457 "atmosphere": {"latitude_rad": 0.6},
458 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
459 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
460 })
461 .to_string();
462 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
463 crate::solve_v1::solve_v1(req).unwrap().resolved_request
464 }
465
466 #[test]
467 fn read_then_write_is_identity() {
468 let r = resolved();
469 let v = read_axis(&r, InputAxis::MuzzleVelocityMps).expect("axis present");
470 let rebuilt = with_axis(&r, InputAxis::MuzzleVelocityMps, v).unwrap();
471 assert_eq!(
472 rebuilt.rifle.muzzle_velocity_mps,
473 r.rifle.muzzle_velocity_mps
474 );
475 }
476
477 #[test]
478 fn writing_an_axis_changes_only_that_axis() {
479 let r = resolved();
480 let changed =
481 with_axis(&r, InputAxis::MuzzleVelocityMps, AxisValue::Scalar(900.0)).unwrap();
482 let baseline: crate::solve_json::SolveRequestV1 = (&r).into();
483 assert_eq!(changed.rifle.muzzle_velocity_mps, 900.0);
484 assert_eq!(
485 changed.atmosphere.pressure_pa,
486 baseline.atmosphere.pressure_pa
487 );
488 assert_eq!(changed.wind.speed_mps, baseline.wind.speed_mps);
489 assert_eq!(changed.shot.max_range_m, baseline.shot.max_range_m);
490 // The five axes added after the brief was written must also be carried unchanged.
491 assert_eq!(
492 changed.projectile.drag_model,
493 baseline.projectile.drag_model
494 );
495 assert_eq!(
496 changed.rifle.twist_rate_m_per_turn,
497 baseline.rifle.twist_rate_m_per_turn
498 );
499 assert_eq!(
500 changed.rifle.twist_direction,
501 baseline.rifle.twist_direction
502 );
503 assert_eq!(
504 changed.rifle.muzzle_height_m,
505 baseline.rifle.muzzle_height_m
506 );
507 assert_eq!(changed.shot.target_height_m, baseline.shot.target_height_m);
508 }
509
510 #[test]
511 fn writing_a_flag_into_a_scalar_axis_is_a_type_error() {
512 let r = resolved();
513 let e = with_axis(&r, InputAxis::MuzzleVelocityMps, AxisValue::Flag(true));
514 assert!(matches!(e, Err(KernelError::TypeMismatch(_))));
515 }
516
517 #[test]
518 fn effect_flags_round_trip() {
519 let r = resolved();
520 let changed = with_axis(&r, InputAxis::CoriolisEnabled, AxisValue::Flag(true)).unwrap();
521 assert_eq!(changed.effects.coriolis, Some(true));
522 }
523
524 /// DragModel is a nine-way enum (G1/G2/G5/G6/G7/G8/GI/GS/RA4), not a bool or float:
525 /// AxisValue needs a dedicated variant to carry it losslessly (see the module doc).
526 #[test]
527 fn drag_model_axis_reads_and_writes_the_enum_value() {
528 let r = resolved();
529 let v = read_axis(&r, InputAxis::DragModel).expect("axis present");
530 assert_eq!(v, AxisValue::DragModel(DragModelV1::G7));
531
532 let changed = with_axis(
533 &r,
534 InputAxis::DragModel,
535 AxisValue::DragModel(DragModelV1::G1),
536 )
537 .unwrap();
538 assert_eq!(changed.projectile.drag_model, DragModelV1::G1);
539 // Nothing else moved.
540 assert_eq!(changed.projectile.mass_kg, r.projectile.mass_kg);
541 assert_eq!(
542 changed.projectile.ballistic_coefficient,
543 r.projectile.ballistic_coefficient
544 );
545 }
546
547 /// TwistDirection is a two-way enum (left/right); same rationale as DragModel above.
548 #[test]
549 fn twist_direction_axis_reads_and_writes_the_enum_value() {
550 let r = resolved();
551 let v = read_axis(&r, InputAxis::TwistDirection).expect("axis present");
552 assert_eq!(v, AxisValue::TwistDirection(TwistDirectionV1::Left));
553
554 let changed = with_axis(
555 &r,
556 InputAxis::TwistDirection,
557 AxisValue::TwistDirection(TwistDirectionV1::Right),
558 )
559 .unwrap();
560 assert_eq!(changed.rifle.twist_direction, Some(TwistDirectionV1::Right));
561 }
562
563 /// Extending AxisValue with enum variants must not weaken the existing type-checking:
564 /// a scalar is still rejected for an enum-valued axis.
565 #[test]
566 fn writing_a_scalar_into_the_drag_model_axis_is_a_type_error() {
567 let r = resolved();
568 let e = with_axis(&r, InputAxis::DragModel, AxisValue::Scalar(1.0));
569 assert!(matches!(
570 e,
571 Err(KernelError::TypeMismatch(InputAxis::DragModel))
572 ));
573 }
574
575 /// ...and the reverse: an enum value is rejected for a scalar axis.
576 #[test]
577 fn writing_a_drag_model_into_a_scalar_axis_is_a_type_error() {
578 let r = resolved();
579 let e = with_axis(&r, InputAxis::Mass, AxisValue::DragModel(DragModelV1::G1));
580 assert!(matches!(e, Err(KernelError::TypeMismatch(InputAxis::Mass))));
581 }
582
583 /// ...and the two enum-valued axes are not interchangeable with EACH OTHER either: both
584 /// are "enum-shaped" AxisValue variants now, so it's worth confirming DragModel's closure
585 /// rejects a TwistDirection value (and not just a Scalar/Flag) as a TypeMismatch.
586 #[test]
587 fn writing_a_twist_direction_into_the_drag_model_axis_is_a_type_error() {
588 let r = resolved();
589 let e = with_axis(
590 &r,
591 InputAxis::DragModel,
592 AxisValue::TwistDirection(TwistDirectionV1::Left),
593 );
594 assert!(matches!(
595 e,
596 Err(KernelError::TypeMismatch(InputAxis::DragModel))
597 ));
598 }
599
600 /// Read/write identity for three of the five axes added after the brief was written,
601 /// exercising each field mapping directly rather than only checking non-interference.
602 #[test]
603 fn twist_rate_and_muzzle_height_and_target_height_round_trip() {
604 let r = resolved();
605 for axis in [
606 InputAxis::TwistRate,
607 InputAxis::MuzzleHeight,
608 InputAxis::TargetHeight,
609 ] {
610 let v = read_axis(&r, axis).unwrap_or_else(|| panic!("{axis:?} should be present"));
611 let rebuilt = with_axis(&r, axis, v).unwrap();
612 match (axis, v) {
613 (InputAxis::TwistRate, AxisValue::Scalar(x)) => {
614 assert_eq!(rebuilt.rifle.twist_rate_m_per_turn, Some(x))
615 }
616 (InputAxis::MuzzleHeight, AxisValue::Scalar(x)) => {
617 assert_eq!(rebuilt.rifle.muzzle_height_m, Some(x))
618 }
619 (InputAxis::TargetHeight, AxisValue::Scalar(x)) => {
620 assert_eq!(rebuilt.shot.target_height_m, Some(x))
621 }
622 _ => panic!("expected a scalar for {axis:?}"),
623 }
624 }
625 }
626
627 /// read_axis returns None for the three wind axes under segmented wind: there is no
628 /// single scalar to perturb (taxonomy.rs Known Limitation (c)). This is intended -- a
629 /// caller must treat the None as "axis absent," never invent a uniform per-segment
630 /// perturbation. with_axis must AGREE with read_axis about this (I4): it must not
631 /// silently write a constant-wind field alongside the still-present wind.segments, which
632 /// solve_v1 would later reject downstream as an opaque, differently-worded error.
633 #[test]
634 fn wind_axes_are_absent_under_segmented_wind() {
635 let json = serde_json::json!({
636 "schema_version": 1,
637 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
638 "ballistic_coefficient": 0.243},
639 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
640 "shot": {"max_range_m": 900.0},
641 "atmosphere": {},
642 "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
643 "direction_from_rad": 1.0}]},
644 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
645 })
646 .to_string();
647 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
648 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
649 assert!(matches!(r.wind, ResolvedWindV1::Segmented(_)));
650
651 assert_eq!(read_axis(&r, InputAxis::WindSpeed), None);
652 assert_eq!(read_axis(&r, InputAxis::WindDirection), None);
653 assert_eq!(read_axis(&r, InputAxis::WindVertical), None);
654
655 for axis in [
656 InputAxis::WindSpeed,
657 InputAxis::WindDirection,
658 InputAxis::WindVertical,
659 ] {
660 let e = with_axis(&r, axis, AxisValue::Scalar(1.0));
661 assert!(
662 matches!(e, Err(KernelError::AxisAbsent(a)) if a == axis),
663 "{axis:?}: expected AxisAbsent, got {e:?}"
664 );
665 }
666 }
667
668 /// Regression test (I1): the rezero-clear used to be gated on the PRE-write
669 /// `zero_distance_m`, evaluated before the match instead of after. For a request
670 /// originally specified purely by `muzzle_angle_rad` (no `zero_distance_m` at all),
671 /// writing `ZeroDistance` would then see the ORIGINAL (absent) zero distance, decide there
672 /// was nothing to gate on, and leave the carried angle in place. The rebuilt request would
673 /// carry both a brand new `zero_distance_m` AND the stale `muzzle_angle_rad`, and
674 /// `solve_v1` always prefers an explicit angle over a zero distance when both are present
675 /// (see the module doc), so the elevation search would never run: the new zero distance
676 /// would have zero effect on elevation, and a sensitivity sweep over `ZeroDistance` would
677 /// silently report "no effect" instead of the truth.
678 #[test]
679 fn writing_zero_distance_onto_an_angle_only_request_clears_the_carried_angle() {
680 let json = serde_json::json!({
681 "schema_version": 1,
682 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
683 "ballistic_coefficient": 0.243},
684 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
685 "shot": {"max_range_m": 900.0, "muzzle_angle_rad": 0.01},
686 "atmosphere": {}, "wind": {},
687 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
688 })
689 .to_string();
690 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
691 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
692 // Sanity check on the fixture: this request has NO zero_distance_m at all, only a
693 // directly-supplied angle, so the bug's precondition actually holds.
694 assert_eq!(r.shot.zero_distance_m, None);
695 assert_eq!(r.shot.muzzle_angle_rad, 0.01);
696
697 let rebuilt = with_axis(&r, InputAxis::ZeroDistance, AxisValue::Scalar(100.0)).unwrap();
698 assert_eq!(rebuilt.shot.zero_distance_m, Some(100.0));
699 assert_eq!(
700 rebuilt.shot.muzzle_angle_rad, None,
701 "the carried angle must be cleared so solve_v1 actually re-zeroes at the new \
702 distance instead of skipping the elevation search because an explicit angle was \
703 still present"
704 );
705 }
706
707 /// I2, assertion 1 of 3 ("clears"): a `requires_rezero` axis clears the carried
708 /// `muzzle_angle_rad` when a `zero_distance_m` is present.
709 #[test]
710 fn rezero_axis_clears_the_carried_muzzle_angle_when_a_zero_distance_is_present() {
711 let r = resolved(); // zero_distance_m = Some(100.0) in this fixture.
712 assert!(r.shot.zero_distance_m.is_some());
713 assert!(
714 axis_meta(InputAxis::Mass).requires_rezero,
715 "fixture assumption: Mass must be a requires_rezero axis for this test to mean \
716 anything"
717 );
718
719 let changed = with_axis(&r, InputAxis::Mass, AxisValue::Scalar(0.02)).unwrap();
720 assert_eq!(changed.shot.muzzle_angle_rad, None);
721 }
722
723 /// I2, assertion 2 of 3 ("preserves"): a non-`requires_rezero` axis leaves the carried
724 /// `muzzle_angle_rad` untouched, even when a `zero_distance_m` is present. (Perturbing
725 /// `MuzzleVelocityMps` in `writing_an_axis_changes_only_that_axis` does NOT demonstrate
726 /// this -- that axis IS a rezero axis, so its angle is expected to change; that test never
727 /// asserts on `muzzle_angle_rad` at all.)
728 #[test]
729 fn non_rezero_axis_preserves_the_carried_muzzle_angle() {
730 let r = resolved(); // zero_distance_m = Some(100.0) in this fixture.
731 assert!(r.shot.zero_distance_m.is_some());
732 assert!(
733 !axis_meta(InputAxis::WindSpeed).requires_rezero,
734 "fixture assumption: WindSpeed must NOT be a requires_rezero axis for this test to \
735 mean anything"
736 );
737
738 let changed = with_axis(&r, InputAxis::WindSpeed, AxisValue::Scalar(5.0)).unwrap();
739 assert_eq!(changed.shot.muzzle_angle_rad, Some(r.shot.muzzle_angle_rad));
740 }
741
742 /// I2, assertion 3 of 3 ("the gate"): a `requires_rezero` axis does NOT clear the carried
743 /// angle when there is no `zero_distance_m` to begin with -- the clearing is gated on
744 /// `zero_distance_m` being present, not on `requires_rezero` alone.
745 #[test]
746 fn rezero_axis_does_not_clear_the_angle_when_no_zero_distance_is_present() {
747 let json = serde_json::json!({
748 "schema_version": 1,
749 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
750 "ballistic_coefficient": 0.243},
751 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
752 "shot": {"max_range_m": 900.0, "muzzle_angle_rad": 0.01},
753 "atmosphere": {}, "wind": {},
754 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
755 })
756 .to_string();
757 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
758 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
759 assert_eq!(r.shot.zero_distance_m, None);
760 assert!(axis_meta(InputAxis::Mass).requires_rezero);
761
762 let changed = with_axis(&r, InputAxis::Mass, AxisValue::Scalar(0.02)).unwrap();
763 assert_eq!(changed.shot.muzzle_angle_rad, Some(r.shot.muzzle_angle_rad));
764 }
765
766 /// Guards the "changes only that axis" property: for each axis present on the fixture,
767 /// read its current value and write that SAME value back. Since nothing numerically
768 /// changes, the rebuilt request must be identical to `SolveRequestV1::from(&r)` in EVERY
769 /// field except `shot.muzzle_angle_rad`, which a `requires_rezero` axis deliberately
770 /// clears whenever `zero_distance_m` is present (see the module doc).
771 ///
772 /// CORRECTNESS CAVEAT (found by review, do not re-widen this claim): this loop is
773 /// structurally BLIND to a *self-consistent* transposition, where `read_axis` and
774 /// `with_axis` are both wrong about the same axis in the same way -- e.g. both mapping
775 /// `ZeroPoiUp` to `zero_poi_right_m`. In that case `read_axis(ZeroPoiUp)` returns
776 /// `zero_poi_right_m`'s current value, `with_axis` writes that SAME value back onto that
777 /// SAME (wrong) field, and the result is a byte-identical no-op: there is nothing for a
778 /// whole-struct diff to see. The same blindness covers a two-axis alias (e.g. `AimAzimuth`
779 /// reading AND writing `shot_azimuth_rad`). This loop therefore does NOT prove the mapping
780 /// is correct, only that whichever field each axis actually touches, it touches only that
781 /// one. `every_axis_writes_to_its_own_named_destination_field` below is what actually
782 /// pins each axis to its correct field, via a hardcoded per-axis destination that is
783 /// independent of (and so cannot silently agree with) a bug in `read_axis`.
784 #[test]
785 fn every_present_axis_round_trips_without_disturbing_any_other_field() {
786 let r = resolved();
787 let baseline: crate::solve_json::SolveRequestV1 = (&r).into();
788 // In THIS fixture zero_distance_m is present, so "will the angle be cleared" reduces
789 // to "is this a requires_rezero axis" -- computed once, outside the loop, from the
790 // same (pre-write) state with_axis itself will see for every axis except
791 // ZeroDistance, whose own write cannot change whether zero_distance_m.is_some() (it
792 // was already Some, and it is written back Some).
793 assert!(r.shot.zero_distance_m.is_some());
794
795 let mut exercised = 0usize;
796 for &axis in InputAxis::ALL {
797 let Some(v) = read_axis(&r, axis) else {
798 continue;
799 };
800 exercised += 1;
801 let rebuilt = with_axis(&r, axis, v).unwrap();
802
803 let mut expected = baseline.clone();
804 if axis_meta(axis).requires_rezero {
805 expected.shot.muzzle_angle_rad = None;
806 }
807 assert_eq!(
808 rebuilt, expected,
809 "writing {axis:?} back onto its own current value changed a field other than \
810 itself (and, for a rezero axis, the carried angle)"
811 );
812 }
813 // Prerequisite the fixture must satisfy for the loop above to mean anything: every one
814 // of the 32 taxonomy axes must actually be present (Some) on this fixture, or the loop
815 // would silently skip whichever axes read back None -- exactly how a naive version of
816 // this fixture would have skipped the ZeroPoiUp/ZeroPoiRight pair before it was
817 // enriched with the five previously-omitted optional fields (see `resolved`'s doc).
818 assert_eq!(
819 exercised,
820 InputAxis::ALL.len(),
821 "fixture does not make every axis readable -- this test is silently under-covering"
822 );
823 }
824
825 /// The actual mapping check (I3, corrected): for every one of the 32 axes, write a
826 /// SENTINEL value distinct from whatever that axis's own field currently holds, then
827 /// assert -- via a hardcoded, explicitly-named `match axis { .. }`, the same pattern
828 /// `twist_rate_and_muzzle_height_and_target_height_round_trip` already uses for three
829 /// axes -- that the sentinel landed in that axis's OWN specific destination field.
830 ///
831 /// This is a second, independent statement of the axis -> field mapping, written directly
832 /// against the real `SolveRequestV1` field paths rather than derived from `read_axis`. That
833 /// independence is the whole point: `every_present_axis_round_trips_without_disturbing_any_other_field`
834 /// above reads a value FROM `read_axis` and writes it back, so if `read_axis` and
835 /// `with_axis` are both wrong about the same axis in the same way, that loop's read and
836 /// write agree with each other and the bug is invisible. This test never calls `read_axis`
837 /// at all: the expected destination for every axis is spelled out here by hand, so it
838 /// cannot silently agree with a mistake made in `access.rs`'s implementation.
839 ///
840 /// Distinctness matters: a sentinel MUST differ from the field's pre-write value, or a
841 /// transposition that leaves the correct field untouched (because it wrote the sentinel to
842 /// the WRONG field instead) would go unnoticed -- the untouched field would coincidentally
843 /// already equal the "expected" value. `MuzzleAngle`/`Temperature`/`Pressure` add a fixed
844 /// delta to whatever the fixture's resolved value happens to be (avoiding a dependency on
845 /// the exact auto-zeroed angle or ICAO default) instead of a hardcoded literal.
846 #[test]
847 fn every_axis_writes_to_its_own_named_destination_field() {
848 let r = resolved();
849 use InputAxis::*;
850
851 for &axis in InputAxis::ALL {
852 match axis {
853 Mass => {
854 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.0199)).unwrap();
855 assert_eq!(rebuilt.projectile.mass_kg, 0.0199, "{axis:?}");
856 }
857 Diameter => {
858 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.0090)).unwrap();
859 assert_eq!(rebuilt.projectile.diameter_m, 0.0090, "{axis:?}");
860 }
861 Length => {
862 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.040)).unwrap();
863 assert_eq!(rebuilt.projectile.length_m, Some(0.040), "{axis:?}");
864 }
865 BallisticCoefficient => {
866 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.300)).unwrap();
867 assert_eq!(rebuilt.projectile.ballistic_coefficient, 0.300, "{axis:?}");
868 }
869 TwistRate => {
870 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.3556)).unwrap();
871 assert_eq!(
872 rebuilt.rifle.twist_rate_m_per_turn,
873 Some(0.3556),
874 "{axis:?}"
875 );
876 }
877 TwistDirection => {
878 let rebuilt =
879 with_axis(&r, axis, AxisValue::TwistDirection(TwistDirectionV1::Right))
880 .unwrap();
881 assert_eq!(
882 rebuilt.rifle.twist_direction,
883 Some(TwistDirectionV1::Right),
884 "{axis:?}"
885 );
886 }
887 DragModel => {
888 let rebuilt =
889 with_axis(&r, axis, AxisValue::DragModel(DragModelV1::G1)).unwrap();
890 assert_eq!(rebuilt.projectile.drag_model, DragModelV1::G1, "{axis:?}");
891 }
892 MuzzleVelocityMps => {
893 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(900.0)).unwrap();
894 assert_eq!(rebuilt.rifle.muzzle_velocity_mps, 900.0, "{axis:?}");
895 }
896 SightHeight => {
897 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.06)).unwrap();
898 assert_eq!(rebuilt.rifle.sight_height_m, Some(0.06), "{axis:?}");
899 }
900 ZeroDistance => {
901 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(200.0)).unwrap();
902 assert_eq!(rebuilt.shot.zero_distance_m, Some(200.0), "{axis:?}");
903 }
904 ZeroPoiUp => {
905 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.05)).unwrap();
906 assert_eq!(rebuilt.shot.zero_poi_up_m, Some(0.05), "{axis:?}");
907 }
908 ZeroPoiRight => {
909 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.06)).unwrap();
910 assert_eq!(rebuilt.shot.zero_poi_right_m, Some(0.06), "{axis:?}");
911 }
912 SightOffsetLateral => {
913 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.10)).unwrap();
914 assert_eq!(rebuilt.rifle.sight_offset_lateral_m, Some(0.10), "{axis:?}");
915 }
916 MuzzleHeight => {
917 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.05)).unwrap();
918 assert_eq!(rebuilt.rifle.muzzle_height_m, Some(0.05), "{axis:?}");
919 }
920 MuzzleAngle => {
921 let sentinel = r.shot.muzzle_angle_rad + 0.01;
922 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
923 assert_eq!(rebuilt.shot.muzzle_angle_rad, Some(sentinel), "{axis:?}");
924 }
925 Altitude => {
926 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1500.0)).unwrap();
927 assert_eq!(rebuilt.atmosphere.altitude_m, Some(1500.0), "{axis:?}");
928 }
929 Temperature => {
930 let sentinel = r.atmosphere.temperature_k + 5.0;
931 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
932 assert_eq!(rebuilt.atmosphere.temperature_k, Some(sentinel), "{axis:?}");
933 }
934 Pressure => {
935 let sentinel = r.atmosphere.pressure_pa + 500.0;
936 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
937 assert_eq!(rebuilt.atmosphere.pressure_pa, Some(sentinel), "{axis:?}");
938 }
939 RelativeHumidity => {
940 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.7)).unwrap();
941 assert_eq!(rebuilt.atmosphere.relative_humidity, Some(0.7), "{axis:?}");
942 }
943 Latitude => {
944 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.3)).unwrap();
945 assert_eq!(rebuilt.atmosphere.latitude_rad, Some(0.3), "{axis:?}");
946 }
947 WindSpeed => {
948 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(7.0)).unwrap();
949 assert_eq!(rebuilt.wind.speed_mps, Some(7.0), "{axis:?}");
950 }
951 WindDirection => {
952 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.4)).unwrap();
953 assert_eq!(rebuilt.wind.direction_from_rad, Some(0.4), "{axis:?}");
954 }
955 WindVertical => {
956 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1.5)).unwrap();
957 assert_eq!(rebuilt.wind.vertical_speed_mps, Some(1.5), "{axis:?}");
958 }
959 TargetDistance => {
960 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1000.0)).unwrap();
961 assert_eq!(rebuilt.shot.max_range_m, 1000.0, "{axis:?}");
962 }
963 ShootingAngle => {
964 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.2)).unwrap();
965 assert_eq!(rebuilt.shot.shooting_angle_rad, Some(0.2), "{axis:?}");
966 }
967 Cant => {
968 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.15)).unwrap();
969 assert_eq!(rebuilt.shot.cant_angle_rad, Some(0.15), "{axis:?}");
970 }
971 ShotAzimuth => {
972 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.25)).unwrap();
973 assert_eq!(rebuilt.shot.shot_azimuth_rad, Some(0.25), "{axis:?}");
974 }
975 AimAzimuth => {
976 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.35)).unwrap();
977 assert_eq!(rebuilt.shot.aim_azimuth_rad, Some(0.35), "{axis:?}");
978 }
979 TargetHeight => {
980 let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.5)).unwrap();
981 assert_eq!(rebuilt.shot.target_height_m, Some(0.5), "{axis:?}");
982 }
983 MagnusEnabled => {
984 let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
985 assert_eq!(rebuilt.effects.magnus, Some(true), "{axis:?}");
986 }
987 CoriolisEnabled => {
988 let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
989 assert_eq!(rebuilt.effects.coriolis, Some(true), "{axis:?}");
990 }
991 EnhancedSpinDriftEnabled => {
992 let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
993 assert_eq!(rebuilt.effects.enhanced_spin_drift, Some(true), "{axis:?}");
994 }
995 }
996 }
997 }
998
999 /// Physics guard (a): perturbing altitude on a QNH-pressure request would silently build
1000 /// a wrong counterfactual (the rebuilt request always carries absolute station pressure --
1001 /// see request_roundtrip.rs -- so density-by-altitude would move but the QNH-referenced
1002 /// station pressure would not, backwards from what a QNH-entering caller means). Detected
1003 /// from the ORIGINAL resolved request's pressure_reference echo and rejected outright.
1004 #[test]
1005 fn altitude_axis_is_unsupported_when_the_original_pressure_was_qnh() {
1006 let json = serde_json::json!({
1007 "schema_version": 1,
1008 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1009 "ballistic_coefficient": 0.243},
1010 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1011 "shot": {"max_range_m": 900.0},
1012 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1013 "pressure_reference": "qnh"},
1014 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1015 })
1016 .to_string();
1017 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1018 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1019 assert_eq!(
1020 r.atmosphere.pressure_reference,
1021 Some(PressureReferenceV1::Qnh)
1022 );
1023
1024 let e = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(600.0));
1025 match e {
1026 Err(KernelError::AxisUnsupportedForRequest {
1027 axis: InputAxis::Altitude,
1028 reason,
1029 }) => {
1030 assert!(
1031 reason.to_lowercase().contains("qnh"),
1032 "reason should name QNH: {reason}"
1033 );
1034 }
1035 other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
1036 }
1037 }
1038
1039 /// The QNH guard must be specific to QNH mode: an OMITTED pressure_reference (the default,
1040 /// and every request that predates MBA-1397) must still allow perturbing altitude
1041 /// normally. This only proves the guard doesn't fire on absence; see
1042 /// `altitude_axis_is_perturbable_when_pressure_is_explicitly_absolute` below for the
1043 /// stronger claim that it doesn't fire on an explicit non-QNH value either.
1044 #[test]
1045 fn altitude_axis_is_perturbable_when_pressure_is_absolute() {
1046 let r = resolved();
1047 assert_eq!(r.atmosphere.pressure_reference, None);
1048 let changed = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(1200.0)).unwrap();
1049 assert_eq!(changed.atmosphere.altitude_m, Some(1200.0));
1050 }
1051
1052 /// Stronger version of the test above: an EXPLICIT `pressure_reference: "absolute"` (not
1053 /// just an omitted field defaulting to it) must still allow perturbing altitude normally.
1054 /// Guards against a hypothetical `!= Some(Qnh)` -> `== None` typo that happens to pass the
1055 /// omitted-field test above but would reject this explicit-but-equivalent case.
1056 #[test]
1057 fn altitude_axis_is_perturbable_when_pressure_is_explicitly_absolute() {
1058 let json = serde_json::json!({
1059 "schema_version": 1,
1060 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1061 "ballistic_coefficient": 0.243},
1062 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1063 "shot": {"max_range_m": 900.0},
1064 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1065 "pressure_reference": "absolute"},
1066 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1067 })
1068 .to_string();
1069 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1070 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1071 assert_eq!(
1072 r.atmosphere.pressure_reference,
1073 Some(PressureReferenceV1::Absolute)
1074 );
1075
1076 let changed = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(600.0)).unwrap();
1077 assert_eq!(changed.atmosphere.altitude_m, Some(600.0));
1078 }
1079
1080 /// Physics guard (b): perturbing shot azimuth on a compass-referenced-wind request would
1081 /// silently build a wrong counterfactual (the rebuilt request always carries
1082 /// shooter-relative wind -- see request_roundtrip.rs -- so the wind would rotate WITH the
1083 /// rifle instead of staying earth-fixed). Detected from the ORIGINAL resolved request's
1084 /// wind_reference echo and rejected outright.
1085 #[test]
1086 fn shot_azimuth_axis_is_unsupported_when_the_original_wind_was_compass_referenced() {
1087 let json = serde_json::json!({
1088 "schema_version": 1,
1089 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1090 "ballistic_coefficient": 0.243},
1091 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1092 "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
1093 "atmosphere": {},
1094 "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
1095 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1096 })
1097 .to_string();
1098 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1099 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1100
1101 let e = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5));
1102 match e {
1103 Err(KernelError::AxisUnsupportedForRequest {
1104 axis: InputAxis::ShotAzimuth,
1105 reason,
1106 }) => {
1107 assert!(
1108 reason.to_lowercase().contains("compass"),
1109 "reason should name compass wind: {reason}"
1110 );
1111 }
1112 other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
1113 }
1114 }
1115
1116 /// The compass guard must be specific to compass mode: an OMITTED wind_reference (the
1117 /// default) must still allow perturbing shot azimuth normally. This only proves the guard
1118 /// doesn't fire on absence; see
1119 /// `shot_azimuth_axis_is_perturbable_when_wind_is_explicitly_shooter_relative` below for
1120 /// the stronger claim that it doesn't fire on an explicit non-compass value either.
1121 #[test]
1122 fn shot_azimuth_axis_is_perturbable_when_wind_is_shooter_relative() {
1123 let r = resolved();
1124 assert_eq!(
1125 match &r.wind {
1126 ResolvedWindV1::Constant(c) => c.wind_reference,
1127 ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
1128 },
1129 None
1130 );
1131 let changed = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5)).unwrap();
1132 assert_eq!(changed.shot.shot_azimuth_rad, Some(0.5));
1133 }
1134
1135 /// Stronger version of the test above: an EXPLICIT `wind_reference: "shooter"` (not just
1136 /// an omitted field defaulting to it) must still allow perturbing shot azimuth normally.
1137 /// Guards against a hypothetical `!= Some(Compass)` -> `== None` typo that happens to pass
1138 /// the omitted-field test above but would reject this explicit-but-equivalent case.
1139 #[test]
1140 fn shot_azimuth_axis_is_perturbable_when_wind_is_explicitly_shooter_relative() {
1141 let json = serde_json::json!({
1142 "schema_version": 1,
1143 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1144 "ballistic_coefficient": 0.243},
1145 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1146 "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
1147 "atmosphere": {},
1148 "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "shooter"},
1149 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1150 })
1151 .to_string();
1152 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1153 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1154 let ResolvedWindV1::Constant(wind) = &r.wind else {
1155 panic!("constant wind expected");
1156 };
1157 assert_eq!(wind.wind_reference, Some(WindReferenceV1::Shooter));
1158
1159 let changed = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5)).unwrap();
1160 assert_eq!(changed.shot.shot_azimuth_rad, Some(0.5));
1161 }
1162}