Skip to main content

oxiphysics_io/
weather_io.rs

1#![allow(clippy::manual_strip, clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Weather and atmospheric data I/O.
6//!
7//! This module provides parsers, readers, and models for common weather and
8//! atmospheric data formats and computations:
9//!
10//! - **GRIB2 basic reader**: Parse GRIB2 binary format headers and extract metadata.
11//! - **NetCDF atmospheric**: Read/write simple NetCDF-like atmospheric data.
12//! - **METAR parser**: Parse METAR aviation weather reports.
13//! - **Sounding (TEMP) data**: Parse upper-air radiosonde sounding data.
14//! - **Wind profile**: Vertical wind profile models (power law, log law).
15//! - **Temperature profile**: Vertical temperature profile models.
16//! - **Pressure altitude**: Barometric altitude calculations.
17//! - **ISA atmosphere model**: International Standard Atmosphere.
18//! - **Humidity/dew point conversion**: Various humidity conversions.
19
20#![allow(dead_code)]
21
22use std::f64::consts::E;
23
24// ════════════════════════════════════════════════════════════════════════════
25// GRIB2 Basic Reader
26// ════════════════════════════════════════════════════════════════════════════
27
28/// GRIB2 section identifiers.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Grib2Section {
31    /// Section 0: Indicator section.
32    Indicator,
33    /// Section 1: Identification section.
34    Identification,
35    /// Section 2: Local use section.
36    LocalUse,
37    /// Section 3: Grid definition section.
38    GridDefinition,
39    /// Section 4: Product definition section.
40    ProductDefinition,
41    /// Section 5: Data representation section.
42    DataRepresentation,
43    /// Section 6: Bit-map section.
44    BitMap,
45    /// Section 7: Data section.
46    Data,
47    /// Section 8: End section ("7777").
48    End,
49}
50
51/// Metadata extracted from a GRIB2 file header.
52#[derive(Debug, Clone)]
53pub struct Grib2Header {
54    /// GRIB edition number (should be 2).
55    pub edition: u8,
56    /// Discipline (0=Meteorological, 1=Hydrological, 2=Land surface, etc.).
57    pub discipline: u8,
58    /// Total message length in bytes.
59    pub total_length: u64,
60    /// Originating center ID.
61    pub center_id: u16,
62    /// Originating sub-center ID.
63    pub subcenter_id: u16,
64    /// Reference time (year, month, day, hour, minute, second).
65    pub reference_time: [u16; 6],
66    /// Production status (0=Operational, 1=Test, etc.).
67    pub production_status: u8,
68    /// Type of data (0=Analysis, 1=Forecast, etc.).
69    pub data_type: u8,
70}
71
72/// Parse the indicator section (Section 0) of a GRIB2 message.
73///
74/// The indicator section is always 16 bytes:
75/// - Bytes 0-3: "GRIB"
76/// - Bytes 4-5: Reserved
77/// - Byte 6: Discipline
78/// - Byte 7: Edition (must be 2)
79/// - Bytes 8-15: Total message length
80///
81/// Returns `None` if the data is too short or doesn't start with "GRIB".
82pub fn parse_grib2_indicator(data: &[u8]) -> Option<(u8, u8, u64)> {
83    if data.len() < 16 {
84        return None;
85    }
86    // Check magic bytes "GRIB"
87    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
101/// Parse the identification section (Section 1) of a GRIB2 message.
102///
103/// Section 1 starts after the 16-byte indicator.
104/// Returns a `Grib2Header` on success.
105pub 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    // Section 1 length
114    let _sec1_len = u32::from_be_bytes([sec1[0], sec1[1], sec1[2], sec1[3]]);
115    // Section number (should be 1)
116    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    // Master tables version, local tables version...
123    // Reference time
124    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
145/// Check if raw bytes contain a valid GRIB2 end section marker ("7777").
146pub 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
154/// Get the GRIB2 discipline name.
155pub 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// ════════════════════════════════════════════════════════════════════════════
168// NetCDF Atmospheric Data
169// ════════════════════════════════════════════════════════════════════════════
170
171/// A simple representation of a NetCDF atmospheric variable.
172#[derive(Debug, Clone)]
173pub struct NetcdfAtmVariable {
174    /// Variable name (e.g., "temperature", "wind_speed").
175    pub name: String,
176    /// Units (e.g., "K", "m/s", "Pa").
177    pub units: String,
178    /// Dimension names.
179    pub dimensions: Vec<String>,
180    /// Data values (flattened).
181    pub data: Vec<f64>,
182    /// Shape of each dimension.
183    pub shape: Vec<usize>,
184    /// Fill/missing value.
185    pub fill_value: f64,
186    /// Scale factor for packed data.
187    pub scale_factor: f64,
188    /// Add offset for packed data.
189    pub add_offset: f64,
190}
191
192impl NetcdfAtmVariable {
193    /// Create a new atmospheric variable.
194    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    /// Unpack a raw value using scale_factor and add_offset.
209    pub fn unpack(&self, raw: f64) -> f64 {
210        raw * self.scale_factor + self.add_offset
211    }
212
213    /// Pack a physical value for storage.
214    pub fn pack(&self, value: f64) -> f64 {
215        (value - self.add_offset) / self.scale_factor
216    }
217
218    /// Get a value at a flat index.
219    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    /// Set a value at a flat index.
230    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    /// Get the total number of elements.
237    pub fn total_size(&self) -> usize {
238        self.shape.iter().product()
239    }
240
241    /// Convert multi-dimensional indices to flat index.
242    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/// A simple NetCDF-like atmospheric dataset.
260#[derive(Debug, Clone)]
261pub struct NetcdfAtmDataset {
262    /// Global attributes (key-value pairs).
263    pub global_attrs: Vec<(String, String)>,
264    /// Variables in the dataset.
265    pub variables: Vec<NetcdfAtmVariable>,
266}
267
268impl NetcdfAtmDataset {
269    /// Create an empty dataset.
270    pub fn new() -> Self {
271        Self {
272            global_attrs: Vec::new(),
273            variables: Vec::new(),
274        }
275    }
276
277    /// Add a global attribute.
278    pub fn add_attribute(&mut self, key: &str, value: &str) {
279        self.global_attrs.push((key.to_string(), value.to_string()));
280    }
281
282    /// Add a variable.
283    pub fn add_variable(&mut self, var: NetcdfAtmVariable) {
284        self.variables.push(var);
285    }
286
287    /// Find a variable by name.
288    pub fn find_variable(&self, name: &str) -> Option<&NetcdfAtmVariable> {
289        self.variables.iter().find(|v| v.name == name)
290    }
291
292    /// Find a mutable variable by name.
293    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    /// Serialize the dataset to a simple text format for exchange.
298    ///
299    /// Format:
300    /// ```text
301    /// # NETCDF_ATM_SIMPLE v1
302    /// @attr key=value
303    /// !var name units dim1,dim2 shape1,shape2
304    /// data_val1 data_val2 ...
305    /// ```
306    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            // Write in chunks of 10 values per line
325            for chunk in vals.chunks(10) {
326                out.push_str(&chunk.join(" "));
327                out.push('\n');
328            }
329        }
330        out
331    }
332
333    /// Parse a dataset from the simple text format.
334    pub fn from_text(text: &str) -> Option<Self> {
335        let mut dataset = Self::new();
336        let mut lines = text.lines().peekable();
337
338        // Check header
339        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                // Read data values
369                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// ════════════════════════════════════════════════════════════════════════════
404// METAR Parser
405// ════════════════════════════════════════════════════════════════════════════
406
407/// Parsed METAR weather observation.
408#[derive(Debug, Clone)]
409pub struct MetarReport {
410    /// Station identifier (ICAO code, e.g., "KJFK").
411    pub station: String,
412    /// Observation day of month.
413    pub day: u8,
414    /// Observation hour (UTC).
415    pub hour: u8,
416    /// Observation minute (UTC).
417    pub minute: u8,
418    /// Wind direction (degrees, 0-360). `None` if variable.
419    pub wind_direction: Option<u16>,
420    /// Wind speed (knots).
421    pub wind_speed: u16,
422    /// Wind gust speed (knots). `None` if no gusts.
423    pub wind_gust: Option<u16>,
424    /// Visibility in statute miles.
425    pub visibility_sm: f64,
426    /// Temperature (Celsius).
427    pub temperature: Option<f64>,
428    /// Dew point (Celsius).
429    pub dew_point: Option<f64>,
430    /// Altimeter setting (inches of mercury).
431    pub altimeter: Option<f64>,
432    /// Raw METAR string.
433    pub raw: String,
434    /// Cloud layers.
435    pub clouds: Vec<CloudLayer>,
436    /// Weather phenomena (e.g., "RA", "SN", "FG").
437    pub weather: Vec<String>,
438    /// Whether this is an automatic observation.
439    pub auto: bool,
440}
441
442/// A cloud layer reported in METAR.
443#[derive(Debug, Clone)]
444pub struct CloudLayer {
445    /// Coverage code: "FEW", "SCT", "BKN", "OVC", "CLR", "SKC".
446    pub coverage: String,
447    /// Height in hundreds of feet AGL. `None` for CLR/SKC.
448    pub height_ft: Option<u32>,
449    /// Cloud type modifier (e.g., "CB", "TCU"). Usually empty.
450    pub cloud_type: String,
451}
452
453impl MetarReport {
454    /// Create a new empty METAR report.
455    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
476/// Parse a METAR string into a `MetarReport`.
477///
478/// Handles the most common METAR elements:
479/// - Station ID, date/time group
480/// - Wind (direction, speed, gusts)
481/// - Visibility
482/// - Temperature/dew point
483/// - Altimeter setting
484/// - Cloud layers
485///
486/// # Example
487/// ```text
488/// KJFK 301456Z 32015G25KT 10SM FEW050 SCT250 24/16 A3002
489/// ```
490pub fn parse_metar(raw: &str) -> Option<MetarReport> {
491    let raw_trimmed = raw.trim();
492    // Remove "METAR" or "SPECI" prefix if present
493    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    // Parse date/time group (DDHHMMz)
510    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    // Check for AUTO
519    if idx < tokens.len() && tokens[idx] == "AUTO" {
520        report.auto = true;
521        idx += 1;
522    }
523
524    // Parse remaining tokens
525    while idx < tokens.len() {
526        let token = tokens[idx];
527        idx += 1;
528
529        // Wind: DDDSSKT or DDDSSGSKT
530        if token.ends_with("KT") && token.len() >= 7 {
531            parse_metar_wind(token, &mut report);
532            continue;
533        }
534
535        // Visibility: XXXXSM
536        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                    // Handle fractions like "1/2SM" or "1 1/2SM"
542                    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        // Cloud layers: FEW/SCT/BKN/OVC + height, or CLR/SKC
556        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            // Check for CB/TCU suffix
566            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        // Temperature/dew point: TT/TD (e.g., "24/16", "M01/M05")
595        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        // Altimeter: Annnn
609        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        // Weather phenomena (simplified)
617        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
632/// Parse wind from a METAR token.
633fn 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        // Gusts present
638        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
662/// Parse a METAR temperature string (e.g., "24", "M01" for -1).
663fn 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// ════════════════════════════════════════════════════════════════════════════
672// Sounding (TEMP) Data
673// ════════════════════════════════════════════════════════════════════════════
674
675/// A single level in a radiosonde sounding.
676#[derive(Debug, Clone, Copy)]
677pub struct SoundingLevel {
678    /// Pressure (hPa / mb).
679    pub pressure: f64,
680    /// Height (m above sea level).
681    pub height: f64,
682    /// Temperature (Celsius).
683    pub temperature: f64,
684    /// Dew point (Celsius).
685    pub dew_point: f64,
686    /// Wind direction (degrees).
687    pub wind_direction: f64,
688    /// Wind speed (knots).
689    pub wind_speed: f64,
690}
691
692/// A complete radiosonde sounding profile.
693#[derive(Debug, Clone)]
694pub struct SoundingProfile {
695    /// Station identifier.
696    pub station: String,
697    /// Observation time (YYYYMMDDHH).
698    pub observation_time: String,
699    /// Station latitude (degrees).
700    pub latitude: f64,
701    /// Station longitude (degrees).
702    pub longitude: f64,
703    /// Station elevation (m).
704    pub elevation: f64,
705    /// Sounding levels (sorted by decreasing pressure / increasing height).
706    pub levels: Vec<SoundingLevel>,
707}
708
709impl SoundingProfile {
710    /// Create a new sounding profile.
711    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    /// Add a sounding level.
723    pub fn add_level(&mut self, level: SoundingLevel) {
724        self.levels.push(level);
725    }
726
727    /// Sort levels by pressure (descending = surface first).
728    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    /// Sort levels by height (ascending = surface first).
737    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    /// Interpolate temperature at a given pressure level using linear interpolation.
746    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    /// Interpolate wind speed at a given pressure level.
754    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    /// Interpolate height at a given pressure level.
762    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    /// Compute the Lifting Condensation Level (LCL) pressure.
770    ///
771    /// Uses the surface level temperature and dew point.
772    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        // Bolton (1980) approximation for LCL temperature
782        let t_lcl = 1.0 / (1.0 / (td + 273.15 - 56.0) + (t - td).ln() / 800.0) + 56.0;
783        // LCL pressure using Poisson's equation
784        let p_lcl = p * ((t_lcl) / (t + 273.15)).powf(3.5);
785        Some(p_lcl)
786    }
787
788    /// Compute the total precipitable water (mm).
789    ///
790    /// Integrates the mixing ratio over all levels.
791    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; // hPa -> Pa
799            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        // Convert kg/m^2 to mm (1 kg/m^2 = 1 mm)
805        pw
806    }
807
808    /// Serialize the sounding to a simple text format.
809    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    /// Parse a sounding from text format.
833    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
874/// Interpolate a sounding value at a given pressure.
875fn interpolate_sounding_value(
876    levels: &[SoundingLevel],
877    pressure: f64,
878    extract: fn(&SoundingLevel) -> f64,
879) -> Option<f64> {
880    // Find bracketing levels (levels sorted by decreasing pressure)
881    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
901// ════════════════════════════════════════════════════════════════════════════
902// Wind Profile Models
903// ════════════════════════════════════════════════════════════════════════════
904
905/// Compute wind speed at height using the power law profile.
906///
907/// V(z) = V_ref * (z / z_ref)^alpha
908///
909/// `v_ref` - Reference wind speed (m/s) at `z_ref`.
910/// `z_ref` - Reference height (m).
911/// `z` - Target height (m).
912/// `alpha` - Power law exponent (typically 0.14 for open terrain, 0.25 for urban).
913pub 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
920/// Compute wind speed at height using the logarithmic law profile.
921///
922/// V(z) = (u* / k) * ln(z / z0)
923///
924/// `u_star` - Friction velocity (m/s).
925/// `z` - Target height (m).
926/// `z0` - Roughness length (m).
927/// `von_karman` - Von Karman constant (typically 0.41).
928pub 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
935/// Compute the friction velocity from a reference wind speed.
936///
937/// u* = V_ref * k / ln(z_ref / z0)
938pub 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
945/// Compute the Ekman spiral wind direction change with height.
946///
947/// The wind direction rotates clockwise (Northern Hemisphere) with height
948/// due to the decrease in friction.
949///
950/// `z` - Height (m).
951/// `boundary_layer_height` - Atmospheric boundary layer height (m).
952/// `surface_direction` - Surface wind direction (degrees).
953/// `geostrophic_direction` - Geostrophic wind direction (degrees).
954pub 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
968// ════════════════════════════════════════════════════════════════════════════
969// Temperature Profile Models
970// ════════════════════════════════════════════════════════════════════════════
971
972/// Compute temperature at altitude using a constant lapse rate.
973///
974/// T(h) = T_surface - lapse_rate * h
975///
976/// `t_surface` - Surface temperature (K or C).
977/// `lapse_rate` - Temperature lapse rate (K/m or C/m, positive means decreasing with height).
978/// `height` - Height above surface (m).
979pub fn temperature_lapse(t_surface: f64, lapse_rate: f64, height: f64) -> f64 {
980    t_surface - lapse_rate * height
981}
982
983/// Compute the potential temperature.
984///
985/// theta = T * (P0 / P)^(R/cp)
986///
987/// `temperature` - Temperature (K).
988/// `pressure` - Pressure (hPa).
989/// `p0` - Reference pressure (typically 1000 hPa).
990pub 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
997/// Compute the virtual temperature.
998///
999/// Tv = T * (1 + 0.61 * w)
1000///
1001/// `temperature` - Temperature (K).
1002/// `mixing_ratio` - Water vapor mixing ratio (kg/kg).
1003pub fn virtual_temperature(temperature: f64, mixing_ratio_val: f64) -> f64 {
1004    temperature * (1.0 + 0.61 * mixing_ratio_val)
1005}
1006
1007/// Compute the equivalent potential temperature (Bolton 1980).
1008///
1009/// `temperature` - Temperature (K).
1010/// `dew_point` - Dew point temperature (K).
1011/// `pressure` - Pressure (hPa).
1012pub fn equivalent_potential_temperature(temperature: f64, dew_point: f64, pressure: f64) -> f64 {
1013    let w = mixing_ratio(dew_point - 273.15, pressure); // Convert K to C for mixing_ratio
1014    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
1022// ════════════════════════════════════════════════════════════════════════════
1023// Pressure-Altitude Computations
1024// ════════════════════════════════════════════════════════════════════════════
1025
1026/// Compute pressure altitude from actual pressure and standard sea-level pressure.
1027///
1028/// Uses the hypsometric equation simplified for standard atmosphere.
1029///
1030/// `pressure` - Actual pressure (hPa).
1031/// `sea_level_pressure` - Sea-level pressure (hPa, standard = 1013.25).
1032///
1033/// Returns altitude in meters.
1034pub 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    // International barometric formula for the troposphere
1039    let t0 = 288.15; // ISA sea level temperature (K)
1040    let lapse = 0.0065; // Standard lapse rate (K/m)
1041    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
1047/// Compute density altitude.
1048///
1049/// `pressure_alt` - Pressure altitude (m).
1050/// `oat` - Outside air temperature (C).
1051///
1052/// Returns density altitude in meters.
1053pub fn density_altitude(pressure_alt: f64, oat: f64) -> f64 {
1054    let isa_temp = 15.0 - 0.0065 * pressure_alt; // ISA temp at this altitude
1055    let temp_dev = oat - isa_temp;
1056    pressure_alt + 120.0 * temp_dev
1057}
1058
1059/// Convert between pressure and flight level.
1060///
1061/// `pressure` - Pressure in hPa.
1062///
1063/// Returns flight level (in hundreds of feet).
1064pub fn pressure_to_flight_level(pressure: f64) -> f64 {
1065    let alt_ft = pressure_altitude(pressure, 1013.25) * 3.28084; // meters to feet
1066    (alt_ft / 100.0).round()
1067}
1068
1069/// Convert flight level to pressure (hPa).
1070///
1071/// `flight_level` - Flight level (hundreds of feet).
1072pub fn flight_level_to_pressure(flight_level: f64) -> f64 {
1073    let alt_m = flight_level * 100.0 / 3.28084; // feet to meters
1074    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// ════════════════════════════════════════════════════════════════════════════
1082// ISA Atmosphere Model
1083// ════════════════════════════════════════════════════════════════════════════
1084
1085/// International Standard Atmosphere (ISA) properties at a given altitude.
1086#[derive(Debug, Clone, Copy)]
1087pub struct IsaProperties {
1088    /// Temperature (K).
1089    pub temperature: f64,
1090    /// Pressure (Pa).
1091    pub pressure: f64,
1092    /// Density (kg/m^3).
1093    pub density: f64,
1094    /// Speed of sound (m/s).
1095    pub speed_of_sound: f64,
1096    /// Dynamic viscosity (Pa*s).
1097    pub dynamic_viscosity: f64,
1098}
1099
1100/// Compute ISA properties at a given geometric altitude.
1101///
1102/// Valid for altitudes from -2000m to 86000m. The atmosphere is divided
1103/// into layers with constant lapse rates.
1104///
1105/// `altitude` - Geometric altitude (m above sea level).
1106pub fn isa_properties(altitude: f64) -> IsaProperties {
1107    // ISA layer definitions: (base_altitude, base_temperature, lapse_rate)
1108    // Layer 0: Troposphere (0 to 11000m)
1109    // Layer 1: Tropopause (11000 to 20000m)
1110    // Layer 2: Stratosphere 1 (20000 to 32000m)
1111    // Layer 3: Stratosphere 2 (32000 to 47000m)
1112    // Layer 4: Stratopause (47000 to 51000m)
1113    // Layer 5: Mesosphere 1 (51000 to 71000m)
1114    // Layer 6: Mesosphere 2 (71000 to 86000m)
1115
1116    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; // Gas constant for dry air (J/(kg*K))
1127    let g = 9.80665;
1128    let gamma = 1.4; // Ratio of specific heats
1129
1130    let alt = altitude.clamp(-2000.0, 86000.0);
1131
1132    // Find the applicable layer
1133    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        // Isothermal layer
1148        temperature = t_base;
1149        pressure = p_base * E.powf(-g * dh / (r * t_base));
1150    } else {
1151        // Gradient layer
1152        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    // Sutherland's law for dynamic viscosity
1160    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
1175/// Compute ISA temperature at altitude (simplified, troposphere only).
1176pub 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
1189/// Compute ISA pressure at altitude (simplified, troposphere only).
1190pub fn isa_pressure(altitude: f64) -> f64 {
1191    isa_properties(altitude).pressure
1192}
1193
1194/// Compute ISA density at altitude.
1195pub fn isa_density(altitude: f64) -> f64 {
1196    isa_properties(altitude).density
1197}
1198
1199// ════════════════════════════════════════════════════════════════════════════
1200// Humidity / Dew Point Conversions
1201// ════════════════════════════════════════════════════════════════════════════
1202
1203/// Compute saturation vapor pressure using the Magnus formula.
1204///
1205/// es(T) = 6.112 * exp(17.67 * T / (T + 243.5))
1206///
1207/// `temperature` - Temperature in Celsius.
1208///
1209/// Returns saturation vapor pressure in hPa.
1210pub fn saturation_vapor_pressure(temperature: f64) -> f64 {
1211    6.112 * (17.67 * temperature / (temperature + 243.5)).exp()
1212}
1213
1214/// Compute actual vapor pressure from dew point.
1215///
1216/// `dew_point` - Dew point temperature in Celsius.
1217pub fn vapor_pressure_from_dewpoint(dew_point: f64) -> f64 {
1218    saturation_vapor_pressure(dew_point)
1219}
1220
1221/// Compute relative humidity from temperature and dew point.
1222///
1223/// RH = (es(Td) / es(T)) * 100
1224///
1225/// Returns relative humidity in percent (0-100).
1226pub 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
1235/// Compute dew point from temperature and relative humidity.
1236///
1237/// Uses the inverse of the Magnus formula.
1238///
1239/// `temperature` - Temperature in Celsius.
1240/// `rh` - Relative humidity in percent (0-100).
1241///
1242/// Returns dew point in Celsius.
1243pub 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
1251/// Compute mixing ratio from dew point and pressure.
1252///
1253/// w = 0.622 * e / (P - e)
1254///
1255/// `dew_point` - Dew point in Celsius.
1256/// `pressure` - Atmospheric pressure in hPa.
1257///
1258/// Returns mixing ratio in kg/kg.
1259pub 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
1268/// Compute specific humidity from mixing ratio.
1269///
1270/// q = w / (1 + w)
1271pub fn specific_humidity(mixing_ratio_val: f64) -> f64 {
1272    mixing_ratio_val / (1.0 + mixing_ratio_val)
1273}
1274
1275/// Compute wet-bulb temperature (approximate, Stull 2011).
1276///
1277/// `temperature` - Temperature in Celsius.
1278/// `rh` - Relative humidity in percent.
1279///
1280/// Returns wet-bulb temperature in Celsius.
1281pub 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
1290/// Compute heat index (apparent temperature, Rothfusz 1990).
1291///
1292/// `temperature` - Temperature in Fahrenheit.
1293/// `rh` - Relative humidity in percent.
1294///
1295/// Returns heat index in Fahrenheit.
1296pub 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
1313/// Convert Celsius to Fahrenheit.
1314pub fn celsius_to_fahrenheit(c: f64) -> f64 {
1315    c * 9.0 / 5.0 + 32.0
1316}
1317
1318/// Convert Fahrenheit to Celsius.
1319pub fn fahrenheit_to_celsius(f: f64) -> f64 {
1320    (f - 32.0) * 5.0 / 9.0
1321}
1322
1323/// Convert Celsius to Kelvin.
1324pub fn celsius_to_kelvin(c: f64) -> f64 {
1325    c + 273.15
1326}
1327
1328/// Convert Kelvin to Celsius.
1329pub fn kelvin_to_celsius(k: f64) -> f64 {
1330    k - 273.15
1331}
1332
1333/// Compute wind chill (for temperatures below 10C and wind > 4.8 km/h).
1334///
1335/// Uses the North American wind chill index.
1336///
1337/// `temperature` - Temperature in Celsius.
1338/// `wind_speed_kmh` - Wind speed in km/h.
1339pub 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
1347/// Convert wind speed from knots to m/s.
1348pub fn knots_to_ms(knots: f64) -> f64 {
1349    knots * 0.514444
1350}
1351
1352/// Convert wind speed from m/s to knots.
1353pub fn ms_to_knots(ms: f64) -> f64 {
1354    ms / 0.514444
1355}
1356
1357/// Convert wind speed from km/h to m/s.
1358pub fn kmh_to_ms(kmh: f64) -> f64 {
1359    kmh / 3.6
1360}
1361
1362/// Convert pressure from hPa to inHg.
1363pub fn hpa_to_inhg(hpa: f64) -> f64 {
1364    hpa * 0.02953
1365}
1366
1367/// Convert pressure from inHg to hPa.
1368pub fn inhg_to_hpa(inhg: f64) -> f64 {
1369    inhg / 0.02953
1370}
1371
1372// ════════════════════════════════════════════════════════════════════════════
1373// Stability Indices
1374// ════════════════════════════════════════════════════════════════════════════
1375
1376/// Compute the Showalter Index.
1377///
1378/// SI = T_500 - T_parcel_500
1379///
1380/// Lifts a parcel from 850 hPa to 500 hPa.
1381///
1382/// `t850` - Temperature at 850 hPa (C).
1383/// `td850` - Dew point at 850 hPa (C).
1384/// `t500` - Temperature at 500 hPa (C).
1385///
1386/// Returns the Showalter Index (negative = unstable).
1387pub fn showalter_index(t850: f64, td850: f64, t500: f64) -> f64 {
1388    // Lift parcel dry-adiabatically from 850 to LCL, then moist-adiabatically to 500
1389    let _t_diff = t850 - td850;
1390
1391    // Approximate LCL temperature
1392    let t_lcl = td850;
1393    // Approximate LCL pressure
1394    let p_lcl = 850.0 * ((t_lcl + 273.15) / (t850 + 273.15)).powf(3.5);
1395
1396    // Dry adiabatic lapse from 850 to LCL
1397    let dry_lapse = 9.8 / 1000.0; // K/m
1398    let _h_lcl = (t850 - t_lcl) / dry_lapse;
1399
1400    // Moist adiabatic lapse from LCL to 500 hPa (approximate 6 K/km)
1401    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    // Simplified: lift moist adiabatically
1406    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
1413/// Compute the K-Index (thunderstorm potential).
1414///
1415/// K = (T850 - T500) + Td850 - (T700 - Td700)
1416///
1417/// Higher K = more thunderstorm potential.
1418pub fn k_index(t850: f64, t500: f64, td850: f64, t700: f64, td700: f64) -> f64 {
1419    (t850 - t500) + td850 - (t700 - td700)
1420}
1421
1422/// Compute the Total Totals index.
1423///
1424/// TT = (T850 - T500) + (Td850 - T500) = T850 + Td850 - 2*T500
1425pub fn total_totals(t850: f64, td850: f64, t500: f64) -> f64 {
1426    t850 + td850 - 2.0 * t500
1427}
1428
1429/// Compute the SWEAT (Severe Weather Threat) index (simplified).
1430///
1431/// SWEAT = 12*Td850 + 20*(TT-49) + 2*f850 + f500 + 125*(S + 0.2)
1432///
1433/// (Simplified version - full version includes wind shear terms.)
1434pub 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    // Simplified: omit wind shear direction term
1440    term1 + term2 + term3 + term4
1441}
1442
1443// ════════════════════════════════════════════════════════════════════════════
1444// Beaufort Scale
1445// ════════════════════════════════════════════════════════════════════════════
1446
1447/// Convert wind speed (m/s) to Beaufort scale number (0-12).
1448pub 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
1479/// Get the Beaufort scale description.
1480pub 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
1499// ════════════════════════════════════════════════════════════════════════════
1500// Cloud Base Estimation
1501// ════════════════════════════════════════════════════════════════════════════
1502
1503/// Estimate cloud base height from surface temperature and dew point.
1504///
1505/// Uses the simple formula: cloud_base_ft = (T - Td) / 2.5 * 1000
1506///
1507/// `temperature` - Surface temperature (C).
1508/// `dew_point` - Surface dew point (C).
1509///
1510/// Returns estimated cloud base in feet AGL.
1511pub 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
1516/// Estimate cloud base height in meters AGL.
1517pub fn estimated_cloud_base_m(temperature: f64, dew_point: f64) -> f64 {
1518    estimated_cloud_base_ft(temperature, dew_point) * 0.3048
1519}
1520
1521// ════════════════════════════════════════════════════════════════════════════
1522// Tests
1523// ════════════════════════════════════════════════════════════════════════════
1524
1525#[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; // discipline
1537        data[7] = 2; // edition
1538        // Total length = 37
1539        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); // 1*4 + 2
1626    }
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        // V = 5 * (80/10)^0.14 ≈ 5 * 1.337 ≈ 6.68
1702        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        // Verify: wind_log_law with this u* at z=10 should give ~10
1717        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        // ~5500m
1743        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        // ISA temp at 1000m = 15 - 6.5 = 8.5C, deviation = 30 - 8.5 = 21.5
1750        // DA = 1000 + 120*21.5 = 3580
1751        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        // Should be about 23.37 hPa
1773        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        // Should be around 0.0147 kg/kg
1797        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        // q = 0.01 / 1.01 ≈ 0.0099
1804        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        // Above 10C, should return temperature
1825        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        // spread = 10, base = 10/2.5*1000 = 4000 ft
1856        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        // KI = (20-(-10)) + 15 - (10-5) = 30 + 15 - 5 = 40
1863        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        // TT = 20 + 15 - 2*(-10) = 55
1870        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        // 500 hPa ≈ FL180
1877        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        // At z=500/1000=0.5, dir = 180 + 0.5*90 = 225
1884        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        // Tv = 300 * (1 + 0.61*0.01) = 300 * 1.0061 = 301.83
1891        assert!((tv - 301.83).abs() < 0.01, "tv={tv}");
1892    }
1893}