ballistics_engine/
solve_v1.rs

1//! Transport-free implementation of the solve-json v1 trajectory service.
2//!
3//! This module owns the semantic boundary between the stable, explicit-SI v1 data-transfer
4//! objects and the engine's legacy internal input types.  It deliberately performs no JSON I/O,
5//! filesystem access, network access, profile lookup, or terminal output.
6
7use crate::solve_json::{
8    AtmosphereV1, DragModelV1, DragTableKindV1, DragTableV1, EffectsV1, PressureReferenceV1,
9    ProjectileV1, ResolvedAtmosphereV1, ResolvedConstantWindV1, ResolvedEffectsV1,
10    ResolvedProjectileV1, ResolvedRifleV1, ResolvedSamplingV1, ResolvedSegmentedWindV1,
11    ResolvedShotV1, ResolvedSolveRequestV1, ResolvedSolverV1, ResolvedWindSegmentV1,
12    ResolvedWindV1, RifleV1, SampleFlagV1, SamplingV1, SchemaVersionV1, ShotV1, SolveErrorCodeV1,
13    SolveErrorEnvelopeV1, SolveErrorV1, SolveNoticeV1, SolveRequestV1, SolveSuccessV1,
14    SolveSummaryV1, SolverMethodV1, SolverV1, SuccessStatusV1, TerminationReasonV1,
15    TrajectorySampleV1, TwistDirectionV1, WindReferenceV1, WindShearModelV1, WindV1,
16    MAX_SOLVE_JSON_SAMPLES_V1,
17};
18use crate::trajectory_observation::{
19    bracket_param, Bracket, TrajectoryObservation, TrajectoryObservationError,
20    TrajectoryObservationFlag, TrajectoryTermination,
21};
22use crate::wind::WindSegment;
23use crate::{
24    AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, TrajectorySolver,
25    WindConditions,
26};
27
28const DEFAULT_SIGHT_HEIGHT_M: f64 = 0.05;
29const DEFAULT_MUZZLE_HEIGHT_M: f64 = 0.0;
30const DEFAULT_TWIST_RATE_M_PER_TURN: f64 = 0.3048;
31const DEFAULT_ALTITUDE_M: f64 = 0.0;
32const DEFAULT_RELATIVE_HUMIDITY: f64 = 0.5;
33const DEFAULT_TIME_STEP_S: f64 = 0.001;
34const DEFAULT_SAMPLE_INTERVAL_M: f64 = 10.0;
35const DEFAULT_GROUND_THRESHOLD_M: f64 = -100.0;
36
37const MIN_ICAO_ALTITUDE_M: f64 = -5_000.0;
38const MAX_ICAO_ALTITUDE_M: f64 = 84_000.0;
39const METERS_PER_INCH: f64 = 0.0254;
40const KILOMETRES_PER_METRE: f64 = 0.001;
41const SECONDS_PER_HOUR: f64 = 3_600.0;
42pub(crate) const PASCALS_PER_HECTOPASCAL: f64 = 100.0;
43pub(crate) const KELVIN_OFFSET_C: f64 = 273.15;
44const KG_PER_GRAIN: f64 = 0.000_064_798_91;
45
46/// Stable assumption code emitted for a literal protocol default.
47pub const ASSUMPTION_DEFAULT_APPLIED: &str = "default_applied";
48/// Stable assumption code emitted when station temperature is resolved from ICAO atmosphere.
49pub const ASSUMPTION_ICAO_STANDARD_TEMPERATURE: &str = "icao_standard_temperature";
50/// Stable assumption code emitted when station pressure is resolved from ICAO atmosphere.
51pub const ASSUMPTION_ICAO_STANDARD_PRESSURE: &str = "icao_standard_pressure";
52/// Stable assumption code emitted when an explicit `pressure_reference: "qnh"` pressure is
53/// reduced to station pressure (MBA-1397).
54pub const ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE: &str = "qnh_reduced_to_station_pressure";
55/// Stable assumption code emitted when projectile length is inferred from mass and diameter.
56pub const ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH: &str = "estimated_projectile_length";
57
58/// Stable warning code for segmented wind that becomes calm before the requested maximum range.
59pub const WARNING_PARTIAL_WIND_COVERAGE: &str = "partial_wind_coverage";
60/// Stable warning code emitted for each opt-in experimental physical effect.
61pub const WARNING_EXPERIMENTAL_EFFECT: &str = "experimental_effect";
62/// Stable warning code for an explicitly supplied fixed step that adaptive RK45 ignores.
63pub const WARNING_RK45_TIME_STEP_IGNORED: &str = "rk45_time_step_ignored";
64/// Stable warning code for a request that supplies both `zero_distance_m` and
65/// `muzzle_angle_rad` (0.33.0 decision-support Task 2): the explicit angle is used directly
66/// and the elevation search does not run. `zero_distance_m` is retained in `resolved_request`
67/// and is not otherwise inert -- see the warning message for what it still affects.
68pub const WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED: &str =
69    "zero_distance_elevation_not_resolved";
70/// Stable warning code emitted when a `corrections.bc5d_table_path` request names a drag
71/// model outside the table's G1/G7 planes and the lookup is typed as G1 (the same coercion
72/// the CLI's `--bc-table-dir` path performs, surfaced instead of silent).
73pub const WARNING_BC5D_DRAG_MODEL_COERCED: &str = "bc5d_drag_model_coerced";
74/// Stable warning code emitted when an accepted `effects.wind_shear_model` has no profile on
75/// this solve path and therefore leaves the wind unchanged (0.36.0). Only `"ekman_spiral"`
76/// reaches it today: the boundary-layer evaluator behind `TrajectorySolver` implements the
77/// logarithmic and power-law profiles, and returns the operative wind for anything else. The
78/// model is still echoed in `resolved_request`, so this warning is what distinguishes "applied
79/// and it moved the numbers" from "applied and it could not".
80pub const WARNING_WIND_SHEAR_MODEL_NOT_MODELED: &str = "wind_shear_model_not_modeled";
81
82/// `effects.aerodynamic_jump` was enabled while the request left the geometry the estimator
83/// reads to a default (MBA-959).
84///
85/// The jump scales with gyroscopic stability and bullet length, so it reads
86/// `rifle.twist_rate_m_per_turn` and `projectile.length_m`. Omitting either does NOT disable
87/// the correction and does not zero it — the solve proceeds against the assumed 1:12 barrel
88/// and the mass/diameter length estimate, and produces a confident number computed for a
89/// rifle the caller does not own. Measured on a .308 175 gr at 800 m in a 10 mph full-value
90/// crosswind, an omitted twist moves the jump from 10.79 cm to 9.08 cm — a 16% error that is
91/// invisible in the response, because the jump is folded into every drop rather than reported
92/// as its own column.
93///
94/// The two assumption notices for the defaults themselves are still emitted; this one exists
95/// because those say "a default was applied" without saying that anything now depends on it.
96///
97/// Aerodynamic jump is not the only effect that reads the barrel: `effects.magnus` and
98/// `effects.enhanced_spin_drift` do too, under the separate
99/// [`WARNING_SPIN_EFFECT_ASSUMED_TWIST_RATE`], and `summary.stability_factor` on every solve,
100/// under [`WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE`]. The codes are distinct so that each
101/// one names the model it belongs to — they are NOT mutually exclusive, and a single omitted
102/// `rifle.twist_rate_m_per_turn` routinely raises several of them at once. A request enabling
103/// both `aerodynamic_jump` and `magnus` without a stated twist gets all three: one per
104/// consumer, each at its own path. Callers must match on the code they care about rather than
105/// assume at most one assumed-barrel warning is present.
106pub const WARNING_AERODYNAMIC_JUMP_ASSUMED_GEOMETRY: &str = "aerodynamic_jump_assumed_geometry";
107
108/// A spin-driven effect (`effects.magnus` or `effects.enhanced_spin_drift`) was enabled while
109/// the request left `rifle.twist_rate_m_per_turn` to its default (MBA-1484).
110///
111/// Both models are functions of the spin the rifling imparts, so both read the twist rate —
112/// enhanced spin drift through the muzzle gyroscopic stability factor that scales the Litz
113/// drift, Magnus through the spin rate that sets its side force and yaw of repose. Omitting
114/// the field does NOT disable either one and does not zero it: `resolve_rifle` substitutes
115/// `DEFAULT_TWIST_RATE_M_PER_TURN` (0.3048 m, exactly 1:12 inches) and the solve runs against
116/// that barrel, producing a confident number for a rifle the caller does not own. The early
117/// return in `TrajectorySolver::apply_spin_drift` for a non-positive twist is real but is
118/// unreachable from here, because the default is applied long before the solver sees the
119/// field.
120///
121/// Measured on a .308 175 gr at 800 m: `enhanced_spin_drift` reports
122/// `summary.spin_drift_m` = 0.1832 m with the twist omitted and the identical 0.1832 m with
123/// 1:12 stated — the omission is not a distinguishable request — while a stated 1:8 gives
124/// 0.3163 m, 73% more. Magnus's contribution is far smaller in absolute terms (it moved the
125/// 800 m drop by 0.19 mm at the assumed 1:12) but is no less twist-bound: at a stated 1:6 the
126/// same contribution is 0.75 mm, four times as large. In neither case is the dependence
127/// visible in the response, because both models are folded into the trajectory columns rather
128/// than reported separately — `summary.spin_drift_m` is the one exception, and it reports the
129/// number, not the barrel it came from.
130///
131/// The assumption notice for the default itself is still emitted; this one exists because it
132/// says "a default was applied" without saying that anything now depends on it.
133///
134/// This code covers the two opt-in models only. The twist is read on EVERY solve by
135/// `summary.stability_factor`, which has its own
136/// [`WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE`]; both fire together when a spin effect is
137/// enabled without a stated twist.
138pub const WARNING_SPIN_EFFECT_ASSUMED_TWIST_RATE: &str = "spin_effect_assumed_twist_rate";
139
140/// `summary.stability_factor` was computed from the default twist because the request left
141/// `rifle.twist_rate_m_per_turn` out (MBA-1484).
142///
143/// Unlike every other assumed-barrel warning this module emits, this one is not gated on an
144/// opt-in flag, because Sg is not opt-in: `effective_sg_from_inputs` runs on every solve and
145/// the muzzle gyroscopic stability factor is a direct function of the twist. An omitted field
146/// therefore does not leave Sg alone — it reports the Sg of a 1:12 barrel, under a field name
147/// that says nothing about which barrel it describes.
148///
149/// Measured on a .308 175 gr with NO effects enabled at all: `summary.stability_factor` is
150/// 1.6650154161603787 with the twist omitted, bit-identically 1.6650154161603787 with 1:12
151/// stated, and 3.7462846863608514 with 1:8 stated — 2.25x, across the marginal/comfortable
152/// line a shooter reads Sg to decide. `drop_m` and `windage_m` are bit-identical in all three,
153/// so the trajectory really is twist-independent when no spin effect is enabled; the summary
154/// is not, and that distinction is the whole reason this code exists separately from
155/// [`WARNING_SPIN_EFFECT_ASSUMED_TWIST_RATE`].
156///
157/// Emitted at `$.rifle.twist_rate_m_per_turn` — the omitted field itself — because there is no
158/// flag to hang it on, and only when Sg is actually reported: when
159/// `summary.stability_factor` is absent nothing misleading was published and there is nothing
160/// to warn about. It fires on any request that omits the twist and gets an Sg, which is most
161/// of them; that breadth is the finding, not a reason to suppress it. The `default_applied`
162/// assumption at the same path stays, and says a default was applied without saying that a
163/// reported number now depends on it.
164pub const WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE: &str = "stability_factor_assumed_twist_rate";
165
166/// Whether the raw request supplied the geometry fields the twist-reading effects need, as
167/// opposed to letting `resolve_rifle`/`resolve_projectile` substitute a default.
168///
169/// `twist_rate` is read by aerodynamic jump, Magnus and enhanced spin drift;
170/// `projectile_length` by aerodynamic jump. Carried rather than re-derived because
171/// `resolve_effects` only sees `EffectsV1`, and the distinction is about the RAW request: by
172/// the time the rifle and projectile are resolved, an assumed 1:12 twist is
173/// indistinguishable from a stated one.
174#[derive(Debug, Clone, Copy)]
175struct SuppliedGeometry {
176    twist_rate: bool,
177    projectile_length: bool,
178}
179
180#[derive(Debug)]
181pub(crate) struct PreparedSolveV1 {
182    pub(crate) resolved_request: ResolvedSolveRequestV1,
183    assumptions: Vec<SolveNoticeV1>,
184    warnings: Vec<SolveNoticeV1>,
185    pub(crate) inputs: BallisticInputs,
186    pub(crate) wind: WindConditions,
187    pub(crate) wind_segments: Vec<WindSegment>,
188    pub(crate) atmosphere: AtmosphericConditions,
189}
190
191/// Execute one semantically validated solve-json v1 request.
192///
193/// The function is transport-free: callers are responsible for decoding and encoding the DTOs.
194/// All dimensional request values are SI.  Valid requests produce a fully resolved success DTO;
195/// invalid requests and engine failures produce the stable v1 error envelope.
196pub fn solve_v1(request: SolveRequestV1) -> Result<SolveSuccessV1, SolveErrorEnvelopeV1> {
197    let mut prepared = prepare_request(&request)?;
198    let max_range_m = prepared.resolved_request.shot.max_range_m;
199    let time_step_s = prepared.resolved_request.solver.time_step_s;
200    let sample_interval_m = prepared.resolved_request.sampling.interval_m;
201    let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
202    let target_height_m = prepared.resolved_request.shot.target_height_m;
203    let enhanced_spin_drift = prepared.resolved_request.effects.enhanced_spin_drift;
204
205    // Retain the exact mapped inputs for summary diagnostics. TrajectorySolver owns its copy and
206    // may update the private effective muzzle angle during zeroing.
207    let summary_inputs = prepared.inputs.clone();
208    let station_temperature_c = prepared.atmosphere.temperature;
209    let station_pressure_hpa = prepared.atmosphere.pressure;
210
211    let (solver, effective_angle) = build_zeroed_solver(
212        prepared.inputs,
213        prepared.wind,
214        prepared.atmosphere,
215        prepared.wind_segments,
216        max_range_m,
217        time_step_s,
218        zero_distance_m,
219        target_height_m,
220        request.shot.muzzle_angle_rad,
221    )?;
222    // 0.33.0 decision-support Task 2: an explicitly supplied muzzle_angle_rad always wins over
223    // zero_distance_m for ELEVATION (resolve_shot's match already resolved it that way; see
224    // build_zeroed_solver's doc comment for the full zero-handling rationale, including the
225    // windage-zero bias). Persist the searched angle -- bit-identical, not just numerically
226    // re-converged, on a later round-trip -- only when the elevation search actually ran.
227    if let Some(angle) = effective_angle {
228        prepared.resolved_request.shot.muzzle_angle_rad = angle;
229    }
230
231    let result = solver.solve().map_err(solve_failed)?;
232    let observations = result
233        .sample_observations(sample_interval_m, MAX_SOLVE_JSON_SAMPLES_V1)
234        .map_err(map_observation_error)?;
235
236    // MBA-1403 target-plane drops reference: this solve path samples drop via
237    // TrajectoryResult::sample_observations (LOS-perpendicular by construction), so the
238    // 1/cos(shooting_angle) reference transform is applied exactly once here, at the wire
239    // boundary. `"los"`/omitted divides by cos(0) == 1.0 exactly — byte-identical.
240    // validate_for_solve has already rejected target mode at |shooting_angle| >= 90 deg.
241    let drops_scale_denominator = match request.shot.drops_reference.unwrap_or_default() {
242        crate::solve_json::DropsReferenceV1::Los => 1.0,
243        crate::solve_json::DropsReferenceV1::Target => {
244            prepared.resolved_request.shot.shooting_angle_rad.cos()
245        }
246    };
247    let samples = observations
248        .iter()
249        .cloned()
250        .map(|mut observation| {
251            observation.drop_m /= drops_scale_denominator;
252            observation_to_wire(observation)
253        })
254        .collect::<Vec<_>>();
255    let terminal = observations.last().ok_or_else(|| {
256        internal_error("trajectory observation sampling returned no terminal observation")
257    })?;
258
259    let maximum_height_m =
260        maximum_world_height_m(&result, prepared.resolved_request.shot.shooting_angle_rad)?;
261
262    let stability = crate::spin_drift::effective_sg_from_inputs(
263        &summary_inputs,
264        station_temperature_c,
265        station_pressure_hpa,
266    );
267    let stability_factor = if stability.is_finite() && stability >= 0.0 {
268        Some(stability)
269    } else {
270        None
271    };
272
273    // MBA-1484. Sg is a direct function of the twist and is computed on every solve, so an
274    // omitted `rifle.twist_rate_m_per_turn` does not leave it alone: it publishes the Sg of the
275    // assumed 1:12 barrel (1.665 on the .308 175 gr fixture, against 3.746 at a stated 1:8)
276    // under a field name that says nothing about which barrel it describes. Warned here rather
277    // than in `resolve_effects` because this is the one assumed-barrel consumer that is not
278    // gated on a flag -- there is nothing in `EffectsV1` to key off, and the gate is whether Sg
279    // was reported at all, which is not known until it has been computed.
280    //
281    // This does fire on nearly every twist-omitting request, including ones written before the
282    // warning existed. That breadth is the finding rather than an argument against it: those
283    // requests were always being handed a barrel-dependent Sg they never described. Warning on
284    // Magnus, whose whole contribution is 0.19 mm of drop at 800 m, while staying silent about
285    // a 2.25x swing in a reported stability number is not a line that can be defended.
286    if request.rifle.twist_rate_m_per_turn.is_none() && stability_factor.is_some() {
287        prepared.warnings.push(notice(
288            WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE,
289            "summary.stability_factor is computed from the twist on every solve, but \
290             rifle.twist_rate_m_per_turn was not supplied. The reported Sg is the Sg of the \
291             assumed 1:12 barrel, not of a barrel this request specified. The trajectory \
292             itself is unaffected unless a spin-driven effect is also enabled.",
293            "$.rifle.twist_rate_m_per_turn",
294        ));
295    }
296    let spin_drift_m = if enhanced_spin_drift {
297        stability_factor
298            .map(|sg| {
299                crate::spin_drift::litz_drift_meters(
300                    sg,
301                    terminal.time_s,
302                    summary_inputs.is_twist_right,
303                )
304            })
305            .filter(|drift| drift.is_finite())
306    } else {
307        None
308    };
309
310    if enhanced_spin_drift && stability_factor.is_some() && spin_drift_m.is_none() {
311        return Err(internal_error(
312            "enhanced spin-drift summary calculation produced a non-finite value",
313        ));
314    }
315
316    // MBA-1395: equivalent horizontal range (BDC shoot-to) — computed only for an
317    // inclined shot fired against a solved zero; None (field absent) otherwise, keeping
318    // every flat/pre-existing response byte-identical. The solver still holds the
319    // inclined, zeroed state here, so the angular-match inversion runs against the same
320    // zero the response describes.
321    let equivalent_horizontal_range_m = match (
322        zero_distance_m,
323        prepared.resolved_request.shot.shooting_angle_rad != 0.0,
324    ) {
325        (Some(zero_m), true) => solver.equivalent_horizontal_range(terminal.distance_m, zero_m),
326        _ => None,
327    };
328
329    // MBA-959: the jump the solver actually applied, read off its own components rather than
330    // recomputed here -- a second spelling of the Litz regression is a second answer. `None`
331    // when the flag is off or the solver declined the effect; `Some(0.0)` when it ran against
332    // no muzzle crosswind, which is a different fact and worth being able to tell apart.
333    let aerodynamic_jump_moa = result
334        .aerodynamic_jump
335        .as_ref()
336        .map(|components| components.vertical_jump_moa)
337        .filter(|moa| moa.is_finite());
338
339    let summary = SolveSummaryV1 {
340        actual_range_m: terminal.distance_m,
341        maximum_height_m,
342        time_of_flight_s: terminal.time_s,
343        terminal_speed_mps: terminal.speed_mps,
344        terminal_energy_j: terminal.energy_j,
345        stability_factor,
346        spin_drift_m,
347        equivalent_horizontal_range_m,
348        aerodynamic_jump_moa,
349        termination: termination_to_wire(result.termination),
350    };
351
352    // MBA-1361: a pure post-processing read of the samples above — it cannot change the
353    // trajectory, and `None` (no `reticle` block) keeps the response byte-identical.
354    let reticle_hold = match request.reticle.as_ref() {
355        Some(reticle) => Some(resolve_reticle_hold(reticle, &samples)?),
356        None => None,
357    };
358
359    let success = SolveSuccessV1 {
360        schema_version: SchemaVersionV1,
361        engine_version: env!("CARGO_PKG_VERSION").to_owned(),
362        status: SuccessStatusV1::Ok,
363        resolved_request: prepared.resolved_request,
364        assumptions: prepared.assumptions,
365        warnings: prepared.warnings,
366        summary,
367        samples,
368        reticle_hold,
369    };
370    success.validate_for_serialization()?;
371    Ok(success)
372}
373
374/// Build a `TrajectorySolver` from prepared inputs, configure it, and apply `solve_v1`'s own
375/// zero handling -- shared by `solve_v1` above and by the perturbation kernel's `evaluate`
376/// (`src/perturbation/mod.rs`, 0.33.0 decision-support Task 6, I3 review fix), which reads
377/// observations directly off the resulting solver's `TrajectoryResult` without going through
378/// the wire success DTO, and without a second, independent implementation of this zero handling
379/// that could silently drift from this one.
380///
381/// Returns the configured solver (not yet solved -- callers run `TrajectorySolver::solve`
382/// themselves, since `solve_v1` needs the solver alive afterward too, for
383/// `equivalent_horizontal_range`) alongside the elevation search's effective angle when it ran
384/// (`Some`), so a caller that wants to persist it -- as `solve_v1` does, onto
385/// `resolved_request.shot.muzzle_angle_rad` -- can; `None` when it did not run at all (no
386/// `zero_distance_m`), or ran only for windage (`zero_distance_m` present alongside an explicit
387/// `explicit_angle`, which needs no persisting since it was already supplied).
388///
389/// `explicit_angle` is the ORIGINAL (unresolved) request's `shot.muzzle_angle_rad`: an
390/// explicitly supplied angle always wins over `zero_distance_m` for ELEVATION (`resolve_shot`'s
391/// match already resolved it that way). `calculate_and_set_zero_angle` also applies a SEPARATE
392/// windage-zero convergence bias (offset-mounted sights / MBA-1396, deliberate horizontal POI /
393/// MBA-1359) to azimuth, gated only on `zero_distance_m` -- not on whether the elevation was
394/// searched for or supplied directly -- so when both fields are present, this still applies just
395/// the windage term on its own rather than skipping the whole call. That bias is deliberately
396/// never returned/persisted (unlike the elevation): `zero_distance_m` being present no longer
397/// implies "the search ran", so folding the bias into a persisted, echoed field would
398/// double-apply it on a second-generation round-trip (the same trap avoided for
399/// `atmosphere.pressure_reference` / `wind.wind_reference` in `request_roundtrip.rs`).
400#[allow(clippy::too_many_arguments)]
401pub(crate) fn build_zeroed_solver(
402    prepared_inputs: BallisticInputs,
403    wind: WindConditions,
404    atmosphere: AtmosphericConditions,
405    wind_segments: Vec<WindSegment>,
406    max_range_m: f64,
407    time_step_s: f64,
408    zero_distance_m: Option<f64>,
409    target_height_m: f64,
410    explicit_angle: Option<f64>,
411) -> Result<(TrajectorySolver, Option<f64>), SolveErrorEnvelopeV1> {
412    let mut solver =
413        TrajectorySolver::new_with_resolved_station_atmosphere(prepared_inputs, wind, atmosphere);
414    solver.set_max_range(max_range_m);
415    solver.set_time_step(time_step_s);
416    if !wind_segments.is_empty() {
417        solver.set_wind_segments(wind_segments);
418    }
419
420    let mut effective_angle = None;
421    match (zero_distance_m, explicit_angle) {
422        (Some(distance_m), None) => {
423            let angle = solver
424                .calculate_and_set_zero_angle(
425                    distance_m,
426                    target_height_m,
427                    crate::cli_api::ZeroTargetFrame::WorldVertical,
428                )
429                .map_err(solve_failed)?;
430            if !angle.is_finite() {
431                return Err(solve_failed_message(
432                    "zero-angle calculation returned a non-finite effective muzzle angle",
433                ));
434            }
435            effective_angle = Some(angle);
436        }
437        (Some(distance_m), Some(_)) => solver.apply_windage_zero_bias(distance_m),
438        (None, _) => {}
439    }
440
441    Ok((solver, effective_angle))
442}
443
444// `pub(crate)` (0.33.0 decision-support Task 6): the perturbation kernel's `evaluate` needs the
445// same request resolution `solve_v1` above starts from, to read observations off the raw
446// `TrajectoryResult` without going through the wire success DTO. Zero handling itself is shared
447// via `build_zeroed_solver` above (I3 review fix: an earlier revision hand-duplicated that match
448// in `perturbation/mod.rs` instead, verified consistent only by an outboard cross-check test --
449// a structural share removes that ongoing "keep two copies in sync" risk instead of relying on a
450// test to keep catching it).
451pub(crate) fn prepare_request(
452    request: &SolveRequestV1,
453) -> Result<PreparedSolveV1, SolveErrorEnvelopeV1> {
454    let mut assumptions = Vec::new();
455    let mut warnings = Vec::new();
456
457    let (projectile, bullet_length_m) = resolve_projectile(&request.projectile, &mut assumptions)?;
458    let rifle = resolve_rifle(&request.rifle, &mut assumptions)?;
459    let shot = resolve_shot(&request.shot, &rifle, &mut assumptions, &mut warnings)?;
460    let atmosphere = resolve_atmosphere(&request.atmosphere, &mut assumptions)?;
461    let wind_coverage_distance_m = shot.zero_distance_m.unwrap_or(0.0).max(shot.max_range_m);
462    let (resolved_wind, wind, wind_segments) = resolve_wind(
463        &request.wind,
464        wind_coverage_distance_m,
465        // MBA-1368: the RAW request option, not the resolved default — compass mode
466        // must reject an omitted shot azimuth, never silently re-reference against
467        // the 0.0 default.
468        request.shot.shot_azimuth_rad,
469        &mut assumptions,
470        &mut warnings,
471    )?;
472    let solver = resolve_solver(&request.solver, &mut assumptions, &mut warnings)?;
473    let effects = resolve_effects(
474        &request.effects,
475        atmosphere.latitude_rad,
476        SuppliedGeometry {
477            twist_rate: request.rifle.twist_rate_m_per_turn.is_some(),
478            projectile_length: request.projectile.length_m.is_some(),
479        },
480        &mut assumptions,
481        &mut warnings,
482    )?;
483    let sampling = resolve_sampling(&request.sampling, &mut assumptions)?;
484
485    // Downrange segments and altitude shear are not a defined combination, and the solver does
486    // not treat them as one: `TrajectorySolver::calculate_acceleration` takes the segmented
487    // wind sock in preference to the shear branch, so a request asking for both would get its
488    // shear dropped on the floor while `resolved_request` still claimed a model was applied.
489    // The CLI (`--wind-segment` + `--enable-wind-shear`) and the WASM front end already refuse
490    // this pairing; refuse it here too rather than inventing the third front end that solves it
491    // silently.
492    if matches!(resolved_wind, ResolvedWindV1::Segmented(_))
493        && effects
494            .wind_shear_model
495            .is_some_and(WindShearModelV1::is_enabled)
496    {
497        return Err(conflicting_fields(
498            "$.effects.wind_shear_model",
499            "wind.segments cannot be combined with effects.wind_shear_model (downrange \
500             segments plus altitude shear is not yet a defined model)",
501        ));
502    }
503
504    // A BC5D table corrects a G1/G7 ballistic coefficient along the flight; a drag table
505    // replaces the G-curve that BC is measured against, so the pair has no meaning. Refused
506    // before the table path is ever opened.
507    if projectile.drag_table.is_some()
508        && request
509            .corrections
510            .as_ref()
511            .is_some_and(|corrections| corrections.bc5d_table_path.is_some())
512    {
513        return Err(conflicting_fields(
514            "$.corrections.bc5d_table_path",
515            "corrections.bc5d_table_path cannot be combined with projectile.drag_table (BC5D \
516             corrects a G1/G7 ballistic coefficient; a drag table replaces the G-curve)",
517        ));
518    }
519
520    // MBA-1598: a BC ladder's band BCs are measured against a reference curve: drag_model's,
521    // or a `reference`-kind drag table's, which replaces it and is flown as Cd_ref / BC with
522    // the band BC exactly as a built-in model is (install_drag_table). A `projectile`-kind
523    // table is the projectile's own Cd, divided by sectional density, so it has no BC for the
524    // bands to change, and that pair is refused. The correction table builds its own ladder
525    // from ballistic_coefficient, so a caller ladder beside it is two answers to one question.
526    // Both refusals happen here, before the correction table path is ever opened.
527    if projectile.bc_segments.is_some()
528        && projectile
529            .drag_table
530            .as_ref()
531            .is_some_and(|table| table.kind == DragTableKindV1::Projectile)
532    {
533        return Err(conflicting_fields(
534            "$.projectile.bc_segments",
535            "projectile.bc_segments cannot be combined with a projectile.drag_table of kind \
536             \"projectile\": that curve is the projectile's own Cd and has no BC for the bands \
537             to change",
538        ));
539    }
540    if projectile.bc_segments.is_some()
541        && request
542            .corrections
543            .as_ref()
544            .is_some_and(|corrections| corrections.bc5d_table_path.is_some())
545    {
546        return Err(conflicting_fields(
547            "$.projectile.bc_segments",
548            "projectile.bc_segments cannot be combined with corrections.bc5d_table_path: the \
549             correction table builds its own BC ladder from ballistic_coefficient",
550        ));
551    }
552
553    let resolved_request = ResolvedSolveRequestV1 {
554        schema_version: SchemaVersionV1,
555        projectile,
556        rifle,
557        shot,
558        atmosphere,
559        wind: resolved_wind,
560        solver,
561        effects,
562        sampling,
563        // MBA-1361: pure pass-through echo (Task 1 of the 0.33.0 decision-support train) — no
564        // defaulting, no unit conversion. The reticle hold itself is computed later in
565        // solve_v1 straight from `request.reticle`, unaffected by this echo.
566        reticle: request.reticle.clone(),
567        // Pure pass-through echo, same contract as `reticle` above: segments are always
568        // regenerated from the PUBLISHED ballistic_coefficient, so re-solving a resolved
569        // request re-applies the same correction rather than compounding it.
570        corrections: request.corrections.clone(),
571    };
572
573    let temperature_c = checked_conversion(
574        resolved_request.atmosphere.temperature_k - KELVIN_OFFSET_C,
575        "$.atmosphere.temperature_k",
576        "temperature conversion to Celsius overflowed",
577    )?;
578    let pressure_hpa = checked_conversion(
579        resolved_request.atmosphere.pressure_pa / PASCALS_PER_HECTOPASCAL,
580        "$.atmosphere.pressure_pa",
581        "pressure conversion to hectopascals overflowed",
582    )?;
583    let humidity_percent = checked_conversion(
584        resolved_request.atmosphere.relative_humidity * 100.0,
585        "$.atmosphere.relative_humidity",
586        "relative-humidity conversion to percent overflowed",
587    )?;
588    let twist_rate_inches = checked_conversion(
589        resolved_request.rifle.twist_rate_m_per_turn / METERS_PER_INCH,
590        "$.rifle.twist_rate_m_per_turn",
591        "twist-rate conversion to inches per turn overflowed",
592    )?;
593    let caliber_inches = checked_conversion(
594        resolved_request.projectile.diameter_m / METERS_PER_INCH,
595        "$.projectile.diameter_m",
596        "diameter conversion to inches overflowed",
597    )?;
598    let weight_grains = checked_conversion(
599        resolved_request.projectile.mass_kg / KG_PER_GRAIN,
600        "$.projectile.mass_kg",
601        "mass conversion to grains overflowed",
602    )?;
603    let latitude_degrees = resolved_request
604        .atmosphere
605        .latitude_rad
606        .map(f64::to_degrees)
607        .map(|value| {
608            checked_conversion(
609                value,
610                "$.atmosphere.latitude_rad",
611                "latitude conversion to degrees overflowed",
612            )
613        })
614        .transpose()?;
615
616    let (wind_speed, wind_direction) = match &resolved_request.wind {
617        ResolvedWindV1::Constant(constant) => (constant.speed_mps, constant.direction_from_rad),
618        ResolvedWindV1::Segmented(_) => (0.0, 0.0),
619    };
620
621    let mut inputs = BallisticInputs {
622        bc_value: resolved_request.projectile.ballistic_coefficient,
623        bc_type: drag_model_to_engine(resolved_request.projectile.drag_model),
624        // solve-json v1 (docs/SOLVE_JSON_V1.md) has no BC-reference-standard field yet;
625        // every published BC in that contract is treated as ICAO-referenced (MBA-1365
626        // is a CLI/WASM/FFI/profile feature, not a v1 JSON schema change).
627        bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
628        bullet_mass: resolved_request.projectile.mass_kg,
629        muzzle_velocity: resolved_request.rifle.muzzle_velocity_mps,
630        bullet_diameter: resolved_request.projectile.diameter_m,
631        bullet_length: bullet_length_m,
632        muzzle_angle: resolved_request.shot.muzzle_angle_rad,
633        target_distance: resolved_request.shot.max_range_m,
634        azimuth_angle: resolved_request.shot.aim_azimuth_rad,
635        shot_azimuth: resolved_request.shot.shot_azimuth_rad,
636        shooting_angle: resolved_request.shot.shooting_angle_rad,
637        cant_angle: resolved_request.shot.cant_angle_rad,
638        sight_height: resolved_request.rifle.sight_height_m,
639        // MBA-1396: consumed straight from the request (validated in resolve_rifle) for the
640        // engine inputs here. Separately echoed at ResolvedRifleV1::sight_offset_lateral_m
641        // when supplied (0.33.0 decision-support Task 1) — same contract as the zero-POI
642        // fields below.
643        sight_offset_lateral_m: request.rifle.sight_offset_lateral_m.unwrap_or(0.0),
644        muzzle_height: resolved_request.rifle.muzzle_height_m,
645        target_height: resolved_request.shot.target_height_m,
646        // MBA-1359: consumed straight from the request (validated in resolve_shot) for the
647        // engine inputs here. Separately echoed at ResolvedShotV1::zero_poi_up_m /
648        // zero_poi_right_m when supplied (0.33.0 decision-support Task 1) — see
649        // resolve_shot's comment. The zero solve in solve_v1 runs on THIS solver, so both
650        // the vertical and azimuth biases apply there.
651        zero_poi_vertical_m: request.shot.zero_poi_up_m.unwrap_or(0.0),
652        zero_poi_horizontal_m: request.shot.zero_poi_right_m.unwrap_or(0.0),
653        ground_threshold: resolved_request.shot.ground_threshold_m,
654        altitude: resolved_request.atmosphere.altitude_m,
655        temperature: temperature_c,
656        pressure: pressure_hpa,
657        humidity: resolved_request.atmosphere.relative_humidity,
658        latitude: latitude_degrees,
659        wind_speed,
660        wind_angle: wind_direction,
661        twist_rate: twist_rate_inches,
662        is_twist_right: resolved_request.rifle.twist_direction == TwistDirectionV1::Right,
663        caliber_inches,
664        weight_grains,
665        manufacturer: None,
666        bullet_model: None,
667        bullet_id: None,
668        bullet_cluster: None,
669        use_rk4: resolved_request.solver.method != SolverMethodV1::Euler,
670        use_adaptive_rk45: resolved_request.solver.method == SolverMethodV1::Rk45,
671        enable_advanced_effects: resolved_request.effects.magnus
672            || resolved_request.effects.coriolis,
673        enable_magnus: resolved_request.effects.magnus,
674        enable_coriolis: resolved_request.effects.coriolis,
675        use_powder_sensitivity: false,
676        powder_temp_sensitivity: 0.0,
677        powder_temp: temperature_c,
678        powder_temp_curve: None,
679        powder_curve_temp_c: None,
680        tipoff_yaw: 0.0,
681        cd_delta2: 7.5,
682        tipoff_decay_distance: 50.0,
683        use_bc_segments: false,
684        bc_segments: None,
685        bc_segments_data: None,
686        use_enhanced_spin_drift: resolved_request.effects.enhanced_spin_drift,
687        use_form_factor: false,
688        // Altitude-dependent wind shear (0.36.0). `enable_wind_shear` is derived from the
689        // model rather than carried as its own request flag: `cli_api`'s shear branch maps
690        // any name it does not recognize -- `"none"` included -- to the power law, so an
691        // "enabled, model none" pair would silently run power-law shear. One field cannot
692        // express that state.
693        //
694        // No shooter altitude is passed, and none exists to pass: this solve path runs on
695        // `TrajectorySolver`, whose `get_wind_at_altitude` keys the boundary-layer profile off
696        // height above the MUZZLE (McCoy Y plus an assumed 1.5 m muzzle height), and
697        // `wind_shear::get_wind_at_position` -- the fast-integrate path's evaluator, which does
698        // take a `shooter_altitude_m` -- discards it for the same reason. Shear is relative to
699        // the local ground; `atmosphere.altitude_m` is height above SEA LEVEL, so feeding it in
700        // would treat a shooter standing at 1500 m MSL as a bullet flying 1500 m above the
701        // ground and inflate the ratio by an order of magnitude. `atmosphere.altitude_m`
702        // continues to reach the solve exactly where it belongs, through `inputs.altitude`
703        // above, as air density.
704        enable_wind_shear: resolved_request
705            .effects
706            .wind_shear_model
707            .is_some_and(WindShearModelV1::is_enabled),
708        wind_shear_model: resolved_request
709            .effects
710            .wind_shear_model
711            .unwrap_or_default()
712            .as_engine_str()
713            .to_owned(),
714        enable_trajectory_sampling: false,
715        sample_interval: resolved_request.sampling.interval_m,
716        // MBA-1403: consumed straight from the request for the engine inputs here.
717        // Separately echoed at ResolvedShotV1::drops_reference when supplied (0.33.0
718        // decision-support Task 1), matching the zero-POI fields above. The engine-side
719        // field is set for validation (target mode
720        // rejects |shooting_angle| >= 90 deg inside validate_for_solve); the actual wire
721        // transform is applied where observations map to samples in solve_v1, because this
722        // solve path samples via TrajectoryResult::sample_observations, not the
723        // trajectory-sampling module.
724        drops_reference: match request.shot.drops_reference.unwrap_or_default() {
725            crate::solve_json::DropsReferenceV1::Los => crate::cli_api::DropsReference::Los,
726            crate::solve_json::DropsReferenceV1::Target => crate::cli_api::DropsReference::Target,
727        },
728        enable_pitch_damping: false,
729        enable_precession_nutation: false,
730        // MBA-959 / Alfredo Mendiola Loyola's change request: the JSON bridge's own switch,
731        // where this used to be hardcoded false with no way for a caller to reach it.
732        enable_aerodynamic_jump: resolved_request.effects.aerodynamic_jump.unwrap_or(false),
733        use_cluster_bc: false,
734        // MBA-1597: `projectile.drag_table` is installed just below, once these inputs exist —
735        // the reference kind's scale reads their own sectional density, and a reference table
736        // flown beside a BC ladder sets `custom_drag_reference` there instead.
737        custom_drag_table: None,
738        cd_scale: 1.0,
739        custom_drag_reference: false,
740        bc_type_str: None,
741    };
742
743    if let Some(drag_table) = &resolved_request.projectile.drag_table {
744        install_drag_table(
745            drag_table,
746            resolved_request.projectile.ballistic_coefficient,
747            resolved_request.projectile.bc_segments.is_some(),
748            &mut inputs,
749        )?;
750    }
751
752    apply_bc5d_correction(request, &resolved_request, &mut inputs, &mut warnings)?;
753    apply_bc_segments(&resolved_request, &mut inputs)?;
754
755    let atmosphere = AtmosphericConditions {
756        temperature: temperature_c,
757        pressure: pressure_hpa,
758        humidity: humidity_percent,
759        altitude: resolved_request.atmosphere.altitude_m,
760    };
761
762    Ok(PreparedSolveV1 {
763        resolved_request,
764        assumptions,
765        warnings,
766        inputs,
767        wind,
768        wind_segments,
769        atmosphere,
770    })
771}
772
773/// Install `projectile.drag_table` on the prepared engine inputs (MBA-1597).
774///
775/// The engine flies a custom table as the projectile's OWN drag coefficient divided by its
776/// sectional density ([`BallisticInputs::custom_drag_denominator`]): the `projectile` kind, BC
777/// unused, exactly as the CLI's `--drag-table`. A `reference` curve is instead a standard the
778/// BC was measured against, which a built-in model flies as `Cd_ref / BC`. It is installed in
779/// one of two ways, which fly that same quotient:
780///
781/// * Without a BC ladder, as the own-curve path with `cd_scale = SD / BC`. Since BC = SD / i,
782///   `Cd_ref * SD / BC` is the projectile's own Cd, and the SD it is divided by is the very one
783///   the scale was built from — no second retardation formula. This is the original MBA-1597
784///   installation, kept unchanged so a request without a ladder solves byte-identically.
785/// * With a BC ladder (`has_bc_ladder`), with [`BallisticInputs::custom_drag_reference`] set
786///   and `cd_scale = 1.0`. The drag sites then divide the curve by the BC they resolve at each
787///   speed, so the band BCs, the scalar in an interior gap and the smoothstep blending at the
788///   band edges reach the table exactly as they reach a built-in model. One whole-curve scale
789///   cannot carry a BC that changes from band to band.
790///
791/// Either installation is rebuilt from the request's BC on every prepare, so a kernel that
792/// perturbs `ballistic_coefficient` and re-solves moves a reference-kind trajectory as it
793/// moves a built-in one.
794fn install_drag_table(
795    drag_table: &DragTableV1,
796    ballistic_coefficient: f64,
797    has_bc_ladder: bool,
798    inputs: &mut BallisticInputs,
799) -> Result<(), SolveErrorEnvelopeV1> {
800    let (mach, cd): (Vec<f64>, Vec<f64>) = drag_table
801        .points
802        .iter()
803        .map(|point| (point.mach, point.cd))
804        .unzip();
805    // `resolve_projectile` has already applied the same rules with exact paths; this is the
806    // engine's own gate, kept so a rule added there can never be skipped here.
807    let table = crate::drag::DragTable::try_new(mach, cd)
808        .map_err(|message| invalid_value("$.projectile.drag_table.points", message))?;
809    inputs.cd_scale = match drag_table.kind {
810        DragTableKindV1::Projectile => 1.0,
811        // A ladder's band BC must reach the denominator at every speed, which the whole-curve
812        // SD / BC scale below cannot carry, so the curve is flown as a built-in model is:
813        // unscaled, over the BC each drag site resolves. Without a ladder the SD / BC arm
814        // below is kept exactly as it was, so those solves stay byte-identical.
815        DragTableKindV1::Reference if has_bc_ladder => {
816            inputs.custom_drag_reference = true;
817            1.0
818        }
819        DragTableKindV1::Reference => {
820            let sectional_density = inputs.sectional_density_lb_in2().ok_or_else(|| {
821                internal_error("projectile mass and diameter gave no sectional density")
822            })?;
823            sectional_density / ballistic_coefficient
824        }
825    };
826    inputs.custom_drag_table = Some(table);
827    Ok(())
828}
829
830/// Apply an optional BC5D offline correction (`$.corrections.bc5d_table_path`) to the
831/// prepared engine inputs, mirroring the CLI's `--bc-table-dir` semantics exactly:
832/// CRC-verified load, the same velocity-keyed segment ladder
833/// ([`crate::bc_table_5d::Bc5dTable::generate_segments`], grains + fps axes), the
834/// table's muzzle correction folded into the scalar `bc_value` as the interior-gap
835/// fallback, and a G1 coercion warning for drag models outside the table's G1/G7
836/// planes. Because `prepare_request` feeds both [`solve_v1`]'s zero search and the
837/// trajectory (and the perturbation kernel), the zero is always solved with the SAME
838/// schedule the flight uses — the 0.22.11 auto-zero lesson.
839///
840/// On wasm32 there is no filesystem: supplying the field is a structured
841/// `invalid_value` error, never a silent no-op (WASM callers load table BYTES via
842/// `loadBc5dTable` instead).
843fn apply_bc5d_correction(
844    request: &SolveRequestV1,
845    resolved_request: &ResolvedSolveRequestV1,
846    inputs: &mut BallisticInputs,
847    warnings: &mut Vec<SolveNoticeV1>,
848) -> Result<(), SolveErrorEnvelopeV1> {
849    let Some(table_path) = request
850        .corrections
851        .as_ref()
852        .and_then(|corrections| corrections.bc5d_table_path.as_deref())
853    else {
854        return Ok(());
855    };
856
857    #[cfg(target_arch = "wasm32")]
858    {
859        let _ = (table_path, resolved_request, inputs, warnings);
860        Err(invalid_value(
861            "$.corrections.bc5d_table_path",
862            "bc5d_table_path is not supported on this build (no filesystem access); \
863             load table bytes through the WASM loadBc5dTable API instead",
864        ))
865    }
866
867    #[cfg(not(target_arch = "wasm32"))]
868    {
869        use crate::bc_table_5d::{path_cache, Bc5dError};
870
871        // Identity, not just integrity: the table's header caliber must be this shot's
872        // caliber. A wrong-caliber table is worse than no table, because every lookup
873        // still succeeds (edge-bin clamping) and silently biases every row — so it is
874        // refused as an invalid_value at the field that named it, never applied and
875        // never downgraded to a silent uncorrected solve. See
876        // `Bc5dTable::ensure_caliber_matches`.
877        let shot_caliber_in = resolved_request.projectile.diameter_m / 0.0254;
878        let table = path_cache::load_verified_for_caliber(
879            std::path::Path::new(table_path),
880            shot_caliber_in,
881        )
882        .map_err(|error| {
883            let code = match error {
884                Bc5dError::IoError(_) => SolveErrorCodeV1::IoError,
885                _ => SolveErrorCodeV1::InvalidValue,
886            };
887            SolveErrorEnvelopeV1::new(
888                SolveErrorV1::new(code, format!("bc5d_table_path: {error}"))
889                    .at_path("$.corrections.bc5d_table_path"),
890            )
891        })?;
892
893        // The v2 BC5D tables carry only G1/G7 planes; every other reference model is
894        // typed as G1, with the same warning surface as the CLI/WASM aux paths.
895        let drag_type = match resolved_request.projectile.drag_model {
896            DragModelV1::G7 => "G7",
897            DragModelV1::G1 => "G1",
898            other => {
899                warnings.push(SolveNoticeV1 {
900                    code: WARNING_BC5D_DRAG_MODEL_COERCED.to_owned(),
901                    message: format!(
902                        "BC5D correction tables support G1/G7 only; treating drag model \
903                         {other:?} as G1 for the table lookup"
904                    ),
905                    path: Some("$.corrections.bc5d_table_path".to_owned()),
906                });
907                "G1"
908            }
909        };
910
911        // Table axes are grains and fps; the prepared inputs already carry both
912        // conversions (the same checked ones the summary diagnostics use).
913        let base_bc = resolved_request.projectile.ballistic_coefficient;
914        let weight_grains = inputs.weight_grains;
915        let muzzle_fps = resolved_request.rifle.muzzle_velocity_mps / 0.3048;
916
917        if let Some(segments) =
918            table.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_fps))
919        {
920            // CLI parity (main.rs `--bc-table-dir`): the scalar BC becomes the
921            // muzzle-corrected fallback for interior coverage gaps.
922            inputs.bc_value =
923                table.get_effective_bc(weight_grains, base_bc, muzzle_fps, muzzle_fps, drag_type);
924            inputs.use_bc_segments = true;
925            inputs.bc_segments_data = Some(segments);
926        }
927        // A neutral table (no meaningful correction for this load) leaves the
928        // published constant BC in place, exactly like the CLI.
929        Ok(())
930    }
931}
932
933/// Install a `projectile.bc_segments` ladder (MBA-1598) on the prepared inputs, converting its
934/// m/s band edges to the fps the engine's `BCSegmentData` is keyed in. Without a ladder this
935/// is a no-op.
936///
937/// The scalar `bc_value` is left untouched, exactly as the CLI's `--bc-segment` and `card.*`
938/// leave it; the correction path above, by contrast, rewrites `bc_value`. The engine flies the
939/// scalar only in an interior gap between two bands, and blended into the bands within up to
940/// 25 fps either side of a gap edge. Below the lowest band, or at or above the highest, the
941/// nearest edge band applies, so under a gapless ladder the scalar never flies.
942/// Band BCs are referenced to `drag_model`'s curve, or to the drag table's curve when the table
943/// is `reference`-kind, bands are selected by airspeed (speed relative to the air mass), and
944/// every interior band edge and gap edge is smoothstep-blended (`velocity_segment_bc` in
945/// `src/bc_estimation.rs`). The lookup does not depend on the array
946/// order; the fastest-first order is only a validation rule.
947fn apply_bc_segments(
948    resolved_request: &ResolvedSolveRequestV1,
949    inputs: &mut BallisticInputs,
950) -> Result<(), SolveErrorEnvelopeV1> {
951    let Some(bc_segments) = &resolved_request.projectile.bc_segments else {
952        return Ok(());
953    };
954
955    let segments: Vec<crate::BCSegmentData> = bc_segments
956        .iter()
957        .map(|seg| crate::BCSegmentData {
958            velocity_min: seg.velocity_min_mps / 0.3048,
959            velocity_max: seg.velocity_max_mps / 0.3048,
960            bc_value: seg.ballistic_coefficient,
961        })
962        .collect();
963
964    inputs.use_bc_segments = true;
965    inputs.bc_segments_data = Some(segments);
966    Ok(())
967}
968
969fn resolve_projectile(
970    projectile: &ProjectileV1,
971    assumptions: &mut Vec<SolveNoticeV1>,
972) -> Result<(ResolvedProjectileV1, f64), SolveErrorEnvelopeV1> {
973    require_positive("$.projectile.mass_kg", projectile.mass_kg)?;
974    require_positive("$.projectile.diameter_m", projectile.diameter_m)?;
975    require_positive(
976        "$.projectile.ballistic_coefficient",
977        projectile.ballistic_coefficient,
978    )?;
979    if let Some(length_m) = projectile.length_m {
980        require_positive("$.projectile.length_m", length_m)?;
981    }
982    if let Some(drag_table) = &projectile.drag_table {
983        crate::solve_json::validate_drag_table_v1(drag_table)?;
984    }
985    if let Some(bc_segments) = &projectile.bc_segments {
986        crate::solve_json::validate_bc_segments_v1(bc_segments, projectile.ballistic_coefficient)?;
987    }
988
989    let bullet_length_m = match projectile.length_m {
990        Some(length_m) => length_m,
991        None => {
992            let estimated = crate::stability::estimate_bullet_length_m(
993                projectile.diameter_m,
994                projectile.mass_kg,
995            );
996            if !estimated.is_finite() || estimated <= 0.0 {
997                return Err(invalid_value(
998                    "$.projectile",
999                    "projectile mass and diameter could not produce a finite positive length estimate",
1000                ));
1001            }
1002            assumptions.push(notice(
1003                ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH,
1004                format!(
1005                    "Projectile length was omitted; the engine estimated {estimated:.12} m from mass and diameter."
1006                ),
1007                "$.projectile.length_m",
1008            ));
1009            estimated
1010        }
1011    };
1012
1013    Ok((
1014        ResolvedProjectileV1 {
1015            mass_kg: projectile.mass_kg,
1016            diameter_m: projectile.diameter_m,
1017            length_m: projectile.length_m,
1018            drag_model: projectile.drag_model,
1019            ballistic_coefficient: projectile.ballistic_coefficient,
1020            drag_table: projectile.drag_table.clone(),
1021            bc_segments: projectile.bc_segments.clone(),
1022        },
1023        bullet_length_m,
1024    ))
1025}
1026
1027fn resolve_rifle(
1028    rifle: &RifleV1,
1029    assumptions: &mut Vec<SolveNoticeV1>,
1030) -> Result<ResolvedRifleV1, SolveErrorEnvelopeV1> {
1031    require_positive("$.rifle.muzzle_velocity_mps", rifle.muzzle_velocity_mps)?;
1032    if let Some(value) = rifle.sight_height_m {
1033        require_non_negative("$.rifle.sight_height_m", value)?;
1034    }
1035    if let Some(value) = rifle.muzzle_height_m {
1036        require_finite("$.rifle.muzzle_height_m", value)?;
1037    }
1038    if let Some(value) = rifle.twist_rate_m_per_turn {
1039        require_positive("$.rifle.twist_rate_m_per_turn", value)?;
1040    }
1041    // MBA-1396: validated here but NOT resolved through `literal_default` — an absence
1042    // notice would change the response bytes of every request that predates the field.
1043    // Consumed directly into the engine inputs, and separately echoed on the resolved
1044    // DTO at ResolvedRifleV1::sight_offset_lateral_m when supplied (0.33.0
1045    // decision-support Task 1); omitting it still stays byte-identical to requests that
1046    // predate the field.
1047    if let Some(value) = rifle.sight_offset_lateral_m {
1048        require_finite("$.rifle.sight_offset_lateral_m", value)?;
1049        if value.abs() >= 0.5 {
1050            return Err(invalid_value(
1051                "$.rifle.sight_offset_lateral_m",
1052                "lateral sight offset must be smaller than 0.5 m in magnitude (the \
1053                 sight-to-bore mount offset, in meters)",
1054            ));
1055        }
1056    }
1057
1058    let sight_height_m = literal_default(
1059        rifle.sight_height_m,
1060        DEFAULT_SIGHT_HEIGHT_M,
1061        "$.rifle.sight_height_m",
1062        "Sight height defaulted to 0.05 m.",
1063        assumptions,
1064    );
1065    let muzzle_height_m = literal_default(
1066        rifle.muzzle_height_m,
1067        DEFAULT_MUZZLE_HEIGHT_M,
1068        "$.rifle.muzzle_height_m",
1069        "Muzzle height defaulted to 0 m.",
1070        assumptions,
1071    );
1072    let twist_rate_m_per_turn = literal_default(
1073        rifle.twist_rate_m_per_turn,
1074        DEFAULT_TWIST_RATE_M_PER_TURN,
1075        "$.rifle.twist_rate_m_per_turn",
1076        "Twist rate defaulted to 0.3048 m per turn.",
1077        assumptions,
1078    );
1079    let twist_direction = match rifle.twist_direction {
1080        Some(direction) => direction,
1081        None => {
1082            assumptions.push(notice(
1083                ASSUMPTION_DEFAULT_APPLIED,
1084                "Twist direction defaulted to right-hand.",
1085                "$.rifle.twist_direction",
1086            ));
1087            TwistDirectionV1::Right
1088        }
1089    };
1090
1091    Ok(ResolvedRifleV1 {
1092        muzzle_velocity_mps: rifle.muzzle_velocity_mps,
1093        sight_height_m,
1094        muzzle_height_m,
1095        twist_rate_m_per_turn,
1096        twist_direction,
1097        // MBA-1396: pure pass-through echo (Task 1 of the 0.33.0 decision-support train) — no
1098        // defaulting, no unit conversion. Validated above.
1099        sight_offset_lateral_m: rifle.sight_offset_lateral_m,
1100    })
1101}
1102
1103fn resolve_shot(
1104    shot: &ShotV1,
1105    rifle: &ResolvedRifleV1,
1106    assumptions: &mut Vec<SolveNoticeV1>,
1107    warnings: &mut Vec<SolveNoticeV1>,
1108) -> Result<ResolvedShotV1, SolveErrorEnvelopeV1> {
1109    let muzzle_height_m = rifle.muzzle_height_m;
1110    require_positive("$.shot.max_range_m", shot.max_range_m)?;
1111    if let Some(value) = shot.zero_distance_m {
1112        require_positive("$.shot.zero_distance_m", value)?;
1113        if !(value * 2.0).is_finite() {
1114            return Err(invalid_value(
1115                "$.shot.zero_distance_m",
1116                "zero distance is too large to construct a finite trial trajectory range",
1117            ));
1118        }
1119    }
1120    if let Some(value) = shot.muzzle_angle_rad {
1121        require_finite("$.shot.muzzle_angle_rad", value)?;
1122    }
1123    // 0.33.0 decision-support Task 2: both fields may be supplied together. This used to be
1124    // a conflicting_fields error, but `From<&ResolvedSolveRequestV1> for SolveRequestV1`
1125    // (request_roundtrip.rs) always carries both when the original request solved a zero --
1126    // zero_distance_m as caller intent, muzzle_angle_rad as the effective angle already
1127    // found -- so a rebuilt request can reuse that exact angle without re-running the
1128    // elevation search. The match below already gives muzzle_angle_rad priority whenever it
1129    // is present, with or without zero_distance_m alongside it; solve_v1 mirrors that
1130    // priority by skipping the elevation search whenever an explicit angle was supplied.
1131    // zero_distance_m no longer re-derives the elevation in that case, but it is not
1132    // otherwise inert: solve_v1 still applies the separate windage-zero convergence bias
1133    // (sight_offset_lateral_m / zero_poi_right_m) gated on it, it still widens the required
1134    // wind coverage below, it is still validated above, and it still feeds
1135    // summary.equivalent_horizontal_range_m. A warning names this explicitly (below).
1136
1137    for (path, value) in [
1138        ("$.shot.aim_azimuth_rad", shot.aim_azimuth_rad),
1139        ("$.shot.shot_azimuth_rad", shot.shot_azimuth_rad),
1140        ("$.shot.shooting_angle_rad", shot.shooting_angle_rad),
1141        ("$.shot.cant_angle_rad", shot.cant_angle_rad),
1142        ("$.shot.target_height_m", shot.target_height_m),
1143        ("$.shot.ground_threshold_m", shot.ground_threshold_m),
1144    ] {
1145        if let Some(value) = value {
1146            require_finite(path, value)?;
1147        }
1148    }
1149
1150    // MBA-1359: deliberate POI offset at the zero range (Kestrel ZH/ZO). Validated here but
1151    // NOT resolved through `literal_default`: emitting an absence notice would change the
1152    // response bytes of every request that predates these fields. They are consumed
1153    // directly into the engine inputs, and separately echoed on the resolved DTO at
1154    // ResolvedShotV1::zero_poi_up_m / zero_poi_right_m when supplied (0.33.0
1155    // decision-support Task 1); omitting them still stays byte-identical to requests that
1156    // predate these fields.
1157    for (path, value) in [
1158        ("$.shot.zero_poi_up_m", shot.zero_poi_up_m),
1159        ("$.shot.zero_poi_right_m", shot.zero_poi_right_m),
1160    ] {
1161        if let Some(value) = value {
1162            require_finite(path, value)?;
1163            if value.abs() >= 1.0 {
1164                return Err(invalid_value(
1165                    path,
1166                    "zero POI offset must be smaller than 1.0 m in magnitude (a linear \
1167                     point-of-impact offset at the zero range, in meters)",
1168                ));
1169            }
1170        }
1171    }
1172
1173    // MBA-1537: exactly the condition under which `build_zeroed_solver` runs the elevation
1174    // search, named once so the zero-frame default below cannot drift out of step with the
1175    // match that decides it.
1176    let elevation_search_runs = shot.zero_distance_m.is_some() && shot.muzzle_angle_rad.is_none();
1177
1178    let muzzle_angle_rad = match (shot.zero_distance_m, shot.muzzle_angle_rad) {
1179        // An explicit angle always wins over zero_distance_m for elevation (0.33.0
1180        // decision-support Task 2). When both are supplied, note that the elevation search
1181        // did not run -- zero_distance_m is still retained and still has other effects (see
1182        // the warning message), but a caller relying on a stale muzzle_angle_rad while
1183        // setting a new zero_distance_m should not mistake the echo for confirmation that a
1184        // zero was actually solved.
1185        (Some(_), Some(angle)) => {
1186            warnings.push(notice(
1187                WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED,
1188                "muzzle_angle_rad was supplied together with zero_distance_m: the elevation \
1189                 was used directly and the elevation search did not run. zero_distance_m is \
1190                 retained in resolved_request and still affects the windage-convergence bias \
1191                 (sight_offset_lateral_m / zero_poi_right_m), summary.equivalent_horizontal_range_m, \
1192                 and downrange wind-coverage validation.",
1193                "$.shot.zero_distance_m",
1194            ));
1195            angle
1196        }
1197        (None, Some(angle)) => angle,
1198        (Some(_), None) => 0.0, // Replaced with the calculated effective angle before solving.
1199        (None, None) => {
1200            assumptions.push(notice(
1201                ASSUMPTION_DEFAULT_APPLIED,
1202                "Muzzle angle defaulted to 0 rad.",
1203                "$.shot.muzzle_angle_rad",
1204            ));
1205            0.0
1206        }
1207    };
1208    let aim_azimuth_rad = literal_default(
1209        shot.aim_azimuth_rad,
1210        0.0,
1211        "$.shot.aim_azimuth_rad",
1212        "Aim azimuth defaulted to 0 rad.",
1213        assumptions,
1214    );
1215    let shot_azimuth_rad = literal_default(
1216        shot.shot_azimuth_rad,
1217        0.0,
1218        "$.shot.shot_azimuth_rad",
1219        "Shot azimuth defaulted to 0 rad (north).",
1220        assumptions,
1221    );
1222    let shooting_angle_rad = literal_default(
1223        shot.shooting_angle_rad,
1224        0.0,
1225        "$.shot.shooting_angle_rad",
1226        "Shooting angle defaulted to 0 rad.",
1227        assumptions,
1228    );
1229    let cant_angle_rad = literal_default(
1230        shot.cant_angle_rad,
1231        0.0,
1232        "$.shot.cant_angle_rad",
1233        "Cant angle defaulted to 0 rad.",
1234        assumptions,
1235    );
1236    // MBA-1537: when the elevation search runs and no target height was supplied, the height
1237    // the search converges on is the LINE OF SIGHT, not the ground datum.
1238    //
1239    // `target_height_m` is a world-vertical height above the local ground datum, and the line
1240    // of sight of a level rifle sits `muzzle_height_m + sight_height_m` above that datum -- the
1241    // same sum `TrajectoryResult::line_of_sight_height_m` reports and the same sum the sampler
1242    // uses as its LOS datum. Defaulting to a flat `0` aimed the search at the ground instead,
1243    // which put the bullet a full line-of-sight height LOW at the caller's stated zero: it
1244    // never crossed the line of sight at all. It also made `sight_height_m` inert -- the solved
1245    // angle was identical for every sight height -- and made the answer a function of
1246    // `muzzle_height_m`, which it physically is not: heights above the ground cancel once the
1247    // target tracks the line of sight. A raised muzzle stopped converging altogether once it
1248    // cleared the bullet's own drop over the zero distance (measured for a .308 at 100 yd:
1249    // 0.07 m still converges, 0.08 m does not; the drop over 91.44 m is 0.0661 m),
1250    // because the ground datum it was aiming at then sits below the muzzle.
1251    //
1252    // This is the height the `ZeroTargetFrame::SightLine` surfaces already use. `cli_api`'s
1253    // `calculate_zero_angle_with_conditions` solves against whatever target height its caller
1254    // hands it; the CLI hands it bore + sight (main.rs:8263), which is this sum. `ffi.rs`'s
1255    // `ballistics_calculate_zero_angle`
1256    // both solve against the sight height (the same sum, for their `muzzle_height` of 0).
1257    // Moving the default here is what makes them agree.
1258    //
1259    // Gated on the search actually running rather than on `zero_distance_m` alone: a request
1260    // that supplies `muzzle_angle_rad` uses that angle directly, so there is no zero to frame,
1261    // and such a request stays byte-identical -- assumption text, resolved echo and all.
1262    // Without a zero search `target_height_m` feeds only the `drops_reference: "target"`
1263    // sampler datum, which has its own distance reference (the target sits at `max_range_m`,
1264    // not at `zero_distance_m`); keeping the historical `0` there leaves that mode untouched.
1265    //
1266    // This is the LEVEL line of sight, and deliberately not projected by `shooting_angle_rad`
1267    // into the inclined sight line. Two reasons: the same field is the target-drops sampler's
1268    // LOS datum, where it is referenced to `max_range_m` rather than to `zero_distance_m`, so a
1269    // default computed at the zero distance would silently slope that datum over the wrong
1270    // span; and at `shooting_angle_rad == 0` the level form equals `sight_position_m` bit for
1271    // bit, which is what keeps that mode untouched here. An inclined zero therefore still has
1272    // to state its own target height -- `docs/SOLVE_JSON_V1.md` gives the projection, and
1273    // `tests/zero_sight_line_default.rs` pins both halves of that.
1274    let target_height_m = if elevation_search_runs {
1275        match shot.target_height_m {
1276            Some(value) => value,
1277            None => {
1278                let line_of_sight_m = muzzle_height_m + rifle.sight_height_m;
1279                assumptions.push(notice(
1280                    ASSUMPTION_DEFAULT_APPLIED,
1281                    format!(
1282                        "Target height defaulted to the line of sight, {line_of_sight_m} m above \
1283                         the local ground datum (muzzle_height_m + sight_height_m), so the solved \
1284                         elevation crosses the line of sight at zero_distance_m. Supply \
1285                         target_height_m explicitly -- 0 included -- to zero against the ground \
1286                         datum instead."
1287                    ),
1288                    "$.shot.target_height_m",
1289                ));
1290                line_of_sight_m
1291            }
1292        }
1293    } else {
1294        literal_default(
1295            shot.target_height_m,
1296            0.0,
1297            "$.shot.target_height_m",
1298            "Target height defaulted to 0 m above the local ground datum.",
1299            assumptions,
1300        )
1301    };
1302    let ground_threshold_m = literal_default(
1303        shot.ground_threshold_m,
1304        DEFAULT_GROUND_THRESHOLD_M,
1305        "$.shot.ground_threshold_m",
1306        "Ground threshold defaulted to -100 m.",
1307        assumptions,
1308    );
1309    if ground_threshold_m >= muzzle_height_m {
1310        return Err(invalid_value(
1311            "$.shot.ground_threshold_m",
1312            "ground threshold must be below the resolved muzzle height",
1313        ));
1314    }
1315
1316    Ok(ResolvedShotV1 {
1317        max_range_m: shot.max_range_m,
1318        zero_distance_m: shot.zero_distance_m,
1319        muzzle_angle_rad,
1320        aim_azimuth_rad,
1321        shot_azimuth_rad,
1322        shooting_angle_rad,
1323        cant_angle_rad,
1324        target_height_m,
1325        ground_threshold_m,
1326        // MBA-1359/MBA-1403: pure pass-through echoes (Task 1 of the 0.33.0 decision-support
1327        // train) — no defaulting, no unit conversion. Validated above.
1328        zero_poi_up_m: shot.zero_poi_up_m,
1329        zero_poi_right_m: shot.zero_poi_right_m,
1330        drops_reference: shot.drops_reference,
1331    })
1332}
1333
1334/// Resolve a solve-json v1 atmosphere exactly as a solve does: validate with `$.atmosphere.*`
1335/// paths, default what was omitted (announcing each default in `assumptions`), and reduce a
1336/// QNH to station pressure. Shared with `atmosphere.density_altitude` so that command
1337/// describes precisely the air a solve with the same `atmosphere` object would fly through.
1338pub(crate) fn resolve_atmosphere(
1339    atmosphere: &AtmosphereV1,
1340    assumptions: &mut Vec<SolveNoticeV1>,
1341) -> Result<ResolvedAtmosphereV1, SolveErrorEnvelopeV1> {
1342    if let Some(value) = atmosphere.altitude_m {
1343        require_range(
1344            "$.atmosphere.altitude_m",
1345            value,
1346            MIN_ICAO_ALTITUDE_M,
1347            MAX_ICAO_ALTITUDE_M,
1348        )?;
1349    }
1350    if let Some(value) = atmosphere.temperature_k {
1351        require_positive("$.atmosphere.temperature_k", value)?;
1352    }
1353    if let Some(value) = atmosphere.pressure_pa {
1354        require_positive("$.atmosphere.pressure_pa", value)?;
1355    }
1356    if let Some(value) = atmosphere.relative_humidity {
1357        require_range("$.atmosphere.relative_humidity", value, 0.0, 1.0)?;
1358    }
1359    if let Some(value) = atmosphere.latitude_rad {
1360        require_range(
1361            "$.atmosphere.latitude_rad",
1362            value,
1363            -std::f64::consts::FRAC_PI_2,
1364            std::f64::consts::FRAC_PI_2,
1365        )?;
1366    }
1367
1368    let altitude_m = match atmosphere.altitude_m {
1369        Some(value) => value,
1370        None => {
1371            assumptions.push(notice(
1372                ASSUMPTION_DEFAULT_APPLIED,
1373                "Station altitude defaulted to 0 m.",
1374                "$.atmosphere.altitude_m",
1375            ));
1376            DEFAULT_ALTITUDE_M
1377        }
1378    };
1379    let (standard_temperature_k, standard_pressure_pa) =
1380        crate::atmosphere::calculate_icao_standard_atmosphere(altitude_m);
1381    let temperature_k = match atmosphere.temperature_k {
1382        Some(value) => value,
1383        None => {
1384            assumptions.push(notice(
1385                ASSUMPTION_ICAO_STANDARD_TEMPERATURE,
1386                format!(
1387                    "Station temperature was omitted; ICAO standard temperature at {altitude_m:.6} m is {standard_temperature_k:.12} K."
1388                ),
1389                "$.atmosphere.temperature_k",
1390            ));
1391            standard_temperature_k
1392        }
1393    };
1394    let pressure_pa = match atmosphere.pressure_pa {
1395        // MBA-1397: an explicit pressure declared as a QNH (sea-level-corrected altimeter
1396        // setting) is reduced to station pressure at `altitude_m` before use. `Absolute`
1397        // (including the omitted-field default) is a pure passthrough -- byte-identical to
1398        // pre-MBA-1397 behavior for every request that never sets `pressure_reference`.
1399        Some(value) if atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh) => {
1400            let qnh_hpa = value / PASCALS_PER_HECTOPASCAL;
1401            let station_hpa =
1402                crate::atmosphere::reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
1403            let station_pa = station_hpa * PASCALS_PER_HECTOPASCAL;
1404            assumptions.push(notice(
1405                ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE,
1406                format!(
1407                    "Station pressure was declared as a QNH (sea-level-corrected altimeter \
1408                     setting) of {value:.6} Pa; reduced to station pressure {station_pa:.12} Pa \
1409                     at {altitude_m:.6} m via the ICAO inverse-barometric formula."
1410                ),
1411                "$.atmosphere.pressure_pa",
1412            ));
1413            station_pa
1414        }
1415        Some(value) => value,
1416        None => {
1417            assumptions.push(notice(
1418                ASSUMPTION_ICAO_STANDARD_PRESSURE,
1419                format!(
1420                    "Station pressure was omitted; ICAO standard pressure at {altitude_m:.6} m is {standard_pressure_pa:.12} Pa."
1421                ),
1422                "$.atmosphere.pressure_pa",
1423            ));
1424            standard_pressure_pa
1425        }
1426    };
1427    let relative_humidity = literal_default(
1428        atmosphere.relative_humidity,
1429        DEFAULT_RELATIVE_HUMIDITY,
1430        "$.atmosphere.relative_humidity",
1431        "Relative humidity defaulted to 0.5.",
1432        assumptions,
1433    );
1434
1435    Ok(ResolvedAtmosphereV1 {
1436        altitude_m,
1437        temperature_k,
1438        pressure_pa,
1439        relative_humidity,
1440        latitude_rad: atmosphere.latitude_rad,
1441        // MBA-1397: pure pass-through echo (Task 1 of the 0.33.0 decision-support train) — the
1442        // mode itself is not resolved to a literal default; only its effect on `pressure_pa`
1443        // above is (see the QNH-reduction branch).
1444        pressure_reference: atmosphere.pressure_reference,
1445    })
1446}
1447
1448fn resolve_wind(
1449    wind: &WindV1,
1450    coverage_distance_m: f64,
1451    request_shot_azimuth_rad: Option<f64>,
1452    assumptions: &mut Vec<SolveNoticeV1>,
1453    warnings: &mut Vec<SolveNoticeV1>,
1454) -> Result<(ResolvedWindV1, WindConditions, Vec<WindSegment>), SolveErrorEnvelopeV1> {
1455    // MBA-1368: resolve the entry frame ONCE, before any direction is consumed.
1456    // Omitted == shooter, byte-identical with no assumption notice (the Batch A
1457    // additive-field precedent: a notice would change every pre-existing response).
1458    // In compass mode every direction below passes through `to_relative` BEFORE the
1459    // resolved echo and the engine structures are built, so downstream physics (and
1460    // the response) only ever see shooter-relative wind-FROM radians — the QNH
1461    // fold-into-the-resolved-value precedent.
1462    let compass_azimuth_rad = match wind.wind_reference.unwrap_or_default() {
1463        WindReferenceV1::Shooter => None,
1464        WindReferenceV1::Compass => match request_shot_azimuth_rad {
1465            Some(azimuth) => Some(azimuth),
1466            None => {
1467                return Err(conflicting_fields(
1468                    "$.wind.wind_reference",
1469                    "wind_reference \"compass\" requires shot.shot_azimuth_rad (earth-fixed \
1470                     bearings are re-referenced against the shot azimuth; omitting it would \
1471                     silently treat bearings as shooter-relative)",
1472                ))
1473            }
1474        },
1475    };
1476    let to_relative = |bearing_rad: f64| -> f64 {
1477        match compass_azimuth_rad {
1478            Some(azimuth) => {
1479                crate::wind::compass_bearing_to_shooter_relative_rad(bearing_rad, azimuth)
1480            }
1481            None => bearing_rad,
1482        }
1483    };
1484
1485    if let Some(segments) = &wind.segments {
1486        if wind.speed_mps.is_some()
1487            || wind.direction_from_rad.is_some()
1488            || wind.vertical_speed_mps.is_some()
1489        {
1490            return Err(conflicting_fields(
1491                "$.wind",
1492                "segments cannot be combined with constant-wind fields",
1493            ));
1494        }
1495        if segments.is_empty() {
1496            return Err(invalid_value(
1497                "$.wind.segments",
1498                "segmented wind must contain at least one segment",
1499            ));
1500        }
1501
1502        let mut resolved_segments = Vec::with_capacity(segments.len());
1503        let mut engine_segments = Vec::with_capacity(segments.len());
1504        let mut previous_until_m = 0.0;
1505        for (index, segment) in segments.iter().enumerate() {
1506            let base = format!("$.wind.segments[{index}]");
1507            require_positive(
1508                &format!("{base}.until_distance_m"),
1509                segment.until_distance_m,
1510            )?;
1511            require_non_negative(&format!("{base}.speed_mps"), segment.speed_mps)?;
1512            require_finite(
1513                &format!("{base}.direction_from_rad"),
1514                segment.direction_from_rad,
1515            )?;
1516            if let Some(value) = segment.vertical_speed_mps {
1517                require_finite(&format!("{base}.vertical_speed_mps"), value)?;
1518            }
1519            if index > 0 && segment.until_distance_m <= previous_until_m {
1520                return Err(invalid_value(
1521                    format!("{base}.until_distance_m"),
1522                    "wind-segment boundaries must be strictly increasing",
1523                ));
1524            }
1525            previous_until_m = segment.until_distance_m;
1526
1527            let vertical_speed_mps = match segment.vertical_speed_mps {
1528                Some(value) => value,
1529                None => {
1530                    assumptions.push(notice(
1531                        ASSUMPTION_DEFAULT_APPLIED,
1532                        "Segment vertical wind speed defaulted to 0 m/s.",
1533                        format!("{base}.vertical_speed_mps"),
1534                    ));
1535                    0.0
1536                }
1537            };
1538            let speed_kmh = checked_conversion(
1539                segment.speed_mps * SECONDS_PER_HOUR * KILOMETRES_PER_METRE,
1540                &format!("{base}.speed_mps"),
1541                "wind speed conversion to kilometres per hour overflowed",
1542            )?;
1543            // MBA-1368: compass bearings become shooter-relative HERE, so both the
1544            // resolved echo and the engine segment carry the converted direction.
1545            let direction_from_rad = to_relative(segment.direction_from_rad);
1546            let angle_deg = checked_conversion(
1547                direction_from_rad.to_degrees(),
1548                &format!("{base}.direction_from_rad"),
1549                "wind direction conversion to degrees overflowed",
1550            )?;
1551
1552            resolved_segments.push(ResolvedWindSegmentV1 {
1553                until_distance_m: segment.until_distance_m,
1554                speed_mps: segment.speed_mps,
1555                direction_from_rad,
1556                vertical_speed_mps,
1557            });
1558            engine_segments.push(WindSegment {
1559                speed_kmh,
1560                angle_deg,
1561                until_m: segment.until_distance_m,
1562                vertical_mps: vertical_speed_mps,
1563            });
1564        }
1565
1566        let final_until_m = resolved_segments
1567            .last()
1568            .expect("non-empty segmented wind was checked")
1569            .until_distance_m;
1570        if final_until_m < coverage_distance_m {
1571            warnings.push(notice(
1572                WARNING_PARTIAL_WIND_COVERAGE,
1573                format!(
1574                    "Segmented wind ends at {final_until_m:.12} m; wind is calm from that boundary through the required {coverage_distance_m:.12} m solve/zero range."
1575                ),
1576                "$.wind.segments",
1577            ));
1578        }
1579
1580        Ok((
1581            ResolvedWindV1::Segmented(ResolvedSegmentedWindV1 {
1582                segments: resolved_segments,
1583                // MBA-1368: pure pass-through echo (Task 1 of the 0.33.0 decision-support
1584                // train) — the mode itself is not resolved to a literal default; only its
1585                // effect on each segment's `direction_from_rad` above is (see `to_relative`).
1586                wind_reference: wind.wind_reference,
1587            }),
1588            WindConditions::default(),
1589            engine_segments,
1590        ))
1591    } else {
1592        match (wind.speed_mps, wind.direction_from_rad) {
1593            (Some(_), None) | (None, Some(_)) => {
1594                return Err(conflicting_fields(
1595                    "$.wind",
1596                    "speed_mps and direction_from_rad must be supplied together",
1597                ));
1598            }
1599            (None, None) if wind.vertical_speed_mps.is_some() => {
1600                return Err(conflicting_fields(
1601                    "$.wind",
1602                    "vertical_speed_mps requires speed_mps and direction_from_rad",
1603                ));
1604            }
1605            _ => {}
1606        }
1607
1608        if let Some(value) = wind.speed_mps {
1609            require_non_negative("$.wind.speed_mps", value)?;
1610        }
1611        if let Some(value) = wind.direction_from_rad {
1612            require_finite("$.wind.direction_from_rad", value)?;
1613        }
1614        if let Some(value) = wind.vertical_speed_mps {
1615            require_finite("$.wind.vertical_speed_mps", value)?;
1616        }
1617
1618        let (speed_mps, direction_from_rad, vertical_speed_mps) =
1619            if let (Some(speed), Some(direction)) = (wind.speed_mps, wind.direction_from_rad) {
1620                let vertical = match wind.vertical_speed_mps {
1621                    Some(value) => value,
1622                    None => {
1623                        assumptions.push(notice(
1624                            ASSUMPTION_DEFAULT_APPLIED,
1625                            "Constant vertical wind speed defaulted to 0 m/s.",
1626                            "$.wind.vertical_speed_mps",
1627                        ));
1628                        0.0
1629                    }
1630                };
1631                (speed, to_relative(direction), vertical)
1632            } else {
1633                assumptions.push(notice(
1634                    ASSUMPTION_DEFAULT_APPLIED,
1635                    "Wind defaulted to still air.",
1636                    "$.wind",
1637                ));
1638                (0.0, 0.0, 0.0)
1639            };
1640
1641        Ok((
1642            ResolvedWindV1::Constant(ResolvedConstantWindV1 {
1643                speed_mps,
1644                direction_from_rad,
1645                vertical_speed_mps,
1646                // MBA-1368: pure pass-through echo (Task 1 of the 0.33.0 decision-support
1647                // train) — the mode itself is not resolved to a literal default; only its
1648                // effect on `direction_from_rad` above is (see `to_relative`).
1649                wind_reference: wind.wind_reference,
1650            }),
1651            WindConditions {
1652                speed: speed_mps,
1653                direction: direction_from_rad,
1654                vertical_speed: vertical_speed_mps,
1655            },
1656            Vec::new(),
1657        ))
1658    }
1659}
1660
1661fn resolve_solver(
1662    solver: &SolverV1,
1663    assumptions: &mut Vec<SolveNoticeV1>,
1664    warnings: &mut Vec<SolveNoticeV1>,
1665) -> Result<ResolvedSolverV1, SolveErrorEnvelopeV1> {
1666    if let Some(value) = solver.time_step_s {
1667        require_positive("$.solver.time_step_s", value)?;
1668    }
1669    let method = match solver.method {
1670        Some(method) => method,
1671        None => {
1672            assumptions.push(notice(
1673                ASSUMPTION_DEFAULT_APPLIED,
1674                "Solver method defaulted to adaptive RK45.",
1675                "$.solver.method",
1676            ));
1677            SolverMethodV1::Rk45
1678        }
1679    };
1680    let time_step_s = literal_default(
1681        solver.time_step_s,
1682        DEFAULT_TIME_STEP_S,
1683        "$.solver.time_step_s",
1684        "Solver time step defaulted to 0.001 s.",
1685        assumptions,
1686    );
1687    if method == SolverMethodV1::Rk45 && solver.time_step_s.is_some() {
1688        warnings.push(notice(
1689            WARNING_RK45_TIME_STEP_IGNORED,
1690            "Adaptive RK45 owns its time step; the explicitly supplied fixed time step is retained in resolved_request but ignored by integration.",
1691            "$.solver.time_step_s",
1692        ));
1693    }
1694
1695    Ok(ResolvedSolverV1 {
1696        method,
1697        time_step_s,
1698    })
1699}
1700
1701fn resolve_effects(
1702    effects: &EffectsV1,
1703    latitude_rad: Option<f64>,
1704    geometry: SuppliedGeometry,
1705    assumptions: &mut Vec<SolveNoticeV1>,
1706    warnings: &mut Vec<SolveNoticeV1>,
1707) -> Result<ResolvedEffectsV1, SolveErrorEnvelopeV1> {
1708    let magnus = bool_default(
1709        effects.magnus,
1710        "$.effects.magnus",
1711        "Magnus effect defaulted to disabled.",
1712        assumptions,
1713    );
1714    let coriolis = bool_default(
1715        effects.coriolis,
1716        "$.effects.coriolis",
1717        "Coriolis effect defaulted to disabled.",
1718        assumptions,
1719    );
1720    let enhanced_spin_drift = bool_default(
1721        effects.enhanced_spin_drift,
1722        "$.effects.enhanced_spin_drift",
1723        "Enhanced spin drift defaulted to disabled.",
1724        assumptions,
1725    );
1726
1727    if magnus && enhanced_spin_drift {
1728        return Err(conflicting_fields(
1729            "$.effects",
1730            "magnus and enhanced_spin_drift cannot both be enabled",
1731        ));
1732    }
1733    if coriolis && latitude_rad.is_none() {
1734        return Err(invalid_value(
1735            "$.atmosphere.latitude_rad",
1736            "latitude_rad is required when the Coriolis effect is enabled",
1737        ));
1738    }
1739
1740    // Aerodynamic jump (MBA-959). Like `wind_shear_model` and unlike the three booleans
1741    // above, this is NOT run through `bool_default`: an omitted field pushes no assumption
1742    // notice and leaves the echo absent, so every request written before the field existed
1743    // still serializes byte-identically. `enabled_aerodynamic_jump` is what the solver acts
1744    // on; `aerodynamic_jump` is what the echo carries, and they differ precisely when the
1745    // caller said `false` out loud.
1746    let aerodynamic_jump = effects.aerodynamic_jump;
1747    let enabled_aerodynamic_jump = aerodynamic_jump.unwrap_or(false);
1748
1749    for (enabled, path, name) in [
1750        (magnus, "$.effects.magnus", "Magnus"),
1751        (
1752            enhanced_spin_drift,
1753            "$.effects.enhanced_spin_drift",
1754            "enhanced spin drift",
1755        ),
1756        (
1757            enabled_aerodynamic_jump,
1758            "$.effects.aerodynamic_jump",
1759            "aerodynamic jump",
1760        ),
1761    ] {
1762        if enabled {
1763            warnings.push(notice(
1764                WARNING_EXPERIMENTAL_EFFECT,
1765                format!("The {name} effect is an opt-in experimental v1 model."),
1766                path,
1767            ));
1768        }
1769    }
1770
1771    // MBA-1484. Magnus and enhanced spin drift are both functions of the spin the rifling
1772    // imparts, so an omitted twist does not leave either of them alone -- it solves them
1773    // against the assumed 1:12 barrel with the same confidence as a stated one. Emitted at the
1774    // flag that made the twist load-bearing, the way the aerodynamic-jump warning below is.
1775    //
1776    // The two flags are mutually exclusive here (the `conflicting_fields` check above returns
1777    // before this point), so the loop pushes at most one notice; it is written as a loop
1778    // anyway so that a future third spin-driven flag cannot quietly go unwarned.
1779    //
1780    // `summary.stability_factor` also reads the twist, on every solve rather than on a flag,
1781    // so it cannot be covered from here -- it is warned separately in `solve_v1` under
1782    // WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE, gated on Sg having actually been reported.
1783    // The two codes both fire when a spin effect is enabled without a stated twist; they are
1784    // distinct so each names its own consumer, not because they are mutually exclusive.
1785    for (enabled, path, name) in [
1786        (magnus, "$.effects.magnus", "Magnus"),
1787        (
1788            enhanced_spin_drift,
1789            "$.effects.enhanced_spin_drift",
1790            "Enhanced spin drift",
1791        ),
1792    ] {
1793        if enabled && !geometry.twist_rate {
1794            warnings.push(notice(
1795                WARNING_SPIN_EFFECT_ASSUMED_TWIST_RATE,
1796                format!(
1797                    "{name} is computed from the spin the rifling imparts, but \
1798                     rifle.twist_rate_m_per_turn was not supplied. The effect is still \
1799                     applied, computed from the assumed 1:12 twist rather than disabled, so \
1800                     the result is for a barrel this request did not specify."
1801                ),
1802                path,
1803            ));
1804        }
1805    }
1806
1807    // Wind shear (0.36.0). Deliberately NOT run through `bool_default`/`literal_default`: an
1808    // omitted field pushes no assumption notice and leaves the echo absent, so every request
1809    // written before this field existed still serializes byte-identically -- the same
1810    // additive-echo contract as `atmosphere.pressure_reference` and `wind.wind_reference`.
1811    //
1812    // The name reaching the engine is always the canonical one, so an `"ekman"` request and
1813    // an `"ekman_spiral"` request resolve to a single spelling in the echo. Unknown names
1814    // never arrive here: `decode_solve_request_v1` refuses them at
1815    // `$.effects.wind_shear_model`, and the typed enum makes them unrepresentable for a
1816    // caller that builds the DTO in Rust.
1817    let wind_shear_model = effects.wind_shear_model;
1818    if wind_shear_model == Some(WindShearModelV1::EkmanSpiral) {
1819        // Not silence: `cli_api`'s boundary-layer evaluator scales the operative wind by a
1820        // height ratio, and it has no near-ground closed form for the Ekman spiral, so the
1821        // ratio is 1.0 and the solve matches an unsheared one. Say so in the response
1822        // instead of letting a caller read "ekman_spiral applied" off the echo and believe
1823        // the numbers moved.
1824        warnings.push(notice(
1825            WARNING_WIND_SHEAR_MODEL_NOT_MODELED,
1826            "The ekman_spiral wind shear model has no near-ground profile on this solve path; \
1827             the wind is left at the operative value, so the result matches an unsheared solve.",
1828            "$.effects.wind_shear_model",
1829        ));
1830    }
1831
1832    // The jump is a function of stability and bullet length, so a defaulted barrel does not
1833    // leave it alone -- it computes a different answer with the same confidence. Say so at the
1834    // flag that made the geometry load-bearing, naming whichever field is missing.
1835    if enabled_aerodynamic_jump && !(geometry.twist_rate && geometry.projectile_length) {
1836        let missing = match (geometry.twist_rate, geometry.projectile_length) {
1837            (false, false) => "rifle.twist_rate_m_per_turn and projectile.length_m were",
1838            (false, true) => "rifle.twist_rate_m_per_turn was",
1839            (true, false) => "projectile.length_m was",
1840            (true, true) => unreachable!("guarded by the condition above"),
1841        };
1842        warnings.push(notice(
1843            WARNING_AERODYNAMIC_JUMP_ASSUMED_GEOMETRY,
1844            format!(
1845                "Aerodynamic jump scales with gyroscopic stability and bullet length, but \
1846                 {missing} not supplied. The correction is still applied, computed from the \
1847                 assumed geometry rather than disabled, so the jump reported in \
1848                 summary.aerodynamic_jump_moa is for a barrel this request did not specify."
1849            ),
1850            "$.effects.aerodynamic_jump",
1851        ));
1852    }
1853
1854    Ok(ResolvedEffectsV1 {
1855        magnus,
1856        coriolis,
1857        enhanced_spin_drift,
1858        wind_shear_model,
1859        aerodynamic_jump,
1860    })
1861}
1862
1863fn resolve_sampling(
1864    sampling: &SamplingV1,
1865    assumptions: &mut Vec<SolveNoticeV1>,
1866) -> Result<ResolvedSamplingV1, SolveErrorEnvelopeV1> {
1867    if let Some(value) = sampling.interval_m {
1868        require_positive("$.sampling.interval_m", value)?;
1869    }
1870    let interval_m = literal_default(
1871        sampling.interval_m,
1872        DEFAULT_SAMPLE_INTERVAL_M,
1873        "$.sampling.interval_m",
1874        "Sampling interval defaulted to 10 m.",
1875        assumptions,
1876    );
1877    Ok(ResolvedSamplingV1 { interval_m })
1878}
1879
1880fn maximum_world_height_m(
1881    result: &crate::TrajectoryResult,
1882    shooting_angle_rad: f64,
1883) -> Result<f64, SolveErrorEnvelopeV1> {
1884    let mut maximum = f64::NEG_INFINITY;
1885    for point in &result.points {
1886        let height = crate::atmosphere::shot_frame_altitude(
1887            0.0,
1888            point.position.x,
1889            point.position.y,
1890            shooting_angle_rad,
1891        );
1892        if !height.is_finite() {
1893            return Err(internal_error(
1894                "world-vertical maximum-height projection produced a non-finite value",
1895            ));
1896        }
1897        maximum = maximum.max(height);
1898    }
1899    if maximum.is_finite() {
1900        Ok(maximum)
1901    } else {
1902        Err(internal_error(
1903            "trajectory contained no finite points for maximum-height calculation",
1904        ))
1905    }
1906}
1907
1908fn observation_to_wire(observation: TrajectoryObservation) -> TrajectorySampleV1 {
1909    TrajectorySampleV1 {
1910        distance_m: observation.distance_m,
1911        time_s: observation.time_s,
1912        speed_mps: observation.speed_mps,
1913        energy_j: observation.energy_j,
1914        drop_m: observation.drop_m,
1915        windage_m: observation.windage_m,
1916        mach: observation.mach,
1917        flags: observation
1918            .flags
1919            .into_iter()
1920            .map(observation_flag_to_wire)
1921            .collect(),
1922    }
1923}
1924
1925fn observation_flag_to_wire(flag: TrajectoryObservationFlag) -> SampleFlagV1 {
1926    match flag {
1927        TrajectoryObservationFlag::Transonic => SampleFlagV1::Transonic,
1928        TrajectoryObservationFlag::Subsonic => SampleFlagV1::Subsonic,
1929        TrajectoryObservationFlag::Terminal => SampleFlagV1::Terminal,
1930        TrajectoryObservationFlag::GroundThreshold => SampleFlagV1::GroundThreshold,
1931    }
1932}
1933
1934fn termination_to_wire(termination: TrajectoryTermination) -> TerminationReasonV1 {
1935    match termination {
1936        TrajectoryTermination::MaxRange => TerminationReasonV1::MaxRange,
1937        TrajectoryTermination::GroundThreshold => TerminationReasonV1::GroundThreshold,
1938        TrajectoryTermination::TimeLimit => TerminationReasonV1::TimeLimit,
1939        TrajectoryTermination::VelocityFloor => TerminationReasonV1::VelocityFloor,
1940    }
1941}
1942
1943fn drag_model_to_engine(model: DragModelV1) -> DragModel {
1944    match model {
1945        DragModelV1::G1 => DragModel::G1,
1946        DragModelV1::G2 => DragModel::G2,
1947        DragModelV1::G5 => DragModel::G5,
1948        DragModelV1::G6 => DragModel::G6,
1949        DragModelV1::G7 => DragModel::G7,
1950        DragModelV1::G8 => DragModel::G8,
1951        DragModelV1::GI => DragModel::GI,
1952        DragModelV1::GS => DragModel::GS,
1953        DragModelV1::RA4 => DragModel::RA4,
1954    }
1955}
1956
1957fn map_observation_error(error: TrajectoryObservationError) -> SolveErrorEnvelopeV1 {
1958    let message = error.to_string();
1959    match error {
1960        TrajectoryObservationError::SampleLimitExceeded { .. }
1961        | TrajectoryObservationError::AllocationFailed { .. } => error_at(
1962            SolveErrorCodeV1::ResourceLimit,
1963            message,
1964            "$.sampling.interval_m",
1965        ),
1966        TrajectoryObservationError::InvalidInterval { .. }
1967        | TrajectoryObservationError::UnrepresentableGrid { .. } => error_at(
1968            SolveErrorCodeV1::InvalidValue,
1969            message,
1970            "$.sampling.interval_m",
1971        ),
1972        // MBA-1599: a trajectory that really moves back toward the muzzle (a headwind carrying a
1973        // stalled projectile back, a launch past vertical, or an explicit-Euler/RK4 step too
1974        // coarse for the drag) cannot be reported against downrange distance. That is a failed
1975        // solve, the code the engine already returns when such a reversal lasts until
1976        // termination. Equal distances stay internal_error below: the engine collapses a
1977        // downrange stall, so a repeated distance would be a broken engine invariant.
1978        TrajectoryObservationError::NonMonotonicTrajectory {
1979            previous_distance_m,
1980            distance_m,
1981            ..
1982        } if distance_m < previous_distance_m => solve_failed_message(format!(
1983            "the projectile moved back toward the muzzle ({previous_distance_m} m then \
1984             {distance_m} m downrange) before reaching a termination boundary, so the \
1985             trajectory cannot be reported against downrange distance"
1986        )),
1987        TrajectoryObservationError::EmptyTrajectory
1988        | TrajectoryObservationError::NonFiniteQuery { .. }
1989        | TrajectoryObservationError::OutOfRange { .. }
1990        | TrajectoryObservationError::NonMonotonicTrajectory { .. }
1991        | TrajectoryObservationError::NonFiniteState { .. }
1992        | TrajectoryObservationError::InvalidState { .. }
1993        | TrajectoryObservationError::InvalidMetadata { .. }
1994        | TrajectoryObservationError::NonFiniteObservation { .. } => internal_error(message),
1995    }
1996}
1997
1998/// Evaluate a request's reticle hold against the already-sampled trajectory (MBA-1361).
1999///
2000/// The angular drop/windage come from the SAME samples the response reports, linearly
2001/// interpolated at `range_m`, so the hold and the sample rows can never describe different
2002/// trajectories. Angles use the milliradian small-angle definition (1 mil subtends 1/1000
2003/// of the range) — the same conversion every other surface uses.
2004///
2005/// A range outside the sampled span is a structured [`SolveErrorCodeV1::InvalidValue`], not
2006/// a clamp or an extrapolation: a hold read off a trajectory that never got there is a
2007/// fabricated number.
2008fn resolve_reticle_hold(
2009    request: &crate::solve_json::ReticleRequestV1,
2010    samples: &[TrajectorySampleV1],
2011) -> Result<crate::solve_json::ReticleHoldV1, SolveErrorEnvelopeV1> {
2012    use crate::reticle::hold_point_in_reticle;
2013
2014    let path = "$.reticle.range_m";
2015    let range_m = request.range_m;
2016    if !range_m.is_finite() || range_m <= 0.0 {
2017        return Err(invalid_value(
2018            path,
2019            "reticle.range_m must be finite and greater than zero",
2020        ));
2021    }
2022
2023    let (lo, t) = match bracket_param(samples.len(), |i| samples[i].distance_m, range_m) {
2024        Bracket::Inside { lo, t } => (lo, t),
2025        Bracket::Degenerate => {
2026            return Err(internal_error(
2027                "trajectory sampling produced no points to read a reticle hold from",
2028            ));
2029        }
2030        Bracket::Below | Bracket::Above => {
2031            // `Degenerate` (len < 2) was already handled above, so at least two samples exist.
2032            let first = &samples[0];
2033            let last = &samples[samples.len() - 1];
2034            return Err(invalid_value(
2035                path,
2036                format!(
2037                    "reticle.range_m {range_m} is outside the sampled trajectory ({} to {} m)",
2038                    first.distance_m, last.distance_m
2039                ),
2040            ));
2041        }
2042    };
2043    let hi = lo + 1;
2044    let lerp = |a: f64, b: f64| a + (b - a) * t;
2045    let drop_mil = lerp(samples[lo].drop_m, samples[hi].drop_m) / range_m * 1000.0;
2046    let wind_mil = lerp(samples[lo].windage_m, samples[hi].windage_m) / range_m * 1000.0;
2047
2048    let hold = hold_point_in_reticle(
2049        drop_mil,
2050        wind_mil,
2051        request.magnification,
2052        &request.description,
2053    )
2054    .map_err(|e| invalid_value("$.reticle", e.to_string()))?;
2055
2056    let nearest = hold
2057        .nearest_mark
2058        .and_then(|index| request.description.marks.get(index));
2059    Ok(crate::solve_json::ReticleHoldV1 {
2060        range_m,
2061        magnification: request.magnification,
2062        down_mil: hold.down_mil,
2063        right_mil: hold.right_mil,
2064        mark_scale: hold.mark_scale,
2065        nearest_mark_index: hold.nearest_mark,
2066        nearest_mark_label: nearest.and_then(|mark| mark.label.clone()),
2067        nearest_mark_distance_mil: hold.nearest_mark_distance_mil,
2068        off_reticle: hold.off_reticle,
2069    })
2070}
2071
2072// `pub(crate)` (0.33.0 decision-support Task 6, I3 review fix): the perturbation kernel's
2073// `evaluate` calls `TrajectorySolver::solve` itself (see `build_zeroed_solver` above) and needs
2074// the identical `BallisticsError` -> `SolveErrorEnvelopeV1` conversion `solve_v1` uses for the
2075// same call, so both report a solve failure in the same shape.
2076pub(crate) fn solve_failed(error: BallisticsError) -> SolveErrorEnvelopeV1 {
2077    solve_failed_message(error.to_string())
2078}
2079
2080fn solve_failed_message(message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2081    SolveErrorEnvelopeV1::new(SolveErrorV1::new(SolveErrorCodeV1::SolveFailed, message))
2082}
2083
2084fn internal_error(message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2085    SolveErrorEnvelopeV1::new(SolveErrorV1::new(SolveErrorCodeV1::InternalError, message))
2086}
2087
2088fn invalid_value(path: impl Into<String>, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2089    error_at(SolveErrorCodeV1::InvalidValue, message, path)
2090}
2091
2092fn conflicting_fields(path: impl Into<String>, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2093    error_at(SolveErrorCodeV1::ConflictingFields, message, path)
2094}
2095
2096fn error_at(
2097    code: SolveErrorCodeV1,
2098    message: impl Into<String>,
2099    path: impl Into<String>,
2100) -> SolveErrorEnvelopeV1 {
2101    SolveErrorEnvelopeV1::new(SolveErrorV1::new(code, message).at_path(path))
2102}
2103
2104fn notice(
2105    code: impl Into<String>,
2106    message: impl Into<String>,
2107    path: impl Into<String>,
2108) -> SolveNoticeV1 {
2109    SolveNoticeV1 {
2110        code: code.into(),
2111        message: message.into(),
2112        path: Some(path.into()),
2113    }
2114}
2115
2116fn literal_default(
2117    value: Option<f64>,
2118    default: f64,
2119    path: &'static str,
2120    message: &'static str,
2121    assumptions: &mut Vec<SolveNoticeV1>,
2122) -> f64 {
2123    match value {
2124        Some(value) => value,
2125        None => {
2126            assumptions.push(notice(ASSUMPTION_DEFAULT_APPLIED, message, path));
2127            default
2128        }
2129    }
2130}
2131
2132fn bool_default(
2133    value: Option<bool>,
2134    path: &'static str,
2135    message: &'static str,
2136    assumptions: &mut Vec<SolveNoticeV1>,
2137) -> bool {
2138    match value {
2139        Some(value) => value,
2140        None => {
2141            assumptions.push(notice(ASSUMPTION_DEFAULT_APPLIED, message, path));
2142            false
2143        }
2144    }
2145}
2146
2147fn checked_conversion(value: f64, path: &str, message: &str) -> Result<f64, SolveErrorEnvelopeV1> {
2148    if value.is_finite() {
2149        Ok(value)
2150    } else {
2151        Err(invalid_value(path, message))
2152    }
2153}
2154
2155fn require_finite(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
2156    if value.is_finite() {
2157        Ok(())
2158    } else {
2159        Err(invalid_value(path, "value must be finite"))
2160    }
2161}
2162
2163fn require_positive(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
2164    if value.is_finite() && value > 0.0 {
2165        Ok(())
2166    } else {
2167        Err(invalid_value(
2168            path,
2169            "value must be finite and greater than zero",
2170        ))
2171    }
2172}
2173
2174fn require_non_negative(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
2175    if value.is_finite() && value >= 0.0 {
2176        Ok(())
2177    } else {
2178        Err(invalid_value(path, "value must be finite and non-negative"))
2179    }
2180}
2181
2182fn require_range(
2183    path: &str,
2184    value: f64,
2185    minimum: f64,
2186    maximum: f64,
2187) -> Result<(), SolveErrorEnvelopeV1> {
2188    if value.is_finite() && (minimum..=maximum).contains(&value) {
2189        Ok(())
2190    } else {
2191        Err(invalid_value(
2192            path,
2193            format!("value must be finite and in the inclusive range [{minimum}, {maximum}]"),
2194        ))
2195    }
2196}
2197
2198#[cfg(test)]
2199mod tests {
2200    use super::*;
2201
2202    fn minimal_request() -> SolveRequestV1 {
2203        SolveRequestV1 {
2204            schema_version: SchemaVersionV1,
2205            projectile: ProjectileV1 {
2206                mass_kg: 0.01134,
2207                diameter_m: 0.00782,
2208                length_m: None,
2209                drag_model: DragModelV1::G7,
2210                ballistic_coefficient: 0.243,
2211                drag_table: None,
2212                bc_segments: None,
2213            },
2214            rifle: RifleV1 {
2215                muzzle_velocity_mps: 823.0,
2216                sight_height_m: None,
2217                muzzle_height_m: None,
2218                twist_rate_m_per_turn: None,
2219                twist_direction: None,
2220                sight_offset_lateral_m: None,
2221            },
2222            shot: ShotV1 {
2223                max_range_m: 1_000.0,
2224                zero_distance_m: None,
2225                muzzle_angle_rad: None,
2226                aim_azimuth_rad: None,
2227                shot_azimuth_rad: None,
2228                shooting_angle_rad: None,
2229                cant_angle_rad: None,
2230                target_height_m: None,
2231                ground_threshold_m: None,
2232                zero_poi_up_m: None,
2233                zero_poi_right_m: None,
2234                drops_reference: None,
2235            },
2236            atmosphere: AtmosphereV1::default(),
2237            wind: WindV1::default(),
2238            solver: SolverV1::default(),
2239            effects: EffectsV1::default(),
2240            sampling: SamplingV1::default(),
2241            reticle: None,
2242            corrections: None,
2243        }
2244    }
2245
2246    #[test]
2247    fn defaults_are_resolved_in_deterministic_request_field_order() {
2248        let request = minimal_request();
2249        let prepared = prepare_request(&request).expect("valid request");
2250        let code_paths = prepared
2251            .assumptions
2252            .iter()
2253            .map(|notice| (notice.code.as_str(), notice.path.as_deref().unwrap()))
2254            .collect::<Vec<_>>();
2255
2256        assert_eq!(
2257            code_paths,
2258            vec![
2259                (
2260                    ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH,
2261                    "$.projectile.length_m"
2262                ),
2263                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.sight_height_m"),
2264                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.muzzle_height_m"),
2265                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.twist_rate_m_per_turn"),
2266                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.twist_direction"),
2267                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.muzzle_angle_rad"),
2268                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.aim_azimuth_rad"),
2269                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.shot_azimuth_rad"),
2270                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.shooting_angle_rad"),
2271                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.cant_angle_rad"),
2272                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.target_height_m"),
2273                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.ground_threshold_m"),
2274                (ASSUMPTION_DEFAULT_APPLIED, "$.atmosphere.altitude_m"),
2275                (
2276                    ASSUMPTION_ICAO_STANDARD_TEMPERATURE,
2277                    "$.atmosphere.temperature_k"
2278                ),
2279                (
2280                    ASSUMPTION_ICAO_STANDARD_PRESSURE,
2281                    "$.atmosphere.pressure_pa"
2282                ),
2283                (ASSUMPTION_DEFAULT_APPLIED, "$.atmosphere.relative_humidity"),
2284                (ASSUMPTION_DEFAULT_APPLIED, "$.wind"),
2285                (ASSUMPTION_DEFAULT_APPLIED, "$.solver.method"),
2286                (ASSUMPTION_DEFAULT_APPLIED, "$.solver.time_step_s"),
2287                (ASSUMPTION_DEFAULT_APPLIED, "$.effects.magnus"),
2288                (ASSUMPTION_DEFAULT_APPLIED, "$.effects.coriolis"),
2289                (ASSUMPTION_DEFAULT_APPLIED, "$.effects.enhanced_spin_drift"),
2290                (ASSUMPTION_DEFAULT_APPLIED, "$.sampling.interval_m"),
2291            ]
2292        );
2293        assert_eq!(
2294            prepared.resolved_request.rifle.twist_rate_m_per_turn,
2295            0.3048
2296        );
2297        assert!((prepared.inputs.twist_rate - 12.0).abs() < 1.0e-12);
2298        assert_eq!(prepared.atmosphere.humidity, 50.0);
2299        assert_eq!(prepared.inputs.humidity, 0.5);
2300    }
2301
2302    #[test]
2303    fn explicit_standard_station_values_at_altitude_remain_authoritative() {
2304        let mut request = minimal_request();
2305        request.atmosphere.altitude_m = Some(1_500.0);
2306        request.atmosphere.temperature_k = Some(288.15);
2307        request.atmosphere.pressure_pa = Some(101_325.0);
2308
2309        let prepared = prepare_request(&request).expect("valid explicit station conditions");
2310        assert_eq!(prepared.atmosphere.temperature, 15.0);
2311        assert_eq!(prepared.atmosphere.pressure, 1013.25);
2312        assert_eq!(prepared.resolved_request.atmosphere.temperature_k, 288.15);
2313        assert_eq!(prepared.resolved_request.atmosphere.pressure_pa, 101_325.0);
2314        assert!(!prepared
2315            .assumptions
2316            .iter()
2317            .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_TEMPERATURE
2318                || notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
2319    }
2320
2321    /// MBA-1397: `pressure_reference: "qnh"` reduces an explicit pressure to station pressure
2322    /// and records the reduction as an assumption instead of treating it as authoritative
2323    /// as-is.
2324    #[test]
2325    fn qnh_pressure_reference_reduces_to_station_pressure_and_is_noted() {
2326        let mut request = minimal_request();
2327        request.atmosphere.altitude_m = Some(1_500.0);
2328        request.atmosphere.pressure_pa = Some(103_000.0); // 1030.0 hPa QNH
2329        request.atmosphere.pressure_reference = Some(PressureReferenceV1::Qnh);
2330
2331        let prepared = prepare_request(&request).expect("valid QNH request");
2332        let expected_station_hpa =
2333            crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1_500.0);
2334        assert!((prepared.atmosphere.pressure - expected_station_hpa).abs() < 1e-9);
2335        assert!(
2336            (prepared.resolved_request.atmosphere.pressure_pa - expected_station_hpa * 100.0).abs()
2337                < 1e-6
2338        );
2339        // Strictly lower than the raw QNH -- proves the reduction actually happened.
2340        assert!(prepared.resolved_request.atmosphere.pressure_pa < 103_000.0);
2341
2342        assert!(prepared.assumptions.iter().any(|notice| notice.code
2343            == ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE
2344            && notice.path.as_deref() == Some("$.atmosphere.pressure_pa")));
2345        // Must NOT also emit the omitted-pressure notice: this is a present, explicit value.
2346        assert!(!prepared
2347            .assumptions
2348            .iter()
2349            .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
2350    }
2351
2352    /// `pressure_reference: "qnh"` with `pressure_pa` OMITTED must be byte-identical to the
2353    /// absolute default: an omitted pressure always resolves to the ICAO standard station
2354    /// pressure regardless of the declared reference (mathematically the same result as
2355    /// reducing a QNH of exactly the ICAO sea-level standard).
2356    #[test]
2357    fn qnh_pressure_reference_with_omitted_pressure_matches_absolute_default() {
2358        let mut request = minimal_request();
2359        request.atmosphere.altitude_m = Some(1_500.0);
2360        request.atmosphere.pressure_reference = Some(PressureReferenceV1::Qnh);
2361
2362        let prepared = prepare_request(&request).expect("valid request");
2363
2364        let mut absolute_request = minimal_request();
2365        absolute_request.atmosphere.altitude_m = Some(1_500.0);
2366        let absolute_prepared = prepare_request(&absolute_request).expect("valid request");
2367
2368        assert_eq!(
2369            prepared.resolved_request.atmosphere.pressure_pa,
2370            absolute_prepared.resolved_request.atmosphere.pressure_pa
2371        );
2372        assert!(prepared
2373            .assumptions
2374            .iter()
2375            .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
2376        assert!(!prepared
2377            .assumptions
2378            .iter()
2379            .any(|notice| notice.code == ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE));
2380    }
2381
2382    /// `pressure_reference` absent entirely (every request before MBA-1397) must be
2383    /// byte-identical to `Some(Absolute)` -- the default variant is a pure passthrough.
2384    #[test]
2385    fn absent_pressure_reference_matches_explicit_absolute() {
2386        let mut request = minimal_request();
2387        request.atmosphere.altitude_m = Some(1_500.0);
2388        request.atmosphere.pressure_pa = Some(90_000.0);
2389
2390        let mut explicit_absolute = request.clone();
2391        explicit_absolute.atmosphere.pressure_reference = Some(PressureReferenceV1::Absolute);
2392
2393        let prepared = prepare_request(&request).expect("valid request");
2394        let prepared_explicit = prepare_request(&explicit_absolute).expect("valid request");
2395        assert_eq!(
2396            prepared.resolved_request.atmosphere.pressure_pa,
2397            prepared_explicit.resolved_request.atmosphere.pressure_pa
2398        );
2399        assert_eq!(prepared.resolved_request.atmosphere.pressure_pa, 90_000.0);
2400    }
2401
2402    #[test]
2403    fn partial_segmented_wind_maps_units_and_warns_without_extending_coverage() {
2404        let mut request = minimal_request();
2405        request.wind.segments = Some(vec![crate::solve_json::WindSegmentV1 {
2406            until_distance_m: 300.0,
2407            speed_mps: 5.0,
2408            direction_from_rad: std::f64::consts::FRAC_PI_2,
2409            vertical_speed_mps: None,
2410        }]);
2411
2412        let prepared = prepare_request(&request).expect("partial wind is valid");
2413        assert_eq!(prepared.wind_segments.len(), 1);
2414        assert_eq!(prepared.wind_segments[0].speed_kmh, 18.0);
2415        assert_eq!(prepared.wind_segments[0].angle_deg, 90.0);
2416        assert_eq!(prepared.wind_segments[0].until_m, 300.0);
2417        assert_eq!(prepared.wind_segments[0].vertical_mps, 0.0);
2418        assert!(prepared.warnings.iter().any(|notice| {
2419            notice.code == WARNING_PARTIAL_WIND_COVERAGE
2420                && notice.path.as_deref() == Some("$.wind.segments")
2421        }));
2422        assert!(matches!(
2423            prepared.resolved_request.wind,
2424            ResolvedWindV1::Segmented(_)
2425        ));
2426    }
2427
2428    #[test]
2429    fn partial_wind_coverage_includes_a_zero_beyond_the_output_range() {
2430        let mut request = minimal_request();
2431        request.shot.max_range_m = 200.0;
2432        request.shot.zero_distance_m = Some(500.0);
2433        request.wind.segments = Some(vec![crate::solve_json::WindSegmentV1 {
2434            until_distance_m: 300.0,
2435            speed_mps: 5.0,
2436            direction_from_rad: 0.0,
2437            vertical_speed_mps: Some(0.0),
2438        }]);
2439
2440        let prepared = prepare_request(&request).expect("partial zero wind is valid");
2441        assert!(prepared.warnings.iter().any(|notice| {
2442            notice.code == WARNING_PARTIAL_WIND_COVERAGE
2443                && notice.path.as_deref() == Some("$.wind.segments")
2444                && notice.message.contains("500.000000000000 m")
2445        }));
2446    }
2447
2448    /// 0.33.0 decision-support Task 2: supplying both no longer conflicts. An explicit
2449    /// muzzle_angle_rad always wins (no zero search runs to derive it), while
2450    /// zero_distance_m is retained purely as the caller's original zeroing intent. This is
2451    /// exactly what `From<&ResolvedSolveRequestV1> for SolveRequestV1`
2452    /// (request_roundtrip.rs) produces for a zeroed solve, so a request rebuilt from a
2453    /// resolved request can reuse the exact original angle instead of silently re-zeroing.
2454    #[test]
2455    fn explicit_muzzle_angle_wins_over_zero_distance_when_both_are_supplied() {
2456        let mut request = minimal_request();
2457        request.shot.zero_distance_m = Some(100.0);
2458        request.shot.muzzle_angle_rad = Some(0.01);
2459        let prepared =
2460            prepare_request(&request).expect("both fields together are no longer a conflict");
2461        assert_eq!(prepared.resolved_request.shot.muzzle_angle_rad, 0.01);
2462        assert_eq!(prepared.resolved_request.shot.zero_distance_m, Some(100.0));
2463        // S5 (0.33.0 final-review fix wave): the elevation search did not run -- a caller must
2464        // be told so, not left to assume the echoed zero_distance_m confirms a zero was
2465        // actually solved for it.
2466        assert!(
2467            prepared.warnings.iter().any(|notice| {
2468                notice.code == WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED
2469                    && notice.path.as_deref() == Some("$.shot.zero_distance_m")
2470            }),
2471            "expected a {WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED} warning naming \
2472             $.shot.zero_distance_m; got {:?}",
2473            prepared.warnings
2474        );
2475    }
2476
2477    #[test]
2478    fn semantic_conflicts_and_invalid_values_keep_exact_paths() {
2479        let mut request = minimal_request();
2480        request.atmosphere.relative_humidity = Some(1.01);
2481        let error = prepare_request(&request).expect_err("humidity above one");
2482        assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
2483        assert_eq!(error.error.path(), Some("$.atmosphere.relative_humidity"));
2484
2485        let mut request = minimal_request();
2486        request.effects.coriolis = Some(true);
2487        let error = prepare_request(&request).expect_err("Coriolis needs latitude");
2488        assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
2489        assert_eq!(error.error.path(), Some("$.atmosphere.latitude_rad"));
2490
2491        let mut request = minimal_request();
2492        request.shot.zero_distance_m = Some(f64::MAX);
2493        let error = prepare_request(&request).expect_err("zero trial range must remain finite");
2494        assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
2495        assert_eq!(error.error.path(), Some("$.shot.zero_distance_m"));
2496    }
2497
2498    /// Zero and fly a prepared request as `solve_v1` does when no explicit muzzle angle was
2499    /// supplied, returning its samples.
2500    fn fly(prepared: PreparedSolveV1) -> Vec<TrajectoryObservation> {
2501        let max_range_m = prepared.resolved_request.shot.max_range_m;
2502        let time_step_s = prepared.resolved_request.solver.time_step_s;
2503        let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
2504        let target_height_m = prepared.resolved_request.shot.target_height_m;
2505        let (solver, _) = build_zeroed_solver(
2506            prepared.inputs,
2507            prepared.wind,
2508            prepared.atmosphere,
2509            prepared.wind_segments,
2510            max_range_m,
2511            time_step_s,
2512            zero_distance_m,
2513            target_height_m,
2514            None,
2515        )
2516        .expect("zeroed solver");
2517        solver
2518            .solve()
2519            .expect("solve")
2520            .sample_observations(25.0, MAX_SOLVE_JSON_SAMPLES_V1)
2521            .expect("samples")
2522    }
2523
2524    /// `minimal_request` zeroed at 100 m, with a ladder written straight into the engine inputs.
2525    fn fly_with_engine_ladder(segments: Vec<crate::BCSegmentData>) -> Vec<TrajectoryObservation> {
2526        let mut request = minimal_request();
2527        request.shot.zero_distance_m = Some(100.0);
2528        let mut prepared = prepare_request(&request).expect("valid request");
2529        prepared.inputs.use_bc_segments = true;
2530        prepared.inputs.bc_segments_data = Some(segments);
2531        fly(prepared)
2532    }
2533
2534    /// MBA-1598: a wire ladder's m/s band edges reach the engine as the fps `BCSegmentData`
2535    /// the CLI's `--bc-segment` builds. The ladder arrives as JSON text through
2536    /// `decode_solve_request_v1`, so the wire spelling is covered too, and is pinned against a
2537    /// solve whose ladder is written directly in fps, with every edge on a round fps number.
2538    /// The .308 slows from 2700 fps through a band, an interior gap, a touching edge and a
2539    /// third band, so a missing or wrong conversion would key every edge off the wrong speed.
2540    #[test]
2541    fn bc_segment_edges_reach_the_engine_in_fps() {
2542        use crate::solve_json::{decode_solve_request_v1, BcSegmentV1};
2543        use crate::BCSegmentData;
2544
2545        // fps: 2200-3000 at 0.250, a 2000-2200 gap flying the scalar 0.243, then 1500-2000 at
2546        // 0.235 touching 0-1500 at 0.225.
2547        let fps_ladder = [
2548            (2_200.0, 3_000.0, 0.250),
2549            (1_500.0, 2_000.0, 0.235),
2550            (0.0, 1_500.0, 0.225),
2551        ];
2552        let mut base = minimal_request();
2553        base.shot.zero_distance_m = Some(100.0);
2554        let mut wire = serde_json::to_value(&base).expect("serialize the base request");
2555        wire["projectile"]["bc_segments"] = serde_json::json!([
2556            {"velocity_min_mps": 670.56, "velocity_max_mps": 914.4, "ballistic_coefficient": 0.250},
2557            {"velocity_min_mps": 457.2, "velocity_max_mps": 609.6, "ballistic_coefficient": 0.235},
2558            {"velocity_min_mps": 0.0, "velocity_max_mps": 457.2, "ballistic_coefficient": 0.225}
2559        ]);
2560        let request = decode_solve_request_v1(&wire.to_string()).expect("decodable ladder");
2561        assert_eq!(
2562            request.projectile.bc_segments.as_deref(),
2563            Some(
2564                &[
2565                    (670.56, 914.4, 0.250),
2566                    (457.2, 609.6, 0.235),
2567                    (0.0, 457.2, 0.225),
2568                ]
2569                .map(|(min, max, bc)| BcSegmentV1 {
2570                    velocity_min_mps: min,
2571                    velocity_max_mps: max,
2572                    ballistic_coefficient: bc,
2573                })[..]
2574            ),
2575            "the wire names decode onto the struct fields"
2576        );
2577        assert_eq!(
2578            SolveRequestV1 {
2579                projectile: ProjectileV1 {
2580                    bc_segments: None,
2581                    ..request.projectile.clone()
2582                },
2583                ..request.clone()
2584            },
2585            base,
2586            "only the ladder differs from the base request"
2587        );
2588
2589        let prepared = prepare_request(&request).expect("valid ladder");
2590        assert!(prepared.inputs.use_bc_segments);
2591        assert_eq!(
2592            prepared.inputs.bc_value, 0.243,
2593            "the scalar BC is left alone"
2594        );
2595        let installed = prepared
2596            .inputs
2597            .bc_segments_data
2598            .clone()
2599            .expect("ladder installed");
2600        assert_eq!(installed.len(), fps_ladder.len());
2601        for (band, (min_fps, max_fps, bc)) in installed.iter().zip(fps_ladder) {
2602            assert!((band.velocity_min - min_fps).abs() < 1e-9, "{band:?}");
2603            assert!((band.velocity_max - max_fps).abs() < 1e-9, "{band:?}");
2604            assert_eq!(band.bc_value, bc);
2605        }
2606
2607        let from_wire = fly(prepared);
2608        let engine_ladder = |scale: f64| {
2609            fps_ladder
2610                .iter()
2611                .map(|&(min, max, bc)| BCSegmentData {
2612                    velocity_min: min * scale,
2613                    velocity_max: max * scale,
2614                    bc_value: bc,
2615                })
2616                .collect::<Vec<_>>()
2617        };
2618        let direct = fly_with_engine_ladder(engine_ladder(1.0));
2619        assert_eq!(from_wire.len(), direct.len());
2620        for (wire, engine) in from_wire.iter().zip(&direct) {
2621            assert!((wire.distance_m - engine.distance_m).abs() < 1e-9);
2622            assert!(
2623                (wire.drop_m - engine.drop_m).abs() < 1e-9,
2624                "{wire:?} vs {engine:?}"
2625            );
2626            assert!(
2627                (wire.speed_mps - engine.speed_mps).abs() < 1e-9,
2628                "{wire:?} vs {engine:?}"
2629            );
2630            assert!(
2631                (wire.time_s - engine.time_s).abs() < 1e-12,
2632                "{wire:?} vs {engine:?}"
2633            );
2634        }
2635
2636        // The pin is not vacuous: edges left in m/s, or the scalar alone, fly differently.
2637        let terminal_drop = |samples: &[TrajectoryObservation]| samples.last().unwrap().drop_m;
2638        let unconverted = fly_with_engine_ladder(engine_ladder(0.3048));
2639        let mut scalar_only = minimal_request();
2640        scalar_only.shot.zero_distance_m = Some(100.0);
2641        let scalar_only = fly(prepare_request(&scalar_only).unwrap());
2642        for other in [&unconverted, &scalar_only] {
2643            assert!(
2644                (terminal_drop(&from_wire) - terminal_drop(other)).abs() > 1e-3,
2645                "{} vs {}",
2646                terminal_drop(&from_wire),
2647                terminal_drop(other)
2648            );
2649        }
2650    }
2651
2652    /// How `install_drag_table` installs each kind, with and without a BC ladder. Only a
2653    /// reference table beside a ladder takes the reference-curve path (`custom_drag_reference`
2654    /// set, `cd_scale` 1.0). A reference table alone keeps the original `cd_scale = SD / BC`
2655    /// installation, so every request accepted before ladders could sit beside a reference
2656    /// table solves byte-identically, and a projectile table beside a ladder is refused.
2657    #[test]
2658    fn install_drag_table_takes_the_reference_curve_path_only_beside_a_ladder() {
2659        let prepare = |kind: DragTableKindV1, with_ladder: bool| {
2660            let mut request = minimal_request();
2661            request.projectile.drag_table = Some(DragTableV1 {
2662                kind,
2663                name: None,
2664                points: vec![
2665                    crate::solve_json::DragPointV1 { mach: 0.0, cd: 0.2 },
2666                    crate::solve_json::DragPointV1 { mach: 2.0, cd: 0.3 },
2667                ],
2668            });
2669            if with_ladder {
2670                request.projectile.bc_segments = Some(vec![
2671                    crate::solve_json::BcSegmentV1 {
2672                        velocity_min_mps: 600.0,
2673                        velocity_max_mps: 999.0,
2674                        ballistic_coefficient: 0.260,
2675                    },
2676                    crate::solve_json::BcSegmentV1 {
2677                        velocity_min_mps: 0.0,
2678                        velocity_max_mps: 600.0,
2679                        ballistic_coefficient: 0.220,
2680                    },
2681                ]);
2682            }
2683            prepare_request(&request)
2684        };
2685
2686        let alone = prepare(DragTableKindV1::Reference, false).unwrap().inputs;
2687        let sd = alone.sectional_density_lb_in2().expect("SD");
2688        assert!(!alone.custom_drag_reference);
2689        assert_eq!(alone.cd_scale, sd / 0.243);
2690
2691        let laddered = prepare(DragTableKindV1::Reference, true).unwrap().inputs;
2692        assert!(laddered.custom_drag_reference);
2693        assert_eq!(laddered.cd_scale, 1.0);
2694        assert!(laddered.custom_drag_table.is_some());
2695        assert!(laddered.use_bc_segments && laddered.bc_segments_data.is_some());
2696        assert_eq!(laddered.bc_value, 0.243, "the scalar still flies in a gap");
2697
2698        let own = prepare(DragTableKindV1::Projectile, false).unwrap().inputs;
2699        assert!(!own.custom_drag_reference);
2700        assert_eq!(own.cd_scale, 1.0);
2701
2702        let refused = prepare(DragTableKindV1::Projectile, true)
2703            .expect_err("a ladder beside a projectile table is refused");
2704        assert_eq!(refused.error.code, SolveErrorCodeV1::ConflictingFields);
2705        assert_eq!(refused.error.path(), Some("$.projectile.bc_segments"));
2706    }
2707
2708    #[test]
2709    fn enum_mappers_cover_every_engine_metadata_variant() {
2710        assert_eq!(drag_model_to_engine(DragModelV1::G1), DragModel::G1);
2711        assert_eq!(drag_model_to_engine(DragModelV1::G2), DragModel::G2);
2712        assert_eq!(drag_model_to_engine(DragModelV1::G5), DragModel::G5);
2713        assert_eq!(drag_model_to_engine(DragModelV1::G6), DragModel::G6);
2714        assert_eq!(drag_model_to_engine(DragModelV1::G7), DragModel::G7);
2715        assert_eq!(drag_model_to_engine(DragModelV1::G8), DragModel::G8);
2716        assert_eq!(drag_model_to_engine(DragModelV1::GI), DragModel::GI);
2717        assert_eq!(drag_model_to_engine(DragModelV1::GS), DragModel::GS);
2718        assert_eq!(drag_model_to_engine(DragModelV1::RA4), DragModel::RA4);
2719
2720        for (engine, wire) in [
2721            (
2722                TrajectoryTermination::MaxRange,
2723                TerminationReasonV1::MaxRange,
2724            ),
2725            (
2726                TrajectoryTermination::GroundThreshold,
2727                TerminationReasonV1::GroundThreshold,
2728            ),
2729            (
2730                TrajectoryTermination::TimeLimit,
2731                TerminationReasonV1::TimeLimit,
2732            ),
2733            (
2734                TrajectoryTermination::VelocityFloor,
2735                TerminationReasonV1::VelocityFloor,
2736            ),
2737        ] {
2738            assert_eq!(termination_to_wire(engine), wire);
2739        }
2740    }
2741}