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, EffectsV1, PressureReferenceV1, ProjectileV1,
9    ResolvedAtmosphereV1, ResolvedConstantWindV1, ResolvedEffectsV1, ResolvedProjectileV1,
10    ResolvedRifleV1, ResolvedSamplingV1, ResolvedSegmentedWindV1, ResolvedShotV1,
11    ResolvedSolveRequestV1, ResolvedSolverV1, ResolvedWindSegmentV1, ResolvedWindV1, RifleV1,
12    SampleFlagV1, SamplingV1, SchemaVersionV1, ShotV1, SolveErrorCodeV1, SolveErrorEnvelopeV1,
13    SolveErrorV1, SolveNoticeV1, SolveRequestV1, SolveSuccessV1, SolveSummaryV1, SolverMethodV1,
14    SolverV1, SuccessStatusV1, TerminationReasonV1, TrajectorySampleV1, TwistDirectionV1, WindV1,
15    MAX_SOLVE_JSON_SAMPLES_V1,
16};
17use crate::trajectory_observation::{
18    TrajectoryObservation, TrajectoryObservationError, TrajectoryObservationFlag,
19    TrajectoryTermination,
20};
21use crate::wind::WindSegment;
22use crate::{
23    AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, TrajectorySolver,
24    WindConditions,
25};
26
27const DEFAULT_SIGHT_HEIGHT_M: f64 = 0.05;
28const DEFAULT_MUZZLE_HEIGHT_M: f64 = 0.0;
29const DEFAULT_TWIST_RATE_M_PER_TURN: f64 = 0.3048;
30const DEFAULT_ALTITUDE_M: f64 = 0.0;
31const DEFAULT_RELATIVE_HUMIDITY: f64 = 0.5;
32const DEFAULT_TIME_STEP_S: f64 = 0.001;
33const DEFAULT_SAMPLE_INTERVAL_M: f64 = 10.0;
34const DEFAULT_GROUND_THRESHOLD_M: f64 = -100.0;
35
36const MIN_ICAO_ALTITUDE_M: f64 = -5_000.0;
37const MAX_ICAO_ALTITUDE_M: f64 = 84_000.0;
38const METERS_PER_INCH: f64 = 0.0254;
39const KILOMETRES_PER_METRE: f64 = 0.001;
40const SECONDS_PER_HOUR: f64 = 3_600.0;
41const PASCALS_PER_HECTOPASCAL: f64 = 100.0;
42const KELVIN_OFFSET_C: f64 = 273.15;
43const KG_PER_GRAIN: f64 = 0.000_064_798_91;
44
45/// Stable assumption code emitted for a literal protocol default.
46pub const ASSUMPTION_DEFAULT_APPLIED: &str = "default_applied";
47/// Stable assumption code emitted when station temperature is resolved from ICAO atmosphere.
48pub const ASSUMPTION_ICAO_STANDARD_TEMPERATURE: &str = "icao_standard_temperature";
49/// Stable assumption code emitted when station pressure is resolved from ICAO atmosphere.
50pub const ASSUMPTION_ICAO_STANDARD_PRESSURE: &str = "icao_standard_pressure";
51/// Stable assumption code emitted when an explicit `pressure_reference: "qnh"` pressure is
52/// reduced to station pressure (MBA-1397).
53pub const ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE: &str = "qnh_reduced_to_station_pressure";
54/// Stable assumption code emitted when projectile length is inferred from mass and diameter.
55pub const ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH: &str = "estimated_projectile_length";
56
57/// Stable warning code for segmented wind that becomes calm before the requested maximum range.
58pub const WARNING_PARTIAL_WIND_COVERAGE: &str = "partial_wind_coverage";
59/// Stable warning code emitted for each opt-in experimental physical effect.
60pub const WARNING_EXPERIMENTAL_EFFECT: &str = "experimental_effect";
61/// Stable warning code for an explicitly supplied fixed step that adaptive RK45 ignores.
62pub const WARNING_RK45_TIME_STEP_IGNORED: &str = "rk45_time_step_ignored";
63
64#[derive(Debug)]
65struct PreparedSolveV1 {
66    resolved_request: ResolvedSolveRequestV1,
67    assumptions: Vec<SolveNoticeV1>,
68    warnings: Vec<SolveNoticeV1>,
69    inputs: BallisticInputs,
70    wind: WindConditions,
71    wind_segments: Vec<WindSegment>,
72    atmosphere: AtmosphericConditions,
73}
74
75/// Execute one semantically validated solve-json v1 request.
76///
77/// The function is transport-free: callers are responsible for decoding and encoding the DTOs.
78/// All dimensional request values are SI.  Valid requests produce a fully resolved success DTO;
79/// invalid requests and engine failures produce the stable v1 error envelope.
80pub fn solve_v1(request: SolveRequestV1) -> Result<SolveSuccessV1, SolveErrorEnvelopeV1> {
81    let mut prepared = prepare_request(&request)?;
82    let max_range_m = prepared.resolved_request.shot.max_range_m;
83    let time_step_s = prepared.resolved_request.solver.time_step_s;
84    let sample_interval_m = prepared.resolved_request.sampling.interval_m;
85    let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
86    let target_height_m = prepared.resolved_request.shot.target_height_m;
87    let enhanced_spin_drift = prepared.resolved_request.effects.enhanced_spin_drift;
88
89    // Retain the exact mapped inputs for summary diagnostics. TrajectorySolver owns its copy and
90    // may update the private effective muzzle angle during zeroing.
91    let summary_inputs = prepared.inputs.clone();
92    let station_temperature_c = prepared.atmosphere.temperature;
93    let station_pressure_hpa = prepared.atmosphere.pressure;
94
95    let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
96        prepared.inputs,
97        prepared.wind,
98        prepared.atmosphere,
99    );
100    solver.set_max_range(max_range_m);
101    solver.set_time_step(time_step_s);
102    if !prepared.wind_segments.is_empty() {
103        solver.set_wind_segments(prepared.wind_segments);
104    }
105
106    if let Some(distance_m) = zero_distance_m {
107        let effective_angle = solver
108            .calculate_and_set_zero_angle(
109                distance_m,
110                target_height_m,
111                crate::cli_api::ZeroTargetFrame::WorldVertical,
112            )
113            .map_err(solve_failed)?;
114        if !effective_angle.is_finite() {
115            return Err(solve_failed_message(
116                "zero-angle calculation returned a non-finite effective muzzle angle",
117            ));
118        }
119        prepared.resolved_request.shot.muzzle_angle_rad = effective_angle;
120    }
121
122    let result = solver.solve().map_err(solve_failed)?;
123    let observations = result
124        .sample_observations(sample_interval_m, MAX_SOLVE_JSON_SAMPLES_V1)
125        .map_err(map_observation_error)?;
126
127    let samples = observations
128        .iter()
129        .cloned()
130        .map(observation_to_wire)
131        .collect::<Vec<_>>();
132    let terminal = observations.last().ok_or_else(|| {
133        internal_error("trajectory observation sampling returned no terminal observation")
134    })?;
135
136    let maximum_height_m =
137        maximum_world_height_m(&result, prepared.resolved_request.shot.shooting_angle_rad)?;
138
139    let stability = crate::spin_drift::effective_sg_from_inputs(
140        &summary_inputs,
141        station_temperature_c,
142        station_pressure_hpa,
143    );
144    let stability_factor = if stability.is_finite() && stability >= 0.0 {
145        Some(stability)
146    } else {
147        None
148    };
149    let spin_drift_m = if enhanced_spin_drift {
150        stability_factor
151            .map(|sg| {
152                crate::spin_drift::litz_drift_meters(
153                    sg,
154                    terminal.time_s,
155                    summary_inputs.is_twist_right,
156                )
157            })
158            .filter(|drift| drift.is_finite())
159    } else {
160        None
161    };
162
163    if enhanced_spin_drift && stability_factor.is_some() && spin_drift_m.is_none() {
164        return Err(internal_error(
165            "enhanced spin-drift summary calculation produced a non-finite value",
166        ));
167    }
168
169    let summary = SolveSummaryV1 {
170        actual_range_m: terminal.distance_m,
171        maximum_height_m,
172        time_of_flight_s: terminal.time_s,
173        terminal_speed_mps: terminal.speed_mps,
174        terminal_energy_j: terminal.energy_j,
175        stability_factor,
176        spin_drift_m,
177        termination: termination_to_wire(result.termination),
178    };
179
180    let success = SolveSuccessV1 {
181        schema_version: SchemaVersionV1,
182        engine_version: env!("CARGO_PKG_VERSION").to_owned(),
183        status: SuccessStatusV1::Ok,
184        resolved_request: prepared.resolved_request,
185        assumptions: prepared.assumptions,
186        warnings: prepared.warnings,
187        summary,
188        samples,
189    };
190    success.validate_for_serialization()?;
191    Ok(success)
192}
193
194fn prepare_request(request: &SolveRequestV1) -> Result<PreparedSolveV1, SolveErrorEnvelopeV1> {
195    let mut assumptions = Vec::new();
196    let mut warnings = Vec::new();
197
198    let (projectile, bullet_length_m) = resolve_projectile(&request.projectile, &mut assumptions)?;
199    let rifle = resolve_rifle(&request.rifle, &mut assumptions)?;
200    let shot = resolve_shot(&request.shot, rifle.muzzle_height_m, &mut assumptions)?;
201    let atmosphere = resolve_atmosphere(&request.atmosphere, &mut assumptions)?;
202    let wind_coverage_distance_m = shot.zero_distance_m.unwrap_or(0.0).max(shot.max_range_m);
203    let (resolved_wind, wind, wind_segments) = resolve_wind(
204        &request.wind,
205        wind_coverage_distance_m,
206        &mut assumptions,
207        &mut warnings,
208    )?;
209    let solver = resolve_solver(&request.solver, &mut assumptions, &mut warnings)?;
210    let effects = resolve_effects(
211        &request.effects,
212        atmosphere.latitude_rad,
213        &mut assumptions,
214        &mut warnings,
215    )?;
216    let sampling = resolve_sampling(&request.sampling, &mut assumptions)?;
217
218    let resolved_request = ResolvedSolveRequestV1 {
219        schema_version: SchemaVersionV1,
220        projectile,
221        rifle,
222        shot,
223        atmosphere,
224        wind: resolved_wind,
225        solver,
226        effects,
227        sampling,
228    };
229
230    let temperature_c = checked_conversion(
231        resolved_request.atmosphere.temperature_k - KELVIN_OFFSET_C,
232        "$.atmosphere.temperature_k",
233        "temperature conversion to Celsius overflowed",
234    )?;
235    let pressure_hpa = checked_conversion(
236        resolved_request.atmosphere.pressure_pa / PASCALS_PER_HECTOPASCAL,
237        "$.atmosphere.pressure_pa",
238        "pressure conversion to hectopascals overflowed",
239    )?;
240    let humidity_percent = checked_conversion(
241        resolved_request.atmosphere.relative_humidity * 100.0,
242        "$.atmosphere.relative_humidity",
243        "relative-humidity conversion to percent overflowed",
244    )?;
245    let twist_rate_inches = checked_conversion(
246        resolved_request.rifle.twist_rate_m_per_turn / METERS_PER_INCH,
247        "$.rifle.twist_rate_m_per_turn",
248        "twist-rate conversion to inches per turn overflowed",
249    )?;
250    let caliber_inches = checked_conversion(
251        resolved_request.projectile.diameter_m / METERS_PER_INCH,
252        "$.projectile.diameter_m",
253        "diameter conversion to inches overflowed",
254    )?;
255    let weight_grains = checked_conversion(
256        resolved_request.projectile.mass_kg / KG_PER_GRAIN,
257        "$.projectile.mass_kg",
258        "mass conversion to grains overflowed",
259    )?;
260    let latitude_degrees = resolved_request
261        .atmosphere
262        .latitude_rad
263        .map(f64::to_degrees)
264        .map(|value| {
265            checked_conversion(
266                value,
267                "$.atmosphere.latitude_rad",
268                "latitude conversion to degrees overflowed",
269            )
270        })
271        .transpose()?;
272
273    let (wind_speed, wind_direction) = match &resolved_request.wind {
274        ResolvedWindV1::Constant(constant) => (constant.speed_mps, constant.direction_from_rad),
275        ResolvedWindV1::Segmented(_) => (0.0, 0.0),
276    };
277
278    let inputs = BallisticInputs {
279        bc_value: resolved_request.projectile.ballistic_coefficient,
280        bc_type: drag_model_to_engine(resolved_request.projectile.drag_model),
281        // solve-json v1 (docs/SOLVE_JSON_V1.md) has no BC-reference-standard field yet;
282        // every published BC in that contract is treated as ICAO-referenced (MBA-1365
283        // is a CLI/WASM/FFI/profile feature, not a v1 JSON schema change).
284        bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
285        bullet_mass: resolved_request.projectile.mass_kg,
286        muzzle_velocity: resolved_request.rifle.muzzle_velocity_mps,
287        bullet_diameter: resolved_request.projectile.diameter_m,
288        bullet_length: bullet_length_m,
289        muzzle_angle: resolved_request.shot.muzzle_angle_rad,
290        target_distance: resolved_request.shot.max_range_m,
291        azimuth_angle: resolved_request.shot.aim_azimuth_rad,
292        shot_azimuth: resolved_request.shot.shot_azimuth_rad,
293        shooting_angle: resolved_request.shot.shooting_angle_rad,
294        cant_angle: resolved_request.shot.cant_angle_rad,
295        sight_height: resolved_request.rifle.sight_height_m,
296        muzzle_height: resolved_request.rifle.muzzle_height_m,
297        target_height: resolved_request.shot.target_height_m,
298        ground_threshold: resolved_request.shot.ground_threshold_m,
299        altitude: resolved_request.atmosphere.altitude_m,
300        temperature: temperature_c,
301        pressure: pressure_hpa,
302        humidity: resolved_request.atmosphere.relative_humidity,
303        latitude: latitude_degrees,
304        wind_speed,
305        wind_angle: wind_direction,
306        twist_rate: twist_rate_inches,
307        is_twist_right: resolved_request.rifle.twist_direction == TwistDirectionV1::Right,
308        caliber_inches,
309        weight_grains,
310        manufacturer: None,
311        bullet_model: None,
312        bullet_id: None,
313        bullet_cluster: None,
314        use_rk4: resolved_request.solver.method != SolverMethodV1::Euler,
315        use_adaptive_rk45: resolved_request.solver.method == SolverMethodV1::Rk45,
316        enable_advanced_effects: resolved_request.effects.magnus
317            || resolved_request.effects.coriolis,
318        enable_magnus: resolved_request.effects.magnus,
319        enable_coriolis: resolved_request.effects.coriolis,
320        use_powder_sensitivity: false,
321        powder_temp_sensitivity: 0.0,
322        powder_temp: temperature_c,
323        powder_temp_curve: None,
324        powder_curve_temp_c: None,
325        tipoff_yaw: 0.0,
326        tipoff_decay_distance: 50.0,
327        use_bc_segments: false,
328        bc_segments: None,
329        bc_segments_data: None,
330        use_enhanced_spin_drift: resolved_request.effects.enhanced_spin_drift,
331        use_form_factor: false,
332        enable_wind_shear: false,
333        wind_shear_model: "none".to_owned(),
334        enable_trajectory_sampling: false,
335        sample_interval: resolved_request.sampling.interval_m,
336        enable_pitch_damping: false,
337        enable_precession_nutation: false,
338        enable_aerodynamic_jump: false,
339        use_cluster_bc: false,
340        custom_drag_table: None,
341        // MBA-1356: solve-json v1 has no custom-drag-table field (see the module doc — the
342        // public JSON contract deliberately does not expose custom decks), so cd_scale is
343        // always inert here; keep it at the neutral default.
344        cd_scale: 1.0,
345        bc_type_str: None,
346    };
347
348    let atmosphere = AtmosphericConditions {
349        temperature: temperature_c,
350        pressure: pressure_hpa,
351        humidity: humidity_percent,
352        altitude: resolved_request.atmosphere.altitude_m,
353    };
354
355    Ok(PreparedSolveV1 {
356        resolved_request,
357        assumptions,
358        warnings,
359        inputs,
360        wind,
361        wind_segments,
362        atmosphere,
363    })
364}
365
366fn resolve_projectile(
367    projectile: &ProjectileV1,
368    assumptions: &mut Vec<SolveNoticeV1>,
369) -> Result<(ResolvedProjectileV1, f64), SolveErrorEnvelopeV1> {
370    require_positive("$.projectile.mass_kg", projectile.mass_kg)?;
371    require_positive("$.projectile.diameter_m", projectile.diameter_m)?;
372    require_positive(
373        "$.projectile.ballistic_coefficient",
374        projectile.ballistic_coefficient,
375    )?;
376    if let Some(length_m) = projectile.length_m {
377        require_positive("$.projectile.length_m", length_m)?;
378    }
379
380    let bullet_length_m = match projectile.length_m {
381        Some(length_m) => length_m,
382        None => {
383            let estimated = crate::stability::estimate_bullet_length_m(
384                projectile.diameter_m,
385                projectile.mass_kg,
386            );
387            if !estimated.is_finite() || estimated <= 0.0 {
388                return Err(invalid_value(
389                    "$.projectile",
390                    "projectile mass and diameter could not produce a finite positive length estimate",
391                ));
392            }
393            assumptions.push(notice(
394                ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH,
395                format!(
396                    "Projectile length was omitted; the engine estimated {estimated:.12} m from mass and diameter."
397                ),
398                "$.projectile.length_m",
399            ));
400            estimated
401        }
402    };
403
404    Ok((
405        ResolvedProjectileV1 {
406            mass_kg: projectile.mass_kg,
407            diameter_m: projectile.diameter_m,
408            length_m: projectile.length_m,
409            drag_model: projectile.drag_model,
410            ballistic_coefficient: projectile.ballistic_coefficient,
411        },
412        bullet_length_m,
413    ))
414}
415
416fn resolve_rifle(
417    rifle: &RifleV1,
418    assumptions: &mut Vec<SolveNoticeV1>,
419) -> Result<ResolvedRifleV1, SolveErrorEnvelopeV1> {
420    require_positive("$.rifle.muzzle_velocity_mps", rifle.muzzle_velocity_mps)?;
421    if let Some(value) = rifle.sight_height_m {
422        require_non_negative("$.rifle.sight_height_m", value)?;
423    }
424    if let Some(value) = rifle.muzzle_height_m {
425        require_finite("$.rifle.muzzle_height_m", value)?;
426    }
427    if let Some(value) = rifle.twist_rate_m_per_turn {
428        require_positive("$.rifle.twist_rate_m_per_turn", value)?;
429    }
430
431    let sight_height_m = literal_default(
432        rifle.sight_height_m,
433        DEFAULT_SIGHT_HEIGHT_M,
434        "$.rifle.sight_height_m",
435        "Sight height defaulted to 0.05 m.",
436        assumptions,
437    );
438    let muzzle_height_m = literal_default(
439        rifle.muzzle_height_m,
440        DEFAULT_MUZZLE_HEIGHT_M,
441        "$.rifle.muzzle_height_m",
442        "Muzzle height defaulted to 0 m.",
443        assumptions,
444    );
445    let twist_rate_m_per_turn = literal_default(
446        rifle.twist_rate_m_per_turn,
447        DEFAULT_TWIST_RATE_M_PER_TURN,
448        "$.rifle.twist_rate_m_per_turn",
449        "Twist rate defaulted to 0.3048 m per turn.",
450        assumptions,
451    );
452    let twist_direction = match rifle.twist_direction {
453        Some(direction) => direction,
454        None => {
455            assumptions.push(notice(
456                ASSUMPTION_DEFAULT_APPLIED,
457                "Twist direction defaulted to right-hand.",
458                "$.rifle.twist_direction",
459            ));
460            TwistDirectionV1::Right
461        }
462    };
463
464    Ok(ResolvedRifleV1 {
465        muzzle_velocity_mps: rifle.muzzle_velocity_mps,
466        sight_height_m,
467        muzzle_height_m,
468        twist_rate_m_per_turn,
469        twist_direction,
470    })
471}
472
473fn resolve_shot(
474    shot: &ShotV1,
475    muzzle_height_m: f64,
476    assumptions: &mut Vec<SolveNoticeV1>,
477) -> Result<ResolvedShotV1, SolveErrorEnvelopeV1> {
478    require_positive("$.shot.max_range_m", shot.max_range_m)?;
479    if let Some(value) = shot.zero_distance_m {
480        require_positive("$.shot.zero_distance_m", value)?;
481        if !(value * 2.0).is_finite() {
482            return Err(invalid_value(
483                "$.shot.zero_distance_m",
484                "zero distance is too large to construct a finite trial trajectory range",
485            ));
486        }
487    }
488    if let Some(value) = shot.muzzle_angle_rad {
489        require_finite("$.shot.muzzle_angle_rad", value)?;
490    }
491    if shot.zero_distance_m.is_some() && shot.muzzle_angle_rad.is_some() {
492        return Err(conflicting_fields(
493            "$.shot",
494            "zero_distance_m and muzzle_angle_rad cannot both be supplied",
495        ));
496    }
497
498    for (path, value) in [
499        ("$.shot.aim_azimuth_rad", shot.aim_azimuth_rad),
500        ("$.shot.shot_azimuth_rad", shot.shot_azimuth_rad),
501        ("$.shot.shooting_angle_rad", shot.shooting_angle_rad),
502        ("$.shot.cant_angle_rad", shot.cant_angle_rad),
503        ("$.shot.target_height_m", shot.target_height_m),
504        ("$.shot.ground_threshold_m", shot.ground_threshold_m),
505    ] {
506        if let Some(value) = value {
507            require_finite(path, value)?;
508        }
509    }
510
511    let muzzle_angle_rad = match (shot.zero_distance_m, shot.muzzle_angle_rad) {
512        (_, Some(angle)) => angle,
513        (Some(_), None) => 0.0, // Replaced with the calculated effective angle before solving.
514        (None, None) => {
515            assumptions.push(notice(
516                ASSUMPTION_DEFAULT_APPLIED,
517                "Muzzle angle defaulted to 0 rad.",
518                "$.shot.muzzle_angle_rad",
519            ));
520            0.0
521        }
522    };
523    let aim_azimuth_rad = literal_default(
524        shot.aim_azimuth_rad,
525        0.0,
526        "$.shot.aim_azimuth_rad",
527        "Aim azimuth defaulted to 0 rad.",
528        assumptions,
529    );
530    let shot_azimuth_rad = literal_default(
531        shot.shot_azimuth_rad,
532        0.0,
533        "$.shot.shot_azimuth_rad",
534        "Shot azimuth defaulted to 0 rad (north).",
535        assumptions,
536    );
537    let shooting_angle_rad = literal_default(
538        shot.shooting_angle_rad,
539        0.0,
540        "$.shot.shooting_angle_rad",
541        "Shooting angle defaulted to 0 rad.",
542        assumptions,
543    );
544    let cant_angle_rad = literal_default(
545        shot.cant_angle_rad,
546        0.0,
547        "$.shot.cant_angle_rad",
548        "Cant angle defaulted to 0 rad.",
549        assumptions,
550    );
551    let target_height_m = literal_default(
552        shot.target_height_m,
553        0.0,
554        "$.shot.target_height_m",
555        "Target height defaulted to 0 m above the local ground datum.",
556        assumptions,
557    );
558    let ground_threshold_m = literal_default(
559        shot.ground_threshold_m,
560        DEFAULT_GROUND_THRESHOLD_M,
561        "$.shot.ground_threshold_m",
562        "Ground threshold defaulted to -100 m.",
563        assumptions,
564    );
565    if ground_threshold_m >= muzzle_height_m {
566        return Err(invalid_value(
567            "$.shot.ground_threshold_m",
568            "ground threshold must be below the resolved muzzle height",
569        ));
570    }
571
572    Ok(ResolvedShotV1 {
573        max_range_m: shot.max_range_m,
574        zero_distance_m: shot.zero_distance_m,
575        muzzle_angle_rad,
576        aim_azimuth_rad,
577        shot_azimuth_rad,
578        shooting_angle_rad,
579        cant_angle_rad,
580        target_height_m,
581        ground_threshold_m,
582    })
583}
584
585fn resolve_atmosphere(
586    atmosphere: &AtmosphereV1,
587    assumptions: &mut Vec<SolveNoticeV1>,
588) -> Result<ResolvedAtmosphereV1, SolveErrorEnvelopeV1> {
589    if let Some(value) = atmosphere.altitude_m {
590        require_range(
591            "$.atmosphere.altitude_m",
592            value,
593            MIN_ICAO_ALTITUDE_M,
594            MAX_ICAO_ALTITUDE_M,
595        )?;
596    }
597    if let Some(value) = atmosphere.temperature_k {
598        require_positive("$.atmosphere.temperature_k", value)?;
599    }
600    if let Some(value) = atmosphere.pressure_pa {
601        require_positive("$.atmosphere.pressure_pa", value)?;
602    }
603    if let Some(value) = atmosphere.relative_humidity {
604        require_range("$.atmosphere.relative_humidity", value, 0.0, 1.0)?;
605    }
606    if let Some(value) = atmosphere.latitude_rad {
607        require_range(
608            "$.atmosphere.latitude_rad",
609            value,
610            -std::f64::consts::FRAC_PI_2,
611            std::f64::consts::FRAC_PI_2,
612        )?;
613    }
614
615    let altitude_m = match atmosphere.altitude_m {
616        Some(value) => value,
617        None => {
618            assumptions.push(notice(
619                ASSUMPTION_DEFAULT_APPLIED,
620                "Station altitude defaulted to 0 m.",
621                "$.atmosphere.altitude_m",
622            ));
623            DEFAULT_ALTITUDE_M
624        }
625    };
626    let (standard_temperature_k, standard_pressure_pa) =
627        crate::atmosphere::calculate_icao_standard_atmosphere(altitude_m);
628    let temperature_k = match atmosphere.temperature_k {
629        Some(value) => value,
630        None => {
631            assumptions.push(notice(
632                ASSUMPTION_ICAO_STANDARD_TEMPERATURE,
633                format!(
634                    "Station temperature was omitted; ICAO standard temperature at {altitude_m:.6} m is {standard_temperature_k:.12} K."
635                ),
636                "$.atmosphere.temperature_k",
637            ));
638            standard_temperature_k
639        }
640    };
641    let pressure_pa = match atmosphere.pressure_pa {
642        // MBA-1397: an explicit pressure declared as a QNH (sea-level-corrected altimeter
643        // setting) is reduced to station pressure at `altitude_m` before use. `Absolute`
644        // (including the omitted-field default) is a pure passthrough -- byte-identical to
645        // pre-MBA-1397 behavior for every request that never sets `pressure_reference`.
646        Some(value)
647            if atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh) =>
648        {
649            let qnh_hpa = value / PASCALS_PER_HECTOPASCAL;
650            let station_hpa =
651                crate::atmosphere::reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
652            let station_pa = station_hpa * PASCALS_PER_HECTOPASCAL;
653            assumptions.push(notice(
654                ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE,
655                format!(
656                    "Station pressure was declared as a QNH (sea-level-corrected altimeter \
657                     setting) of {value:.6} Pa; reduced to station pressure {station_pa:.12} Pa \
658                     at {altitude_m:.6} m via the ICAO inverse-barometric formula."
659                ),
660                "$.atmosphere.pressure_pa",
661            ));
662            station_pa
663        }
664        Some(value) => value,
665        None => {
666            assumptions.push(notice(
667                ASSUMPTION_ICAO_STANDARD_PRESSURE,
668                format!(
669                    "Station pressure was omitted; ICAO standard pressure at {altitude_m:.6} m is {standard_pressure_pa:.12} Pa."
670                ),
671                "$.atmosphere.pressure_pa",
672            ));
673            standard_pressure_pa
674        }
675    };
676    let relative_humidity = literal_default(
677        atmosphere.relative_humidity,
678        DEFAULT_RELATIVE_HUMIDITY,
679        "$.atmosphere.relative_humidity",
680        "Relative humidity defaulted to 0.5.",
681        assumptions,
682    );
683
684    Ok(ResolvedAtmosphereV1 {
685        altitude_m,
686        temperature_k,
687        pressure_pa,
688        relative_humidity,
689        latitude_rad: atmosphere.latitude_rad,
690    })
691}
692
693fn resolve_wind(
694    wind: &WindV1,
695    coverage_distance_m: f64,
696    assumptions: &mut Vec<SolveNoticeV1>,
697    warnings: &mut Vec<SolveNoticeV1>,
698) -> Result<(ResolvedWindV1, WindConditions, Vec<WindSegment>), SolveErrorEnvelopeV1> {
699    if let Some(segments) = &wind.segments {
700        if wind.speed_mps.is_some()
701            || wind.direction_from_rad.is_some()
702            || wind.vertical_speed_mps.is_some()
703        {
704            return Err(conflicting_fields(
705                "$.wind",
706                "segments cannot be combined with constant-wind fields",
707            ));
708        }
709        if segments.is_empty() {
710            return Err(invalid_value(
711                "$.wind.segments",
712                "segmented wind must contain at least one segment",
713            ));
714        }
715
716        let mut resolved_segments = Vec::with_capacity(segments.len());
717        let mut engine_segments = Vec::with_capacity(segments.len());
718        let mut previous_until_m = 0.0;
719        for (index, segment) in segments.iter().enumerate() {
720            let base = format!("$.wind.segments[{index}]");
721            require_positive(
722                &format!("{base}.until_distance_m"),
723                segment.until_distance_m,
724            )?;
725            require_non_negative(&format!("{base}.speed_mps"), segment.speed_mps)?;
726            require_finite(
727                &format!("{base}.direction_from_rad"),
728                segment.direction_from_rad,
729            )?;
730            if let Some(value) = segment.vertical_speed_mps {
731                require_finite(&format!("{base}.vertical_speed_mps"), value)?;
732            }
733            if index > 0 && segment.until_distance_m <= previous_until_m {
734                return Err(invalid_value(
735                    format!("{base}.until_distance_m"),
736                    "wind-segment boundaries must be strictly increasing",
737                ));
738            }
739            previous_until_m = segment.until_distance_m;
740
741            let vertical_speed_mps = match segment.vertical_speed_mps {
742                Some(value) => value,
743                None => {
744                    assumptions.push(notice(
745                        ASSUMPTION_DEFAULT_APPLIED,
746                        "Segment vertical wind speed defaulted to 0 m/s.",
747                        format!("{base}.vertical_speed_mps"),
748                    ));
749                    0.0
750                }
751            };
752            let speed_kmh = checked_conversion(
753                segment.speed_mps * SECONDS_PER_HOUR * KILOMETRES_PER_METRE,
754                &format!("{base}.speed_mps"),
755                "wind speed conversion to kilometres per hour overflowed",
756            )?;
757            let angle_deg = checked_conversion(
758                segment.direction_from_rad.to_degrees(),
759                &format!("{base}.direction_from_rad"),
760                "wind direction conversion to degrees overflowed",
761            )?;
762
763            resolved_segments.push(ResolvedWindSegmentV1 {
764                until_distance_m: segment.until_distance_m,
765                speed_mps: segment.speed_mps,
766                direction_from_rad: segment.direction_from_rad,
767                vertical_speed_mps,
768            });
769            engine_segments.push(WindSegment {
770                speed_kmh,
771                angle_deg,
772                until_m: segment.until_distance_m,
773                vertical_mps: vertical_speed_mps,
774            });
775        }
776
777        let final_until_m = resolved_segments
778            .last()
779            .expect("non-empty segmented wind was checked")
780            .until_distance_m;
781        if final_until_m < coverage_distance_m {
782            warnings.push(notice(
783                WARNING_PARTIAL_WIND_COVERAGE,
784                format!(
785                    "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."
786                ),
787                "$.wind.segments",
788            ));
789        }
790
791        Ok((
792            ResolvedWindV1::Segmented(ResolvedSegmentedWindV1 {
793                segments: resolved_segments,
794            }),
795            WindConditions::default(),
796            engine_segments,
797        ))
798    } else {
799        match (wind.speed_mps, wind.direction_from_rad) {
800            (Some(_), None) | (None, Some(_)) => {
801                return Err(conflicting_fields(
802                    "$.wind",
803                    "speed_mps and direction_from_rad must be supplied together",
804                ));
805            }
806            (None, None) if wind.vertical_speed_mps.is_some() => {
807                return Err(conflicting_fields(
808                    "$.wind",
809                    "vertical_speed_mps requires speed_mps and direction_from_rad",
810                ));
811            }
812            _ => {}
813        }
814
815        if let Some(value) = wind.speed_mps {
816            require_non_negative("$.wind.speed_mps", value)?;
817        }
818        if let Some(value) = wind.direction_from_rad {
819            require_finite("$.wind.direction_from_rad", value)?;
820        }
821        if let Some(value) = wind.vertical_speed_mps {
822            require_finite("$.wind.vertical_speed_mps", value)?;
823        }
824
825        let (speed_mps, direction_from_rad, vertical_speed_mps) =
826            if let (Some(speed), Some(direction)) = (wind.speed_mps, wind.direction_from_rad) {
827                let vertical = match wind.vertical_speed_mps {
828                    Some(value) => value,
829                    None => {
830                        assumptions.push(notice(
831                            ASSUMPTION_DEFAULT_APPLIED,
832                            "Constant vertical wind speed defaulted to 0 m/s.",
833                            "$.wind.vertical_speed_mps",
834                        ));
835                        0.0
836                    }
837                };
838                (speed, direction, vertical)
839            } else {
840                assumptions.push(notice(
841                    ASSUMPTION_DEFAULT_APPLIED,
842                    "Wind defaulted to still air.",
843                    "$.wind",
844                ));
845                (0.0, 0.0, 0.0)
846            };
847
848        Ok((
849            ResolvedWindV1::Constant(ResolvedConstantWindV1 {
850                speed_mps,
851                direction_from_rad,
852                vertical_speed_mps,
853            }),
854            WindConditions {
855                speed: speed_mps,
856                direction: direction_from_rad,
857                vertical_speed: vertical_speed_mps,
858            },
859            Vec::new(),
860        ))
861    }
862}
863
864fn resolve_solver(
865    solver: &SolverV1,
866    assumptions: &mut Vec<SolveNoticeV1>,
867    warnings: &mut Vec<SolveNoticeV1>,
868) -> Result<ResolvedSolverV1, SolveErrorEnvelopeV1> {
869    if let Some(value) = solver.time_step_s {
870        require_positive("$.solver.time_step_s", value)?;
871    }
872    let method = match solver.method {
873        Some(method) => method,
874        None => {
875            assumptions.push(notice(
876                ASSUMPTION_DEFAULT_APPLIED,
877                "Solver method defaulted to adaptive RK45.",
878                "$.solver.method",
879            ));
880            SolverMethodV1::Rk45
881        }
882    };
883    let time_step_s = literal_default(
884        solver.time_step_s,
885        DEFAULT_TIME_STEP_S,
886        "$.solver.time_step_s",
887        "Solver time step defaulted to 0.001 s.",
888        assumptions,
889    );
890    if method == SolverMethodV1::Rk45 && solver.time_step_s.is_some() {
891        warnings.push(notice(
892            WARNING_RK45_TIME_STEP_IGNORED,
893            "Adaptive RK45 owns its time step; the explicitly supplied fixed time step is retained in resolved_request but ignored by integration.",
894            "$.solver.time_step_s",
895        ));
896    }
897
898    Ok(ResolvedSolverV1 {
899        method,
900        time_step_s,
901    })
902}
903
904fn resolve_effects(
905    effects: &EffectsV1,
906    latitude_rad: Option<f64>,
907    assumptions: &mut Vec<SolveNoticeV1>,
908    warnings: &mut Vec<SolveNoticeV1>,
909) -> Result<ResolvedEffectsV1, SolveErrorEnvelopeV1> {
910    let magnus = bool_default(
911        effects.magnus,
912        "$.effects.magnus",
913        "Magnus effect defaulted to disabled.",
914        assumptions,
915    );
916    let coriolis = bool_default(
917        effects.coriolis,
918        "$.effects.coriolis",
919        "Coriolis effect defaulted to disabled.",
920        assumptions,
921    );
922    let enhanced_spin_drift = bool_default(
923        effects.enhanced_spin_drift,
924        "$.effects.enhanced_spin_drift",
925        "Enhanced spin drift defaulted to disabled.",
926        assumptions,
927    );
928
929    if magnus && enhanced_spin_drift {
930        return Err(conflicting_fields(
931            "$.effects",
932            "magnus and enhanced_spin_drift cannot both be enabled",
933        ));
934    }
935    if coriolis && latitude_rad.is_none() {
936        return Err(invalid_value(
937            "$.atmosphere.latitude_rad",
938            "latitude_rad is required when the Coriolis effect is enabled",
939        ));
940    }
941
942    for (enabled, path, name) in [
943        (magnus, "$.effects.magnus", "Magnus"),
944        (
945            enhanced_spin_drift,
946            "$.effects.enhanced_spin_drift",
947            "enhanced spin drift",
948        ),
949    ] {
950        if enabled {
951            warnings.push(notice(
952                WARNING_EXPERIMENTAL_EFFECT,
953                format!("The {name} effect is an opt-in experimental v1 model."),
954                path,
955            ));
956        }
957    }
958
959    Ok(ResolvedEffectsV1 {
960        magnus,
961        coriolis,
962        enhanced_spin_drift,
963    })
964}
965
966fn resolve_sampling(
967    sampling: &SamplingV1,
968    assumptions: &mut Vec<SolveNoticeV1>,
969) -> Result<ResolvedSamplingV1, SolveErrorEnvelopeV1> {
970    if let Some(value) = sampling.interval_m {
971        require_positive("$.sampling.interval_m", value)?;
972    }
973    let interval_m = literal_default(
974        sampling.interval_m,
975        DEFAULT_SAMPLE_INTERVAL_M,
976        "$.sampling.interval_m",
977        "Sampling interval defaulted to 10 m.",
978        assumptions,
979    );
980    Ok(ResolvedSamplingV1 { interval_m })
981}
982
983fn maximum_world_height_m(
984    result: &crate::TrajectoryResult,
985    shooting_angle_rad: f64,
986) -> Result<f64, SolveErrorEnvelopeV1> {
987    let mut maximum = f64::NEG_INFINITY;
988    for point in &result.points {
989        let height = crate::atmosphere::shot_frame_altitude(
990            0.0,
991            point.position.x,
992            point.position.y,
993            shooting_angle_rad,
994        );
995        if !height.is_finite() {
996            return Err(internal_error(
997                "world-vertical maximum-height projection produced a non-finite value",
998            ));
999        }
1000        maximum = maximum.max(height);
1001    }
1002    if maximum.is_finite() {
1003        Ok(maximum)
1004    } else {
1005        Err(internal_error(
1006            "trajectory contained no finite points for maximum-height calculation",
1007        ))
1008    }
1009}
1010
1011fn observation_to_wire(observation: TrajectoryObservation) -> TrajectorySampleV1 {
1012    TrajectorySampleV1 {
1013        distance_m: observation.distance_m,
1014        time_s: observation.time_s,
1015        speed_mps: observation.speed_mps,
1016        energy_j: observation.energy_j,
1017        drop_m: observation.drop_m,
1018        windage_m: observation.windage_m,
1019        mach: observation.mach,
1020        flags: observation
1021            .flags
1022            .into_iter()
1023            .map(observation_flag_to_wire)
1024            .collect(),
1025    }
1026}
1027
1028fn observation_flag_to_wire(flag: TrajectoryObservationFlag) -> SampleFlagV1 {
1029    match flag {
1030        TrajectoryObservationFlag::Transonic => SampleFlagV1::Transonic,
1031        TrajectoryObservationFlag::Subsonic => SampleFlagV1::Subsonic,
1032        TrajectoryObservationFlag::Terminal => SampleFlagV1::Terminal,
1033        TrajectoryObservationFlag::GroundThreshold => SampleFlagV1::GroundThreshold,
1034    }
1035}
1036
1037fn termination_to_wire(termination: TrajectoryTermination) -> TerminationReasonV1 {
1038    match termination {
1039        TrajectoryTermination::MaxRange => TerminationReasonV1::MaxRange,
1040        TrajectoryTermination::GroundThreshold => TerminationReasonV1::GroundThreshold,
1041        TrajectoryTermination::TimeLimit => TerminationReasonV1::TimeLimit,
1042        TrajectoryTermination::VelocityFloor => TerminationReasonV1::VelocityFloor,
1043    }
1044}
1045
1046fn drag_model_to_engine(model: DragModelV1) -> DragModel {
1047    match model {
1048        DragModelV1::G1 => DragModel::G1,
1049        DragModelV1::G6 => DragModel::G6,
1050        DragModelV1::G7 => DragModel::G7,
1051        DragModelV1::G8 => DragModel::G8,
1052    }
1053}
1054
1055fn map_observation_error(error: TrajectoryObservationError) -> SolveErrorEnvelopeV1 {
1056    let message = error.to_string();
1057    match error {
1058        TrajectoryObservationError::SampleLimitExceeded { .. }
1059        | TrajectoryObservationError::AllocationFailed { .. } => error_at(
1060            SolveErrorCodeV1::ResourceLimit,
1061            message,
1062            "$.sampling.interval_m",
1063        ),
1064        TrajectoryObservationError::InvalidInterval { .. }
1065        | TrajectoryObservationError::UnrepresentableGrid { .. } => error_at(
1066            SolveErrorCodeV1::InvalidValue,
1067            message,
1068            "$.sampling.interval_m",
1069        ),
1070        TrajectoryObservationError::EmptyTrajectory
1071        | TrajectoryObservationError::NonFiniteQuery { .. }
1072        | TrajectoryObservationError::OutOfRange { .. }
1073        | TrajectoryObservationError::NonMonotonicTrajectory { .. }
1074        | TrajectoryObservationError::NonFiniteState { .. }
1075        | TrajectoryObservationError::InvalidState { .. }
1076        | TrajectoryObservationError::InvalidMetadata { .. }
1077        | TrajectoryObservationError::NonFiniteObservation { .. } => internal_error(message),
1078    }
1079}
1080
1081fn solve_failed(error: BallisticsError) -> SolveErrorEnvelopeV1 {
1082    solve_failed_message(error.to_string())
1083}
1084
1085fn solve_failed_message(message: impl Into<String>) -> SolveErrorEnvelopeV1 {
1086    SolveErrorEnvelopeV1::new(SolveErrorV1::new(SolveErrorCodeV1::SolveFailed, message))
1087}
1088
1089fn internal_error(message: impl Into<String>) -> SolveErrorEnvelopeV1 {
1090    SolveErrorEnvelopeV1::new(SolveErrorV1::new(SolveErrorCodeV1::InternalError, message))
1091}
1092
1093fn invalid_value(path: impl Into<String>, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
1094    error_at(SolveErrorCodeV1::InvalidValue, message, path)
1095}
1096
1097fn conflicting_fields(path: impl Into<String>, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
1098    error_at(SolveErrorCodeV1::ConflictingFields, message, path)
1099}
1100
1101fn error_at(
1102    code: SolveErrorCodeV1,
1103    message: impl Into<String>,
1104    path: impl Into<String>,
1105) -> SolveErrorEnvelopeV1 {
1106    SolveErrorEnvelopeV1::new(SolveErrorV1::new(code, message).at_path(path))
1107}
1108
1109fn notice(
1110    code: impl Into<String>,
1111    message: impl Into<String>,
1112    path: impl Into<String>,
1113) -> SolveNoticeV1 {
1114    SolveNoticeV1 {
1115        code: code.into(),
1116        message: message.into(),
1117        path: Some(path.into()),
1118    }
1119}
1120
1121fn literal_default(
1122    value: Option<f64>,
1123    default: f64,
1124    path: &'static str,
1125    message: &'static str,
1126    assumptions: &mut Vec<SolveNoticeV1>,
1127) -> f64 {
1128    match value {
1129        Some(value) => value,
1130        None => {
1131            assumptions.push(notice(ASSUMPTION_DEFAULT_APPLIED, message, path));
1132            default
1133        }
1134    }
1135}
1136
1137fn bool_default(
1138    value: Option<bool>,
1139    path: &'static str,
1140    message: &'static str,
1141    assumptions: &mut Vec<SolveNoticeV1>,
1142) -> bool {
1143    match value {
1144        Some(value) => value,
1145        None => {
1146            assumptions.push(notice(ASSUMPTION_DEFAULT_APPLIED, message, path));
1147            false
1148        }
1149    }
1150}
1151
1152fn checked_conversion(value: f64, path: &str, message: &str) -> Result<f64, SolveErrorEnvelopeV1> {
1153    if value.is_finite() {
1154        Ok(value)
1155    } else {
1156        Err(invalid_value(path, message))
1157    }
1158}
1159
1160fn require_finite(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
1161    if value.is_finite() {
1162        Ok(())
1163    } else {
1164        Err(invalid_value(path, "value must be finite"))
1165    }
1166}
1167
1168fn require_positive(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
1169    if value.is_finite() && value > 0.0 {
1170        Ok(())
1171    } else {
1172        Err(invalid_value(
1173            path,
1174            "value must be finite and greater than zero",
1175        ))
1176    }
1177}
1178
1179fn require_non_negative(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
1180    if value.is_finite() && value >= 0.0 {
1181        Ok(())
1182    } else {
1183        Err(invalid_value(path, "value must be finite and non-negative"))
1184    }
1185}
1186
1187fn require_range(
1188    path: &str,
1189    value: f64,
1190    minimum: f64,
1191    maximum: f64,
1192) -> Result<(), SolveErrorEnvelopeV1> {
1193    if value.is_finite() && (minimum..=maximum).contains(&value) {
1194        Ok(())
1195    } else {
1196        Err(invalid_value(
1197            path,
1198            format!("value must be finite and in the inclusive range [{minimum}, {maximum}]"),
1199        ))
1200    }
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::*;
1206
1207    fn minimal_request() -> SolveRequestV1 {
1208        SolveRequestV1 {
1209            schema_version: SchemaVersionV1,
1210            projectile: ProjectileV1 {
1211                mass_kg: 0.01134,
1212                diameter_m: 0.00782,
1213                length_m: None,
1214                drag_model: DragModelV1::G7,
1215                ballistic_coefficient: 0.243,
1216            },
1217            rifle: RifleV1 {
1218                muzzle_velocity_mps: 823.0,
1219                sight_height_m: None,
1220                muzzle_height_m: None,
1221                twist_rate_m_per_turn: None,
1222                twist_direction: None,
1223            },
1224            shot: ShotV1 {
1225                max_range_m: 1_000.0,
1226                zero_distance_m: None,
1227                muzzle_angle_rad: None,
1228                aim_azimuth_rad: None,
1229                shot_azimuth_rad: None,
1230                shooting_angle_rad: None,
1231                cant_angle_rad: None,
1232                target_height_m: None,
1233                ground_threshold_m: None,
1234            },
1235            atmosphere: AtmosphereV1::default(),
1236            wind: WindV1::default(),
1237            solver: SolverV1::default(),
1238            effects: EffectsV1::default(),
1239            sampling: SamplingV1::default(),
1240        }
1241    }
1242
1243    #[test]
1244    fn defaults_are_resolved_in_deterministic_request_field_order() {
1245        let request = minimal_request();
1246        let prepared = prepare_request(&request).expect("valid request");
1247        let code_paths = prepared
1248            .assumptions
1249            .iter()
1250            .map(|notice| (notice.code.as_str(), notice.path.as_deref().unwrap()))
1251            .collect::<Vec<_>>();
1252
1253        assert_eq!(
1254            code_paths,
1255            vec![
1256                (
1257                    ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH,
1258                    "$.projectile.length_m"
1259                ),
1260                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.sight_height_m"),
1261                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.muzzle_height_m"),
1262                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.twist_rate_m_per_turn"),
1263                (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.twist_direction"),
1264                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.muzzle_angle_rad"),
1265                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.aim_azimuth_rad"),
1266                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.shot_azimuth_rad"),
1267                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.shooting_angle_rad"),
1268                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.cant_angle_rad"),
1269                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.target_height_m"),
1270                (ASSUMPTION_DEFAULT_APPLIED, "$.shot.ground_threshold_m"),
1271                (ASSUMPTION_DEFAULT_APPLIED, "$.atmosphere.altitude_m"),
1272                (
1273                    ASSUMPTION_ICAO_STANDARD_TEMPERATURE,
1274                    "$.atmosphere.temperature_k"
1275                ),
1276                (
1277                    ASSUMPTION_ICAO_STANDARD_PRESSURE,
1278                    "$.atmosphere.pressure_pa"
1279                ),
1280                (ASSUMPTION_DEFAULT_APPLIED, "$.atmosphere.relative_humidity"),
1281                (ASSUMPTION_DEFAULT_APPLIED, "$.wind"),
1282                (ASSUMPTION_DEFAULT_APPLIED, "$.solver.method"),
1283                (ASSUMPTION_DEFAULT_APPLIED, "$.solver.time_step_s"),
1284                (ASSUMPTION_DEFAULT_APPLIED, "$.effects.magnus"),
1285                (ASSUMPTION_DEFAULT_APPLIED, "$.effects.coriolis"),
1286                (ASSUMPTION_DEFAULT_APPLIED, "$.effects.enhanced_spin_drift"),
1287                (ASSUMPTION_DEFAULT_APPLIED, "$.sampling.interval_m"),
1288            ]
1289        );
1290        assert_eq!(
1291            prepared.resolved_request.rifle.twist_rate_m_per_turn,
1292            0.3048
1293        );
1294        assert!((prepared.inputs.twist_rate - 12.0).abs() < 1.0e-12);
1295        assert_eq!(prepared.atmosphere.humidity, 50.0);
1296        assert_eq!(prepared.inputs.humidity, 0.5);
1297    }
1298
1299    #[test]
1300    fn explicit_standard_station_values_at_altitude_remain_authoritative() {
1301        let mut request = minimal_request();
1302        request.atmosphere.altitude_m = Some(1_500.0);
1303        request.atmosphere.temperature_k = Some(288.15);
1304        request.atmosphere.pressure_pa = Some(101_325.0);
1305
1306        let prepared = prepare_request(&request).expect("valid explicit station conditions");
1307        assert_eq!(prepared.atmosphere.temperature, 15.0);
1308        assert_eq!(prepared.atmosphere.pressure, 1013.25);
1309        assert_eq!(prepared.resolved_request.atmosphere.temperature_k, 288.15);
1310        assert_eq!(prepared.resolved_request.atmosphere.pressure_pa, 101_325.0);
1311        assert!(!prepared
1312            .assumptions
1313            .iter()
1314            .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_TEMPERATURE
1315                || notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
1316    }
1317
1318    /// MBA-1397: `pressure_reference: "qnh"` reduces an explicit pressure to station pressure
1319    /// and records the reduction as an assumption instead of treating it as authoritative
1320    /// as-is.
1321    #[test]
1322    fn qnh_pressure_reference_reduces_to_station_pressure_and_is_noted() {
1323        let mut request = minimal_request();
1324        request.atmosphere.altitude_m = Some(1_500.0);
1325        request.atmosphere.pressure_pa = Some(103_000.0); // 1030.0 hPa QNH
1326        request.atmosphere.pressure_reference = Some(PressureReferenceV1::Qnh);
1327
1328        let prepared = prepare_request(&request).expect("valid QNH request");
1329        let expected_station_hpa =
1330            crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1_500.0);
1331        assert!((prepared.atmosphere.pressure - expected_station_hpa).abs() < 1e-9);
1332        assert!(
1333            (prepared.resolved_request.atmosphere.pressure_pa - expected_station_hpa * 100.0)
1334                .abs()
1335                < 1e-6
1336        );
1337        // Strictly lower than the raw QNH -- proves the reduction actually happened.
1338        assert!(prepared.resolved_request.atmosphere.pressure_pa < 103_000.0);
1339
1340        assert!(prepared
1341            .assumptions
1342            .iter()
1343            .any(|notice| notice.code == ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE
1344                && notice.path.as_deref() == Some("$.atmosphere.pressure_pa")));
1345        // Must NOT also emit the omitted-pressure notice: this is a present, explicit value.
1346        assert!(!prepared
1347            .assumptions
1348            .iter()
1349            .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
1350    }
1351
1352    /// `pressure_reference: "qnh"` with `pressure_pa` OMITTED must be byte-identical to the
1353    /// absolute default: an omitted pressure always resolves to the ICAO standard station
1354    /// pressure regardless of the declared reference (mathematically the same result as
1355    /// reducing a QNH of exactly the ICAO sea-level standard).
1356    #[test]
1357    fn qnh_pressure_reference_with_omitted_pressure_matches_absolute_default() {
1358        let mut request = minimal_request();
1359        request.atmosphere.altitude_m = Some(1_500.0);
1360        request.atmosphere.pressure_reference = Some(PressureReferenceV1::Qnh);
1361
1362        let prepared = prepare_request(&request).expect("valid request");
1363
1364        let mut absolute_request = minimal_request();
1365        absolute_request.atmosphere.altitude_m = Some(1_500.0);
1366        let absolute_prepared = prepare_request(&absolute_request).expect("valid request");
1367
1368        assert_eq!(
1369            prepared.resolved_request.atmosphere.pressure_pa,
1370            absolute_prepared.resolved_request.atmosphere.pressure_pa
1371        );
1372        assert!(prepared
1373            .assumptions
1374            .iter()
1375            .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
1376        assert!(!prepared
1377            .assumptions
1378            .iter()
1379            .any(|notice| notice.code == ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE));
1380    }
1381
1382    /// `pressure_reference` absent entirely (every request before MBA-1397) must be
1383    /// byte-identical to `Some(Absolute)` -- the default variant is a pure passthrough.
1384    #[test]
1385    fn absent_pressure_reference_matches_explicit_absolute() {
1386        let mut request = minimal_request();
1387        request.atmosphere.altitude_m = Some(1_500.0);
1388        request.atmosphere.pressure_pa = Some(90_000.0);
1389
1390        let mut explicit_absolute = request.clone();
1391        explicit_absolute.atmosphere.pressure_reference = Some(PressureReferenceV1::Absolute);
1392
1393        let prepared = prepare_request(&request).expect("valid request");
1394        let prepared_explicit = prepare_request(&explicit_absolute).expect("valid request");
1395        assert_eq!(
1396            prepared.resolved_request.atmosphere.pressure_pa,
1397            prepared_explicit.resolved_request.atmosphere.pressure_pa
1398        );
1399        assert_eq!(prepared.resolved_request.atmosphere.pressure_pa, 90_000.0);
1400    }
1401
1402    #[test]
1403    fn partial_segmented_wind_maps_units_and_warns_without_extending_coverage() {
1404        let mut request = minimal_request();
1405        request.wind.segments = Some(vec![crate::solve_json::WindSegmentV1 {
1406            until_distance_m: 300.0,
1407            speed_mps: 5.0,
1408            direction_from_rad: std::f64::consts::FRAC_PI_2,
1409            vertical_speed_mps: None,
1410        }]);
1411
1412        let prepared = prepare_request(&request).expect("partial wind is valid");
1413        assert_eq!(prepared.wind_segments.len(), 1);
1414        assert_eq!(prepared.wind_segments[0].speed_kmh, 18.0);
1415        assert_eq!(prepared.wind_segments[0].angle_deg, 90.0);
1416        assert_eq!(prepared.wind_segments[0].until_m, 300.0);
1417        assert_eq!(prepared.wind_segments[0].vertical_mps, 0.0);
1418        assert!(prepared.warnings.iter().any(|notice| {
1419            notice.code == WARNING_PARTIAL_WIND_COVERAGE
1420                && notice.path.as_deref() == Some("$.wind.segments")
1421        }));
1422        assert!(matches!(
1423            prepared.resolved_request.wind,
1424            ResolvedWindV1::Segmented(_)
1425        ));
1426    }
1427
1428    #[test]
1429    fn partial_wind_coverage_includes_a_zero_beyond_the_output_range() {
1430        let mut request = minimal_request();
1431        request.shot.max_range_m = 200.0;
1432        request.shot.zero_distance_m = Some(500.0);
1433        request.wind.segments = Some(vec![crate::solve_json::WindSegmentV1 {
1434            until_distance_m: 300.0,
1435            speed_mps: 5.0,
1436            direction_from_rad: 0.0,
1437            vertical_speed_mps: Some(0.0),
1438        }]);
1439
1440        let prepared = prepare_request(&request).expect("partial zero wind is valid");
1441        assert!(prepared.warnings.iter().any(|notice| {
1442            notice.code == WARNING_PARTIAL_WIND_COVERAGE
1443                && notice.path.as_deref() == Some("$.wind.segments")
1444                && notice.message.contains("500.000000000000 m")
1445        }));
1446    }
1447
1448    #[test]
1449    fn semantic_conflicts_and_invalid_values_keep_exact_paths() {
1450        let mut request = minimal_request();
1451        request.shot.zero_distance_m = Some(100.0);
1452        request.shot.muzzle_angle_rad = Some(0.01);
1453        let error = prepare_request(&request).expect_err("zero and angle conflict");
1454        assert_eq!(error.error.code, SolveErrorCodeV1::ConflictingFields);
1455        assert_eq!(error.error.path(), Some("$.shot"));
1456
1457        let mut request = minimal_request();
1458        request.atmosphere.relative_humidity = Some(1.01);
1459        let error = prepare_request(&request).expect_err("humidity above one");
1460        assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
1461        assert_eq!(error.error.path(), Some("$.atmosphere.relative_humidity"));
1462
1463        let mut request = minimal_request();
1464        request.effects.coriolis = Some(true);
1465        let error = prepare_request(&request).expect_err("Coriolis needs latitude");
1466        assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
1467        assert_eq!(error.error.path(), Some("$.atmosphere.latitude_rad"));
1468
1469        let mut request = minimal_request();
1470        request.shot.zero_distance_m = Some(f64::MAX);
1471        let error = prepare_request(&request).expect_err("zero trial range must remain finite");
1472        assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
1473        assert_eq!(error.error.path(), Some("$.shot.zero_distance_m"));
1474    }
1475
1476    #[test]
1477    fn enum_mappers_cover_every_engine_metadata_variant() {
1478        assert_eq!(drag_model_to_engine(DragModelV1::G1), DragModel::G1);
1479        assert_eq!(drag_model_to_engine(DragModelV1::G6), DragModel::G6);
1480        assert_eq!(drag_model_to_engine(DragModelV1::G7), DragModel::G7);
1481        assert_eq!(drag_model_to_engine(DragModelV1::G8), DragModel::G8);
1482
1483        for (engine, wire) in [
1484            (
1485                TrajectoryTermination::MaxRange,
1486                TerminationReasonV1::MaxRange,
1487            ),
1488            (
1489                TrajectoryTermination::GroundThreshold,
1490                TerminationReasonV1::GroundThreshold,
1491            ),
1492            (
1493                TrajectoryTermination::TimeLimit,
1494                TerminationReasonV1::TimeLimit,
1495            ),
1496            (
1497                TrajectoryTermination::VelocityFloor,
1498                TerminationReasonV1::VelocityFloor,
1499            ),
1500        ] {
1501            assert_eq!(termination_to_wire(engine), wire);
1502        }
1503    }
1504}