Skip to main content

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}