Skip to main content

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