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::{
46 plan_corrections, AngularCorrection, DialPlanReportV1, OpticError, OpticProfile, Preferences,
47};
48use crate::truing::{DropUnit, TruingModelInputsV1};
49use crate::truing_dsf::{
50 dsf_observation_warrants_90pct_warning, interpolate_position_and_velocity,
51 mach_1_crossing_range_m, solve_for_dsf, DsfSolveInputs, DSF_MACH_CEILING,
52};
53
54/// Request for a tall-target correction-factor computation.
55///
56/// `metric` selects the measured-travel unit exactly like the CLI's `--units` flag:
57/// `false` measures `measured` in inches at yards (divisor 36.0), `true` measures it in
58/// centimeters at meters (divisor 100.0).
59#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct TallTargetRequestV1 {
62 pub dialed: f64,
63 pub measured: f64,
64 pub range: f64,
65 pub unit: AdjustmentUnit,
66 pub metric: bool,
67}
68
69/// Result of a tall-target correction-factor computation.
70#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
71pub struct TallTargetResultV1 {
72 pub dialed: f64,
73 pub actual: f64,
74 pub correction_factor: f64,
75 pub within_accepted_band: bool,
76}
77
78/// Errors returned by [`tall_target_v1`]. One variant per guard (not a single stringly-typed
79/// variant) so a caller — in particular a future JSON bridge — can match exhaustively instead
80/// of sniffing message text; adding a fifth guard here forces every `match` on this type to be
81/// updated at compile time.
82#[derive(Debug, thiserror::Error, PartialEq)]
83pub enum TallTargetErrorV1 {
84 #[error("clicks is not an angular unit; enter the dialed travel in mil, moa, smoa, or iphy")]
85 ClicksNotAngular,
86 #[error("dialed must be a positive angular travel")]
87 InvalidDialed,
88 #[error("measured must be a positive measured travel")]
89 InvalidMeasured,
90 #[error("range must be at least 1 yard/meter")]
91 InvalidRange,
92}
93
94/// Computes the tall-target correction factor: same arithmetic and validation guards as the
95/// CLI's `tall-target` command, minus the printing.
96pub fn tall_target_v1(req: &TallTargetRequestV1) -> Result<TallTargetResultV1, TallTargetErrorV1> {
97 if req.unit == AdjustmentUnit::Clicks {
98 return Err(TallTargetErrorV1::ClicksNotAngular);
99 }
100 if !req.dialed.is_finite() || req.dialed <= 0.0 {
101 return Err(TallTargetErrorV1::InvalidDialed);
102 }
103 if !req.measured.is_finite() || req.measured <= 0.0 {
104 return Err(TallTargetErrorV1::InvalidMeasured);
105 }
106 if !req.range.is_finite() || req.range < 1.0 {
107 return Err(TallTargetErrorV1::InvalidRange);
108 }
109 let drop_len = if req.metric {
110 req.measured / 100.0
111 } else {
112 req.measured / 36.0
113 };
114 let actual = drop_to_adjustment(drop_len, req.range, req.unit);
115 let correction_factor = actual / req.dialed;
116 Ok(TallTargetResultV1 {
117 dialed: req.dialed,
118 actual,
119 correction_factor,
120 within_accepted_band: crate::profile::validate_tracking_cf(
121 correction_factor,
122 "the computed factor",
123 )
124 .is_ok(),
125 })
126}
127
128// ============================================================================
129// `dsf` derivation (MBA-1357 Task 9)
130// ============================================================================
131
132/// Mirrors the CLI's own `DSF_DEFAULT_SOLVE_ENVELOPE` (1000 yd) — the solve envelope
133/// `trajectory --saved-profile NAME`/`come-ups --profile NAME` use by default, absent an
134/// explicit `--max-range`. A bare model-driven request has no saved profile's own
135/// configured max range to reuse, so this is the model-side equivalent: solve out to 1000
136/// yd, or 5% past the requested observation range when that range itself exceeds 1000 yd,
137/// so the solved points always bracket the requested range.
138const DSF_DEFAULT_SOLVE_ENVELOPE_YD: f64 = 1000.0;
139
140fn dsf_solve_envelope_m(range_yd: f64) -> f64 {
141 let default_envelope_m = DSF_DEFAULT_SOLVE_ENVELOPE_YD * 0.9144;
142 let range_m = range_yd * 0.9144;
143 if range_m > default_envelope_m {
144 range_m * 1.05
145 } else {
146 default_envelope_m
147 }
148}
149
150/// Request for a `dsf` point derivation from a bare scalar-BC model (MBA-1357 Task 9).
151///
152/// `range_yd` is the observation's horizontal range, yards. `observed_drop` is the
153/// physically observed drop at that range, in `drop_unit` — the same
154/// numeric-value-plus-unit-suffix pair the CLI's `--observed-drop` flag parses (e.g.
155/// `5.1mil`, `17.4moa`, `42.0in`).
156#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct DsfDeriveRequestV1 {
159 pub model: TruingModelInputsV1,
160 pub range_yd: f64,
161 pub observed_drop: f64,
162 pub drop_unit: DropUnit,
163}
164
165/// One derived Mach-keyed DSF point, plus any non-fatal warnings the derivation raised.
166///
167/// `predicted_drop` and `observed_drop` are both expressed in `drop_unit` (mirroring the
168/// request), so a caller can compare them directly.
169#[derive(Debug, Clone, Serialize)]
170pub struct DsfPointResultV1 {
171 pub mach: f64,
172 pub dsf: f64,
173 pub predicted_drop: f64,
174 pub observed_drop: f64,
175 pub drop_unit: DropUnit,
176 pub warnings: Vec<String>,
177}
178
179/// Errors returned by [`derive_dsf_point_v1`] and [`derive_dsf_point_from_solve_v1`]. One
180/// variant per failure mode (not a single stringly-typed variant) so a caller — in
181/// particular the JSON bridge — can match exhaustively instead of sniffing message text.
182#[derive(Debug, thiserror::Error, PartialEq)]
183pub enum DsfServiceErrorV1 {
184 #[error("invalid dsf request: {0}")]
185 InvalidInput(String),
186 #[error(
187 "observation at Mach {mach:.2} is supersonic (ceiling {ceiling:.2}); \
188 use true.fit to correct muzzle velocity instead"
189 )]
190 Supersonic { mach: f64, ceiling: f64 },
191 #[error("trajectory does not reach {range_yd:.0} yd (solved to {solved_yd:.0} yd)")]
192 OutOfRange { range_yd: f64, solved_yd: f64 },
193 #[error("predicted drop at {range_yd:.0} yd is zero or non-finite; no DSF ratio exists")]
194 DegenerateDrop { range_yd: f64 },
195 #[error("dsf forward model failed: {0}")]
196 ForwardModel(String),
197}
198
199impl DsfServiceErrorV1 {
200 /// Structured detail payload for a JSON bridge error envelope (MBA-1357 Task 2's
201 /// ruling: an INHERENT method, not a trait impl, so this stays in `truing_service` and
202 /// keeps compiling with the `bridge` feature off).
203 pub fn failure_details(&self) -> Option<serde_json::Value> {
204 Some(match self {
205 Self::InvalidInput(_) => serde_json::json!({"reason": "invalid_input"}),
206 Self::Supersonic { mach, ceiling } => {
207 serde_json::json!({"reason": "supersonic", "mach": mach, "ceiling": ceiling})
208 }
209 Self::OutOfRange {
210 range_yd,
211 solved_yd,
212 } => serde_json::json!({
213 "reason": "out_of_range",
214 "range_yd": range_yd,
215 "solved_yd": solved_yd
216 }),
217 Self::DegenerateDrop { range_yd } => {
218 serde_json::json!({"reason": "degenerate_drop", "range_yd": range_yd})
219 }
220 Self::ForwardModel(_) => serde_json::json!({"reason": "forward_model"}),
221 })
222 }
223}
224
225/// Derive a `dsf` point from a bare scalar-BC model (MBA-1357 Task 9) — the JSON bridge's
226/// entry point, with no saved profile behind it.
227///
228/// Builds [`DsfSolveInputs`] from `req.model` via the existing `From` impl (bridge
229/// defaults: no wind, level shot, no zero-POI/sight-mount offsets, ICAO BC reference, no
230/// BC segments, no custom drag curve — see that impl's own doc comment), sizes a solve
231/// envelope the same way the CLI's own default does, solves, and hands the result to
232/// [`derive_dsf_point_from_solve_v1`] for the actual derivation.
233///
234/// **Derive only** — never writes a DSF table, opens a profile, touches the filesystem, or
235/// calls `std::process::exit`.
236pub fn derive_dsf_point_v1(
237 req: &DsfDeriveRequestV1,
238) -> Result<DsfPointResultV1, DsfServiceErrorV1> {
239 req.model
240 .validate()
241 .map_err(DsfServiceErrorV1::InvalidInput)?;
242 if !req.range_yd.is_finite() || req.range_yd <= 0.0 {
243 return Err(DsfServiceErrorV1::InvalidInput(
244 "range_yd must be a positive, finite distance".to_string(),
245 ));
246 }
247 if !req.observed_drop.is_finite() {
248 return Err(DsfServiceErrorV1::InvalidInput(
249 "observed_drop must be finite".to_string(),
250 ));
251 }
252
253 let solve_inputs: DsfSolveInputs = (&req.model).into();
254 let max_range_m = dsf_solve_envelope_m(req.range_yd);
255 let result =
256 solve_for_dsf(&solve_inputs, max_range_m).map_err(DsfServiceErrorV1::ForwardModel)?;
257
258 let range_m = req.range_yd * 0.9144;
259 derive_dsf_point_from_solve_v1(&result, range_m, req.observed_drop, req.drop_unit)
260}
261
262/// The post-solve half of a `dsf` point derivation: interpolation, Mach computation, both
263/// gates, and the DSF ratio, run against an ALREADY-SOLVED `result` (MBA-1357 Task 9).
264///
265/// Shared by [`derive_dsf_point_v1`] (which solves from a bare `TruingModelInputsV1`) and
266/// the CLI's saved-profile `dsf` command (which solves via the profile-rich
267/// [`DsfSolveInputs`] instead, preserving every field Task 8 fixed — see this module's own
268/// doc comment) so the two callers' arithmetic cannot drift apart.
269///
270/// `range_m` is the observation's horizontal range in meters. The two gates carried over
271/// from the CLI exactly:
272/// - A supersonic observation (`mach > DSF_MACH_CEILING`) is an error — that's
273/// `true-velocity`'s job, not DSF's.
274/// - The "beyond Mach 1 AND beyond 90% of the solved range" case (a COMPOUND condition,
275/// not the 90% ratio alone) is a warning appended to the result's `warnings`, not a
276/// failure.
277/// - A zero or non-finite predicted drop is an error; no DSF ratio exists.
278pub fn derive_dsf_point_from_solve_v1(
279 result: &TrajectoryResult,
280 range_m: f64,
281 observed_drop: f64,
282 drop_unit: DropUnit,
283) -> Result<DsfPointResultV1, DsfServiceErrorV1> {
284 let range_yd = range_m / 0.9144;
285
286 let (position_y, velocity_mag) = interpolate_position_and_velocity(&result.points, range_m)
287 .ok_or(DsfServiceErrorV1::OutOfRange {
288 range_yd,
289 solved_yd: result.max_range / 0.9144,
290 })?;
291
292 let predicted_drop_m = result.line_of_sight_height_m - position_y;
293 let mach = if result.station_speed_of_sound_mps > 0.0 {
294 velocity_mag / result.station_speed_of_sound_mps
295 } else {
296 0.0
297 };
298
299 // Gate 1: reject a supersonic observation outright — that's true-velocity's job.
300 if mach > DSF_MACH_CEILING {
301 return Err(DsfServiceErrorV1::Supersonic {
302 mach,
303 ceiling: DSF_MACH_CEILING,
304 });
305 }
306
307 // Gate 2: warn when the observation is BOTH beyond the trajectory's Mach-1.0 crossing
308 // AND beyond 90% of the solved max range (a compound condition, not the 90% ratio
309 // alone — see dsf_observation_warrants_90pct_warning's own doc comment).
310 let mut warnings = Vec::new();
311 let crossing_m = mach_1_crossing_range_m(result);
312 if dsf_observation_warrants_90pct_warning(range_m, crossing_m, result.max_range) {
313 warnings.push(format!(
314 "observation at {range_yd:.0} yd is beyond 90% of the solved range; solution \
315 reliability degrades past this point"
316 ));
317 }
318
319 let predicted_value = drop_unit.express_drop_m(predicted_drop_m, range_m);
320 if !predicted_value.is_finite() || predicted_value == 0.0 {
321 return Err(DsfServiceErrorV1::DegenerateDrop { range_yd });
322 }
323 let dsf = observed_drop / predicted_value;
324
325 Ok(DsfPointResultV1 {
326 mach,
327 dsf,
328 predicted_drop: predicted_value,
329 observed_drop,
330 drop_unit,
331 warnings,
332 })
333}
334
335// ============================================================================
336// `dial-plan` (MBA-1348's `plan_corrections`, bridge wrapper)
337// ============================================================================
338
339/// Yards-to-metres, matching `dsf_solve_envelope_m`'s own constant above: `plan_corrections`
340/// takes `range_m` in metres, but every other `true.*` command except `true.wind` is
341/// imperial on the wire, so the request stays imperial and this is the one conversion the
342/// service performs.
343const YARDS_TO_METRES: f64 = 0.9144;
344
345/// Serde default for `DialPlanRequestV1::elevation_cf`/`windage_cf`: 1.0, the neutral
346/// tracking-correction-factor value. Most callers have no tall-target CF measured yet, so
347/// requiring the field would be a trap — see the struct's own doc comment.
348fn unity_cf() -> f64 {
349 1.0
350}
351
352/// Request for [`dial_plan_v1`]: the bridge's wrapper around [`plan_corrections`]'s six
353/// flat parameters (MBA-1348 Task 6's CLI wraps the same six from `--elevation`/`--windage`,
354/// `--profile`/inline optic flags, `--range`, and `--prefer-hold`/`--max-hold`).
355///
356/// `range_yd` is in YARDS: `plan_corrections` itself takes metres, but every other `true.*`
357/// command except `true.wind` is imperial on the wire, so this one stays imperial too and
358/// [`dial_plan_v1`] converts at the boundary. `elevation_cf`/`windage_cf` default to `1.0`
359/// (the neutral value) rather than being required — the CLI's own `--profile`-less inline
360/// mode has no tracking-CF flags at all and always passes 1.0, and most callers have no
361/// tall-target correction factor measured yet, so requiring the field here would be a trap.
362///
363/// The optic is supplied INLINE (`optic: OpticProfile`), never loaded from a named profile:
364/// the CLI's `--profile` mode reads a saved profile from disk (`load_profile`), and that
365/// filesystem dependency must not enter this module — see the module's own doc comment.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct DialPlanRequestV1 {
369 pub correction: AngularCorrection,
370 pub optic: OpticProfile,
371 /// Range in YARDS for the linear-miss-at-range residual. See the struct doc comment.
372 pub range_yd: f64,
373 #[serde(default = "unity_cf")]
374 pub elevation_cf: f64,
375 #[serde(default = "unity_cf")]
376 pub windage_cf: f64,
377 #[serde(default)]
378 pub preferences: Preferences,
379}
380
381/// Turn a TRUE angular [`AngularCorrection`] into ranked dial/hold/hybrid plans for an
382/// inline [`OpticProfile`] — the JSON bridge's entry point to [`plan_corrections`].
383///
384/// Contains NO arithmetic beyond the yards-to-metres conversion: everything else is
385/// [`plan_corrections`] itself, so there is no second implementation of the CF rule or the
386/// ranking logic to drift from the CLI's `dial-plan` command or from `plan_corrections`'s
387/// own tests.
388pub fn dial_plan_v1(req: &DialPlanRequestV1) -> Result<DialPlanReportV1, OpticError> {
389 plan_corrections(
390 req.correction,
391 &req.optic,
392 req.range_yd * YARDS_TO_METRES,
393 req.elevation_cf,
394 req.windage_cf,
395 &req.preferences,
396 )
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn tall_target_matches_the_cli_arithmetic_and_rejects_clicks() {
405 // 30 inches measured at 100 yd against 10 mil dialed.
406 let req = TallTargetRequestV1 {
407 dialed: 10.0,
408 measured: 30.0,
409 range: 100.0,
410 unit: AdjustmentUnit::Mil,
411 metric: false,
412 };
413 let r = tall_target_v1(&req).expect("computes");
414 let expected_actual =
415 crate::adjustment::drop_to_adjustment(30.0 / 36.0, 100.0, AdjustmentUnit::Mil);
416 assert!((r.actual - expected_actual).abs() < 1e-12);
417 assert!((r.correction_factor - expected_actual / 10.0).abs() < 1e-12);
418
419 // Clicks is not an angular unit.
420 let bad = TallTargetRequestV1 {
421 unit: AdjustmentUnit::Clicks,
422 ..req
423 };
424 assert_eq!(
425 tall_target_v1(&bad),
426 Err(TallTargetErrorV1::ClicksNotAngular)
427 );
428
429 // Non-positive and sub-1 range are rejected, matching the CLI guards, each with its
430 // own variant (not a shared stringly-typed error).
431 assert_eq!(
432 tall_target_v1(&TallTargetRequestV1 { dialed: 0.0, ..req }),
433 Err(TallTargetErrorV1::InvalidDialed)
434 );
435 assert_eq!(
436 tall_target_v1(&TallTargetRequestV1 {
437 measured: -1.0,
438 ..req
439 }),
440 Err(TallTargetErrorV1::InvalidMeasured)
441 );
442 assert_eq!(
443 tall_target_v1(&TallTargetRequestV1 { range: 0.5, ..req }),
444 Err(TallTargetErrorV1::InvalidRange)
445 );
446 }
447
448 #[test]
449 fn service_reproduces_the_cli_imperial_and_metric_ratios() {
450 // Imperial: inches at yards, 36 in/yd. Metric: cm at metres, 100 cm/m.
451 for (metric, measured, divisor) in [(false, 30.0, 36.0), (true, 76.2, 100.0)] {
452 let r = tall_target_v1(&TallTargetRequestV1 {
453 dialed: 10.0,
454 measured,
455 range: 100.0,
456 unit: AdjustmentUnit::Mil,
457 metric,
458 })
459 .expect("computes");
460 let expected = crate::adjustment::drop_to_adjustment(
461 measured / divisor,
462 100.0,
463 AdjustmentUnit::Mil,
464 );
465 assert!((r.actual - expected).abs() < 1e-12, "metric={metric}");
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}