ballistics_engine/truing_service.rs
1//! Transport-free tall-target and `dsf` derivation services.
2//!
3//! ## Tall-target
4//!
5//! Replicates the CLI's `tall-target` arithmetic exactly — same imperial/metric divisor
6//! branch, same four validation guards, same tracking-CF band check via
7//! [`crate::profile::validate_tracking_cf`] — as a versioned request/result pair the bridge
8//! can expose to embedded consumers. The CLI's `Commands::TallTarget` arm keeps its
9//! `println!` formatting but calls [`tall_target_v1`] for the arithmetic, so the two callers
10//! cannot drift apart.
11//!
12//! ## `dsf` derivation (MBA-1357 Task 9)
13//!
14//! [`derive_dsf_point_v1`] is the model-driven entry point a JSON bridge caller with only a
15//! bare [`crate::truing::TruingModelInputsV1`] reaches: it widens the model into
16//! [`crate::truing_dsf::DsfSolveInputs`] via the existing `From` impl (no wind, level shot,
17//! no offsets, ICAO reference, no BC segments — see that impl's own doc comment), solves,
18//! and derives one Mach-keyed DSF point.
19//!
20//! The CLI's saved-profile `dsf` command has a richer input than any bare
21//! `TruingModelInputsV1` can express — wind, shooting angle, zero-POI offsets, sight-mount
22//! offset, BC reference, BC segments, a custom drag curve (MBA-1357 Task 8's
23//! `DsfSolveInputs`, and the two regressions its review caught when that fidelity was first
24//! silently narrowed). So the CLI keeps building its own `DsfSolveInputs` from the loaded
25//! profile and solving directly via [`crate::truing_dsf::solve_for_dsf`] — unchanged from
26//! Task 8 — rather than going through [`derive_dsf_point_v1`]'s narrower model. What it DOES
27//! share with [`derive_dsf_point_v1`], so the two callers' arithmetic cannot drift apart, is
28//! [`derive_dsf_point_from_solve_v1`]: the post-solve derivation (interpolation, Mach
29//! computation, both gates, the DSF ratio) factored out of [`derive_dsf_point_v1`] so it can
30//! run against a `TrajectoryResult` from EITHER solve path. `derive_dsf_point_v1` is a thin
31//! "build inputs, solve, then call the shared derivation" wrapper around it.
32//!
33//! **Derive only.** Neither function ever writes a DSF table, opens a profile, touches the
34//! filesystem, or calls `std::process::exit` — persistence and printing are the CLI's job.
35//!
36//! No feature gate: must compile for wasm32-unknown-unknown. The tall-target half depends
37//! only on `crate::adjustment` and `crate::profile`; the `dsf` half depends on
38//! `crate::truing`, `crate::truing_dsf`, and `crate::cli_api::TrajectoryResult`, all
39//! unconditional and filesystem-free.
40
41use serde::{Deserialize, Serialize};
42
43use crate::adjustment::{drop_to_adjustment, AdjustmentUnit};
44use crate::cli_api::TrajectoryResult;
45use crate::optic::{plan_corrections, AngularCorrection, DialPlanReportV1, OpticError, OpticProfile, Preferences};
46use crate::truing::{DropUnit, TruingModelInputsV1};
47use crate::truing_dsf::{
48 dsf_observation_warrants_90pct_warning, interpolate_position_and_velocity,
49 mach_1_crossing_range_m, solve_for_dsf, DsfSolveInputs, DSF_MACH_CEILING,
50};
51
52/// Request for a tall-target correction-factor computation.
53///
54/// `metric` selects the measured-travel unit exactly like the CLI's `--units` flag:
55/// `false` measures `measured` in inches at yards (divisor 36.0), `true` measures it in
56/// centimeters at meters (divisor 100.0).
57#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct TallTargetRequestV1 {
60 pub dialed: f64,
61 pub measured: f64,
62 pub range: f64,
63 pub unit: AdjustmentUnit,
64 pub metric: bool,
65}
66
67/// Result of a tall-target correction-factor computation.
68#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
69pub struct TallTargetResultV1 {
70 pub dialed: f64,
71 pub actual: f64,
72 pub correction_factor: f64,
73 pub within_accepted_band: bool,
74}
75
76/// Errors returned by [`tall_target_v1`]. One variant per guard (not a single stringly-typed
77/// variant) so a caller — in particular a future JSON bridge — can match exhaustively instead
78/// of sniffing message text; adding a fifth guard here forces every `match` on this type to be
79/// updated at compile time.
80#[derive(Debug, thiserror::Error, PartialEq)]
81pub enum TallTargetErrorV1 {
82 #[error("clicks is not an angular unit; enter the dialed travel in mil, moa, smoa, or iphy")]
83 ClicksNotAngular,
84 #[error("dialed must be a positive angular travel")]
85 InvalidDialed,
86 #[error("measured must be a positive measured travel")]
87 InvalidMeasured,
88 #[error("range must be at least 1 yard/meter")]
89 InvalidRange,
90}
91
92/// Computes the tall-target correction factor: same arithmetic and validation guards as the
93/// CLI's `tall-target` command, minus the printing.
94pub fn tall_target_v1(req: &TallTargetRequestV1) -> Result<TallTargetResultV1, TallTargetErrorV1> {
95 if req.unit == AdjustmentUnit::Clicks {
96 return Err(TallTargetErrorV1::ClicksNotAngular);
97 }
98 if !req.dialed.is_finite() || req.dialed <= 0.0 {
99 return Err(TallTargetErrorV1::InvalidDialed);
100 }
101 if !req.measured.is_finite() || req.measured <= 0.0 {
102 return Err(TallTargetErrorV1::InvalidMeasured);
103 }
104 if !req.range.is_finite() || req.range < 1.0 {
105 return Err(TallTargetErrorV1::InvalidRange);
106 }
107 let drop_len = if req.metric {
108 req.measured / 100.0
109 } else {
110 req.measured / 36.0
111 };
112 let actual = drop_to_adjustment(drop_len, req.range, req.unit);
113 let correction_factor = actual / req.dialed;
114 Ok(TallTargetResultV1 {
115 dialed: req.dialed,
116 actual,
117 correction_factor,
118 within_accepted_band: crate::profile::validate_tracking_cf(
119 correction_factor,
120 "the computed factor",
121 )
122 .is_ok(),
123 })
124}
125
126// ============================================================================
127// `dsf` derivation (MBA-1357 Task 9)
128// ============================================================================
129
130/// Mirrors the CLI's own `DSF_DEFAULT_SOLVE_ENVELOPE` (1000 yd) — the solve envelope
131/// `trajectory --saved-profile NAME`/`come-ups --profile NAME` use by default, absent an
132/// explicit `--max-range`. A bare model-driven request has no saved profile's own
133/// configured max range to reuse, so this is the model-side equivalent: solve out to 1000
134/// yd, or 5% past the requested observation range when that range itself exceeds 1000 yd,
135/// so the solved points always bracket the requested range.
136const DSF_DEFAULT_SOLVE_ENVELOPE_YD: f64 = 1000.0;
137
138fn dsf_solve_envelope_m(range_yd: f64) -> f64 {
139 let default_envelope_m = DSF_DEFAULT_SOLVE_ENVELOPE_YD * 0.9144;
140 let range_m = range_yd * 0.9144;
141 if range_m > default_envelope_m {
142 range_m * 1.05
143 } else {
144 default_envelope_m
145 }
146}
147
148/// Request for a `dsf` point derivation from a bare scalar-BC model (MBA-1357 Task 9).
149///
150/// `range_yd` is the observation's horizontal range, yards. `observed_drop` is the
151/// physically observed drop at that range, in `drop_unit` — the same
152/// numeric-value-plus-unit-suffix pair the CLI's `--observed-drop` flag parses (e.g.
153/// `5.1mil`, `17.4moa`, `42.0in`).
154#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct DsfDeriveRequestV1 {
157 pub model: TruingModelInputsV1,
158 pub range_yd: f64,
159 pub observed_drop: f64,
160 pub drop_unit: DropUnit,
161}
162
163/// One derived Mach-keyed DSF point, plus any non-fatal warnings the derivation raised.
164///
165/// `predicted_drop` and `observed_drop` are both expressed in `drop_unit` (mirroring the
166/// request), so a caller can compare them directly.
167#[derive(Debug, Clone, Serialize)]
168pub struct DsfPointResultV1 {
169 pub mach: f64,
170 pub dsf: f64,
171 pub predicted_drop: f64,
172 pub observed_drop: f64,
173 pub drop_unit: DropUnit,
174 pub warnings: Vec<String>,
175}
176
177/// Errors returned by [`derive_dsf_point_v1`] and [`derive_dsf_point_from_solve_v1`]. One
178/// variant per failure mode (not a single stringly-typed variant) so a caller — in
179/// particular the JSON bridge — can match exhaustively instead of sniffing message text.
180#[derive(Debug, thiserror::Error, PartialEq)]
181pub enum DsfServiceErrorV1 {
182 #[error("invalid dsf request: {0}")]
183 InvalidInput(String),
184 #[error(
185 "observation at Mach {mach:.2} is supersonic (ceiling {ceiling:.2}); \
186 use true.fit to correct muzzle velocity instead"
187 )]
188 Supersonic { mach: f64, ceiling: f64 },
189 #[error("trajectory does not reach {range_yd:.0} yd (solved to {solved_yd:.0} yd)")]
190 OutOfRange { range_yd: f64, solved_yd: f64 },
191 #[error("predicted drop at {range_yd:.0} yd is zero or non-finite; no DSF ratio exists")]
192 DegenerateDrop { range_yd: f64 },
193 #[error("dsf forward model failed: {0}")]
194 ForwardModel(String),
195}
196
197impl DsfServiceErrorV1 {
198 /// Structured detail payload for a JSON bridge error envelope (MBA-1357 Task 2's
199 /// ruling: an INHERENT method, not a trait impl, so this stays in `truing_service` and
200 /// keeps compiling with the `bridge` feature off).
201 pub fn failure_details(&self) -> Option<serde_json::Value> {
202 Some(match self {
203 Self::InvalidInput(_) => serde_json::json!({"reason": "invalid_input"}),
204 Self::Supersonic { mach, ceiling } => {
205 serde_json::json!({"reason": "supersonic", "mach": mach, "ceiling": ceiling})
206 }
207 Self::OutOfRange {
208 range_yd,
209 solved_yd,
210 } => serde_json::json!({
211 "reason": "out_of_range",
212 "range_yd": range_yd,
213 "solved_yd": solved_yd
214 }),
215 Self::DegenerateDrop { range_yd } => {
216 serde_json::json!({"reason": "degenerate_drop", "range_yd": range_yd})
217 }
218 Self::ForwardModel(_) => serde_json::json!({"reason": "forward_model"}),
219 })
220 }
221}
222
223/// Derive a `dsf` point from a bare scalar-BC model (MBA-1357 Task 9) — the JSON bridge's
224/// entry point, with no saved profile behind it.
225///
226/// Builds [`DsfSolveInputs`] from `req.model` via the existing `From` impl (bridge
227/// defaults: no wind, level shot, no zero-POI/sight-mount offsets, ICAO BC reference, no
228/// BC segments, no custom drag curve — see that impl's own doc comment), sizes a solve
229/// envelope the same way the CLI's own default does, solves, and hands the result to
230/// [`derive_dsf_point_from_solve_v1`] for the actual derivation.
231///
232/// **Derive only** — never writes a DSF table, opens a profile, touches the filesystem, or
233/// calls `std::process::exit`.
234pub fn derive_dsf_point_v1(req: &DsfDeriveRequestV1) -> Result<DsfPointResultV1, DsfServiceErrorV1> {
235 req.model
236 .validate()
237 .map_err(DsfServiceErrorV1::InvalidInput)?;
238 if !req.range_yd.is_finite() || req.range_yd <= 0.0 {
239 return Err(DsfServiceErrorV1::InvalidInput(
240 "range_yd must be a positive, finite distance".to_string(),
241 ));
242 }
243 if !req.observed_drop.is_finite() {
244 return Err(DsfServiceErrorV1::InvalidInput(
245 "observed_drop must be finite".to_string(),
246 ));
247 }
248
249 let solve_inputs: DsfSolveInputs = (&req.model).into();
250 let max_range_m = dsf_solve_envelope_m(req.range_yd);
251 let result =
252 solve_for_dsf(&solve_inputs, max_range_m).map_err(DsfServiceErrorV1::ForwardModel)?;
253
254 let range_m = req.range_yd * 0.9144;
255 derive_dsf_point_from_solve_v1(&result, range_m, req.observed_drop, req.drop_unit)
256}
257
258/// The post-solve half of a `dsf` point derivation: interpolation, Mach computation, both
259/// gates, and the DSF ratio, run against an ALREADY-SOLVED `result` (MBA-1357 Task 9).
260///
261/// Shared by [`derive_dsf_point_v1`] (which solves from a bare `TruingModelInputsV1`) and
262/// the CLI's saved-profile `dsf` command (which solves via the profile-rich
263/// [`DsfSolveInputs`] instead, preserving every field Task 8 fixed — see this module's own
264/// doc comment) so the two callers' arithmetic cannot drift apart.
265///
266/// `range_m` is the observation's horizontal range in meters. The two gates carried over
267/// from the CLI exactly:
268/// - A supersonic observation (`mach > DSF_MACH_CEILING`) is an error — that's
269/// `true-velocity`'s job, not DSF's.
270/// - The "beyond Mach 1 AND beyond 90% of the solved range" case (a COMPOUND condition,
271/// not the 90% ratio alone) is a warning appended to the result's `warnings`, not a
272/// failure.
273/// - A zero or non-finite predicted drop is an error; no DSF ratio exists.
274pub fn derive_dsf_point_from_solve_v1(
275 result: &TrajectoryResult,
276 range_m: f64,
277 observed_drop: f64,
278 drop_unit: DropUnit,
279) -> Result<DsfPointResultV1, DsfServiceErrorV1> {
280 let range_yd = range_m / 0.9144;
281
282 let (position_y, velocity_mag) = interpolate_position_and_velocity(&result.points, range_m)
283 .ok_or(DsfServiceErrorV1::OutOfRange {
284 range_yd,
285 solved_yd: result.max_range / 0.9144,
286 })?;
287
288 let predicted_drop_m = result.line_of_sight_height_m - position_y;
289 let mach = if result.station_speed_of_sound_mps > 0.0 {
290 velocity_mag / result.station_speed_of_sound_mps
291 } else {
292 0.0
293 };
294
295 // Gate 1: reject a supersonic observation outright — that's true-velocity's job.
296 if mach > DSF_MACH_CEILING {
297 return Err(DsfServiceErrorV1::Supersonic {
298 mach,
299 ceiling: DSF_MACH_CEILING,
300 });
301 }
302
303 // Gate 2: warn when the observation is BOTH beyond the trajectory's Mach-1.0 crossing
304 // AND beyond 90% of the solved max range (a compound condition, not the 90% ratio
305 // alone — see dsf_observation_warrants_90pct_warning's own doc comment).
306 let mut warnings = Vec::new();
307 let crossing_m = mach_1_crossing_range_m(result);
308 if dsf_observation_warrants_90pct_warning(range_m, crossing_m, result.max_range) {
309 warnings.push(format!(
310 "observation at {range_yd:.0} yd is beyond 90% of the solved range; solution \
311 reliability degrades past this point"
312 ));
313 }
314
315 let predicted_value = drop_unit.express_drop_m(predicted_drop_m, range_m);
316 if !predicted_value.is_finite() || predicted_value == 0.0 {
317 return Err(DsfServiceErrorV1::DegenerateDrop { range_yd });
318 }
319 let dsf = observed_drop / predicted_value;
320
321 Ok(DsfPointResultV1 {
322 mach,
323 dsf,
324 predicted_drop: predicted_value,
325 observed_drop,
326 drop_unit,
327 warnings,
328 })
329}
330
331// ============================================================================
332// `dial-plan` (MBA-1348's `plan_corrections`, bridge wrapper)
333// ============================================================================
334
335/// Yards-to-metres, matching `dsf_solve_envelope_m`'s own constant above: `plan_corrections`
336/// takes `range_m` in metres, but every other `true.*` command except `true.wind` is
337/// imperial on the wire, so the request stays imperial and this is the one conversion the
338/// service performs.
339const YARDS_TO_METRES: f64 = 0.9144;
340
341/// Serde default for `DialPlanRequestV1::elevation_cf`/`windage_cf`: 1.0, the neutral
342/// tracking-correction-factor value. Most callers have no tall-target CF measured yet, so
343/// requiring the field would be a trap — see the struct's own doc comment.
344fn unity_cf() -> f64 {
345 1.0
346}
347
348/// Request for [`dial_plan_v1`]: the bridge's wrapper around [`plan_corrections`]'s six
349/// flat parameters (MBA-1348 Task 6's CLI wraps the same six from `--elevation`/`--windage`,
350/// `--profile`/inline optic flags, `--range`, and `--prefer-hold`/`--max-hold`).
351///
352/// `range_yd` is in YARDS: `plan_corrections` itself takes metres, but every other `true.*`
353/// command except `true.wind` is imperial on the wire, so this one stays imperial too and
354/// [`dial_plan_v1`] converts at the boundary. `elevation_cf`/`windage_cf` default to `1.0`
355/// (the neutral value) rather than being required — the CLI's own `--profile`-less inline
356/// mode has no tracking-CF flags at all and always passes 1.0, and most callers have no
357/// tall-target correction factor measured yet, so requiring the field here would be a trap.
358///
359/// The optic is supplied INLINE (`optic: OpticProfile`), never loaded from a named profile:
360/// the CLI's `--profile` mode reads a saved profile from disk (`load_profile`), and that
361/// filesystem dependency must not enter this module — see the module's own doc comment.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct DialPlanRequestV1 {
365 pub correction: AngularCorrection,
366 pub optic: OpticProfile,
367 /// Range in YARDS for the linear-miss-at-range residual. See the struct doc comment.
368 pub range_yd: f64,
369 #[serde(default = "unity_cf")]
370 pub elevation_cf: f64,
371 #[serde(default = "unity_cf")]
372 pub windage_cf: f64,
373 #[serde(default)]
374 pub preferences: Preferences,
375}
376
377/// Turn a TRUE angular [`AngularCorrection`] into ranked dial/hold/hybrid plans for an
378/// inline [`OpticProfile`] — the JSON bridge's entry point to [`plan_corrections`].
379///
380/// Contains NO arithmetic beyond the yards-to-metres conversion: everything else is
381/// [`plan_corrections`] itself, so there is no second implementation of the CF rule or the
382/// ranking logic to drift from the CLI's `dial-plan` command or from `plan_corrections`'s
383/// own tests.
384pub fn dial_plan_v1(req: &DialPlanRequestV1) -> Result<DialPlanReportV1, OpticError> {
385 plan_corrections(
386 req.correction,
387 &req.optic,
388 req.range_yd * YARDS_TO_METRES,
389 req.elevation_cf,
390 req.windage_cf,
391 &req.preferences,
392 )
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn tall_target_matches_the_cli_arithmetic_and_rejects_clicks() {
401 // 30 inches measured at 100 yd against 10 mil dialed.
402 let req = TallTargetRequestV1 {
403 dialed: 10.0,
404 measured: 30.0,
405 range: 100.0,
406 unit: AdjustmentUnit::Mil,
407 metric: false,
408 };
409 let r = tall_target_v1(&req).expect("computes");
410 let expected_actual =
411 crate::adjustment::drop_to_adjustment(30.0 / 36.0, 100.0, AdjustmentUnit::Mil);
412 assert!((r.actual - expected_actual).abs() < 1e-12);
413 assert!((r.correction_factor - expected_actual / 10.0).abs() < 1e-12);
414
415 // Clicks is not an angular unit.
416 let bad = TallTargetRequestV1 {
417 unit: AdjustmentUnit::Clicks,
418 ..req
419 };
420 assert_eq!(tall_target_v1(&bad), Err(TallTargetErrorV1::ClicksNotAngular));
421
422 // Non-positive and sub-1 range are rejected, matching the CLI guards, each with its
423 // own variant (not a shared stringly-typed error).
424 assert_eq!(
425 tall_target_v1(&TallTargetRequestV1 {
426 dialed: 0.0,
427 ..req
428 }),
429 Err(TallTargetErrorV1::InvalidDialed)
430 );
431 assert_eq!(
432 tall_target_v1(&TallTargetRequestV1 {
433 measured: -1.0,
434 ..req
435 }),
436 Err(TallTargetErrorV1::InvalidMeasured)
437 );
438 assert_eq!(
439 tall_target_v1(&TallTargetRequestV1 {
440 range: 0.5,
441 ..req
442 }),
443 Err(TallTargetErrorV1::InvalidRange)
444 );
445 }
446
447 #[test]
448 fn service_reproduces_the_cli_imperial_and_metric_ratios() {
449 // Imperial: inches at yards, 36 in/yd. Metric: cm at metres, 100 cm/m.
450 for (metric, measured, divisor) in [(false, 30.0, 36.0), (true, 76.2, 100.0)] {
451 let r = tall_target_v1(&TallTargetRequestV1 {
452 dialed: 10.0,
453 measured,
454 range: 100.0,
455 unit: AdjustmentUnit::Mil,
456 metric,
457 })
458 .expect("computes");
459 let expected = crate::adjustment::drop_to_adjustment(
460 measured / divisor,
461 100.0,
462 AdjustmentUnit::Mil,
463 );
464 assert!((r.actual - expected).abs() < 1e-12, "metric={metric}");
465 }
466 }
467
468
469 // ---- dsf derivation (MBA-1357 Task 9) ----
470 //
471 // DsfSolveInputs (like the historical profile-driven solve it replaced) hardcodes a
472 // 60-inch bore/muzzle height with ground-impact detection at height 0 (see
473 // solve_for_dsf's own doc comment) — a typical supersonic rifle load hits that
474 // "ground" well before it decelerates into the DSF table's Mach < 1.2 domain, so a
475 // muzzle velocity picked to be transonic-quickly (~Mach 1.4 at the muzzle) is needed
476 // to reach BOTH a clearly-supersonic short-range point and a transonic longer-range
477 // point within the solved envelope. Confirmed empirically (not asserted blind): at
478 // this load, Mach crosses 1.2 around 186 yd and the trajectory reaches "ground" around
479 // 327 yd, so 50 yd (Mach ~1.37) and 200 yd (Mach ~1.18) below exercise exactly the two
480 // gates without ever hitting `OutOfRange`.
481 use crate::truing::DragModelArg;
482
483 fn test_model() -> TruingModelInputsV1 {
484 TruingModelInputsV1 {
485 muzzle_velocity_fps: 1600.0,
486 ballistic_coefficient: 0.243,
487 drag_model: DragModelArg::G7,
488 mass_gr: 168.0,
489 diameter_in: 0.308,
490 zero_distance_yd: 100.0,
491 sight_height_in: 2.0,
492 temperature_f: 59.0,
493 pressure_inhg: 29.92,
494 humidity_pct: 50.0,
495 altitude_ft: 0.0,
496 }
497 }
498
499 #[test]
500 fn dsf_derives_a_point_and_refuses_a_supersonic_observation() {
501 let model = test_model();
502 // Transonic-band observation: derives a point.
503 let ok = derive_dsf_point_v1(&DsfDeriveRequestV1 {
504 model,
505 range_yd: 200.0,
506 observed_drop: 12.0,
507 drop_unit: DropUnit::Mil,
508 })
509 .expect("derives");
510 assert!(ok.mach <= crate::truing_dsf::DSF_MACH_CEILING);
511 assert!(ok.dsf > 0.0);
512
513 // Supersonic observation is true-velocity's job, not DSF's.
514 let err = derive_dsf_point_v1(&DsfDeriveRequestV1 {
515 model,
516 range_yd: 50.0,
517 observed_drop: 0.5,
518 drop_unit: DropUnit::Mil,
519 });
520 assert!(err.is_err(), "a supersonic observation must be refused");
521 assert!(matches!(err, Err(DsfServiceErrorV1::Supersonic { .. })));
522 }
523
524 #[test]
525 fn dsf_rejects_invalid_range_and_observed_drop() {
526 let model = test_model();
527 assert!(matches!(
528 derive_dsf_point_v1(&DsfDeriveRequestV1 {
529 model,
530 range_yd: 0.0,
531 observed_drop: 1.0,
532 drop_unit: DropUnit::Mil,
533 }),
534 Err(DsfServiceErrorV1::InvalidInput(_))
535 ));
536 assert!(matches!(
537 derive_dsf_point_v1(&DsfDeriveRequestV1 {
538 model,
539 range_yd: 200.0,
540 observed_drop: f64::NAN,
541 drop_unit: DropUnit::Mil,
542 }),
543 Err(DsfServiceErrorV1::InvalidInput(_))
544 ));
545 // An invalid model (e.g. non-positive mass) is rejected via TruingModelInputsV1's
546 // own validate(), not silently solved.
547 let mut bad_model = model;
548 bad_model.mass_gr = -1.0;
549 assert!(matches!(
550 derive_dsf_point_v1(&DsfDeriveRequestV1 {
551 model: bad_model,
552 range_yd: 200.0,
553 observed_drop: 1.0,
554 drop_unit: DropUnit::Mil,
555 }),
556 Err(DsfServiceErrorV1::InvalidInput(_))
557 ));
558 }
559
560 #[test]
561 fn dsf_derive_from_solve_matches_derive_dsf_point_v1() {
562 // The CLI's profile-driven path calls derive_dsf_point_from_solve_v1 directly
563 // against its own DsfSolveInputs solve; a bare-model DsfSolveInputs conversion
564 // (via From<&TruingModelInputsV1>) must reproduce derive_dsf_point_v1's result
565 // exactly, since that's what derive_dsf_point_v1 does internally.
566 let model = test_model();
567 let range_yd = 200.0;
568 let observed_drop = 12.0;
569 let drop_unit = DropUnit::Mil;
570
571 let via_v1 = derive_dsf_point_v1(&DsfDeriveRequestV1 {
572 model,
573 range_yd,
574 observed_drop,
575 drop_unit,
576 })
577 .expect("derives");
578
579 let solve_inputs: DsfSolveInputs = (&model).into();
580 let max_range_m = dsf_solve_envelope_m(range_yd);
581 let result = solve_for_dsf(&solve_inputs, max_range_m).expect("solves");
582 let via_solve =
583 derive_dsf_point_from_solve_v1(&result, range_yd * 0.9144, observed_drop, drop_unit)
584 .expect("derives");
585
586 assert_eq!(via_v1.mach, via_solve.mach);
587 assert_eq!(via_v1.dsf, via_solve.dsf);
588 assert_eq!(via_v1.predicted_drop, via_solve.predicted_drop);
589 assert_eq!(via_v1.warnings, via_solve.warnings);
590 }
591
592 #[test]
593 fn dsf_out_of_range_and_degenerate_drop_errors() {
594 let model = test_model();
595 // Far beyond anything this load reaches (max range ~327 yd).
596 let err = derive_dsf_point_v1(&DsfDeriveRequestV1 {
597 model,
598 range_yd: 20_000.0,
599 observed_drop: 12.0,
600 drop_unit: DropUnit::Mil,
601 });
602 assert!(matches!(err, Err(DsfServiceErrorV1::OutOfRange { .. })));
603
604 // Zero observed drop is a perfectly valid observation; predicted drop is never
605 // zero at 200 yd (well past the 100 yd zero crossing) for a real trajectory, so
606 // DegenerateDrop is a genuine edge case (only reachable right at the zero-crossing
607 // range, where floating-point exactness makes it impractical to force
608 // deterministically) rather than something this test can trigger. Confirm zero
609 // observed_drop alone still derives a (zero) DSF rather than being rejected as
610 // invalid input.
611 let ok = derive_dsf_point_v1(&DsfDeriveRequestV1 {
612 model,
613 range_yd: 200.0,
614 observed_drop: 0.0,
615 drop_unit: DropUnit::Mil,
616 })
617 .expect("zero observed drop still derives");
618 assert_eq!(ok.dsf, 0.0);
619 }
620}