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}