1use crate::solve_json::{
8 AtmosphereV1, DragModelV1, DragTableKindV1, DragTableV1, EffectsV1, PressureReferenceV1,
9 ProjectileV1, ResolvedAtmosphereV1, ResolvedConstantWindV1, ResolvedEffectsV1,
10 ResolvedProjectileV1, ResolvedRifleV1, ResolvedSamplingV1, ResolvedSegmentedWindV1,
11 ResolvedShotV1, ResolvedSolveRequestV1, ResolvedSolverV1, ResolvedWindSegmentV1,
12 ResolvedWindV1, RifleV1, SampleFlagV1, SamplingV1, SchemaVersionV1, ShotV1, SolveErrorCodeV1,
13 SolveErrorEnvelopeV1, SolveErrorV1, SolveNoticeV1, SolveRequestV1, SolveSuccessV1,
14 SolveSummaryV1, SolverMethodV1, SolverV1, SuccessStatusV1, TerminationReasonV1,
15 TrajectorySampleV1, TwistDirectionV1, WindReferenceV1, WindShearModelV1, WindV1,
16 MAX_SOLVE_JSON_SAMPLES_V1,
17};
18use crate::trajectory_observation::{
19 bracket_param, Bracket, TrajectoryObservation, TrajectoryObservationError,
20 TrajectoryObservationFlag, TrajectoryTermination,
21};
22use crate::wind::WindSegment;
23use crate::{
24 AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, TrajectorySolver,
25 WindConditions,
26};
27
28const DEFAULT_SIGHT_HEIGHT_M: f64 = 0.05;
29const DEFAULT_MUZZLE_HEIGHT_M: f64 = 0.0;
30const DEFAULT_TWIST_RATE_M_PER_TURN: f64 = 0.3048;
31const DEFAULT_ALTITUDE_M: f64 = 0.0;
32const DEFAULT_RELATIVE_HUMIDITY: f64 = 0.5;
33const DEFAULT_TIME_STEP_S: f64 = 0.001;
34const DEFAULT_SAMPLE_INTERVAL_M: f64 = 10.0;
35const DEFAULT_GROUND_THRESHOLD_M: f64 = -100.0;
36
37const MIN_ICAO_ALTITUDE_M: f64 = -5_000.0;
38const MAX_ICAO_ALTITUDE_M: f64 = 84_000.0;
39const METERS_PER_INCH: f64 = 0.0254;
40const KILOMETRES_PER_METRE: f64 = 0.001;
41const SECONDS_PER_HOUR: f64 = 3_600.0;
42pub(crate) const PASCALS_PER_HECTOPASCAL: f64 = 100.0;
43pub(crate) const KELVIN_OFFSET_C: f64 = 273.15;
44const KG_PER_GRAIN: f64 = 0.000_064_798_91;
45
46pub const ASSUMPTION_DEFAULT_APPLIED: &str = "default_applied";
48pub const ASSUMPTION_ICAO_STANDARD_TEMPERATURE: &str = "icao_standard_temperature";
50pub const ASSUMPTION_ICAO_STANDARD_PRESSURE: &str = "icao_standard_pressure";
52pub const ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE: &str = "qnh_reduced_to_station_pressure";
55pub const ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH: &str = "estimated_projectile_length";
57
58pub const WARNING_PARTIAL_WIND_COVERAGE: &str = "partial_wind_coverage";
60pub const WARNING_EXPERIMENTAL_EFFECT: &str = "experimental_effect";
62pub const WARNING_RK45_TIME_STEP_IGNORED: &str = "rk45_time_step_ignored";
64pub const WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED: &str =
69 "zero_distance_elevation_not_resolved";
70pub const WARNING_BC5D_DRAG_MODEL_COERCED: &str = "bc5d_drag_model_coerced";
74pub const WARNING_WIND_SHEAR_MODEL_NOT_MODELED: &str = "wind_shear_model_not_modeled";
81
82pub const WARNING_AERODYNAMIC_JUMP_ASSUMED_GEOMETRY: &str = "aerodynamic_jump_assumed_geometry";
107
108pub const WARNING_SPIN_EFFECT_ASSUMED_TWIST_RATE: &str = "spin_effect_assumed_twist_rate";
139
140pub const WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE: &str = "stability_factor_assumed_twist_rate";
165
166#[derive(Debug, Clone, Copy)]
175struct SuppliedGeometry {
176 twist_rate: bool,
177 projectile_length: bool,
178}
179
180#[derive(Debug)]
181pub(crate) struct PreparedSolveV1 {
182 pub(crate) resolved_request: ResolvedSolveRequestV1,
183 assumptions: Vec<SolveNoticeV1>,
184 warnings: Vec<SolveNoticeV1>,
185 pub(crate) inputs: BallisticInputs,
186 pub(crate) wind: WindConditions,
187 pub(crate) wind_segments: Vec<WindSegment>,
188 pub(crate) atmosphere: AtmosphericConditions,
189}
190
191pub fn solve_v1(request: SolveRequestV1) -> Result<SolveSuccessV1, SolveErrorEnvelopeV1> {
197 let mut prepared = prepare_request(&request)?;
198 let max_range_m = prepared.resolved_request.shot.max_range_m;
199 let time_step_s = prepared.resolved_request.solver.time_step_s;
200 let sample_interval_m = prepared.resolved_request.sampling.interval_m;
201 let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
202 let target_height_m = prepared.resolved_request.shot.target_height_m;
203 let enhanced_spin_drift = prepared.resolved_request.effects.enhanced_spin_drift;
204
205 let summary_inputs = prepared.inputs.clone();
208 let station_temperature_c = prepared.atmosphere.temperature;
209 let station_pressure_hpa = prepared.atmosphere.pressure;
210
211 let (solver, effective_angle) = build_zeroed_solver(
212 prepared.inputs,
213 prepared.wind,
214 prepared.atmosphere,
215 prepared.wind_segments,
216 max_range_m,
217 time_step_s,
218 zero_distance_m,
219 target_height_m,
220 request.shot.muzzle_angle_rad,
221 )?;
222 if let Some(angle) = effective_angle {
228 prepared.resolved_request.shot.muzzle_angle_rad = angle;
229 }
230
231 let result = solver.solve().map_err(solve_failed)?;
232 let observations = result
233 .sample_observations(sample_interval_m, MAX_SOLVE_JSON_SAMPLES_V1)
234 .map_err(map_observation_error)?;
235
236 let drops_scale_denominator = match request.shot.drops_reference.unwrap_or_default() {
242 crate::solve_json::DropsReferenceV1::Los => 1.0,
243 crate::solve_json::DropsReferenceV1::Target => {
244 prepared.resolved_request.shot.shooting_angle_rad.cos()
245 }
246 };
247 let samples = observations
248 .iter()
249 .cloned()
250 .map(|mut observation| {
251 observation.drop_m /= drops_scale_denominator;
252 observation_to_wire(observation)
253 })
254 .collect::<Vec<_>>();
255 let terminal = observations.last().ok_or_else(|| {
256 internal_error("trajectory observation sampling returned no terminal observation")
257 })?;
258
259 let maximum_height_m =
260 maximum_world_height_m(&result, prepared.resolved_request.shot.shooting_angle_rad)?;
261
262 let stability = crate::spin_drift::effective_sg_from_inputs(
263 &summary_inputs,
264 station_temperature_c,
265 station_pressure_hpa,
266 );
267 let stability_factor = if stability.is_finite() && stability >= 0.0 {
268 Some(stability)
269 } else {
270 None
271 };
272
273 if request.rifle.twist_rate_m_per_turn.is_none() && stability_factor.is_some() {
287 prepared.warnings.push(notice(
288 WARNING_STABILITY_FACTOR_ASSUMED_TWIST_RATE,
289 "summary.stability_factor is computed from the twist on every solve, but \
290 rifle.twist_rate_m_per_turn was not supplied. The reported Sg is the Sg of the \
291 assumed 1:12 barrel, not of a barrel this request specified. The trajectory \
292 itself is unaffected unless a spin-driven effect is also enabled.",
293 "$.rifle.twist_rate_m_per_turn",
294 ));
295 }
296 let spin_drift_m = if enhanced_spin_drift {
297 stability_factor
298 .map(|sg| {
299 crate::spin_drift::litz_drift_meters(
300 sg,
301 terminal.time_s,
302 summary_inputs.is_twist_right,
303 )
304 })
305 .filter(|drift| drift.is_finite())
306 } else {
307 None
308 };
309
310 if enhanced_spin_drift && stability_factor.is_some() && spin_drift_m.is_none() {
311 return Err(internal_error(
312 "enhanced spin-drift summary calculation produced a non-finite value",
313 ));
314 }
315
316 let equivalent_horizontal_range_m = match (
322 zero_distance_m,
323 prepared.resolved_request.shot.shooting_angle_rad != 0.0,
324 ) {
325 (Some(zero_m), true) => solver.equivalent_horizontal_range(terminal.distance_m, zero_m),
326 _ => None,
327 };
328
329 let aerodynamic_jump_moa = result
334 .aerodynamic_jump
335 .as_ref()
336 .map(|components| components.vertical_jump_moa)
337 .filter(|moa| moa.is_finite());
338
339 let summary = SolveSummaryV1 {
340 actual_range_m: terminal.distance_m,
341 maximum_height_m,
342 time_of_flight_s: terminal.time_s,
343 terminal_speed_mps: terminal.speed_mps,
344 terminal_energy_j: terminal.energy_j,
345 stability_factor,
346 spin_drift_m,
347 equivalent_horizontal_range_m,
348 aerodynamic_jump_moa,
349 termination: termination_to_wire(result.termination),
350 };
351
352 let reticle_hold = match request.reticle.as_ref() {
355 Some(reticle) => Some(resolve_reticle_hold(reticle, &samples)?),
356 None => None,
357 };
358
359 let success = SolveSuccessV1 {
360 schema_version: SchemaVersionV1,
361 engine_version: env!("CARGO_PKG_VERSION").to_owned(),
362 status: SuccessStatusV1::Ok,
363 resolved_request: prepared.resolved_request,
364 assumptions: prepared.assumptions,
365 warnings: prepared.warnings,
366 summary,
367 samples,
368 reticle_hold,
369 };
370 success.validate_for_serialization()?;
371 Ok(success)
372}
373
374#[allow(clippy::too_many_arguments)]
401pub(crate) fn build_zeroed_solver(
402 prepared_inputs: BallisticInputs,
403 wind: WindConditions,
404 atmosphere: AtmosphericConditions,
405 wind_segments: Vec<WindSegment>,
406 max_range_m: f64,
407 time_step_s: f64,
408 zero_distance_m: Option<f64>,
409 target_height_m: f64,
410 explicit_angle: Option<f64>,
411) -> Result<(TrajectorySolver, Option<f64>), SolveErrorEnvelopeV1> {
412 let mut solver =
413 TrajectorySolver::new_with_resolved_station_atmosphere(prepared_inputs, wind, atmosphere);
414 solver.set_max_range(max_range_m);
415 solver.set_time_step(time_step_s);
416 if !wind_segments.is_empty() {
417 solver.set_wind_segments(wind_segments);
418 }
419
420 let mut effective_angle = None;
421 match (zero_distance_m, explicit_angle) {
422 (Some(distance_m), None) => {
423 let angle = solver
424 .calculate_and_set_zero_angle(
425 distance_m,
426 target_height_m,
427 crate::cli_api::ZeroTargetFrame::WorldVertical,
428 )
429 .map_err(solve_failed)?;
430 if !angle.is_finite() {
431 return Err(solve_failed_message(
432 "zero-angle calculation returned a non-finite effective muzzle angle",
433 ));
434 }
435 effective_angle = Some(angle);
436 }
437 (Some(distance_m), Some(_)) => solver.apply_windage_zero_bias(distance_m),
438 (None, _) => {}
439 }
440
441 Ok((solver, effective_angle))
442}
443
444pub(crate) fn prepare_request(
452 request: &SolveRequestV1,
453) -> Result<PreparedSolveV1, SolveErrorEnvelopeV1> {
454 let mut assumptions = Vec::new();
455 let mut warnings = Vec::new();
456
457 let (projectile, bullet_length_m) = resolve_projectile(&request.projectile, &mut assumptions)?;
458 let rifle = resolve_rifle(&request.rifle, &mut assumptions)?;
459 let shot = resolve_shot(&request.shot, &rifle, &mut assumptions, &mut warnings)?;
460 let atmosphere = resolve_atmosphere(&request.atmosphere, &mut assumptions)?;
461 let wind_coverage_distance_m = shot.zero_distance_m.unwrap_or(0.0).max(shot.max_range_m);
462 let (resolved_wind, wind, wind_segments) = resolve_wind(
463 &request.wind,
464 wind_coverage_distance_m,
465 request.shot.shot_azimuth_rad,
469 &mut assumptions,
470 &mut warnings,
471 )?;
472 let solver = resolve_solver(&request.solver, &mut assumptions, &mut warnings)?;
473 let effects = resolve_effects(
474 &request.effects,
475 atmosphere.latitude_rad,
476 SuppliedGeometry {
477 twist_rate: request.rifle.twist_rate_m_per_turn.is_some(),
478 projectile_length: request.projectile.length_m.is_some(),
479 },
480 &mut assumptions,
481 &mut warnings,
482 )?;
483 let sampling = resolve_sampling(&request.sampling, &mut assumptions)?;
484
485 if matches!(resolved_wind, ResolvedWindV1::Segmented(_))
493 && effects
494 .wind_shear_model
495 .is_some_and(WindShearModelV1::is_enabled)
496 {
497 return Err(conflicting_fields(
498 "$.effects.wind_shear_model",
499 "wind.segments cannot be combined with effects.wind_shear_model (downrange \
500 segments plus altitude shear is not yet a defined model)",
501 ));
502 }
503
504 if projectile.drag_table.is_some()
508 && request
509 .corrections
510 .as_ref()
511 .is_some_and(|corrections| corrections.bc5d_table_path.is_some())
512 {
513 return Err(conflicting_fields(
514 "$.corrections.bc5d_table_path",
515 "corrections.bc5d_table_path cannot be combined with projectile.drag_table (BC5D \
516 corrects a G1/G7 ballistic coefficient; a drag table replaces the G-curve)",
517 ));
518 }
519
520 if projectile.bc_segments.is_some()
528 && projectile
529 .drag_table
530 .as_ref()
531 .is_some_and(|table| table.kind == DragTableKindV1::Projectile)
532 {
533 return Err(conflicting_fields(
534 "$.projectile.bc_segments",
535 "projectile.bc_segments cannot be combined with a projectile.drag_table of kind \
536 \"projectile\": that curve is the projectile's own Cd and has no BC for the bands \
537 to change",
538 ));
539 }
540 if projectile.bc_segments.is_some()
541 && request
542 .corrections
543 .as_ref()
544 .is_some_and(|corrections| corrections.bc5d_table_path.is_some())
545 {
546 return Err(conflicting_fields(
547 "$.projectile.bc_segments",
548 "projectile.bc_segments cannot be combined with corrections.bc5d_table_path: the \
549 correction table builds its own BC ladder from ballistic_coefficient",
550 ));
551 }
552
553 let resolved_request = ResolvedSolveRequestV1 {
554 schema_version: SchemaVersionV1,
555 projectile,
556 rifle,
557 shot,
558 atmosphere,
559 wind: resolved_wind,
560 solver,
561 effects,
562 sampling,
563 reticle: request.reticle.clone(),
567 corrections: request.corrections.clone(),
571 };
572
573 let temperature_c = checked_conversion(
574 resolved_request.atmosphere.temperature_k - KELVIN_OFFSET_C,
575 "$.atmosphere.temperature_k",
576 "temperature conversion to Celsius overflowed",
577 )?;
578 let pressure_hpa = checked_conversion(
579 resolved_request.atmosphere.pressure_pa / PASCALS_PER_HECTOPASCAL,
580 "$.atmosphere.pressure_pa",
581 "pressure conversion to hectopascals overflowed",
582 )?;
583 let humidity_percent = checked_conversion(
584 resolved_request.atmosphere.relative_humidity * 100.0,
585 "$.atmosphere.relative_humidity",
586 "relative-humidity conversion to percent overflowed",
587 )?;
588 let twist_rate_inches = checked_conversion(
589 resolved_request.rifle.twist_rate_m_per_turn / METERS_PER_INCH,
590 "$.rifle.twist_rate_m_per_turn",
591 "twist-rate conversion to inches per turn overflowed",
592 )?;
593 let caliber_inches = checked_conversion(
594 resolved_request.projectile.diameter_m / METERS_PER_INCH,
595 "$.projectile.diameter_m",
596 "diameter conversion to inches overflowed",
597 )?;
598 let weight_grains = checked_conversion(
599 resolved_request.projectile.mass_kg / KG_PER_GRAIN,
600 "$.projectile.mass_kg",
601 "mass conversion to grains overflowed",
602 )?;
603 let latitude_degrees = resolved_request
604 .atmosphere
605 .latitude_rad
606 .map(f64::to_degrees)
607 .map(|value| {
608 checked_conversion(
609 value,
610 "$.atmosphere.latitude_rad",
611 "latitude conversion to degrees overflowed",
612 )
613 })
614 .transpose()?;
615
616 let (wind_speed, wind_direction) = match &resolved_request.wind {
617 ResolvedWindV1::Constant(constant) => (constant.speed_mps, constant.direction_from_rad),
618 ResolvedWindV1::Segmented(_) => (0.0, 0.0),
619 };
620
621 let mut inputs = BallisticInputs {
622 bc_value: resolved_request.projectile.ballistic_coefficient,
623 bc_type: drag_model_to_engine(resolved_request.projectile.drag_model),
624 bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
628 bullet_mass: resolved_request.projectile.mass_kg,
629 muzzle_velocity: resolved_request.rifle.muzzle_velocity_mps,
630 bullet_diameter: resolved_request.projectile.diameter_m,
631 bullet_length: bullet_length_m,
632 muzzle_angle: resolved_request.shot.muzzle_angle_rad,
633 target_distance: resolved_request.shot.max_range_m,
634 azimuth_angle: resolved_request.shot.aim_azimuth_rad,
635 shot_azimuth: resolved_request.shot.shot_azimuth_rad,
636 shooting_angle: resolved_request.shot.shooting_angle_rad,
637 cant_angle: resolved_request.shot.cant_angle_rad,
638 sight_height: resolved_request.rifle.sight_height_m,
639 sight_offset_lateral_m: request.rifle.sight_offset_lateral_m.unwrap_or(0.0),
644 muzzle_height: resolved_request.rifle.muzzle_height_m,
645 target_height: resolved_request.shot.target_height_m,
646 zero_poi_vertical_m: request.shot.zero_poi_up_m.unwrap_or(0.0),
652 zero_poi_horizontal_m: request.shot.zero_poi_right_m.unwrap_or(0.0),
653 ground_threshold: resolved_request.shot.ground_threshold_m,
654 altitude: resolved_request.atmosphere.altitude_m,
655 temperature: temperature_c,
656 pressure: pressure_hpa,
657 humidity: resolved_request.atmosphere.relative_humidity,
658 latitude: latitude_degrees,
659 wind_speed,
660 wind_angle: wind_direction,
661 twist_rate: twist_rate_inches,
662 is_twist_right: resolved_request.rifle.twist_direction == TwistDirectionV1::Right,
663 caliber_inches,
664 weight_grains,
665 manufacturer: None,
666 bullet_model: None,
667 bullet_id: None,
668 bullet_cluster: None,
669 use_rk4: resolved_request.solver.method != SolverMethodV1::Euler,
670 use_adaptive_rk45: resolved_request.solver.method == SolverMethodV1::Rk45,
671 enable_advanced_effects: resolved_request.effects.magnus
672 || resolved_request.effects.coriolis,
673 enable_magnus: resolved_request.effects.magnus,
674 enable_coriolis: resolved_request.effects.coriolis,
675 use_powder_sensitivity: false,
676 powder_temp_sensitivity: 0.0,
677 powder_temp: temperature_c,
678 powder_temp_curve: None,
679 powder_curve_temp_c: None,
680 tipoff_yaw: 0.0,
681 cd_delta2: 7.5,
682 tipoff_decay_distance: 50.0,
683 use_bc_segments: false,
684 bc_segments: None,
685 bc_segments_data: None,
686 use_enhanced_spin_drift: resolved_request.effects.enhanced_spin_drift,
687 use_form_factor: false,
688 enable_wind_shear: resolved_request
705 .effects
706 .wind_shear_model
707 .is_some_and(WindShearModelV1::is_enabled),
708 wind_shear_model: resolved_request
709 .effects
710 .wind_shear_model
711 .unwrap_or_default()
712 .as_engine_str()
713 .to_owned(),
714 enable_trajectory_sampling: false,
715 sample_interval: resolved_request.sampling.interval_m,
716 drops_reference: match request.shot.drops_reference.unwrap_or_default() {
725 crate::solve_json::DropsReferenceV1::Los => crate::cli_api::DropsReference::Los,
726 crate::solve_json::DropsReferenceV1::Target => crate::cli_api::DropsReference::Target,
727 },
728 enable_pitch_damping: false,
729 enable_precession_nutation: false,
730 enable_aerodynamic_jump: resolved_request.effects.aerodynamic_jump.unwrap_or(false),
733 use_cluster_bc: false,
734 custom_drag_table: None,
738 cd_scale: 1.0,
739 custom_drag_reference: false,
740 bc_type_str: None,
741 };
742
743 if let Some(drag_table) = &resolved_request.projectile.drag_table {
744 install_drag_table(
745 drag_table,
746 resolved_request.projectile.ballistic_coefficient,
747 resolved_request.projectile.bc_segments.is_some(),
748 &mut inputs,
749 )?;
750 }
751
752 apply_bc5d_correction(request, &resolved_request, &mut inputs, &mut warnings)?;
753 apply_bc_segments(&resolved_request, &mut inputs)?;
754
755 let atmosphere = AtmosphericConditions {
756 temperature: temperature_c,
757 pressure: pressure_hpa,
758 humidity: humidity_percent,
759 altitude: resolved_request.atmosphere.altitude_m,
760 };
761
762 Ok(PreparedSolveV1 {
763 resolved_request,
764 assumptions,
765 warnings,
766 inputs,
767 wind,
768 wind_segments,
769 atmosphere,
770 })
771}
772
773fn install_drag_table(
795 drag_table: &DragTableV1,
796 ballistic_coefficient: f64,
797 has_bc_ladder: bool,
798 inputs: &mut BallisticInputs,
799) -> Result<(), SolveErrorEnvelopeV1> {
800 let (mach, cd): (Vec<f64>, Vec<f64>) = drag_table
801 .points
802 .iter()
803 .map(|point| (point.mach, point.cd))
804 .unzip();
805 let table = crate::drag::DragTable::try_new(mach, cd)
808 .map_err(|message| invalid_value("$.projectile.drag_table.points", message))?;
809 inputs.cd_scale = match drag_table.kind {
810 DragTableKindV1::Projectile => 1.0,
811 DragTableKindV1::Reference if has_bc_ladder => {
816 inputs.custom_drag_reference = true;
817 1.0
818 }
819 DragTableKindV1::Reference => {
820 let sectional_density = inputs.sectional_density_lb_in2().ok_or_else(|| {
821 internal_error("projectile mass and diameter gave no sectional density")
822 })?;
823 sectional_density / ballistic_coefficient
824 }
825 };
826 inputs.custom_drag_table = Some(table);
827 Ok(())
828}
829
830fn apply_bc5d_correction(
844 request: &SolveRequestV1,
845 resolved_request: &ResolvedSolveRequestV1,
846 inputs: &mut BallisticInputs,
847 warnings: &mut Vec<SolveNoticeV1>,
848) -> Result<(), SolveErrorEnvelopeV1> {
849 let Some(table_path) = request
850 .corrections
851 .as_ref()
852 .and_then(|corrections| corrections.bc5d_table_path.as_deref())
853 else {
854 return Ok(());
855 };
856
857 #[cfg(target_arch = "wasm32")]
858 {
859 let _ = (table_path, resolved_request, inputs, warnings);
860 Err(invalid_value(
861 "$.corrections.bc5d_table_path",
862 "bc5d_table_path is not supported on this build (no filesystem access); \
863 load table bytes through the WASM loadBc5dTable API instead",
864 ))
865 }
866
867 #[cfg(not(target_arch = "wasm32"))]
868 {
869 use crate::bc_table_5d::{path_cache, Bc5dError};
870
871 let shot_caliber_in = resolved_request.projectile.diameter_m / 0.0254;
878 let table = path_cache::load_verified_for_caliber(
879 std::path::Path::new(table_path),
880 shot_caliber_in,
881 )
882 .map_err(|error| {
883 let code = match error {
884 Bc5dError::IoError(_) => SolveErrorCodeV1::IoError,
885 _ => SolveErrorCodeV1::InvalidValue,
886 };
887 SolveErrorEnvelopeV1::new(
888 SolveErrorV1::new(code, format!("bc5d_table_path: {error}"))
889 .at_path("$.corrections.bc5d_table_path"),
890 )
891 })?;
892
893 let drag_type = match resolved_request.projectile.drag_model {
896 DragModelV1::G7 => "G7",
897 DragModelV1::G1 => "G1",
898 other => {
899 warnings.push(SolveNoticeV1 {
900 code: WARNING_BC5D_DRAG_MODEL_COERCED.to_owned(),
901 message: format!(
902 "BC5D correction tables support G1/G7 only; treating drag model \
903 {other:?} as G1 for the table lookup"
904 ),
905 path: Some("$.corrections.bc5d_table_path".to_owned()),
906 });
907 "G1"
908 }
909 };
910
911 let base_bc = resolved_request.projectile.ballistic_coefficient;
914 let weight_grains = inputs.weight_grains;
915 let muzzle_fps = resolved_request.rifle.muzzle_velocity_mps / 0.3048;
916
917 if let Some(segments) =
918 table.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_fps))
919 {
920 inputs.bc_value =
923 table.get_effective_bc(weight_grains, base_bc, muzzle_fps, muzzle_fps, drag_type);
924 inputs.use_bc_segments = true;
925 inputs.bc_segments_data = Some(segments);
926 }
927 Ok(())
930 }
931}
932
933fn apply_bc_segments(
948 resolved_request: &ResolvedSolveRequestV1,
949 inputs: &mut BallisticInputs,
950) -> Result<(), SolveErrorEnvelopeV1> {
951 let Some(bc_segments) = &resolved_request.projectile.bc_segments else {
952 return Ok(());
953 };
954
955 let segments: Vec<crate::BCSegmentData> = bc_segments
956 .iter()
957 .map(|seg| crate::BCSegmentData {
958 velocity_min: seg.velocity_min_mps / 0.3048,
959 velocity_max: seg.velocity_max_mps / 0.3048,
960 bc_value: seg.ballistic_coefficient,
961 })
962 .collect();
963
964 inputs.use_bc_segments = true;
965 inputs.bc_segments_data = Some(segments);
966 Ok(())
967}
968
969fn resolve_projectile(
970 projectile: &ProjectileV1,
971 assumptions: &mut Vec<SolveNoticeV1>,
972) -> Result<(ResolvedProjectileV1, f64), SolveErrorEnvelopeV1> {
973 require_positive("$.projectile.mass_kg", projectile.mass_kg)?;
974 require_positive("$.projectile.diameter_m", projectile.diameter_m)?;
975 require_positive(
976 "$.projectile.ballistic_coefficient",
977 projectile.ballistic_coefficient,
978 )?;
979 if let Some(length_m) = projectile.length_m {
980 require_positive("$.projectile.length_m", length_m)?;
981 }
982 if let Some(drag_table) = &projectile.drag_table {
983 crate::solve_json::validate_drag_table_v1(drag_table)?;
984 }
985 if let Some(bc_segments) = &projectile.bc_segments {
986 crate::solve_json::validate_bc_segments_v1(bc_segments, projectile.ballistic_coefficient)?;
987 }
988
989 let bullet_length_m = match projectile.length_m {
990 Some(length_m) => length_m,
991 None => {
992 let estimated = crate::stability::estimate_bullet_length_m(
993 projectile.diameter_m,
994 projectile.mass_kg,
995 );
996 if !estimated.is_finite() || estimated <= 0.0 {
997 return Err(invalid_value(
998 "$.projectile",
999 "projectile mass and diameter could not produce a finite positive length estimate",
1000 ));
1001 }
1002 assumptions.push(notice(
1003 ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH,
1004 format!(
1005 "Projectile length was omitted; the engine estimated {estimated:.12} m from mass and diameter."
1006 ),
1007 "$.projectile.length_m",
1008 ));
1009 estimated
1010 }
1011 };
1012
1013 Ok((
1014 ResolvedProjectileV1 {
1015 mass_kg: projectile.mass_kg,
1016 diameter_m: projectile.diameter_m,
1017 length_m: projectile.length_m,
1018 drag_model: projectile.drag_model,
1019 ballistic_coefficient: projectile.ballistic_coefficient,
1020 drag_table: projectile.drag_table.clone(),
1021 bc_segments: projectile.bc_segments.clone(),
1022 },
1023 bullet_length_m,
1024 ))
1025}
1026
1027fn resolve_rifle(
1028 rifle: &RifleV1,
1029 assumptions: &mut Vec<SolveNoticeV1>,
1030) -> Result<ResolvedRifleV1, SolveErrorEnvelopeV1> {
1031 require_positive("$.rifle.muzzle_velocity_mps", rifle.muzzle_velocity_mps)?;
1032 if let Some(value) = rifle.sight_height_m {
1033 require_non_negative("$.rifle.sight_height_m", value)?;
1034 }
1035 if let Some(value) = rifle.muzzle_height_m {
1036 require_finite("$.rifle.muzzle_height_m", value)?;
1037 }
1038 if let Some(value) = rifle.twist_rate_m_per_turn {
1039 require_positive("$.rifle.twist_rate_m_per_turn", value)?;
1040 }
1041 if let Some(value) = rifle.sight_offset_lateral_m {
1048 require_finite("$.rifle.sight_offset_lateral_m", value)?;
1049 if value.abs() >= 0.5 {
1050 return Err(invalid_value(
1051 "$.rifle.sight_offset_lateral_m",
1052 "lateral sight offset must be smaller than 0.5 m in magnitude (the \
1053 sight-to-bore mount offset, in meters)",
1054 ));
1055 }
1056 }
1057
1058 let sight_height_m = literal_default(
1059 rifle.sight_height_m,
1060 DEFAULT_SIGHT_HEIGHT_M,
1061 "$.rifle.sight_height_m",
1062 "Sight height defaulted to 0.05 m.",
1063 assumptions,
1064 );
1065 let muzzle_height_m = literal_default(
1066 rifle.muzzle_height_m,
1067 DEFAULT_MUZZLE_HEIGHT_M,
1068 "$.rifle.muzzle_height_m",
1069 "Muzzle height defaulted to 0 m.",
1070 assumptions,
1071 );
1072 let twist_rate_m_per_turn = literal_default(
1073 rifle.twist_rate_m_per_turn,
1074 DEFAULT_TWIST_RATE_M_PER_TURN,
1075 "$.rifle.twist_rate_m_per_turn",
1076 "Twist rate defaulted to 0.3048 m per turn.",
1077 assumptions,
1078 );
1079 let twist_direction = match rifle.twist_direction {
1080 Some(direction) => direction,
1081 None => {
1082 assumptions.push(notice(
1083 ASSUMPTION_DEFAULT_APPLIED,
1084 "Twist direction defaulted to right-hand.",
1085 "$.rifle.twist_direction",
1086 ));
1087 TwistDirectionV1::Right
1088 }
1089 };
1090
1091 Ok(ResolvedRifleV1 {
1092 muzzle_velocity_mps: rifle.muzzle_velocity_mps,
1093 sight_height_m,
1094 muzzle_height_m,
1095 twist_rate_m_per_turn,
1096 twist_direction,
1097 sight_offset_lateral_m: rifle.sight_offset_lateral_m,
1100 })
1101}
1102
1103fn resolve_shot(
1104 shot: &ShotV1,
1105 rifle: &ResolvedRifleV1,
1106 assumptions: &mut Vec<SolveNoticeV1>,
1107 warnings: &mut Vec<SolveNoticeV1>,
1108) -> Result<ResolvedShotV1, SolveErrorEnvelopeV1> {
1109 let muzzle_height_m = rifle.muzzle_height_m;
1110 require_positive("$.shot.max_range_m", shot.max_range_m)?;
1111 if let Some(value) = shot.zero_distance_m {
1112 require_positive("$.shot.zero_distance_m", value)?;
1113 if !(value * 2.0).is_finite() {
1114 return Err(invalid_value(
1115 "$.shot.zero_distance_m",
1116 "zero distance is too large to construct a finite trial trajectory range",
1117 ));
1118 }
1119 }
1120 if let Some(value) = shot.muzzle_angle_rad {
1121 require_finite("$.shot.muzzle_angle_rad", value)?;
1122 }
1123 for (path, value) in [
1138 ("$.shot.aim_azimuth_rad", shot.aim_azimuth_rad),
1139 ("$.shot.shot_azimuth_rad", shot.shot_azimuth_rad),
1140 ("$.shot.shooting_angle_rad", shot.shooting_angle_rad),
1141 ("$.shot.cant_angle_rad", shot.cant_angle_rad),
1142 ("$.shot.target_height_m", shot.target_height_m),
1143 ("$.shot.ground_threshold_m", shot.ground_threshold_m),
1144 ] {
1145 if let Some(value) = value {
1146 require_finite(path, value)?;
1147 }
1148 }
1149
1150 for (path, value) in [
1158 ("$.shot.zero_poi_up_m", shot.zero_poi_up_m),
1159 ("$.shot.zero_poi_right_m", shot.zero_poi_right_m),
1160 ] {
1161 if let Some(value) = value {
1162 require_finite(path, value)?;
1163 if value.abs() >= 1.0 {
1164 return Err(invalid_value(
1165 path,
1166 "zero POI offset must be smaller than 1.0 m in magnitude (a linear \
1167 point-of-impact offset at the zero range, in meters)",
1168 ));
1169 }
1170 }
1171 }
1172
1173 let elevation_search_runs = shot.zero_distance_m.is_some() && shot.muzzle_angle_rad.is_none();
1177
1178 let muzzle_angle_rad = match (shot.zero_distance_m, shot.muzzle_angle_rad) {
1179 (Some(_), Some(angle)) => {
1186 warnings.push(notice(
1187 WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED,
1188 "muzzle_angle_rad was supplied together with zero_distance_m: the elevation \
1189 was used directly and the elevation search did not run. zero_distance_m is \
1190 retained in resolved_request and still affects the windage-convergence bias \
1191 (sight_offset_lateral_m / zero_poi_right_m), summary.equivalent_horizontal_range_m, \
1192 and downrange wind-coverage validation.",
1193 "$.shot.zero_distance_m",
1194 ));
1195 angle
1196 }
1197 (None, Some(angle)) => angle,
1198 (Some(_), None) => 0.0, (None, None) => {
1200 assumptions.push(notice(
1201 ASSUMPTION_DEFAULT_APPLIED,
1202 "Muzzle angle defaulted to 0 rad.",
1203 "$.shot.muzzle_angle_rad",
1204 ));
1205 0.0
1206 }
1207 };
1208 let aim_azimuth_rad = literal_default(
1209 shot.aim_azimuth_rad,
1210 0.0,
1211 "$.shot.aim_azimuth_rad",
1212 "Aim azimuth defaulted to 0 rad.",
1213 assumptions,
1214 );
1215 let shot_azimuth_rad = literal_default(
1216 shot.shot_azimuth_rad,
1217 0.0,
1218 "$.shot.shot_azimuth_rad",
1219 "Shot azimuth defaulted to 0 rad (north).",
1220 assumptions,
1221 );
1222 let shooting_angle_rad = literal_default(
1223 shot.shooting_angle_rad,
1224 0.0,
1225 "$.shot.shooting_angle_rad",
1226 "Shooting angle defaulted to 0 rad.",
1227 assumptions,
1228 );
1229 let cant_angle_rad = literal_default(
1230 shot.cant_angle_rad,
1231 0.0,
1232 "$.shot.cant_angle_rad",
1233 "Cant angle defaulted to 0 rad.",
1234 assumptions,
1235 );
1236 let target_height_m = if elevation_search_runs {
1275 match shot.target_height_m {
1276 Some(value) => value,
1277 None => {
1278 let line_of_sight_m = muzzle_height_m + rifle.sight_height_m;
1279 assumptions.push(notice(
1280 ASSUMPTION_DEFAULT_APPLIED,
1281 format!(
1282 "Target height defaulted to the line of sight, {line_of_sight_m} m above \
1283 the local ground datum (muzzle_height_m + sight_height_m), so the solved \
1284 elevation crosses the line of sight at zero_distance_m. Supply \
1285 target_height_m explicitly -- 0 included -- to zero against the ground \
1286 datum instead."
1287 ),
1288 "$.shot.target_height_m",
1289 ));
1290 line_of_sight_m
1291 }
1292 }
1293 } else {
1294 literal_default(
1295 shot.target_height_m,
1296 0.0,
1297 "$.shot.target_height_m",
1298 "Target height defaulted to 0 m above the local ground datum.",
1299 assumptions,
1300 )
1301 };
1302 let ground_threshold_m = literal_default(
1303 shot.ground_threshold_m,
1304 DEFAULT_GROUND_THRESHOLD_M,
1305 "$.shot.ground_threshold_m",
1306 "Ground threshold defaulted to -100 m.",
1307 assumptions,
1308 );
1309 if ground_threshold_m >= muzzle_height_m {
1310 return Err(invalid_value(
1311 "$.shot.ground_threshold_m",
1312 "ground threshold must be below the resolved muzzle height",
1313 ));
1314 }
1315
1316 Ok(ResolvedShotV1 {
1317 max_range_m: shot.max_range_m,
1318 zero_distance_m: shot.zero_distance_m,
1319 muzzle_angle_rad,
1320 aim_azimuth_rad,
1321 shot_azimuth_rad,
1322 shooting_angle_rad,
1323 cant_angle_rad,
1324 target_height_m,
1325 ground_threshold_m,
1326 zero_poi_up_m: shot.zero_poi_up_m,
1329 zero_poi_right_m: shot.zero_poi_right_m,
1330 drops_reference: shot.drops_reference,
1331 })
1332}
1333
1334pub(crate) fn resolve_atmosphere(
1339 atmosphere: &AtmosphereV1,
1340 assumptions: &mut Vec<SolveNoticeV1>,
1341) -> Result<ResolvedAtmosphereV1, SolveErrorEnvelopeV1> {
1342 if let Some(value) = atmosphere.altitude_m {
1343 require_range(
1344 "$.atmosphere.altitude_m",
1345 value,
1346 MIN_ICAO_ALTITUDE_M,
1347 MAX_ICAO_ALTITUDE_M,
1348 )?;
1349 }
1350 if let Some(value) = atmosphere.temperature_k {
1351 require_positive("$.atmosphere.temperature_k", value)?;
1352 }
1353 if let Some(value) = atmosphere.pressure_pa {
1354 require_positive("$.atmosphere.pressure_pa", value)?;
1355 }
1356 if let Some(value) = atmosphere.relative_humidity {
1357 require_range("$.atmosphere.relative_humidity", value, 0.0, 1.0)?;
1358 }
1359 if let Some(value) = atmosphere.latitude_rad {
1360 require_range(
1361 "$.atmosphere.latitude_rad",
1362 value,
1363 -std::f64::consts::FRAC_PI_2,
1364 std::f64::consts::FRAC_PI_2,
1365 )?;
1366 }
1367
1368 let altitude_m = match atmosphere.altitude_m {
1369 Some(value) => value,
1370 None => {
1371 assumptions.push(notice(
1372 ASSUMPTION_DEFAULT_APPLIED,
1373 "Station altitude defaulted to 0 m.",
1374 "$.atmosphere.altitude_m",
1375 ));
1376 DEFAULT_ALTITUDE_M
1377 }
1378 };
1379 let (standard_temperature_k, standard_pressure_pa) =
1380 crate::atmosphere::calculate_icao_standard_atmosphere(altitude_m);
1381 let temperature_k = match atmosphere.temperature_k {
1382 Some(value) => value,
1383 None => {
1384 assumptions.push(notice(
1385 ASSUMPTION_ICAO_STANDARD_TEMPERATURE,
1386 format!(
1387 "Station temperature was omitted; ICAO standard temperature at {altitude_m:.6} m is {standard_temperature_k:.12} K."
1388 ),
1389 "$.atmosphere.temperature_k",
1390 ));
1391 standard_temperature_k
1392 }
1393 };
1394 let pressure_pa = match atmosphere.pressure_pa {
1395 Some(value) if atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh) => {
1400 let qnh_hpa = value / PASCALS_PER_HECTOPASCAL;
1401 let station_hpa =
1402 crate::atmosphere::reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
1403 let station_pa = station_hpa * PASCALS_PER_HECTOPASCAL;
1404 assumptions.push(notice(
1405 ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE,
1406 format!(
1407 "Station pressure was declared as a QNH (sea-level-corrected altimeter \
1408 setting) of {value:.6} Pa; reduced to station pressure {station_pa:.12} Pa \
1409 at {altitude_m:.6} m via the ICAO inverse-barometric formula."
1410 ),
1411 "$.atmosphere.pressure_pa",
1412 ));
1413 station_pa
1414 }
1415 Some(value) => value,
1416 None => {
1417 assumptions.push(notice(
1418 ASSUMPTION_ICAO_STANDARD_PRESSURE,
1419 format!(
1420 "Station pressure was omitted; ICAO standard pressure at {altitude_m:.6} m is {standard_pressure_pa:.12} Pa."
1421 ),
1422 "$.atmosphere.pressure_pa",
1423 ));
1424 standard_pressure_pa
1425 }
1426 };
1427 let relative_humidity = literal_default(
1428 atmosphere.relative_humidity,
1429 DEFAULT_RELATIVE_HUMIDITY,
1430 "$.atmosphere.relative_humidity",
1431 "Relative humidity defaulted to 0.5.",
1432 assumptions,
1433 );
1434
1435 Ok(ResolvedAtmosphereV1 {
1436 altitude_m,
1437 temperature_k,
1438 pressure_pa,
1439 relative_humidity,
1440 latitude_rad: atmosphere.latitude_rad,
1441 pressure_reference: atmosphere.pressure_reference,
1445 })
1446}
1447
1448fn resolve_wind(
1449 wind: &WindV1,
1450 coverage_distance_m: f64,
1451 request_shot_azimuth_rad: Option<f64>,
1452 assumptions: &mut Vec<SolveNoticeV1>,
1453 warnings: &mut Vec<SolveNoticeV1>,
1454) -> Result<(ResolvedWindV1, WindConditions, Vec<WindSegment>), SolveErrorEnvelopeV1> {
1455 let compass_azimuth_rad = match wind.wind_reference.unwrap_or_default() {
1463 WindReferenceV1::Shooter => None,
1464 WindReferenceV1::Compass => match request_shot_azimuth_rad {
1465 Some(azimuth) => Some(azimuth),
1466 None => {
1467 return Err(conflicting_fields(
1468 "$.wind.wind_reference",
1469 "wind_reference \"compass\" requires shot.shot_azimuth_rad (earth-fixed \
1470 bearings are re-referenced against the shot azimuth; omitting it would \
1471 silently treat bearings as shooter-relative)",
1472 ))
1473 }
1474 },
1475 };
1476 let to_relative = |bearing_rad: f64| -> f64 {
1477 match compass_azimuth_rad {
1478 Some(azimuth) => {
1479 crate::wind::compass_bearing_to_shooter_relative_rad(bearing_rad, azimuth)
1480 }
1481 None => bearing_rad,
1482 }
1483 };
1484
1485 if let Some(segments) = &wind.segments {
1486 if wind.speed_mps.is_some()
1487 || wind.direction_from_rad.is_some()
1488 || wind.vertical_speed_mps.is_some()
1489 {
1490 return Err(conflicting_fields(
1491 "$.wind",
1492 "segments cannot be combined with constant-wind fields",
1493 ));
1494 }
1495 if segments.is_empty() {
1496 return Err(invalid_value(
1497 "$.wind.segments",
1498 "segmented wind must contain at least one segment",
1499 ));
1500 }
1501
1502 let mut resolved_segments = Vec::with_capacity(segments.len());
1503 let mut engine_segments = Vec::with_capacity(segments.len());
1504 let mut previous_until_m = 0.0;
1505 for (index, segment) in segments.iter().enumerate() {
1506 let base = format!("$.wind.segments[{index}]");
1507 require_positive(
1508 &format!("{base}.until_distance_m"),
1509 segment.until_distance_m,
1510 )?;
1511 require_non_negative(&format!("{base}.speed_mps"), segment.speed_mps)?;
1512 require_finite(
1513 &format!("{base}.direction_from_rad"),
1514 segment.direction_from_rad,
1515 )?;
1516 if let Some(value) = segment.vertical_speed_mps {
1517 require_finite(&format!("{base}.vertical_speed_mps"), value)?;
1518 }
1519 if index > 0 && segment.until_distance_m <= previous_until_m {
1520 return Err(invalid_value(
1521 format!("{base}.until_distance_m"),
1522 "wind-segment boundaries must be strictly increasing",
1523 ));
1524 }
1525 previous_until_m = segment.until_distance_m;
1526
1527 let vertical_speed_mps = match segment.vertical_speed_mps {
1528 Some(value) => value,
1529 None => {
1530 assumptions.push(notice(
1531 ASSUMPTION_DEFAULT_APPLIED,
1532 "Segment vertical wind speed defaulted to 0 m/s.",
1533 format!("{base}.vertical_speed_mps"),
1534 ));
1535 0.0
1536 }
1537 };
1538 let speed_kmh = checked_conversion(
1539 segment.speed_mps * SECONDS_PER_HOUR * KILOMETRES_PER_METRE,
1540 &format!("{base}.speed_mps"),
1541 "wind speed conversion to kilometres per hour overflowed",
1542 )?;
1543 let direction_from_rad = to_relative(segment.direction_from_rad);
1546 let angle_deg = checked_conversion(
1547 direction_from_rad.to_degrees(),
1548 &format!("{base}.direction_from_rad"),
1549 "wind direction conversion to degrees overflowed",
1550 )?;
1551
1552 resolved_segments.push(ResolvedWindSegmentV1 {
1553 until_distance_m: segment.until_distance_m,
1554 speed_mps: segment.speed_mps,
1555 direction_from_rad,
1556 vertical_speed_mps,
1557 });
1558 engine_segments.push(WindSegment {
1559 speed_kmh,
1560 angle_deg,
1561 until_m: segment.until_distance_m,
1562 vertical_mps: vertical_speed_mps,
1563 });
1564 }
1565
1566 let final_until_m = resolved_segments
1567 .last()
1568 .expect("non-empty segmented wind was checked")
1569 .until_distance_m;
1570 if final_until_m < coverage_distance_m {
1571 warnings.push(notice(
1572 WARNING_PARTIAL_WIND_COVERAGE,
1573 format!(
1574 "Segmented wind ends at {final_until_m:.12} m; wind is calm from that boundary through the required {coverage_distance_m:.12} m solve/zero range."
1575 ),
1576 "$.wind.segments",
1577 ));
1578 }
1579
1580 Ok((
1581 ResolvedWindV1::Segmented(ResolvedSegmentedWindV1 {
1582 segments: resolved_segments,
1583 wind_reference: wind.wind_reference,
1587 }),
1588 WindConditions::default(),
1589 engine_segments,
1590 ))
1591 } else {
1592 match (wind.speed_mps, wind.direction_from_rad) {
1593 (Some(_), None) | (None, Some(_)) => {
1594 return Err(conflicting_fields(
1595 "$.wind",
1596 "speed_mps and direction_from_rad must be supplied together",
1597 ));
1598 }
1599 (None, None) if wind.vertical_speed_mps.is_some() => {
1600 return Err(conflicting_fields(
1601 "$.wind",
1602 "vertical_speed_mps requires speed_mps and direction_from_rad",
1603 ));
1604 }
1605 _ => {}
1606 }
1607
1608 if let Some(value) = wind.speed_mps {
1609 require_non_negative("$.wind.speed_mps", value)?;
1610 }
1611 if let Some(value) = wind.direction_from_rad {
1612 require_finite("$.wind.direction_from_rad", value)?;
1613 }
1614 if let Some(value) = wind.vertical_speed_mps {
1615 require_finite("$.wind.vertical_speed_mps", value)?;
1616 }
1617
1618 let (speed_mps, direction_from_rad, vertical_speed_mps) =
1619 if let (Some(speed), Some(direction)) = (wind.speed_mps, wind.direction_from_rad) {
1620 let vertical = match wind.vertical_speed_mps {
1621 Some(value) => value,
1622 None => {
1623 assumptions.push(notice(
1624 ASSUMPTION_DEFAULT_APPLIED,
1625 "Constant vertical wind speed defaulted to 0 m/s.",
1626 "$.wind.vertical_speed_mps",
1627 ));
1628 0.0
1629 }
1630 };
1631 (speed, to_relative(direction), vertical)
1632 } else {
1633 assumptions.push(notice(
1634 ASSUMPTION_DEFAULT_APPLIED,
1635 "Wind defaulted to still air.",
1636 "$.wind",
1637 ));
1638 (0.0, 0.0, 0.0)
1639 };
1640
1641 Ok((
1642 ResolvedWindV1::Constant(ResolvedConstantWindV1 {
1643 speed_mps,
1644 direction_from_rad,
1645 vertical_speed_mps,
1646 wind_reference: wind.wind_reference,
1650 }),
1651 WindConditions {
1652 speed: speed_mps,
1653 direction: direction_from_rad,
1654 vertical_speed: vertical_speed_mps,
1655 },
1656 Vec::new(),
1657 ))
1658 }
1659}
1660
1661fn resolve_solver(
1662 solver: &SolverV1,
1663 assumptions: &mut Vec<SolveNoticeV1>,
1664 warnings: &mut Vec<SolveNoticeV1>,
1665) -> Result<ResolvedSolverV1, SolveErrorEnvelopeV1> {
1666 if let Some(value) = solver.time_step_s {
1667 require_positive("$.solver.time_step_s", value)?;
1668 }
1669 let method = match solver.method {
1670 Some(method) => method,
1671 None => {
1672 assumptions.push(notice(
1673 ASSUMPTION_DEFAULT_APPLIED,
1674 "Solver method defaulted to adaptive RK45.",
1675 "$.solver.method",
1676 ));
1677 SolverMethodV1::Rk45
1678 }
1679 };
1680 let time_step_s = literal_default(
1681 solver.time_step_s,
1682 DEFAULT_TIME_STEP_S,
1683 "$.solver.time_step_s",
1684 "Solver time step defaulted to 0.001 s.",
1685 assumptions,
1686 );
1687 if method == SolverMethodV1::Rk45 && solver.time_step_s.is_some() {
1688 warnings.push(notice(
1689 WARNING_RK45_TIME_STEP_IGNORED,
1690 "Adaptive RK45 owns its time step; the explicitly supplied fixed time step is retained in resolved_request but ignored by integration.",
1691 "$.solver.time_step_s",
1692 ));
1693 }
1694
1695 Ok(ResolvedSolverV1 {
1696 method,
1697 time_step_s,
1698 })
1699}
1700
1701fn resolve_effects(
1702 effects: &EffectsV1,
1703 latitude_rad: Option<f64>,
1704 geometry: SuppliedGeometry,
1705 assumptions: &mut Vec<SolveNoticeV1>,
1706 warnings: &mut Vec<SolveNoticeV1>,
1707) -> Result<ResolvedEffectsV1, SolveErrorEnvelopeV1> {
1708 let magnus = bool_default(
1709 effects.magnus,
1710 "$.effects.magnus",
1711 "Magnus effect defaulted to disabled.",
1712 assumptions,
1713 );
1714 let coriolis = bool_default(
1715 effects.coriolis,
1716 "$.effects.coriolis",
1717 "Coriolis effect defaulted to disabled.",
1718 assumptions,
1719 );
1720 let enhanced_spin_drift = bool_default(
1721 effects.enhanced_spin_drift,
1722 "$.effects.enhanced_spin_drift",
1723 "Enhanced spin drift defaulted to disabled.",
1724 assumptions,
1725 );
1726
1727 if magnus && enhanced_spin_drift {
1728 return Err(conflicting_fields(
1729 "$.effects",
1730 "magnus and enhanced_spin_drift cannot both be enabled",
1731 ));
1732 }
1733 if coriolis && latitude_rad.is_none() {
1734 return Err(invalid_value(
1735 "$.atmosphere.latitude_rad",
1736 "latitude_rad is required when the Coriolis effect is enabled",
1737 ));
1738 }
1739
1740 let aerodynamic_jump = effects.aerodynamic_jump;
1747 let enabled_aerodynamic_jump = aerodynamic_jump.unwrap_or(false);
1748
1749 for (enabled, path, name) in [
1750 (magnus, "$.effects.magnus", "Magnus"),
1751 (
1752 enhanced_spin_drift,
1753 "$.effects.enhanced_spin_drift",
1754 "enhanced spin drift",
1755 ),
1756 (
1757 enabled_aerodynamic_jump,
1758 "$.effects.aerodynamic_jump",
1759 "aerodynamic jump",
1760 ),
1761 ] {
1762 if enabled {
1763 warnings.push(notice(
1764 WARNING_EXPERIMENTAL_EFFECT,
1765 format!("The {name} effect is an opt-in experimental v1 model."),
1766 path,
1767 ));
1768 }
1769 }
1770
1771 for (enabled, path, name) in [
1786 (magnus, "$.effects.magnus", "Magnus"),
1787 (
1788 enhanced_spin_drift,
1789 "$.effects.enhanced_spin_drift",
1790 "Enhanced spin drift",
1791 ),
1792 ] {
1793 if enabled && !geometry.twist_rate {
1794 warnings.push(notice(
1795 WARNING_SPIN_EFFECT_ASSUMED_TWIST_RATE,
1796 format!(
1797 "{name} is computed from the spin the rifling imparts, but \
1798 rifle.twist_rate_m_per_turn was not supplied. The effect is still \
1799 applied, computed from the assumed 1:12 twist rather than disabled, so \
1800 the result is for a barrel this request did not specify."
1801 ),
1802 path,
1803 ));
1804 }
1805 }
1806
1807 let wind_shear_model = effects.wind_shear_model;
1818 if wind_shear_model == Some(WindShearModelV1::EkmanSpiral) {
1819 warnings.push(notice(
1825 WARNING_WIND_SHEAR_MODEL_NOT_MODELED,
1826 "The ekman_spiral wind shear model has no near-ground profile on this solve path; \
1827 the wind is left at the operative value, so the result matches an unsheared solve.",
1828 "$.effects.wind_shear_model",
1829 ));
1830 }
1831
1832 if enabled_aerodynamic_jump && !(geometry.twist_rate && geometry.projectile_length) {
1836 let missing = match (geometry.twist_rate, geometry.projectile_length) {
1837 (false, false) => "rifle.twist_rate_m_per_turn and projectile.length_m were",
1838 (false, true) => "rifle.twist_rate_m_per_turn was",
1839 (true, false) => "projectile.length_m was",
1840 (true, true) => unreachable!("guarded by the condition above"),
1841 };
1842 warnings.push(notice(
1843 WARNING_AERODYNAMIC_JUMP_ASSUMED_GEOMETRY,
1844 format!(
1845 "Aerodynamic jump scales with gyroscopic stability and bullet length, but \
1846 {missing} not supplied. The correction is still applied, computed from the \
1847 assumed geometry rather than disabled, so the jump reported in \
1848 summary.aerodynamic_jump_moa is for a barrel this request did not specify."
1849 ),
1850 "$.effects.aerodynamic_jump",
1851 ));
1852 }
1853
1854 Ok(ResolvedEffectsV1 {
1855 magnus,
1856 coriolis,
1857 enhanced_spin_drift,
1858 wind_shear_model,
1859 aerodynamic_jump,
1860 })
1861}
1862
1863fn resolve_sampling(
1864 sampling: &SamplingV1,
1865 assumptions: &mut Vec<SolveNoticeV1>,
1866) -> Result<ResolvedSamplingV1, SolveErrorEnvelopeV1> {
1867 if let Some(value) = sampling.interval_m {
1868 require_positive("$.sampling.interval_m", value)?;
1869 }
1870 let interval_m = literal_default(
1871 sampling.interval_m,
1872 DEFAULT_SAMPLE_INTERVAL_M,
1873 "$.sampling.interval_m",
1874 "Sampling interval defaulted to 10 m.",
1875 assumptions,
1876 );
1877 Ok(ResolvedSamplingV1 { interval_m })
1878}
1879
1880fn maximum_world_height_m(
1881 result: &crate::TrajectoryResult,
1882 shooting_angle_rad: f64,
1883) -> Result<f64, SolveErrorEnvelopeV1> {
1884 let mut maximum = f64::NEG_INFINITY;
1885 for point in &result.points {
1886 let height = crate::atmosphere::shot_frame_altitude(
1887 0.0,
1888 point.position.x,
1889 point.position.y,
1890 shooting_angle_rad,
1891 );
1892 if !height.is_finite() {
1893 return Err(internal_error(
1894 "world-vertical maximum-height projection produced a non-finite value",
1895 ));
1896 }
1897 maximum = maximum.max(height);
1898 }
1899 if maximum.is_finite() {
1900 Ok(maximum)
1901 } else {
1902 Err(internal_error(
1903 "trajectory contained no finite points for maximum-height calculation",
1904 ))
1905 }
1906}
1907
1908fn observation_to_wire(observation: TrajectoryObservation) -> TrajectorySampleV1 {
1909 TrajectorySampleV1 {
1910 distance_m: observation.distance_m,
1911 time_s: observation.time_s,
1912 speed_mps: observation.speed_mps,
1913 energy_j: observation.energy_j,
1914 drop_m: observation.drop_m,
1915 windage_m: observation.windage_m,
1916 mach: observation.mach,
1917 flags: observation
1918 .flags
1919 .into_iter()
1920 .map(observation_flag_to_wire)
1921 .collect(),
1922 }
1923}
1924
1925fn observation_flag_to_wire(flag: TrajectoryObservationFlag) -> SampleFlagV1 {
1926 match flag {
1927 TrajectoryObservationFlag::Transonic => SampleFlagV1::Transonic,
1928 TrajectoryObservationFlag::Subsonic => SampleFlagV1::Subsonic,
1929 TrajectoryObservationFlag::Terminal => SampleFlagV1::Terminal,
1930 TrajectoryObservationFlag::GroundThreshold => SampleFlagV1::GroundThreshold,
1931 }
1932}
1933
1934fn termination_to_wire(termination: TrajectoryTermination) -> TerminationReasonV1 {
1935 match termination {
1936 TrajectoryTermination::MaxRange => TerminationReasonV1::MaxRange,
1937 TrajectoryTermination::GroundThreshold => TerminationReasonV1::GroundThreshold,
1938 TrajectoryTermination::TimeLimit => TerminationReasonV1::TimeLimit,
1939 TrajectoryTermination::VelocityFloor => TerminationReasonV1::VelocityFloor,
1940 }
1941}
1942
1943fn drag_model_to_engine(model: DragModelV1) -> DragModel {
1944 match model {
1945 DragModelV1::G1 => DragModel::G1,
1946 DragModelV1::G2 => DragModel::G2,
1947 DragModelV1::G5 => DragModel::G5,
1948 DragModelV1::G6 => DragModel::G6,
1949 DragModelV1::G7 => DragModel::G7,
1950 DragModelV1::G8 => DragModel::G8,
1951 DragModelV1::GI => DragModel::GI,
1952 DragModelV1::GS => DragModel::GS,
1953 DragModelV1::RA4 => DragModel::RA4,
1954 }
1955}
1956
1957fn map_observation_error(error: TrajectoryObservationError) -> SolveErrorEnvelopeV1 {
1958 let message = error.to_string();
1959 match error {
1960 TrajectoryObservationError::SampleLimitExceeded { .. }
1961 | TrajectoryObservationError::AllocationFailed { .. } => error_at(
1962 SolveErrorCodeV1::ResourceLimit,
1963 message,
1964 "$.sampling.interval_m",
1965 ),
1966 TrajectoryObservationError::InvalidInterval { .. }
1967 | TrajectoryObservationError::UnrepresentableGrid { .. } => error_at(
1968 SolveErrorCodeV1::InvalidValue,
1969 message,
1970 "$.sampling.interval_m",
1971 ),
1972 TrajectoryObservationError::NonMonotonicTrajectory {
1979 previous_distance_m,
1980 distance_m,
1981 ..
1982 } if distance_m < previous_distance_m => solve_failed_message(format!(
1983 "the projectile moved back toward the muzzle ({previous_distance_m} m then \
1984 {distance_m} m downrange) before reaching a termination boundary, so the \
1985 trajectory cannot be reported against downrange distance"
1986 )),
1987 TrajectoryObservationError::EmptyTrajectory
1988 | TrajectoryObservationError::NonFiniteQuery { .. }
1989 | TrajectoryObservationError::OutOfRange { .. }
1990 | TrajectoryObservationError::NonMonotonicTrajectory { .. }
1991 | TrajectoryObservationError::NonFiniteState { .. }
1992 | TrajectoryObservationError::InvalidState { .. }
1993 | TrajectoryObservationError::InvalidMetadata { .. }
1994 | TrajectoryObservationError::NonFiniteObservation { .. } => internal_error(message),
1995 }
1996}
1997
1998fn resolve_reticle_hold(
2009 request: &crate::solve_json::ReticleRequestV1,
2010 samples: &[TrajectorySampleV1],
2011) -> Result<crate::solve_json::ReticleHoldV1, SolveErrorEnvelopeV1> {
2012 use crate::reticle::hold_point_in_reticle;
2013
2014 let path = "$.reticle.range_m";
2015 let range_m = request.range_m;
2016 if !range_m.is_finite() || range_m <= 0.0 {
2017 return Err(invalid_value(
2018 path,
2019 "reticle.range_m must be finite and greater than zero",
2020 ));
2021 }
2022
2023 let (lo, t) = match bracket_param(samples.len(), |i| samples[i].distance_m, range_m) {
2024 Bracket::Inside { lo, t } => (lo, t),
2025 Bracket::Degenerate => {
2026 return Err(internal_error(
2027 "trajectory sampling produced no points to read a reticle hold from",
2028 ));
2029 }
2030 Bracket::Below | Bracket::Above => {
2031 let first = &samples[0];
2033 let last = &samples[samples.len() - 1];
2034 return Err(invalid_value(
2035 path,
2036 format!(
2037 "reticle.range_m {range_m} is outside the sampled trajectory ({} to {} m)",
2038 first.distance_m, last.distance_m
2039 ),
2040 ));
2041 }
2042 };
2043 let hi = lo + 1;
2044 let lerp = |a: f64, b: f64| a + (b - a) * t;
2045 let drop_mil = lerp(samples[lo].drop_m, samples[hi].drop_m) / range_m * 1000.0;
2046 let wind_mil = lerp(samples[lo].windage_m, samples[hi].windage_m) / range_m * 1000.0;
2047
2048 let hold = hold_point_in_reticle(
2049 drop_mil,
2050 wind_mil,
2051 request.magnification,
2052 &request.description,
2053 )
2054 .map_err(|e| invalid_value("$.reticle", e.to_string()))?;
2055
2056 let nearest = hold
2057 .nearest_mark
2058 .and_then(|index| request.description.marks.get(index));
2059 Ok(crate::solve_json::ReticleHoldV1 {
2060 range_m,
2061 magnification: request.magnification,
2062 down_mil: hold.down_mil,
2063 right_mil: hold.right_mil,
2064 mark_scale: hold.mark_scale,
2065 nearest_mark_index: hold.nearest_mark,
2066 nearest_mark_label: nearest.and_then(|mark| mark.label.clone()),
2067 nearest_mark_distance_mil: hold.nearest_mark_distance_mil,
2068 off_reticle: hold.off_reticle,
2069 })
2070}
2071
2072pub(crate) fn solve_failed(error: BallisticsError) -> SolveErrorEnvelopeV1 {
2077 solve_failed_message(error.to_string())
2078}
2079
2080fn solve_failed_message(message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2081 SolveErrorEnvelopeV1::new(SolveErrorV1::new(SolveErrorCodeV1::SolveFailed, message))
2082}
2083
2084fn internal_error(message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2085 SolveErrorEnvelopeV1::new(SolveErrorV1::new(SolveErrorCodeV1::InternalError, message))
2086}
2087
2088fn invalid_value(path: impl Into<String>, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2089 error_at(SolveErrorCodeV1::InvalidValue, message, path)
2090}
2091
2092fn conflicting_fields(path: impl Into<String>, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
2093 error_at(SolveErrorCodeV1::ConflictingFields, message, path)
2094}
2095
2096fn error_at(
2097 code: SolveErrorCodeV1,
2098 message: impl Into<String>,
2099 path: impl Into<String>,
2100) -> SolveErrorEnvelopeV1 {
2101 SolveErrorEnvelopeV1::new(SolveErrorV1::new(code, message).at_path(path))
2102}
2103
2104fn notice(
2105 code: impl Into<String>,
2106 message: impl Into<String>,
2107 path: impl Into<String>,
2108) -> SolveNoticeV1 {
2109 SolveNoticeV1 {
2110 code: code.into(),
2111 message: message.into(),
2112 path: Some(path.into()),
2113 }
2114}
2115
2116fn literal_default(
2117 value: Option<f64>,
2118 default: f64,
2119 path: &'static str,
2120 message: &'static str,
2121 assumptions: &mut Vec<SolveNoticeV1>,
2122) -> f64 {
2123 match value {
2124 Some(value) => value,
2125 None => {
2126 assumptions.push(notice(ASSUMPTION_DEFAULT_APPLIED, message, path));
2127 default
2128 }
2129 }
2130}
2131
2132fn bool_default(
2133 value: Option<bool>,
2134 path: &'static str,
2135 message: &'static str,
2136 assumptions: &mut Vec<SolveNoticeV1>,
2137) -> bool {
2138 match value {
2139 Some(value) => value,
2140 None => {
2141 assumptions.push(notice(ASSUMPTION_DEFAULT_APPLIED, message, path));
2142 false
2143 }
2144 }
2145}
2146
2147fn checked_conversion(value: f64, path: &str, message: &str) -> Result<f64, SolveErrorEnvelopeV1> {
2148 if value.is_finite() {
2149 Ok(value)
2150 } else {
2151 Err(invalid_value(path, message))
2152 }
2153}
2154
2155fn require_finite(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
2156 if value.is_finite() {
2157 Ok(())
2158 } else {
2159 Err(invalid_value(path, "value must be finite"))
2160 }
2161}
2162
2163fn require_positive(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
2164 if value.is_finite() && value > 0.0 {
2165 Ok(())
2166 } else {
2167 Err(invalid_value(
2168 path,
2169 "value must be finite and greater than zero",
2170 ))
2171 }
2172}
2173
2174fn require_non_negative(path: &str, value: f64) -> Result<(), SolveErrorEnvelopeV1> {
2175 if value.is_finite() && value >= 0.0 {
2176 Ok(())
2177 } else {
2178 Err(invalid_value(path, "value must be finite and non-negative"))
2179 }
2180}
2181
2182fn require_range(
2183 path: &str,
2184 value: f64,
2185 minimum: f64,
2186 maximum: f64,
2187) -> Result<(), SolveErrorEnvelopeV1> {
2188 if value.is_finite() && (minimum..=maximum).contains(&value) {
2189 Ok(())
2190 } else {
2191 Err(invalid_value(
2192 path,
2193 format!("value must be finite and in the inclusive range [{minimum}, {maximum}]"),
2194 ))
2195 }
2196}
2197
2198#[cfg(test)]
2199mod tests {
2200 use super::*;
2201
2202 fn minimal_request() -> SolveRequestV1 {
2203 SolveRequestV1 {
2204 schema_version: SchemaVersionV1,
2205 projectile: ProjectileV1 {
2206 mass_kg: 0.01134,
2207 diameter_m: 0.00782,
2208 length_m: None,
2209 drag_model: DragModelV1::G7,
2210 ballistic_coefficient: 0.243,
2211 drag_table: None,
2212 bc_segments: None,
2213 },
2214 rifle: RifleV1 {
2215 muzzle_velocity_mps: 823.0,
2216 sight_height_m: None,
2217 muzzle_height_m: None,
2218 twist_rate_m_per_turn: None,
2219 twist_direction: None,
2220 sight_offset_lateral_m: None,
2221 },
2222 shot: ShotV1 {
2223 max_range_m: 1_000.0,
2224 zero_distance_m: None,
2225 muzzle_angle_rad: None,
2226 aim_azimuth_rad: None,
2227 shot_azimuth_rad: None,
2228 shooting_angle_rad: None,
2229 cant_angle_rad: None,
2230 target_height_m: None,
2231 ground_threshold_m: None,
2232 zero_poi_up_m: None,
2233 zero_poi_right_m: None,
2234 drops_reference: None,
2235 },
2236 atmosphere: AtmosphereV1::default(),
2237 wind: WindV1::default(),
2238 solver: SolverV1::default(),
2239 effects: EffectsV1::default(),
2240 sampling: SamplingV1::default(),
2241 reticle: None,
2242 corrections: None,
2243 }
2244 }
2245
2246 #[test]
2247 fn defaults_are_resolved_in_deterministic_request_field_order() {
2248 let request = minimal_request();
2249 let prepared = prepare_request(&request).expect("valid request");
2250 let code_paths = prepared
2251 .assumptions
2252 .iter()
2253 .map(|notice| (notice.code.as_str(), notice.path.as_deref().unwrap()))
2254 .collect::<Vec<_>>();
2255
2256 assert_eq!(
2257 code_paths,
2258 vec![
2259 (
2260 ASSUMPTION_ESTIMATED_PROJECTILE_LENGTH,
2261 "$.projectile.length_m"
2262 ),
2263 (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.sight_height_m"),
2264 (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.muzzle_height_m"),
2265 (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.twist_rate_m_per_turn"),
2266 (ASSUMPTION_DEFAULT_APPLIED, "$.rifle.twist_direction"),
2267 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.muzzle_angle_rad"),
2268 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.aim_azimuth_rad"),
2269 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.shot_azimuth_rad"),
2270 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.shooting_angle_rad"),
2271 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.cant_angle_rad"),
2272 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.target_height_m"),
2273 (ASSUMPTION_DEFAULT_APPLIED, "$.shot.ground_threshold_m"),
2274 (ASSUMPTION_DEFAULT_APPLIED, "$.atmosphere.altitude_m"),
2275 (
2276 ASSUMPTION_ICAO_STANDARD_TEMPERATURE,
2277 "$.atmosphere.temperature_k"
2278 ),
2279 (
2280 ASSUMPTION_ICAO_STANDARD_PRESSURE,
2281 "$.atmosphere.pressure_pa"
2282 ),
2283 (ASSUMPTION_DEFAULT_APPLIED, "$.atmosphere.relative_humidity"),
2284 (ASSUMPTION_DEFAULT_APPLIED, "$.wind"),
2285 (ASSUMPTION_DEFAULT_APPLIED, "$.solver.method"),
2286 (ASSUMPTION_DEFAULT_APPLIED, "$.solver.time_step_s"),
2287 (ASSUMPTION_DEFAULT_APPLIED, "$.effects.magnus"),
2288 (ASSUMPTION_DEFAULT_APPLIED, "$.effects.coriolis"),
2289 (ASSUMPTION_DEFAULT_APPLIED, "$.effects.enhanced_spin_drift"),
2290 (ASSUMPTION_DEFAULT_APPLIED, "$.sampling.interval_m"),
2291 ]
2292 );
2293 assert_eq!(
2294 prepared.resolved_request.rifle.twist_rate_m_per_turn,
2295 0.3048
2296 );
2297 assert!((prepared.inputs.twist_rate - 12.0).abs() < 1.0e-12);
2298 assert_eq!(prepared.atmosphere.humidity, 50.0);
2299 assert_eq!(prepared.inputs.humidity, 0.5);
2300 }
2301
2302 #[test]
2303 fn explicit_standard_station_values_at_altitude_remain_authoritative() {
2304 let mut request = minimal_request();
2305 request.atmosphere.altitude_m = Some(1_500.0);
2306 request.atmosphere.temperature_k = Some(288.15);
2307 request.atmosphere.pressure_pa = Some(101_325.0);
2308
2309 let prepared = prepare_request(&request).expect("valid explicit station conditions");
2310 assert_eq!(prepared.atmosphere.temperature, 15.0);
2311 assert_eq!(prepared.atmosphere.pressure, 1013.25);
2312 assert_eq!(prepared.resolved_request.atmosphere.temperature_k, 288.15);
2313 assert_eq!(prepared.resolved_request.atmosphere.pressure_pa, 101_325.0);
2314 assert!(!prepared
2315 .assumptions
2316 .iter()
2317 .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_TEMPERATURE
2318 || notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
2319 }
2320
2321 #[test]
2325 fn qnh_pressure_reference_reduces_to_station_pressure_and_is_noted() {
2326 let mut request = minimal_request();
2327 request.atmosphere.altitude_m = Some(1_500.0);
2328 request.atmosphere.pressure_pa = Some(103_000.0); request.atmosphere.pressure_reference = Some(PressureReferenceV1::Qnh);
2330
2331 let prepared = prepare_request(&request).expect("valid QNH request");
2332 let expected_station_hpa =
2333 crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1_500.0);
2334 assert!((prepared.atmosphere.pressure - expected_station_hpa).abs() < 1e-9);
2335 assert!(
2336 (prepared.resolved_request.atmosphere.pressure_pa - expected_station_hpa * 100.0).abs()
2337 < 1e-6
2338 );
2339 assert!(prepared.resolved_request.atmosphere.pressure_pa < 103_000.0);
2341
2342 assert!(prepared.assumptions.iter().any(|notice| notice.code
2343 == ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE
2344 && notice.path.as_deref() == Some("$.atmosphere.pressure_pa")));
2345 assert!(!prepared
2347 .assumptions
2348 .iter()
2349 .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
2350 }
2351
2352 #[test]
2357 fn qnh_pressure_reference_with_omitted_pressure_matches_absolute_default() {
2358 let mut request = minimal_request();
2359 request.atmosphere.altitude_m = Some(1_500.0);
2360 request.atmosphere.pressure_reference = Some(PressureReferenceV1::Qnh);
2361
2362 let prepared = prepare_request(&request).expect("valid request");
2363
2364 let mut absolute_request = minimal_request();
2365 absolute_request.atmosphere.altitude_m = Some(1_500.0);
2366 let absolute_prepared = prepare_request(&absolute_request).expect("valid request");
2367
2368 assert_eq!(
2369 prepared.resolved_request.atmosphere.pressure_pa,
2370 absolute_prepared.resolved_request.atmosphere.pressure_pa
2371 );
2372 assert!(prepared
2373 .assumptions
2374 .iter()
2375 .any(|notice| notice.code == ASSUMPTION_ICAO_STANDARD_PRESSURE));
2376 assert!(!prepared
2377 .assumptions
2378 .iter()
2379 .any(|notice| notice.code == ASSUMPTION_QNH_REDUCED_TO_STATION_PRESSURE));
2380 }
2381
2382 #[test]
2385 fn absent_pressure_reference_matches_explicit_absolute() {
2386 let mut request = minimal_request();
2387 request.atmosphere.altitude_m = Some(1_500.0);
2388 request.atmosphere.pressure_pa = Some(90_000.0);
2389
2390 let mut explicit_absolute = request.clone();
2391 explicit_absolute.atmosphere.pressure_reference = Some(PressureReferenceV1::Absolute);
2392
2393 let prepared = prepare_request(&request).expect("valid request");
2394 let prepared_explicit = prepare_request(&explicit_absolute).expect("valid request");
2395 assert_eq!(
2396 prepared.resolved_request.atmosphere.pressure_pa,
2397 prepared_explicit.resolved_request.atmosphere.pressure_pa
2398 );
2399 assert_eq!(prepared.resolved_request.atmosphere.pressure_pa, 90_000.0);
2400 }
2401
2402 #[test]
2403 fn partial_segmented_wind_maps_units_and_warns_without_extending_coverage() {
2404 let mut request = minimal_request();
2405 request.wind.segments = Some(vec![crate::solve_json::WindSegmentV1 {
2406 until_distance_m: 300.0,
2407 speed_mps: 5.0,
2408 direction_from_rad: std::f64::consts::FRAC_PI_2,
2409 vertical_speed_mps: None,
2410 }]);
2411
2412 let prepared = prepare_request(&request).expect("partial wind is valid");
2413 assert_eq!(prepared.wind_segments.len(), 1);
2414 assert_eq!(prepared.wind_segments[0].speed_kmh, 18.0);
2415 assert_eq!(prepared.wind_segments[0].angle_deg, 90.0);
2416 assert_eq!(prepared.wind_segments[0].until_m, 300.0);
2417 assert_eq!(prepared.wind_segments[0].vertical_mps, 0.0);
2418 assert!(prepared.warnings.iter().any(|notice| {
2419 notice.code == WARNING_PARTIAL_WIND_COVERAGE
2420 && notice.path.as_deref() == Some("$.wind.segments")
2421 }));
2422 assert!(matches!(
2423 prepared.resolved_request.wind,
2424 ResolvedWindV1::Segmented(_)
2425 ));
2426 }
2427
2428 #[test]
2429 fn partial_wind_coverage_includes_a_zero_beyond_the_output_range() {
2430 let mut request = minimal_request();
2431 request.shot.max_range_m = 200.0;
2432 request.shot.zero_distance_m = Some(500.0);
2433 request.wind.segments = Some(vec![crate::solve_json::WindSegmentV1 {
2434 until_distance_m: 300.0,
2435 speed_mps: 5.0,
2436 direction_from_rad: 0.0,
2437 vertical_speed_mps: Some(0.0),
2438 }]);
2439
2440 let prepared = prepare_request(&request).expect("partial zero wind is valid");
2441 assert!(prepared.warnings.iter().any(|notice| {
2442 notice.code == WARNING_PARTIAL_WIND_COVERAGE
2443 && notice.path.as_deref() == Some("$.wind.segments")
2444 && notice.message.contains("500.000000000000 m")
2445 }));
2446 }
2447
2448 #[test]
2455 fn explicit_muzzle_angle_wins_over_zero_distance_when_both_are_supplied() {
2456 let mut request = minimal_request();
2457 request.shot.zero_distance_m = Some(100.0);
2458 request.shot.muzzle_angle_rad = Some(0.01);
2459 let prepared =
2460 prepare_request(&request).expect("both fields together are no longer a conflict");
2461 assert_eq!(prepared.resolved_request.shot.muzzle_angle_rad, 0.01);
2462 assert_eq!(prepared.resolved_request.shot.zero_distance_m, Some(100.0));
2463 assert!(
2467 prepared.warnings.iter().any(|notice| {
2468 notice.code == WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED
2469 && notice.path.as_deref() == Some("$.shot.zero_distance_m")
2470 }),
2471 "expected a {WARNING_ZERO_DISTANCE_ELEVATION_NOT_RESOLVED} warning naming \
2472 $.shot.zero_distance_m; got {:?}",
2473 prepared.warnings
2474 );
2475 }
2476
2477 #[test]
2478 fn semantic_conflicts_and_invalid_values_keep_exact_paths() {
2479 let mut request = minimal_request();
2480 request.atmosphere.relative_humidity = Some(1.01);
2481 let error = prepare_request(&request).expect_err("humidity above one");
2482 assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
2483 assert_eq!(error.error.path(), Some("$.atmosphere.relative_humidity"));
2484
2485 let mut request = minimal_request();
2486 request.effects.coriolis = Some(true);
2487 let error = prepare_request(&request).expect_err("Coriolis needs latitude");
2488 assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
2489 assert_eq!(error.error.path(), Some("$.atmosphere.latitude_rad"));
2490
2491 let mut request = minimal_request();
2492 request.shot.zero_distance_m = Some(f64::MAX);
2493 let error = prepare_request(&request).expect_err("zero trial range must remain finite");
2494 assert_eq!(error.error.code, SolveErrorCodeV1::InvalidValue);
2495 assert_eq!(error.error.path(), Some("$.shot.zero_distance_m"));
2496 }
2497
2498 fn fly(prepared: PreparedSolveV1) -> Vec<TrajectoryObservation> {
2501 let max_range_m = prepared.resolved_request.shot.max_range_m;
2502 let time_step_s = prepared.resolved_request.solver.time_step_s;
2503 let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
2504 let target_height_m = prepared.resolved_request.shot.target_height_m;
2505 let (solver, _) = build_zeroed_solver(
2506 prepared.inputs,
2507 prepared.wind,
2508 prepared.atmosphere,
2509 prepared.wind_segments,
2510 max_range_m,
2511 time_step_s,
2512 zero_distance_m,
2513 target_height_m,
2514 None,
2515 )
2516 .expect("zeroed solver");
2517 solver
2518 .solve()
2519 .expect("solve")
2520 .sample_observations(25.0, MAX_SOLVE_JSON_SAMPLES_V1)
2521 .expect("samples")
2522 }
2523
2524 fn fly_with_engine_ladder(segments: Vec<crate::BCSegmentData>) -> Vec<TrajectoryObservation> {
2526 let mut request = minimal_request();
2527 request.shot.zero_distance_m = Some(100.0);
2528 let mut prepared = prepare_request(&request).expect("valid request");
2529 prepared.inputs.use_bc_segments = true;
2530 prepared.inputs.bc_segments_data = Some(segments);
2531 fly(prepared)
2532 }
2533
2534 #[test]
2541 fn bc_segment_edges_reach_the_engine_in_fps() {
2542 use crate::solve_json::{decode_solve_request_v1, BcSegmentV1};
2543 use crate::BCSegmentData;
2544
2545 let fps_ladder = [
2548 (2_200.0, 3_000.0, 0.250),
2549 (1_500.0, 2_000.0, 0.235),
2550 (0.0, 1_500.0, 0.225),
2551 ];
2552 let mut base = minimal_request();
2553 base.shot.zero_distance_m = Some(100.0);
2554 let mut wire = serde_json::to_value(&base).expect("serialize the base request");
2555 wire["projectile"]["bc_segments"] = serde_json::json!([
2556 {"velocity_min_mps": 670.56, "velocity_max_mps": 914.4, "ballistic_coefficient": 0.250},
2557 {"velocity_min_mps": 457.2, "velocity_max_mps": 609.6, "ballistic_coefficient": 0.235},
2558 {"velocity_min_mps": 0.0, "velocity_max_mps": 457.2, "ballistic_coefficient": 0.225}
2559 ]);
2560 let request = decode_solve_request_v1(&wire.to_string()).expect("decodable ladder");
2561 assert_eq!(
2562 request.projectile.bc_segments.as_deref(),
2563 Some(
2564 &[
2565 (670.56, 914.4, 0.250),
2566 (457.2, 609.6, 0.235),
2567 (0.0, 457.2, 0.225),
2568 ]
2569 .map(|(min, max, bc)| BcSegmentV1 {
2570 velocity_min_mps: min,
2571 velocity_max_mps: max,
2572 ballistic_coefficient: bc,
2573 })[..]
2574 ),
2575 "the wire names decode onto the struct fields"
2576 );
2577 assert_eq!(
2578 SolveRequestV1 {
2579 projectile: ProjectileV1 {
2580 bc_segments: None,
2581 ..request.projectile.clone()
2582 },
2583 ..request.clone()
2584 },
2585 base,
2586 "only the ladder differs from the base request"
2587 );
2588
2589 let prepared = prepare_request(&request).expect("valid ladder");
2590 assert!(prepared.inputs.use_bc_segments);
2591 assert_eq!(
2592 prepared.inputs.bc_value, 0.243,
2593 "the scalar BC is left alone"
2594 );
2595 let installed = prepared
2596 .inputs
2597 .bc_segments_data
2598 .clone()
2599 .expect("ladder installed");
2600 assert_eq!(installed.len(), fps_ladder.len());
2601 for (band, (min_fps, max_fps, bc)) in installed.iter().zip(fps_ladder) {
2602 assert!((band.velocity_min - min_fps).abs() < 1e-9, "{band:?}");
2603 assert!((band.velocity_max - max_fps).abs() < 1e-9, "{band:?}");
2604 assert_eq!(band.bc_value, bc);
2605 }
2606
2607 let from_wire = fly(prepared);
2608 let engine_ladder = |scale: f64| {
2609 fps_ladder
2610 .iter()
2611 .map(|&(min, max, bc)| BCSegmentData {
2612 velocity_min: min * scale,
2613 velocity_max: max * scale,
2614 bc_value: bc,
2615 })
2616 .collect::<Vec<_>>()
2617 };
2618 let direct = fly_with_engine_ladder(engine_ladder(1.0));
2619 assert_eq!(from_wire.len(), direct.len());
2620 for (wire, engine) in from_wire.iter().zip(&direct) {
2621 assert!((wire.distance_m - engine.distance_m).abs() < 1e-9);
2622 assert!(
2623 (wire.drop_m - engine.drop_m).abs() < 1e-9,
2624 "{wire:?} vs {engine:?}"
2625 );
2626 assert!(
2627 (wire.speed_mps - engine.speed_mps).abs() < 1e-9,
2628 "{wire:?} vs {engine:?}"
2629 );
2630 assert!(
2631 (wire.time_s - engine.time_s).abs() < 1e-12,
2632 "{wire:?} vs {engine:?}"
2633 );
2634 }
2635
2636 let terminal_drop = |samples: &[TrajectoryObservation]| samples.last().unwrap().drop_m;
2638 let unconverted = fly_with_engine_ladder(engine_ladder(0.3048));
2639 let mut scalar_only = minimal_request();
2640 scalar_only.shot.zero_distance_m = Some(100.0);
2641 let scalar_only = fly(prepare_request(&scalar_only).unwrap());
2642 for other in [&unconverted, &scalar_only] {
2643 assert!(
2644 (terminal_drop(&from_wire) - terminal_drop(other)).abs() > 1e-3,
2645 "{} vs {}",
2646 terminal_drop(&from_wire),
2647 terminal_drop(other)
2648 );
2649 }
2650 }
2651
2652 #[test]
2658 fn install_drag_table_takes_the_reference_curve_path_only_beside_a_ladder() {
2659 let prepare = |kind: DragTableKindV1, with_ladder: bool| {
2660 let mut request = minimal_request();
2661 request.projectile.drag_table = Some(DragTableV1 {
2662 kind,
2663 name: None,
2664 points: vec![
2665 crate::solve_json::DragPointV1 { mach: 0.0, cd: 0.2 },
2666 crate::solve_json::DragPointV1 { mach: 2.0, cd: 0.3 },
2667 ],
2668 });
2669 if with_ladder {
2670 request.projectile.bc_segments = Some(vec![
2671 crate::solve_json::BcSegmentV1 {
2672 velocity_min_mps: 600.0,
2673 velocity_max_mps: 999.0,
2674 ballistic_coefficient: 0.260,
2675 },
2676 crate::solve_json::BcSegmentV1 {
2677 velocity_min_mps: 0.0,
2678 velocity_max_mps: 600.0,
2679 ballistic_coefficient: 0.220,
2680 },
2681 ]);
2682 }
2683 prepare_request(&request)
2684 };
2685
2686 let alone = prepare(DragTableKindV1::Reference, false).unwrap().inputs;
2687 let sd = alone.sectional_density_lb_in2().expect("SD");
2688 assert!(!alone.custom_drag_reference);
2689 assert_eq!(alone.cd_scale, sd / 0.243);
2690
2691 let laddered = prepare(DragTableKindV1::Reference, true).unwrap().inputs;
2692 assert!(laddered.custom_drag_reference);
2693 assert_eq!(laddered.cd_scale, 1.0);
2694 assert!(laddered.custom_drag_table.is_some());
2695 assert!(laddered.use_bc_segments && laddered.bc_segments_data.is_some());
2696 assert_eq!(laddered.bc_value, 0.243, "the scalar still flies in a gap");
2697
2698 let own = prepare(DragTableKindV1::Projectile, false).unwrap().inputs;
2699 assert!(!own.custom_drag_reference);
2700 assert_eq!(own.cd_scale, 1.0);
2701
2702 let refused = prepare(DragTableKindV1::Projectile, true)
2703 .expect_err("a ladder beside a projectile table is refused");
2704 assert_eq!(refused.error.code, SolveErrorCodeV1::ConflictingFields);
2705 assert_eq!(refused.error.path(), Some("$.projectile.bc_segments"));
2706 }
2707
2708 #[test]
2709 fn enum_mappers_cover_every_engine_metadata_variant() {
2710 assert_eq!(drag_model_to_engine(DragModelV1::G1), DragModel::G1);
2711 assert_eq!(drag_model_to_engine(DragModelV1::G2), DragModel::G2);
2712 assert_eq!(drag_model_to_engine(DragModelV1::G5), DragModel::G5);
2713 assert_eq!(drag_model_to_engine(DragModelV1::G6), DragModel::G6);
2714 assert_eq!(drag_model_to_engine(DragModelV1::G7), DragModel::G7);
2715 assert_eq!(drag_model_to_engine(DragModelV1::G8), DragModel::G8);
2716 assert_eq!(drag_model_to_engine(DragModelV1::GI), DragModel::GI);
2717 assert_eq!(drag_model_to_engine(DragModelV1::GS), DragModel::GS);
2718 assert_eq!(drag_model_to_engine(DragModelV1::RA4), DragModel::RA4);
2719
2720 for (engine, wire) in [
2721 (
2722 TrajectoryTermination::MaxRange,
2723 TerminationReasonV1::MaxRange,
2724 ),
2725 (
2726 TrajectoryTermination::GroundThreshold,
2727 TerminationReasonV1::GroundThreshold,
2728 ),
2729 (
2730 TrajectoryTermination::TimeLimit,
2731 TerminationReasonV1::TimeLimit,
2732 ),
2733 (
2734 TrajectoryTermination::VelocityFloor,
2735 TerminationReasonV1::VelocityFloor,
2736 ),
2737 ] {
2738 assert_eq!(termination_to_wire(engine), wire);
2739 }
2740 }
2741}