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 MAX_DRAG_TABLE_NAME_CHARS_V1: usize = 64;
320
321pub const MIN_BC_SEGMENTS_V1: usize = 1;
324
325pub const MAX_BC_SEGMENTS_V1: usize = 64;
329
330pub const ONE_BAND_BC_RELATIVE_TOLERANCE_V1: f64 = 1.0e-6;
336
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
346#[serde(deny_unknown_fields)]
347pub struct DragTableV1 {
348 pub kind: DragTableKindV1,
349 #[serde(
357 default,
358 skip_serializing_if = "Option::is_none",
359 deserialize_with = "deserialize_present"
360 )]
361 pub name: Option<String>,
362 pub points: Vec<DragPointV1>,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(rename_all = "snake_case")]
370pub enum DragTableKindV1 {
371 Reference,
376 Projectile,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
385#[serde(deny_unknown_fields)]
386pub struct DragPointV1 {
387 pub mach: f64,
388 pub cd: f64,
389}
390
391pub(crate) fn validate_drag_table_v1(table: &DragTableV1) -> Result<(), SolveErrorEnvelopeV1> {
397 if let Some(name) = &table.name {
398 validate_drag_table_name_v1(name)?;
399 }
400 let points_path = "$.projectile.drag_table.points";
401 let count = table.points.len();
402 if !(MIN_DRAG_TABLE_POINTS_V1..=MAX_DRAG_TABLE_POINTS_V1).contains(&count) {
403 return Err(protocol_error(
404 SolveErrorCodeV1::InvalidValue,
405 format!(
406 "drag_table must have {MIN_DRAG_TABLE_POINTS_V1} to \
407 {MAX_DRAG_TABLE_POINTS_V1} points, got {count}"
408 ),
409 points_path,
410 ));
411 }
412 let mut previous_mach: Option<f64> = None;
413 for (index, point) in table.points.iter().enumerate() {
414 let mach_path = format!("{points_path}[{index}].mach");
415 if !point.mach.is_finite() || point.mach < 0.0 {
416 return Err(protocol_error(
417 SolveErrorCodeV1::InvalidValue,
418 format!("mach must be finite and >= 0, got {}", point.mach),
419 mach_path,
420 ));
421 }
422 if let Some(previous) = previous_mach.filter(|previous| point.mach <= *previous) {
423 return Err(protocol_error(
424 SolveErrorCodeV1::InvalidValue,
425 format!(
426 "mach must strictly ascend; {} follows {previous} at points[{}]",
427 point.mach,
428 index - 1
429 ),
430 mach_path,
431 ));
432 }
433 if !point.cd.is_finite() || point.cd <= 0.0 {
434 return Err(protocol_error(
435 SolveErrorCodeV1::InvalidValue,
436 format!("cd must be finite and > 0, got {}", point.cd),
437 format!("{points_path}[{index}].cd"),
438 ));
439 }
440 previous_mach = Some(point.mach);
441 }
442 Ok(())
443}
444
445fn validate_drag_table_name_v1(name: &str) -> Result<(), SolveErrorEnvelopeV1> {
452 let path = "$.projectile.drag_table.name";
453 if name.trim().is_empty() {
454 return Err(protocol_error(
455 SolveErrorCodeV1::InvalidValue,
456 "name must not be blank; omit the field for an unnamed table",
457 path,
458 ));
459 }
460 if let Some(breaking) = name
461 .chars()
462 .find(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
463 {
464 return Err(protocol_error(
465 SolveErrorCodeV1::InvalidValue,
466 format!(
467 "name must be one line, with no control characters or line breaks; found U+{:04X}",
468 u32::from(breaking)
469 ),
470 path,
471 ));
472 }
473 let length = name.chars().count();
474 if length > MAX_DRAG_TABLE_NAME_CHARS_V1 {
475 return Err(protocol_error(
476 SolveErrorCodeV1::InvalidValue,
477 format!("name must be at most {MAX_DRAG_TABLE_NAME_CHARS_V1} characters, got {length}"),
478 path,
479 ));
480 }
481 Ok(())
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
486pub enum DragModelV1 {
487 #[serde(rename = "G1")]
488 G1,
489 #[serde(rename = "G6")]
490 G6,
491 #[serde(rename = "G7")]
492 G7,
493 #[serde(rename = "G8")]
494 G8,
495 #[serde(rename = "G2")]
496 G2,
497 #[serde(rename = "G5")]
498 G5,
499 #[serde(rename = "GI")]
500 GI,
501 #[serde(rename = "GS")]
502 GS,
503 #[serde(rename = "RA4")]
504 RA4,
505}
506
507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
509#[serde(deny_unknown_fields)]
510pub struct RifleV1 {
511 pub muzzle_velocity_mps: f64,
512 #[serde(
513 default,
514 skip_serializing_if = "Option::is_none",
515 deserialize_with = "deserialize_present"
516 )]
517 pub sight_height_m: Option<f64>,
518 #[serde(
519 default,
520 skip_serializing_if = "Option::is_none",
521 deserialize_with = "deserialize_present"
522 )]
523 pub muzzle_height_m: Option<f64>,
524 #[serde(
543 default,
544 skip_serializing_if = "Option::is_none",
545 deserialize_with = "deserialize_present"
546 )]
547 pub twist_rate_m_per_turn: Option<f64>,
548 #[serde(
553 default,
554 skip_serializing_if = "Option::is_none",
555 deserialize_with = "deserialize_present"
556 )]
557 pub twist_direction: Option<TwistDirectionV1>,
558 #[serde(
566 default,
567 skip_serializing_if = "Option::is_none",
568 deserialize_with = "deserialize_present"
569 )]
570 pub sight_offset_lateral_m: Option<f64>,
571}
572
573#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
575#[serde(rename_all = "snake_case")]
576pub enum TwistDirectionV1 {
577 Left,
578 #[default]
579 Right,
580}
581
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct ShotV1 {
586 pub max_range_m: f64,
587 #[serde(
588 default,
589 skip_serializing_if = "Option::is_none",
590 deserialize_with = "deserialize_present"
591 )]
592 pub zero_distance_m: Option<f64>,
593 #[serde(
594 default,
595 skip_serializing_if = "Option::is_none",
596 deserialize_with = "deserialize_present"
597 )]
598 pub muzzle_angle_rad: Option<f64>,
599 #[serde(
600 default,
601 skip_serializing_if = "Option::is_none",
602 deserialize_with = "deserialize_present"
603 )]
604 pub aim_azimuth_rad: Option<f64>,
605 #[serde(
606 default,
607 skip_serializing_if = "Option::is_none",
608 deserialize_with = "deserialize_present"
609 )]
610 pub shot_azimuth_rad: Option<f64>,
611 #[serde(
612 default,
613 skip_serializing_if = "Option::is_none",
614 deserialize_with = "deserialize_present"
615 )]
616 pub shooting_angle_rad: Option<f64>,
617 #[serde(
618 default,
619 skip_serializing_if = "Option::is_none",
620 deserialize_with = "deserialize_present"
621 )]
622 pub cant_angle_rad: Option<f64>,
623 #[serde(
641 default,
642 skip_serializing_if = "Option::is_none",
643 deserialize_with = "deserialize_present"
644 )]
645 pub target_height_m: Option<f64>,
646 #[serde(
647 default,
648 skip_serializing_if = "Option::is_none",
649 deserialize_with = "deserialize_present"
650 )]
651 pub ground_threshold_m: Option<f64>,
652 #[serde(
658 default,
659 skip_serializing_if = "Option::is_none",
660 deserialize_with = "deserialize_present"
661 )]
662 pub zero_poi_up_m: Option<f64>,
663 #[serde(
667 default,
668 skip_serializing_if = "Option::is_none",
669 deserialize_with = "deserialize_present"
670 )]
671 pub zero_poi_right_m: Option<f64>,
672 #[serde(
680 default,
681 skip_serializing_if = "Option::is_none",
682 deserialize_with = "deserialize_present"
683 )]
684 pub drops_reference: Option<DropsReferenceV1>,
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum DropsReferenceV1 {
691 #[default]
693 Los,
694 Target,
697}
698
699#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
714#[serde(deny_unknown_fields)]
715pub struct AtmosphereV1 {
716 #[serde(
717 default,
718 skip_serializing_if = "Option::is_none",
719 deserialize_with = "deserialize_present"
720 )]
721 pub altitude_m: Option<f64>,
722 #[serde(
725 default,
726 skip_serializing_if = "Option::is_none",
727 deserialize_with = "deserialize_present"
728 )]
729 pub temperature_k: Option<f64>,
730 #[serde(
736 default,
737 skip_serializing_if = "Option::is_none",
738 deserialize_with = "deserialize_present"
739 )]
740 pub pressure_pa: Option<f64>,
741 #[serde(
747 default,
748 skip_serializing_if = "Option::is_none",
749 deserialize_with = "deserialize_present"
750 )]
751 pub pressure_reference: Option<PressureReferenceV1>,
752 #[serde(
753 default,
754 skip_serializing_if = "Option::is_none",
755 deserialize_with = "deserialize_present"
756 )]
757 pub relative_humidity: Option<f64>,
758 #[serde(
759 default,
760 skip_serializing_if = "Option::is_none",
761 deserialize_with = "deserialize_present"
762 )]
763 pub latitude_rad: Option<f64>,
764}
765
766#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
770#[serde(rename_all = "snake_case")]
771pub enum PressureReferenceV1 {
772 #[default]
773 Absolute,
774 Qnh,
775}
776
777#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
782#[serde(deny_unknown_fields)]
783pub struct WindV1 {
784 #[serde(
785 default,
786 skip_serializing_if = "Option::is_none",
787 deserialize_with = "deserialize_present"
788 )]
789 pub speed_mps: Option<f64>,
790 #[serde(
791 default,
792 skip_serializing_if = "Option::is_none",
793 deserialize_with = "deserialize_present"
794 )]
795 pub direction_from_rad: Option<f64>,
796 #[serde(
797 default,
798 skip_serializing_if = "Option::is_none",
799 deserialize_with = "deserialize_present"
800 )]
801 pub vertical_speed_mps: Option<f64>,
802 #[serde(
803 default,
804 skip_serializing_if = "Option::is_none",
805 deserialize_with = "deserialize_present"
806 )]
807 pub segments: Option<Vec<WindSegmentV1>>,
808 #[serde(
821 default,
822 skip_serializing_if = "Option::is_none",
823 deserialize_with = "deserialize_present"
824 )]
825 pub wind_reference: Option<WindReferenceV1>,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
830#[serde(rename_all = "snake_case")]
831pub enum WindReferenceV1 {
832 #[default]
834 Shooter,
835 Compass,
837}
838
839#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
841#[serde(deny_unknown_fields)]
842pub struct WindSegmentV1 {
843 pub until_distance_m: f64,
844 pub speed_mps: f64,
845 pub direction_from_rad: f64,
846 #[serde(
847 default,
848 skip_serializing_if = "Option::is_none",
849 deserialize_with = "deserialize_present"
850 )]
851 pub vertical_speed_mps: Option<f64>,
852}
853
854#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
856#[serde(deny_unknown_fields)]
857pub struct SolverV1 {
858 #[serde(
859 default,
860 skip_serializing_if = "Option::is_none",
861 deserialize_with = "deserialize_present"
862 )]
863 pub method: Option<SolverMethodV1>,
864 #[serde(
866 default,
867 skip_serializing_if = "Option::is_none",
868 deserialize_with = "deserialize_present"
869 )]
870 pub time_step_s: Option<f64>,
871}
872
873#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(rename_all = "snake_case")]
876pub enum SolverMethodV1 {
877 Euler,
878 Rk4,
879 #[default]
880 Rk45,
881}
882
883#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
885#[serde(deny_unknown_fields)]
886pub struct EffectsV1 {
887 #[serde(
899 default,
900 skip_serializing_if = "Option::is_none",
901 deserialize_with = "deserialize_present"
902 )]
903 pub magnus: Option<bool>,
904 #[serde(
908 default,
909 skip_serializing_if = "Option::is_none",
910 deserialize_with = "deserialize_present"
911 )]
912 pub coriolis: Option<bool>,
913 #[serde(
926 default,
927 skip_serializing_if = "Option::is_none",
928 deserialize_with = "deserialize_present"
929 )]
930 pub enhanced_spin_drift: Option<bool>,
931 #[serde(
941 default,
942 skip_serializing_if = "Option::is_none",
943 deserialize_with = "deserialize_present"
944 )]
945 pub wind_shear_model: Option<WindShearModelV1>,
946 #[serde(
963 default,
964 skip_serializing_if = "Option::is_none",
965 deserialize_with = "deserialize_present"
966 )]
967 pub aerodynamic_jump: Option<bool>,
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
978#[serde(rename_all = "snake_case")]
979pub enum WindShearModelV1 {
980 #[default]
983 None,
984 Logarithmic,
986 PowerLaw,
988 #[serde(alias = "ekman")]
993 EkmanSpiral,
994}
995
996impl WindShearModelV1 {
997 pub fn as_engine_str(self) -> &'static str {
1002 match self {
1003 WindShearModelV1::None => "none",
1004 WindShearModelV1::Logarithmic => "logarithmic",
1005 WindShearModelV1::PowerLaw => "power_law",
1006 WindShearModelV1::EkmanSpiral => "ekman_spiral",
1007 }
1008 }
1009
1010 pub fn is_enabled(self) -> bool {
1017 !matches!(self, WindShearModelV1::None)
1018 }
1019}
1020
1021#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1023#[serde(deny_unknown_fields)]
1024pub struct SamplingV1 {
1025 #[serde(
1026 default,
1027 skip_serializing_if = "Option::is_none",
1028 deserialize_with = "deserialize_present"
1029 )]
1030 pub interval_m: Option<f64>,
1031}
1032
1033#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1039#[serde(deny_unknown_fields)]
1040pub struct ResolvedSolveRequestV1 {
1041 pub schema_version: SchemaVersionV1,
1042 pub projectile: ResolvedProjectileV1,
1043 pub rifle: ResolvedRifleV1,
1044 pub shot: ResolvedShotV1,
1045 pub atmosphere: ResolvedAtmosphereV1,
1046 pub wind: ResolvedWindV1,
1047 pub solver: ResolvedSolverV1,
1048 pub effects: ResolvedEffectsV1,
1049 pub sampling: ResolvedSamplingV1,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub reticle: Option<ReticleRequestV1>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1061 pub corrections: Option<CorrectionsV1>,
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1066#[serde(deny_unknown_fields)]
1067pub struct ResolvedProjectileV1 {
1068 pub mass_kg: f64,
1069 pub diameter_m: f64,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub length_m: Option<f64>,
1072 pub drag_model: DragModelV1,
1073 pub ballistic_coefficient: f64,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub drag_table: Option<DragTableV1>,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub bc_segments: Option<Vec<BcSegmentV1>>,
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1088#[serde(deny_unknown_fields)]
1089pub struct ResolvedRifleV1 {
1090 pub muzzle_velocity_mps: f64,
1091 pub sight_height_m: f64,
1092 pub muzzle_height_m: f64,
1093 pub twist_rate_m_per_turn: f64,
1094 pub twist_direction: TwistDirectionV1,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub sight_offset_lateral_m: Option<f64>,
1099}
1100
1101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1113#[serde(deny_unknown_fields)]
1114pub struct ResolvedShotV1 {
1115 pub max_range_m: f64,
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub zero_distance_m: Option<f64>,
1118 pub muzzle_angle_rad: f64,
1119 pub aim_azimuth_rad: f64,
1120 pub shot_azimuth_rad: f64,
1121 pub shooting_angle_rad: f64,
1122 pub cant_angle_rad: f64,
1123 pub target_height_m: f64,
1124 pub ground_threshold_m: f64,
1125 #[serde(default, skip_serializing_if = "Option::is_none")]
1128 pub zero_poi_up_m: Option<f64>,
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub zero_poi_right_m: Option<f64>,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub drops_reference: Option<DropsReferenceV1>,
1137}
1138
1139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1141#[serde(deny_unknown_fields)]
1142pub struct ResolvedAtmosphereV1 {
1143 pub altitude_m: f64,
1144 pub temperature_k: f64,
1145 pub pressure_pa: f64,
1146 pub relative_humidity: f64,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1148 pub latitude_rad: Option<f64>,
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub pressure_reference: Option<PressureReferenceV1>,
1153}
1154
1155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1160#[serde(untagged)]
1161pub enum ResolvedWindV1 {
1162 Constant(ResolvedConstantWindV1),
1163 Segmented(ResolvedSegmentedWindV1),
1164}
1165
1166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1168#[serde(deny_unknown_fields)]
1169pub struct ResolvedConstantWindV1 {
1170 pub speed_mps: f64,
1171 pub direction_from_rad: f64,
1172 pub vertical_speed_mps: f64,
1173 #[serde(default, skip_serializing_if = "Option::is_none")]
1177 pub wind_reference: Option<WindReferenceV1>,
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1182#[serde(deny_unknown_fields)]
1183pub struct ResolvedSegmentedWindV1 {
1184 pub segments: Vec<ResolvedWindSegmentV1>,
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1189 pub wind_reference: Option<WindReferenceV1>,
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1194#[serde(deny_unknown_fields)]
1195pub struct ResolvedWindSegmentV1 {
1196 pub until_distance_m: f64,
1197 pub speed_mps: f64,
1198 pub direction_from_rad: f64,
1199 pub vertical_speed_mps: f64,
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1204#[serde(deny_unknown_fields)]
1205pub struct ResolvedSolverV1 {
1206 pub method: SolverMethodV1,
1207 pub time_step_s: f64,
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1212#[serde(deny_unknown_fields)]
1213pub struct ResolvedEffectsV1 {
1214 pub magnus: bool,
1215 pub coriolis: bool,
1216 pub enhanced_spin_drift: bool,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1221 pub wind_shear_model: Option<WindShearModelV1>,
1222 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 pub aerodynamic_jump: Option<bool>,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1237#[serde(deny_unknown_fields)]
1238pub struct ResolvedSamplingV1 {
1239 pub interval_m: f64,
1240}
1241
1242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1244#[serde(deny_unknown_fields)]
1245pub struct SolveSuccessV1 {
1246 pub schema_version: SchemaVersionV1,
1247 pub engine_version: String,
1248 pub status: SuccessStatusV1,
1249 pub resolved_request: ResolvedSolveRequestV1,
1250 #[serde(default)]
1251 pub assumptions: Vec<SolveNoticeV1>,
1252 #[serde(default)]
1253 pub warnings: Vec<SolveNoticeV1>,
1254 pub summary: SolveSummaryV1,
1255 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
1256 pub samples: Vec<TrajectorySampleV1>,
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub reticle_hold: Option<ReticleHoldV1>,
1262}
1263
1264fn serialize_solve_samples_v1<S>(
1265 samples: &[TrajectorySampleV1],
1266 serializer: S,
1267) -> Result<S::Ok, S::Error>
1268where
1269 S: Serializer,
1270{
1271 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
1272 return Err(serde::ser::Error::custom(format_args!(
1273 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1274 samples.len()
1275 )));
1276 }
1277 samples.serialize(serializer)
1278}
1279
1280impl SolveSuccessV1 {
1281 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
1287 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
1288 return Ok(());
1289 }
1290
1291 Err(SolveErrorEnvelopeV1::new(
1292 SolveErrorV1::new(
1293 SolveErrorCodeV1::ResourceLimit,
1294 format!(
1295 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1296 self.samples.len()
1297 ),
1298 )
1299 .at_path("$.sampling.interval_m"),
1300 ))
1301 }
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1306#[serde(rename_all = "snake_case")]
1307pub enum SuccessStatusV1 {
1308 Ok,
1309}
1310
1311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1313#[serde(deny_unknown_fields)]
1314pub struct SolveNoticeV1 {
1315 pub code: String,
1316 pub message: String,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1318 pub path: Option<String>,
1319}
1320
1321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1323#[serde(deny_unknown_fields)]
1324pub struct SolveSummaryV1 {
1325 pub actual_range_m: f64,
1326 pub maximum_height_m: f64,
1329 pub time_of_flight_s: f64,
1330 pub terminal_speed_mps: f64,
1331 pub terminal_energy_j: f64,
1332 #[serde(default, skip_serializing_if = "Option::is_none")]
1335 pub stability_factor: Option<f64>,
1336 #[serde(default, skip_serializing_if = "Option::is_none")]
1340 pub spin_drift_m: Option<f64>,
1341 #[serde(default, skip_serializing_if = "Option::is_none")]
1349 pub equivalent_horizontal_range_m: Option<f64>,
1350 #[serde(default, skip_serializing_if = "Option::is_none")]
1362 pub aerodynamic_jump_moa: Option<f64>,
1363 pub termination: TerminationReasonV1,
1364}
1365
1366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1368#[serde(rename_all = "snake_case")]
1369pub enum TerminationReasonV1 {
1370 MaxRange,
1371 GroundThreshold,
1372 TimeLimit,
1373 VelocityFloor,
1374}
1375
1376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1378#[serde(deny_unknown_fields)]
1379pub struct TrajectorySampleV1 {
1380 pub distance_m: f64,
1381 pub time_s: f64,
1382 pub speed_mps: f64,
1383 pub energy_j: f64,
1384 pub drop_m: f64,
1386 pub windage_m: f64,
1388 pub mach: f64,
1389 #[serde(default)]
1390 pub flags: Vec<SampleFlagV1>,
1391}
1392
1393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1395#[serde(rename_all = "snake_case")]
1396pub enum SampleFlagV1 {
1397 Transonic,
1398 Subsonic,
1399 Terminal,
1400 GroundThreshold,
1401}
1402
1403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1405#[serde(deny_unknown_fields)]
1406pub struct SolveErrorEnvelopeV1 {
1407 pub schema_version: SchemaVersionV1,
1408 pub status: ErrorStatusV1,
1409 pub error: SolveErrorV1,
1410}
1411
1412impl SolveErrorEnvelopeV1 {
1413 pub fn new(error: SolveErrorV1) -> Self {
1415 Self {
1416 schema_version: SchemaVersionV1,
1417 status: ErrorStatusV1::Error,
1418 error,
1419 }
1420 }
1421}
1422
1423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1425#[serde(rename_all = "snake_case")]
1426pub enum ErrorStatusV1 {
1427 Error,
1428}
1429
1430#[derive(Debug, Clone, PartialEq, Eq)]
1436pub struct SolveErrorV1 {
1437 pub code: SolveErrorCodeV1,
1438 pub message: String,
1439 location: SolveErrorLocationV1,
1440}
1441
1442#[derive(Debug, Clone, PartialEq, Eq)]
1443enum SolveErrorLocationV1 {
1444 None,
1445 Path(String),
1446 Source {
1447 line: NonZeroUsize,
1448 column: NonZeroUsize,
1449 },
1450}
1451
1452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454pub enum SolveErrorLocationErrorV1 {
1455 ZeroLine,
1456 ZeroColumn,
1457}
1458
1459impl fmt::Display for SolveErrorLocationErrorV1 {
1460 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1461 match self {
1462 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1463 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1464 }
1465 }
1466}
1467
1468impl std::error::Error for SolveErrorLocationErrorV1 {}
1469
1470impl SolveErrorV1 {
1471 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1473 Self {
1474 code,
1475 message: message.into(),
1476 location: SolveErrorLocationV1::None,
1477 }
1478 }
1479
1480 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1482 self.location = SolveErrorLocationV1::Path(path.into());
1483 self
1484 }
1485
1486 pub fn at_location(
1488 mut self,
1489 line: usize,
1490 column: usize,
1491 ) -> Result<Self, SolveErrorLocationErrorV1> {
1492 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1493 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1494 self.location = SolveErrorLocationV1::Source { line, column };
1495 Ok(self)
1496 }
1497
1498 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1503 self.location = SolveErrorLocationV1::Source {
1504 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1505 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1506 };
1507 self
1508 }
1509
1510 pub fn path(&self) -> Option<&str> {
1512 match &self.location {
1513 SolveErrorLocationV1::Path(path) => Some(path),
1514 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1515 }
1516 }
1517
1518 pub fn line(&self) -> Option<usize> {
1520 match &self.location {
1521 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1522 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1523 }
1524 }
1525
1526 pub fn column(&self) -> Option<usize> {
1528 match &self.location {
1529 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1530 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1531 }
1532 }
1533}
1534
1535impl Serialize for SolveErrorV1 {
1536 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1537 where
1538 S: Serializer,
1539 {
1540 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1541 state.serialize_field("code", &self.code)?;
1542 state.serialize_field("message", &self.message)?;
1543 state.serialize_field("path", &self.path())?;
1544 state.serialize_field("line", &self.line())?;
1545 state.serialize_field("column", &self.column())?;
1546 state.end()
1547 }
1548}
1549
1550impl<'de> Deserialize<'de> for SolveErrorV1 {
1551 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1552 where
1553 D: Deserializer<'de>,
1554 {
1555 #[derive(Deserialize)]
1556 #[serde(deny_unknown_fields)]
1557 struct SolveErrorWireV1 {
1558 code: SolveErrorCodeV1,
1559 message: String,
1560 path: Option<String>,
1561 line: Option<usize>,
1562 column: Option<usize>,
1563 }
1564
1565 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1566 let location = match (wire.path, wire.line, wire.column) {
1567 (None, None, None) => SolveErrorLocationV1::None,
1568 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1569 (None, Some(line), Some(column)) => {
1570 let line = NonZeroUsize::new(line)
1571 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1572 let column = NonZeroUsize::new(column)
1573 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1574 SolveErrorLocationV1::Source { line, column }
1575 }
1576 _ => {
1577 return Err(de::Error::custom(
1578 "error location must contain either path or both line and column",
1579 ));
1580 }
1581 };
1582
1583 Ok(Self {
1584 code: wire.code,
1585 message: wire.message,
1586 location,
1587 })
1588 }
1589}
1590
1591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1593#[serde(rename_all = "snake_case")]
1594pub enum SolveErrorCodeV1 {
1595 InvalidJson,
1596 UnsupportedSchemaVersion,
1597 UnknownField,
1598 MissingField,
1599 InvalidValue,
1600 ConflictingFields,
1601 ResourceLimit,
1602 SolveFailed,
1603 IoError,
1604 InternalError,
1605}
1606
1607pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1614 let value: Value = serde_json::from_str(input).map_err(|error| {
1615 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1616 .at_parser_location(error.line(), error.column());
1617 envelope(error)
1618 })?;
1619
1620 validate_request_shape(&value)?;
1621
1622 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1623 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1624 })?;
1625
1626 validate_request_ranges(&request)?;
1627
1628 Ok(request)
1629}
1630
1631pub(crate) mod limits {
1646 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1650 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1652 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1654 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1656
1657 }
1664
1665fn require_range(
1666 value: f64,
1667 (min, max): (f64, f64),
1668 path: &str,
1669) -> Result<(), SolveErrorEnvelopeV1> {
1670 if !value.is_finite() {
1671 return Err(protocol_error(
1672 SolveErrorCodeV1::InvalidValue,
1673 format!("{path} must be a finite number"),
1674 path,
1675 ));
1676 }
1677 if value < min || value > max {
1678 return Err(protocol_error(
1679 SolveErrorCodeV1::InvalidValue,
1680 format!("{path} must be between {min} and {max}, got {value}"),
1681 path,
1682 ));
1683 }
1684 Ok(())
1685}
1686
1687fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1692 require_range(
1693 request.projectile.mass_kg,
1694 limits::MASS_KG,
1695 "$.projectile.mass_kg",
1696 )?;
1697 require_range(
1698 request.projectile.diameter_m,
1699 limits::DIAMETER_M,
1700 "$.projectile.diameter_m",
1701 )?;
1702 require_range(
1703 request.projectile.ballistic_coefficient,
1704 limits::BALLISTIC_COEFFICIENT,
1705 "$.projectile.ballistic_coefficient",
1706 )?;
1707 if let Some(length_m) = request.projectile.length_m {
1708 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1709 }
1710 if let Some(drag_table) = &request.projectile.drag_table {
1711 validate_drag_table_v1(drag_table)?;
1712 }
1713 if let Some(bc_segments) = &request.projectile.bc_segments {
1714 validate_bc_segments_v1(bc_segments, request.projectile.ballistic_coefficient)?;
1715 }
1716 Ok(())
1717}
1718
1719fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1720 SolveErrorEnvelopeV1::new(error)
1721}
1722
1723fn protocol_error(
1724 code: SolveErrorCodeV1,
1725 message: impl Into<String>,
1726 path: impl Into<String>,
1727) -> SolveErrorEnvelopeV1 {
1728 envelope(SolveErrorV1::new(code, message).at_path(path))
1729}
1730
1731fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1732 let root = require_object(value, "$")?;
1733
1734 validate_schema_version(root)?;
1738 validate_members(
1739 root,
1740 "$",
1741 &[
1742 "schema_version",
1743 "projectile",
1744 "rifle",
1745 "shot",
1746 "atmosphere",
1747 "wind",
1748 "solver",
1749 "effects",
1750 "sampling",
1751 "reticle",
1753 "corrections",
1755 ],
1756 &[
1757 "schema_version",
1758 "projectile",
1759 "rifle",
1760 "shot",
1761 "atmosphere",
1762 "wind",
1763 "solver",
1764 "effects",
1765 "sampling",
1766 ],
1767 )?;
1768
1769 validate_projectile(required_value(root, "projectile", "$")?)?;
1770 validate_rifle(required_value(root, "rifle", "$")?)?;
1771 validate_shot(required_value(root, "shot", "$")?)?;
1772 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1773 validate_wind(required_value(root, "wind", "$")?)?;
1774 validate_solver(required_value(root, "solver", "$")?)?;
1775 validate_effects(required_value(root, "effects", "$")?)?;
1776 validate_sampling(required_value(root, "sampling", "$")?)?;
1777 if let Some(reticle) = root.get("reticle") {
1778 validate_reticle(reticle)?;
1779 }
1780 if let Some(corrections) = root.get("corrections") {
1781 validate_corrections(corrections)?;
1782 }
1783 Ok(())
1784}
1785
1786fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1791 let path = "$.corrections";
1792 let object = require_object(value, path)?;
1793 validate_members(object, path, &["bc5d_table_path"], &[])?;
1794 if let Some(table_path) = object.get("bc5d_table_path") {
1795 if !table_path.is_string() {
1796 return Err(protocol_error(
1797 SolveErrorCodeV1::InvalidValue,
1798 "expected a string",
1799 "$.corrections.bc5d_table_path",
1800 ));
1801 }
1802 }
1803 Ok(())
1804}
1805
1806fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1813 let path = "$.reticle";
1814 let object = require_object(value, path)?;
1815 validate_members(
1816 object,
1817 path,
1818 &["range_m", "magnification", "description"],
1819 &["range_m", "magnification", "description"],
1820 )?;
1821 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1822 require_object(
1823 required_value(object, "description", path)?,
1824 "$.reticle.description",
1825 )?;
1826 Ok(())
1827}
1828
1829fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1830 let value = required_value(root, "schema_version", "$")?;
1831 let version = if let Some(version) = value.as_i64() {
1832 i128::from(version)
1833 } else if let Some(version) = value.as_u64() {
1834 i128::from(version)
1835 } else {
1836 return Err(protocol_error(
1837 SolveErrorCodeV1::InvalidValue,
1838 "schema_version must be the integer 1",
1839 "$.schema_version",
1840 ));
1841 };
1842 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1843 return Err(protocol_error(
1844 SolveErrorCodeV1::UnsupportedSchemaVersion,
1845 format!(
1846 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1847 ),
1848 "$.schema_version",
1849 ));
1850 }
1851 Ok(())
1852}
1853
1854fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1855 let path = "$.projectile";
1856 let object = require_object(value, path)?;
1857 validate_members(
1858 object,
1859 path,
1860 &[
1861 "mass_kg",
1862 "diameter_m",
1863 "length_m",
1864 "drag_model",
1865 "ballistic_coefficient",
1866 "drag_table",
1868 "bc_segments",
1870 ],
1871 &[
1872 "mass_kg",
1873 "diameter_m",
1874 "drag_model",
1875 "ballistic_coefficient",
1876 ],
1877 )?;
1878 validate_required_numbers(
1879 object,
1880 path,
1881 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1882 )?;
1883 validate_optional_number(object, path, "length_m")?;
1884 validate_string_enum(
1885 required_value(object, "drag_model", path)?,
1886 "$.projectile.drag_model",
1887 &DRAG_MODEL_WIRE_NAMES_V1,
1888 )?;
1889 if let Some(drag_table) = object.get("drag_table") {
1890 validate_drag_table_shape(drag_table)?;
1891 }
1892 if let Some(bc_segments) = object.get("bc_segments") {
1893 validate_bc_segments_shape(bc_segments)?;
1894 }
1895 Ok(())
1896}
1897
1898const BC_SEGMENT_FIELDS_V1: [&str; 3] = [
1900 "velocity_min_mps",
1901 "velocity_max_mps",
1902 "ballistic_coefficient",
1903];
1904
1905fn validate_bc_segments_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1911 let segments_path = "$.projectile.bc_segments";
1912 let Some(segments) = value.as_array() else {
1913 return Err(protocol_error(
1914 SolveErrorCodeV1::InvalidValue,
1915 "bc_segments must be an array",
1916 segments_path,
1917 ));
1918 };
1919 if segments.is_empty() {
1920 return Err(protocol_error(
1921 SolveErrorCodeV1::InvalidValue,
1922 bc_segment_count_message(0),
1923 segments_path,
1924 ));
1925 }
1926 for (index, segment) in segments.iter().enumerate() {
1927 let seg_path = format!("{segments_path}[{index}]");
1928 let segment = require_object(segment, &seg_path)?;
1929 validate_members(
1930 segment,
1931 &seg_path,
1932 &BC_SEGMENT_FIELDS_V1,
1933 &BC_SEGMENT_FIELDS_V1,
1934 )?;
1935 validate_required_numbers(segment, &seg_path, &BC_SEGMENT_FIELDS_V1)?;
1936 }
1937 Ok(())
1938}
1939
1940fn bc_segment_count_message(count: usize) -> String {
1941 format!("bc_segments must have {MIN_BC_SEGMENTS_V1} to {MAX_BC_SEGMENTS_V1} bands, got {count}")
1942}
1943
1944pub(crate) fn validate_bc_segments_v1(
1962 segments: &[BcSegmentV1],
1963 scalar_bc: f64,
1964) -> Result<(), SolveErrorEnvelopeV1> {
1965 let base = "$.projectile.bc_segments";
1966 let count = segments.len();
1967 if !(MIN_BC_SEGMENTS_V1..=MAX_BC_SEGMENTS_V1).contains(&count) {
1968 return Err(protocol_error(
1969 SolveErrorCodeV1::InvalidValue,
1970 bc_segment_count_message(count),
1971 base,
1972 ));
1973 }
1974 let mut previous: Option<BcSegmentV1> = None;
1975 for (index, seg) in segments.iter().enumerate() {
1976 let seg_path = format!("{base}[{index}]");
1977 let min_path = format!("{seg_path}.velocity_min_mps");
1978 let max_path = format!("{seg_path}.velocity_max_mps");
1979 if !seg.velocity_min_mps.is_finite() || seg.velocity_min_mps < 0.0 {
1980 return Err(protocol_error(
1981 SolveErrorCodeV1::InvalidValue,
1982 format!(
1983 "velocity_min_mps must be finite and >= 0, got {}",
1984 seg.velocity_min_mps
1985 ),
1986 min_path,
1987 ));
1988 }
1989 if !seg.velocity_max_mps.is_finite() || seg.velocity_max_mps <= seg.velocity_min_mps {
1990 return Err(protocol_error(
1991 SolveErrorCodeV1::InvalidValue,
1992 format!(
1993 "velocity_max_mps must be finite and > velocity_min_mps ({}), got {}",
1994 seg.velocity_min_mps, seg.velocity_max_mps
1995 ),
1996 max_path,
1997 ));
1998 }
1999 require_range(
2000 seg.ballistic_coefficient,
2001 limits::BALLISTIC_COEFFICIENT,
2002 &format!("{seg_path}.ballistic_coefficient"),
2003 )?;
2004 if let Some(prev) = previous {
2005 if seg.velocity_min_mps >= prev.velocity_min_mps {
2006 return Err(protocol_error(
2007 SolveErrorCodeV1::InvalidValue,
2008 format!(
2009 "velocity_min_mps must be below bc_segments[{}].velocity_min_mps ({}), \
2010 got {}: list the bands from the highest speed to the lowest, or \
2011 reverse the array",
2012 index - 1,
2013 prev.velocity_min_mps,
2014 seg.velocity_min_mps
2015 ),
2016 min_path,
2017 ));
2018 }
2019 if seg.velocity_max_mps > prev.velocity_min_mps {
2020 return Err(protocol_error(
2021 SolveErrorCodeV1::InvalidValue,
2022 format!(
2023 "velocity_max_mps ({}) overlaps bc_segments[{}], which starts at {} m/s: \
2024 a band must end at or below the start of the faster band before it",
2025 seg.velocity_max_mps,
2026 index - 1,
2027 prev.velocity_min_mps
2028 ),
2029 max_path,
2030 ));
2031 }
2032 }
2033 previous = Some(*seg);
2034 }
2035 if let [band] = segments {
2036 let difference = (band.ballistic_coefficient - scalar_bc).abs();
2037 let allowed = ONE_BAND_BC_RELATIVE_TOLERANCE_V1 * band.ballistic_coefficient.max(scalar_bc);
2038 if difference > allowed {
2039 return Err(protocol_error(
2040 SolveErrorCodeV1::ConflictingFields,
2041 format!(
2042 "a one-band ladder flies its band's BC at every speed, so \
2043 bc_segments[0].ballistic_coefficient ({}) must match \
2044 projectile.ballistic_coefficient ({scalar_bc}): send the same value in \
2045 both, or omit bc_segments",
2046 band.ballistic_coefficient
2047 ),
2048 format!("{base}[0].ballistic_coefficient"),
2049 ));
2050 }
2051 }
2052 Ok(())
2053}
2054
2055fn validate_drag_table_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2061 let path = "$.projectile.drag_table";
2062 let object = require_object(value, path)?;
2063 validate_members(
2064 object,
2065 path,
2066 &["kind", "name", "points"],
2067 &["kind", "points"],
2068 )?;
2069 validate_string_enum(
2070 required_value(object, "kind", path)?,
2071 "$.projectile.drag_table.kind",
2072 &["reference", "projectile"],
2073 )?;
2074 if object.get("name").is_some_and(|name| !name.is_string()) {
2075 return Err(protocol_error(
2076 SolveErrorCodeV1::InvalidValue,
2077 "expected a string",
2078 "$.projectile.drag_table.name",
2079 ));
2080 }
2081 let points_path = "$.projectile.drag_table.points";
2082 let Some(points) = required_value(object, "points", path)?.as_array() else {
2083 return Err(protocol_error(
2084 SolveErrorCodeV1::InvalidValue,
2085 "points must be an array",
2086 points_path,
2087 ));
2088 };
2089 for (index, point) in points.iter().enumerate() {
2090 let point_path = format!("{points_path}[{index}]");
2091 let point = require_object(point, &point_path)?;
2092 validate_members(point, &point_path, &["mach", "cd"], &["mach", "cd"])?;
2093 validate_required_numbers(point, &point_path, &["mach", "cd"])?;
2094 }
2095 Ok(())
2096}
2097
2098fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2099 let path = "$.rifle";
2100 let object = require_object(value, path)?;
2101 validate_members(
2102 object,
2103 path,
2104 &[
2105 "muzzle_velocity_mps",
2106 "sight_height_m",
2107 "muzzle_height_m",
2108 "twist_rate_m_per_turn",
2109 "twist_direction",
2110 "sight_offset_lateral_m",
2111 ],
2112 &["muzzle_velocity_mps"],
2113 )?;
2114 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
2115 validate_optional_numbers(
2116 object,
2117 path,
2118 &[
2119 "sight_height_m",
2120 "muzzle_height_m",
2121 "twist_rate_m_per_turn",
2122 "sight_offset_lateral_m",
2123 ],
2124 )?;
2125 if let Some(direction) = object.get("twist_direction") {
2126 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
2127 }
2128 Ok(())
2129}
2130
2131fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2132 let path = "$.shot";
2133 let object = require_object(value, path)?;
2134 validate_members(
2135 object,
2136 path,
2137 &[
2138 "max_range_m",
2139 "zero_distance_m",
2140 "muzzle_angle_rad",
2141 "aim_azimuth_rad",
2142 "shot_azimuth_rad",
2143 "shooting_angle_rad",
2144 "cant_angle_rad",
2145 "target_height_m",
2146 "ground_threshold_m",
2147 "zero_poi_up_m",
2148 "zero_poi_right_m",
2149 "drops_reference",
2150 ],
2151 &["max_range_m"],
2152 )?;
2153 validate_required_numbers(object, path, &["max_range_m"])?;
2154 validate_optional_number(object, path, "zero_distance_m")?;
2155 validate_optional_number(object, path, "muzzle_angle_rad")?;
2156 validate_optional_numbers(
2157 object,
2158 path,
2159 &[
2160 "aim_azimuth_rad",
2161 "shot_azimuth_rad",
2162 "shooting_angle_rad",
2163 "cant_angle_rad",
2164 "target_height_m",
2165 "ground_threshold_m",
2166 "zero_poi_up_m",
2167 "zero_poi_right_m",
2168 ],
2169 )?;
2170 if let Some(reference) = object.get("drops_reference") {
2173 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
2174 }
2175 Ok(())
2176}
2177
2178pub(crate) fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2179 let path = "$.atmosphere";
2180 let object = require_object(value, path)?;
2181 validate_members(
2182 object,
2183 path,
2184 &[
2185 "altitude_m",
2186 "temperature_k",
2187 "pressure_pa",
2188 "pressure_reference",
2189 "relative_humidity",
2190 "latitude_rad",
2191 ],
2192 &[],
2193 )?;
2194 validate_optional_numbers(
2195 object,
2196 path,
2197 &[
2198 "altitude_m",
2199 "temperature_k",
2200 "pressure_pa",
2201 "relative_humidity",
2202 "latitude_rad",
2203 ],
2204 )?;
2205 if let Some(reference) = object.get("pressure_reference") {
2211 validate_string_enum(
2212 reference,
2213 "$.atmosphere.pressure_reference",
2214 &["absolute", "qnh"],
2215 )?;
2216 }
2217 Ok(())
2218}
2219
2220fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2221 let path = "$.wind";
2222 let object = require_object(value, path)?;
2223 validate_members(
2224 object,
2225 path,
2226 &[
2227 "speed_mps",
2228 "direction_from_rad",
2229 "vertical_speed_mps",
2230 "segments",
2231 "wind_reference",
2232 ],
2233 &[],
2234 )?;
2235 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
2236 validate_optional_number(object, path, field)?;
2237 }
2238 if let Some(reference) = object.get("wind_reference") {
2240 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
2241 }
2242
2243 if let Some(segments) = object.get("segments") {
2244 let Some(segments) = segments.as_array() else {
2245 return Err(protocol_error(
2246 SolveErrorCodeV1::InvalidValue,
2247 "segments must be an array",
2248 "$.wind.segments",
2249 ));
2250 };
2251 for (index, segment) in segments.iter().enumerate() {
2252 let segment_path = format!("$.wind.segments[{index}]");
2253 let segment = require_object(segment, &segment_path)?;
2254 validate_members(
2255 segment,
2256 &segment_path,
2257 &[
2258 "until_distance_m",
2259 "speed_mps",
2260 "direction_from_rad",
2261 "vertical_speed_mps",
2262 ],
2263 &["until_distance_m", "speed_mps", "direction_from_rad"],
2264 )?;
2265 validate_required_numbers(
2266 segment,
2267 &segment_path,
2268 &["until_distance_m", "speed_mps", "direction_from_rad"],
2269 )?;
2270 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
2271 }
2272 }
2273 Ok(())
2274}
2275
2276fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2277 let path = "$.solver";
2278 let object = require_object(value, path)?;
2279 validate_members(object, path, &["method", "time_step_s"], &[])?;
2280 validate_optional_number(object, path, "time_step_s")?;
2281 if let Some(method) = object.get("method") {
2282 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
2283 }
2284 Ok(())
2285}
2286
2287pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
2296 "magnus",
2297 "coriolis",
2298 "enhanced_spin_drift",
2299 "wind_shear_model",
2300 "aerodynamic_jump",
2301];
2302
2303fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2304 let path = "$.effects";
2305 let object = require_object(value, path)?;
2306 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
2307 validate_optional_booleans(
2308 object,
2309 path,
2310 &[
2311 "magnus",
2312 "coriolis",
2313 "enhanced_spin_drift",
2314 "aerodynamic_jump",
2315 ],
2316 )?;
2317
2318 if let Some(model) = object.get("wind_shear_model") {
2322 validate_string_enum(
2323 model,
2324 "$.effects.wind_shear_model",
2325 &[
2326 "none",
2327 "logarithmic",
2328 "power_law",
2329 "ekman_spiral",
2330 "ekman",
2332 ],
2333 )?;
2334 }
2335
2336 if object.get("magnus").and_then(Value::as_bool) == Some(true)
2337 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
2338 {
2339 return Err(protocol_error(
2340 SolveErrorCodeV1::ConflictingFields,
2341 "magnus and enhanced_spin_drift cannot both be enabled",
2342 "$.effects",
2343 ));
2344 }
2345
2346 Ok(())
2347}
2348
2349fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
2350 let path = "$.sampling";
2351 let object = require_object(value, path)?;
2352 validate_members(object, path, &["interval_m"], &[])?;
2353 validate_optional_number(object, path, "interval_m")
2354}
2355
2356pub(crate) fn require_object<'a>(
2357 value: &'a Value,
2358 path: &str,
2359) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
2360 value
2361 .as_object()
2362 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
2363}
2364
2365fn required_value<'a>(
2366 object: &'a Map<String, Value>,
2367 field: &str,
2368 parent_path: &str,
2369) -> Result<&'a Value, SolveErrorEnvelopeV1> {
2370 object.get(field).ok_or_else(|| {
2371 protocol_error(
2372 SolveErrorCodeV1::MissingField,
2373 format!("missing required field `{field}`"),
2374 child_path(parent_path, field),
2375 )
2376 })
2377}
2378
2379pub(crate) fn validate_members(
2380 object: &Map<String, Value>,
2381 path: &str,
2382 allowed: &[&str],
2383 required: &[&str],
2384) -> Result<(), SolveErrorEnvelopeV1> {
2385 if let Some(field) = object
2386 .keys()
2387 .find(|field| !allowed.contains(&field.as_str()))
2388 {
2389 return Err(protocol_error(
2390 SolveErrorCodeV1::UnknownField,
2391 format!("unknown field `{field}`"),
2392 child_path(path, field),
2393 ));
2394 }
2395
2396 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
2397 return Err(protocol_error(
2398 SolveErrorCodeV1::MissingField,
2399 format!("missing required field `{field}`"),
2400 child_path(path, field),
2401 ));
2402 }
2403 Ok(())
2404}
2405
2406fn validate_string_enum(
2407 value: &Value,
2408 path: &str,
2409 allowed: &[&str],
2410) -> Result<(), SolveErrorEnvelopeV1> {
2411 let Some(value) = value.as_str() else {
2412 return Err(protocol_error(
2413 SolveErrorCodeV1::InvalidValue,
2414 "expected a string enum value",
2415 path,
2416 ));
2417 };
2418 if !allowed.contains(&value) {
2419 return Err(protocol_error(
2420 SolveErrorCodeV1::InvalidValue,
2421 format!(
2422 "invalid value `{value}`; expected one of {}",
2423 allowed.join(", ")
2424 ),
2425 path,
2426 ));
2427 }
2428 Ok(())
2429}
2430
2431fn validate_required_numbers(
2432 object: &Map<String, Value>,
2433 parent_path: &str,
2434 fields: &[&str],
2435) -> Result<(), SolveErrorEnvelopeV1> {
2436 for field in fields {
2437 let value = required_value(object, field, parent_path)?;
2438 validate_number(value, &child_path(parent_path, field))?;
2439 }
2440 Ok(())
2441}
2442
2443fn validate_optional_numbers(
2444 object: &Map<String, Value>,
2445 parent_path: &str,
2446 fields: &[&str],
2447) -> Result<(), SolveErrorEnvelopeV1> {
2448 for field in fields {
2449 validate_optional_number(object, parent_path, field)?;
2450 }
2451 Ok(())
2452}
2453
2454fn validate_optional_number(
2455 object: &Map<String, Value>,
2456 parent_path: &str,
2457 field: &str,
2458) -> Result<(), SolveErrorEnvelopeV1> {
2459 if let Some(value) = object.get(field) {
2460 validate_number(value, &child_path(parent_path, field))?;
2461 }
2462 Ok(())
2463}
2464
2465fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
2466 if value.is_number() {
2467 Ok(())
2468 } else {
2469 Err(protocol_error(
2470 SolveErrorCodeV1::InvalidValue,
2471 "expected a number",
2472 path,
2473 ))
2474 }
2475}
2476
2477fn validate_optional_booleans(
2478 object: &Map<String, Value>,
2479 parent_path: &str,
2480 fields: &[&str],
2481) -> Result<(), SolveErrorEnvelopeV1> {
2482 for field in fields {
2483 if let Some(value) = object.get(*field) {
2484 if !value.is_boolean() {
2485 return Err(protocol_error(
2486 SolveErrorCodeV1::InvalidValue,
2487 "expected a boolean",
2488 child_path(parent_path, field),
2489 ));
2490 }
2491 }
2492 }
2493 Ok(())
2494}
2495
2496fn child_path(parent: &str, field: &str) -> String {
2497 format!("{parent}.{field}")
2498}
2499
2500#[cfg(test)]
2502mod request_range_tests {
2503 use super::*;
2504
2505 fn valid_request_json() -> String {
2508 r#"{"schema_version":1,
2509 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2510 "drag_model":"G7","ballistic_coefficient":0.243},
2511 "rifle":{"muzzle_velocity_mps":823.0},
2512 "shot":{"max_range_m":1000.0},
2513 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2514 .to_string()
2515 }
2516
2517 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2518 decode_solve_request_v1(json).expect_err("request should have been rejected")
2519 }
2520
2521 #[test]
2522 fn an_ordinary_request_still_decodes() {
2523 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2524 }
2525
2526 #[test]
2532 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2533 let reproducer = r#"{"schema_version":1,
2534 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2535 "drag_model":"G7","ballistic_coefficient":1.2e2},
2536 "rifle":{"muzzle_velocity_mps":823.0},
2537 "shot":{"max_range_m":100.0},
2538 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2539
2540 let envelope = decode_err(reproducer);
2541 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2542 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2543 }
2544
2545 #[test]
2548 fn the_rejection_envelope_round_trips() {
2549 let envelope = decode_err(
2550 r#"{"schema_version":1,
2551 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2552 "drag_model":"G7","ballistic_coefficient":0.243},
2553 "rifle":{"muzzle_velocity_mps":823.0},
2554 "shot":{"max_range_m":100.0},
2555 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2556 );
2557 let encoded = serde_json::to_string(&envelope).expect("serialize");
2558 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2559 assert_eq!(decoded, envelope);
2560 }
2561
2562 #[test]
2563 fn each_bounded_field_reports_its_own_path() {
2564 for (field, bad_value, path) in [
2565 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2566 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2567 (
2568 "ballistic_coefficient",
2569 "1.0e6",
2570 "$.projectile.ballistic_coefficient",
2571 ),
2572 ("length_m", "1.0e-9", "$.projectile.length_m"),
2573 ] {
2574 let json = valid_request_json().replace(
2575 &format!("\"{field}\":{}", default_for(field)),
2576 &format!("\"{field}\":{bad_value}"),
2577 );
2578 let envelope = decode_err(&json);
2579 assert_eq!(envelope.error.path(), Some(path), "wrong path for {field}");
2580 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2581 }
2582 }
2583
2584 fn default_for(field: &str) -> &'static str {
2585 match field {
2586 "mass_kg" => "0.01134",
2587 "diameter_m" => "0.00782",
2588 "ballistic_coefficient" => "0.243",
2589 "length_m" => "0.031",
2590 other => panic!("no default recorded for {other}"),
2591 }
2592 }
2593
2594 #[test]
2598 fn muzzle_velocity_is_deliberately_left_unbounded() {
2599 let json = valid_request_json().replace("823.0", "1.0e308");
2600 decode_solve_request_v1(&json)
2601 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2602 }
2603}