1#![allow(clippy::manual_strip, clippy::needless_range_loop)]
2#![allow(dead_code)]
21
22use std::f64::consts::E;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Grib2Section {
31 Indicator,
33 Identification,
35 LocalUse,
37 GridDefinition,
39 ProductDefinition,
41 DataRepresentation,
43 BitMap,
45 Data,
47 End,
49}
50
51#[derive(Debug, Clone)]
53pub struct Grib2Header {
54 pub edition: u8,
56 pub discipline: u8,
58 pub total_length: u64,
60 pub center_id: u16,
62 pub subcenter_id: u16,
64 pub reference_time: [u16; 6],
66 pub production_status: u8,
68 pub data_type: u8,
70}
71
72pub fn parse_grib2_indicator(data: &[u8]) -> Option<(u8, u8, u64)> {
83 if data.len() < 16 {
84 return None;
85 }
86 if data[0] != b'G' || data[1] != b'R' || data[2] != b'I' || data[3] != b'B' {
88 return None;
89 }
90 let discipline = data[6];
91 let edition = data[7];
92 if edition != 2 {
93 return None;
94 }
95 let total_length = u64::from_be_bytes([
96 data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
97 ]);
98 Some((discipline, edition, total_length))
99}
100
101pub fn parse_grib2_header(data: &[u8]) -> Option<Grib2Header> {
106 let (discipline, edition, total_length) = parse_grib2_indicator(data)?;
107
108 if data.len() < 16 + 21 {
109 return None;
110 }
111
112 let sec1 = &data[16..];
113 let _sec1_len = u32::from_be_bytes([sec1[0], sec1[1], sec1[2], sec1[3]]);
115 if sec1[4] != 1 {
117 return None;
118 }
119
120 let center_id = u16::from_be_bytes([sec1[5], sec1[6]]);
121 let subcenter_id = u16::from_be_bytes([sec1[7], sec1[8]]);
122 let year = u16::from_be_bytes([sec1[12], sec1[13]]);
125 let month = sec1[14] as u16;
126 let day = sec1[15] as u16;
127 let hour = sec1[16] as u16;
128 let minute = sec1[17] as u16;
129 let second = sec1[18] as u16;
130 let production_status = sec1[19];
131 let data_type = sec1[20];
132
133 Some(Grib2Header {
134 edition,
135 discipline,
136 total_length,
137 center_id,
138 subcenter_id,
139 reference_time: [year, month, day, hour, minute, second],
140 production_status,
141 data_type,
142 })
143}
144
145pub fn has_grib2_end_marker(data: &[u8]) -> bool {
147 if data.len() < 4 {
148 return false;
149 }
150 let end = &data[data.len() - 4..];
151 end == b"7777"
152}
153
154pub fn grib2_discipline_name(discipline: u8) -> &'static str {
156 match discipline {
157 0 => "Meteorological",
158 1 => "Hydrological",
159 2 => "Land Surface",
160 3 => "Satellite Remote Sensing",
161 4 => "Space Weather",
162 10 => "Oceanographic",
163 _ => "Unknown",
164 }
165}
166
167#[derive(Debug, Clone)]
173pub struct NetcdfAtmVariable {
174 pub name: String,
176 pub units: String,
178 pub dimensions: Vec<String>,
180 pub data: Vec<f64>,
182 pub shape: Vec<usize>,
184 pub fill_value: f64,
186 pub scale_factor: f64,
188 pub add_offset: f64,
190}
191
192impl NetcdfAtmVariable {
193 pub fn new(name: &str, units: &str, dimensions: Vec<String>, shape: Vec<usize>) -> Self {
195 let total_size: usize = shape.iter().product();
196 Self {
197 name: name.to_string(),
198 units: units.to_string(),
199 dimensions,
200 data: vec![0.0; total_size],
201 shape,
202 fill_value: -9999.0,
203 scale_factor: 1.0,
204 add_offset: 0.0,
205 }
206 }
207
208 pub fn unpack(&self, raw: f64) -> f64 {
210 raw * self.scale_factor + self.add_offset
211 }
212
213 pub fn pack(&self, value: f64) -> f64 {
215 (value - self.add_offset) / self.scale_factor
216 }
217
218 pub fn get_value(&self, index: usize) -> Option<f64> {
220 self.data.get(index).copied().map(|v| {
221 if (v - self.fill_value).abs() < 1e-10 {
222 f64::NAN
223 } else {
224 self.unpack(v)
225 }
226 })
227 }
228
229 pub fn set_value(&mut self, index: usize, value: f64) {
231 if index < self.data.len() {
232 self.data[index] = self.pack(value);
233 }
234 }
235
236 pub fn total_size(&self) -> usize {
238 self.shape.iter().product()
239 }
240
241 pub fn flat_index(&self, indices: &[usize]) -> Option<usize> {
243 if indices.len() != self.shape.len() {
244 return None;
245 }
246 let mut idx = 0;
247 let mut stride = 1;
248 for i in (0..indices.len()).rev() {
249 if indices[i] >= self.shape[i] {
250 return None;
251 }
252 idx += indices[i] * stride;
253 stride *= self.shape[i];
254 }
255 Some(idx)
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct NetcdfAtmDataset {
262 pub global_attrs: Vec<(String, String)>,
264 pub variables: Vec<NetcdfAtmVariable>,
266}
267
268impl NetcdfAtmDataset {
269 pub fn new() -> Self {
271 Self {
272 global_attrs: Vec::new(),
273 variables: Vec::new(),
274 }
275 }
276
277 pub fn add_attribute(&mut self, key: &str, value: &str) {
279 self.global_attrs.push((key.to_string(), value.to_string()));
280 }
281
282 pub fn add_variable(&mut self, var: NetcdfAtmVariable) {
284 self.variables.push(var);
285 }
286
287 pub fn find_variable(&self, name: &str) -> Option<&NetcdfAtmVariable> {
289 self.variables.iter().find(|v| v.name == name)
290 }
291
292 pub fn find_variable_mut(&mut self, name: &str) -> Option<&mut NetcdfAtmVariable> {
294 self.variables.iter_mut().find(|v| v.name == name)
295 }
296
297 pub fn to_text(&self) -> String {
307 let mut out = String::new();
308 out.push_str("# NETCDF_ATM_SIMPLE v1\n");
309
310 for (k, v) in &self.global_attrs {
311 out.push_str(&format!("@attr {}={}\n", k, v));
312 }
313
314 for var in &self.variables {
315 let dims = var.dimensions.join(",");
316 let shape: Vec<String> = var.shape.iter().map(|s| s.to_string()).collect();
317 let shape_str = shape.join(",");
318 out.push_str(&format!(
319 "!var {} {} {} {}\n",
320 var.name, var.units, dims, shape_str
321 ));
322
323 let vals: Vec<String> = var.data.iter().map(|v| format!("{:.6}", v)).collect();
324 for chunk in vals.chunks(10) {
326 out.push_str(&chunk.join(" "));
327 out.push('\n');
328 }
329 }
330 out
331 }
332
333 pub fn from_text(text: &str) -> Option<Self> {
335 let mut dataset = Self::new();
336 let mut lines = text.lines().peekable();
337
338 let header = lines.next()?;
340 if !header.starts_with("# NETCDF_ATM_SIMPLE") {
341 return None;
342 }
343
344 while let Some(line) = lines.next() {
345 let line = line.trim();
346 if line.is_empty() || line.starts_with('#') {
347 continue;
348 }
349
350 if let Some(attr) = line.strip_prefix("@attr ") {
351 if let Some((k, v)) = attr.split_once('=') {
352 dataset.add_attribute(k, v);
353 }
354 } else if let Some(var_def) = line.strip_prefix("!var ") {
355 let parts: Vec<&str> = var_def.split_whitespace().collect();
356 if parts.len() < 4 {
357 continue;
358 }
359 let name = parts[0];
360 let units = parts[1];
361 let dims: Vec<String> = parts[2].split(',').map(|s| s.to_string()).collect();
362 let shape: Vec<usize> =
363 parts[3].split(',').filter_map(|s| s.parse().ok()).collect();
364
365 let total: usize = shape.iter().product();
366 let mut var = NetcdfAtmVariable::new(name, units, dims, shape);
367
368 let mut values = Vec::with_capacity(total);
370 while values.len() < total {
371 if let Some(data_line) = lines.peek() {
372 let data_line = data_line.trim();
373 if data_line.starts_with('@')
374 || data_line.starts_with('!')
375 || data_line.starts_with('#')
376 {
377 break;
378 }
379 let parsed: Vec<f64> = data_line
380 .split_whitespace()
381 .filter_map(|s| s.parse().ok())
382 .collect();
383 values.extend(parsed);
384 lines.next();
385 } else {
386 break;
387 }
388 }
389 var.data = values;
390 dataset.add_variable(var);
391 }
392 }
393 Some(dataset)
394 }
395}
396
397impl Default for NetcdfAtmDataset {
398 fn default() -> Self {
399 Self::new()
400 }
401}
402
403#[derive(Debug, Clone)]
409pub struct MetarReport {
410 pub station: String,
412 pub day: u8,
414 pub hour: u8,
416 pub minute: u8,
418 pub wind_direction: Option<u16>,
420 pub wind_speed: u16,
422 pub wind_gust: Option<u16>,
424 pub visibility_sm: f64,
426 pub temperature: Option<f64>,
428 pub dew_point: Option<f64>,
430 pub altimeter: Option<f64>,
432 pub raw: String,
434 pub clouds: Vec<CloudLayer>,
436 pub weather: Vec<String>,
438 pub auto: bool,
440}
441
442#[derive(Debug, Clone)]
444pub struct CloudLayer {
445 pub coverage: String,
447 pub height_ft: Option<u32>,
449 pub cloud_type: String,
451}
452
453impl MetarReport {
454 pub fn new(station: &str) -> Self {
456 Self {
457 station: station.to_string(),
458 day: 0,
459 hour: 0,
460 minute: 0,
461 wind_direction: None,
462 wind_speed: 0,
463 wind_gust: None,
464 visibility_sm: 10.0,
465 temperature: None,
466 dew_point: None,
467 altimeter: None,
468 raw: String::new(),
469 clouds: Vec::new(),
470 weather: Vec::new(),
471 auto: false,
472 }
473 }
474}
475
476pub fn parse_metar(raw: &str) -> Option<MetarReport> {
491 let raw_trimmed = raw.trim();
492 let text = if raw_trimmed.starts_with("METAR ") || raw_trimmed.starts_with("SPECI ") {
494 &raw_trimmed[6..]
495 } else {
496 raw_trimmed
497 };
498
499 let tokens: Vec<&str> = text.split_whitespace().collect();
500 if tokens.len() < 3 {
501 return None;
502 }
503
504 let mut report = MetarReport::new(tokens[0]);
505 report.raw = raw_trimmed.to_string();
506
507 let mut idx = 1;
508
509 if idx < tokens.len() && tokens[idx].len() == 7 && tokens[idx].ends_with('Z') {
511 let dtg = tokens[idx];
512 report.day = dtg[0..2].parse().unwrap_or(0);
513 report.hour = dtg[2..4].parse().unwrap_or(0);
514 report.minute = dtg[4..6].parse().unwrap_or(0);
515 idx += 1;
516 }
517
518 if idx < tokens.len() && tokens[idx] == "AUTO" {
520 report.auto = true;
521 idx += 1;
522 }
523
524 while idx < tokens.len() {
526 let token = tokens[idx];
527 idx += 1;
528
529 if token.ends_with("KT") && token.len() >= 7 {
531 parse_metar_wind(token, &mut report);
532 continue;
533 }
534
535 if token.ends_with("SM") {
537 if let Some(vis_str) = token.strip_suffix("SM") {
538 if let Ok(vis) = vis_str.parse::<f64>() {
539 report.visibility_sm = vis;
540 } else if vis_str.contains('/') {
541 let parts: Vec<&str> = vis_str.split('/').collect();
543 if parts.len() == 2 {
544 let num: f64 = parts[0].parse().unwrap_or(0.0);
545 let den: f64 = parts[1].parse().unwrap_or(1.0);
546 if den.abs() > 1e-10 {
547 report.visibility_sm = num / den;
548 }
549 }
550 }
551 }
552 continue;
553 }
554
555 if token.starts_with("FEW")
557 || token.starts_with("SCT")
558 || token.starts_with("BKN")
559 || token.starts_with("OVC")
560 {
561 let coverage = &token[..3];
562 let height_str = &token[3..];
563 let mut cloud_type = String::new();
564
565 let height_part = if height_str.ends_with("CB") {
567 cloud_type = "CB".to_string();
568 &height_str[..height_str.len() - 2]
569 } else if height_str.ends_with("TCU") {
570 cloud_type = "TCU".to_string();
571 &height_str[..height_str.len() - 3]
572 } else {
573 height_str
574 };
575
576 let height_ft = height_part.parse::<u32>().ok().map(|h| h * 100);
577 report.clouds.push(CloudLayer {
578 coverage: coverage.to_string(),
579 height_ft,
580 cloud_type,
581 });
582 continue;
583 }
584
585 if token == "CLR" || token == "SKC" || token == "NCD" || token == "NSC" {
586 report.clouds.push(CloudLayer {
587 coverage: token.to_string(),
588 height_ft: None,
589 cloud_type: String::new(),
590 });
591 continue;
592 }
593
594 if token.contains('/') && !token.ends_with("SM") && !token.ends_with("KT") {
596 let parts: Vec<&str> = token.split('/').collect();
597 if parts.len() == 2 {
598 if let Some(t) = parse_metar_temp(parts[0]) {
599 report.temperature = Some(t);
600 }
601 if let Some(d) = parse_metar_temp(parts[1]) {
602 report.dew_point = Some(d);
603 }
604 }
605 continue;
606 }
607
608 if token.starts_with('A') && token.len() == 5 {
610 if let Ok(alt) = token[1..].parse::<f64>() {
611 report.altimeter = Some(alt / 100.0);
612 }
613 continue;
614 }
615
616 let weather_codes = [
618 "RA", "SN", "DZ", "FG", "BR", "HZ", "TS", "SH", "GR", "GS", "FZ", "PL", "SG", "IC",
619 "PE", "UP", "FU", "VA", "DU", "SA", "SS", "DS", "SQ", "FC", "PO",
620 ];
621 for code in &weather_codes {
622 if token.contains(code) {
623 report.weather.push(token.to_string());
624 break;
625 }
626 }
627 }
628
629 Some(report)
630}
631
632fn parse_metar_wind(token: &str, report: &mut MetarReport) {
634 let wind_str = token.strip_suffix("KT").unwrap_or(token);
635
636 if wind_str.contains('G') {
637 let parts: Vec<&str> = wind_str.split('G').collect();
639 if parts.len() == 2 && parts[0].len() >= 5 {
640 let dir_str = &parts[0][..3];
641 let spd_str = &parts[0][3..];
642 if dir_str == "VRB" {
643 report.wind_direction = None;
644 } else {
645 report.wind_direction = dir_str.parse().ok();
646 }
647 report.wind_speed = spd_str.parse().unwrap_or(0);
648 report.wind_gust = parts[1].parse().ok();
649 }
650 } else if wind_str.len() >= 5 {
651 let dir_str = &wind_str[..3];
652 let spd_str = &wind_str[3..];
653 if dir_str == "VRB" {
654 report.wind_direction = None;
655 } else {
656 report.wind_direction = dir_str.parse().ok();
657 }
658 report.wind_speed = spd_str.parse().unwrap_or(0);
659 }
660}
661
662fn parse_metar_temp(s: &str) -> Option<f64> {
664 if s.starts_with('M') {
665 s[1..].parse::<f64>().ok().map(|v| -v)
666 } else {
667 s.parse::<f64>().ok()
668 }
669}
670
671#[derive(Debug, Clone, Copy)]
677pub struct SoundingLevel {
678 pub pressure: f64,
680 pub height: f64,
682 pub temperature: f64,
684 pub dew_point: f64,
686 pub wind_direction: f64,
688 pub wind_speed: f64,
690}
691
692#[derive(Debug, Clone)]
694pub struct SoundingProfile {
695 pub station: String,
697 pub observation_time: String,
699 pub latitude: f64,
701 pub longitude: f64,
703 pub elevation: f64,
705 pub levels: Vec<SoundingLevel>,
707}
708
709impl SoundingProfile {
710 pub fn new(station: &str) -> Self {
712 Self {
713 station: station.to_string(),
714 observation_time: String::new(),
715 latitude: 0.0,
716 longitude: 0.0,
717 elevation: 0.0,
718 levels: Vec::new(),
719 }
720 }
721
722 pub fn add_level(&mut self, level: SoundingLevel) {
724 self.levels.push(level);
725 }
726
727 pub fn sort_by_pressure(&mut self) {
729 self.levels.sort_by(|a, b| {
730 b.pressure
731 .partial_cmp(&a.pressure)
732 .unwrap_or(std::cmp::Ordering::Equal)
733 });
734 }
735
736 pub fn sort_by_height(&mut self) {
738 self.levels.sort_by(|a, b| {
739 a.height
740 .partial_cmp(&b.height)
741 .unwrap_or(std::cmp::Ordering::Equal)
742 });
743 }
744
745 pub fn interpolate_temperature(&self, pressure: f64) -> Option<f64> {
747 if self.levels.len() < 2 {
748 return None;
749 }
750 interpolate_sounding_value(&self.levels, pressure, |l| l.temperature)
751 }
752
753 pub fn interpolate_wind_speed(&self, pressure: f64) -> Option<f64> {
755 if self.levels.len() < 2 {
756 return None;
757 }
758 interpolate_sounding_value(&self.levels, pressure, |l| l.wind_speed)
759 }
760
761 pub fn interpolate_height(&self, pressure: f64) -> Option<f64> {
763 if self.levels.len() < 2 {
764 return None;
765 }
766 interpolate_sounding_value(&self.levels, pressure, |l| l.height)
767 }
768
769 pub fn compute_lcl_pressure(&self) -> Option<f64> {
773 if self.levels.is_empty() {
774 return None;
775 }
776 let surface = &self.levels[0];
777 let t = surface.temperature;
778 let td = surface.dew_point;
779 let p = surface.pressure;
780
781 let t_lcl = 1.0 / (1.0 / (td + 273.15 - 56.0) + (t - td).ln() / 800.0) + 56.0;
783 let p_lcl = p * ((t_lcl) / (t + 273.15)).powf(3.5);
785 Some(p_lcl)
786 }
787
788 pub fn precipitable_water(&self) -> f64 {
792 if self.levels.len() < 2 {
793 return 0.0;
794 }
795 let g = 9.80665;
796 let mut pw = 0.0;
797 for i in 0..self.levels.len() - 1 {
798 let dp = (self.levels[i].pressure - self.levels[i + 1].pressure).abs() * 100.0; let w1 = mixing_ratio(self.levels[i].dew_point, self.levels[i].pressure);
800 let w2 = mixing_ratio(self.levels[i + 1].dew_point, self.levels[i + 1].pressure);
801 let w_avg = (w1 + w2) / 2.0;
802 pw += w_avg * dp / g;
803 }
804 pw
806 }
807
808 pub fn to_text(&self) -> String {
810 let mut out = String::new();
811 out.push_str(&format!("# SOUNDING {}\n", self.station));
812 out.push_str(&format!("# TIME {}\n", self.observation_time));
813 out.push_str(&format!(
814 "# LAT {:.4} LON {:.4} ELEV {:.1}\n",
815 self.latitude, self.longitude, self.elevation
816 ));
817 out.push_str("# PRES(hPa) HGT(m) TEMP(C) DWPT(C) WDIR(deg) WSPD(kt)\n");
818 for level in &self.levels {
819 out.push_str(&format!(
820 "{:.1} {:.1} {:.1} {:.1} {:.0} {:.0}\n",
821 level.pressure,
822 level.height,
823 level.temperature,
824 level.dew_point,
825 level.wind_direction,
826 level.wind_speed
827 ));
828 }
829 out
830 }
831
832 pub fn from_text(text: &str) -> Option<Self> {
834 let mut profile = SoundingProfile::new("");
835 for line in text.lines() {
836 let line = line.trim();
837 if line.starts_with("# SOUNDING ") {
838 profile.station = line[11..].trim().to_string();
839 } else if line.starts_with("# TIME ") {
840 profile.observation_time = line[7..].trim().to_string();
841 } else if line.starts_with("# LAT ") {
842 let parts: Vec<&str> = line.split_whitespace().collect();
843 if parts.len() >= 6 {
844 profile.latitude = parts[2].parse().unwrap_or(0.0);
845 profile.longitude = parts[4].parse().unwrap_or(0.0);
846 profile.elevation = parts[6].parse().unwrap_or(0.0);
847 }
848 } else if line.starts_with('#') || line.is_empty() {
849 continue;
850 } else {
851 let vals: Vec<f64> = line
852 .split_whitespace()
853 .filter_map(|s| s.parse().ok())
854 .collect();
855 if vals.len() >= 6 {
856 profile.add_level(SoundingLevel {
857 pressure: vals[0],
858 height: vals[1],
859 temperature: vals[2],
860 dew_point: vals[3],
861 wind_direction: vals[4],
862 wind_speed: vals[5],
863 });
864 }
865 }
866 }
867 if profile.station.is_empty() {
868 return None;
869 }
870 Some(profile)
871 }
872}
873
874fn interpolate_sounding_value(
876 levels: &[SoundingLevel],
877 pressure: f64,
878 extract: fn(&SoundingLevel) -> f64,
879) -> Option<f64> {
880 for i in 0..levels.len() - 1 {
882 let p1 = levels[i].pressure;
883 let p2 = levels[i + 1].pressure;
884 if (pressure <= p1 && pressure >= p2) || (pressure >= p1 && pressure <= p2) {
885 let log_p = pressure.ln();
886 let log_p1 = p1.ln();
887 let log_p2 = p2.ln();
888 let denom = log_p2 - log_p1;
889 if denom.abs() < 1e-15 {
890 return Some(extract(&levels[i]));
891 }
892 let t = (log_p - log_p1) / denom;
893 let v1 = extract(&levels[i]);
894 let v2 = extract(&levels[i + 1]);
895 return Some(v1 + t * (v2 - v1));
896 }
897 }
898 None
899}
900
901pub fn wind_power_law(v_ref: f64, z_ref: f64, z: f64, alpha: f64) -> f64 {
914 if z_ref < 1e-10 || z < 0.0 {
915 return 0.0;
916 }
917 v_ref * (z / z_ref).powf(alpha)
918}
919
920pub fn wind_log_law(u_star: f64, z: f64, z0: f64, von_karman: f64) -> f64 {
929 if z <= z0 || z0 < 1e-15 || von_karman.abs() < 1e-15 {
930 return 0.0;
931 }
932 (u_star / von_karman) * (z / z0).ln()
933}
934
935pub fn friction_velocity(v_ref: f64, z_ref: f64, z0: f64, von_karman: f64) -> f64 {
939 if z_ref <= z0 || z0 < 1e-15 {
940 return 0.0;
941 }
942 v_ref * von_karman / (z_ref / z0).ln()
943}
944
945pub fn ekman_wind_direction(
955 z: f64,
956 boundary_layer_height: f64,
957 surface_direction: f64,
958 geostrophic_direction: f64,
959) -> f64 {
960 if boundary_layer_height < 1e-10 {
961 return geostrophic_direction;
962 }
963 let ratio = (z / boundary_layer_height).min(1.0);
964 let delta = geostrophic_direction - surface_direction;
965 surface_direction + delta * ratio
966}
967
968pub fn temperature_lapse(t_surface: f64, lapse_rate: f64, height: f64) -> f64 {
980 t_surface - lapse_rate * height
981}
982
983pub fn potential_temperature(temperature: f64, pressure: f64, p0: f64) -> f64 {
991 if pressure < 1e-10 {
992 return temperature;
993 }
994 temperature * (p0 / pressure).powf(0.286)
995}
996
997pub fn virtual_temperature(temperature: f64, mixing_ratio_val: f64) -> f64 {
1004 temperature * (1.0 + 0.61 * mixing_ratio_val)
1005}
1006
1007pub fn equivalent_potential_temperature(temperature: f64, dew_point: f64, pressure: f64) -> f64 {
1013 let w = mixing_ratio(dew_point - 273.15, pressure); let theta = potential_temperature(temperature, pressure, 1000.0);
1015 let t_lcl = 1.0 / (1.0 / (dew_point - 56.0) + (temperature / dew_point).ln() / 800.0) + 56.0;
1016 theta
1017 * (3.376 / t_lcl - 0.00254)
1018 .exp()
1019 .powf(w * 1000.0 * (1.0 + 0.81e-3 * w * 1000.0))
1020}
1021
1022pub fn pressure_altitude(pressure: f64, sea_level_pressure: f64) -> f64 {
1035 if pressure < 1e-10 || sea_level_pressure < 1e-10 {
1036 return 0.0;
1037 }
1038 let t0 = 288.15; let lapse = 0.0065; let g = 9.80665;
1042 let r = 287.053;
1043
1044 (t0 / lapse) * (1.0 - (pressure / sea_level_pressure).powf(r * lapse / g))
1045}
1046
1047pub fn density_altitude(pressure_alt: f64, oat: f64) -> f64 {
1054 let isa_temp = 15.0 - 0.0065 * pressure_alt; let temp_dev = oat - isa_temp;
1056 pressure_alt + 120.0 * temp_dev
1057}
1058
1059pub fn pressure_to_flight_level(pressure: f64) -> f64 {
1065 let alt_ft = pressure_altitude(pressure, 1013.25) * 3.28084; (alt_ft / 100.0).round()
1067}
1068
1069pub fn flight_level_to_pressure(flight_level: f64) -> f64 {
1073 let alt_m = flight_level * 100.0 / 3.28084; let t0 = 288.15;
1075 let lapse = 0.0065;
1076 let g = 9.80665;
1077 let r = 287.053;
1078 1013.25 * (1.0 - lapse * alt_m / t0).powf(g / (r * lapse))
1079}
1080
1081#[derive(Debug, Clone, Copy)]
1087pub struct IsaProperties {
1088 pub temperature: f64,
1090 pub pressure: f64,
1092 pub density: f64,
1094 pub speed_of_sound: f64,
1096 pub dynamic_viscosity: f64,
1098}
1099
1100pub fn isa_properties(altitude: f64) -> IsaProperties {
1107 let layers: [(f64, f64, f64, f64); 7] = [
1117 (0.0, 288.15, -0.0065, 101325.0),
1118 (11000.0, 216.65, 0.0, 22632.1),
1119 (20000.0, 216.65, 0.001, 5474.89),
1120 (32000.0, 228.65, 0.0028, 868.019),
1121 (47000.0, 270.65, 0.0, 110.906),
1122 (51000.0, 270.65, -0.0028, 66.9389),
1123 (71000.0, 214.65, -0.002, 3.95642),
1124 ];
1125
1126 let r = 287.053; let g = 9.80665;
1128 let gamma = 1.4; let alt = altitude.clamp(-2000.0, 86000.0);
1131
1132 let mut layer_idx = 0;
1134 for i in 1..layers.len() {
1135 if alt >= layers[i].0 {
1136 layer_idx = i;
1137 }
1138 }
1139
1140 let (h_base, t_base, lapse, p_base) = layers[layer_idx];
1141 let dh = alt - h_base;
1142
1143 let temperature;
1144 let pressure;
1145
1146 if lapse.abs() < 1e-10 {
1147 temperature = t_base;
1149 pressure = p_base * E.powf(-g * dh / (r * t_base));
1150 } else {
1151 temperature = t_base + lapse * dh;
1153 pressure = p_base * (temperature / t_base).powf(-g / (lapse * r));
1154 }
1155
1156 let density = pressure / (r * temperature);
1157 let speed_of_sound = (gamma * r * temperature).sqrt();
1158
1159 let mu_ref = 1.716e-5;
1161 let t_ref = 273.15;
1162 let s = 110.4;
1163 let dynamic_viscosity =
1164 mu_ref * (temperature / t_ref).powf(1.5) * (t_ref + s) / (temperature + s);
1165
1166 IsaProperties {
1167 temperature,
1168 pressure,
1169 density,
1170 speed_of_sound,
1171 dynamic_viscosity,
1172 }
1173}
1174
1175pub fn isa_temperature(altitude: f64) -> f64 {
1177 let alt = altitude.clamp(-2000.0, 86000.0);
1178 if alt <= 11000.0 {
1179 288.15 - 0.0065 * alt
1180 } else if alt <= 20000.0 {
1181 216.65
1182 } else if alt <= 32000.0 {
1183 216.65 + 0.001 * (alt - 20000.0)
1184 } else {
1185 228.65 + 0.0028 * (alt - 32000.0)
1186 }
1187}
1188
1189pub fn isa_pressure(altitude: f64) -> f64 {
1191 isa_properties(altitude).pressure
1192}
1193
1194pub fn isa_density(altitude: f64) -> f64 {
1196 isa_properties(altitude).density
1197}
1198
1199pub fn saturation_vapor_pressure(temperature: f64) -> f64 {
1211 6.112 * (17.67 * temperature / (temperature + 243.5)).exp()
1212}
1213
1214pub fn vapor_pressure_from_dewpoint(dew_point: f64) -> f64 {
1218 saturation_vapor_pressure(dew_point)
1219}
1220
1221pub fn relative_humidity(temperature: f64, dew_point: f64) -> f64 {
1227 let es_td = saturation_vapor_pressure(dew_point);
1228 let es_t = saturation_vapor_pressure(temperature);
1229 if es_t < 1e-10 {
1230 return 0.0;
1231 }
1232 (es_td / es_t * 100.0).clamp(0.0, 100.0)
1233}
1234
1235pub fn dew_point_from_rh(temperature: f64, rh: f64) -> f64 {
1244 let rh_frac = (rh / 100.0).clamp(0.001, 1.0);
1245 let a = 17.67;
1246 let b = 243.5;
1247 let gamma = a * temperature / (b + temperature) + rh_frac.ln();
1248 b * gamma / (a - gamma)
1249}
1250
1251pub fn mixing_ratio(dew_point: f64, pressure: f64) -> f64 {
1260 let e = saturation_vapor_pressure(dew_point);
1261 let denom = pressure - e;
1262 if denom < 1e-10 {
1263 return 0.0;
1264 }
1265 0.622 * e / denom
1266}
1267
1268pub fn specific_humidity(mixing_ratio_val: f64) -> f64 {
1272 mixing_ratio_val / (1.0 + mixing_ratio_val)
1273}
1274
1275pub fn wet_bulb_temperature(temperature: f64, rh: f64) -> f64 {
1282 let t = temperature;
1283 let rh_val = rh.clamp(0.0, 100.0);
1284 t * (0.151977 * (rh_val + 8.313659_f64).sqrt()).atan() + (t + rh_val).atan()
1285 - (rh_val - 1.676331).atan()
1286 + 0.00391838 * rh_val.powf(1.5) * (0.023101 * rh_val).atan()
1287 - 4.686035
1288}
1289
1290pub fn heat_index_fahrenheit(temperature: f64, rh: f64) -> f64 {
1297 let t = temperature;
1298 let r = rh;
1299
1300 if t < 80.0 {
1301 return t;
1302 }
1303
1304 -42.379 + 2.04901523 * t + 10.14333127 * r
1305 - 0.22475541 * t * r
1306 - 6.83783e-3 * t * t
1307 - 5.481717e-2 * r * r
1308 + 1.22874e-3 * t * t * r
1309 + 8.5282e-4 * t * r * r
1310 - 1.99e-6 * t * t * r * r
1311}
1312
1313pub fn celsius_to_fahrenheit(c: f64) -> f64 {
1315 c * 9.0 / 5.0 + 32.0
1316}
1317
1318pub fn fahrenheit_to_celsius(f: f64) -> f64 {
1320 (f - 32.0) * 5.0 / 9.0
1321}
1322
1323pub fn celsius_to_kelvin(c: f64) -> f64 {
1325 c + 273.15
1326}
1327
1328pub fn kelvin_to_celsius(k: f64) -> f64 {
1330 k - 273.15
1331}
1332
1333pub fn wind_chill(temperature: f64, wind_speed_kmh: f64) -> f64 {
1340 if temperature > 10.0 || wind_speed_kmh < 4.8 {
1341 return temperature;
1342 }
1343 let v = wind_speed_kmh;
1344 13.12 + 0.6215 * temperature - 11.37 * v.powf(0.16) + 0.3965 * temperature * v.powf(0.16)
1345}
1346
1347pub fn knots_to_ms(knots: f64) -> f64 {
1349 knots * 0.514444
1350}
1351
1352pub fn ms_to_knots(ms: f64) -> f64 {
1354 ms / 0.514444
1355}
1356
1357pub fn kmh_to_ms(kmh: f64) -> f64 {
1359 kmh / 3.6
1360}
1361
1362pub fn hpa_to_inhg(hpa: f64) -> f64 {
1364 hpa * 0.02953
1365}
1366
1367pub fn inhg_to_hpa(inhg: f64) -> f64 {
1369 inhg / 0.02953
1370}
1371
1372pub fn showalter_index(t850: f64, td850: f64, t500: f64) -> f64 {
1388 let _t_diff = t850 - td850;
1390
1391 let t_lcl = td850;
1393 let p_lcl = 850.0 * ((t_lcl + 273.15) / (t850 + 273.15)).powf(3.5);
1395
1396 let dry_lapse = 9.8 / 1000.0; let _h_lcl = (t850 - t_lcl) / dry_lapse;
1399
1400 let moist_lapse = 6.0 / 1000.0;
1402 let _h_lcl_m = pressure_altitude(p_lcl, 1013.25);
1403 let _h_500 = pressure_altitude(500.0, 1013.25);
1404
1405 let t_parcel_500 = t_lcl
1407 - moist_lapse
1408 * (pressure_altitude(500.0, 1013.25) - pressure_altitude(p_lcl.max(500.0), 1013.25));
1409
1410 t500 - t_parcel_500
1411}
1412
1413pub fn k_index(t850: f64, t500: f64, td850: f64, t700: f64, td700: f64) -> f64 {
1419 (t850 - t500) + td850 - (t700 - td700)
1420}
1421
1422pub fn total_totals(t850: f64, td850: f64, t500: f64) -> f64 {
1426 t850 + td850 - 2.0 * t500
1427}
1428
1429pub fn sweat_index(td850: f64, total_totals_val: f64, wind850: f64, wind500: f64) -> f64 {
1435 let term1 = 12.0 * td850.max(0.0);
1436 let term2 = 20.0 * (total_totals_val - 49.0).max(0.0);
1437 let term3 = 2.0 * wind850;
1438 let term4 = wind500;
1439 term1 + term2 + term3 + term4
1441}
1442
1443pub fn beaufort_number(wind_speed_ms: f64) -> u8 {
1449 let v = wind_speed_ms;
1450 if v < 0.3 {
1451 0
1452 } else if v < 1.6 {
1453 1
1454 } else if v < 3.4 {
1455 2
1456 } else if v < 5.5 {
1457 3
1458 } else if v < 8.0 {
1459 4
1460 } else if v < 10.8 {
1461 5
1462 } else if v < 13.9 {
1463 6
1464 } else if v < 17.2 {
1465 7
1466 } else if v < 20.8 {
1467 8
1468 } else if v < 24.5 {
1469 9
1470 } else if v < 28.5 {
1471 10
1472 } else if v < 32.7 {
1473 11
1474 } else {
1475 12
1476 }
1477}
1478
1479pub fn beaufort_description(number: u8) -> &'static str {
1481 match number {
1482 0 => "Calm",
1483 1 => "Light air",
1484 2 => "Light breeze",
1485 3 => "Gentle breeze",
1486 4 => "Moderate breeze",
1487 5 => "Fresh breeze",
1488 6 => "Strong breeze",
1489 7 => "High wind",
1490 8 => "Gale",
1491 9 => "Strong gale",
1492 10 => "Storm",
1493 11 => "Violent storm",
1494 12 => "Hurricane force",
1495 _ => "Unknown",
1496 }
1497}
1498
1499pub fn estimated_cloud_base_ft(temperature: f64, dew_point: f64) -> f64 {
1512 let spread = (temperature - dew_point).max(0.0);
1513 spread / 2.5 * 1000.0
1514}
1515
1516pub fn estimated_cloud_base_m(temperature: f64, dew_point: f64) -> f64 {
1518 estimated_cloud_base_ft(temperature, dew_point) * 0.3048
1519}
1520
1521#[cfg(test)]
1526mod tests {
1527 use super::*;
1528
1529 #[test]
1530 fn test_grib2_indicator_valid() {
1531 let mut data = vec![0u8; 37];
1532 data[0] = b'G';
1533 data[1] = b'R';
1534 data[2] = b'I';
1535 data[3] = b'B';
1536 data[6] = 0; data[7] = 2; data[15] = 37;
1540
1541 let result = parse_grib2_indicator(&data);
1542 assert!(result.is_some());
1543 let (disc, ed, len) = result.unwrap();
1544 assert_eq!(disc, 0);
1545 assert_eq!(ed, 2);
1546 assert_eq!(len, 37);
1547 }
1548
1549 #[test]
1550 fn test_grib2_indicator_invalid_magic() {
1551 let data = vec![0u8; 16];
1552 assert!(parse_grib2_indicator(&data).is_none());
1553 }
1554
1555 #[test]
1556 fn test_grib2_indicator_too_short() {
1557 let data = vec![b'G', b'R', b'I', b'B'];
1558 assert!(parse_grib2_indicator(&data).is_none());
1559 }
1560
1561 #[test]
1562 fn test_grib2_end_marker() {
1563 let data = b"some_data7777";
1564 assert!(has_grib2_end_marker(data));
1565 let data2 = b"no_end_marker";
1566 assert!(!has_grib2_end_marker(data2));
1567 }
1568
1569 #[test]
1570 fn test_grib2_discipline_name() {
1571 assert_eq!(grib2_discipline_name(0), "Meteorological");
1572 assert_eq!(grib2_discipline_name(10), "Oceanographic");
1573 assert_eq!(grib2_discipline_name(255), "Unknown");
1574 }
1575
1576 #[test]
1577 fn test_metar_parse_basic() {
1578 let metar = "KJFK 301456Z 32015G25KT 10SM FEW050 SCT250 24/16 A3002";
1579 let report = parse_metar(metar).unwrap();
1580 assert_eq!(report.station, "KJFK");
1581 assert_eq!(report.day, 30);
1582 assert_eq!(report.hour, 14);
1583 assert_eq!(report.minute, 56);
1584 assert_eq!(report.wind_direction, Some(320));
1585 assert_eq!(report.wind_speed, 15);
1586 assert_eq!(report.wind_gust, Some(25));
1587 assert!((report.visibility_sm - 10.0).abs() < 0.1);
1588 assert!((report.temperature.unwrap() - 24.0).abs() < 0.1);
1589 assert!((report.dew_point.unwrap() - 16.0).abs() < 0.1);
1590 assert!((report.altimeter.unwrap() - 30.02).abs() < 0.01);
1591 assert_eq!(report.clouds.len(), 2);
1592 }
1593
1594 #[test]
1595 fn test_metar_parse_negative_temp() {
1596 let metar = "CYUL 150000Z 36010KT 15SM OVC020 M05/M12 A2980";
1597 let report = parse_metar(metar).unwrap();
1598 assert!((report.temperature.unwrap() - (-5.0)).abs() < 0.1);
1599 assert!((report.dew_point.unwrap() - (-12.0)).abs() < 0.1);
1600 }
1601
1602 #[test]
1603 fn test_metar_parse_auto() {
1604 let metar = "KORD 301556Z AUTO 28012KT 10SM CLR 22/10 A3010";
1605 let report = parse_metar(metar).unwrap();
1606 assert!(report.auto);
1607 assert_eq!(report.wind_direction, Some(280));
1608 }
1609
1610 #[test]
1611 fn test_netcdf_variable() {
1612 let mut var = NetcdfAtmVariable::new(
1613 "temperature",
1614 "K",
1615 vec!["lat".to_string(), "lon".to_string()],
1616 vec![3, 4],
1617 );
1618 assert_eq!(var.total_size(), 12);
1619
1620 var.set_value(0, 300.0);
1621 let v = var.get_value(0).unwrap();
1622 assert!((v - 300.0).abs() < 1e-6);
1623
1624 let idx = var.flat_index(&[1, 2]).unwrap();
1625 assert_eq!(idx, 6); }
1627
1628 #[test]
1629 fn test_netcdf_dataset_roundtrip() {
1630 let mut ds = NetcdfAtmDataset::new();
1631 ds.add_attribute("source", "test");
1632 let mut var = NetcdfAtmVariable::new("temp", "K", vec!["level".to_string()], vec![3]);
1633 var.data = vec![300.0, 250.0, 200.0];
1634 ds.add_variable(var);
1635
1636 let text = ds.to_text();
1637 let ds2 = NetcdfAtmDataset::from_text(&text).unwrap();
1638 assert_eq!(ds2.variables.len(), 1);
1639 assert_eq!(ds2.variables[0].name, "temp");
1640 }
1641
1642 #[test]
1643 fn test_sounding_profile() {
1644 let mut profile = SoundingProfile::new("72451");
1645 profile.add_level(SoundingLevel {
1646 pressure: 1000.0,
1647 height: 100.0,
1648 temperature: 25.0,
1649 dew_point: 20.0,
1650 wind_direction: 180.0,
1651 wind_speed: 10.0,
1652 });
1653 profile.add_level(SoundingLevel {
1654 pressure: 850.0,
1655 height: 1500.0,
1656 temperature: 15.0,
1657 dew_point: 10.0,
1658 wind_direction: 200.0,
1659 wind_speed: 20.0,
1660 });
1661 profile.add_level(SoundingLevel {
1662 pressure: 500.0,
1663 height: 5500.0,
1664 temperature: -10.0,
1665 dew_point: -20.0,
1666 wind_direction: 270.0,
1667 wind_speed: 40.0,
1668 });
1669
1670 let t = profile.interpolate_temperature(700.0);
1671 assert!(t.is_some());
1672
1673 let pw = profile.precipitable_water();
1674 assert!(pw > 0.0);
1675 }
1676
1677 #[test]
1678 fn test_sounding_text_roundtrip() {
1679 let mut profile = SoundingProfile::new("72451");
1680 profile.observation_time = "2026033012".to_string();
1681 profile.latitude = 40.78;
1682 profile.longitude = -73.97;
1683 profile.elevation = 10.0;
1684 profile.add_level(SoundingLevel {
1685 pressure: 1000.0,
1686 height: 10.0,
1687 temperature: 20.0,
1688 dew_point: 15.0,
1689 wind_direction: 180.0,
1690 wind_speed: 10.0,
1691 });
1692 let text = profile.to_text();
1693 let p2 = SoundingProfile::from_text(&text).unwrap();
1694 assert_eq!(p2.station, "72451");
1695 assert_eq!(p2.levels.len(), 1);
1696 }
1697
1698 #[test]
1699 fn test_wind_power_law() {
1700 let v = wind_power_law(5.0, 10.0, 80.0, 0.14);
1701 assert!(v > 5.0 && v < 10.0, "v={v}");
1703 }
1704
1705 #[test]
1706 fn test_wind_log_law() {
1707 let u_star = 0.5;
1708 let v = wind_log_law(u_star, 10.0, 0.03, 0.41);
1709 assert!(v > 0.0, "v={v}");
1710 }
1711
1712 #[test]
1713 fn test_friction_velocity() {
1714 let u_star = friction_velocity(10.0, 10.0, 0.03, 0.41);
1715 assert!(u_star > 0.0);
1716 let v = wind_log_law(u_star, 10.0, 0.03, 0.41);
1718 assert!((v - 10.0).abs() < 0.1, "v={v}");
1719 }
1720
1721 #[test]
1722 fn test_temperature_lapse() {
1723 let t = temperature_lapse(288.15, 0.0065, 1000.0);
1724 assert!((t - 281.65).abs() < 1e-6);
1725 }
1726
1727 #[test]
1728 fn test_potential_temperature() {
1729 let theta = potential_temperature(273.15, 1000.0, 1000.0);
1730 assert!((theta - 273.15).abs() < 0.01);
1731 }
1732
1733 #[test]
1734 fn test_pressure_altitude_sea_level() {
1735 let alt = pressure_altitude(1013.25, 1013.25);
1736 assert!(alt.abs() < 1.0, "alt={alt}");
1737 }
1738
1739 #[test]
1740 fn test_pressure_altitude_500hpa() {
1741 let alt = pressure_altitude(500.0, 1013.25);
1742 assert!(alt > 5000.0 && alt < 6000.0, "alt={alt}");
1744 }
1745
1746 #[test]
1747 fn test_density_altitude() {
1748 let da = density_altitude(1000.0, 30.0);
1749 assert!((da - 3580.0).abs() < 1.0, "da={da}");
1752 }
1753
1754 #[test]
1755 fn test_isa_sea_level() {
1756 let props = isa_properties(0.0);
1757 assert!((props.temperature - 288.15).abs() < 0.01);
1758 assert!((props.pressure - 101325.0).abs() < 1.0);
1759 assert!((props.density - 1.225).abs() < 0.01);
1760 assert!((props.speed_of_sound - 340.3).abs() < 1.0);
1761 }
1762
1763 #[test]
1764 fn test_isa_tropopause() {
1765 let props = isa_properties(11000.0);
1766 assert!((props.temperature - 216.65).abs() < 0.1);
1767 }
1768
1769 #[test]
1770 fn test_saturation_vapor_pressure() {
1771 let es = saturation_vapor_pressure(20.0);
1772 assert!((es - 23.37).abs() < 0.5, "es={es}");
1774 }
1775
1776 #[test]
1777 fn test_relative_humidity() {
1778 let rh = relative_humidity(20.0, 20.0);
1779 assert!((rh - 100.0).abs() < 0.1);
1780 let rh2 = relative_humidity(20.0, 10.0);
1781 assert!(rh2 < 100.0 && rh2 > 0.0);
1782 }
1783
1784 #[test]
1785 fn test_dew_point_from_rh() {
1786 let td = dew_point_from_rh(20.0, 100.0);
1787 assert!((td - 20.0).abs() < 0.5, "td={td}");
1788
1789 let td2 = dew_point_from_rh(20.0, 50.0);
1790 assert!(td2 < 20.0);
1791 }
1792
1793 #[test]
1794 fn test_mixing_ratio() {
1795 let w = mixing_ratio(20.0, 1013.25);
1796 assert!(w > 0.01 && w < 0.02, "w={w}");
1798 }
1799
1800 #[test]
1801 fn test_specific_humidity() {
1802 let q = specific_humidity(0.01);
1803 assert!((q - 0.0099).abs() < 0.001, "q={q}");
1805 }
1806
1807 #[test]
1808 fn test_celsius_fahrenheit_conversion() {
1809 assert!((celsius_to_fahrenheit(0.0) - 32.0).abs() < 1e-10);
1810 assert!((celsius_to_fahrenheit(100.0) - 212.0).abs() < 1e-10);
1811 assert!((fahrenheit_to_celsius(32.0) - 0.0).abs() < 1e-10);
1812 }
1813
1814 #[test]
1815 fn test_celsius_kelvin_conversion() {
1816 assert!((celsius_to_kelvin(0.0) - 273.15).abs() < 1e-10);
1817 assert!((kelvin_to_celsius(273.15) - 0.0).abs() < 1e-10);
1818 }
1819
1820 #[test]
1821 fn test_wind_chill() {
1822 let wc = wind_chill(-10.0, 30.0);
1823 assert!(wc < -10.0, "wc={wc}");
1824 let wc2 = wind_chill(15.0, 30.0);
1826 assert!((wc2 - 15.0).abs() < 1e-10);
1827 }
1828
1829 #[test]
1830 fn test_knots_conversions() {
1831 let ms = knots_to_ms(1.0);
1832 assert!((ms - 0.514444).abs() < 1e-4);
1833 let kt = ms_to_knots(0.514444);
1834 assert!((kt - 1.0).abs() < 1e-4);
1835 }
1836
1837 #[test]
1838 fn test_pressure_conversions() {
1839 let inhg = hpa_to_inhg(1013.25);
1840 assert!((inhg - 29.92).abs() < 0.1, "inhg={inhg}");
1841 }
1842
1843 #[test]
1844 fn test_beaufort_scale() {
1845 assert_eq!(beaufort_number(0.0), 0);
1846 assert_eq!(beaufort_number(5.0), 3);
1847 assert_eq!(beaufort_number(35.0), 12);
1848 assert_eq!(beaufort_description(0), "Calm");
1849 assert_eq!(beaufort_description(12), "Hurricane force");
1850 }
1851
1852 #[test]
1853 fn test_cloud_base_estimation() {
1854 let cb_ft = estimated_cloud_base_ft(25.0, 15.0);
1855 assert!((cb_ft - 4000.0).abs() < 1.0, "cb_ft={cb_ft}");
1857 }
1858
1859 #[test]
1860 fn test_k_index() {
1861 let ki = k_index(20.0, -10.0, 15.0, 10.0, 5.0);
1862 assert!((ki - 40.0).abs() < 1e-6, "ki={ki}");
1864 }
1865
1866 #[test]
1867 fn test_total_totals() {
1868 let tt = total_totals(20.0, 15.0, -10.0);
1869 assert!((tt - 55.0).abs() < 1e-6);
1871 }
1872
1873 #[test]
1874 fn test_flight_level() {
1875 let fl = pressure_to_flight_level(500.0);
1876 assert!(fl > 150.0 && fl < 200.0, "fl={fl}");
1878 }
1879
1880 #[test]
1881 fn test_ekman_wind_direction() {
1882 let dir = ekman_wind_direction(500.0, 1000.0, 180.0, 270.0);
1883 assert!((dir - 225.0).abs() < 1e-6, "dir={dir}");
1885 }
1886
1887 #[test]
1888 fn test_virtual_temperature() {
1889 let tv = virtual_temperature(300.0, 0.01);
1890 assert!((tv - 301.83).abs() < 0.01, "tv={tv}");
1892 }
1893}