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(
241 default,
242 skip_serializing_if = "Option::is_none",
243 deserialize_with = "deserialize_present"
244 )]
245 pub drag_table: Option<DragTableV1>,
246 #[serde(
260 default,
261 skip_serializing_if = "Option::is_none",
262 deserialize_with = "deserialize_present"
263 )]
264 pub bc_segments: Option<Vec<BcSegmentV1>>,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct BcSegmentV1 {
291 pub velocity_min_mps: f64,
293 pub velocity_max_mps: f64,
296 pub ballistic_coefficient: f64,
299}
300
301pub const MIN_DRAG_TABLE_POINTS_V1: usize = 2;
303
304pub const MAX_DRAG_TABLE_POINTS_V1: usize = 4096;
307
308pub const MIN_BC_SEGMENTS_V1: usize = 1;
311
312pub const MAX_BC_SEGMENTS_V1: usize = 64;
316
317pub const ONE_BAND_BC_RELATIVE_TOLERANCE_V1: f64 = 1.0e-6;
323
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct DragTableV1 {
333 pub kind: DragTableKindV1,
334 pub points: Vec<DragPointV1>,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum DragTableKindV1 {
343 Reference,
346 Projectile,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct DragPointV1 {
355 pub mach: f64,
356 pub cd: f64,
357}
358
359pub(crate) fn validate_drag_table_v1(table: &DragTableV1) -> Result<(), SolveErrorEnvelopeV1> {
365 let points_path = "$.projectile.drag_table.points";
366 let count = table.points.len();
367 if !(MIN_DRAG_TABLE_POINTS_V1..=MAX_DRAG_TABLE_POINTS_V1).contains(&count) {
368 return Err(protocol_error(
369 SolveErrorCodeV1::InvalidValue,
370 format!(
371 "drag_table must have {MIN_DRAG_TABLE_POINTS_V1} to \
372 {MAX_DRAG_TABLE_POINTS_V1} points, got {count}"
373 ),
374 points_path,
375 ));
376 }
377 let mut previous_mach: Option<f64> = None;
378 for (index, point) in table.points.iter().enumerate() {
379 let mach_path = format!("{points_path}[{index}].mach");
380 if !point.mach.is_finite() || point.mach < 0.0 {
381 return Err(protocol_error(
382 SolveErrorCodeV1::InvalidValue,
383 format!("mach must be finite and >= 0, got {}", point.mach),
384 mach_path,
385 ));
386 }
387 if let Some(previous) = previous_mach.filter(|previous| point.mach <= *previous) {
388 return Err(protocol_error(
389 SolveErrorCodeV1::InvalidValue,
390 format!(
391 "mach must strictly ascend; {} follows {previous} at points[{}]",
392 point.mach,
393 index - 1
394 ),
395 mach_path,
396 ));
397 }
398 if !point.cd.is_finite() || point.cd <= 0.0 {
399 return Err(protocol_error(
400 SolveErrorCodeV1::InvalidValue,
401 format!("cd must be finite and > 0, got {}", point.cd),
402 format!("{points_path}[{index}].cd"),
403 ));
404 }
405 previous_mach = Some(point.mach);
406 }
407 Ok(())
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
412pub enum DragModelV1 {
413 #[serde(rename = "G1")]
414 G1,
415 #[serde(rename = "G6")]
416 G6,
417 #[serde(rename = "G7")]
418 G7,
419 #[serde(rename = "G8")]
420 G8,
421 #[serde(rename = "G2")]
422 G2,
423 #[serde(rename = "G5")]
424 G5,
425 #[serde(rename = "GI")]
426 GI,
427 #[serde(rename = "GS")]
428 GS,
429 #[serde(rename = "RA4")]
430 RA4,
431}
432
433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
435#[serde(deny_unknown_fields)]
436pub struct RifleV1 {
437 pub muzzle_velocity_mps: f64,
438 #[serde(
439 default,
440 skip_serializing_if = "Option::is_none",
441 deserialize_with = "deserialize_present"
442 )]
443 pub sight_height_m: Option<f64>,
444 #[serde(
445 default,
446 skip_serializing_if = "Option::is_none",
447 deserialize_with = "deserialize_present"
448 )]
449 pub muzzle_height_m: Option<f64>,
450 #[serde(
469 default,
470 skip_serializing_if = "Option::is_none",
471 deserialize_with = "deserialize_present"
472 )]
473 pub twist_rate_m_per_turn: Option<f64>,
474 #[serde(
479 default,
480 skip_serializing_if = "Option::is_none",
481 deserialize_with = "deserialize_present"
482 )]
483 pub twist_direction: Option<TwistDirectionV1>,
484 #[serde(
492 default,
493 skip_serializing_if = "Option::is_none",
494 deserialize_with = "deserialize_present"
495 )]
496 pub sight_offset_lateral_m: Option<f64>,
497}
498
499#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub enum TwistDirectionV1 {
503 Left,
504 #[default]
505 Right,
506}
507
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510#[serde(deny_unknown_fields)]
511pub struct ShotV1 {
512 pub max_range_m: f64,
513 #[serde(
514 default,
515 skip_serializing_if = "Option::is_none",
516 deserialize_with = "deserialize_present"
517 )]
518 pub zero_distance_m: Option<f64>,
519 #[serde(
520 default,
521 skip_serializing_if = "Option::is_none",
522 deserialize_with = "deserialize_present"
523 )]
524 pub muzzle_angle_rad: Option<f64>,
525 #[serde(
526 default,
527 skip_serializing_if = "Option::is_none",
528 deserialize_with = "deserialize_present"
529 )]
530 pub aim_azimuth_rad: Option<f64>,
531 #[serde(
532 default,
533 skip_serializing_if = "Option::is_none",
534 deserialize_with = "deserialize_present"
535 )]
536 pub shot_azimuth_rad: Option<f64>,
537 #[serde(
538 default,
539 skip_serializing_if = "Option::is_none",
540 deserialize_with = "deserialize_present"
541 )]
542 pub shooting_angle_rad: Option<f64>,
543 #[serde(
544 default,
545 skip_serializing_if = "Option::is_none",
546 deserialize_with = "deserialize_present"
547 )]
548 pub cant_angle_rad: Option<f64>,
549 #[serde(
567 default,
568 skip_serializing_if = "Option::is_none",
569 deserialize_with = "deserialize_present"
570 )]
571 pub target_height_m: Option<f64>,
572 #[serde(
573 default,
574 skip_serializing_if = "Option::is_none",
575 deserialize_with = "deserialize_present"
576 )]
577 pub ground_threshold_m: Option<f64>,
578 #[serde(
584 default,
585 skip_serializing_if = "Option::is_none",
586 deserialize_with = "deserialize_present"
587 )]
588 pub zero_poi_up_m: Option<f64>,
589 #[serde(
593 default,
594 skip_serializing_if = "Option::is_none",
595 deserialize_with = "deserialize_present"
596 )]
597 pub zero_poi_right_m: Option<f64>,
598 #[serde(
606 default,
607 skip_serializing_if = "Option::is_none",
608 deserialize_with = "deserialize_present"
609 )]
610 pub drops_reference: Option<DropsReferenceV1>,
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
615#[serde(rename_all = "snake_case")]
616pub enum DropsReferenceV1 {
617 #[default]
619 Los,
620 Target,
623}
624
625#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct AtmosphereV1 {
642 #[serde(
643 default,
644 skip_serializing_if = "Option::is_none",
645 deserialize_with = "deserialize_present"
646 )]
647 pub altitude_m: Option<f64>,
648 #[serde(
651 default,
652 skip_serializing_if = "Option::is_none",
653 deserialize_with = "deserialize_present"
654 )]
655 pub temperature_k: Option<f64>,
656 #[serde(
662 default,
663 skip_serializing_if = "Option::is_none",
664 deserialize_with = "deserialize_present"
665 )]
666 pub pressure_pa: Option<f64>,
667 #[serde(
673 default,
674 skip_serializing_if = "Option::is_none",
675 deserialize_with = "deserialize_present"
676 )]
677 pub pressure_reference: Option<PressureReferenceV1>,
678 #[serde(
679 default,
680 skip_serializing_if = "Option::is_none",
681 deserialize_with = "deserialize_present"
682 )]
683 pub relative_humidity: Option<f64>,
684 #[serde(
685 default,
686 skip_serializing_if = "Option::is_none",
687 deserialize_with = "deserialize_present"
688 )]
689 pub latitude_rad: Option<f64>,
690}
691
692#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "snake_case")]
697pub enum PressureReferenceV1 {
698 #[default]
699 Absolute,
700 Qnh,
701}
702
703#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
708#[serde(deny_unknown_fields)]
709pub struct WindV1 {
710 #[serde(
711 default,
712 skip_serializing_if = "Option::is_none",
713 deserialize_with = "deserialize_present"
714 )]
715 pub speed_mps: Option<f64>,
716 #[serde(
717 default,
718 skip_serializing_if = "Option::is_none",
719 deserialize_with = "deserialize_present"
720 )]
721 pub direction_from_rad: Option<f64>,
722 #[serde(
723 default,
724 skip_serializing_if = "Option::is_none",
725 deserialize_with = "deserialize_present"
726 )]
727 pub vertical_speed_mps: Option<f64>,
728 #[serde(
729 default,
730 skip_serializing_if = "Option::is_none",
731 deserialize_with = "deserialize_present"
732 )]
733 pub segments: Option<Vec<WindSegmentV1>>,
734 #[serde(
747 default,
748 skip_serializing_if = "Option::is_none",
749 deserialize_with = "deserialize_present"
750 )]
751 pub wind_reference: Option<WindReferenceV1>,
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
756#[serde(rename_all = "snake_case")]
757pub enum WindReferenceV1 {
758 #[default]
760 Shooter,
761 Compass,
763}
764
765#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
767#[serde(deny_unknown_fields)]
768pub struct WindSegmentV1 {
769 pub until_distance_m: f64,
770 pub speed_mps: f64,
771 pub direction_from_rad: f64,
772 #[serde(
773 default,
774 skip_serializing_if = "Option::is_none",
775 deserialize_with = "deserialize_present"
776 )]
777 pub vertical_speed_mps: Option<f64>,
778}
779
780#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
782#[serde(deny_unknown_fields)]
783pub struct SolverV1 {
784 #[serde(
785 default,
786 skip_serializing_if = "Option::is_none",
787 deserialize_with = "deserialize_present"
788 )]
789 pub method: Option<SolverMethodV1>,
790 #[serde(
792 default,
793 skip_serializing_if = "Option::is_none",
794 deserialize_with = "deserialize_present"
795 )]
796 pub time_step_s: Option<f64>,
797}
798
799#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
801#[serde(rename_all = "snake_case")]
802pub enum SolverMethodV1 {
803 Euler,
804 Rk4,
805 #[default]
806 Rk45,
807}
808
809#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
811#[serde(deny_unknown_fields)]
812pub struct EffectsV1 {
813 #[serde(
825 default,
826 skip_serializing_if = "Option::is_none",
827 deserialize_with = "deserialize_present"
828 )]
829 pub magnus: Option<bool>,
830 #[serde(
834 default,
835 skip_serializing_if = "Option::is_none",
836 deserialize_with = "deserialize_present"
837 )]
838 pub coriolis: Option<bool>,
839 #[serde(
852 default,
853 skip_serializing_if = "Option::is_none",
854 deserialize_with = "deserialize_present"
855 )]
856 pub enhanced_spin_drift: Option<bool>,
857 #[serde(
867 default,
868 skip_serializing_if = "Option::is_none",
869 deserialize_with = "deserialize_present"
870 )]
871 pub wind_shear_model: Option<WindShearModelV1>,
872 #[serde(
889 default,
890 skip_serializing_if = "Option::is_none",
891 deserialize_with = "deserialize_present"
892 )]
893 pub aerodynamic_jump: Option<bool>,
894}
895
896#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
904#[serde(rename_all = "snake_case")]
905pub enum WindShearModelV1 {
906 #[default]
909 None,
910 Logarithmic,
912 PowerLaw,
914 #[serde(alias = "ekman")]
919 EkmanSpiral,
920}
921
922impl WindShearModelV1 {
923 pub fn as_engine_str(self) -> &'static str {
928 match self {
929 WindShearModelV1::None => "none",
930 WindShearModelV1::Logarithmic => "logarithmic",
931 WindShearModelV1::PowerLaw => "power_law",
932 WindShearModelV1::EkmanSpiral => "ekman_spiral",
933 }
934 }
935
936 pub fn is_enabled(self) -> bool {
943 !matches!(self, WindShearModelV1::None)
944 }
945}
946
947#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct SamplingV1 {
951 #[serde(
952 default,
953 skip_serializing_if = "Option::is_none",
954 deserialize_with = "deserialize_present"
955 )]
956 pub interval_m: Option<f64>,
957}
958
959#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965#[serde(deny_unknown_fields)]
966pub struct ResolvedSolveRequestV1 {
967 pub schema_version: SchemaVersionV1,
968 pub projectile: ResolvedProjectileV1,
969 pub rifle: ResolvedRifleV1,
970 pub shot: ResolvedShotV1,
971 pub atmosphere: ResolvedAtmosphereV1,
972 pub wind: ResolvedWindV1,
973 pub solver: ResolvedSolverV1,
974 pub effects: ResolvedEffectsV1,
975 pub sampling: ResolvedSamplingV1,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub reticle: Option<ReticleRequestV1>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub corrections: Option<CorrectionsV1>,
988}
989
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
992#[serde(deny_unknown_fields)]
993pub struct ResolvedProjectileV1 {
994 pub mass_kg: f64,
995 pub diameter_m: f64,
996 #[serde(default, skip_serializing_if = "Option::is_none")]
997 pub length_m: Option<f64>,
998 pub drag_model: DragModelV1,
999 pub ballistic_coefficient: f64,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub drag_table: Option<DragTableV1>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub bc_segments: Option<Vec<BcSegmentV1>>,
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1014#[serde(deny_unknown_fields)]
1015pub struct ResolvedRifleV1 {
1016 pub muzzle_velocity_mps: f64,
1017 pub sight_height_m: f64,
1018 pub muzzle_height_m: f64,
1019 pub twist_rate_m_per_turn: f64,
1020 pub twist_direction: TwistDirectionV1,
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub sight_offset_lateral_m: Option<f64>,
1025}
1026
1027#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1039#[serde(deny_unknown_fields)]
1040pub struct ResolvedShotV1 {
1041 pub max_range_m: f64,
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub zero_distance_m: Option<f64>,
1044 pub muzzle_angle_rad: f64,
1045 pub aim_azimuth_rad: f64,
1046 pub shot_azimuth_rad: f64,
1047 pub shooting_angle_rad: f64,
1048 pub cant_angle_rad: f64,
1049 pub target_height_m: f64,
1050 pub ground_threshold_m: f64,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub zero_poi_up_m: Option<f64>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub zero_poi_right_m: Option<f64>,
1059 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub drops_reference: Option<DropsReferenceV1>,
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1067#[serde(deny_unknown_fields)]
1068pub struct ResolvedAtmosphereV1 {
1069 pub altitude_m: f64,
1070 pub temperature_k: f64,
1071 pub pressure_pa: f64,
1072 pub relative_humidity: f64,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub latitude_rad: Option<f64>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub pressure_reference: Option<PressureReferenceV1>,
1079}
1080
1081#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1086#[serde(untagged)]
1087pub enum ResolvedWindV1 {
1088 Constant(ResolvedConstantWindV1),
1089 Segmented(ResolvedSegmentedWindV1),
1090}
1091
1092#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1094#[serde(deny_unknown_fields)]
1095pub struct ResolvedConstantWindV1 {
1096 pub speed_mps: f64,
1097 pub direction_from_rad: f64,
1098 pub vertical_speed_mps: f64,
1099 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub wind_reference: Option<WindReferenceV1>,
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1108#[serde(deny_unknown_fields)]
1109pub struct ResolvedSegmentedWindV1 {
1110 pub segments: Vec<ResolvedWindSegmentV1>,
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub wind_reference: Option<WindReferenceV1>,
1116}
1117
1118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1120#[serde(deny_unknown_fields)]
1121pub struct ResolvedWindSegmentV1 {
1122 pub until_distance_m: f64,
1123 pub speed_mps: f64,
1124 pub direction_from_rad: f64,
1125 pub vertical_speed_mps: f64,
1126}
1127
1128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct ResolvedSolverV1 {
1132 pub method: SolverMethodV1,
1133 pub time_step_s: f64,
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1138#[serde(deny_unknown_fields)]
1139pub struct ResolvedEffectsV1 {
1140 pub magnus: bool,
1141 pub coriolis: bool,
1142 pub enhanced_spin_drift: bool,
1143 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub wind_shear_model: Option<WindShearModelV1>,
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1158 pub aerodynamic_jump: Option<bool>,
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1163#[serde(deny_unknown_fields)]
1164pub struct ResolvedSamplingV1 {
1165 pub interval_m: f64,
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1170#[serde(deny_unknown_fields)]
1171pub struct SolveSuccessV1 {
1172 pub schema_version: SchemaVersionV1,
1173 pub engine_version: String,
1174 pub status: SuccessStatusV1,
1175 pub resolved_request: ResolvedSolveRequestV1,
1176 #[serde(default)]
1177 pub assumptions: Vec<SolveNoticeV1>,
1178 #[serde(default)]
1179 pub warnings: Vec<SolveNoticeV1>,
1180 pub summary: SolveSummaryV1,
1181 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
1182 pub samples: Vec<TrajectorySampleV1>,
1183 #[serde(default, skip_serializing_if = "Option::is_none")]
1187 pub reticle_hold: Option<ReticleHoldV1>,
1188}
1189
1190fn serialize_solve_samples_v1<S>(
1191 samples: &[TrajectorySampleV1],
1192 serializer: S,
1193) -> Result<S::Ok, S::Error>
1194where
1195 S: Serializer,
1196{
1197 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
1198 return Err(serde::ser::Error::custom(format_args!(
1199 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1200 samples.len()
1201 )));
1202 }
1203 samples.serialize(serializer)
1204}
1205
1206impl SolveSuccessV1 {
1207 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
1213 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
1214 return Ok(());
1215 }
1216
1217 Err(SolveErrorEnvelopeV1::new(
1218 SolveErrorV1::new(
1219 SolveErrorCodeV1::ResourceLimit,
1220 format!(
1221 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1222 self.samples.len()
1223 ),
1224 )
1225 .at_path("$.sampling.interval_m"),
1226 ))
1227 }
1228}
1229
1230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1232#[serde(rename_all = "snake_case")]
1233pub enum SuccessStatusV1 {
1234 Ok,
1235}
1236
1237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1239#[serde(deny_unknown_fields)]
1240pub struct SolveNoticeV1 {
1241 pub code: String,
1242 pub message: String,
1243 #[serde(default, skip_serializing_if = "Option::is_none")]
1244 pub path: Option<String>,
1245}
1246
1247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1249#[serde(deny_unknown_fields)]
1250pub struct SolveSummaryV1 {
1251 pub actual_range_m: f64,
1252 pub maximum_height_m: f64,
1255 pub time_of_flight_s: f64,
1256 pub terminal_speed_mps: f64,
1257 pub terminal_energy_j: f64,
1258 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub stability_factor: Option<f64>,
1262 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub spin_drift_m: Option<f64>,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1275 pub equivalent_horizontal_range_m: Option<f64>,
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1288 pub aerodynamic_jump_moa: Option<f64>,
1289 pub termination: TerminationReasonV1,
1290}
1291
1292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1294#[serde(rename_all = "snake_case")]
1295pub enum TerminationReasonV1 {
1296 MaxRange,
1297 GroundThreshold,
1298 TimeLimit,
1299 VelocityFloor,
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1304#[serde(deny_unknown_fields)]
1305pub struct TrajectorySampleV1 {
1306 pub distance_m: f64,
1307 pub time_s: f64,
1308 pub speed_mps: f64,
1309 pub energy_j: f64,
1310 pub drop_m: f64,
1312 pub windage_m: f64,
1314 pub mach: f64,
1315 #[serde(default)]
1316 pub flags: Vec<SampleFlagV1>,
1317}
1318
1319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1321#[serde(rename_all = "snake_case")]
1322pub enum SampleFlagV1 {
1323 Transonic,
1324 Subsonic,
1325 Terminal,
1326 GroundThreshold,
1327}
1328
1329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1331#[serde(deny_unknown_fields)]
1332pub struct SolveErrorEnvelopeV1 {
1333 pub schema_version: SchemaVersionV1,
1334 pub status: ErrorStatusV1,
1335 pub error: SolveErrorV1,
1336}
1337
1338impl SolveErrorEnvelopeV1 {
1339 pub fn new(error: SolveErrorV1) -> Self {
1341 Self {
1342 schema_version: SchemaVersionV1,
1343 status: ErrorStatusV1::Error,
1344 error,
1345 }
1346 }
1347}
1348
1349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1351#[serde(rename_all = "snake_case")]
1352pub enum ErrorStatusV1 {
1353 Error,
1354}
1355
1356#[derive(Debug, Clone, PartialEq, Eq)]
1362pub struct SolveErrorV1 {
1363 pub code: SolveErrorCodeV1,
1364 pub message: String,
1365 location: SolveErrorLocationV1,
1366}
1367
1368#[derive(Debug, Clone, PartialEq, Eq)]
1369enum SolveErrorLocationV1 {
1370 None,
1371 Path(String),
1372 Source {
1373 line: NonZeroUsize,
1374 column: NonZeroUsize,
1375 },
1376}
1377
1378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1380pub enum SolveErrorLocationErrorV1 {
1381 ZeroLine,
1382 ZeroColumn,
1383}
1384
1385impl fmt::Display for SolveErrorLocationErrorV1 {
1386 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1387 match self {
1388 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1389 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1390 }
1391 }
1392}
1393
1394impl std::error::Error for SolveErrorLocationErrorV1 {}
1395
1396impl SolveErrorV1 {
1397 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1399 Self {
1400 code,
1401 message: message.into(),
1402 location: SolveErrorLocationV1::None,
1403 }
1404 }
1405
1406 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1408 self.location = SolveErrorLocationV1::Path(path.into());
1409 self
1410 }
1411
1412 pub fn at_location(
1414 mut self,
1415 line: usize,
1416 column: usize,
1417 ) -> Result<Self, SolveErrorLocationErrorV1> {
1418 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1419 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1420 self.location = SolveErrorLocationV1::Source { line, column };
1421 Ok(self)
1422 }
1423
1424 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1429 self.location = SolveErrorLocationV1::Source {
1430 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1431 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1432 };
1433 self
1434 }
1435
1436 pub fn path(&self) -> Option<&str> {
1438 match &self.location {
1439 SolveErrorLocationV1::Path(path) => Some(path),
1440 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1441 }
1442 }
1443
1444 pub fn line(&self) -> Option<usize> {
1446 match &self.location {
1447 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1448 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1449 }
1450 }
1451
1452 pub fn column(&self) -> Option<usize> {
1454 match &self.location {
1455 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1456 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1457 }
1458 }
1459}
1460
1461impl Serialize for SolveErrorV1 {
1462 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1463 where
1464 S: Serializer,
1465 {
1466 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1467 state.serialize_field("code", &self.code)?;
1468 state.serialize_field("message", &self.message)?;
1469 state.serialize_field("path", &self.path())?;
1470 state.serialize_field("line", &self.line())?;
1471 state.serialize_field("column", &self.column())?;
1472 state.end()
1473 }
1474}
1475
1476impl<'de> Deserialize<'de> for SolveErrorV1 {
1477 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1478 where
1479 D: Deserializer<'de>,
1480 {
1481 #[derive(Deserialize)]
1482 #[serde(deny_unknown_fields)]
1483 struct SolveErrorWireV1 {
1484 code: SolveErrorCodeV1,
1485 message: String,
1486 path: Option<String>,
1487 line: Option<usize>,
1488 column: Option<usize>,
1489 }
1490
1491 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1492 let location = match (wire.path, wire.line, wire.column) {
1493 (None, None, None) => SolveErrorLocationV1::None,
1494 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1495 (None, Some(line), Some(column)) => {
1496 let line = NonZeroUsize::new(line)
1497 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1498 let column = NonZeroUsize::new(column)
1499 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1500 SolveErrorLocationV1::Source { line, column }
1501 }
1502 _ => {
1503 return Err(de::Error::custom(
1504 "error location must contain either path or both line and column",
1505 ));
1506 }
1507 };
1508
1509 Ok(Self {
1510 code: wire.code,
1511 message: wire.message,
1512 location,
1513 })
1514 }
1515}
1516
1517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1519#[serde(rename_all = "snake_case")]
1520pub enum SolveErrorCodeV1 {
1521 InvalidJson,
1522 UnsupportedSchemaVersion,
1523 UnknownField,
1524 MissingField,
1525 InvalidValue,
1526 ConflictingFields,
1527 ResourceLimit,
1528 SolveFailed,
1529 IoError,
1530 InternalError,
1531}
1532
1533pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1540 let value: Value = serde_json::from_str(input).map_err(|error| {
1541 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1542 .at_parser_location(error.line(), error.column());
1543 envelope(error)
1544 })?;
1545
1546 validate_request_shape(&value)?;
1547
1548 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1549 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1550 })?;
1551
1552 validate_request_ranges(&request)?;
1553
1554 Ok(request)
1555}
1556
1557pub(crate) mod limits {
1572 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1576 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1578 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1580 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1582
1583 }
1590
1591fn require_range(
1592 value: f64,
1593 (min, max): (f64, f64),
1594 path: &str,
1595) -> Result<(), SolveErrorEnvelopeV1> {
1596 if !value.is_finite() {
1597 return Err(protocol_error(
1598 SolveErrorCodeV1::InvalidValue,
1599 format!("{path} must be a finite number"),
1600 path,
1601 ));
1602 }
1603 if value < min || value > max {
1604 return Err(protocol_error(
1605 SolveErrorCodeV1::InvalidValue,
1606 format!("{path} must be between {min} and {max}, got {value}"),
1607 path,
1608 ));
1609 }
1610 Ok(())
1611}
1612
1613fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1618 require_range(
1619 request.projectile.mass_kg,
1620 limits::MASS_KG,
1621 "$.projectile.mass_kg",
1622 )?;
1623 require_range(
1624 request.projectile.diameter_m,
1625 limits::DIAMETER_M,
1626 "$.projectile.diameter_m",
1627 )?;
1628 require_range(
1629 request.projectile.ballistic_coefficient,
1630 limits::BALLISTIC_COEFFICIENT,
1631 "$.projectile.ballistic_coefficient",
1632 )?;
1633 if let Some(length_m) = request.projectile.length_m {
1634 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1635 }
1636 if let Some(drag_table) = &request.projectile.drag_table {
1637 validate_drag_table_v1(drag_table)?;
1638 }
1639 if let Some(bc_segments) = &request.projectile.bc_segments {
1640 validate_bc_segments_v1(bc_segments, request.projectile.ballistic_coefficient)?;
1641 }
1642 Ok(())
1643}
1644
1645fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1646 SolveErrorEnvelopeV1::new(error)
1647}
1648
1649fn protocol_error(
1650 code: SolveErrorCodeV1,
1651 message: impl Into<String>,
1652 path: impl Into<String>,
1653) -> SolveErrorEnvelopeV1 {
1654 envelope(SolveErrorV1::new(code, message).at_path(path))
1655}
1656
1657fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1658 let root = require_object(value, "$")?;
1659
1660 validate_schema_version(root)?;
1664 validate_members(
1665 root,
1666 "$",
1667 &[
1668 "schema_version",
1669 "projectile",
1670 "rifle",
1671 "shot",
1672 "atmosphere",
1673 "wind",
1674 "solver",
1675 "effects",
1676 "sampling",
1677 "reticle",
1679 "corrections",
1681 ],
1682 &[
1683 "schema_version",
1684 "projectile",
1685 "rifle",
1686 "shot",
1687 "atmosphere",
1688 "wind",
1689 "solver",
1690 "effects",
1691 "sampling",
1692 ],
1693 )?;
1694
1695 validate_projectile(required_value(root, "projectile", "$")?)?;
1696 validate_rifle(required_value(root, "rifle", "$")?)?;
1697 validate_shot(required_value(root, "shot", "$")?)?;
1698 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1699 validate_wind(required_value(root, "wind", "$")?)?;
1700 validate_solver(required_value(root, "solver", "$")?)?;
1701 validate_effects(required_value(root, "effects", "$")?)?;
1702 validate_sampling(required_value(root, "sampling", "$")?)?;
1703 if let Some(reticle) = root.get("reticle") {
1704 validate_reticle(reticle)?;
1705 }
1706 if let Some(corrections) = root.get("corrections") {
1707 validate_corrections(corrections)?;
1708 }
1709 Ok(())
1710}
1711
1712fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1717 let path = "$.corrections";
1718 let object = require_object(value, path)?;
1719 validate_members(object, path, &["bc5d_table_path"], &[])?;
1720 if let Some(table_path) = object.get("bc5d_table_path") {
1721 if !table_path.is_string() {
1722 return Err(protocol_error(
1723 SolveErrorCodeV1::InvalidValue,
1724 "expected a string",
1725 "$.corrections.bc5d_table_path",
1726 ));
1727 }
1728 }
1729 Ok(())
1730}
1731
1732fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1739 let path = "$.reticle";
1740 let object = require_object(value, path)?;
1741 validate_members(
1742 object,
1743 path,
1744 &["range_m", "magnification", "description"],
1745 &["range_m", "magnification", "description"],
1746 )?;
1747 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1748 require_object(
1749 required_value(object, "description", path)?,
1750 "$.reticle.description",
1751 )?;
1752 Ok(())
1753}
1754
1755fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1756 let value = required_value(root, "schema_version", "$")?;
1757 let version = if let Some(version) = value.as_i64() {
1758 i128::from(version)
1759 } else if let Some(version) = value.as_u64() {
1760 i128::from(version)
1761 } else {
1762 return Err(protocol_error(
1763 SolveErrorCodeV1::InvalidValue,
1764 "schema_version must be the integer 1",
1765 "$.schema_version",
1766 ));
1767 };
1768 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1769 return Err(protocol_error(
1770 SolveErrorCodeV1::UnsupportedSchemaVersion,
1771 format!(
1772 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1773 ),
1774 "$.schema_version",
1775 ));
1776 }
1777 Ok(())
1778}
1779
1780fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1781 let path = "$.projectile";
1782 let object = require_object(value, path)?;
1783 validate_members(
1784 object,
1785 path,
1786 &[
1787 "mass_kg",
1788 "diameter_m",
1789 "length_m",
1790 "drag_model",
1791 "ballistic_coefficient",
1792 "drag_table",
1794 "bc_segments",
1796 ],
1797 &[
1798 "mass_kg",
1799 "diameter_m",
1800 "drag_model",
1801 "ballistic_coefficient",
1802 ],
1803 )?;
1804 validate_required_numbers(
1805 object,
1806 path,
1807 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1808 )?;
1809 validate_optional_number(object, path, "length_m")?;
1810 validate_string_enum(
1811 required_value(object, "drag_model", path)?,
1812 "$.projectile.drag_model",
1813 &DRAG_MODEL_WIRE_NAMES_V1,
1814 )?;
1815 if let Some(drag_table) = object.get("drag_table") {
1816 validate_drag_table_shape(drag_table)?;
1817 }
1818 if let Some(bc_segments) = object.get("bc_segments") {
1819 validate_bc_segments_shape(bc_segments)?;
1820 }
1821 Ok(())
1822}
1823
1824const BC_SEGMENT_FIELDS_V1: [&str; 3] = [
1826 "velocity_min_mps",
1827 "velocity_max_mps",
1828 "ballistic_coefficient",
1829];
1830
1831fn validate_bc_segments_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1837 let segments_path = "$.projectile.bc_segments";
1838 let Some(segments) = value.as_array() else {
1839 return Err(protocol_error(
1840 SolveErrorCodeV1::InvalidValue,
1841 "bc_segments must be an array",
1842 segments_path,
1843 ));
1844 };
1845 if segments.is_empty() {
1846 return Err(protocol_error(
1847 SolveErrorCodeV1::InvalidValue,
1848 bc_segment_count_message(0),
1849 segments_path,
1850 ));
1851 }
1852 for (index, segment) in segments.iter().enumerate() {
1853 let seg_path = format!("{segments_path}[{index}]");
1854 let segment = require_object(segment, &seg_path)?;
1855 validate_members(
1856 segment,
1857 &seg_path,
1858 &BC_SEGMENT_FIELDS_V1,
1859 &BC_SEGMENT_FIELDS_V1,
1860 )?;
1861 validate_required_numbers(segment, &seg_path, &BC_SEGMENT_FIELDS_V1)?;
1862 }
1863 Ok(())
1864}
1865
1866fn bc_segment_count_message(count: usize) -> String {
1867 format!("bc_segments must have {MIN_BC_SEGMENTS_V1} to {MAX_BC_SEGMENTS_V1} bands, got {count}")
1868}
1869
1870pub(crate) fn validate_bc_segments_v1(
1888 segments: &[BcSegmentV1],
1889 scalar_bc: f64,
1890) -> Result<(), SolveErrorEnvelopeV1> {
1891 let base = "$.projectile.bc_segments";
1892 let count = segments.len();
1893 if !(MIN_BC_SEGMENTS_V1..=MAX_BC_SEGMENTS_V1).contains(&count) {
1894 return Err(protocol_error(
1895 SolveErrorCodeV1::InvalidValue,
1896 bc_segment_count_message(count),
1897 base,
1898 ));
1899 }
1900 let mut previous: Option<BcSegmentV1> = None;
1901 for (index, seg) in segments.iter().enumerate() {
1902 let seg_path = format!("{base}[{index}]");
1903 let min_path = format!("{seg_path}.velocity_min_mps");
1904 let max_path = format!("{seg_path}.velocity_max_mps");
1905 if !seg.velocity_min_mps.is_finite() || seg.velocity_min_mps < 0.0 {
1906 return Err(protocol_error(
1907 SolveErrorCodeV1::InvalidValue,
1908 format!(
1909 "velocity_min_mps must be finite and >= 0, got {}",
1910 seg.velocity_min_mps
1911 ),
1912 min_path,
1913 ));
1914 }
1915 if !seg.velocity_max_mps.is_finite() || seg.velocity_max_mps <= seg.velocity_min_mps {
1916 return Err(protocol_error(
1917 SolveErrorCodeV1::InvalidValue,
1918 format!(
1919 "velocity_max_mps must be finite and > velocity_min_mps ({}), got {}",
1920 seg.velocity_min_mps, seg.velocity_max_mps
1921 ),
1922 max_path,
1923 ));
1924 }
1925 require_range(
1926 seg.ballistic_coefficient,
1927 limits::BALLISTIC_COEFFICIENT,
1928 &format!("{seg_path}.ballistic_coefficient"),
1929 )?;
1930 if let Some(prev) = previous {
1931 if seg.velocity_min_mps >= prev.velocity_min_mps {
1932 return Err(protocol_error(
1933 SolveErrorCodeV1::InvalidValue,
1934 format!(
1935 "velocity_min_mps must be below bc_segments[{}].velocity_min_mps ({}), \
1936 got {}: list the bands from the highest speed to the lowest, or \
1937 reverse the array",
1938 index - 1,
1939 prev.velocity_min_mps,
1940 seg.velocity_min_mps
1941 ),
1942 min_path,
1943 ));
1944 }
1945 if seg.velocity_max_mps > prev.velocity_min_mps {
1946 return Err(protocol_error(
1947 SolveErrorCodeV1::InvalidValue,
1948 format!(
1949 "velocity_max_mps ({}) overlaps bc_segments[{}], which starts at {} m/s: \
1950 a band must end at or below the start of the faster band before it",
1951 seg.velocity_max_mps,
1952 index - 1,
1953 prev.velocity_min_mps
1954 ),
1955 max_path,
1956 ));
1957 }
1958 }
1959 previous = Some(*seg);
1960 }
1961 if let [band] = segments {
1962 let difference = (band.ballistic_coefficient - scalar_bc).abs();
1963 let allowed = ONE_BAND_BC_RELATIVE_TOLERANCE_V1 * band.ballistic_coefficient.max(scalar_bc);
1964 if difference > allowed {
1965 return Err(protocol_error(
1966 SolveErrorCodeV1::ConflictingFields,
1967 format!(
1968 "a one-band ladder flies its band's BC at every speed, so \
1969 bc_segments[0].ballistic_coefficient ({}) must match \
1970 projectile.ballistic_coefficient ({scalar_bc}): send the same value in \
1971 both, or omit bc_segments",
1972 band.ballistic_coefficient
1973 ),
1974 format!("{base}[0].ballistic_coefficient"),
1975 ));
1976 }
1977 }
1978 Ok(())
1979}
1980
1981fn validate_drag_table_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1986 let path = "$.projectile.drag_table";
1987 let object = require_object(value, path)?;
1988 validate_members(object, path, &["kind", "points"], &["kind", "points"])?;
1989 validate_string_enum(
1990 required_value(object, "kind", path)?,
1991 "$.projectile.drag_table.kind",
1992 &["reference", "projectile"],
1993 )?;
1994 let points_path = "$.projectile.drag_table.points";
1995 let Some(points) = required_value(object, "points", path)?.as_array() else {
1996 return Err(protocol_error(
1997 SolveErrorCodeV1::InvalidValue,
1998 "points must be an array",
1999 points_path,
2000 ));
2001 };
2002 for (index, point) in points.iter().enumerate() {
2003 let point_path = format!("{points_path}[{index}]");
2004 let point = require_object(point, &point_path)?;
2005 validate_members(point, &point_path, &["mach", "cd"], &["mach", "cd"])?;
2006 validate_required_numbers(point, &point_path, &["mach", "cd"])?;
2007 }
2008 Ok(())
2009}
2010
2011fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2012 let path = "$.rifle";
2013 let object = require_object(value, path)?;
2014 validate_members(
2015 object,
2016 path,
2017 &[
2018 "muzzle_velocity_mps",
2019 "sight_height_m",
2020 "muzzle_height_m",
2021 "twist_rate_m_per_turn",
2022 "twist_direction",
2023 "sight_offset_lateral_m",
2024 ],
2025 &["muzzle_velocity_mps"],
2026 )?;
2027 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
2028 validate_optional_numbers(
2029 object,
2030 path,
2031 &[
2032 "sight_height_m",
2033 "muzzle_height_m",
2034 "twist_rate_m_per_turn",
2035 "sight_offset_lateral_m",
2036 ],
2037 )?;
2038 if let Some(direction) = object.get("twist_direction") {
2039 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
2040 }
2041 Ok(())
2042}
2043
2044fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2045 let path = "$.shot";
2046 let object = require_object(value, path)?;
2047 validate_members(
2048 object,
2049 path,
2050 &[
2051 "max_range_m",
2052 "zero_distance_m",
2053 "muzzle_angle_rad",
2054 "aim_azimuth_rad",
2055 "shot_azimuth_rad",
2056 "shooting_angle_rad",
2057 "cant_angle_rad",
2058 "target_height_m",
2059 "ground_threshold_m",
2060 "zero_poi_up_m",
2061 "zero_poi_right_m",
2062 "drops_reference",
2063 ],
2064 &["max_range_m"],
2065 )?;
2066 validate_required_numbers(object, path, &["max_range_m"])?;
2067 validate_optional_number(object, path, "zero_distance_m")?;
2068 validate_optional_number(object, path, "muzzle_angle_rad")?;
2069 validate_optional_numbers(
2070 object,
2071 path,
2072 &[
2073 "aim_azimuth_rad",
2074 "shot_azimuth_rad",
2075 "shooting_angle_rad",
2076 "cant_angle_rad",
2077 "target_height_m",
2078 "ground_threshold_m",
2079 "zero_poi_up_m",
2080 "zero_poi_right_m",
2081 ],
2082 )?;
2083 if let Some(reference) = object.get("drops_reference") {
2086 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
2087 }
2088 Ok(())
2089}
2090
2091pub(crate) fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2092 let path = "$.atmosphere";
2093 let object = require_object(value, path)?;
2094 validate_members(
2095 object,
2096 path,
2097 &[
2098 "altitude_m",
2099 "temperature_k",
2100 "pressure_pa",
2101 "pressure_reference",
2102 "relative_humidity",
2103 "latitude_rad",
2104 ],
2105 &[],
2106 )?;
2107 validate_optional_numbers(
2108 object,
2109 path,
2110 &[
2111 "altitude_m",
2112 "temperature_k",
2113 "pressure_pa",
2114 "relative_humidity",
2115 "latitude_rad",
2116 ],
2117 )?;
2118 if let Some(reference) = object.get("pressure_reference") {
2124 validate_string_enum(
2125 reference,
2126 "$.atmosphere.pressure_reference",
2127 &["absolute", "qnh"],
2128 )?;
2129 }
2130 Ok(())
2131}
2132
2133fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2134 let path = "$.wind";
2135 let object = require_object(value, path)?;
2136 validate_members(
2137 object,
2138 path,
2139 &[
2140 "speed_mps",
2141 "direction_from_rad",
2142 "vertical_speed_mps",
2143 "segments",
2144 "wind_reference",
2145 ],
2146 &[],
2147 )?;
2148 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
2149 validate_optional_number(object, path, field)?;
2150 }
2151 if let Some(reference) = object.get("wind_reference") {
2153 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
2154 }
2155
2156 if let Some(segments) = object.get("segments") {
2157 let Some(segments) = segments.as_array() else {
2158 return Err(protocol_error(
2159 SolveErrorCodeV1::InvalidValue,
2160 "segments must be an array",
2161 "$.wind.segments",
2162 ));
2163 };
2164 for (index, segment) in segments.iter().enumerate() {
2165 let segment_path = format!("$.wind.segments[{index}]");
2166 let segment = require_object(segment, &segment_path)?;
2167 validate_members(
2168 segment,
2169 &segment_path,
2170 &[
2171 "until_distance_m",
2172 "speed_mps",
2173 "direction_from_rad",
2174 "vertical_speed_mps",
2175 ],
2176 &["until_distance_m", "speed_mps", "direction_from_rad"],
2177 )?;
2178 validate_required_numbers(
2179 segment,
2180 &segment_path,
2181 &["until_distance_m", "speed_mps", "direction_from_rad"],
2182 )?;
2183 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
2184 }
2185 }
2186 Ok(())
2187}
2188
2189fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2190 let path = "$.solver";
2191 let object = require_object(value, path)?;
2192 validate_members(object, path, &["method", "time_step_s"], &[])?;
2193 validate_optional_number(object, path, "time_step_s")?;
2194 if let Some(method) = object.get("method") {
2195 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
2196 }
2197 Ok(())
2198}
2199
2200pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
2209 "magnus",
2210 "coriolis",
2211 "enhanced_spin_drift",
2212 "wind_shear_model",
2213 "aerodynamic_jump",
2214];
2215
2216fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2217 let path = "$.effects";
2218 let object = require_object(value, path)?;
2219 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
2220 validate_optional_booleans(
2221 object,
2222 path,
2223 &[
2224 "magnus",
2225 "coriolis",
2226 "enhanced_spin_drift",
2227 "aerodynamic_jump",
2228 ],
2229 )?;
2230
2231 if let Some(model) = object.get("wind_shear_model") {
2235 validate_string_enum(
2236 model,
2237 "$.effects.wind_shear_model",
2238 &[
2239 "none",
2240 "logarithmic",
2241 "power_law",
2242 "ekman_spiral",
2243 "ekman",
2245 ],
2246 )?;
2247 }
2248
2249 if object.get("magnus").and_then(Value::as_bool) == Some(true)
2250 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
2251 {
2252 return Err(protocol_error(
2253 SolveErrorCodeV1::ConflictingFields,
2254 "magnus and enhanced_spin_drift cannot both be enabled",
2255 "$.effects",
2256 ));
2257 }
2258
2259 Ok(())
2260}
2261
2262fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2263 let path = "$.sampling";
2264 let object = require_object(value, path)?;
2265 validate_members(object, path, &["interval_m"], &[])?;
2266 validate_optional_number(object, path, "interval_m")
2267}
2268
2269pub(crate) fn require_object<'a>(
2270 value: &'a Value,
2271 path: &str,
2272) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
2273 value
2274 .as_object()
2275 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
2276}
2277
2278fn required_value<'a>(
2279 object: &'a Map<String, Value>,
2280 field: &str,
2281 parent_path: &str,
2282) -> Result<&'a Value, SolveErrorEnvelopeV1> {
2283 object.get(field).ok_or_else(|| {
2284 protocol_error(
2285 SolveErrorCodeV1::MissingField,
2286 format!("missing required field `{field}`"),
2287 child_path(parent_path, field),
2288 )
2289 })
2290}
2291
2292pub(crate) fn validate_members(
2293 object: &Map<String, Value>,
2294 path: &str,
2295 allowed: &[&str],
2296 required: &[&str],
2297) -> Result<(), SolveErrorEnvelopeV1> {
2298 if let Some(field) = object
2299 .keys()
2300 .find(|field| !allowed.contains(&field.as_str()))
2301 {
2302 return Err(protocol_error(
2303 SolveErrorCodeV1::UnknownField,
2304 format!("unknown field `{field}`"),
2305 child_path(path, field),
2306 ));
2307 }
2308
2309 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
2310 return Err(protocol_error(
2311 SolveErrorCodeV1::MissingField,
2312 format!("missing required field `{field}`"),
2313 child_path(path, field),
2314 ));
2315 }
2316 Ok(())
2317}
2318
2319fn validate_string_enum(
2320 value: &Value,
2321 path: &str,
2322 allowed: &[&str],
2323) -> Result<(), SolveErrorEnvelopeV1> {
2324 let Some(value) = value.as_str() else {
2325 return Err(protocol_error(
2326 SolveErrorCodeV1::InvalidValue,
2327 "expected a string enum value",
2328 path,
2329 ));
2330 };
2331 if !allowed.contains(&value) {
2332 return Err(protocol_error(
2333 SolveErrorCodeV1::InvalidValue,
2334 format!(
2335 "invalid value `{value}`; expected one of {}",
2336 allowed.join(", ")
2337 ),
2338 path,
2339 ));
2340 }
2341 Ok(())
2342}
2343
2344fn validate_required_numbers(
2345 object: &Map<String, Value>,
2346 parent_path: &str,
2347 fields: &[&str],
2348) -> Result<(), SolveErrorEnvelopeV1> {
2349 for field in fields {
2350 let value = required_value(object, field, parent_path)?;
2351 validate_number(value, &child_path(parent_path, field))?;
2352 }
2353 Ok(())
2354}
2355
2356fn validate_optional_numbers(
2357 object: &Map<String, Value>,
2358 parent_path: &str,
2359 fields: &[&str],
2360) -> Result<(), SolveErrorEnvelopeV1> {
2361 for field in fields {
2362 validate_optional_number(object, parent_path, field)?;
2363 }
2364 Ok(())
2365}
2366
2367fn validate_optional_number(
2368 object: &Map<String, Value>,
2369 parent_path: &str,
2370 field: &str,
2371) -> Result<(), SolveErrorEnvelopeV1> {
2372 if let Some(value) = object.get(field) {
2373 validate_number(value, &child_path(parent_path, field))?;
2374 }
2375 Ok(())
2376}
2377
2378fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
2379 if value.is_number() {
2380 Ok(())
2381 } else {
2382 Err(protocol_error(
2383 SolveErrorCodeV1::InvalidValue,
2384 "expected a number",
2385 path,
2386 ))
2387 }
2388}
2389
2390fn validate_optional_booleans(
2391 object: &Map<String, Value>,
2392 parent_path: &str,
2393 fields: &[&str],
2394) -> Result<(), SolveErrorEnvelopeV1> {
2395 for field in fields {
2396 if let Some(value) = object.get(*field) {
2397 if !value.is_boolean() {
2398 return Err(protocol_error(
2399 SolveErrorCodeV1::InvalidValue,
2400 "expected a boolean",
2401 child_path(parent_path, field),
2402 ));
2403 }
2404 }
2405 }
2406 Ok(())
2407}
2408
2409fn child_path(parent: &str, field: &str) -> String {
2410 format!("{parent}.{field}")
2411}
2412
2413#[cfg(test)]
2415mod request_range_tests {
2416 use super::*;
2417
2418 fn valid_request_json() -> String {
2421 r#"{"schema_version":1,
2422 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2423 "drag_model":"G7","ballistic_coefficient":0.243},
2424 "rifle":{"muzzle_velocity_mps":823.0},
2425 "shot":{"max_range_m":1000.0},
2426 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2427 .to_string()
2428 }
2429
2430 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2431 decode_solve_request_v1(json).expect_err("request should have been rejected")
2432 }
2433
2434 #[test]
2435 fn an_ordinary_request_still_decodes() {
2436 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2437 }
2438
2439 #[test]
2445 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2446 let reproducer = r#"{"schema_version":1,
2447 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2448 "drag_model":"G7","ballistic_coefficient":1.2e2},
2449 "rifle":{"muzzle_velocity_mps":823.0},
2450 "shot":{"max_range_m":100.0},
2451 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2452
2453 let envelope = decode_err(reproducer);
2454 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2455 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2456 }
2457
2458 #[test]
2461 fn the_rejection_envelope_round_trips() {
2462 let envelope = decode_err(
2463 r#"{"schema_version":1,
2464 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2465 "drag_model":"G7","ballistic_coefficient":0.243},
2466 "rifle":{"muzzle_velocity_mps":823.0},
2467 "shot":{"max_range_m":100.0},
2468 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2469 );
2470 let encoded = serde_json::to_string(&envelope).expect("serialize");
2471 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2472 assert_eq!(decoded, envelope);
2473 }
2474
2475 #[test]
2476 fn each_bounded_field_reports_its_own_path() {
2477 for (field, bad_value, path) in [
2478 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2479 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2480 (
2481 "ballistic_coefficient",
2482 "1.0e6",
2483 "$.projectile.ballistic_coefficient",
2484 ),
2485 ("length_m", "1.0e-9", "$.projectile.length_m"),
2486 ] {
2487 let json = valid_request_json().replace(
2488 &format!("\"{field}\":{}", default_for(field)),
2489 &format!("\"{field}\":{bad_value}"),
2490 );
2491 let envelope = decode_err(&json);
2492 assert_eq!(envelope.error.path(), Some(path), "wrong path for {field}");
2493 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2494 }
2495 }
2496
2497 fn default_for(field: &str) -> &'static str {
2498 match field {
2499 "mass_kg" => "0.01134",
2500 "diameter_m" => "0.00782",
2501 "ballistic_coefficient" => "0.243",
2502 "length_m" => "0.031",
2503 other => panic!("no default recorded for {other}"),
2504 }
2505 }
2506
2507 #[test]
2511 fn muzzle_velocity_is_deliberately_left_unbounded() {
2512 let json = valid_request_json().replace("823.0", "1.0e308");
2513 decode_solve_request_v1(&json)
2514 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2515 }
2516}