1use 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
45pub const ASSUMPTION_DEFAULT_APPLIED: &str = "default_applied";
47pub const ASSUMPTION_ICAO_STANDARD_TEMPERATURE: &str = "icao_standard_temperature";
49pub const ASSUMPTION_ICAO_STANDARD_PRESSURE: &str = "icao_standard_pressure";
51pub const ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE: &str = "qnh_reduced_to_station_pressure";
54pub const ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH: &str = "estimated_projectile_length";
56
57pub const WARNING_PARTIAL_WIND_COVERAGE: &str = "partial_wind_coverage";
59pub const WARNING_EXPERIMENTAL_EFFECT: &str = "experimental_effect";
61pub 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
75pub 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 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 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 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, (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 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 #[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); 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 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 assert!(!prepared
1347 .assumptions
1348 .iter()
1349 .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
1350 }
1351
1352 #[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 #[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}