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