1use std::cmp::Ordering;
7
8#[derive(Debug, Clone)]
10struct AtmosphereLayer {
11 base_altitude: f64,
13 base_temperature: f64,
15 base_pressure: f64,
17 lapse_rate: f64,
19}
20
21const G_ACCEL_MPS2: f64 = 9.80665;
23const R_AIR: f64 = 287.0531; const GAMMA: f64 = 1.4; const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
26const MIN_GEOMETRIC_ALTITUDE_M: f64 = -5000.0;
27const MAX_GEOMETRIC_ALTITUDE_M: f64 = 84000.0;
28const MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = -5000.0;
29const MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = 84000.0;
30
31const R: f64 = 8.314472; const M_A: f64 = 28.96546e-3; const M_V: f64 = 18.01528e-3; const ICAO_LAYERS: &[AtmosphereLayer] = &[
39 AtmosphereLayer {
41 base_altitude: 0.0,
42 base_temperature: 288.15, base_pressure: 101325.0, lapse_rate: -0.0065, },
46 AtmosphereLayer {
48 base_altitude: 11000.0,
49 base_temperature: 216.65, base_pressure: 22632.1, lapse_rate: 0.0, },
53 AtmosphereLayer {
55 base_altitude: 20000.0,
56 base_temperature: 216.65, base_pressure: 5474.89, lapse_rate: 0.001, },
60 AtmosphereLayer {
62 base_altitude: 32000.0,
63 base_temperature: 228.65, base_pressure: 868.02, lapse_rate: 0.0028, },
67 AtmosphereLayer {
69 base_altitude: 47000.0,
70 base_temperature: 270.65, base_pressure: 110.91, lapse_rate: 0.0, },
74 AtmosphereLayer {
76 base_altitude: 51000.0,
77 base_temperature: 270.65, base_pressure: 66.94, lapse_rate: -0.0028, },
81 AtmosphereLayer {
83 base_altitude: 71000.0,
84 base_temperature: 214.65, base_pressure: 3.96, lapse_rate: -0.002, },
88];
89
90pub(crate) fn calculate_icao_standard_atmosphere(altitude_m: f64) -> (f64, f64) {
102 let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
103 let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
104 MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
105 MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
106 );
107
108 let layer = ICAO_LAYERS
110 .iter()
111 .rev()
112 .find(|layer| geopotential_height >= layer.base_altitude)
113 .unwrap_or(&ICAO_LAYERS[0]);
114
115 let height_diff = geopotential_height - layer.base_altitude;
116 let temperature = layer.base_temperature + layer.lapse_rate * height_diff;
117
118 let pressure = if layer.lapse_rate.abs() < 1e-10 {
119 layer.base_pressure * (-G_ACCEL_MPS2 * height_diff / (R_AIR * layer.base_temperature)).exp()
121 } else {
122 let temp_ratio = temperature / layer.base_temperature;
124 layer.base_pressure * temp_ratio.powf(-G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR))
125 };
126
127 (temperature, pressure)
128}
129
130#[inline]
132fn geometric_to_geopotential_height_m(geometric_altitude_m: f64) -> f64 {
133 GEOPOTENTIAL_EARTH_RADIUS_M * geometric_altitude_m
134 / (GEOPOTENTIAL_EARTH_RADIUS_M + geometric_altitude_m)
135}
136
137pub fn resolve_station_pressure(pressure_hpa: f64, altitude_m: f64) -> Option<f64> {
153 const SEA_LEVEL_HPA: f64 = 1013.25;
154 if (pressure_hpa - SEA_LEVEL_HPA).abs() < 0.5 && altitude_m.abs() > 1.0 {
155 None } else {
157 Some(pressure_hpa) }
159}
160
161pub fn resolve_station_temperature(temperature_c: f64, altitude_m: f64) -> Option<f64> {
177 const SEA_LEVEL_TEMP_C: f64 = 15.0;
178 if (temperature_c - SEA_LEVEL_TEMP_C).abs() < 0.1 && altitude_m.abs() > 1.0 {
179 None } else {
181 Some(temperature_c) }
183}
184
185pub fn resolve_station_conditions(
188 temperature_c: f64,
189 pressure_hpa: f64,
190 altitude_m: f64,
191) -> (f64, f64) {
192 let temp_override = resolve_station_temperature(temperature_c, altitude_m);
193 let press_override = resolve_station_pressure(pressure_hpa, altitude_m);
194 let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
195 let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
196 let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
197 (temp_c, pressure_hpa)
198}
199
200pub fn calculate_atmosphere(
211 altitude_m: f64,
212 temp_override_c: Option<f64>,
213 press_override_hpa: Option<f64>,
214 humidity_percent: f64,
215) -> (f64, f64) {
216 let combined_overrides = temp_override_c.zip(press_override_hpa);
218 let (temp_k, pressure_pa) = if let Some((temp_c, pressure_hpa)) = combined_overrides {
219 (temp_c + 273.15, pressure_hpa * 100.0)
221 } else {
222 let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
224
225 let final_temp_k = if let Some(temp_c) = temp_override_c {
226 temp_c + 273.15
227 } else {
228 std_temp_k
229 };
230
231 let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
232 press_hpa * 100.0
233 } else {
234 std_pressure_pa
235 };
236
237 (final_temp_k, final_pressure_pa)
238 };
239
240 let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
242 let temp_c = temp_k - 273.15;
243
244 let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
248
249 let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
255
256 (density, speed_of_sound)
257}
258
259pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
278 let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
279 let temp_c = temp_k - 273.15;
280
281 let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
285 let f = enhanced_enhancement_factor(pressure_pa, temp_c);
286 let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
287
288 let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
291
292 let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
296 let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
297
298 (gamma_moist * r_moist * temp_k).sqrt()
299}
300
301pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
311 let t_k = temp_c + 273.15;
312
313 let p_sv = enhanced_saturation_vapor_pressure(t_k);
315
316 let pressure_pa = pressure_hpa * 100.0;
317
318 let f = enhanced_enhancement_factor(pressure_pa, temp_c);
320
321 let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv;
324
325 let p_v_pa = p_v * 100.0;
331
332 let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
335
336 let x_v = (p_v_pa / p_pa).min(1.0);
338
339 let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
341
342 ((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
345}
346
347#[inline(always)]
350fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
351 const A: [f64; 6] = [
353 -7.85951783,
354 1.84408259,
355 -11.7866497,
356 22.6807411,
357 -15.9618719,
358 1.80122502,
359 ];
360
361 let t_k_safe = t_k.max(173.15); let tau = 1.0 - t_k_safe / 647.096; let ln_p_ratio = (647.096 / t_k_safe)
366 * (A[0] * tau
367 + A[1] * tau.powf(1.5)
368 + A[2] * tau.powf(3.0)
369 + A[3] * tau.powf(3.5)
370 + A[4] * tau.powf(4.0)
371 + A[5] * tau.powf(7.5));
372
373 220640.0 * ln_p_ratio.exp() }
375
376#[inline(always)]
378fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
379 const ALPHA: f64 = 1.00062;
380 const BETA: f64 = 3.14e-8;
381 const GAMMA: f64 = 5.6e-7;
382
383 ALPHA + BETA * p + GAMMA * t * t
384}
385
386#[inline(always)]
388fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
389 const A0: f64 = 1.58123e-6;
391 const A1: f64 = -2.9331e-8;
392 const A2: f64 = 1.1043e-10;
393 const B0: f64 = 5.707e-6;
394 const B1: f64 = -2.051e-8;
395 const C0: f64 = 1.9898e-4;
396 const C1: f64 = -2.376e-6;
397 const D: f64 = 1.83e-11;
398 const E: f64 = -0.765e-8;
399
400 let t_k_safe = t_k.max(173.15); let t = t_k_safe - 273.15;
403 let p_t = p / t_k_safe;
404
405 let z_second_order =
406 1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
407
408 let z_third_order = p_t * p_t * (D + E * x_v * x_v);
409
410 z_second_order + z_third_order
411}
412
413#[inline]
419pub(crate) fn shot_frame_altitude(
420 base_altitude_m: f64,
421 downrange_m: f64,
422 shot_y_m: f64,
423 shooting_angle_rad: f64,
424) -> f64 {
425 base_altitude_m
426 + downrange_m * shooting_angle_rad.sin()
427 + shot_y_m * shooting_angle_rad.cos()
428}
429
430pub fn get_local_atmosphere(
442 altitude_m: f64,
443 base_alt: f64,
444 base_temp_c: f64,
445 base_press_hpa: f64,
446 base_ratio: f64,
447) -> (f64, f64) {
448 let (temp_k, _pressure_pa, density) =
449 local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
450
451 let speed_of_sound = (temp_k * 401.874).sqrt();
454
455 (density, speed_of_sound)
456}
457
458pub fn get_local_atmosphere_humid(
476 altitude_m: f64,
477 base_alt: f64,
478 base_temp_c: f64,
479 base_press_hpa: f64,
480 base_ratio: f64,
481 humidity_percent: f64,
482) -> (f64, f64) {
483 let (temp_k, pressure_pa, density) =
484 local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
485 (density, moist_speed_of_sound(temp_k, pressure_pa, humidity_percent))
486}
487
488#[inline]
491fn local_temp_pressure_density(
492 altitude_m: f64,
493 base_alt: f64,
494 base_temp_c: f64,
495 base_press_hpa: f64,
496 base_ratio: f64,
497) -> (f64, f64, f64) {
498 let base_temp_k = base_temp_c + 273.15;
499
500 if !altitude_m.is_finite() || !base_alt.is_finite() {
503 return (f64::NAN, f64::NAN, f64::NAN);
504 }
505
506 let base_geopotential_m = geometric_to_geopotential_height_m(base_alt);
507 let target_geopotential_m = geometric_to_geopotential_height_m(altitude_m);
508 let (temp_k, pressure_hpa) = integrate_local_atmosphere_layers(
509 base_geopotential_m,
510 target_geopotential_m,
511 base_temp_k,
512 base_press_hpa,
513 );
514
515 let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
517 let density = density_ratio * 1.225;
518
519 (temp_k, pressure_hpa * 100.0, density)
520}
521
522fn integrate_local_atmosphere_layers(
526 base_geopotential_m: f64,
527 target_geopotential_m: f64,
528 mut temp_k: f64,
529 mut pressure_hpa: f64,
530) -> (f64, f64) {
531 let mut current_alt = base_geopotential_m;
532
533 if target_geopotential_m > current_alt {
534 while current_alt < target_geopotential_m {
535 let layer_index = ICAO_LAYERS
537 .iter()
538 .rposition(|layer| current_alt >= layer.base_altitude)
539 .unwrap_or(0);
540 let segment_end = ICAO_LAYERS
541 .get(layer_index + 1)
542 .map_or(target_geopotential_m, |next| {
543 target_geopotential_m.min(next.base_altitude)
544 });
545 (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
546 temp_k,
547 pressure_hpa,
548 segment_end - current_alt,
549 ICAO_LAYERS[layer_index].lapse_rate,
550 );
551 current_alt = segment_end;
552 }
553 } else {
554 while current_alt > target_geopotential_m {
555 let layer_index = ICAO_LAYERS
558 .iter()
559 .rposition(|layer| current_alt > layer.base_altitude)
560 .unwrap_or(0);
561 let segment_end = if layer_index == 0 {
562 target_geopotential_m
563 } else {
564 target_geopotential_m.max(ICAO_LAYERS[layer_index].base_altitude)
565 };
566 (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
567 temp_k,
568 pressure_hpa,
569 segment_end - current_alt,
570 ICAO_LAYERS[layer_index].lapse_rate,
571 );
572 current_alt = segment_end;
573 }
574 }
575
576 (temp_k, pressure_hpa)
577}
578
579#[inline]
580fn integrate_local_atmosphere_segment(
581 base_temp_k: f64,
582 base_pressure_hpa: f64,
583 height_diff: f64,
584 lapse_rate: f64,
585) -> (f64, f64) {
586 let temp_k = base_temp_k + lapse_rate * height_diff;
587 let pressure_hpa = if lapse_rate.abs() < 1e-10 {
588 base_pressure_hpa * (-G_ACCEL_MPS2 * height_diff / (R_AIR * base_temp_k)).exp()
589 } else {
590 let temp_ratio = temp_k / base_temp_k;
591 base_pressure_hpa * temp_ratio.powf(-G_ACCEL_MPS2 / (lapse_rate * R_AIR))
592 };
593
594 (temp_k, pressure_hpa)
595}
596
597#[cfg(test)]
599#[inline(always)]
600fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
601 let layer = ICAO_LAYERS
603 .iter()
604 .rev()
605 .find(|layer| altitude_m >= layer.base_altitude)
606 .unwrap_or(&ICAO_LAYERS[0]);
607
608 layer.lapse_rate
609}
610
611#[inline(always)]
620pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
621 (density, speed_of_sound)
622}
623
624pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
626 calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
627}
628
629pub type AtmoSegment = (f64, f64, f64, f64);
637
638#[derive(Debug, Clone)]
649pub struct AtmoSock {
650 segments: Vec<AtmoSegment>,
652}
653
654impl AtmoSock {
655 pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
660 segments.sort_by(|a, b| match (a.3.is_nan(), b.3.is_nan()) {
661 (true, true) => Ordering::Equal,
662 (true, false) => Ordering::Greater,
663 (false, true) => Ordering::Less,
664 (false, false) => a.3.partial_cmp(&b.3).unwrap(),
665 });
666 AtmoSock { segments }
667 }
668
669 pub fn is_empty(&self) -> bool {
671 self.segments.is_empty()
672 }
673
674 pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
685 if self.segments.is_empty() {
686 return (15.0, 1013.25, 0.0); }
688 if downrange_m.is_nan() {
690 let s = self.segments[0];
691 return (s.0, s.1, s.2);
692 }
693 for seg in &self.segments {
694 if downrange_m < seg.3 {
695 return (seg.0, seg.1, seg.2);
696 }
697 }
698 let last = self.segments[self.segments.len() - 1];
700 (last.0, last.1, last.2)
701 }
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn inclined_shot_frame_position_maps_to_world_altitude() {
710 let base_altitude = 100.0;
711 let downrange = 1_000.0;
712 let shot_y = 10.0;
713 let angle = std::f64::consts::FRAC_PI_6;
714 let expected = base_altitude + downrange * angle.sin() + shot_y * angle.cos();
715
716 let actual = shot_frame_altitude(base_altitude, downrange, shot_y, angle);
717 assert!(
718 (actual - expected).abs() < 1e-12,
719 "30-degree shot at x=1000/y=10 should be at {expected} m, got {actual} m"
720 );
721 assert_eq!(
722 shot_frame_altitude(base_altitude, downrange, shot_y, 0.0),
723 base_altitude + shot_y,
724 "flat-fire altitude must remain byte-identical"
725 );
726 let downhill = shot_frame_altitude(base_altitude, downrange, shot_y, -angle);
727 let expected_downhill = base_altitude - downrange * angle.sin() + shot_y * angle.cos();
728 assert!((downhill - expected_downhill).abs() < 1e-12);
729 }
730
731 #[test]
739 fn test_mba1136_dry_sea_level_reference() {
740 let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
741 assert!(
742 (density - 1.225).abs() < 0.002,
743 "dry sea-level density {density} not within 1.225 +- 0.002"
744 );
745 assert!(
746 (sos - 340.3).abs() < 0.6,
747 "dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
748 );
749 }
750
751 #[test]
754 fn test_mba1136_humid_lighter_than_dry() {
755 let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
756 let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
757
758 assert!(
759 (moist_rho - 1.2211).abs() < 0.002,
760 "50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
761 );
762 assert!(
763 moist_rho < dry_rho,
764 "moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
765 );
766 assert!(
767 moist_sos > dry_sos,
768 "moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
769 );
770 }
771
772 #[test]
774 fn test_mba1136_density_monotone_in_humidity() {
775 let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
776 let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
777 let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
778 assert!(
779 rho_100 < rho_50 && rho_50 < rho_0,
780 "humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
781 );
782 }
783
784 #[test]
787 fn test_mba1136_atmosphere_density_is_cipm() {
788 for (t, p, h) in [
789 (15.0, 1013.25, 0.0),
790 (15.0, 1013.25, 50.0),
791 (30.0, 1000.0, 80.0),
792 (-10.0, 1020.0, 20.0),
793 ] {
794 let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
795 let cipm = calculate_air_density_cimp(t, p, h);
796 assert_eq!(
797 density, cipm,
798 "calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
799 );
800 }
801 }
802
803 #[test]
806 fn test_mba1136_moist_speed_of_sound_extraction() {
807 for (t, p, h) in [
808 (15.0, 1013.25, 0.0),
809 (15.0, 1013.25, 50.0),
810 (25.0, 900.0, 100.0),
811 ] {
812 let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
813 let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
814 assert_eq!(
815 sos, extracted,
816 "extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
817 );
818 }
819 }
820
821 #[test]
824 fn test_mba1136_get_local_atmosphere_reference() {
825 let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
826 assert!(
827 (d0 - 1.286250000000).abs() < 1e-9,
828 "local density@500m drifted: {d0}"
829 );
830 assert!(
831 (c0 - 337.328657395129).abs() < 1e-9,
832 "local sos@500m drifted: {c0}"
833 );
834 let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
835 assert!(
836 (d1 - 1.165238292559).abs() < 1e-9,
837 "local density@1500m drifted: {d1}"
838 );
839 assert!(
840 (c1 - 333.435546617978).abs() < 1e-9,
841 "local sos@1500m drifted: {c1}"
842 );
843 }
844
845 #[test]
846 fn fractional_station_altitude_is_not_quantized() {
847 let station_altitude_m = 500.25;
848 let station_temp_c = 10.0;
849 let station_pressure_hpa = 950.0;
850 let station_density_ratio = 1.05;
851
852 let (density, speed_of_sound) = get_local_atmosphere(
853 station_altitude_m,
854 station_altitude_m,
855 station_temp_c,
856 station_pressure_hpa,
857 station_density_ratio,
858 );
859
860 let expected_density = station_density_ratio * 1.225;
861 let expected_speed_of_sound = ((station_temp_c + 273.15) * 401.874).sqrt();
862 assert!((density - expected_density).abs() < 1e-12);
863 assert!((speed_of_sound - expected_speed_of_sound).abs() < 1e-12);
864 }
865
866 #[test]
870 fn test_mba1136_get_local_atmosphere_humid() {
871 let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
872 let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
873 assert_eq!(d_dry, d_h0, "humid variant must not change density");
874 assert!(
875 (c_h0 - c_dry).abs() < 1e-3,
876 "0% RH humid sos {c_h0} should match dry sos {c_dry}"
877 );
878 let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
879 assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
880 }
881
882 #[test]
883 fn test_icao_standard_atmosphere() {
884 let (temp, press) = calculate_icao_standard_atmosphere(0.0);
886 assert!((temp - 288.15).abs() < 0.01);
887 assert!((press - 101325.0).abs() < 1.0);
888
889 let geometric_tropopause_m =
892 GEOPOTENTIAL_EARTH_RADIUS_M * 11000.0 / (GEOPOTENTIAL_EARTH_RADIUS_M - 11000.0);
893 let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(geometric_tropopause_m);
894 assert!((temp_11km - 216.65).abs() < 0.01);
895 assert!((press_11km - 22632.1).abs() < 1.0);
896
897 let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
899 assert!(temp_25km > 216.65); }
901
902 #[test]
903 fn standard_atmosphere_extends_below_sea_level() {
904 let altitude_m = -430.0;
905 let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
906
907 assert!((temp_k - 290.945_189_079_054).abs() < 1e-9);
908 assert!((pressure_pa - 106_598.763_552_437).abs() < 0.1);
909
910 let (station_temp_c, station_pressure_hpa) =
911 resolve_station_conditions(15.0, 1013.25, altitude_m);
912 assert!((station_temp_c - 17.795_189_079_054).abs() < 1e-9);
913 assert!((station_pressure_hpa - 1_065.987_635_524).abs() < 1e-6);
914
915 let (sea_density, _) = calculate_atmosphere(0.0, None, None, 0.0);
916 let (below_sea_density, _) = calculate_atmosphere(altitude_m, None, None, 0.0);
917 assert!((below_sea_density - 1.276_908_642_79).abs() < 1e-6);
918 assert!(below_sea_density > sea_density * 1.04);
919
920 assert_eq!(
921 calculate_icao_standard_atmosphere(-6000.0),
922 calculate_icao_standard_atmosphere(MIN_GEOMETRIC_ALTITUDE_M)
923 );
924 }
925
926 #[test]
927 fn standard_atmosphere_converts_geometric_to_geopotential_height() {
928 let cases: [(f64, f64, f64); 3] = [
929 (10_000.0, 223.252_092_647_979, 26_499.901_600_244),
930 (30_000.0, 226.509_083_611_330, 1_197.032_108_466),
931 (84_000.0, 190.841_043_736_102, 0.531_525_514_935),
932 ];
933 for (geometric_m, expected_temp_k, expected_pressure_pa) in cases {
934 let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(geometric_m);
935 let pressure_tolerance = expected_pressure_pa.max(1.0_f64) * 1e-6;
936
937 assert!((temp_k - expected_temp_k).abs() < 1e-9);
938 assert!((pressure_pa - expected_pressure_pa).abs() < pressure_tolerance);
939 }
940 }
941
942 #[test]
943 fn local_atmosphere_walks_icao_layers_continuously() {
944 for altitude_m in [
945 10999.0, 11000.0, 11001.0, 11050.0, 19999.0, 20000.0, 20001.0, 25000.0,
946 ] {
947 let (local_temp_k, local_pressure_pa, _) =
948 local_temp_pressure_density(altitude_m, 0.0, 15.0, 1013.25, 1.0);
949 let (standard_temp_k, standard_pressure_pa) =
950 calculate_icao_standard_atmosphere(altitude_m);
951
952 assert!(
953 (local_temp_k - standard_temp_k).abs() < 1e-9,
954 "local temperature diverged from ICAO at {altitude_m} m: local={local_temp_k}, standard={standard_temp_k}"
955 );
956 assert!(
957 ((local_pressure_pa - standard_pressure_pa) / standard_pressure_pa).abs() < 5e-5,
958 "local pressure diverged from ICAO at {altitude_m} m: local={local_pressure_pa}, standard={standard_pressure_pa}"
959 );
960 }
961
962 let (density_below, sound_below) =
963 get_local_atmosphere(10999.0, 0.0, 15.0, 1013.25, 1.0);
964 let (density_above, sound_above) =
965 get_local_atmosphere(11001.0, 0.0, 15.0, 1013.25, 1.0);
966 assert!(
967 (density_below - density_above).abs() < 0.001,
968 "density jumped across 11 km: below={density_below}, above={density_above}"
969 );
970 assert!(
971 (sound_below - sound_above).abs() < 0.1,
972 "speed of sound jumped across 11 km: below={sound_below}, above={sound_above}"
973 );
974 }
975
976 #[test]
977 fn local_atmosphere_preserves_nonstandard_station_offset_and_round_trips() {
978 let base_alt = 7500.0;
979 let base_temp_c = 5.0;
980 let base_pressure_hpa = 410.0;
981 let base_ratio = 0.72;
982
983 let (high_temp_k, high_pressure_pa, high_density) = local_temp_pressure_density(
984 25000.0,
985 base_alt,
986 base_temp_c,
987 base_pressure_hpa,
988 base_ratio,
989 );
990 assert!((high_temp_k - 260.244_615_053_376).abs() < 1e-9);
991 assert!((high_pressure_pa / 100.0 - 40.964_358_485_456).abs() < 1e-9);
992 assert!((high_density - 0.094_186_400_274).abs() < 1e-9);
993
994 let (back_temp_k, back_pressure_pa, back_density) = local_temp_pressure_density(
995 base_alt,
996 25000.0,
997 high_temp_k - 273.15,
998 high_pressure_pa / 100.0,
999 high_density / 1.225,
1000 );
1001 assert!((back_temp_k - (base_temp_c + 273.15)).abs() < 1e-9);
1002 assert!((back_pressure_pa / 100.0 - base_pressure_hpa).abs() < 1e-8);
1003 assert!((back_density - base_ratio * 1.225).abs() < 1e-9);
1004 }
1005
1006 #[test]
1007 fn test_enhanced_atmosphere_sea_level() {
1008 let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
1009 assert!((density - 1.225).abs() < 0.01);
1010 assert!((speed - 340.0).abs() < 1.0);
1011 }
1012
1013 #[test]
1014 fn test_resolve_station_pressure_contract() {
1015 assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
1017 assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
1019 assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
1021 assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
1023 }
1024
1025 #[test]
1026 fn test_altitude_affects_density_with_default_pressure() {
1027 let press = resolve_station_pressure(1013.25, 0.0);
1030 let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
1031 let press_alt = resolve_station_pressure(1013.25, 2000.0);
1032 let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
1033 assert!(
1034 rho_2km < rho_sea * 0.9,
1035 "density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
1036 );
1037
1038 let p = resolve_station_pressure(900.0, 2000.0);
1041 let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
1042 let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
1043 assert!(
1044 (rho_a - rho_b).abs() < 1e-9,
1045 "explicit pressure must ignore altitude"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_resolve_station_temperature_contract() {
1051 assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
1053 assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
1055 assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
1056 assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
1058 }
1059
1060 #[test]
1061 fn test_altitude_only_default_matches_full_icao_standard() {
1062 for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
1068 let t = resolve_station_temperature(15.0, alt);
1069 let p = resolve_station_pressure(1013.25, alt);
1070 let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
1071 let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
1072 assert!(
1073 (rho_resolved - rho_std).abs() < 1e-9,
1074 "alt {alt}: altitude-only default density {rho_resolved} should equal the full \
1075 ICAO standard {rho_std}"
1076 );
1077 let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
1079 assert!(
1080 rho_resolved > rho_warm,
1081 "alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
1082 );
1083 }
1084 }
1085
1086 #[test]
1087 fn test_enhanced_atmosphere_with_humidity() {
1088 let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
1089 let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
1090
1091 assert!(density_humid < density_dry);
1093 assert!(speed_humid > speed_dry);
1095 }
1096
1097 #[test]
1098 fn test_enhanced_atmosphere_stratosphere() {
1099 let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
1101 let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
1102
1103 assert!(density_30km < density_20km);
1105 assert!(speed_30km > speed_20km);
1107 }
1108
1109 #[test]
1110 fn test_enhanced_cimp_density() {
1111 let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
1112 assert!((density - 1.225).abs() < 0.01);
1113
1114 let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
1116 assert!(density_humid < density);
1117 }
1118
1119 #[test]
1120 fn test_cipm_moist_air_matches_python_reference() {
1121 let cases = [
1128 (15.0, 1013.25, 50.0, 1.221125867723075),
1129 (30.0, 1000.0, 80.0, 1.1344071877123691),
1130 (-10.0, 1020.0, 20.0, 1.3500610713710515),
1131 ];
1132 for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
1133 let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
1134 let rel_err = ((density - expected) / expected).abs();
1135 assert!(
1136 rel_err < 1e-3,
1137 "CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
1138 got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
1139 );
1140 }
1141
1142 let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
1145 let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
1146 assert!(
1147 dry - moist > 3e-3,
1148 "humidity effect too small: dry {dry} vs 50% RH {moist}"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_variable_lapse_rates() {
1154 let lapse_tropo = determine_local_lapse_rate(5000.0);
1156 let lapse_strato = determine_local_lapse_rate(25000.0);
1157
1158 assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
1159 assert!(lapse_strato > 0.0); }
1161
1162 #[test]
1165 fn test_atmo_sock_empty_falls_back_to_isa() {
1166 let sock = AtmoSock::new(vec![]);
1167 assert!(sock.is_empty());
1168 assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
1170 assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
1171 }
1172
1173 #[test]
1174 fn test_atmo_sock_single_segment_holds_beyond_last() {
1175 let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
1178 assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
1179 assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
1181 }
1182
1183 #[test]
1184 fn test_atmo_sock_boundary_is_upper_exclusive() {
1185 let sock = AtmoSock::new(vec![
1188 (30.0, 1010.0, 80.0, 100.0), (-5.0, 900.0, 10.0, 200.0), ]);
1191 assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
1193 assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
1195 assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
1197 assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
1198 }
1199
1200 #[test]
1201 fn test_atmo_sock_sorts_unordered_segments() {
1202 let sock = AtmoSock::new(vec![
1204 (-5.0, 900.0, 10.0, 200.0),
1205 (30.0, 1010.0, 80.0, 100.0),
1206 ]);
1207 assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
1208 assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
1209 }
1210
1211 #[test]
1212 fn test_atmo_sock_orders_nan_thresholds_last() {
1213 let positive_nan = f64::from_bits(0x7ff8_0000_0000_0001);
1214 let negative_nan = f64::from_bits(0xfff8_0000_0000_0002);
1215 let sock = AtmoSock::new(vec![
1216 (97.0, 997.0, 97.0, positive_nan),
1217 (30.0, 1030.0, 30.0, 300.0),
1218 (98.0, 998.0, 98.0, negative_nan),
1219 (10.0, 1010.0, 10.0, 100.0),
1220 (99.0, 999.0, 99.0, f64::NAN),
1221 (20.0, 1020.0, 20.0, 200.0),
1222 ]);
1223
1224 let thresholds: Vec<_> = sock.segments.iter().map(|segment| segment.3).collect();
1225 assert_eq!(&thresholds[..3], &[100.0, 200.0, 300.0]);
1226 assert!(thresholds[3..].iter().all(|threshold| threshold.is_nan()));
1227 assert_eq!(sock.atmo_for_range(f64::NAN), (10.0, 1010.0, 10.0));
1228 assert_eq!(sock.atmo_for_range(350.0), (99.0, 999.0, 99.0));
1229 }
1230
1231 #[test]
1232 fn test_atmo_sock_nan_uses_first_zone() {
1233 let sock = AtmoSock::new(vec![
1234 (30.0, 1010.0, 80.0, 100.0),
1235 (-5.0, 900.0, 10.0, 200.0),
1236 ]);
1237 assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
1239 }
1240}