1use serde::{
8 de::{self, Visitor},
9 ser::SerializeStruct,
10 Deserialize, Deserializer, Serialize, Serializer,
11};
12use serde_json::{Map, Value};
13use std::{fmt, num::NonZeroUsize};
14
15pub const SOLVE_JSON_SCHEMA_VERSION_V1: u32 = 1;
17
18pub const DRAG_MODEL_WIRE_NAMES_V1: [&str; 9] =
23 ["G1", "G2", "G5", "G6", "G7", "G8", "GI", "GS", "RA4"];
24
25pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
30
31fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
37where
38 D: Deserializer<'de>,
39 T: Deserialize<'de>,
40{
41 T::deserialize(deserializer).map(Some)
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
49pub struct SchemaVersionV1;
50
51impl SchemaVersionV1 {
52 pub const fn get(self) -> u32 {
54 SOLVE_JSON_SCHEMA_VERSION_V1
55 }
56}
57
58impl Serialize for SchemaVersionV1 {
59 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: Serializer,
62 {
63 serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
64 }
65}
66
67impl<'de> Deserialize<'de> for SchemaVersionV1 {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: Deserializer<'de>,
71 {
72 struct SchemaVersionVisitor;
73
74 impl<'de> Visitor<'de> for SchemaVersionVisitor {
75 type Value = SchemaVersionV1;
76
77 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str("the integer 1")
79 }
80
81 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
82 where
83 E: de::Error,
84 {
85 if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
86 Ok(SchemaVersionV1)
87 } else {
88 Err(E::custom(format!(
89 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
90 )))
91 }
92 }
93
94 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
95 where
96 E: de::Error,
97 {
98 if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
99 Ok(SchemaVersionV1)
100 } else {
101 Err(E::custom(format!(
102 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
103 )))
104 }
105 }
106 }
107
108 deserializer.deserialize_any(SchemaVersionVisitor)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct SolveRequestV1 {
119 pub schema_version: SchemaVersionV1,
121 pub projectile: ProjectileV1,
122 pub rifle: RifleV1,
123 pub shot: ShotV1,
124 pub atmosphere: AtmosphereV1,
125 pub wind: WindV1,
126 pub solver: SolverV1,
127 pub effects: EffectsV1,
128 pub sampling: SamplingV1,
129 #[serde(
136 default,
137 skip_serializing_if = "Option::is_none",
138 deserialize_with = "deserialize_present"
139 )]
140 pub reticle: Option<ReticleRequestV1>,
141 #[serde(
147 default,
148 skip_serializing_if = "Option::is_none",
149 deserialize_with = "deserialize_present"
150 )]
151 pub corrections: Option<CorrectionsV1>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct CorrectionsV1 {
169 #[serde(
171 default,
172 skip_serializing_if = "Option::is_none",
173 deserialize_with = "deserialize_present"
174 )]
175 pub bc5d_table_path: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ReticleRequestV1 {
188 pub range_m: f64,
190 pub magnification: f64,
193 pub description: crate::reticle::ReticleDescription,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ReticleHoldV1 {
203 pub range_m: f64,
205 pub magnification: f64,
207 pub down_mil: f64,
208 pub right_mil: f64,
209 pub mark_scale: f64,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub nearest_mark_index: Option<usize>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub nearest_mark_label: Option<String>,
219 pub nearest_mark_distance_mil: f64,
220 pub off_reticle: bool,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ProjectileV1 {
229 pub mass_kg: f64,
230 pub diameter_m: f64,
231 #[serde(
232 default,
233 skip_serializing_if = "Option::is_none",
234 deserialize_with = "deserialize_present"
235 )]
236 pub length_m: Option<f64>,
237 pub drag_model: DragModelV1,
238 pub ballistic_coefficient: f64,
239 #[serde(
243 default,
244 skip_serializing_if = "Option::is_none",
245 deserialize_with = "deserialize_present"
246 )]
247 pub drag_table: Option<DragTableV1>,
248 #[serde(
267 default,
268 skip_serializing_if = "Option::is_none",
269 deserialize_with = "deserialize_present"
270 )]
271 pub bc_segments: Option<Vec<BcSegmentV1>>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub struct BcSegmentV1 {
299 pub velocity_min_mps: f64,
301 pub velocity_max_mps: f64,
304 pub ballistic_coefficient: f64,
308}
309
310pub const MIN_DRAG_TABLE_POINTS_V1: usize = 2;
312
313pub const MAX_DRAG_TABLE_POINTS_V1: usize = 4096;
316
317pub const MIN_BC_SEGMENTS_V1: usize = 1;
320
321pub const MAX_BC_SEGMENTS_V1: usize = 64;
325
326pub const ONE_BAND_BC_RELATIVE_TOLERANCE_V1: f64 = 1.0e-6;
332
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342#[serde(deny_unknown_fields)]
343pub struct DragTableV1 {
344 pub kind: DragTableKindV1,
345 pub points: Vec<DragPointV1>,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum DragTableKindV1 {
354 Reference,
359 Projectile,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
368#[serde(deny_unknown_fields)]
369pub struct DragPointV1 {
370 pub mach: f64,
371 pub cd: f64,
372}
373
374pub(crate) fn validate_drag_table_v1(table: &DragTableV1) -> Result<(), SolveErrorEnvelopeV1> {
380 let points_path = "$.projectile.drag_table.points";
381 let count = table.points.len();
382 if !(MIN_DRAG_TABLE_POINTS_V1..=MAX_DRAG_TABLE_POINTS_V1).contains(&count) {
383 return Err(protocol_error(
384 SolveErrorCodeV1::InvalidValue,
385 format!(
386 "drag_table must have {MIN_DRAG_TABLE_POINTS_V1} to \
387 {MAX_DRAG_TABLE_POINTS_V1} points, got {count}"
388 ),
389 points_path,
390 ));
391 }
392 let mut previous_mach: Option<f64> = None;
393 for (index, point) in table.points.iter().enumerate() {
394 let mach_path = format!("{points_path}[{index}].mach");
395 if !point.mach.is_finite() || point.mach < 0.0 {
396 return Err(protocol_error(
397 SolveErrorCodeV1::InvalidValue,
398 format!("mach must be finite and >= 0, got {}", point.mach),
399 mach_path,
400 ));
401 }
402 if let Some(previous) = previous_mach.filter(|previous| point.mach <= *previous) {
403 return Err(protocol_error(
404 SolveErrorCodeV1::InvalidValue,
405 format!(
406 "mach must strictly ascend; {} follows {previous} at points[{}]",
407 point.mach,
408 index - 1
409 ),
410 mach_path,
411 ));
412 }
413 if !point.cd.is_finite() || point.cd <= 0.0 {
414 return Err(protocol_error(
415 SolveErrorCodeV1::InvalidValue,
416 format!("cd must be finite and > 0, got {}", point.cd),
417 format!("{points_path}[{index}].cd"),
418 ));
419 }
420 previous_mach = Some(point.mach);
421 }
422 Ok(())
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
427pub enum DragModelV1 {
428 #[serde(rename = "G1")]
429 G1,
430 #[serde(rename = "G6")]
431 G6,
432 #[serde(rename = "G7")]
433 G7,
434 #[serde(rename = "G8")]
435 G8,
436 #[serde(rename = "G2")]
437 G2,
438 #[serde(rename = "G5")]
439 G5,
440 #[serde(rename = "GI")]
441 GI,
442 #[serde(rename = "GS")]
443 GS,
444 #[serde(rename = "RA4")]
445 RA4,
446}
447
448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct RifleV1 {
452 pub muzzle_velocity_mps: f64,
453 #[serde(
454 default,
455 skip_serializing_if = "Option::is_none",
456 deserialize_with = "deserialize_present"
457 )]
458 pub sight_height_m: Option<f64>,
459 #[serde(
460 default,
461 skip_serializing_if = "Option::is_none",
462 deserialize_with = "deserialize_present"
463 )]
464 pub muzzle_height_m: Option<f64>,
465 #[serde(
484 default,
485 skip_serializing_if = "Option::is_none",
486 deserialize_with = "deserialize_present"
487 )]
488 pub twist_rate_m_per_turn: Option<f64>,
489 #[serde(
494 default,
495 skip_serializing_if = "Option::is_none",
496 deserialize_with = "deserialize_present"
497 )]
498 pub twist_direction: Option<TwistDirectionV1>,
499 #[serde(
507 default,
508 skip_serializing_if = "Option::is_none",
509 deserialize_with = "deserialize_present"
510 )]
511 pub sight_offset_lateral_m: Option<f64>,
512}
513
514#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "snake_case")]
517pub enum TwistDirectionV1 {
518 Left,
519 #[default]
520 Right,
521}
522
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct ShotV1 {
527 pub max_range_m: f64,
528 #[serde(
529 default,
530 skip_serializing_if = "Option::is_none",
531 deserialize_with = "deserialize_present"
532 )]
533 pub zero_distance_m: Option<f64>,
534 #[serde(
535 default,
536 skip_serializing_if = "Option::is_none",
537 deserialize_with = "deserialize_present"
538 )]
539 pub muzzle_angle_rad: Option<f64>,
540 #[serde(
541 default,
542 skip_serializing_if = "Option::is_none",
543 deserialize_with = "deserialize_present"
544 )]
545 pub aim_azimuth_rad: Option<f64>,
546 #[serde(
547 default,
548 skip_serializing_if = "Option::is_none",
549 deserialize_with = "deserialize_present"
550 )]
551 pub shot_azimuth_rad: Option<f64>,
552 #[serde(
553 default,
554 skip_serializing_if = "Option::is_none",
555 deserialize_with = "deserialize_present"
556 )]
557 pub shooting_angle_rad: Option<f64>,
558 #[serde(
559 default,
560 skip_serializing_if = "Option::is_none",
561 deserialize_with = "deserialize_present"
562 )]
563 pub cant_angle_rad: Option<f64>,
564 #[serde(
582 default,
583 skip_serializing_if = "Option::is_none",
584 deserialize_with = "deserialize_present"
585 )]
586 pub target_height_m: Option<f64>,
587 #[serde(
588 default,
589 skip_serializing_if = "Option::is_none",
590 deserialize_with = "deserialize_present"
591 )]
592 pub ground_threshold_m: Option<f64>,
593 #[serde(
599 default,
600 skip_serializing_if = "Option::is_none",
601 deserialize_with = "deserialize_present"
602 )]
603 pub zero_poi_up_m: Option<f64>,
604 #[serde(
608 default,
609 skip_serializing_if = "Option::is_none",
610 deserialize_with = "deserialize_present"
611 )]
612 pub zero_poi_right_m: Option<f64>,
613 #[serde(
621 default,
622 skip_serializing_if = "Option::is_none",
623 deserialize_with = "deserialize_present"
624 )]
625 pub drops_reference: Option<DropsReferenceV1>,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
630#[serde(rename_all = "snake_case")]
631pub enum DropsReferenceV1 {
632 #[default]
634 Los,
635 Target,
638}
639
640#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct AtmosphereV1 {
657 #[serde(
658 default,
659 skip_serializing_if = "Option::is_none",
660 deserialize_with = "deserialize_present"
661 )]
662 pub altitude_m: Option<f64>,
663 #[serde(
666 default,
667 skip_serializing_if = "Option::is_none",
668 deserialize_with = "deserialize_present"
669 )]
670 pub temperature_k: Option<f64>,
671 #[serde(
677 default,
678 skip_serializing_if = "Option::is_none",
679 deserialize_with = "deserialize_present"
680 )]
681 pub pressure_pa: Option<f64>,
682 #[serde(
688 default,
689 skip_serializing_if = "Option::is_none",
690 deserialize_with = "deserialize_present"
691 )]
692 pub pressure_reference: Option<PressureReferenceV1>,
693 #[serde(
694 default,
695 skip_serializing_if = "Option::is_none",
696 deserialize_with = "deserialize_present"
697 )]
698 pub relative_humidity: Option<f64>,
699 #[serde(
700 default,
701 skip_serializing_if = "Option::is_none",
702 deserialize_with = "deserialize_present"
703 )]
704 pub latitude_rad: Option<f64>,
705}
706
707#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
711#[serde(rename_all = "snake_case")]
712pub enum PressureReferenceV1 {
713 #[default]
714 Absolute,
715 Qnh,
716}
717
718#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct WindV1 {
725 #[serde(
726 default,
727 skip_serializing_if = "Option::is_none",
728 deserialize_with = "deserialize_present"
729 )]
730 pub speed_mps: Option<f64>,
731 #[serde(
732 default,
733 skip_serializing_if = "Option::is_none",
734 deserialize_with = "deserialize_present"
735 )]
736 pub direction_from_rad: Option<f64>,
737 #[serde(
738 default,
739 skip_serializing_if = "Option::is_none",
740 deserialize_with = "deserialize_present"
741 )]
742 pub vertical_speed_mps: Option<f64>,
743 #[serde(
744 default,
745 skip_serializing_if = "Option::is_none",
746 deserialize_with = "deserialize_present"
747 )]
748 pub segments: Option<Vec<WindSegmentV1>>,
749 #[serde(
762 default,
763 skip_serializing_if = "Option::is_none",
764 deserialize_with = "deserialize_present"
765 )]
766 pub wind_reference: Option<WindReferenceV1>,
767}
768
769#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
771#[serde(rename_all = "snake_case")]
772pub enum WindReferenceV1 {
773 #[default]
775 Shooter,
776 Compass,
778}
779
780#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
782#[serde(deny_unknown_fields)]
783pub struct WindSegmentV1 {
784 pub until_distance_m: f64,
785 pub speed_mps: f64,
786 pub direction_from_rad: f64,
787 #[serde(
788 default,
789 skip_serializing_if = "Option::is_none",
790 deserialize_with = "deserialize_present"
791 )]
792 pub vertical_speed_mps: Option<f64>,
793}
794
795#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
797#[serde(deny_unknown_fields)]
798pub struct SolverV1 {
799 #[serde(
800 default,
801 skip_serializing_if = "Option::is_none",
802 deserialize_with = "deserialize_present"
803 )]
804 pub method: Option<SolverMethodV1>,
805 #[serde(
807 default,
808 skip_serializing_if = "Option::is_none",
809 deserialize_with = "deserialize_present"
810 )]
811 pub time_step_s: Option<f64>,
812}
813
814#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(rename_all = "snake_case")]
817pub enum SolverMethodV1 {
818 Euler,
819 Rk4,
820 #[default]
821 Rk45,
822}
823
824#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(deny_unknown_fields)]
827pub struct EffectsV1 {
828 #[serde(
840 default,
841 skip_serializing_if = "Option::is_none",
842 deserialize_with = "deserialize_present"
843 )]
844 pub magnus: Option<bool>,
845 #[serde(
849 default,
850 skip_serializing_if = "Option::is_none",
851 deserialize_with = "deserialize_present"
852 )]
853 pub coriolis: Option<bool>,
854 #[serde(
867 default,
868 skip_serializing_if = "Option::is_none",
869 deserialize_with = "deserialize_present"
870 )]
871 pub enhanced_spin_drift: Option<bool>,
872 #[serde(
882 default,
883 skip_serializing_if = "Option::is_none",
884 deserialize_with = "deserialize_present"
885 )]
886 pub wind_shear_model: Option<WindShearModelV1>,
887 #[serde(
904 default,
905 skip_serializing_if = "Option::is_none",
906 deserialize_with = "deserialize_present"
907 )]
908 pub aerodynamic_jump: Option<bool>,
909}
910
911#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
919#[serde(rename_all = "snake_case")]
920pub enum WindShearModelV1 {
921 #[default]
924 None,
925 Logarithmic,
927 PowerLaw,
929 #[serde(alias = "ekman")]
934 EkmanSpiral,
935}
936
937impl WindShearModelV1 {
938 pub fn as_engine_str(self) -> &'static str {
943 match self {
944 WindShearModelV1::None => "none",
945 WindShearModelV1::Logarithmic => "logarithmic",
946 WindShearModelV1::PowerLaw => "power_law",
947 WindShearModelV1::EkmanSpiral => "ekman_spiral",
948 }
949 }
950
951 pub fn is_enabled(self) -> bool {
958 !matches!(self, WindShearModelV1::None)
959 }
960}
961
962#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
964#[serde(deny_unknown_fields)]
965pub struct SamplingV1 {
966 #[serde(
967 default,
968 skip_serializing_if = "Option::is_none",
969 deserialize_with = "deserialize_present"
970 )]
971 pub interval_m: Option<f64>,
972}
973
974#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
980#[serde(deny_unknown_fields)]
981pub struct ResolvedSolveRequestV1 {
982 pub schema_version: SchemaVersionV1,
983 pub projectile: ResolvedProjectileV1,
984 pub rifle: ResolvedRifleV1,
985 pub shot: ResolvedShotV1,
986 pub atmosphere: ResolvedAtmosphereV1,
987 pub wind: ResolvedWindV1,
988 pub solver: ResolvedSolverV1,
989 pub effects: ResolvedEffectsV1,
990 pub sampling: ResolvedSamplingV1,
991 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub reticle: Option<ReticleRequestV1>,
996 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub corrections: Option<CorrectionsV1>,
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1007#[serde(deny_unknown_fields)]
1008pub struct ResolvedProjectileV1 {
1009 pub mass_kg: f64,
1010 pub diameter_m: f64,
1011 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub length_m: Option<f64>,
1013 pub drag_model: DragModelV1,
1014 pub ballistic_coefficient: f64,
1015 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub drag_table: Option<DragTableV1>,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub bc_segments: Option<Vec<BcSegmentV1>>,
1025}
1026
1027#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1029#[serde(deny_unknown_fields)]
1030pub struct ResolvedRifleV1 {
1031 pub muzzle_velocity_mps: f64,
1032 pub sight_height_m: f64,
1033 pub muzzle_height_m: f64,
1034 pub twist_rate_m_per_turn: f64,
1035 pub twist_direction: TwistDirectionV1,
1036 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub sight_offset_lateral_m: Option<f64>,
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1054#[serde(deny_unknown_fields)]
1055pub struct ResolvedShotV1 {
1056 pub max_range_m: f64,
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub zero_distance_m: Option<f64>,
1059 pub muzzle_angle_rad: f64,
1060 pub aim_azimuth_rad: f64,
1061 pub shot_azimuth_rad: f64,
1062 pub shooting_angle_rad: f64,
1063 pub cant_angle_rad: f64,
1064 pub target_height_m: f64,
1065 pub ground_threshold_m: f64,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub zero_poi_up_m: Option<f64>,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub zero_poi_right_m: Option<f64>,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub drops_reference: Option<DropsReferenceV1>,
1078}
1079
1080#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1082#[serde(deny_unknown_fields)]
1083pub struct ResolvedAtmosphereV1 {
1084 pub altitude_m: f64,
1085 pub temperature_k: f64,
1086 pub pressure_pa: f64,
1087 pub relative_humidity: f64,
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub latitude_rad: Option<f64>,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub pressure_reference: Option<PressureReferenceV1>,
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1101#[serde(untagged)]
1102pub enum ResolvedWindV1 {
1103 Constant(ResolvedConstantWindV1),
1104 Segmented(ResolvedSegmentedWindV1),
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1109#[serde(deny_unknown_fields)]
1110pub struct ResolvedConstantWindV1 {
1111 pub speed_mps: f64,
1112 pub direction_from_rad: f64,
1113 pub vertical_speed_mps: f64,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub wind_reference: Option<WindReferenceV1>,
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1123#[serde(deny_unknown_fields)]
1124pub struct ResolvedSegmentedWindV1 {
1125 pub segments: Vec<ResolvedWindSegmentV1>,
1126 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub wind_reference: Option<WindReferenceV1>,
1131}
1132
1133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135#[serde(deny_unknown_fields)]
1136pub struct ResolvedWindSegmentV1 {
1137 pub until_distance_m: f64,
1138 pub speed_mps: f64,
1139 pub direction_from_rad: f64,
1140 pub vertical_speed_mps: f64,
1141}
1142
1143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1145#[serde(deny_unknown_fields)]
1146pub struct ResolvedSolverV1 {
1147 pub method: SolverMethodV1,
1148 pub time_step_s: f64,
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1153#[serde(deny_unknown_fields)]
1154pub struct ResolvedEffectsV1 {
1155 pub magnus: bool,
1156 pub coriolis: bool,
1157 pub enhanced_spin_drift: bool,
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1162 pub wind_shear_model: Option<WindShearModelV1>,
1163 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub aerodynamic_jump: Option<bool>,
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1178#[serde(deny_unknown_fields)]
1179pub struct ResolvedSamplingV1 {
1180 pub interval_m: f64,
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1185#[serde(deny_unknown_fields)]
1186pub struct SolveSuccessV1 {
1187 pub schema_version: SchemaVersionV1,
1188 pub engine_version: String,
1189 pub status: SuccessStatusV1,
1190 pub resolved_request: ResolvedSolveRequestV1,
1191 #[serde(default)]
1192 pub assumptions: Vec<SolveNoticeV1>,
1193 #[serde(default)]
1194 pub warnings: Vec<SolveNoticeV1>,
1195 pub summary: SolveSummaryV1,
1196 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
1197 pub samples: Vec<TrajectorySampleV1>,
1198 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub reticle_hold: Option<ReticleHoldV1>,
1203}
1204
1205fn serialize_solve_samples_v1<S>(
1206 samples: &[TrajectorySampleV1],
1207 serializer: S,
1208) -> Result<S::Ok, S::Error>
1209where
1210 S: Serializer,
1211{
1212 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
1213 return Err(serde::ser::Error::custom(format_args!(
1214 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1215 samples.len()
1216 )));
1217 }
1218 samples.serialize(serializer)
1219}
1220
1221impl SolveSuccessV1 {
1222 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
1228 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
1229 return Ok(());
1230 }
1231
1232 Err(SolveErrorEnvelopeV1::new(
1233 SolveErrorV1::new(
1234 SolveErrorCodeV1::ResourceLimit,
1235 format!(
1236 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1237 self.samples.len()
1238 ),
1239 )
1240 .at_path("$.sampling.interval_m"),
1241 ))
1242 }
1243}
1244
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1247#[serde(rename_all = "snake_case")]
1248pub enum SuccessStatusV1 {
1249 Ok,
1250}
1251
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1254#[serde(deny_unknown_fields)]
1255pub struct SolveNoticeV1 {
1256 pub code: String,
1257 pub message: String,
1258 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub path: Option<String>,
1260}
1261
1262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1264#[serde(deny_unknown_fields)]
1265pub struct SolveSummaryV1 {
1266 pub actual_range_m: f64,
1267 pub maximum_height_m: f64,
1270 pub time_of_flight_s: f64,
1271 pub terminal_speed_mps: f64,
1272 pub terminal_energy_j: f64,
1273 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub stability_factor: Option<f64>,
1277 #[serde(default, skip_serializing_if = "Option::is_none")]
1281 pub spin_drift_m: Option<f64>,
1282 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub equivalent_horizontal_range_m: Option<f64>,
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub aerodynamic_jump_moa: Option<f64>,
1304 pub termination: TerminationReasonV1,
1305}
1306
1307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1309#[serde(rename_all = "snake_case")]
1310pub enum TerminationReasonV1 {
1311 MaxRange,
1312 GroundThreshold,
1313 TimeLimit,
1314 VelocityFloor,
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1319#[serde(deny_unknown_fields)]
1320pub struct TrajectorySampleV1 {
1321 pub distance_m: f64,
1322 pub time_s: f64,
1323 pub speed_mps: f64,
1324 pub energy_j: f64,
1325 pub drop_m: f64,
1327 pub windage_m: f64,
1329 pub mach: f64,
1330 #[serde(default)]
1331 pub flags: Vec<SampleFlagV1>,
1332}
1333
1334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1336#[serde(rename_all = "snake_case")]
1337pub enum SampleFlagV1 {
1338 Transonic,
1339 Subsonic,
1340 Terminal,
1341 GroundThreshold,
1342}
1343
1344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1346#[serde(deny_unknown_fields)]
1347pub struct SolveErrorEnvelopeV1 {
1348 pub schema_version: SchemaVersionV1,
1349 pub status: ErrorStatusV1,
1350 pub error: SolveErrorV1,
1351}
1352
1353impl SolveErrorEnvelopeV1 {
1354 pub fn new(error: SolveErrorV1) -> Self {
1356 Self {
1357 schema_version: SchemaVersionV1,
1358 status: ErrorStatusV1::Error,
1359 error,
1360 }
1361 }
1362}
1363
1364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1366#[serde(rename_all = "snake_case")]
1367pub enum ErrorStatusV1 {
1368 Error,
1369}
1370
1371#[derive(Debug, Clone, PartialEq, Eq)]
1377pub struct SolveErrorV1 {
1378 pub code: SolveErrorCodeV1,
1379 pub message: String,
1380 location: SolveErrorLocationV1,
1381}
1382
1383#[derive(Debug, Clone, PartialEq, Eq)]
1384enum SolveErrorLocationV1 {
1385 None,
1386 Path(String),
1387 Source {
1388 line: NonZeroUsize,
1389 column: NonZeroUsize,
1390 },
1391}
1392
1393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1395pub enum SolveErrorLocationErrorV1 {
1396 ZeroLine,
1397 ZeroColumn,
1398}
1399
1400impl fmt::Display for SolveErrorLocationErrorV1 {
1401 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1402 match self {
1403 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1404 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1405 }
1406 }
1407}
1408
1409impl std::error::Error for SolveErrorLocationErrorV1 {}
1410
1411impl SolveErrorV1 {
1412 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1414 Self {
1415 code,
1416 message: message.into(),
1417 location: SolveErrorLocationV1::None,
1418 }
1419 }
1420
1421 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1423 self.location = SolveErrorLocationV1::Path(path.into());
1424 self
1425 }
1426
1427 pub fn at_location(
1429 mut self,
1430 line: usize,
1431 column: usize,
1432 ) -> Result<Self, SolveErrorLocationErrorV1> {
1433 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1434 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1435 self.location = SolveErrorLocationV1::Source { line, column };
1436 Ok(self)
1437 }
1438
1439 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1444 self.location = SolveErrorLocationV1::Source {
1445 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1446 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1447 };
1448 self
1449 }
1450
1451 pub fn path(&self) -> Option<&str> {
1453 match &self.location {
1454 SolveErrorLocationV1::Path(path) => Some(path),
1455 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1456 }
1457 }
1458
1459 pub fn line(&self) -> Option<usize> {
1461 match &self.location {
1462 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1463 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1464 }
1465 }
1466
1467 pub fn column(&self) -> Option<usize> {
1469 match &self.location {
1470 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1471 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1472 }
1473 }
1474}
1475
1476impl Serialize for SolveErrorV1 {
1477 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1478 where
1479 S: Serializer,
1480 {
1481 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1482 state.serialize_field("code", &self.code)?;
1483 state.serialize_field("message", &self.message)?;
1484 state.serialize_field("path", &self.path())?;
1485 state.serialize_field("line", &self.line())?;
1486 state.serialize_field("column", &self.column())?;
1487 state.end()
1488 }
1489}
1490
1491impl<'de> Deserialize<'de> for SolveErrorV1 {
1492 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1493 where
1494 D: Deserializer<'de>,
1495 {
1496 #[derive(Deserialize)]
1497 #[serde(deny_unknown_fields)]
1498 struct SolveErrorWireV1 {
1499 code: SolveErrorCodeV1,
1500 message: String,
1501 path: Option<String>,
1502 line: Option<usize>,
1503 column: Option<usize>,
1504 }
1505
1506 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1507 let location = match (wire.path, wire.line, wire.column) {
1508 (None, None, None) => SolveErrorLocationV1::None,
1509 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1510 (None, Some(line), Some(column)) => {
1511 let line = NonZeroUsize::new(line)
1512 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1513 let column = NonZeroUsize::new(column)
1514 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1515 SolveErrorLocationV1::Source { line, column }
1516 }
1517 _ => {
1518 return Err(de::Error::custom(
1519 "error location must contain either path or both line and column",
1520 ));
1521 }
1522 };
1523
1524 Ok(Self {
1525 code: wire.code,
1526 message: wire.message,
1527 location,
1528 })
1529 }
1530}
1531
1532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1534#[serde(rename_all = "snake_case")]
1535pub enum SolveErrorCodeV1 {
1536 InvalidJson,
1537 UnsupportedSchemaVersion,
1538 UnknownField,
1539 MissingField,
1540 InvalidValue,
1541 ConflictingFields,
1542 ResourceLimit,
1543 SolveFailed,
1544 IoError,
1545 InternalError,
1546}
1547
1548pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1555 let value: Value = serde_json::from_str(input).map_err(|error| {
1556 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1557 .at_parser_location(error.line(), error.column());
1558 envelope(error)
1559 })?;
1560
1561 validate_request_shape(&value)?;
1562
1563 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1564 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1565 })?;
1566
1567 validate_request_ranges(&request)?;
1568
1569 Ok(request)
1570}
1571
1572pub(crate) mod limits {
1587 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1591 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1593 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1595 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1597
1598 }
1605
1606fn require_range(
1607 value: f64,
1608 (min, max): (f64, f64),
1609 path: &str,
1610) -> Result<(), SolveErrorEnvelopeV1> {
1611 if !value.is_finite() {
1612 return Err(protocol_error(
1613 SolveErrorCodeV1::InvalidValue,
1614 format!("{path} must be a finite number"),
1615 path,
1616 ));
1617 }
1618 if value < min || value > max {
1619 return Err(protocol_error(
1620 SolveErrorCodeV1::InvalidValue,
1621 format!("{path} must be between {min} and {max}, got {value}"),
1622 path,
1623 ));
1624 }
1625 Ok(())
1626}
1627
1628fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1633 require_range(
1634 request.projectile.mass_kg,
1635 limits::MASS_KG,
1636 "$.projectile.mass_kg",
1637 )?;
1638 require_range(
1639 request.projectile.diameter_m,
1640 limits::DIAMETER_M,
1641 "$.projectile.diameter_m",
1642 )?;
1643 require_range(
1644 request.projectile.ballistic_coefficient,
1645 limits::BALLISTIC_COEFFICIENT,
1646 "$.projectile.ballistic_coefficient",
1647 )?;
1648 if let Some(length_m) = request.projectile.length_m {
1649 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1650 }
1651 if let Some(drag_table) = &request.projectile.drag_table {
1652 validate_drag_table_v1(drag_table)?;
1653 }
1654 if let Some(bc_segments) = &request.projectile.bc_segments {
1655 validate_bc_segments_v1(bc_segments, request.projectile.ballistic_coefficient)?;
1656 }
1657 Ok(())
1658}
1659
1660fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1661 SolveErrorEnvelopeV1::new(error)
1662}
1663
1664fn protocol_error(
1665 code: SolveErrorCodeV1,
1666 message: impl Into<String>,
1667 path: impl Into<String>,
1668) -> SolveErrorEnvelopeV1 {
1669 envelope(SolveErrorV1::new(code, message).at_path(path))
1670}
1671
1672fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1673 let root = require_object(value, "$")?;
1674
1675 validate_schema_version(root)?;
1679 validate_members(
1680 root,
1681 "$",
1682 &[
1683 "schema_version",
1684 "projectile",
1685 "rifle",
1686 "shot",
1687 "atmosphere",
1688 "wind",
1689 "solver",
1690 "effects",
1691 "sampling",
1692 "reticle",
1694 "corrections",
1696 ],
1697 &[
1698 "schema_version",
1699 "projectile",
1700 "rifle",
1701 "shot",
1702 "atmosphere",
1703 "wind",
1704 "solver",
1705 "effects",
1706 "sampling",
1707 ],
1708 )?;
1709
1710 validate_projectile(required_value(root, "projectile", "$")?)?;
1711 validate_rifle(required_value(root, "rifle", "$")?)?;
1712 validate_shot(required_value(root, "shot", "$")?)?;
1713 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1714 validate_wind(required_value(root, "wind", "$")?)?;
1715 validate_solver(required_value(root, "solver", "$")?)?;
1716 validate_effects(required_value(root, "effects", "$")?)?;
1717 validate_sampling(required_value(root, "sampling", "$")?)?;
1718 if let Some(reticle) = root.get("reticle") {
1719 validate_reticle(reticle)?;
1720 }
1721 if let Some(corrections) = root.get("corrections") {
1722 validate_corrections(corrections)?;
1723 }
1724 Ok(())
1725}
1726
1727fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1732 let path = "$.corrections";
1733 let object = require_object(value, path)?;
1734 validate_members(object, path, &["bc5d_table_path"], &[])?;
1735 if let Some(table_path) = object.get("bc5d_table_path") {
1736 if !table_path.is_string() {
1737 return Err(protocol_error(
1738 SolveErrorCodeV1::InvalidValue,
1739 "expected a string",
1740 "$.corrections.bc5d_table_path",
1741 ));
1742 }
1743 }
1744 Ok(())
1745}
1746
1747fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1754 let path = "$.reticle";
1755 let object = require_object(value, path)?;
1756 validate_members(
1757 object,
1758 path,
1759 &["range_m", "magnification", "description"],
1760 &["range_m", "magnification", "description"],
1761 )?;
1762 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1763 require_object(
1764 required_value(object, "description", path)?,
1765 "$.reticle.description",
1766 )?;
1767 Ok(())
1768}
1769
1770fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1771 let value = required_value(root, "schema_version", "$")?;
1772 let version = if let Some(version) = value.as_i64() {
1773 i128::from(version)
1774 } else if let Some(version) = value.as_u64() {
1775 i128::from(version)
1776 } else {
1777 return Err(protocol_error(
1778 SolveErrorCodeV1::InvalidValue,
1779 "schema_version must be the integer 1",
1780 "$.schema_version",
1781 ));
1782 };
1783 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1784 return Err(protocol_error(
1785 SolveErrorCodeV1::UnsupportedSchemaVersion,
1786 format!(
1787 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1788 ),
1789 "$.schema_version",
1790 ));
1791 }
1792 Ok(())
1793}
1794
1795fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1796 let path = "$.projectile";
1797 let object = require_object(value, path)?;
1798 validate_members(
1799 object,
1800 path,
1801 &[
1802 "mass_kg",
1803 "diameter_m",
1804 "length_m",
1805 "drag_model",
1806 "ballistic_coefficient",
1807 "drag_table",
1809 "bc_segments",
1811 ],
1812 &[
1813 "mass_kg",
1814 "diameter_m",
1815 "drag_model",
1816 "ballistic_coefficient",
1817 ],
1818 )?;
1819 validate_required_numbers(
1820 object,
1821 path,
1822 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1823 )?;
1824 validate_optional_number(object, path, "length_m")?;
1825 validate_string_enum(
1826 required_value(object, "drag_model", path)?,
1827 "$.projectile.drag_model",
1828 &DRAG_MODEL_WIRE_NAMES_V1,
1829 )?;
1830 if let Some(drag_table) = object.get("drag_table") {
1831 validate_drag_table_shape(drag_table)?;
1832 }
1833 if let Some(bc_segments) = object.get("bc_segments") {
1834 validate_bc_segments_shape(bc_segments)?;
1835 }
1836 Ok(())
1837}
1838
1839const BC_SEGMENT_FIELDS_V1: [&str; 3] = [
1841 "velocity_min_mps",
1842 "velocity_max_mps",
1843 "ballistic_coefficient",
1844];
1845
1846fn validate_bc_segments_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1852 let segments_path = "$.projectile.bc_segments";
1853 let Some(segments) = value.as_array() else {
1854 return Err(protocol_error(
1855 SolveErrorCodeV1::InvalidValue,
1856 "bc_segments must be an array",
1857 segments_path,
1858 ));
1859 };
1860 if segments.is_empty() {
1861 return Err(protocol_error(
1862 SolveErrorCodeV1::InvalidValue,
1863 bc_segment_count_message(0),
1864 segments_path,
1865 ));
1866 }
1867 for (index, segment) in segments.iter().enumerate() {
1868 let seg_path = format!("{segments_path}[{index}]");
1869 let segment = require_object(segment, &seg_path)?;
1870 validate_members(
1871 segment,
1872 &seg_path,
1873 &BC_SEGMENT_FIELDS_V1,
1874 &BC_SEGMENT_FIELDS_V1,
1875 )?;
1876 validate_required_numbers(segment, &seg_path, &BC_SEGMENT_FIELDS_V1)?;
1877 }
1878 Ok(())
1879}
1880
1881fn bc_segment_count_message(count: usize) -> String {
1882 format!("bc_segments must have {MIN_BC_SEGMENTS_V1} to {MAX_BC_SEGMENTS_V1} bands, got {count}")
1883}
1884
1885pub(crate) fn validate_bc_segments_v1(
1903 segments: &[BcSegmentV1],
1904 scalar_bc: f64,
1905) -> Result<(), SolveErrorEnvelopeV1> {
1906 let base = "$.projectile.bc_segments";
1907 let count = segments.len();
1908 if !(MIN_BC_SEGMENTS_V1..=MAX_BC_SEGMENTS_V1).contains(&count) {
1909 return Err(protocol_error(
1910 SolveErrorCodeV1::InvalidValue,
1911 bc_segment_count_message(count),
1912 base,
1913 ));
1914 }
1915 let mut previous: Option<BcSegmentV1> = None;
1916 for (index, seg) in segments.iter().enumerate() {
1917 let seg_path = format!("{base}[{index}]");
1918 let min_path = format!("{seg_path}.velocity_min_mps");
1919 let max_path = format!("{seg_path}.velocity_max_mps");
1920 if !seg.velocity_min_mps.is_finite() || seg.velocity_min_mps < 0.0 {
1921 return Err(protocol_error(
1922 SolveErrorCodeV1::InvalidValue,
1923 format!(
1924 "velocity_min_mps must be finite and >= 0, got {}",
1925 seg.velocity_min_mps
1926 ),
1927 min_path,
1928 ));
1929 }
1930 if !seg.velocity_max_mps.is_finite() || seg.velocity_max_mps <= seg.velocity_min_mps {
1931 return Err(protocol_error(
1932 SolveErrorCodeV1::InvalidValue,
1933 format!(
1934 "velocity_max_mps must be finite and > velocity_min_mps ({}), got {}",
1935 seg.velocity_min_mps, seg.velocity_max_mps
1936 ),
1937 max_path,
1938 ));
1939 }
1940 require_range(
1941 seg.ballistic_coefficient,
1942 limits::BALLISTIC_COEFFICIENT,
1943 &format!("{seg_path}.ballistic_coefficient"),
1944 )?;
1945 if let Some(prev) = previous {
1946 if seg.velocity_min_mps >= prev.velocity_min_mps {
1947 return Err(protocol_error(
1948 SolveErrorCodeV1::InvalidValue,
1949 format!(
1950 "velocity_min_mps must be below bc_segments[{}].velocity_min_mps ({}), \
1951 got {}: list the bands from the highest speed to the lowest, or \
1952 reverse the array",
1953 index - 1,
1954 prev.velocity_min_mps,
1955 seg.velocity_min_mps
1956 ),
1957 min_path,
1958 ));
1959 }
1960 if seg.velocity_max_mps > prev.velocity_min_mps {
1961 return Err(protocol_error(
1962 SolveErrorCodeV1::InvalidValue,
1963 format!(
1964 "velocity_max_mps ({}) overlaps bc_segments[{}], which starts at {} m/s: \
1965 a band must end at or below the start of the faster band before it",
1966 seg.velocity_max_mps,
1967 index - 1,
1968 prev.velocity_min_mps
1969 ),
1970 max_path,
1971 ));
1972 }
1973 }
1974 previous = Some(*seg);
1975 }
1976 if let [band] = segments {
1977 let difference = (band.ballistic_coefficient - scalar_bc).abs();
1978 let allowed = ONE_BAND_BC_RELATIVE_TOLERANCE_V1 * band.ballistic_coefficient.max(scalar_bc);
1979 if difference > allowed {
1980 return Err(protocol_error(
1981 SolveErrorCodeV1::ConflictingFields,
1982 format!(
1983 "a one-band ladder flies its band's BC at every speed, so \
1984 bc_segments[0].ballistic_coefficient ({}) must match \
1985 projectile.ballistic_coefficient ({scalar_bc}): send the same value in \
1986 both, or omit bc_segments",
1987 band.ballistic_coefficient
1988 ),
1989 format!("{base}[0].ballistic_coefficient"),
1990 ));
1991 }
1992 }
1993 Ok(())
1994}
1995
1996fn validate_drag_table_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2001 let path = "$.projectile.drag_table";
2002 let object = require_object(value, path)?;
2003 validate_members(object, path, &["kind", "points"], &["kind", "points"])?;
2004 validate_string_enum(
2005 required_value(object, "kind", path)?,
2006 "$.projectile.drag_table.kind",
2007 &["reference", "projectile"],
2008 )?;
2009 let points_path = "$.projectile.drag_table.points";
2010 let Some(points) = required_value(object, "points", path)?.as_array() else {
2011 return Err(protocol_error(
2012 SolveErrorCodeV1::InvalidValue,
2013 "points must be an array",
2014 points_path,
2015 ));
2016 };
2017 for (index, point) in points.iter().enumerate() {
2018 let point_path = format!("{points_path}[{index}]");
2019 let point = require_object(point, &point_path)?;
2020 validate_members(point, &point_path, &["mach", "cd"], &["mach", "cd"])?;
2021 validate_required_numbers(point, &point_path, &["mach", "cd"])?;
2022 }
2023 Ok(())
2024}
2025
2026fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2027 let path = "$.rifle";
2028 let object = require_object(value, path)?;
2029 validate_members(
2030 object,
2031 path,
2032 &[
2033 "muzzle_velocity_mps",
2034 "sight_height_m",
2035 "muzzle_height_m",
2036 "twist_rate_m_per_turn",
2037 "twist_direction",
2038 "sight_offset_lateral_m",
2039 ],
2040 &["muzzle_velocity_mps"],
2041 )?;
2042 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
2043 validate_optional_numbers(
2044 object,
2045 path,
2046 &[
2047 "sight_height_m",
2048 "muzzle_height_m",
2049 "twist_rate_m_per_turn",
2050 "sight_offset_lateral_m",
2051 ],
2052 )?;
2053 if let Some(direction) = object.get("twist_direction") {
2054 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
2055 }
2056 Ok(())
2057}
2058
2059fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2060 let path = "$.shot";
2061 let object = require_object(value, path)?;
2062 validate_members(
2063 object,
2064 path,
2065 &[
2066 "max_range_m",
2067 "zero_distance_m",
2068 "muzzle_angle_rad",
2069 "aim_azimuth_rad",
2070 "shot_azimuth_rad",
2071 "shooting_angle_rad",
2072 "cant_angle_rad",
2073 "target_height_m",
2074 "ground_threshold_m",
2075 "zero_poi_up_m",
2076 "zero_poi_right_m",
2077 "drops_reference",
2078 ],
2079 &["max_range_m"],
2080 )?;
2081 validate_required_numbers(object, path, &["max_range_m"])?;
2082 validate_optional_number(object, path, "zero_distance_m")?;
2083 validate_optional_number(object, path, "muzzle_angle_rad")?;
2084 validate_optional_numbers(
2085 object,
2086 path,
2087 &[
2088 "aim_azimuth_rad",
2089 "shot_azimuth_rad",
2090 "shooting_angle_rad",
2091 "cant_angle_rad",
2092 "target_height_m",
2093 "ground_threshold_m",
2094 "zero_poi_up_m",
2095 "zero_poi_right_m",
2096 ],
2097 )?;
2098 if let Some(reference) = object.get("drops_reference") {
2101 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
2102 }
2103 Ok(())
2104}
2105
2106pub(crate) fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2107 let path = "$.atmosphere";
2108 let object = require_object(value, path)?;
2109 validate_members(
2110 object,
2111 path,
2112 &[
2113 "altitude_m",
2114 "temperature_k",
2115 "pressure_pa",
2116 "pressure_reference",
2117 "relative_humidity",
2118 "latitude_rad",
2119 ],
2120 &[],
2121 )?;
2122 validate_optional_numbers(
2123 object,
2124 path,
2125 &[
2126 "altitude_m",
2127 "temperature_k",
2128 "pressure_pa",
2129 "relative_humidity",
2130 "latitude_rad",
2131 ],
2132 )?;
2133 if let Some(reference) = object.get("pressure_reference") {
2139 validate_string_enum(
2140 reference,
2141 "$.atmosphere.pressure_reference",
2142 &["absolute", "qnh"],
2143 )?;
2144 }
2145 Ok(())
2146}
2147
2148fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2149 let path = "$.wind";
2150 let object = require_object(value, path)?;
2151 validate_members(
2152 object,
2153 path,
2154 &[
2155 "speed_mps",
2156 "direction_from_rad",
2157 "vertical_speed_mps",
2158 "segments",
2159 "wind_reference",
2160 ],
2161 &[],
2162 )?;
2163 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
2164 validate_optional_number(object, path, field)?;
2165 }
2166 if let Some(reference) = object.get("wind_reference") {
2168 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
2169 }
2170
2171 if let Some(segments) = object.get("segments") {
2172 let Some(segments) = segments.as_array() else {
2173 return Err(protocol_error(
2174 SolveErrorCodeV1::InvalidValue,
2175 "segments must be an array",
2176 "$.wind.segments",
2177 ));
2178 };
2179 for (index, segment) in segments.iter().enumerate() {
2180 let segment_path = format!("$.wind.segments[{index}]");
2181 let segment = require_object(segment, &segment_path)?;
2182 validate_members(
2183 segment,
2184 &segment_path,
2185 &[
2186 "until_distance_m",
2187 "speed_mps",
2188 "direction_from_rad",
2189 "vertical_speed_mps",
2190 ],
2191 &["until_distance_m", "speed_mps", "direction_from_rad"],
2192 )?;
2193 validate_required_numbers(
2194 segment,
2195 &segment_path,
2196 &["until_distance_m", "speed_mps", "direction_from_rad"],
2197 )?;
2198 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
2199 }
2200 }
2201 Ok(())
2202}
2203
2204fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2205 let path = "$.solver";
2206 let object = require_object(value, path)?;
2207 validate_members(object, path, &["method", "time_step_s"], &[])?;
2208 validate_optional_number(object, path, "time_step_s")?;
2209 if let Some(method) = object.get("method") {
2210 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
2211 }
2212 Ok(())
2213}
2214
2215pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
2224 "magnus",
2225 "coriolis",
2226 "enhanced_spin_drift",
2227 "wind_shear_model",
2228 "aerodynamic_jump",
2229];
2230
2231fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2232 let path = "$.effects";
2233 let object = require_object(value, path)?;
2234 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
2235 validate_optional_booleans(
2236 object,
2237 path,
2238 &[
2239 "magnus",
2240 "coriolis",
2241 "enhanced_spin_drift",
2242 "aerodynamic_jump",
2243 ],
2244 )?;
2245
2246 if let Some(model) = object.get("wind_shear_model") {
2250 validate_string_enum(
2251 model,
2252 "$.effects.wind_shear_model",
2253 &[
2254 "none",
2255 "logarithmic",
2256 "power_law",
2257 "ekman_spiral",
2258 "ekman",
2260 ],
2261 )?;
2262 }
2263
2264 if object.get("magnus").and_then(Value::as_bool) == Some(true)
2265 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
2266 {
2267 return Err(protocol_error(
2268 SolveErrorCodeV1::ConflictingFields,
2269 "magnus and enhanced_spin_drift cannot both be enabled",
2270 "$.effects",
2271 ));
2272 }
2273
2274 Ok(())
2275}
2276
2277fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2278 let path = "$.sampling";
2279 let object = require_object(value, path)?;
2280 validate_members(object, path, &["interval_m"], &[])?;
2281 validate_optional_number(object, path, "interval_m")
2282}
2283
2284pub(crate) fn require_object<'a>(
2285 value: &'a Value,
2286 path: &str,
2287) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
2288 value
2289 .as_object()
2290 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
2291}
2292
2293fn required_value<'a>(
2294 object: &'a Map<String, Value>,
2295 field: &str,
2296 parent_path: &str,
2297) -> Result<&'a Value, SolveErrorEnvelopeV1> {
2298 object.get(field).ok_or_else(|| {
2299 protocol_error(
2300 SolveErrorCodeV1::MissingField,
2301 format!("missing required field `{field}`"),
2302 child_path(parent_path, field),
2303 )
2304 })
2305}
2306
2307pub(crate) fn validate_members(
2308 object: &Map<String, Value>,
2309 path: &str,
2310 allowed: &[&str],
2311 required: &[&str],
2312) -> Result<(), SolveErrorEnvelopeV1> {
2313 if let Some(field) = object
2314 .keys()
2315 .find(|field| !allowed.contains(&field.as_str()))
2316 {
2317 return Err(protocol_error(
2318 SolveErrorCodeV1::UnknownField,
2319 format!("unknown field `{field}`"),
2320 child_path(path, field),
2321 ));
2322 }
2323
2324 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
2325 return Err(protocol_error(
2326 SolveErrorCodeV1::MissingField,
2327 format!("missing required field `{field}`"),
2328 child_path(path, field),
2329 ));
2330 }
2331 Ok(())
2332}
2333
2334fn validate_string_enum(
2335 value: &Value,
2336 path: &str,
2337 allowed: &[&str],
2338) -> Result<(), SolveErrorEnvelopeV1> {
2339 let Some(value) = value.as_str() else {
2340 return Err(protocol_error(
2341 SolveErrorCodeV1::InvalidValue,
2342 "expected a string enum value",
2343 path,
2344 ));
2345 };
2346 if !allowed.contains(&value) {
2347 return Err(protocol_error(
2348 SolveErrorCodeV1::InvalidValue,
2349 format!(
2350 "invalid value `{value}`; expected one of {}",
2351 allowed.join(", ")
2352 ),
2353 path,
2354 ));
2355 }
2356 Ok(())
2357}
2358
2359fn validate_required_numbers(
2360 object: &Map<String, Value>,
2361 parent_path: &str,
2362 fields: &[&str],
2363) -> Result<(), SolveErrorEnvelopeV1> {
2364 for field in fields {
2365 let value = required_value(object, field, parent_path)?;
2366 validate_number(value, &child_path(parent_path, field))?;
2367 }
2368 Ok(())
2369}
2370
2371fn validate_optional_numbers(
2372 object: &Map<String, Value>,
2373 parent_path: &str,
2374 fields: &[&str],
2375) -> Result<(), SolveErrorEnvelopeV1> {
2376 for field in fields {
2377 validate_optional_number(object, parent_path, field)?;
2378 }
2379 Ok(())
2380}
2381
2382fn validate_optional_number(
2383 object: &Map<String, Value>,
2384 parent_path: &str,
2385 field: &str,
2386) -> Result<(), SolveErrorEnvelopeV1> {
2387 if let Some(value) = object.get(field) {
2388 validate_number(value, &child_path(parent_path, field))?;
2389 }
2390 Ok(())
2391}
2392
2393fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
2394 if value.is_number() {
2395 Ok(())
2396 } else {
2397 Err(protocol_error(
2398 SolveErrorCodeV1::InvalidValue,
2399 "expected a number",
2400 path,
2401 ))
2402 }
2403}
2404
2405fn validate_optional_booleans(
2406 object: &Map<String, Value>,
2407 parent_path: &str,
2408 fields: &[&str],
2409) -> Result<(), SolveErrorEnvelopeV1> {
2410 for field in fields {
2411 if let Some(value) = object.get(*field) {
2412 if !value.is_boolean() {
2413 return Err(protocol_error(
2414 SolveErrorCodeV1::InvalidValue,
2415 "expected a boolean",
2416 child_path(parent_path, field),
2417 ));
2418 }
2419 }
2420 }
2421 Ok(())
2422}
2423
2424fn child_path(parent: &str, field: &str) -> String {
2425 format!("{parent}.{field}")
2426}
2427
2428#[cfg(test)]
2430mod request_range_tests {
2431 use super::*;
2432
2433 fn valid_request_json() -> String {
2436 r#"{"schema_version":1,
2437 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2438 "drag_model":"G7","ballistic_coefficient":0.243},
2439 "rifle":{"muzzle_velocity_mps":823.0},
2440 "shot":{"max_range_m":1000.0},
2441 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2442 .to_string()
2443 }
2444
2445 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2446 decode_solve_request_v1(json).expect_err("request should have been rejected")
2447 }
2448
2449 #[test]
2450 fn an_ordinary_request_still_decodes() {
2451 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2452 }
2453
2454 #[test]
2460 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2461 let reproducer = r#"{"schema_version":1,
2462 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2463 "drag_model":"G7","ballistic_coefficient":1.2e2},
2464 "rifle":{"muzzle_velocity_mps":823.0},
2465 "shot":{"max_range_m":100.0},
2466 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2467
2468 let envelope = decode_err(reproducer);
2469 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2470 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2471 }
2472
2473 #[test]
2476 fn the_rejection_envelope_round_trips() {
2477 let envelope = decode_err(
2478 r#"{"schema_version":1,
2479 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2480 "drag_model":"G7","ballistic_coefficient":0.243},
2481 "rifle":{"muzzle_velocity_mps":823.0},
2482 "shot":{"max_range_m":100.0},
2483 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2484 );
2485 let encoded = serde_json::to_string(&envelope).expect("serialize");
2486 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2487 assert_eq!(decoded, envelope);
2488 }
2489
2490 #[test]
2491 fn each_bounded_field_reports_its_own_path() {
2492 for (field, bad_value, path) in [
2493 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2494 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2495 (
2496 "ballistic_coefficient",
2497 "1.0e6",
2498 "$.projectile.ballistic_coefficient",
2499 ),
2500 ("length_m", "1.0e-9", "$.projectile.length_m"),
2501 ] {
2502 let json = valid_request_json().replace(
2503 &format!("\"{field}\":{}", default_for(field)),
2504 &format!("\"{field}\":{bad_value}"),
2505 );
2506 let envelope = decode_err(&json);
2507 assert_eq!(envelope.error.path(), Some(path), "wrong path for {field}");
2508 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2509 }
2510 }
2511
2512 fn default_for(field: &str) -> &'static str {
2513 match field {
2514 "mass_kg" => "0.01134",
2515 "diameter_m" => "0.00782",
2516 "ballistic_coefficient" => "0.243",
2517 "length_m" => "0.031",
2518 other => panic!("no default recorded for {other}"),
2519 }
2520 }
2521
2522 #[test]
2526 fn muzzle_velocity_is_deliberately_left_unbounded() {
2527 let json = valid_request_json().replace("823.0", "1.0e308");
2528 decode_solve_request_v1(&json)
2529 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2530 }
2531}