1use serde::{Deserialize, Serialize};
64use serde_json::Value;
65
66use crate::solve_json::{
67 AtmosphereV1, PressureReferenceV1, ResolvedAtmosphereV1, SolveErrorCodeV1,
68 SolveErrorEnvelopeV1, SolveErrorV1, SolveNoticeV1,
69};
70use crate::solve_v1::{KELVIN_OFFSET_C, PASCALS_PER_HECTOPASCAL};
71
72const METERS_PER_FOOT: f64 = 0.3048;
73
74const MIN_TEMPERATURE_K: f64 = 173.15;
77const MAX_TEMPERATURE_K: f64 = 373.15;
80const LOWEST_ALTITUDE_M: f64 = -5_000.0;
82const TROPOSPHERE_TOP_M: f64 = 11_000.0;
85
86#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct DensityAltitudeRequestV1 {
97 pub atmosphere: AtmosphereV1,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct AltitudeV1 {
106 pub m: f64,
107 pub ft: f64,
108}
109
110impl AltitudeV1 {
111 fn from_ft(ft: f64) -> Self {
112 Self {
113 m: ft * METERS_PER_FOOT,
114 ft,
115 }
116 }
117
118 fn from_m(m: f64) -> Self {
119 Self {
120 m,
121 ft: m / METERS_PER_FOOT,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct DensityAltitudesV1 {
131 pub faa_rule: AltitudeV1,
134 pub density_matched: AltitudeV1,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct DensityAltitudeResponseV1 {
142 pub atmosphere: ResolvedAtmosphereV1,
148 pub pressure_altitude: AltitudeV1,
150 pub density_altitude: DensityAltitudesV1,
151 pub air_density_kg_m3: f64,
153 pub assumptions: Vec<SolveNoticeV1>,
156}
157
158pub fn decode_density_altitude_request_v1(
163 value: &Value,
164) -> Result<DensityAltitudeRequestV1, SolveErrorEnvelopeV1> {
165 let root = crate::solve_json::require_object(value, "$")?;
166 crate::solve_json::validate_members(root, "$", &["atmosphere"], &["atmosphere"])?;
167 crate::solve_json::validate_atmosphere(&root["atmosphere"])?;
168 serde_json::from_value(value.clone()).map_err(|error| invalid_value("$", error.to_string()))
169}
170
171pub fn density_altitude_v1(
177 request: &DensityAltitudeRequestV1,
178) -> Result<DensityAltitudeResponseV1, SolveErrorEnvelopeV1> {
179 let mut assumptions = Vec::new();
180 let atmosphere = crate::solve_v1::resolve_atmosphere(&request.atmosphere, &mut assumptions)?;
181
182 if !(MIN_TEMPERATURE_K..=MAX_TEMPERATURE_K).contains(&atmosphere.temperature_k) {
186 return Err(invalid_value(
187 "$.atmosphere.temperature_k",
188 format!(
189 "{} K is outside {MIN_TEMPERATURE_K}–{MAX_TEMPERATURE_K} K (−100 °C to 100 °C), \
190 the range the humid-air model behind these figures is defined for; \
191 temperature_k is kelvin (°C + 273.15)",
192 echo(atmosphere.temperature_k)
193 ),
194 ));
195 }
196
197 let temperature_c = atmosphere.temperature_k - KELVIN_OFFSET_C;
200 let temperature_f = temperature_c * 9.0 / 5.0 + 32.0;
201 let station_pressure_hpa = atmosphere.pressure_pa / PASCALS_PER_HECTOPASCAL;
202 let humidity_percent = atmosphere.relative_humidity * 100.0;
203
204 let pressure_altitude_ft = crate::atmosphere::nws_pressure_altitude_ft(station_pressure_hpa);
207 let pressure_altitude = AltitudeV1::from_ft(pressure_altitude_ft);
208 if !within_troposphere(pressure_altitude.m) {
209 return Err(pressure_outside_troposphere(
210 &request.atmosphere,
211 &atmosphere,
212 pressure_altitude.m,
213 ));
214 }
215
216 let vapor_fraction = crate::atmosphere::water_vapor_mole_fraction_uncapped(
217 temperature_c,
218 station_pressure_hpa,
219 humidity_percent,
220 );
221 if vapor_fraction.is_nan() || vapor_fraction >= 1.0 {
223 return Err(invalid_value(
224 "$.atmosphere.relative_humidity",
225 format!(
226 "a relative humidity of {} at {} K needs more water-vapor pressure than the \
227 air's total pressure of {} Pa; no such air exists",
228 atmosphere.relative_humidity,
229 echo(atmosphere.temperature_k),
230 echo(atmosphere.pressure_pa)
231 ),
232 ));
233 }
234
235 let faa_rule = AltitudeV1::from_ft(crate::atmosphere::faa_rule_density_altitude_ft(
236 station_pressure_hpa,
237 temperature_f,
238 ));
239 let density_matched = AltitudeV1::from_m(crate::atmosphere::density_matched_altitude_m(
240 temperature_c,
241 station_pressure_hpa,
242 humidity_percent,
243 ));
244 let (air_density_kg_m3, _speed_of_sound) = crate::atmosphere::calculate_atmosphere(
245 atmosphere.altitude_m,
246 Some(temperature_c),
247 Some(station_pressure_hpa),
248 humidity_percent,
249 );
250
251 for (name, altitude) in [("faa_rule", faa_rule), ("density_matched", density_matched)] {
252 if !within_troposphere(altitude.m) {
253 return Err(invalid_value(
254 "$.atmosphere",
255 format!(
256 "the {name} density altitude of these conditions is {:.0} m; density \
257 altitude is computed only between {LOWEST_ALTITUDE_M} m and \
258 {TROPOSPHERE_TOP_M} m, the standard atmosphere's troposphere. Check that \
259 temperature_k is kelvin and pressure_pa is pascals",
260 altitude.m
261 ),
262 ));
263 }
264 }
265 let figures = [
269 pressure_altitude.m,
270 pressure_altitude.ft,
271 faa_rule.m,
272 faa_rule.ft,
273 density_matched.m,
274 density_matched.ft,
275 ];
276 if !(figures.iter().all(|x| x.is_finite())
277 && air_density_kg_m3.is_finite()
278 && air_density_kg_m3 > 0.0)
279 {
280 return Err(invalid_value(
281 "$.atmosphere",
282 "these conditions do not produce a finite, positive air density",
283 ));
284 }
285
286 Ok(DensityAltitudeResponseV1 {
287 atmosphere,
288 pressure_altitude,
289 density_altitude: DensityAltitudesV1 {
290 faa_rule,
291 density_matched,
292 },
293 air_density_kg_m3,
294 assumptions,
295 })
296}
297
298fn pressure_outside_troposphere(
301 sent: &AtmosphereV1,
302 resolved: &ResolvedAtmosphereV1,
303 pressure_altitude_m: f64,
304) -> SolveErrorEnvelopeV1 {
305 let scope = format!(
306 "is a pressure altitude of {pressure_altitude_m:.0} m; density altitude is computed only \
307 between {LOWEST_ALTITUDE_M} m and {TROPOSPHERE_TOP_M} m, the standard atmosphere's \
308 troposphere"
309 );
310 let station = echo(resolved.pressure_pa);
311 let altitude = echo(resolved.altitude_m);
312 match sent.pressure_pa {
313 Some(qnh) if sent.pressure_reference == Some(PressureReferenceV1::Qnh) => {
314 let qnh_alone_m =
318 crate::atmosphere::nws_pressure_altitude_ft(qnh / PASCALS_PER_HECTOPASCAL)
319 * METERS_PER_FOOT;
320 let path = if within_troposphere(qnh_alone_m) {
321 "$.atmosphere.altitude_m"
322 } else {
323 "$.atmosphere.pressure_pa"
324 };
325 invalid_value(
326 path,
327 format!(
328 "a QNH of {} Pa, reduced to a station pressure of {station} Pa at \
329 {altitude} m, {scope}. pressure_pa is pascals (hPa × 100) and altitude_m \
330 is meters",
331 echo(qnh)
332 ),
333 )
334 }
335 Some(_) => invalid_value(
336 "$.atmosphere.pressure_pa",
337 format!(
338 "a station pressure of {station} Pa {scope}. pressure_pa is pascals (hPa × 100)"
339 ),
340 ),
341 None => invalid_value(
342 "$.atmosphere.altitude_m",
343 format!(
344 "the ICAO standard pressure at {altitude} m, {station} Pa, {scope}. altitude_m \
345 is meters"
346 ),
347 ),
348 }
349}
350
351fn echo(value: f64) -> String {
354 if value == 0.0 || (1e-3..1e7).contains(&value.abs()) {
355 format!("{value}")
356 } else {
357 format!("{value:e}")
358 }
359}
360
361fn within_troposphere(altitude_m: f64) -> bool {
362 (LOWEST_ALTITUDE_M..=TROPOSPHERE_TOP_M).contains(&altitude_m)
363}
364
365fn invalid_value(path: &str, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
366 SolveErrorEnvelopeV1::new(
367 SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, message).at_path(path),
368 )
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use serde_json::json;
375
376 fn describe(atmosphere: AtmosphereV1) -> DensityAltitudeResponseV1 {
377 density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere }).expect("valid atmosphere")
378 }
379
380 fn refusal(atmosphere: AtmosphereV1) -> SolveErrorV1 {
381 density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere })
382 .expect_err("atmosphere should be refused")
383 .error
384 }
385
386 fn at(temperature_c: f64, pressure_hpa: f64, relative_humidity: f64) -> AtmosphereV1 {
387 AtmosphereV1 {
388 altitude_m: Some(0.0),
389 temperature_k: Some(temperature_c + KELVIN_OFFSET_C),
390 pressure_pa: Some(pressure_hpa * PASCALS_PER_HECTOPASCAL),
391 relative_humidity: Some(relative_humidity),
392 ..AtmosphereV1::default()
393 }
394 }
395
396 #[test]
397 fn icao_standard_dry_air_is_zero_by_both_definitions() {
398 let r = describe(at(15.0, 1013.25, 0.0));
399 assert!(r.density_altitude.faa_rule.m.abs() < 1e-9, "{r:?}");
400 assert!(r.density_altitude.density_matched.m.abs() < 1e-9, "{r:?}");
401 assert!(r.pressure_altitude.m.abs() < 1e-9, "{r:?}");
402 }
403
404 #[test]
411 fn humidity_moves_the_density_matched_figure_and_not_the_faa_one() {
412 let r = describe(at(15.0, 1013.25, 0.5));
413 assert!(r.density_altitude.faa_rule.m.abs() < 1e-9, "{r:?}");
414 let matched = r.density_altitude.density_matched.m;
415 assert!(
416 (matched - 33.10).abs() < 0.2,
417 "50% RH at ICAO standard should be 33.1 m density-matched, got {matched}"
418 );
419 }
420
421 #[test]
422 fn density_matched_rises_monotonically_with_humidity() {
423 let mut last = f64::NEG_INFINITY;
424 for rh in [0.0, 0.25, 0.5, 0.75, 1.0] {
425 let m = describe(at(15.0, 1013.25, rh))
426 .density_altitude
427 .density_matched
428 .m;
429 assert!(m > last, "RH {rh} gave {m}, not above {last}");
430 last = m;
431 }
432 }
433
434 #[test]
439 fn the_definitions_differ_in_dry_air_away_from_standard() {
440 let r = describe(at(30.0, 1013.25, 0.0));
441 let faa = r.density_altitude.faa_rule.m;
442 let matched = r.density_altitude.density_matched.m;
443 assert!((faa - 548.64).abs() < 0.05, "FAA rule at 30 C dry: {faa}");
444 assert!(
445 (matched - 526.92).abs() < 0.2,
446 "density-matched at 30 C dry: {matched}"
447 );
448 assert!(
449 faa - matched > 15.0,
450 "expected the FAA rule to read ~22 m high, got {faa} vs {matched}"
451 );
452 }
453
454 #[test]
458 fn faa_rule_is_bit_identical_to_the_card_formula_it_replaced() {
459 for (t_c, p_hpa) in [
460 (15.0, 1013.25),
461 (30.0, 1013.25),
462 (15.0, 950.0),
463 (0.0, 1013.25),
464 (-10.0, 850.0),
465 (41.3, 702.7),
466 ] {
467 let r = describe(at(t_c, p_hpa, 0.5));
468 let t_c = (t_c + KELVIN_OFFSET_C) - KELVIN_OFFSET_C;
471 let p_hpa = (p_hpa * PASCALS_PER_HECTOPASCAL) / PASCALS_PER_HECTOPASCAL;
472 let t_f = t_c * 9.0 / 5.0 + 32.0;
473 let pressure_alt = 145_366.45 * (1.0 - (p_hpa / 1013.25_f64).powf(0.190_284));
474 let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
475 let expected = pressure_alt + (120.0 * 5.0 / 9.0) * (t_f - isa_temp_f);
476 assert_eq!(
477 r.density_altitude.faa_rule.ft.to_bits(),
478 expected.to_bits(),
479 "{t_c} C {p_hpa} hPa"
480 );
481 }
482 }
483
484 #[test]
486 fn air_density_is_the_solvers_density() {
487 let r = describe(at(15.0, 1013.25, 0.5));
488 let (solver_density, _) =
489 crate::atmosphere::calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
490 assert_eq!(r.air_density_kg_m3.to_bits(), solver_density.to_bits());
491 assert!(
492 (r.air_density_kg_m3 - 1.2216).abs() < 0.0002,
493 "{}",
494 r.air_density_kg_m3
495 );
496 }
497
498 #[test]
501 fn humidity_in_percent_is_refused_at_its_path() {
502 let error = refusal(at(15.0, 1013.25, 50.0));
503 assert_eq!(error.path(), Some("$.atmosphere.relative_humidity"));
504 }
505
506 #[test]
509 fn omitted_fields_resolve_like_a_solve_and_are_announced() {
510 let r = describe(AtmosphereV1::default());
511 assert_eq!(r.atmosphere.altitude_m, 0.0);
512 assert!((r.atmosphere.temperature_k - 288.15).abs() < 1e-9);
513 assert!((r.atmosphere.pressure_pa - 101_325.0).abs() < 1e-6);
514 assert_eq!(r.atmosphere.relative_humidity, 0.5);
515 let paths: Vec<_> = r
516 .assumptions
517 .iter()
518 .filter_map(|a| a.path.as_deref())
519 .collect();
520 for expected in [
521 "$.atmosphere.altitude_m",
522 "$.atmosphere.temperature_k",
523 "$.atmosphere.pressure_pa",
524 "$.atmosphere.relative_humidity",
525 ] {
526 assert!(
527 paths.contains(&expected),
528 "missing assumption at {expected}: {paths:?}"
529 );
530 }
531 }
532
533 #[test]
536 fn a_qnh_is_reduced_to_station_pressure_first() {
537 let altitude_m = 1500.0;
538 let mut qnh = at(15.0, 1013.25, 0.0);
539 qnh.altitude_m = Some(altitude_m);
540 qnh.pressure_reference = Some(PressureReferenceV1::Qnh);
541 let r = describe(qnh);
542 assert!(
543 r.atmosphere.pressure_pa < 90_000.0,
544 "QNH 1013.25 at 1500 m should reduce to ~845 hPa station, got {} Pa",
545 r.atmosphere.pressure_pa
546 );
547 assert!(
548 (r.pressure_altitude.m - altitude_m).abs() < 30.0,
549 "pressure altitude of a standard QNH should be ~the station altitude: {}",
550 r.pressure_altitude.m
551 );
552 assert!(r
553 .assumptions
554 .iter()
555 .any(|a| a.path.as_deref() == Some("$.atmosphere.pressure_pa")));
556 }
557
558 #[test]
559 fn meters_and_feet_agree() {
560 let r = describe(at(30.0, 950.0, 0.5));
561 for altitude in [
562 r.pressure_altitude,
563 r.density_altitude.faa_rule,
564 r.density_altitude.density_matched,
565 ] {
566 assert!(
567 (altitude.m - altitude.ft * METERS_PER_FOOT).abs() < 1e-9,
568 "{altitude:?}"
569 );
570 }
571 }
572
573 #[test]
577 fn a_temperature_no_air_can_have_is_refused_at_temperature_k() {
578 for kelvin in [15.0, 5.0, 1e-300, 173.0, 373.2, 400.0, 1e300] {
579 let mut atmosphere = at(15.0, 1013.25, 0.5);
580 atmosphere.temperature_k = Some(kelvin);
581 let error = refusal(atmosphere);
582 assert_eq!(
583 error.path(),
584 Some("$.atmosphere.temperature_k"),
585 "{kelvin} K: {error:?}"
586 );
587 }
588 }
589
590 #[test]
594 fn a_pressure_outside_the_troposphere_is_refused_at_pressure_pa() {
595 for pascals in [1013.25, 101.325, 29.92, 1e-300, 250_000.0, 1e300] {
596 let mut atmosphere = at(25.0, 1013.25, 0.9);
597 atmosphere.pressure_pa = Some(pascals);
598 let error = refusal(atmosphere);
599 assert_eq!(
600 error.path(),
601 Some("$.atmosphere.pressure_pa"),
602 "{pascals} Pa: {error:?}"
603 );
604 }
605 }
606
607 #[test]
609 fn a_defaulted_pressure_above_the_troposphere_is_refused_at_altitude_m() {
610 let error = refusal(AtmosphereV1 {
611 altitude_m: Some(20_000.0),
612 ..AtmosphereV1::default()
613 });
614 assert_eq!(error.path(), Some("$.atmosphere.altitude_m"));
615 }
616
617 #[test]
621 fn a_qnh_refusal_blames_the_input_that_is_wrong_and_quotes_what_was_sent() {
622 let mut feet_as_meters = at(15.0, 1013.25, 0.5);
623 feet_as_meters.altitude_m = Some(36_000.0);
624 feet_as_meters.pressure_reference = Some(PressureReferenceV1::Qnh);
625 let error = refusal(feet_as_meters);
626 assert_eq!(error.path(), Some("$.atmosphere.altitude_m"), "{error:?}");
627 assert!(
628 error.message.contains("a QNH of 101325 Pa"),
629 "{}",
630 error.message
631 );
632
633 let mut hpa_as_pa = at(15.0, 1013.25, 0.5);
634 hpa_as_pa.altitude_m = Some(1500.0);
635 hpa_as_pa.pressure_pa = Some(1013.25);
636 hpa_as_pa.pressure_reference = Some(PressureReferenceV1::Qnh);
637 let error = refusal(hpa_as_pa);
638 assert_eq!(error.path(), Some("$.atmosphere.pressure_pa"), "{error:?}");
639 assert!(
640 error.message.contains("a QNH of 1013.25 Pa"),
641 "{}",
642 error.message
643 );
644 }
645
646 #[test]
648 fn absurd_inputs_are_echoed_compactly() {
649 let mut atmosphere = at(15.0, 1013.25, 0.5);
650 atmosphere.temperature_k = Some(1e300);
651 let error = refusal(atmosphere);
652 assert!(error.message.starts_with("1e300 K"), "{}", error.message);
653 assert!(error.message.len() < 300, "{}", error.message);
654 }
655
656 #[test]
660 fn humidity_beyond_the_total_pressure_is_refused_at_relative_humidity() {
661 let error = refusal(at(100.0, 1013.25, 1.0));
662 assert_eq!(
663 error.path(),
664 Some("$.atmosphere.relative_humidity"),
665 "{error:?}"
666 );
667 describe(at(100.0, 1013.25, 0.9));
669 describe(at(45.0, 1013.25, 1.0));
671 }
672
673 #[test]
675 fn every_accepted_atmosphere_reports_finite_figures() {
676 let mut accepted = 0;
677 for t in (0..=20).map(|i| MIN_TEMPERATURE_K + 10.0 * f64::from(i)) {
678 for p in (0..=16).map(|i| 20_000.0 + 10_000.0 * f64::from(i)) {
679 for rh in [0.0, 0.3, 0.7, 1.0] {
680 let atmosphere = AtmosphereV1 {
681 altitude_m: Some(0.0),
682 temperature_k: Some(t),
683 pressure_pa: Some(p),
684 relative_humidity: Some(rh),
685 ..AtmosphereV1::default()
686 };
687 let Ok(r) = density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere })
688 else {
689 continue;
690 };
691 accepted += 1;
692 assert!(r.air_density_kg_m3.is_finite() && r.air_density_kg_m3 > 0.0);
693 for altitude in [
694 r.pressure_altitude,
695 r.density_altitude.faa_rule,
696 r.density_altitude.density_matched,
697 ] {
698 assert!(altitude.m.is_finite() && altitude.ft.is_finite(), "{r:?}");
699 assert!(within_troposphere(altitude.m), "{r:?}");
700 }
701 }
702 }
703 }
704 assert!(accepted > 300, "only {accepted} accepted");
705 }
706
707 #[test]
710 fn malformed_requests_get_the_solves_codes_and_paths() {
711 let cases = [
712 (
713 json!({"atmosphere": {"temperature_c": 30.0}}),
714 SolveErrorCodeV1::UnknownField,
715 "$.atmosphere.temperature_c",
716 ),
717 (
718 json!({"atmosphere": {"temperature_k": null}}),
719 SolveErrorCodeV1::InvalidValue,
720 "$.atmosphere.temperature_k",
721 ),
722 (
723 json!({"atmosphere": {"pressure_pa": "101325"}}),
724 SolveErrorCodeV1::InvalidValue,
725 "$.atmosphere.pressure_pa",
726 ),
727 (
728 json!({"atmosphere": {"pressure_reference": "altimeter"}}),
729 SolveErrorCodeV1::InvalidValue,
730 "$.atmosphere.pressure_reference",
731 ),
732 (json!({}), SolveErrorCodeV1::MissingField, "$.atmosphere"),
733 (
734 json!({"atmosphere": {}, "units": "si"}),
735 SolveErrorCodeV1::UnknownField,
736 "$.units",
737 ),
738 (json!([{}]), SolveErrorCodeV1::InvalidValue, "$"),
739 ];
740 for (request, code, path) in cases {
741 let error = decode_density_altitude_request_v1(&request)
742 .expect_err("malformed")
743 .error;
744 assert_eq!(error.code, code, "{request}: {error:?}");
745 assert_eq!(error.path(), Some(path), "{request}: {error:?}");
746 }
747 assert!(decode_density_altitude_request_v1(&json!({"atmosphere": {}})).is_ok());
748 }
749}