Skip to main content

itu_rs/
lib.rs

1//! Selected [ITU-R P-series](https://www.itu.int/rec/R-REC-p) atmospheric propagation models.
2//!
3//! This crate is a Rust port of the subset of
4//! [`python-itu-r`](https://github.com/inigodelportillo/ITU-Rpy) needed for
5//! Earth-space atmospheric attenuation on a slant path. It currently focuses on
6//! `atmospheric_attenuation_slant_path` and the recommendation data needed to
7//! compute gas, cloud, rain, scintillation, and total attenuation contributions.
8//!
9//! # Data files
10//!
11//! Model grids are loaded lazily on first use. In a repository checkout, the
12//! crate looks for data under `data/` next to `Cargo.toml`.
13//!
14//! # Automatic Data Download
15//!
16//! For published package use, enable `features = ["data"]`. No runtime
17//! configuration is required: the build uses local `data/` files when present,
18//! otherwise downloads and embeds the verified data archive.
19//!
20//! The raw [ITU-R](https://www.itu.int/pub/R-REC) grids are too large to
21//! include directly because crates.io limits `.crate` archive size.
22//!
23//! # Manual Data Directory
24//!
25//! If automatic data embedding is not suitable, set `ITU_RS_DATA_DIR` to a
26//! directory containing the
27//! [`python-itu-r`](https://github.com/inigodelportillo/ITU-Rpy) `itur/data`
28//! layout. On Unix shells:
29//!
30//! ```sh
31//! export ITU_RS_DATA_DIR=/path/to/ITU-Rpy/itur/data
32//! ```
33//!
34//! On Windows PowerShell:
35//!
36//! ```powershell
37//! $env:ITU_RS_DATA_DIR = "C:\path\to\ITU-Rpy\itur\data"
38//! ```
39//!
40//! The examples that depend on recommendation grids check whether data is
41//! available before calling the model. That keeps doctests useful in this
42//! repository, with `features = ["data"]`, and in downstream builds that set
43//! `ITU_RS_DATA_DIR`.
44//!
45//! # Conventions
46//!
47//! Public APIs use plain `f64` values and encode units in argument and return
48//! names:
49//!
50//! - `_deg`: degrees. `lat_deg` is geodetic latitude and must be in
51//!   `[-90, 90]`. `lon_deg` is geodetic longitude; any finite degree value is
52//!   accepted and map lookups wrap it internally.
53//! - `_ghz`: carrier frequency in gigahertz.
54//! - `_km` and `_m`: kilometres and metres. Site heights are above mean sea
55//!   level unless the function says otherwise.
56//! - `_hpa`: pressure in hectopascals.
57//! - `_gm3`: density in grams per cubic metre.
58//! - `_kgm2`: columnar mass in kilograms per square metre.
59//! - `_mmh`: rainfall rate in millimetres per hour.
60//! - `_db` and `_db_per_km`: attenuation in decibels and specific attenuation
61//!   in decibels per kilometre.
62//! - `_k` and `_c`: temperature in kelvin and degrees Celsius.
63//!
64//! The `p` argument is a percentage of time exceeded, not a probability
65//! fraction. For example, `0.1` means 0.1% of an average year. Elevation angles
66//! are above the local horizon and must be in the open interval `(0, 90)`.
67//!
68//! # Example
69//!
70//! ```
71//! # fn data_available() -> bool {
72//! #     cfg!(feature = "data")
73//! #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
74//! #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
75//! #             .join("data/1511/v2_lat.npz")
76//! #             .exists()
77//! # }
78//! # if data_available() {
79//! use itu_rs::{atmospheric_attenuation_slant_path, SlantPathOptions};
80//!
81//! let attenuation = atmospheric_attenuation_slant_path(
82//!     45.4215,   // latitude, degrees
83//!     -75.6972,  // longitude, degrees
84//!     12.0,      // frequency, GHz
85//!     30.0,      // elevation, degrees
86//!     0.1,       // time percentage exceeded
87//!     1.2,       // antenna diameter, m
88//!     SlantPathOptions::default(),
89//! )?;
90//!
91//! assert!(attenuation.total_db.is_finite());
92//! # }
93//! # Ok::<(), Box<dyn std::error::Error>>(())
94//! ```
95//!
96
97use ndarray::{Array2, Axis};
98use ndarray_npy::NpzReader;
99use std::borrow::Cow;
100use std::f64::consts::FRAC_PI_2;
101use std::io::{BufRead, BufReader, Cursor};
102use std::path::{Path, PathBuf};
103use std::sync::OnceLock;
104
105#[cfg(feature = "data")]
106mod bundled_data {
107    include!(concat!(env!("OUT_DIR"), "/bundled_data.rs"));
108}
109
110#[cfg(not(feature = "data"))]
111mod bundled_data {
112    pub fn get(_rel_path: &str) -> Option<&'static [u8]> {
113        None
114    }
115}
116
117/// Error returned by ITU-R model loading, validation, and calculation routines.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ItuError {
120    message: String,
121}
122
123impl ItuError {
124    fn new(message: impl Into<String>) -> Self {
125        Self {
126            message: message.into(),
127        }
128    }
129
130    /// Returns the human-readable error message.
131    ///
132    /// The message names the input or model-loading problem that caused the
133    /// operation to fail. Validation errors do not require ITU-R data to be
134    /// loaded, so they are useful for checking bad inputs before any grid files
135    /// are touched.
136    ///
137    /// # Example
138    ///
139    /// ```
140    /// let err = itu_rs::rainfall_rate_r001_mmh(91.0, 0.0).unwrap_err();
141    ///
142    /// assert_eq!(err.message(), "lat_deg must be in [-90, 90]");
143    /// ```
144    pub fn message(&self) -> &str {
145        &self.message
146    }
147}
148
149impl std::fmt::Display for ItuError {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.write_str(&self.message)
152    }
153}
154
155impl std::error::Error for ItuError {}
156
157impl From<String> for ItuError {
158    fn from(value: String) -> Self {
159        Self::new(value)
160    }
161}
162
163const EPSILON: f64 = 1e-9;
164
165const P836_LEVELS: [f64; 18] = [
166    0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 50.0, 60.0, 70.0, 80.0, 90.0, 95.0,
167    99.0,
168];
169
170const P840_LEVELS: [f64; 23] = [
171    0.01, 0.02, 0.03, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 50.0, 60.0,
172    70.0, 80.0, 90.0, 95.0, 99.0, 100.0,
173];
174
175const P453_LEVELS: [f64; 18] = [
176    0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 50.0, 60.0, 70.0, 80.0, 90.0, 95.0,
177    99.0,
178];
179
180const P453_DN_LEVELS: [f64; 21] = [
181    0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 95.0, 98.0,
182    99.0, 99.5, 99.8, 99.9,
183];
184
185const HW_COEFFS_V13: [(f64, f64, f64); 3] = [
186    (22.235080, 2.6846, 2.7649),
187    (183.310087, 5.8905, 4.9219),
188    (325.152888, 2.9810, 3.0748),
189];
190
191const HW_A_V13: f64 = 5.6585e-5;
192const HW_B_V13: f64 = 1.8348;
193const EXACT_GAS_LAYERS: usize = 922;
194
195static MODEL: OnceLock<Result<IturModel, String>> = OnceLock::new();
196
197#[derive(Clone)]
198struct SpectralLine {
199    f: f64,
200    c1: f64,
201    c2: f64,
202    c3: f64,
203    c4: f64,
204    c5: f64,
205    c6: f64,
206}
207
208#[derive(Clone)]
209struct H0Coefficients {
210    freq_ghz: f64,
211    a0: f64,
212    b0: f64,
213    c0: f64,
214    d0: f64,
215}
216
217struct RegularGrid2D {
218    lat_axis: Vec<f64>,
219    lon_axis: Vec<f64>,
220    values: Array2<f64>,
221}
222
223impl RegularGrid2D {
224    fn from_arrays(
225        lat_grid: &Array2<f64>,
226        lon_grid: &Array2<f64>,
227        values: &Array2<f64>,
228    ) -> Result<Self, String> {
229        validate_grid_shapes(lat_grid, lon_grid, values)?;
230        if !is_regular_grid_inner(lat_grid, lon_grid)? {
231            return Err("lat_grid and lon_grid must describe a regular grid".to_string());
232        }
233
234        let mut lat_axis: Vec<f64> = lat_grid.column(0).iter().copied().collect();
235        let mut lon_axis: Vec<f64> = lon_grid.row(0).iter().copied().collect();
236        let mut values = values.clone();
237
238        if lat_axis.first().copied().unwrap_or(0.0) > lat_axis.last().copied().unwrap_or(0.0) {
239            lat_axis.reverse();
240            values.invert_axis(Axis(0));
241        }
242
243        if lon_axis.first().copied().unwrap_or(0.0) > lon_axis.last().copied().unwrap_or(0.0) {
244            lon_axis.reverse();
245            values.invert_axis(Axis(1));
246        }
247
248        Ok(Self {
249            lat_axis,
250            lon_axis,
251            values,
252        })
253    }
254
255    fn from_npz(
256        rel_lat: &str,
257        rel_lon: &str,
258        rel_values: &str,
259        flip_ud: bool,
260    ) -> Result<Self, String> {
261        let lat_grid = load_npz_array2(rel_lat)?;
262        let lon_grid = load_npz_array2(rel_lon)?;
263        let mut values = load_npz_array2(rel_values)?;
264
265        if lat_grid.nrows() == 0 || lat_grid.ncols() == 0 {
266            return Err(format!("grid is empty for {rel_values}"));
267        }
268
269        let mut lat_axis: Vec<f64> = lat_grid.column(0).iter().copied().collect();
270        let mut lon_axis: Vec<f64> = lon_grid.row(0).iter().copied().collect();
271
272        if flip_ud {
273            lat_axis.reverse();
274            values.invert_axis(Axis(0));
275        }
276
277        if lat_axis.first().copied().unwrap_or(0.0) > lat_axis.last().copied().unwrap_or(0.0) {
278            lat_axis.reverse();
279            values.invert_axis(Axis(0));
280        }
281
282        if lon_axis.first().copied().unwrap_or(0.0) > lon_axis.last().copied().unwrap_or(0.0) {
283            lon_axis.reverse();
284            values.invert_axis(Axis(1));
285        }
286
287        Ok(Self {
288            lat_axis,
289            lon_axis,
290            values,
291        })
292    }
293
294    fn interp(&self, lat: f64, lon: f64) -> f64 {
295        if !lat.is_finite() || !lon.is_finite() {
296            return f64::NAN;
297        }
298
299        let (lat_lo, lat_hi, lat_frac) = bracket(&self.lat_axis, lat);
300        let (lon_lo, lon_hi, lon_frac) = bracket(&self.lon_axis, lon);
301
302        let v00 = self.values[[lat_lo, lon_lo]];
303        let v10 = self.values[[lat_hi, lon_lo]];
304        let v01 = self.values[[lat_lo, lon_hi]];
305        let v11 = self.values[[lat_hi, lon_hi]];
306
307        let v0 = v00 * (1.0 - lat_frac) + v10 * lat_frac;
308        let v1 = v01 * (1.0 - lat_frac) + v11 * lat_frac;
309        v0 * (1.0 - lon_frac) + v1 * lon_frac
310    }
311
312    fn nearest(&self, lat: f64, lon: f64) -> f64 {
313        let lat_idx = nearest_axis_index(&self.lat_axis, lat);
314        let lon_idx = nearest_axis_index(&self.lon_axis, lon);
315        self.values[[lat_idx, lon_idx]]
316    }
317}
318
319struct BicubicGrid2D {
320    lat_axis: Vec<f64>,
321    lon_axis: Vec<f64>,
322    values: Array2<f64>,
323}
324
325impl BicubicGrid2D {
326    fn from_arrays(
327        lat_grid: &Array2<f64>,
328        lon_grid: &Array2<f64>,
329        values: &Array2<f64>,
330    ) -> Result<Self, String> {
331        validate_grid_shapes(lat_grid, lon_grid, values)?;
332        if lat_grid.nrows() < 4 || lat_grid.ncols() < 4 {
333            return Err("bicubic interpolation requires at least a 4x4 grid".to_string());
334        }
335        if !is_regular_grid_inner(lat_grid, lon_grid)? {
336            return Err("lat_grid and lon_grid must describe a regular grid".to_string());
337        }
338
339        let mut lat_axis: Vec<f64> = lat_grid.column(0).iter().copied().collect();
340        let mut lon_axis: Vec<f64> = lon_grid.row(0).iter().copied().collect();
341        let mut values = values.clone();
342
343        if lat_axis.first().copied().unwrap_or(0.0) > lat_axis.last().copied().unwrap_or(0.0) {
344            lat_axis.reverse();
345            values.invert_axis(Axis(0));
346        }
347
348        if lon_axis.first().copied().unwrap_or(0.0) > lon_axis.last().copied().unwrap_or(0.0) {
349            lon_axis.reverse();
350            values.invert_axis(Axis(1));
351        }
352
353        Ok(Self {
354            lat_axis,
355            lon_axis,
356            values,
357        })
358    }
359
360    fn from_npz(
361        rel_lat: &str,
362        rel_lon: &str,
363        rel_values: &str,
364        flip_ud: bool,
365    ) -> Result<Self, String> {
366        let lat_grid = load_npz_array2(rel_lat)?;
367        let lon_grid = load_npz_array2(rel_lon)?;
368        let mut values = load_npz_array2(rel_values)?;
369
370        if lat_grid.nrows() < 4 || lat_grid.ncols() < 4 {
371            return Err(format!("bicubic grid is too small for {rel_values}"));
372        }
373
374        let mut lat_axis: Vec<f64> = lat_grid.column(0).iter().copied().collect();
375        let mut lon_axis: Vec<f64> = lon_grid.row(0).iter().copied().collect();
376
377        if flip_ud
378            || lat_axis.first().copied().unwrap_or(0.0) > lat_axis.last().copied().unwrap_or(0.0)
379        {
380            lat_axis.reverse();
381            values.invert_axis(Axis(0));
382        }
383
384        if lon_axis.first().copied().unwrap_or(0.0) > lon_axis.last().copied().unwrap_or(0.0) {
385            lon_axis.reverse();
386            values.invert_axis(Axis(1));
387        }
388
389        Ok(Self {
390            lat_axis,
391            lon_axis,
392            values,
393        })
394    }
395
396    fn interp(&self, lat: f64, lon: f64) -> f64 {
397        if !lat.is_finite() || !lon.is_finite() {
398            return f64::NAN;
399        }
400
401        let lat_row = &self.lat_axis[1..self.lat_axis.len() - 1];
402        let lon_row = &self.lon_axis[1..self.lon_axis.len() - 1];
403        let lat_step = lat_row[1] - lat_row[0];
404        let lon_step = lon_row[1] - lon_row[0];
405
406        let mut r_idx = ((searchsorted_right(lat_row, lat) as isize - 1)
407            + (searchsorted_left(lat_row, lat) as isize - 1))
408            / 2;
409        let mut c_idx = ((searchsorted_right(lon_row, lon) as isize - 1)
410            + (searchsorted_right(lon_row, lon) as isize - 1))
411            / 2;
412
413        r_idx = r_idx.clamp(0, self.values.nrows() as isize - 4);
414        c_idx = c_idx.clamp(0, self.values.ncols() as isize - 4);
415
416        let r = (lat - lat_row[0]) / lat_step + 1.0;
417        let c = (lon - lon_row[0]) / lon_step + 1.0;
418
419        let r0 = r_idx as usize;
420        let c0 = c_idx as usize;
421
422        let mut row_accum = [0.0_f64; 4];
423        for (dr, row_value) in row_accum.iter_mut().enumerate() {
424            let rr = r0 + dr;
425            *row_value = self.values[[rr, c0]] * kernel(c - c0 as f64)
426                + self.values[[rr, c0 + 1]] * kernel(c - (c0 + 1) as f64)
427                + self.values[[rr, c0 + 2]] * kernel(c - (c0 + 2) as f64)
428                + self.values[[rr, c0 + 3]] * kernel(c - (c0 + 3) as f64);
429        }
430
431        row_accum[0] * kernel(r - r0 as f64)
432            + row_accum[1] * kernel(r - (r0 + 1) as f64)
433            + row_accum[2] * kernel(r - (r0 + 2) as f64)
434            + row_accum[3] * kernel(r - (r0 + 3) as f64)
435    }
436}
437
438struct LazyRegularGrid2D {
439    rel_lat: String,
440    rel_lon: String,
441    rel_values: String,
442    flip_ud: bool,
443    grid: OnceLock<Result<RegularGrid2D, String>>,
444}
445
446impl LazyRegularGrid2D {
447    fn new(
448        rel_lat: impl Into<String>,
449        rel_lon: impl Into<String>,
450        rel_values: impl Into<String>,
451        flip_ud: bool,
452    ) -> Self {
453        Self {
454            rel_lat: rel_lat.into(),
455            rel_lon: rel_lon.into(),
456            rel_values: rel_values.into(),
457            flip_ud,
458            grid: OnceLock::new(),
459        }
460    }
461
462    fn get(&self) -> Result<&RegularGrid2D, String> {
463        self.grid
464            .get_or_init(|| {
465                RegularGrid2D::from_npz(
466                    &self.rel_lat,
467                    &self.rel_lon,
468                    &self.rel_values,
469                    self.flip_ud,
470                )
471            })
472            .as_ref()
473            .map_err(|err| err.clone())
474    }
475
476    fn interp(&self, lat: f64, lon: f64) -> Result<f64, String> {
477        Ok(self.get()?.interp(lat, lon))
478    }
479}
480
481struct LazyBicubicGrid2D {
482    rel_lat: String,
483    rel_lon: String,
484    rel_values: String,
485    flip_ud: bool,
486    grid: OnceLock<Result<BicubicGrid2D, String>>,
487}
488
489impl LazyBicubicGrid2D {
490    fn new(
491        rel_lat: impl Into<String>,
492        rel_lon: impl Into<String>,
493        rel_values: impl Into<String>,
494        flip_ud: bool,
495    ) -> Self {
496        Self {
497            rel_lat: rel_lat.into(),
498            rel_lon: rel_lon.into(),
499            rel_values: rel_values.into(),
500            flip_ud,
501            grid: OnceLock::new(),
502        }
503    }
504
505    fn get(&self) -> Result<&BicubicGrid2D, String> {
506        self.grid
507            .get_or_init(|| {
508                BicubicGrid2D::from_npz(
509                    &self.rel_lat,
510                    &self.rel_lon,
511                    &self.rel_values,
512                    self.flip_ud,
513                )
514            })
515            .as_ref()
516            .map_err(|err| err.clone())
517    }
518
519    fn interp(&self, lat: f64, lon: f64) -> Result<f64, String> {
520        Ok(self.get()?.interp(lat, lon))
521    }
522}
523
524struct LazySpectralLines {
525    rel_path: String,
526    lines: OnceLock<Result<Vec<SpectralLine>, String>>,
527}
528
529impl LazySpectralLines {
530    fn new(rel_path: impl Into<String>) -> Self {
531        Self {
532            rel_path: rel_path.into(),
533            lines: OnceLock::new(),
534        }
535    }
536
537    fn get(&self) -> Result<&[SpectralLine], String> {
538        self.lines
539            .get_or_init(|| load_spectral_lines(&self.rel_path))
540            .as_ref()
541            .map(|lines| lines.as_slice())
542            .map_err(|err| err.clone())
543    }
544}
545
546struct LazyH0Coefficients {
547    rel_path: String,
548    coeffs: OnceLock<Result<Vec<H0Coefficients>, String>>,
549}
550
551impl LazyH0Coefficients {
552    fn new(rel_path: impl Into<String>) -> Self {
553        Self {
554            rel_path: rel_path.into(),
555            coeffs: OnceLock::new(),
556        }
557    }
558
559    fn get(&self) -> Result<&[H0Coefficients], String> {
560        self.coeffs
561            .get_or_init(|| load_h0_coefficients(&self.rel_path))
562            .as_ref()
563            .map(|coeffs| coeffs.as_slice())
564            .map_err(|err| err.clone())
565    }
566}
567
568struct IturModel {
569    topo_1511_v2: LazyBicubicGrid2D,
570    temp_1510_v1: LazyRegularGrid2D,
571    month_temp_1510_v1: Vec<LazyRegularGrid2D>,
572    topo_836_v6: LazyBicubicGrid2D,
573    rho_836_v6: Vec<(f64, LazyRegularGrid2D)>,
574    v_836_v6: Vec<(f64, LazyRegularGrid2D)>,
575    vsch_836_v6: Vec<(f64, LazyRegularGrid2D)>,
576    oxygen_lines_v13: LazySpectralLines,
577    water_lines_v13: LazySpectralLines,
578    h0_coeffs_v13: LazyH0Coefficients,
579    rainfall_r001_837_v7: LazyRegularGrid2D,
580    rainfall_mt_837_v7: Vec<LazyRegularGrid2D>,
581    zero_isotherm_839_v4: LazyRegularGrid2D,
582    cloud_lred_840_v9: Vec<(f64, LazyRegularGrid2D)>,
583    cloud_lognormal_m_840_v9: LazyRegularGrid2D,
584    cloud_lognormal_sigma_840_v9: LazyRegularGrid2D,
585    cloud_lognormal_pclw_840_v9: LazyRegularGrid2D,
586    wet_refractivity_453_v13: Vec<(f64, LazyRegularGrid2D)>,
587    dn65_453_v13: Vec<(f64, LazyRegularGrid2D)>,
588    dn1_453_v13: Vec<(f64, LazyRegularGrid2D)>,
589    climatic_ratio_678_v3: LazyRegularGrid2D,
590}
591
592/// Hydrometeor phase used by [P.453](https://www.itu.int/rec/R-REC-P.453) saturation vapour-pressure formulas.
593#[derive(Clone, Copy, Debug, PartialEq, Eq)]
594pub enum HydrometeorType {
595    /// Saturation over liquid water.
596    Water,
597    /// Saturation over ice.
598    Ice,
599}
600
601/// Calculation mode for [P.676](https://www.itu.int/rec/R-REC-P.676) gaseous path attenuation.
602#[derive(Clone, Copy, Debug, PartialEq, Eq)]
603pub enum GasPathMode {
604    /// Use the faster equivalent-height approximation.
605    Approximate,
606    /// Use the exact specific-attenuation method where the recommendation defines one.
607    Exact,
608}
609
610/// Tests whether latitude and longitude matrices describe a regular [P.1144](https://www.itu.int/rec/R-REC-P.1144) grid.
611///
612/// A regular grid has constant latitude spacing down columns, constant
613/// longitude spacing across rows, and non-zero spacing in both directions.
614/// Inputs are coordinate matrices in degrees.
615///
616/// # Parameters
617///
618/// - `lat_grid`: latitude coordinate matrix in degrees.
619/// - `lon_grid`: longitude coordinate matrix in degrees.
620///
621/// # Returns
622///
623/// `true` when the coordinate matrices form a regular grid.
624///
625/// # Errors
626///
627/// Returns an error if shapes differ, the grid is too small, or any coordinate
628/// is not finite.
629///
630/// # Example
631///
632/// ```
633/// use ndarray::array;
634///
635/// let lat = array![[1.0, 1.0], [0.0, 0.0]];
636/// let lon = array![[0.0, 1.0], [0.0, 1.0]];
637///
638/// assert!(itu_rs::is_regular_grid(&lat, &lon)?);
639/// # Ok::<(), Box<dyn std::error::Error>>(())
640/// ```
641pub fn is_regular_grid(
642    lat_grid: &Array2<f64>,
643    lon_grid: &Array2<f64>,
644) -> std::result::Result<bool, ItuError> {
645    validate_grid_shapes(lat_grid, lon_grid, lat_grid).map_err(ItuError::from)?;
646    is_regular_grid_inner(lat_grid, lon_grid).map_err(ItuError::from)
647}
648
649/// Interpolates a regular [P.1144](https://www.itu.int/rec/R-REC-P.1144) grid with nearest-neighbour interpolation.
650///
651/// # Parameters
652///
653/// - `lat_grid`: latitude coordinate matrix in degrees.
654/// - `lon_grid`: longitude coordinate matrix in degrees.
655/// - `values`: value matrix at the coordinate points.
656/// - `lat_deg`: query latitude in degrees.
657/// - `lon_deg`: query longitude in degrees.
658///
659/// # Returns
660///
661/// Nearest grid value at the query coordinate.
662///
663/// # Errors
664///
665/// Returns an error if inputs are invalid or the coordinates are not a regular
666/// grid.
667///
668/// # Example
669///
670/// ```
671/// use ndarray::array;
672///
673/// let lat = array![[1.0, 1.0], [0.0, 0.0]];
674/// let lon = array![[0.0, 1.0], [0.0, 1.0]];
675/// let values = array![[10.0, 11.0], [0.0, 1.0]];
676///
677/// let value = itu_rs::nearest_2d_interpolate(&lat, &lon, &values, 0.2, 0.2)?;
678/// assert_eq!(value, 0.0);
679/// # Ok::<(), Box<dyn std::error::Error>>(())
680/// ```
681pub fn nearest_2d_interpolate(
682    lat_grid: &Array2<f64>,
683    lon_grid: &Array2<f64>,
684    values: &Array2<f64>,
685    lat_deg: f64,
686    lon_deg: f64,
687) -> std::result::Result<f64, ItuError> {
688    validate_finite("lat_deg", lat_deg)
689        .and_then(|_| validate_finite("lon_deg", lon_deg))
690        .map_err(ItuError::from)?;
691    Ok(RegularGrid2D::from_arrays(lat_grid, lon_grid, values)
692        .map_err(ItuError::from)?
693        .nearest(lat_deg, lon_deg))
694}
695
696/// Interpolates a regular [P.1144](https://www.itu.int/rec/R-REC-P.1144) grid with bilinear interpolation.
697///
698/// Bilinear interpolation blends the four surrounding grid values. Queries
699/// outside the grid are clamped to the nearest edge, matching the crate's
700/// internal map lookup behavior.
701///
702/// # Parameters
703///
704/// - `lat_grid`: latitude coordinate matrix in degrees.
705/// - `lon_grid`: longitude coordinate matrix in degrees.
706/// - `values`: value matrix at the coordinate points.
707/// - `lat_deg`: query latitude in degrees.
708/// - `lon_deg`: query longitude in degrees.
709///
710/// # Returns
711///
712/// Bilinearly interpolated value.
713///
714/// # Errors
715///
716/// Returns an error if inputs are invalid or the coordinates are not a regular
717/// grid.
718///
719/// # Example
720///
721/// ```
722/// use ndarray::array;
723///
724/// let lat = array![[1.0, 1.0], [0.0, 0.0]];
725/// let lon = array![[0.0, 1.0], [0.0, 1.0]];
726/// let values = array![[10.0, 11.0], [0.0, 1.0]];
727///
728/// let value = itu_rs::bilinear_2d_interpolate(&lat, &lon, &values, 0.5, 0.5)?;
729/// assert!((value - 5.5).abs() < 1e-12);
730/// # Ok::<(), Box<dyn std::error::Error>>(())
731/// ```
732pub fn bilinear_2d_interpolate(
733    lat_grid: &Array2<f64>,
734    lon_grid: &Array2<f64>,
735    values: &Array2<f64>,
736    lat_deg: f64,
737    lon_deg: f64,
738) -> std::result::Result<f64, ItuError> {
739    validate_finite("lat_deg", lat_deg)
740        .and_then(|_| validate_finite("lon_deg", lon_deg))
741        .map_err(ItuError::from)?;
742    Ok(RegularGrid2D::from_arrays(lat_grid, lon_grid, values)
743        .map_err(ItuError::from)?
744        .interp(lat_deg, lon_deg))
745}
746
747/// Interpolates a regular [P.1144](https://www.itu.int/rec/R-REC-P.1144) grid with bicubic interpolation.
748///
749/// Bicubic interpolation uses a 4-by-4 neighbourhood and requires at least a
750/// 4-by-4 regular coordinate grid.
751///
752/// # Parameters
753///
754/// - `lat_grid`: latitude coordinate matrix in degrees.
755/// - `lon_grid`: longitude coordinate matrix in degrees.
756/// - `values`: value matrix at the coordinate points.
757/// - `lat_deg`: query latitude in degrees.
758/// - `lon_deg`: query longitude in degrees.
759///
760/// # Returns
761///
762/// Bicubic interpolated value.
763///
764/// # Errors
765///
766/// Returns an error if inputs are invalid, the grid is smaller than 4-by-4, or
767/// the coordinates are not a regular grid.
768///
769/// # Example
770///
771/// ```
772/// use ndarray::array;
773///
774/// let lat = array![
775///     [3.0, 3.0, 3.0, 3.0],
776///     [2.0, 2.0, 2.0, 2.0],
777///     [1.0, 1.0, 1.0, 1.0],
778///     [0.0, 0.0, 0.0, 0.0],
779/// ];
780/// let lon = array![
781///     [0.0, 1.0, 2.0, 3.0],
782///     [0.0, 1.0, 2.0, 3.0],
783///     [0.0, 1.0, 2.0, 3.0],
784///     [0.0, 1.0, 2.0, 3.0],
785/// ];
786/// let values = &lat * 10.0 + &lon;
787///
788/// let value = itu_rs::bicubic_2d_interpolate(&lat, &lon, &values, 1.5, 1.5)?;
789/// assert!(value.is_finite());
790/// # Ok::<(), Box<dyn std::error::Error>>(())
791/// ```
792pub fn bicubic_2d_interpolate(
793    lat_grid: &Array2<f64>,
794    lon_grid: &Array2<f64>,
795    values: &Array2<f64>,
796    lat_deg: f64,
797    lon_deg: f64,
798) -> std::result::Result<f64, ItuError> {
799    validate_finite("lat_deg", lat_deg)
800        .and_then(|_| validate_finite("lon_deg", lon_deg))
801        .map_err(ItuError::from)?;
802    Ok(BicubicGrid2D::from_arrays(lat_grid, lon_grid, values)
803        .map_err(ItuError::from)?
804        .interp(lat_deg, lon_deg))
805}
806
807#[derive(Clone, Copy, Debug)]
808/// Optional environmental and model inputs for slant-path attenuation.
809///
810/// Defaults match the ported `python-itu-r` slant-path behavior: all
811/// environmental inputs are looked up from the bundled
812/// [ITU-R](https://www.itu.int/pub/R-REC) grids where possible, approximate
813/// gaseous attenuation is used, and gas, cloud, rain, and scintillation
814/// contributions are included.
815pub struct SlantPathOptions {
816    /// Earth station altitude above mean sea level in kilometres.
817    ///
818    /// This is the ground terminal height used by rain, gas, and water-vapour
819    /// calculations. It may be negative for sites below sea level. When `None`,
820    /// the model uses the [P.1511](https://www.itu.int/rec/R-REC-P.1511)/[P.836](https://www.itu.int/rec/R-REC-P.836) topographic map at the requested site.
821    pub hs_km: Option<f64>,
822    /// Surface water vapour density in g/m^3.
823    ///
824    /// This is the near-surface absolute humidity used by gaseous attenuation.
825    /// Values must be non-negative. When `None`, [P.836](https://www.itu.int/rec/R-REC-P.836) water-vapour maps are
826    /// used for the site and time percentage.
827    pub rho_gm3: Option<f64>,
828    /// Rainfall rate exceeded for 0.01% of an average year, in mm/h.
829    ///
830    /// This is the intense-rain reference rate used by [P.618](https://www.itu.int/rec/R-REC-P.618) to scale rain
831    /// attenuation for the requested time percentage. Values must be
832    /// non-negative. When `None`, [P.837](https://www.itu.int/rec/R-REC-P.837) data are used.
833    pub r001_mmh: Option<f64>,
834    /// Antenna efficiency factor used in scintillation attenuation.
835    ///
836    /// This dimensionless factor describes effective aperture efficiency for
837    /// the scintillation fade calculation. It must be in `(0, 1]`; the default
838    /// is `0.5`.
839    pub eta: f64,
840    /// Surface temperature in kelvin for gaseous attenuation.
841    ///
842    /// This temperature is used by gaseous attenuation and, when
843    /// `h_percent` is provided, converted to Celsius for scintillation humidity
844    /// calculations. Values must be greater than zero. When `None`, [P.1510](https://www.itu.int/rec/R-REC-P.1510)
845    /// annual mean surface temperature data are used.
846    pub t: Option<f64>,
847    /// Relative humidity percentage for scintillation attenuation.
848    ///
849    /// Values must be in `[0, 100]`. When `None`, scintillation uses the
850    /// recommendation's wet-term refractivity map instead of local humidity,
851    /// temperature, and pressure.
852    pub h_percent: Option<f64>,
853    /// Atmospheric pressure in hPa.
854    ///
855    /// This is the surface pressure used by gaseous attenuation and, when
856    /// `h_percent` is provided, scintillation water-vapour pressure. Values
857    /// must be positive. When `None`, [P.835](https://www.itu.int/rec/R-REC-P.835) standard-atmosphere pressure is
858    /// computed from `hs_km`.
859    pub pressure_hpa: Option<f64>,
860    /// Turbulent layer height in metres for scintillation attenuation.
861    ///
862    /// This is the effective height of the tropospheric turbulent layer. It
863    /// must be positive; the default is `1000.0` m.
864    pub h_l_m: f64,
865    /// Slant-path length through rain in kilometres.
866    ///
867    /// This is the portion of the Earth-space path inside the rain region.
868    /// Values must be positive when provided. When `None`, [P.618](https://www.itu.int/rec/R-REC-P.618) derives the
869    /// path length from rain height, station height, and elevation.
870    pub l_s_km: Option<f64>,
871    /// Polarization tilt angle in degrees.
872    ///
873    /// This describes the radio wave polarization relative to horizontal and
874    /// affects [P.838](https://www.itu.int/rec/R-REC-P.838) rain specific attenuation coefficients. It must be in
875    /// `[0, 90]`; the default is `45.0`.
876    pub tau_deg: f64,
877    /// Total water vapour content in kg/m^2 for gaseous attenuation.
878    ///
879    /// This is the vertically integrated water-vapour column above the site.
880    /// Values must be non-negative. When `None`, [P.836](https://www.itu.int/rec/R-REC-P.836) total-content maps are
881    /// used.
882    pub v_t_kgm2: Option<f64>,
883    /// Use exact gaseous attenuation when `true`; use the faster approximate
884    /// path when `false`.
885    ///
886    /// Exact mode integrates the [P.676](https://www.itu.int/rec/R-REC-P.676) gaseous model through atmosphere layers.
887    /// Approximate mode is faster and matches the default slant-path behavior
888    /// of the port. The default is `false`.
889    pub exact: bool,
890    /// Include the rain attenuation contribution in the returned total.
891    ///
892    /// When `false`, `SlantPathContributions::rain_db` is `0.0`.
893    pub include_rain: bool,
894    /// Include the gaseous attenuation contribution in the returned total.
895    ///
896    /// When `false`, `SlantPathContributions::gas_db` is `0.0`.
897    pub include_gas: bool,
898    /// Include the scintillation attenuation contribution in the returned total.
899    ///
900    /// When `false`, `SlantPathContributions::scintillation_db` is `0.0`.
901    pub include_scintillation: bool,
902    /// Include the cloud attenuation contribution in the returned total.
903    ///
904    /// When `false`, `SlantPathContributions::cloud_db` is `0.0`.
905    pub include_clouds: bool,
906}
907
908#[derive(Clone, Copy, Debug)]
909/// Atmospheric attenuation contribution breakdown in dB.
910///
911/// The fields separate the mechanisms used by the supported Earth-space
912/// slant-path model. `total_db` is not a plain sum of all fields; it follows the
913/// ported combination rule:
914///
915/// `gas_db + sqrt((rain_db + cloud_db)^2 + scintillation_db^2)`.
916pub struct SlantPathContributions {
917    /// Gaseous absorption contribution in dB.
918    ///
919    /// This comes from oxygen and water-vapour absorption along the slant path.
920    pub gas_db: f64,
921    /// Cloud liquid-water attenuation contribution in dB.
922    ///
923    /// This comes from reduced liquid water content and cloud mass absorption.
924    pub cloud_db: f64,
925    /// Rain fade contribution in dB.
926    ///
927    /// This comes from the [P.618](https://www.itu.int/rec/R-REC-P.618) rain model using rain rate, rain height,
928    /// elevation, path geometry, and polarization.
929    pub rain_db: f64,
930    /// Tropospheric scintillation contribution in dB.
931    ///
932    /// This represents short-term amplitude fluctuations caused by turbulence.
933    pub scintillation_db: f64,
934    /// Total attenuation in dB.
935    ///
936    /// This follows the same combination rule as `python-itu-r` for the
937    /// supported slant-path model:
938    /// `gas_db + sqrt((rain_db + cloud_db)^2 + scintillation_db^2)`.
939    pub total_db: f64,
940}
941
942impl Default for SlantPathOptions {
943    fn default() -> Self {
944        Self {
945            hs_km: None,
946            rho_gm3: None,
947            r001_mmh: None,
948            eta: 0.5,
949            t: None,
950            h_percent: None,
951            pressure_hpa: None,
952            h_l_m: 1000.0,
953            l_s_km: None,
954            tau_deg: 45.0,
955            v_t_kgm2: None,
956            exact: false,
957            include_rain: true,
958            include_gas: true,
959            include_scintillation: true,
960            include_clouds: true,
961        }
962    }
963}
964
965#[allow(clippy::too_many_arguments)]
966impl IturModel {
967    fn load() -> Result<Self, String> {
968        let topo_1511_v2 = LazyBicubicGrid2D::new(
969            "1511/v2_lat.npz",
970            "1511/v2_lon.npz",
971            "1511/v2_topo.npz",
972            true,
973        );
974        let temp_1510_v1 = LazyRegularGrid2D::new(
975            "1510/v1_lat.npz",
976            "1510/v1_lon.npz",
977            "1510/v1_t_annual.npz",
978            true,
979        );
980        let mut month_temp_1510_v1 = Vec::with_capacity(12);
981        for month in 1..=12 {
982            month_temp_1510_v1.push(LazyRegularGrid2D::new(
983                "1510/v1_lat.npz",
984                "1510/v1_lon.npz",
985                format!("1510/v1_t_month{month:02}.npz"),
986                true,
987            ));
988        }
989        let topo_836_v6 = LazyBicubicGrid2D::new(
990            "836/v6_topolat.npz",
991            "836/v6_topolon.npz",
992            "836/v6_topo_0dot5.npz",
993            true,
994        );
995
996        let mut rho_836_v6 = Vec::with_capacity(P836_LEVELS.len());
997        let mut v_836_v6 = Vec::with_capacity(P836_LEVELS.len());
998        let mut vsch_836_v6 = Vec::with_capacity(P836_LEVELS.len());
999        for p in P836_LEVELS {
1000            let suffix = p836_suffix(p);
1001            rho_836_v6.push((
1002                p,
1003                LazyRegularGrid2D::new(
1004                    "836/v6_lat.npz",
1005                    "836/v6_lon.npz",
1006                    format!("836/v6_rho_{suffix}.npz"),
1007                    false,
1008                ),
1009            ));
1010            v_836_v6.push((
1011                p,
1012                LazyRegularGrid2D::new(
1013                    "836/v6_lat.npz",
1014                    "836/v6_lon.npz",
1015                    format!("836/v6_v_{suffix}.npz"),
1016                    false,
1017                ),
1018            ));
1019            vsch_836_v6.push((
1020                p,
1021                LazyRegularGrid2D::new(
1022                    "836/v6_lat.npz",
1023                    "836/v6_lon.npz",
1024                    format!("836/v6_vsch_{suffix}.npz"),
1025                    false,
1026                ),
1027            ));
1028        }
1029
1030        let oxygen_lines_v13 = LazySpectralLines::new("676/v13_lines_oxygen.txt");
1031        let water_lines_v13 = LazySpectralLines::new("676/v13_lines_water_vapour.txt");
1032        let h0_coeffs_v13 = LazyH0Coefficients::new("676/v13_h0_coefficients.txt");
1033        let rainfall_r001_837_v7 = LazyRegularGrid2D::new(
1034            "837/v7_lat_r001.npz",
1035            "837/v7_lon_r001.npz",
1036            "837/v7_r001.npz",
1037            true,
1038        );
1039        let mut rainfall_mt_837_v7 = Vec::with_capacity(12);
1040        for month in 1..=12 {
1041            rainfall_mt_837_v7.push(LazyRegularGrid2D::new(
1042                "837/v7_lat_mt.npz",
1043                "837/v7_lon_mt.npz",
1044                format!("837/v7_mt_month{month:02}.npz"),
1045                true,
1046            ));
1047        }
1048        let zero_isotherm_839_v4 = LazyRegularGrid2D::new(
1049            "839/v4_esalat.npz",
1050            "839/v4_esalon.npz",
1051            "839/v4_esa0height.npz",
1052            false,
1053        );
1054
1055        let mut cloud_lred_840_v9 = Vec::with_capacity(P840_LEVELS.len());
1056        for p in P840_LEVELS {
1057            let stem = p840_stem(p)?;
1058            cloud_lred_840_v9.push((
1059                p,
1060                LazyRegularGrid2D::new(
1061                    "840/v9_lat.npz",
1062                    "840/v9_lon.npz",
1063                    format!("840/v9_l_{stem}.npz"),
1064                    false,
1065                ),
1066            ));
1067        }
1068
1069        let cloud_lognormal_m_840_v9 =
1070            LazyRegularGrid2D::new("840/v9_lat.npz", "840/v9_lon.npz", "840/v9_ml.npz", false);
1071        let cloud_lognormal_sigma_840_v9 =
1072            LazyRegularGrid2D::new("840/v9_lat.npz", "840/v9_lon.npz", "840/v9_sl.npz", false);
1073        let cloud_lognormal_pclw_840_v9 =
1074            LazyRegularGrid2D::new("840/v9_lat.npz", "840/v9_lon.npz", "840/v9_pl.npz", false);
1075
1076        let mut wet_refractivity_453_v13 = Vec::with_capacity(P453_LEVELS.len());
1077        for p in P453_LEVELS {
1078            let suffix = p453_suffix(p);
1079            wet_refractivity_453_v13.push((
1080                p,
1081                LazyRegularGrid2D::new(
1082                    "453/v13_lat_n.npz",
1083                    "453/v13_lon_n.npz",
1084                    format!("453/v13_nwet_annual_{suffix}.npz"),
1085                    true,
1086                ),
1087            ));
1088        }
1089
1090        let mut dn65_453_v13 = Vec::with_capacity(P453_DN_LEVELS.len());
1091        let mut dn1_453_v13 = Vec::with_capacity(P453_DN_LEVELS.len());
1092        for p in P453_DN_LEVELS {
1093            let suffix = p453_dn_suffix(p);
1094            dn65_453_v13.push((
1095                p,
1096                LazyRegularGrid2D::new(
1097                    "453/v12_lat0d75.npz",
1098                    "453/v12_lon0d75.npz",
1099                    format!("453/v12_dn65m_{suffix}_v1.npz"),
1100                    false,
1101                ),
1102            ));
1103            dn1_453_v13.push((
1104                p,
1105                LazyRegularGrid2D::new(
1106                    "453/v12_lat0d75.npz",
1107                    "453/v12_lon0d75.npz",
1108                    format!("453/v12_dn_{suffix}_v1.npz"),
1109                    false,
1110                ),
1111            ));
1112        }
1113
1114        let climatic_ratio_678_v3 = LazyRegularGrid2D::new(
1115            "678/v3_lat.npz",
1116            "678/v3_lon.npz",
1117            "678/v3_climatic_ratio.npz",
1118            false,
1119        );
1120
1121        Ok(Self {
1122            topo_1511_v2,
1123            temp_1510_v1,
1124            month_temp_1510_v1,
1125            topo_836_v6,
1126            rho_836_v6,
1127            v_836_v6,
1128            vsch_836_v6,
1129            oxygen_lines_v13,
1130            water_lines_v13,
1131            h0_coeffs_v13,
1132            rainfall_r001_837_v7,
1133            rainfall_mt_837_v7,
1134            zero_isotherm_839_v4,
1135            cloud_lred_840_v9,
1136            cloud_lognormal_m_840_v9,
1137            cloud_lognormal_sigma_840_v9,
1138            cloud_lognormal_pclw_840_v9,
1139            wet_refractivity_453_v13,
1140            dn65_453_v13,
1141            dn1_453_v13,
1142            climatic_ratio_678_v3,
1143        })
1144    }
1145
1146    fn topographic_altitude_km(&self, lat_deg: f64, lon_deg: f64) -> Result<f64, String> {
1147        let lon_180 = wrap_lon_180(lon_deg);
1148        Ok((self.topo_1511_v2.interp(lat_deg, lon_180)? / 1000.0).max(EPSILON))
1149    }
1150
1151    fn surface_mean_temperature_k(&self, lat_deg: f64, lon_deg: f64) -> Result<f64, String> {
1152        let lon_180 = wrap_lon_180(lon_deg);
1153        self.temp_1510_v1.interp(lat_deg, lon_180)
1154    }
1155
1156    fn surface_month_mean_temperature_k(
1157        &self,
1158        lat_deg: f64,
1159        lon_deg: f64,
1160        month: u8,
1161    ) -> Result<f64, String> {
1162        let lon_180 = wrap_lon_180(lon_deg);
1163        self.month_temp_1510_v1[usize::from(month - 1)].interp(lat_deg, lon_180)
1164    }
1165
1166    fn standard_pressure_hpa(&self, h_km: f64) -> f64 {
1167        let h_p = 6356.766 * h_km / (6356.766 + h_km);
1168        if h_p <= 11.0 {
1169            1013.25 * (288.15 / (288.15 - 6.5 * h_p)).powf(-34.1632 / 6.5)
1170        } else if h_p <= 20.0 {
1171            226.3226 * (-34.1632 * (h_p - 11.0) / 216.65).exp()
1172        } else if h_p <= 32.0 {
1173            54.74980 * (216.65 / (216.65 + (h_p - 20.0))).powf(34.1632)
1174        } else if h_p <= 47.0 {
1175            8.680422 * (228.65 / (228.65 + 2.8 * (h_p - 32.0))).powf(34.1632 / 2.8)
1176        } else if h_p <= 51.0 {
1177            1.109106 * (-34.1632 * (h_p - 47.0) / 270.65).exp()
1178        } else if h_p <= 71.0 {
1179            0.6694167 * (270.65 / (270.65 - 2.8 * (h_p - 51.0))).powf(-34.1632 / 2.8)
1180        } else if h_p <= 84.852 {
1181            0.03956649 * (214.65 / (214.65 - 2.0 * (h_p - 71.0))).powf(-34.1632 / 2.0)
1182        } else if (86.0..=100.0).contains(&h_km) {
1183            (95.571899 - 4.011801 * h_km + 6.424731e-2 * h_km.powi(2) - 4.789660e-4 * h_km.powi(3)
1184                + 1.340543e-6 * h_km.powi(4))
1185            .exp()
1186        } else {
1187            1e-62
1188        }
1189    }
1190
1191    fn standard_temperature_k(&self, h_km: f64) -> f64 {
1192        let h_p = 6356.766 * h_km / (6356.766 + h_km);
1193        if h_p <= 11.0 {
1194            288.15 - 6.5 * h_p
1195        } else if h_p <= 20.0 {
1196            216.65
1197        } else if h_p <= 32.0 {
1198            216.65 + (h_p - 20.0)
1199        } else if h_p <= 47.0 {
1200            228.65 + 2.8 * (h_p - 32.0)
1201        } else if h_p <= 51.0 {
1202            270.65
1203        } else if h_p <= 71.0 {
1204            270.65 - 2.8 * (h_p - 51.0)
1205        } else if h_p <= 84.852 {
1206            214.65 - 2.0 * (h_p - 71.0)
1207        } else if (86.0..=91.0).contains(&h_km) {
1208            186.8673
1209        } else if (91.0..=100.0).contains(&h_km) {
1210            263.1905 - 76.3232 * (1.0 - ((h_km - 91.0) / 19.9429).powi(2)).sqrt()
1211        } else {
1212            195.08134
1213        }
1214    }
1215
1216    fn standard_water_vapour_density_gm3(&self, h_km: f64, rho0_gm3: f64) -> f64 {
1217        rho0_gm3 * (-h_km / 2.0).exp()
1218    }
1219
1220    fn radio_refractive_index(&self, pd_hpa: f64, e_hpa: f64, t_k: f64) -> f64 {
1221        let n = 77.6 * pd_hpa / t_k + 72.0 * e_hpa / t_k + 3.75e5 * e_hpa / t_k.powi(2);
1222        1.0 + n * 1e-6
1223    }
1224
1225    fn dry_term_radio_refractivity(&self, pd_hpa: f64, t_k: f64) -> f64 {
1226        77.6 * pd_hpa / t_k
1227    }
1228
1229    fn saturation_vapour_pressure_hpa(
1230        &self,
1231        t_c: f64,
1232        pressure_hpa: f64,
1233        hydrometeor: HydrometeorType,
1234    ) -> f64 {
1235        let (ef, a, b, c, d) = match hydrometeor {
1236            HydrometeorType::Water => (
1237                1.0 + 1e-4 * (7.2 + pressure_hpa * (0.0320 + 5.9e-6 * t_c.powi(2))),
1238                6.1121,
1239                18.678,
1240                257.14,
1241                234.5,
1242            ),
1243            HydrometeorType::Ice => (
1244                1.0 + 1e-4 * (2.2 + pressure_hpa * (0.0383 + 6.4e-6 * t_c.powi(2))),
1245                6.1115,
1246                23.036,
1247                279.82,
1248                333.7,
1249            ),
1250        };
1251        ef * a * (((b - t_c / d) * t_c) / (t_c + c)).exp()
1252    }
1253
1254    fn surface_water_vapour_density_gm3(
1255        &self,
1256        lat_deg: f64,
1257        lon_deg: f64,
1258        p: f64,
1259        alt_km: f64,
1260    ) -> Result<f64, String> {
1261        self.interpolator_836_scalar(&self.rho_836_v6, lat_deg, lon_deg, p, Some(alt_km))
1262    }
1263
1264    fn total_water_vapour_content_kgm2(
1265        &self,
1266        lat_deg: f64,
1267        lon_deg: f64,
1268        p: f64,
1269        alt_km: f64,
1270    ) -> Result<f64, String> {
1271        self.interpolator_836_scalar(&self.v_836_v6, lat_deg, lon_deg, p, Some(alt_km))
1272    }
1273
1274    fn interpolator_836_scalar(
1275        &self,
1276        datasets: &[(f64, LazyRegularGrid2D)],
1277        lat_deg: f64,
1278        lon_deg: f64,
1279        p: f64,
1280        alt_km: Option<f64>,
1281    ) -> Result<f64, String> {
1282        let lon_mod = mod_360(lon_deg);
1283        let (p_below, p_above, p_exact) = percentile_bounds(&P836_LEVELS, p);
1284
1285        let r = ((90.0 - lat_deg) / 1.125).floor();
1286        let c = (lon_mod / 1.125).floor();
1287
1288        let lats = [
1289            90.0 - r * 1.125,
1290            90.0 - (r + 1.0) * 1.125,
1291            90.0 - r * 1.125,
1292            90.0 - (r + 1.0) * 1.125,
1293        ];
1294        let lons = [
1295            mod_360(c * 1.125),
1296            mod_360(c * 1.125),
1297            mod_360((c + 1.0) * 1.125),
1298            mod_360((c + 1.0) * 1.125),
1299        ];
1300
1301        let frac_r = (90.0 - lat_deg) / 1.125;
1302        let frac_c = lon_mod / 1.125;
1303
1304        let mut altitude_res = [0.0_f64; 4];
1305        for i in 0..4 {
1306            altitude_res[i] = self.topo_836_v6.interp(lats[i], lons[i])?;
1307        }
1308
1309        let alt = alt_km.unwrap_or(0.0);
1310        let use_alt_scalar = alt_km.is_some();
1311
1312        let data_a = self.adjust_836_and_blend(
1313            datasets,
1314            p_above,
1315            &lats,
1316            &lons,
1317            &altitude_res,
1318            alt,
1319            use_alt_scalar,
1320            frac_r,
1321            frac_c,
1322        )?;
1323        if p_exact {
1324            Ok(data_a)
1325        } else {
1326            let data_b = self.adjust_836_and_blend(
1327                datasets,
1328                p_below,
1329                &lats,
1330                &lons,
1331                &altitude_res,
1332                alt,
1333                use_alt_scalar,
1334                frac_r,
1335                frac_c,
1336            )?;
1337            Ok(
1338                data_b
1339                    + (data_a - data_b) * (p.ln() - p_below.ln()) / (p_above.ln() - p_below.ln()),
1340            )
1341        }
1342    }
1343
1344    #[allow(clippy::too_many_arguments)]
1345    fn adjust_836_and_blend(
1346        &self,
1347        datasets: &[(f64, LazyRegularGrid2D)],
1348        p: f64,
1349        lats: &[f64; 4],
1350        lons: &[f64; 4],
1351        altitude_res: &[f64; 4],
1352        alt_scalar: f64,
1353        use_alt_scalar: bool,
1354        r: f64,
1355        c: f64,
1356    ) -> Result<f64, String> {
1357        let data_grid = grid_for_p(datasets, p);
1358        let vsch_grid = grid_for_p(&self.vsch_836_v6, p);
1359
1360        let r_floor = r.floor();
1361        let c_floor = c.floor();
1362        let weights = [
1363            (r_floor + 1.0 - r) * (c_floor + 1.0 - c),
1364            (r - r_floor) * (c_floor + 1.0 - c),
1365            (r_floor + 1.0 - r) * (c - c_floor),
1366            (r - r_floor) * (c - c_floor),
1367        ];
1368
1369        let mut blended = 0.0;
1370        for i in 0..4 {
1371            let base = data_grid.interp(lats[i], lons[i])?;
1372            let vsch = vsch_grid.interp(lats[i], lons[i])?;
1373            let alt_here = if use_alt_scalar {
1374                alt_scalar
1375            } else {
1376                altitude_res[i]
1377            };
1378            let adjusted = base * (-(alt_here - altitude_res[i]) / vsch).exp();
1379            blended += adjusted * weights[i];
1380        }
1381        Ok(blended)
1382    }
1383
1384    fn rainfall_rate_r001_mmh(&self, lat_deg: f64, lon_deg: f64) -> Result<f64, String> {
1385        self.rainfall_r001_837_v7
1386            .interp(lat_deg, wrap_lon_180(lon_deg))
1387    }
1388
1389    fn monthly_rainfall_total_mm(
1390        &self,
1391        lat_deg: f64,
1392        lon_deg: f64,
1393        month: u8,
1394    ) -> Result<f64, String> {
1395        self.rainfall_mt_837_v7[usize::from(month - 1)].interp(lat_deg, wrap_lon_180(lon_deg))
1396    }
1397
1398    fn rainfall_probability_percent(&self, lat_deg: f64, lon_deg: f64) -> Result<f64, String> {
1399        let month_days = [
1400            31.0, 28.25, 31.0, 30.0, 31.0, 30.0, 31.0, 31.0, 30.0, 31.0, 30.0, 31.0,
1401        ];
1402        let mut weighted_probability = 0.0;
1403        for (idx, days) in month_days.iter().enumerate() {
1404            let month = (idx + 1) as u8;
1405            let temp_c = self.surface_month_mean_temperature_k(lat_deg, lon_deg, month)? - 273.15;
1406            let mt = self.monthly_rainfall_total_mm(lat_deg, lon_deg, month)?;
1407            let mut rain_rate = if temp_c >= 0.0 {
1408                0.5874 * (0.0883 * temp_c).exp()
1409            } else {
1410                0.5874
1411            };
1412            let mut probability = 100.0 * mt / (24.0 * days * rain_rate);
1413            if probability > 70.0 {
1414                rain_rate = 100.0 / 70.0 * mt / (24.0 * days);
1415                probability = 100.0 * mt / (24.0 * days * rain_rate);
1416            }
1417            weighted_probability += days * probability.min(70.0);
1418        }
1419        Ok(weighted_probability / 365.25)
1420    }
1421
1422    fn rainfall_rate_mmh(&self, lat_deg: f64, lon_deg: f64, p: f64) -> Result<f64, String> {
1423        if (p - 0.01).abs() < 1e-12 {
1424            return self.rainfall_rate_r001_mmh(lat_deg, lon_deg);
1425        }
1426
1427        let month_days = [
1428            31.0, 28.25, 31.0, 30.0, 31.0, 30.0, 31.0, 31.0, 30.0, 31.0, 30.0, 31.0,
1429        ];
1430        let mut rain_rates = [0.0_f64; 12];
1431        let mut probabilities = [0.0_f64; 12];
1432        let mut p0_annual = 0.0;
1433
1434        for (idx, days) in month_days.iter().enumerate() {
1435            let month = (idx + 1) as u8;
1436            let temp_c = self.surface_month_mean_temperature_k(lat_deg, lon_deg, month)? - 273.15;
1437            let mt = self.monthly_rainfall_total_mm(lat_deg, lon_deg, month)?;
1438            let mut rain_rate = if temp_c >= 0.0 {
1439                0.5874 * (0.0883 * temp_c).exp()
1440            } else {
1441                0.5874
1442            };
1443            let mut probability = 100.0 * mt / (24.0 * days * rain_rate);
1444            if probability > 70.0 {
1445                rain_rate = 100.0 / 70.0 * mt / (24.0 * days);
1446                probability = 70.0;
1447            }
1448            rain_rates[idx] = rain_rate;
1449            probabilities[idx] = probability;
1450            p0_annual += days * probability;
1451        }
1452        p0_annual /= 365.25;
1453        if p > p0_annual {
1454            return Ok(0.0);
1455        }
1456
1457        Ok(bisect_root(1e-10, 1000.0, 80, 1e-5, |r_ref| {
1458            let mut p_r_ge_r = 0.0;
1459            for idx in 0..12 {
1460                let z = (r_ref.ln() + 0.7938 - rain_rates[idx].ln()) / 1.26;
1461                let sf = 0.5 * erfc_approx(z / 2.0_f64.sqrt());
1462                p_r_ge_r += month_days[idx] * probabilities[idx] * sf;
1463            }
1464            p_r_ge_r /= 365.25;
1465            100.0 * (p_r_ge_r / p - 1.0)
1466        }))
1467    }
1468
1469    fn unavailability_from_rainfall_rate_percent(
1470        &self,
1471        lat_deg: f64,
1472        lon_deg: f64,
1473        rainfall_rate_mmh: f64,
1474    ) -> Result<f64, String> {
1475        if rainfall_rate_mmh <= 0.0 {
1476            return Ok(100.0);
1477        }
1478        let f = |p: f64| {
1479            self.rainfall_rate_mmh(lat_deg, lon_deg, p)
1480                .map(|rain| rain - rainfall_rate_mmh - 1e-6)
1481        };
1482        let f_low = f(1e-5);
1483        if f_low? < 0.0 {
1484            return Ok(1e-5);
1485        }
1486        let f_high = f(100.0);
1487        if f_high? > 0.0 {
1488            return Ok(100.0);
1489        }
1490        Ok(bisect_root(1e-5, 100.0, 50, 1e-8, |p| {
1491            f(p).unwrap_or(f64::NAN)
1492        }))
1493    }
1494
1495    fn zero_isotherm_height_km(&self, lat_deg: f64, lon_deg: f64) -> Result<f64, String> {
1496        self.zero_isotherm_839_v4.interp(lat_deg, mod_360(lon_deg))
1497    }
1498
1499    fn rain_height_km(&self, lat_deg: f64, lon_deg: f64) -> Result<f64, String> {
1500        Ok(self.zero_isotherm_height_km(lat_deg, lon_deg)? + 0.36)
1501    }
1502
1503    fn cloud_reduced_liquid_kgm2(&self, lat_deg: f64, lon_deg: f64, p: f64) -> Result<f64, String> {
1504        interpolate_grid_log_p(
1505            &self.cloud_lred_840_v9,
1506            &P840_LEVELS,
1507            lat_deg,
1508            mod_360(lon_deg),
1509            p,
1510        )
1511    }
1512
1513    fn map_wet_term_radio_refractivity(
1514        &self,
1515        lat_deg: f64,
1516        lon_deg: f64,
1517        p: f64,
1518    ) -> Result<f64, String> {
1519        interpolate_grid_log_p(
1520            &self.wet_refractivity_453_v13,
1521            &P453_LEVELS,
1522            lat_deg,
1523            wrap_lon_180(lon_deg),
1524            p,
1525        )
1526    }
1527
1528    fn dn65(&self, lat_deg: f64, lon_deg: f64, p: f64) -> Result<f64, String> {
1529        grid_for_p(&self.dn65_453_v13, p).interp(lat_deg, mod_360(lon_deg))
1530    }
1531
1532    fn dn1(&self, lat_deg: f64, lon_deg: f64, p: f64) -> Result<f64, String> {
1533        grid_for_p(&self.dn1_453_v13, p).interp(lat_deg, mod_360(lon_deg))
1534    }
1535
1536    fn inter_annual_variability(
1537        &self,
1538        p_fraction: f64,
1539        lat_deg: f64,
1540        lon_deg: f64,
1541    ) -> Result<f64, String> {
1542        let rc = self
1543            .climatic_ratio_678_v3
1544            .interp(lat_deg, mod_360(lon_deg))?;
1545        let b = -0.0396 * p_fraction.ln() + 0.286;
1546        let a = 0.0265_f64;
1547        let n = 525_960.0_f64;
1548        let dt = 60.0_f64;
1549        let max_i = (((30.0 / a).powf(1.0 / b) / dt).ceil() as usize + 1).min(525_959);
1550        let mut c_sum = 0.0;
1551        for i in 0..=max_i {
1552            let cu = (-a * ((i as f64) * dt).abs().powf(b)).exp();
1553            c_sum += if i == 0 { cu } else { 2.0 * cu };
1554        }
1555        let estimation_variance = p_fraction * (1.0 - p_fraction) * c_sum / n;
1556        let climatic_variance = (rc * p_fraction).powi(2);
1557        Ok(climatic_variance + estimation_variance)
1558    }
1559
1560    fn risk_of_exceedance(
1561        &self,
1562        p_fraction: f64,
1563        pr_fraction: f64,
1564        lat_deg: f64,
1565        lon_deg: f64,
1566    ) -> Result<f64, String> {
1567        if (pr_fraction - p_fraction).abs() < 1e-15 {
1568            return Ok(50.0);
1569        }
1570        let sigma = self
1571            .inter_annual_variability(p_fraction, lat_deg, lon_deg)?
1572            .sqrt();
1573        Ok(50.0 * erfc_approx((pr_fraction - p_fraction) / (sigma * 2.0_f64.sqrt())))
1574    }
1575
1576    fn gamma0_exact_v13(
1577        &self,
1578        freq_ghz: f64,
1579        pressure_hpa: f64,
1580        rho_gm3: f64,
1581        temp_k: f64,
1582    ) -> Result<f64, String> {
1583        let theta = 300.0 / temp_k;
1584        let e = rho_gm3 * temp_k / 216.7;
1585
1586        let mut n_pp = 0.0;
1587        for line in self.oxygen_lines_v13.get()? {
1588            let d_f = line.c3 * 1e-4 * (pressure_hpa * theta.powf(0.8 - line.c4) + 1.1 * e * theta);
1589            let d_f = (d_f * d_f + 2.25e-6).sqrt();
1590            let delta = (line.c5 + line.c6 * theta) * 1e-4 * (pressure_hpa + e) * theta.powf(0.8);
1591            let f_i = freq_ghz / line.f
1592                * ((d_f - delta * (line.f - freq_ghz)) / ((line.f - freq_ghz).powi(2) + d_f * d_f)
1593                    + (d_f - delta * (line.f + freq_ghz))
1594                        / ((line.f + freq_ghz).powi(2) + d_f * d_f));
1595            let s_i =
1596                line.c1 * 1e-7 * pressure_hpa * theta.powi(3) * (line.c2 * (1.0 - theta)).exp();
1597            n_pp += s_i * f_i;
1598        }
1599
1600        let d = 5.6e-4 * (pressure_hpa + e) * theta.powf(0.8);
1601        let n_d_pp = freq_ghz
1602            * pressure_hpa
1603            * theta.powi(2)
1604            * (6.14e-5 / (d * (1.0 + (freq_ghz / d).powi(2)))
1605                + 1.4e-12 * pressure_hpa * theta.powf(1.5) / (1.0 + 1.9e-5 * freq_ghz.powf(1.5)));
1606
1607        Ok(0.1820 * freq_ghz * (n_pp + n_d_pp))
1608    }
1609
1610    fn gammaw_exact_v13(
1611        &self,
1612        freq_ghz: f64,
1613        pressure_hpa: f64,
1614        rho_gm3: f64,
1615        temp_k: f64,
1616    ) -> Result<f64, String> {
1617        let theta = 300.0 / temp_k;
1618        let e = rho_gm3 * temp_k / 216.7;
1619
1620        let mut n_pp = 0.0;
1621        for line in self.water_lines_v13.get()? {
1622            let d_f = line.c3
1623                * 1e-4
1624                * (pressure_hpa * theta.powf(line.c4) + line.c5 * e * theta.powf(line.c6));
1625            let d_f =
1626                0.535 * d_f + (0.217 * d_f * d_f + 2.1316e-12 * line.f * line.f / theta).sqrt();
1627            let f_i = freq_ghz / line.f
1628                * (d_f / ((line.f - freq_ghz).powi(2) + d_f * d_f)
1629                    + d_f / ((line.f + freq_ghz).powi(2) + d_f * d_f));
1630            let s_i = line.c1 * 1e-1 * e * theta.powf(3.5) * (line.c2 * (1.0 - theta)).exp();
1631            n_pp += s_i * f_i;
1632        }
1633
1634        Ok(0.1820 * freq_ghz * n_pp)
1635    }
1636
1637    fn gamma_exact_v13(
1638        &self,
1639        freq_ghz: f64,
1640        pressure_hpa: f64,
1641        rho_gm3: f64,
1642        temp_k: f64,
1643    ) -> Result<f64, String> {
1644        Ok(
1645            self.gamma0_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?
1646                + self.gammaw_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?,
1647        )
1648    }
1649
1650    fn slant_inclined_path_equivalent_height_v13(
1651        &self,
1652        freq_ghz: f64,
1653        pressure_hpa: f64,
1654        rho_gm3: f64,
1655        temp_k: f64,
1656    ) -> Result<(f64, f64), String> {
1657        let e = rho_gm3 * temp_k / 216.7;
1658        let ps = pressure_hpa + e;
1659        let coeffs = self.h0_coeffs_v13.get()?;
1660        let a0 = interpolate_h0_coeff(coeffs, freq_ghz, |c| c.a0);
1661        let b0 = interpolate_h0_coeff(coeffs, freq_ghz, |c| c.b0);
1662        let c0 = interpolate_h0_coeff(coeffs, freq_ghz, |c| c.c0);
1663        let d0 = interpolate_h0_coeff(coeffs, freq_ghz, |c| c.d0);
1664        let h0 = a0 + b0 * temp_k + c0 * ps + d0 * rho_gm3;
1665
1666        let hw = HW_A_V13 * freq_ghz
1667            + HW_B_V13
1668            + HW_COEFFS_V13
1669                .iter()
1670                .map(|(fi, ai, bi)| ai / ((freq_ghz - fi).powi(2) + bi))
1671                .sum::<f64>();
1672        Ok((h0, hw))
1673    }
1674
1675    fn zenith_water_vapour_attenuation_db(
1676        &self,
1677        freq_ghz: f64,
1678        v_t_kgm2: f64,
1679        h_km: f64,
1680    ) -> Result<f64, String> {
1681        let f_ref = 20.6;
1682        let p_ref = 845.0;
1683        let rho_ref = v_t_kgm2 / 2.38;
1684        let t_ref_c = 14.0 * (0.22 * v_t_kgm2 / 2.38).ln() + 3.0;
1685
1686        let a = 0.2048 * (-((freq_ghz - 22.43) / 3.097).powi(2)).exp()
1687            + 0.2326 * (-((freq_ghz - 183.5) / 4.096).powi(2)).exp()
1688            + 0.2073 * (-((freq_ghz - 325.0) / 3.651).powi(2)).exp()
1689            - 0.1113;
1690        let b = 8.741e4 * (-0.587 * freq_ghz).exp() + 312.2 * freq_ghz.powf(-2.38) + 0.723;
1691        let h_clipped = h_km.clamp(0.0, 4.0);
1692
1693        let gamma_ratio = self.gammaw_exact_v13(freq_ghz, p_ref, rho_ref, t_ref_c + 273.15)?
1694            / self.gammaw_exact_v13(f_ref, p_ref, rho_ref, t_ref_c + 273.15)?;
1695        let aw_term1 = 0.0176 * v_t_kgm2 * gamma_ratio;
1696        if freq_ghz < 20.0 {
1697            Ok(aw_term1)
1698        } else {
1699            Ok(aw_term1 * (a * h_clipped.powf(b) + 1.0))
1700        }
1701    }
1702
1703    fn gaseous_attenuation_slant_path_v13(
1704        &self,
1705        freq_ghz: f64,
1706        elevation_deg: f64,
1707        rho_gm3: f64,
1708        pressure_hpa: f64,
1709        temp_k: f64,
1710        v_t_kgm2: f64,
1711        h_km: f64,
1712        exact: bool,
1713    ) -> Result<f64, String> {
1714        if !exact {
1715            let gamma0 = self.gamma0_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?;
1716            let gammaw = self.gammaw_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?;
1717            let (h0, hw) = self.slant_inclined_path_equivalent_height_v13(
1718                freq_ghz,
1719                pressure_hpa,
1720                rho_gm3,
1721                temp_k,
1722            )?;
1723            let aw = if v_t_kgm2.is_finite() && h_km.is_finite() {
1724                self.zenith_water_vapour_attenuation_db(freq_ghz, v_t_kgm2, h_km)?
1725            } else {
1726                gammaw * hw
1727            };
1728            let a0 = gamma0 * h0;
1729            return Ok((a0 + aw) / elevation_deg.to_radians().sin());
1730        }
1731
1732        let exp_step = (1.0_f64 / 100.0).exp();
1733        let denom = exp_step - 1.0;
1734
1735        let mut n_values = Vec::with_capacity(EXACT_GAS_LAYERS);
1736        let mut layer_data = Vec::with_capacity(EXACT_GAS_LAYERS);
1737        for idx in 0..EXACT_GAS_LAYERS {
1738            let k = idx as f64;
1739            let delta_h = 0.0001 * (k / 100.0).exp();
1740            let h_n = 0.0001 * (((k / 100.0).exp() - 1.0) / denom);
1741            let h_mid = h_n + delta_h / 2.0;
1742            let t_n = self.standard_temperature_k(h_mid);
1743            let press_n = self.standard_pressure_hpa(h_mid);
1744            let rho_n = self.standard_water_vapour_density_gm3(h_mid, rho_gm3);
1745            let e_n = rho_n * t_n / 216.7;
1746            let n_n = self.radio_refractive_index(press_n, e_n, t_n);
1747            n_values.push(n_n);
1748            layer_data.push((t_n, press_n, rho_n, delta_h, 6371.0 + h_n));
1749        }
1750
1751        let mut b = FRAC_PI_2 - elevation_deg.to_radians();
1752        let mut attenuation_db = 0.0;
1753        for idx in 0..EXACT_GAS_LAYERS {
1754            let (t_n, press_n, rho_n, delta_h, r_n) = layer_data[idx];
1755            let n_ratio = if idx + 1 < EXACT_GAS_LAYERS {
1756                n_values[idx] / n_values[idx + 1]
1757            } else {
1758                1.0
1759            };
1760
1761            let cos_b = b.cos();
1762            let a = -r_n * cos_b
1763                + 0.5
1764                    * (4.0 * r_n.powi(2) * cos_b.powi(2)
1765                        + 8.0 * r_n * delta_h
1766                        + 4.0 * delta_h.powi(2))
1767                    .sqrt();
1768            let alpha = (((r_n / (r_n + delta_h)) * b.sin()).clamp(-1.0, 1.0)).asin();
1769            let p_dry = press_n - rho_n * t_n / 216.7;
1770            let gamma = self.gamma_exact_v13(freq_ghz, p_dry, rho_n, t_n)?;
1771            attenuation_db += a * gamma;
1772            b = (alpha.sin() * n_ratio).clamp(-1.0, 1.0).asin();
1773        }
1774
1775        Ok(attenuation_db)
1776    }
1777
1778    fn gaseous_attenuation_terrestrial_path_db(
1779        &self,
1780        path_length_km: f64,
1781        freq_ghz: f64,
1782        rho_gm3: f64,
1783        pressure_hpa: f64,
1784        temp_k: f64,
1785        mode: GasPathMode,
1786    ) -> Result<f64, String> {
1787        let gamma = match mode {
1788            GasPathMode::Approximate => {
1789                self.gamma0_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?
1790                    + self.gammaw_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?
1791            }
1792            GasPathMode::Exact => self.gamma_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)?,
1793        };
1794        Ok(gamma * path_length_km)
1795    }
1796
1797    fn gaseous_attenuation_inclined_path_db(
1798        &self,
1799        freq_ghz: f64,
1800        elevation_deg: f64,
1801        rho_gm3: f64,
1802        pressure_hpa: f64,
1803        temp_k: f64,
1804        h1_km: f64,
1805        h2_km: f64,
1806        mode: GasPathMode,
1807    ) -> Result<f64, String> {
1808        let rho = match mode {
1809            GasPathMode::Approximate => rho_gm3 * (h1_km / 2.0).exp(),
1810            GasPathMode::Exact => rho_gm3,
1811        };
1812        let gamma0 = self.gamma0_exact_v13(freq_ghz, pressure_hpa, rho, temp_k)?;
1813        let gammaw = match mode {
1814            GasPathMode::Approximate => {
1815                self.gammaw_exact_v13(freq_ghz, pressure_hpa, rho, temp_k)?
1816            }
1817            GasPathMode::Exact => 0.0,
1818        };
1819
1820        let e = rho * temp_k / 216.7;
1821        let (h0, hw) = self.slant_inclined_path_equivalent_height_v13(
1822            freq_ghz,
1823            pressure_hpa + e,
1824            rho,
1825            temp_k,
1826        )?;
1827        let elevation_rad = elevation_deg.to_radians();
1828
1829        if elevation_deg > 5.0 && elevation_deg < 90.0 {
1830            let h0_p = h0 * ((-h1_km / h0).exp() - (-h2_km / h0).exp());
1831            let hw_p = hw * ((-h1_km / hw).exp() - (-h2_km / hw).exp());
1832            return Ok((gamma0 * h0_p + gammaw * hw_p) / elevation_rad.sin());
1833        }
1834
1835        fn f_factor(x: f64) -> f64 {
1836            1.0 / (0.661 * x + 0.339 * (x.powi(2) + 5.51).sqrt())
1837        }
1838
1839        let re_km = 8500.0;
1840        let el2 = (((re_km + h1_km) / (re_km + h2_km)) * elevation_rad.cos())
1841            .clamp(-1.0, 1.0)
1842            .acos()
1843            .to_degrees();
1844        let xi = |el_deg: f64, h_km: f64, height_km: f64| {
1845            el_deg.to_radians().tan() * ((re_km + h_km) / height_km).sqrt()
1846        };
1847        let eq_33 = |h_num: f64, h_den: f64, el_deg: f64, x: f64| {
1848            (re_km + h_num).sqrt() * f_factor(x) * (-h_num / h_den).exp()
1849                / el_deg.to_radians().cos()
1850        };
1851
1852        Ok(gamma0
1853            * h0.sqrt()
1854            * (eq_33(h1_km, h0, elevation_deg, xi(elevation_deg, h1_km, h0))
1855                - eq_33(h2_km, h0, el2, xi(el2, h2_km, h0)))
1856            + gammaw
1857                * hw.sqrt()
1858                * (eq_33(h1_km, hw, elevation_deg, xi(elevation_deg, h1_km, hw))
1859                    - eq_33(h2_km, hw, el2, xi(el2, h2_km, hw))))
1860    }
1861
1862    fn rain_specific_attenuation_coefficients(
1863        &self,
1864        freq_ghz: f64,
1865        elevation_deg: f64,
1866        tau_deg: f64,
1867    ) -> (f64, f64) {
1868        let kh_aj = [-5.33980, -0.35351, -0.23789, -0.94158];
1869        let kh_bj = [-0.10008, 1.2697, 0.86036, 0.64552];
1870        let kh_cj = [1.13098, 0.454, 0.15354, 0.16817];
1871        let kv_aj = [-3.80595, -3.44965, -0.39902, 0.50167];
1872        let kv_bj = [0.56934, -0.22911, 0.73042, 1.07319];
1873        let kv_cj = [0.81061, 0.51059, 0.11899, 0.27195];
1874        let ah_aj = [-0.14318, 0.29591, 0.32177, -5.37610, 16.1721];
1875        let ah_bj = [1.82442, 0.77564, 0.63773, -0.96230, -3.29980];
1876        let ah_cj = [-0.55187, 0.19822, 0.13164, 1.47828, 3.4399];
1877        let av_aj = [-0.07771, 0.56727, -0.20238, -48.2991, 48.5833];
1878        let av_bj = [2.3384, 0.95545, 1.1452, 0.791669, 0.791459];
1879        let av_cj = [-0.76284, 0.54039, 0.26809, 0.116226, 0.116479];
1880
1881        let curve = |f: f64, a: f64, b: f64, c: f64| a * (-((f.log10() - b) / c).powi(2)).exp();
1882
1883        let kh = 10_f64.powf(
1884            kh_aj
1885                .iter()
1886                .zip(kh_bj.iter())
1887                .zip(kh_cj.iter())
1888                .map(|((a, b), c)| curve(freq_ghz, *a, *b, *c))
1889                .sum::<f64>()
1890                + (-0.18961) * freq_ghz.log10()
1891                + 0.71147,
1892        );
1893        let kv = 10_f64.powf(
1894            kv_aj
1895                .iter()
1896                .zip(kv_bj.iter())
1897                .zip(kv_cj.iter())
1898                .map(|((a, b), c)| curve(freq_ghz, *a, *b, *c))
1899                .sum::<f64>()
1900                + (-0.16398) * freq_ghz.log10()
1901                + 0.63297,
1902        );
1903
1904        let alpha_h = ah_aj
1905            .iter()
1906            .zip(ah_bj.iter())
1907            .zip(ah_cj.iter())
1908            .map(|((a, b), c)| curve(freq_ghz, *a, *b, *c))
1909            .sum::<f64>()
1910            + 0.67849 * freq_ghz.log10()
1911            - 1.95537;
1912        let alpha_v = av_aj
1913            .iter()
1914            .zip(av_bj.iter())
1915            .zip(av_cj.iter())
1916            .map(|((a, b), c)| curve(freq_ghz, *a, *b, *c))
1917            .sum::<f64>()
1918            + (-0.053739) * freq_ghz.log10()
1919            + 0.83433;
1920
1921        let elevation_rad = elevation_deg.to_radians();
1922        let tau_rad = tau_deg.to_radians();
1923        let k = (kh + kv + (kh - kv) * elevation_rad.cos().powi(2) * (2.0 * tau_rad).cos()) / 2.0;
1924        let alpha = (kh * alpha_h
1925            + kv * alpha_v
1926            + (kh * alpha_h - kv * alpha_v) * elevation_rad.cos().powi(2) * (2.0 * tau_rad).cos())
1927            / (2.0 * k);
1928        (k, alpha)
1929    }
1930
1931    fn rain_specific_attenuation_db_per_km(
1932        &self,
1933        rainfall_rate_mmh: f64,
1934        freq_ghz: f64,
1935        elevation_deg: f64,
1936        tau_deg: f64,
1937    ) -> f64 {
1938        let (k, alpha) =
1939            self.rain_specific_attenuation_coefficients(freq_ghz, elevation_deg, tau_deg);
1940        k * rainfall_rate_mmh.powf(alpha)
1941    }
1942
1943    fn rain_attenuation_db(
1944        &self,
1945        lat_deg: f64,
1946        lon_deg: f64,
1947        freq_ghz: f64,
1948        elevation_deg: f64,
1949        hs_km: f64,
1950        p: f64,
1951        r001_mmh: Option<f64>,
1952        tau_deg: f64,
1953        l_s_km: Option<f64>,
1954    ) -> Result<f64, String> {
1955        let re_km = 8500.0;
1956        let hr_km = self.rain_height_km(lat_deg, lon_deg)?;
1957
1958        let elevation_rad = elevation_deg.to_radians();
1959        let l_s = if let Some(path_km) = l_s_km {
1960            path_km
1961        } else if elevation_deg >= 5.0 {
1962            (hr_km - hs_km) / elevation_rad.sin()
1963        } else {
1964            2.0 * (hr_km - hs_km)
1965                / ((elevation_rad.sin().powi(2) + 2.0 * (hr_km - hs_km) / re_km).sqrt()
1966                    + elevation_rad.sin())
1967        };
1968
1969        let l_g = (l_s * elevation_rad.cos()).abs();
1970        let r001 = r001_mmh.unwrap_or(self.rainfall_rate_r001_mmh(lat_deg, lon_deg)? + EPSILON);
1971        let gamma_r =
1972            self.rain_specific_attenuation_db_per_km(r001, freq_ghz, elevation_deg, tau_deg);
1973        let r001_factor = 1.0
1974            / (1.0 + 0.78 * (l_g * gamma_r / freq_ghz).sqrt() - 0.38 * (1.0 - (-2.0 * l_g).exp()));
1975
1976        let eta = (hr_km - hs_km).atan2(l_g * r001_factor).to_degrees();
1977        let delta_h = if hr_km - hs_km <= 0.0 {
1978            EPSILON
1979        } else {
1980            hr_km - hs_km
1981        };
1982        let l_r = if eta > elevation_deg {
1983            l_g * r001_factor / elevation_rad.cos()
1984        } else {
1985            delta_h / elevation_rad.sin()
1986        };
1987
1988        let xi = if lat_deg.abs() < 36.0 {
1989            36.0 - lat_deg.abs()
1990        } else {
1991            0.0
1992        };
1993        let v001 = 1.0
1994            / (1.0
1995                + elevation_rad.sin().sqrt()
1996                    * (31.0
1997                        * (1.0 - (-(elevation_deg / (1.0 + xi))).exp())
1998                        * (l_r * gamma_r).sqrt()
1999                        / freq_ghz.powi(2)
2000                        - 0.45));
2001
2002        let l_e = l_r * v001;
2003        let a001 = gamma_r * l_e;
2004
2005        let beta = if p >= 1.0 || lat_deg.abs() >= 36.0 {
2006            0.0
2007        } else if elevation_deg > 25.0 {
2008            -0.005 * (lat_deg.abs() - 36.0)
2009        } else {
2010            -0.005 * (lat_deg.abs() - 36.0) + 1.8 - 4.25 * elevation_rad.sin()
2011        };
2012
2013        Ok(a001
2014            * (p / 0.01).powf(
2015                -(0.655 + 0.033 * p.ln()
2016                    - 0.045 * a001.ln()
2017                    - beta * (1.0 - p) * elevation_rad.sin()),
2018            ))
2019    }
2020
2021    fn rain_attenuation_probability_percent(
2022        &self,
2023        lat_deg: f64,
2024        lon_deg: f64,
2025        elevation_deg: f64,
2026        hs_km: Option<f64>,
2027        l_s_km: Option<f64>,
2028        p0_percent: Option<f64>,
2029    ) -> Result<f64, String> {
2030        let re_km = 8500.0;
2031        let hs = match hs_km {
2032            Some(value) => value,
2033            None => self.topographic_altitude_km(lat_deg, lon_deg)?,
2034        };
2035        let p0 = p0_percent.unwrap_or(self.rainfall_probability_percent(lat_deg, lon_deg)?) / 100.0;
2036        if !(0.0..1.0).contains(&p0) {
2037            return Err("p0_percent must be in (0, 100)".to_string());
2038        }
2039
2040        let alpha = inverse_standard_normal_cdf(1.0 - p0);
2041        let hr = self.rain_height_km(lat_deg, lon_deg)?;
2042        let elevation_rad = elevation_deg.to_radians();
2043        let l_s = if let Some(path_km) = l_s_km {
2044            path_km
2045        } else if elevation_deg >= 5.0 {
2046            (hr - hs) / elevation_rad.sin()
2047        } else {
2048            2.0 * (hr - hs)
2049                / ((elevation_rad.sin().powi(2) + 2.0 * (hr - hs) / re_km).sqrt()
2050                    + elevation_rad.sin())
2051        };
2052        let d = l_s * elevation_rad.cos();
2053        let rho = 0.59 * (-d.abs() / 31.0).exp() + 0.41 * (-d.abs() / 800.0).exp();
2054        let c_b = bivariate_normal_ccdf(alpha, alpha, rho);
2055        let probability = 1.0 - (1.0 - p0) * ((c_b - p0.powi(2)) / (p0 * (1.0 - p0))).powf(p0);
2056        Ok(100.0 * probability)
2057    }
2058
2059    fn fit_rain_attenuation_to_lognormal(
2060        &self,
2061        lat_deg: f64,
2062        lon_deg: f64,
2063        freq_ghz: f64,
2064        elevation_deg: f64,
2065        hs_km: f64,
2066        p_k_percent: f64,
2067        tau_deg: f64,
2068    ) -> Result<(f64, f64), String> {
2069        let mut n = 0.0;
2070        let mut sum_x = 0.0;
2071        let mut sum_y = 0.0;
2072        let mut sum_xx = 0.0;
2073        let mut sum_xy = 0.0;
2074        for p_i in [
2075            0.01, 0.02, 0.03, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0,
2076        ] {
2077            if p_i >= p_k_percent {
2078                continue;
2079            }
2080            let attenuation = self.rain_attenuation_db(
2081                lat_deg,
2082                lon_deg,
2083                freq_ghz,
2084                elevation_deg,
2085                hs_km,
2086                p_i,
2087                None,
2088                tau_deg,
2089                None,
2090            )?;
2091            let x = inverse_standard_normal_cdf(1.0 - p_i / p_k_percent);
2092            let y = attenuation.ln();
2093            n += 1.0;
2094            sum_x += x;
2095            sum_y += y;
2096            sum_xx += x * x;
2097            sum_xy += x * y;
2098        }
2099        if n < 2.0 {
2100            return Err(
2101                "p_k_percent must be greater than at least two fit probabilities".to_string(),
2102            );
2103        }
2104        let denom = n * sum_xx - sum_x * sum_x;
2105        if denom.abs() < 1e-15 {
2106            return Err("lognormal fit is ill-conditioned".to_string());
2107        }
2108        let sigma = (n * sum_xy - sum_x * sum_y) / denom;
2109        let mean = (sum_y - sigma * sum_x) / n;
2110        Ok((sigma, mean))
2111    }
2112
2113    #[allow(clippy::too_many_arguments)]
2114    fn site_diversity_rain_outage_probability_percent(
2115        &self,
2116        lat1_deg: f64,
2117        lon1_deg: f64,
2118        a1_db: f64,
2119        elevation1_deg: f64,
2120        lat2_deg: f64,
2121        lon2_deg: f64,
2122        a2_db: f64,
2123        elevation2_deg: f64,
2124        freq_ghz: f64,
2125        tau_deg: f64,
2126        hs1_km: Option<f64>,
2127        hs2_km: Option<f64>,
2128    ) -> Result<f64, String> {
2129        let d = haversine_distance_km(lat1_deg, lon1_deg, lat2_deg, lon2_deg);
2130        let rho_r = 0.7 * (-d / 60.0).exp() + 0.3 * (-(d / 700.0).powi(2)).exp();
2131
2132        let p1 = self.rainfall_probability_percent(lat1_deg, lon1_deg)? / 100.0;
2133        let p2 = self.rainfall_probability_percent(lat2_deg, lon2_deg)? / 100.0;
2134        let r1 = inverse_standard_normal_cdf(1.0 - p1);
2135        let r2 = inverse_standard_normal_cdf(1.0 - p2);
2136        let p_r = bivariate_normal_ccdf(r1, r2, rho_r);
2137
2138        let hs1 = match hs1_km {
2139            Some(value) => value,
2140            None => self.topographic_altitude_km(lat1_deg, lon1_deg)?,
2141        };
2142        let hs2 = match hs2_km {
2143            Some(value) => value,
2144            None => self.topographic_altitude_km(lat2_deg, lon2_deg)?,
2145        };
2146        let (sigma1, mean1) = self.fit_rain_attenuation_to_lognormal(
2147            lat1_deg,
2148            lon1_deg,
2149            freq_ghz,
2150            elevation1_deg,
2151            hs1,
2152            p1 * 100.0,
2153            tau_deg,
2154        )?;
2155        let (sigma2, mean2) = self.fit_rain_attenuation_to_lognormal(
2156            lat2_deg,
2157            lon2_deg,
2158            freq_ghz,
2159            elevation2_deg,
2160            hs2,
2161            p2 * 100.0,
2162            tau_deg,
2163        )?;
2164
2165        let rho_a = 0.94 * (-d / 30.0).exp() + 0.06 * (-(d / 500.0).powi(2)).exp();
2166        let lim1 = (a1_db.ln() - mean1) / sigma1;
2167        let lim2 = (a2_db.ln() - mean2) / sigma2;
2168        let p_a = bivariate_normal_ccdf(lim1, lim2, rho_a);
2169        Ok(100.0 * p_r * p_a)
2170    }
2171
2172    fn rain_cross_polarization_discrimination_db(
2173        &self,
2174        attenuation_db: f64,
2175        freq_ghz: f64,
2176        elevation_deg: f64,
2177        p: f64,
2178        tau_deg: f64,
2179    ) -> f64 {
2180        let mut freq = freq_ghz;
2181        let freq_orig = freq;
2182        let scale_to_orig_freq = (4.0..6.0).contains(&freq);
2183        if scale_to_orig_freq {
2184            freq = 6.0;
2185        }
2186
2187        let c_f = if freq < 9.0 {
2188            60.0 * freq.log10() - 28.3
2189        } else if freq < 36.0 {
2190            26.0 * freq.log10() + 4.1
2191        } else {
2192            35.9 * freq.log10() - 11.3
2193        };
2194
2195        let v = if freq < 9.0 {
2196            30.8 * freq.powf(-0.21)
2197        } else if freq < 20.0 {
2198            12.8 * freq.powf(0.19)
2199        } else if freq < 40.0 {
2200            22.6
2201        } else {
2202            13.0 * freq.powf(0.15)
2203        };
2204        let c_a = v * attenuation_db.log10();
2205        let tau_term = 1.0 - 0.484 * (1.0 + (4.0 * tau_deg).to_radians().cos());
2206        let c_tau = -10.0 * tau_term.log10();
2207        let c_theta = -40.0 * elevation_deg.to_radians().cos().log10();
2208        let c_sigma = if p <= 0.001 {
2209            0.0053 * 15.0_f64.powi(2)
2210        } else if p <= 0.01 {
2211            0.0053 * 10.0_f64.powi(2)
2212        } else if p <= 0.1 {
2213            0.0053 * 5.0_f64.powi(2)
2214        } else {
2215            0.0
2216        };
2217        let xpd_rain = c_f - c_a + c_tau + c_theta + c_sigma;
2218        let c_ice = xpd_rain * (0.3 + 0.1 * p.log10()) / 2.0;
2219        let mut xpd = xpd_rain - c_ice;
2220        if scale_to_orig_freq {
2221            xpd -= 20.0 * (freq_orig / freq).log10();
2222        }
2223        xpd
2224    }
2225
2226    fn cloud_liquid_mass_absorption_coefficient(&self, freq_ghz: f64) -> f64 {
2227        let t_ref_c = 273.75 - 273.15;
2228        let kl = self.cloud_specific_attenuation_coefficients(freq_ghz, t_ref_c);
2229        let correction = 0.1522 * (-(freq_ghz + 23.9589).powi(2) / 3.2991e3).exp()
2230            + 11.51 * (-(freq_ghz - 219.2096).powi(2) / 2.7595e6).exp()
2231            - 10.4912;
2232        kl * correction
2233    }
2234
2235    fn cloud_specific_attenuation_coefficients(&self, freq_ghz: f64, t_c: f64) -> f64 {
2236        let t_kelvin = t_c + 273.15;
2237        let theta = 300.0 / t_kelvin;
2238        let epsilon0 = 77.66 + 103.3 * (theta - 1.0);
2239        let epsilon1 = 0.0671 * epsilon0;
2240        let epsilon2 = 3.52;
2241        let fp = 20.20 - 146.0 * (theta - 1.0) + 316.0 * (theta - 1.0).powi(2);
2242        let fs = 39.8 * fp;
2243        let epsilonp = (epsilon0 - epsilon1) / (1.0 + (freq_ghz / fp).powi(2))
2244            + (epsilon1 - epsilon2) / (1.0 + (freq_ghz / fs).powi(2))
2245            + epsilon2;
2246        let epsilonpp = freq_ghz * (epsilon0 - epsilon1) / (fp * (1.0 + (freq_ghz / fp).powi(2)))
2247            + freq_ghz * (epsilon1 - epsilon2) / (fs * (1.0 + (freq_ghz / fs).powi(2)));
2248        let eta = (2.0 + epsilonp) / epsilonpp;
2249        (0.819 * freq_ghz) / (epsilonpp * (1.0 + eta.powi(2)))
2250    }
2251
2252    fn cloud_attenuation_db(
2253        &self,
2254        lat_deg: f64,
2255        lon_deg: f64,
2256        elevation_deg: f64,
2257        freq_ghz: f64,
2258        p: f64,
2259        lred_kgm2: Option<f64>,
2260    ) -> Result<f64, String> {
2261        let kl = self.cloud_liquid_mass_absorption_coefficient(freq_ghz);
2262        let lred = match lred_kgm2 {
2263            Some(value) => value,
2264            None => self.cloud_reduced_liquid_kgm2(lat_deg, lon_deg, p)?,
2265        };
2266        Ok((lred * kl / elevation_deg.to_radians().sin()).max(0.0))
2267    }
2268
2269    fn lognormal_approximation_coefficients(
2270        &self,
2271        lat_deg: f64,
2272        lon_deg: f64,
2273    ) -> Result<(f64, f64, f64), String> {
2274        let lon = mod_360(lon_deg);
2275        Ok((
2276            self.cloud_lognormal_m_840_v9.interp(lat_deg, lon)?,
2277            self.cloud_lognormal_sigma_840_v9.interp(lat_deg, lon)?,
2278            self.cloud_lognormal_pclw_840_v9.interp(lat_deg, lon)?,
2279        ))
2280    }
2281
2282    fn cloud_attenuation_lognormal_db(
2283        &self,
2284        lat_deg: f64,
2285        lon_deg: f64,
2286        elevation_deg: f64,
2287        freq_ghz: f64,
2288        p: f64,
2289    ) -> Result<f64, String> {
2290        let kl = self.cloud_liquid_mass_absorption_coefficient(freq_ghz);
2291        let (m_l, sigma_l, p_l) = self.lognormal_approximation_coefficients(lat_deg, lon_deg)?;
2292        if p >= p_l || p_l <= 0.02 || m_l.is_nan() {
2293            return Ok(0.0);
2294        }
2295        let ratio = (p / p_l).clamp(1e-12, 1.0 - 1e-12);
2296        let q_inv = inverse_standard_normal_cdf(1.0 - ratio);
2297        let attenuation = kl * (m_l + sigma_l * q_inv).exp() / elevation_deg.to_radians().sin();
2298        if attenuation.is_finite() {
2299            Ok(attenuation.max(0.0))
2300        } else {
2301            Ok(0.0)
2302        }
2303    }
2304
2305    fn wet_term_radio_refractivity(&self, e_hpa: f64, t_c: f64) -> f64 {
2306        let t_k = t_c + 273.15;
2307        72.0 * e_hpa / t_k + 3.75e5 * e_hpa / t_k.powi(2)
2308    }
2309
2310    fn water_vapour_pressure_hpa(&self, t_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
2311        let e_s = self.saturation_vapour_pressure_hpa(t_c, pressure_hpa, HydrometeorType::Water);
2312        humidity_percent * e_s / 100.0
2313    }
2314
2315    fn scintillation_sigma_db(
2316        &self,
2317        lat_deg: f64,
2318        lon_deg: f64,
2319        freq_ghz: f64,
2320        elevation_deg: f64,
2321        dish_m: f64,
2322        eta: f64,
2323        temp_c: Option<f64>,
2324        humidity_percent: Option<f64>,
2325        pressure_hpa: Option<f64>,
2326        h_l_m: f64,
2327    ) -> Result<f64, String> {
2328        let n_wet =
2329            if let (Some(t_c), Some(h), Some(p_hpa)) = (temp_c, humidity_percent, pressure_hpa) {
2330                let e = self.water_vapour_pressure_hpa(t_c, p_hpa, h);
2331                self.wet_term_radio_refractivity(e, t_c)
2332            } else {
2333                self.map_wet_term_radio_refractivity(lat_deg, lon_deg, 50.0)?
2334            };
2335
2336        let sigma_ref = 3.6e-3 + 1e-4 * n_wet;
2337        let elevation_rad = elevation_deg.to_radians();
2338        let l =
2339            2.0 * h_l_m / ((elevation_rad.sin().powi(2) + 2.35e-4).sqrt() + elevation_rad.sin());
2340        let d_eff = eta.sqrt() * dish_m;
2341        let x = 1.22 * d_eff.powi(2) * freq_ghz / l;
2342        let g = if x >= 7.0 {
2343            0.0
2344        } else {
2345            (3.86 * (x.powi(2) + 1.0).powf(11.0 / 12.0) * ((11.0 / 6.0) * 1.0_f64.atan2(x)).sin()
2346                - 7.08 * x.powf(5.0 / 6.0))
2347            .sqrt()
2348        };
2349
2350        Ok(sigma_ref * freq_ghz.powf(7.0 / 12.0) * g / elevation_rad.sin().powf(1.2))
2351    }
2352
2353    fn scintillation_attenuation_db(
2354        &self,
2355        lat_deg: f64,
2356        lon_deg: f64,
2357        freq_ghz: f64,
2358        elevation_deg: f64,
2359        p: f64,
2360        dish_m: f64,
2361        eta: f64,
2362        temp_c: Option<f64>,
2363        humidity_percent: Option<f64>,
2364        pressure_hpa: Option<f64>,
2365        h_l_m: f64,
2366    ) -> Result<f64, String> {
2367        let sigma = self.scintillation_sigma_db(
2368            lat_deg,
2369            lon_deg,
2370            freq_ghz,
2371            elevation_deg,
2372            dish_m,
2373            eta,
2374            temp_c,
2375            humidity_percent,
2376            pressure_hpa,
2377            h_l_m,
2378        )?;
2379        let log_p = p.log10();
2380        let a = -0.061 * log_p.powi(3) + 0.072 * log_p.powi(2) - 1.71 * log_p + 3.0;
2381        Ok(a * sigma)
2382    }
2383
2384    fn atmospheric_attenuation(
2385        &self,
2386        lat_deg: f64,
2387        lon_deg: f64,
2388        freq_ghz: f64,
2389        elevation_deg: f64,
2390        p: f64,
2391        dish_m: f64,
2392        options: SlantPathOptions,
2393    ) -> Result<SlantPathContributions, String> {
2394        let hs_km = match options.hs_km {
2395            Some(value) => value,
2396            None => self.topographic_altitude_km(lat_deg, lon_deg)?,
2397        };
2398        let surface_temp_k = self.surface_mean_temperature_k(lat_deg, lon_deg)?;
2399        let gas_temp_k = options.t.unwrap_or(surface_temp_k);
2400        let pressure_hpa = options
2401            .pressure_hpa
2402            .unwrap_or_else(|| self.standard_pressure_hpa(hs_km));
2403        let p_c_g = p.max(1.0);
2404        let v_t_kgm2 = match options.v_t_kgm2 {
2405            Some(value) => value,
2406            None => self.total_water_vapour_content_kgm2(lat_deg, lon_deg, p_c_g, hs_km)?,
2407        };
2408        let rho_gm3 = match options.rho_gm3 {
2409            Some(value) => value,
2410            None => self.surface_water_vapour_density_gm3(lat_deg, lon_deg, p_c_g, hs_km)?,
2411        };
2412
2413        let rain_db = if options.include_rain {
2414            self.rain_attenuation_db(
2415                lat_deg,
2416                lon_deg,
2417                freq_ghz,
2418                elevation_deg,
2419                hs_km,
2420                p,
2421                options.r001_mmh,
2422                options.tau_deg,
2423                options.l_s_km,
2424            )?
2425        } else {
2426            0.0
2427        };
2428
2429        let gas_db = if options.include_gas {
2430            self.gaseous_attenuation_slant_path_v13(
2431                freq_ghz,
2432                elevation_deg,
2433                rho_gm3,
2434                pressure_hpa,
2435                gas_temp_k,
2436                v_t_kgm2,
2437                hs_km,
2438                options.exact,
2439            )?
2440        } else {
2441            0.0
2442        };
2443
2444        let cloud_db = if options.include_clouds {
2445            self.cloud_attenuation_db(lat_deg, lon_deg, elevation_deg, freq_ghz, p_c_g, None)?
2446        } else {
2447            0.0
2448        };
2449
2450        let scintillation_temp_c = if options.h_percent.is_some() {
2451            Some(options.t.unwrap_or(surface_temp_k - 273.15))
2452        } else {
2453            None
2454        };
2455        let scintillation_pressure_hpa = if options.h_percent.is_some() {
2456            Some(pressure_hpa)
2457        } else {
2458            None
2459        };
2460        let scintillation_db = if options.include_scintillation {
2461            self.scintillation_attenuation_db(
2462                lat_deg,
2463                lon_deg,
2464                freq_ghz,
2465                elevation_deg,
2466                p,
2467                dish_m,
2468                options.eta,
2469                scintillation_temp_c,
2470                options.h_percent,
2471                scintillation_pressure_hpa,
2472                options.h_l_m,
2473            )?
2474        } else {
2475            0.0
2476        };
2477
2478        let total_db = gas_db + ((rain_db + cloud_db).powi(2) + scintillation_db.powi(2)).sqrt();
2479        Ok(SlantPathContributions {
2480            gas_db,
2481            cloud_db,
2482            rain_db,
2483            scintillation_db,
2484            total_db,
2485        })
2486    }
2487
2488    fn atmospheric_attenuation_default_gas_only(
2489        &self,
2490        lat_deg: f64,
2491        lon_deg: f64,
2492        freq_ghz: f64,
2493        elevation_deg: f64,
2494        p: f64,
2495        dish_m: f64,
2496    ) -> Result<f64, String> {
2497        self.atmospheric_attenuation(
2498            lat_deg,
2499            lon_deg,
2500            freq_ghz,
2501            elevation_deg,
2502            p,
2503            dish_m,
2504            SlantPathOptions {
2505                hs_km: None,
2506                rho_gm3: None,
2507                r001_mmh: None,
2508                eta: 0.5,
2509                t: None,
2510                h_percent: None,
2511                pressure_hpa: None,
2512                h_l_m: 1000.0,
2513                l_s_km: None,
2514                tau_deg: 45.0,
2515                v_t_kgm2: None,
2516                exact: false,
2517                include_rain: false,
2518                include_gas: true,
2519                include_scintillation: false,
2520                include_clouds: false,
2521            },
2522        )
2523        .map(|contributions| contributions.total_db)
2524    }
2525}
2526
2527fn model() -> Result<&'static IturModel, String> {
2528    MODEL
2529        .get_or_init(IturModel::load)
2530        .as_ref()
2531        .map_err(|err| err.clone())
2532}
2533
2534fn data_root() -> PathBuf {
2535    std::env::var_os("ITU_RS_DATA_DIR")
2536        .map(PathBuf::from)
2537        .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"))
2538}
2539
2540struct DataBlob {
2541    label: String,
2542    bytes: Cow<'static, [u8]>,
2543}
2544
2545fn load_data(rel_path: &str) -> Result<DataBlob, String> {
2546    if let Some(root) = std::env::var_os("ITU_RS_DATA_DIR") {
2547        let full_path = PathBuf::from(root).join(rel_path);
2548        let bytes = std::fs::read(&full_path)
2549            .map_err(|err| format!("failed opening {}: {err}", full_path.display()))?;
2550        return Ok(DataBlob {
2551            label: full_path.display().to_string(),
2552            bytes: Cow::Owned(bytes),
2553        });
2554    }
2555
2556    let full_path = data_root().join(rel_path);
2557    match std::fs::read(&full_path) {
2558        Ok(bytes) => {
2559            return Ok(DataBlob {
2560                label: full_path.display().to_string(),
2561                bytes: Cow::Owned(bytes),
2562            });
2563        }
2564        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2565        Err(err) => return Err(format!("failed opening {}: {err}", full_path.display())),
2566    }
2567
2568    if let Some(bytes) = bundled_data::get(rel_path) {
2569        return Ok(DataBlob {
2570            label: format!("bundled data/{rel_path}"),
2571            bytes: Cow::Borrowed(bytes),
2572        });
2573    }
2574
2575    Err(format!(
2576        "failed locating data/{rel_path}; set ITU_RS_DATA_DIR to a python-itu-r itur/data directory or enable the itu-rs `data` feature"
2577    ))
2578}
2579
2580fn load_npz_array2(rel_path: &str) -> Result<Array2<f64>, String> {
2581    let data = load_data(rel_path)?;
2582    let mut npz = NpzReader::new(Cursor::new(data.bytes.as_ref()))
2583        .map_err(|err| format!("failed reading npz {}: {err}", data.label))?;
2584    npz.by_name("arr_0.npy")
2585        .map_err(|err| format!("failed loading arr_0.npy from {}: {err}", data.label))
2586}
2587
2588fn load_spectral_lines(rel_path: &str) -> Result<Vec<SpectralLine>, String> {
2589    let data = load_data(rel_path)?;
2590    let reader = BufReader::new(Cursor::new(data.bytes.as_ref()));
2591    let mut out = Vec::new();
2592    for (idx, line) in reader.lines().enumerate() {
2593        let line =
2594            line.map_err(|err| format!("failed reading {} line {}: {err}", data.label, idx + 1))?;
2595        if idx == 0 || line.trim().is_empty() {
2596            continue;
2597        }
2598        let cols: Vec<f64> = line
2599            .split(',')
2600            .map(|part| part.trim().parse::<f64>())
2601            .collect::<Result<Vec<_>, _>>()
2602            .map_err(|err| format!("failed parsing {} line {}: {err}", data.label, idx + 1))?;
2603        if cols.len() != 7 {
2604            return Err(format!(
2605                "unexpected column count in {} line {}",
2606                data.label,
2607                idx + 1
2608            ));
2609        }
2610        out.push(SpectralLine {
2611            f: cols[0],
2612            c1: cols[1],
2613            c2: cols[2],
2614            c3: cols[3],
2615            c4: cols[4],
2616            c5: cols[5],
2617            c6: cols[6],
2618        });
2619    }
2620    Ok(out)
2621}
2622
2623fn load_h0_coefficients(rel_path: &str) -> Result<Vec<H0Coefficients>, String> {
2624    let data = load_data(rel_path)?;
2625    let reader = BufReader::new(Cursor::new(data.bytes.as_ref()));
2626    let mut out = Vec::new();
2627    for (idx, line) in reader.lines().enumerate() {
2628        let line =
2629            line.map_err(|err| format!("failed reading {} line {}: {err}", data.label, idx + 1))?;
2630        if idx == 0 || line.trim().is_empty() {
2631            continue;
2632        }
2633        let cols: Vec<f64> = line
2634            .split(',')
2635            .map(|part| part.trim().parse::<f64>())
2636            .collect::<Result<Vec<_>, _>>()
2637            .map_err(|err| format!("failed parsing {} line {}: {err}", data.label, idx + 1))?;
2638        if cols.len() != 5 {
2639            return Err(format!(
2640                "unexpected column count in {} line {}",
2641                data.label,
2642                idx + 1
2643            ));
2644        }
2645        out.push(H0Coefficients {
2646            freq_ghz: cols[0],
2647            a0: cols[1],
2648            b0: cols[2],
2649            c0: cols[3],
2650            d0: cols[4],
2651        });
2652    }
2653    Ok(out)
2654}
2655
2656fn kernel(d: f64) -> f64 {
2657    let d = d.abs();
2658    if d <= 1.0 {
2659        1.5 * d.powi(3) - 2.5 * d.powi(2) + 1.0
2660    } else if d <= 2.0 {
2661        -0.5 * d.powi(3) + 2.5 * d.powi(2) - 4.0 * d + 2.0
2662    } else {
2663        0.0
2664    }
2665}
2666
2667fn bracket(axis: &[f64], x: f64) -> (usize, usize, f64) {
2668    debug_assert!(axis.len() >= 2);
2669    if x <= axis[0] {
2670        return (0, 1, 0.0);
2671    }
2672    if x >= axis[axis.len() - 1] {
2673        return (axis.len() - 2, axis.len() - 1, 1.0);
2674    }
2675
2676    let hi = searchsorted_right(axis, x);
2677    let lo = hi - 1;
2678    let frac = (x - axis[lo]) / (axis[hi] - axis[lo]);
2679    (lo, hi, frac)
2680}
2681
2682fn searchsorted_left(axis: &[f64], x: f64) -> usize {
2683    axis.partition_point(|value| *value < x)
2684}
2685
2686fn searchsorted_right(axis: &[f64], x: f64) -> usize {
2687    axis.partition_point(|value| *value <= x)
2688}
2689
2690fn nearest_axis_index(axis: &[f64], x: f64) -> usize {
2691    let hi = axis.partition_point(|value| *value < x);
2692    if hi == 0 {
2693        0
2694    } else if hi >= axis.len() {
2695        axis.len() - 1
2696    } else if (x - axis[hi - 1]).abs() <= (axis[hi] - x).abs() {
2697        hi - 1
2698    } else {
2699        hi
2700    }
2701}
2702
2703fn validate_grid_shapes(
2704    lat_grid: &Array2<f64>,
2705    lon_grid: &Array2<f64>,
2706    values: &Array2<f64>,
2707) -> Result<(), String> {
2708    if lat_grid.shape() != lon_grid.shape() || lat_grid.shape() != values.shape() {
2709        return Err("lat_grid, lon_grid, and values must have the same shape".to_string());
2710    }
2711    if lat_grid.nrows() < 2 || lat_grid.ncols() < 2 {
2712        return Err("grid must be at least 2x2".to_string());
2713    }
2714    if lat_grid
2715        .iter()
2716        .chain(lon_grid.iter())
2717        .chain(values.iter())
2718        .any(|value| !value.is_finite())
2719    {
2720        return Err("grid coordinates and values must be finite".to_string());
2721    }
2722    Ok(())
2723}
2724
2725fn is_regular_grid_inner(lat_grid: &Array2<f64>, lon_grid: &Array2<f64>) -> Result<bool, String> {
2726    if lat_grid.shape() != lon_grid.shape() {
2727        return Err("lat_grid and lon_grid must have the same shape".to_string());
2728    }
2729    if lat_grid.nrows() < 2 || lat_grid.ncols() < 2 {
2730        return Err("grid must be at least 2x2".to_string());
2731    }
2732    if lat_grid
2733        .iter()
2734        .chain(lon_grid.iter())
2735        .any(|value| !value.is_finite())
2736    {
2737        return Err("grid coordinates must be finite".to_string());
2738    }
2739
2740    let lon_step = lon_grid[[0, 1]] - lon_grid[[0, 0]];
2741    let lat_step = lat_grid[[1, 0]] - lat_grid[[0, 0]];
2742    if lon_step.abs() <= f64::EPSILON || lat_step.abs() <= f64::EPSILON {
2743        return Ok(false);
2744    }
2745
2746    let tol = 1e-5;
2747    for row in 0..lat_grid.nrows() {
2748        for col in 1..lat_grid.ncols() {
2749            if !approx_equal(
2750                lon_grid[[row, col]] - lon_grid[[row, col - 1]],
2751                lon_step,
2752                tol,
2753            ) {
2754                return Ok(false);
2755            }
2756        }
2757    }
2758    for row in 1..lat_grid.nrows() {
2759        for col in 0..lat_grid.ncols() {
2760            if !approx_equal(
2761                lat_grid[[row, col]] - lat_grid[[row - 1, col]],
2762                lat_step,
2763                tol,
2764            ) {
2765                return Ok(false);
2766            }
2767        }
2768    }
2769    Ok(true)
2770}
2771
2772fn approx_equal(actual: f64, expected: f64, rtol: f64) -> bool {
2773    (actual - expected).abs() <= rtol * expected.abs().max(1.0)
2774}
2775
2776fn mod_360(lon_deg: f64) -> f64 {
2777    lon_deg.rem_euclid(360.0)
2778}
2779
2780fn wrap_lon_180(lon_deg: f64) -> f64 {
2781    let lon_mod = mod_360(lon_deg);
2782    if lon_mod > 180.0 {
2783        lon_mod - 360.0
2784    } else {
2785        lon_mod
2786    }
2787}
2788
2789fn p836_suffix(p: f64) -> String {
2790    let mut s = p.to_string();
2791    s.retain(|ch| ch != '.');
2792    s
2793}
2794
2795fn p453_suffix(p: f64) -> String {
2796    let mut s = p.to_string();
2797    s.retain(|ch| ch != '.');
2798    s
2799}
2800
2801fn p453_dn_suffix(p: f64) -> String {
2802    let int_part = p.floor() as u32;
2803    let frac_part = ((p - f64::from(int_part)) * 100.0).round() as u32;
2804    format!("{int_part:02}d{frac_part:02}")
2805}
2806
2807fn p840_stem(p: f64) -> Result<&'static str, String> {
2808    match p {
2809        x if (x - 0.01).abs() < 1e-12 => Ok("001"),
2810        x if (x - 0.02).abs() < 1e-12 => Ok("002"),
2811        x if (x - 0.03).abs() < 1e-12 => Ok("003"),
2812        x if (x - 0.05).abs() < 1e-12 => Ok("005"),
2813        x if (x - 0.1).abs() < 1e-12 => Ok("01"),
2814        x if (x - 0.2).abs() < 1e-12 => Ok("02"),
2815        x if (x - 0.3).abs() < 1e-12 => Ok("03"),
2816        x if (x - 0.5).abs() < 1e-12 => Ok("05"),
2817        x if (x - 1.0).abs() < 1e-12 => Ok("1"),
2818        x if (x - 2.0).abs() < 1e-12 => Ok("2"),
2819        x if (x - 3.0).abs() < 1e-12 => Ok("3"),
2820        x if (x - 5.0).abs() < 1e-12 => Ok("5"),
2821        x if (x - 10.0).abs() < 1e-12 => Ok("10"),
2822        x if (x - 20.0).abs() < 1e-12 => Ok("20"),
2823        x if (x - 30.0).abs() < 1e-12 => Ok("30"),
2824        x if (x - 50.0).abs() < 1e-12 => Ok("50"),
2825        x if (x - 60.0).abs() < 1e-12 => Ok("60"),
2826        x if (x - 70.0).abs() < 1e-12 => Ok("70"),
2827        x if (x - 80.0).abs() < 1e-12 => Ok("80"),
2828        x if (x - 90.0).abs() < 1e-12 => Ok("90"),
2829        x if (x - 95.0).abs() < 1e-12 => Ok("95"),
2830        x if (x - 99.0).abs() < 1e-12 => Ok("99"),
2831        x if (x - 100.0).abs() < 1e-12 => Ok("100"),
2832        _ => Err(format!("unsupported P.840 percentile {p}")),
2833    }
2834}
2835
2836fn percentile_bounds(levels: &[f64], p: f64) -> (f64, f64, bool) {
2837    for level in levels {
2838        if (p - *level).abs() < 1e-12 {
2839            return (*level, *level, true);
2840        }
2841    }
2842
2843    let insertion = levels.partition_point(|level| *level < p);
2844    if insertion == 0 {
2845        (levels[0], levels[1], false)
2846    } else if insertion >= levels.len() {
2847        (levels[levels.len() - 2], levels[levels.len() - 1], false)
2848    } else {
2849        (levels[insertion - 1], levels[insertion], false)
2850    }
2851}
2852
2853fn grid_for_p(datasets: &[(f64, LazyRegularGrid2D)], p: f64) -> &LazyRegularGrid2D {
2854    datasets
2855        .iter()
2856        .find(|(level, _)| (*level - p).abs() < 1e-12)
2857        .map(|(_, grid)| grid)
2858        .expect("missing percentile dataset")
2859}
2860
2861fn interpolate_grid_log_p(
2862    datasets: &[(f64, LazyRegularGrid2D)],
2863    levels: &[f64],
2864    lat_deg: f64,
2865    lon_deg: f64,
2866    p: f64,
2867) -> Result<f64, String> {
2868    let (p_below, p_above, p_exact) = percentile_bounds(levels, p);
2869    let above = grid_for_p(datasets, p_above).interp(lat_deg, lon_deg)?;
2870    if p_exact {
2871        Ok(above)
2872    } else {
2873        let below = grid_for_p(datasets, p_below).interp(lat_deg, lon_deg)?;
2874        Ok(below + (above - below) * (p.ln() - p_below.ln()) / (p_above.ln() - p_below.ln()))
2875    }
2876}
2877
2878fn interpolate_h0_coeff(
2879    coeffs: &[H0Coefficients],
2880    freq_ghz: f64,
2881    map: impl Fn(&H0Coefficients) -> f64,
2882) -> f64 {
2883    if freq_ghz <= coeffs[0].freq_ghz {
2884        return map(&coeffs[0]);
2885    }
2886    if freq_ghz >= coeffs[coeffs.len() - 1].freq_ghz {
2887        return map(&coeffs[coeffs.len() - 1]);
2888    }
2889
2890    let hi = coeffs.partition_point(|entry| entry.freq_ghz < freq_ghz);
2891    let lo = hi - 1;
2892    let x0 = coeffs[lo].freq_ghz;
2893    let x1 = coeffs[hi].freq_ghz;
2894    let y0 = map(&coeffs[lo]);
2895    let y1 = map(&coeffs[hi]);
2896    y0 + (y1 - y0) * (freq_ghz - x0) / (x1 - x0)
2897}
2898
2899fn bisect_root(
2900    mut low: f64,
2901    mut high: f64,
2902    max_iter: usize,
2903    tol: f64,
2904    mut f: impl FnMut(f64) -> f64,
2905) -> f64 {
2906    let mut f_low = f(low);
2907    for _ in 0..max_iter {
2908        let mid = 0.5 * (low + high);
2909        let f_mid = f(mid);
2910        if f_mid.abs() <= tol || (high - low).abs() <= tol {
2911            return mid;
2912        }
2913        if f_low.signum() == f_mid.signum() {
2914            low = mid;
2915            f_low = f_mid;
2916        } else {
2917            high = mid;
2918        }
2919    }
2920    0.5 * (low + high)
2921}
2922
2923fn erfc_approx(x: f64) -> f64 {
2924    let z = x.abs();
2925    let t = 1.0 / (1.0 + 0.5 * z);
2926    let r = t
2927        * (-z * z - 1.265_512_23
2928            + t * (1.000_023_68
2929                + t * (0.374_091_96
2930                    + t * (0.096_784_18
2931                        + t * (-0.186_288_06
2932                            + t * (0.278_868_07
2933                                + t * (-1.135_203_98
2934                                    + t * (1.488_515_87
2935                                        + t * (-0.822_152_23 + t * 0.170_872_77)))))))))
2936            .exp();
2937    if x >= 0.0 { r } else { 2.0 - r }
2938}
2939
2940fn normal_ccdf(x: f64) -> f64 {
2941    0.5 * erfc_approx(x / 2.0_f64.sqrt())
2942}
2943
2944fn normal_pdf(x: f64) -> f64 {
2945    (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
2946}
2947
2948fn bivariate_normal_ccdf(alpha_x: f64, alpha_y: f64, rho: f64) -> f64 {
2949    let rho = rho.clamp(-0.999_999, 0.999_999);
2950    let sigma = (1.0 - rho * rho).sqrt();
2951    let lower = alpha_y.clamp(-10.0, 10.0);
2952    let upper = 10.0;
2953    if lower >= upper {
2954        return 0.0;
2955    }
2956
2957    let n = 4096;
2958    let h = (upper - lower) / n as f64;
2959    let integrand = |y: f64| normal_pdf(y) * normal_ccdf((alpha_x - rho * y) / sigma);
2960    let mut sum = integrand(lower) + integrand(upper);
2961    for idx in 1..n {
2962        let y = lower + h * idx as f64;
2963        sum += if idx % 2 == 0 {
2964            2.0 * integrand(y)
2965        } else {
2966            4.0 * integrand(y)
2967        };
2968    }
2969    (sum * h / 3.0).clamp(0.0, 1.0)
2970}
2971
2972fn inverse_standard_normal_cdf(p: f64) -> f64 {
2973    const A: [f64; 6] = [
2974        -3.969_683_028_665_376e1,
2975        2.209_460_984_245_205e2,
2976        -2.759_285_104_469_687e2,
2977        1.383_577_518_672_69e2,
2978        -3.066_479_806_614_716e1,
2979        2.506_628_277_459_239,
2980    ];
2981    const B: [f64; 5] = [
2982        -5.447_609_879_822_406e1,
2983        1.615_858_368_580_409e2,
2984        -1.556_989_798_598_866e2,
2985        6.680_131_188_771_972e1,
2986        -1.328_068_155_288_572e1,
2987    ];
2988    const C: [f64; 6] = [
2989        -7.784_894_002_430_293e-3,
2990        -3.223_964_580_411_365e-1,
2991        -2.400_758_277_161_838,
2992        -2.549_732_539_343_734,
2993        4.374_664_141_464_968,
2994        2.938_163_982_698_783,
2995    ];
2996    const D: [f64; 4] = [
2997        7.784_695_709_041_462e-3,
2998        3.224_671_290_700_398e-1,
2999        2.445_134_137_142_996,
3000        3.754_408_661_907_416,
3001    ];
3002
3003    let p = p.clamp(1e-12, 1.0 - 1e-12);
3004    let plow = 0.02425;
3005    let phigh = 1.0 - plow;
3006    if p < plow {
3007        let q = (-2.0 * p.ln()).sqrt();
3008        (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
3009            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
3010    } else if p <= phigh {
3011        let q = p - 0.5;
3012        let r = q * q;
3013        (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
3014            / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
3015    } else {
3016        let q = (-2.0 * (1.0 - p).ln()).sqrt();
3017        -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
3018            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
3019    }
3020}
3021
3022fn haversine_distance_km(lat1_deg: f64, lon1_deg: f64, lat2_deg: f64, lon2_deg: f64) -> f64 {
3023    let re_km = 6371.0;
3024    let lat1 = lat1_deg.to_radians();
3025    let lat2 = lat2_deg.to_radians();
3026    let dlat = lat2 - lat1;
3027    let dlon = (lon2_deg - lon1_deg).to_radians();
3028    let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
3029    2.0 * re_km * a.clamp(0.0, 1.0).sqrt().asin()
3030}
3031
3032fn validate_common_inputs(
3033    lat_deg: f64,
3034    lon_deg: f64,
3035    freq_ghz: f64,
3036    p: f64,
3037    dish_m: f64,
3038) -> Result<(), String> {
3039    if !lat_deg.is_finite()
3040        || !lon_deg.is_finite()
3041        || !freq_ghz.is_finite()
3042        || !p.is_finite()
3043        || !dish_m.is_finite()
3044    {
3045        return Err("all required inputs must be finite".to_string());
3046    }
3047    if !(-90.0..=90.0).contains(&lat_deg) {
3048        return Err("lat_deg must be in [-90, 90]".to_string());
3049    }
3050    if freq_ghz <= 0.0 {
3051        return Err("freq_ghz must be > 0".to_string());
3052    }
3053    if p <= 0.0 {
3054        return Err("p must be > 0".to_string());
3055    }
3056    if dish_m <= 0.0 {
3057        return Err("d_m must be > 0".to_string());
3058    }
3059    Ok(())
3060}
3061
3062fn validate_elevation_deg(elevation_deg: f64) -> Result<(), String> {
3063    if !elevation_deg.is_finite() {
3064        return Err("elevation_deg must be finite".to_string());
3065    }
3066    if elevation_deg <= 0.0 || elevation_deg >= 90.0 {
3067        return Err("elevation_deg must be in (0, 90)".to_string());
3068    }
3069    Ok(())
3070}
3071
3072fn validate_lat_lon(lat_deg: f64, lon_deg: f64) -> Result<(), String> {
3073    if !lat_deg.is_finite() || !lon_deg.is_finite() {
3074        return Err("lat_deg and lon_deg must be finite".to_string());
3075    }
3076    if !(-90.0..=90.0).contains(&lat_deg) {
3077        return Err("lat_deg must be in [-90, 90]".to_string());
3078    }
3079    Ok(())
3080}
3081
3082fn validate_positive(name: &str, value: f64) -> Result<(), String> {
3083    if !value.is_finite() {
3084        return Err(format!("{name} must be finite"));
3085    }
3086    if value <= 0.0 {
3087        return Err(format!("{name} must be > 0"));
3088    }
3089    Ok(())
3090}
3091
3092fn validate_nonnegative(name: &str, value: f64) -> Result<(), String> {
3093    if !value.is_finite() {
3094        return Err(format!("{name} must be finite"));
3095    }
3096    if value < 0.0 {
3097        return Err(format!("{name} must be >= 0"));
3098    }
3099    Ok(())
3100}
3101
3102fn validate_finite(name: &str, value: f64) -> Result<(), String> {
3103    if value.is_finite() {
3104        Ok(())
3105    } else {
3106        Err(format!("{name} must be finite"))
3107    }
3108}
3109
3110fn validate_p(p: f64) -> Result<(), String> {
3111    validate_positive("p", p)
3112}
3113
3114fn validate_supported_p(name: &str, p: f64, levels: &[f64]) -> Result<(), String> {
3115    validate_positive(name, p)?;
3116    if levels.iter().any(|level| (*level - p).abs() < 1e-12) {
3117        Ok(())
3118    } else {
3119        Err(format!("{name} is not available for this recommendation"))
3120    }
3121}
3122
3123fn validate_month(month: u8) -> Result<(), String> {
3124    if (1..=12).contains(&month) {
3125        Ok(())
3126    } else {
3127        Err("month must be in 1..=12".to_string())
3128    }
3129}
3130
3131fn validate_probability_fraction(name: &str, value: f64) -> Result<(), String> {
3132    if !value.is_finite() {
3133        return Err(format!("{name} must be finite"));
3134    }
3135    if !(0.0..=1.0).contains(&value) {
3136        return Err(format!("{name} must be in [0, 1]"));
3137    }
3138    Ok(())
3139}
3140
3141fn validate_p678_fraction(name: &str, value: f64) -> Result<(), String> {
3142    validate_probability_fraction(name, value)?;
3143    if !(0.0001..=0.02).contains(&value) {
3144        return Err(format!("{name} must be in [0.0001, 0.02]"));
3145    }
3146    Ok(())
3147}
3148
3149fn validate_tau_deg(tau_deg: f64) -> Result<(), String> {
3150    validate_finite("tau_deg", tau_deg)?;
3151    if !(0.0..=90.0).contains(&tau_deg) {
3152        return Err("tau_deg must be in [0, 90]".to_string());
3153    }
3154    Ok(())
3155}
3156
3157fn validate_optional_nonnegative(name: &str, value: Option<f64>) -> Result<(), String> {
3158    if let Some(value) = value {
3159        validate_nonnegative(name, value)?;
3160    }
3161    Ok(())
3162}
3163
3164fn validate_optional_positive(name: &str, value: Option<f64>) -> Result<(), String> {
3165    if let Some(value) = value {
3166        validate_positive(name, value)?;
3167    }
3168    Ok(())
3169}
3170
3171fn validate_options(options: SlantPathOptions) -> Result<(), String> {
3172    let optional_values = [
3173        options.hs_km,
3174        options.rho_gm3,
3175        options.r001_mmh,
3176        options.t,
3177        options.h_percent,
3178        options.pressure_hpa,
3179        options.l_s_km,
3180        options.v_t_kgm2,
3181    ];
3182    if optional_values
3183        .iter()
3184        .flatten()
3185        .any(|value| !value.is_finite())
3186    {
3187        return Err("optional numeric inputs must be finite when provided".to_string());
3188    }
3189    if !options.eta.is_finite() || !options.h_l_m.is_finite() || !options.tau_deg.is_finite() {
3190        return Err("eta, h_l_m, and tau_deg must be finite".to_string());
3191    }
3192    if options.eta <= 0.0 || options.eta > 1.0 {
3193        return Err("eta must be in (0, 1]".to_string());
3194    }
3195    if options.h_l_m <= 0.0 {
3196        return Err("h_l_m must be > 0".to_string());
3197    }
3198    if !(0.0..=90.0).contains(&options.tau_deg) {
3199        return Err("tau_deg must be in [0, 90]".to_string());
3200    }
3201    if let Some(rho_gm3) = options.rho_gm3
3202        && rho_gm3 < 0.0
3203    {
3204        return Err("rho_gm3 must be >= 0".to_string());
3205    }
3206    if let Some(r001_mmh) = options.r001_mmh
3207        && r001_mmh < 0.0
3208    {
3209        return Err("r001_mmh must be >= 0".to_string());
3210    }
3211    if let Some(t) = options.t
3212        && t <= 0.0
3213    {
3214        return Err("t must be > 0".to_string());
3215    }
3216    if let Some(h_percent) = options.h_percent
3217        && !(0.0..=100.0).contains(&h_percent)
3218    {
3219        return Err("h_percent must be in [0, 100]".to_string());
3220    }
3221    if let Some(pressure_hpa) = options.pressure_hpa
3222        && pressure_hpa <= 0.0
3223    {
3224        return Err("pressure_hpa must be > 0".to_string());
3225    }
3226    if let Some(l_s_km) = options.l_s_km
3227        && l_s_km <= 0.0
3228    {
3229        return Err("l_s_km must be > 0".to_string());
3230    }
3231    if let Some(v_t_kgm2) = options.v_t_kgm2
3232        && v_t_kgm2 < 0.0
3233    {
3234        return Err("v_t_kgm2 must be >= 0".to_string());
3235    }
3236    Ok(())
3237}
3238
3239#[allow(clippy::too_many_arguments)]
3240fn rust_itur_slant_path_scalar(
3241    lat_deg: f64,
3242    lon_deg: f64,
3243    freq_ghz: f64,
3244    elevation_deg: f64,
3245    p: f64,
3246    dish_m: f64,
3247    options: SlantPathOptions,
3248) -> Result<SlantPathContributions, String> {
3249    validate_common_inputs(lat_deg, lon_deg, freq_ghz, p, dish_m)?;
3250    validate_elevation_deg(elevation_deg)?;
3251    validate_options(options)?;
3252    model()?.atmospheric_attenuation(
3253        lat_deg,
3254        lon_deg,
3255        freq_ghz,
3256        elevation_deg,
3257        p,
3258        dish_m,
3259        options,
3260    )
3261}
3262
3263#[allow(dead_code)]
3264fn gas_attenuation_default_many_clamped(
3265    lat_deg: f64,
3266    lon_deg: f64,
3267    freq_ghz: f64,
3268    elevation_deg: &[f64],
3269    p: f64,
3270    dish_m: f64,
3271) -> Result<Vec<f64>, String> {
3272    validate_common_inputs(lat_deg, lon_deg, freq_ghz, p, dish_m)?;
3273    let model = model()?;
3274    let mut out = Vec::with_capacity(elevation_deg.len());
3275    for &el in elevation_deg {
3276        let el_query = el.clamp(0.01, 89.99);
3277        validate_elevation_deg(el_query)?;
3278        out.push(model.atmospheric_attenuation_default_gas_only(
3279            lat_deg, lon_deg, freq_ghz, el_query, p, dish_m,
3280        )?);
3281    }
3282    Ok(out)
3283}
3284
3285/// Looks up topographic altitude above mean sea level from ITU-R [P.1511](https://www.itu.int/rec/R-REC-P.1511).
3286///
3287/// This is the terrain height used when a slant-path calculation needs the
3288/// Earth station altitude and `SlantPathOptions::hs_km` is not supplied.
3289///
3290/// # Parameters
3291///
3292/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3293/// - `lon_deg`: station geodetic longitude in degrees.
3294///
3295/// # Returns
3296///
3297/// Terrain height in kilometres above mean sea level. The value is clamped to a
3298/// tiny positive floor internally so downstream propagation equations do not
3299/// see an exact zero height.
3300///
3301/// # Errors
3302///
3303/// Returns an error if coordinates are not finite, latitude is outside
3304/// `[-90, 90]`, or model data cannot be loaded.
3305///
3306/// # Example
3307///
3308/// ```
3309/// # fn data_available() -> bool {
3310/// #     cfg!(feature = "data")
3311/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3312/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3313/// #             .join("data/1511/v2_lat.npz")
3314/// #             .exists()
3315/// # }
3316/// # if data_available() {
3317/// let h_km = itu_rs::topographic_altitude_km(45.4215, -75.6972)?;
3318///
3319/// assert!(h_km.is_finite());
3320/// # }
3321/// # Ok::<(), Box<dyn std::error::Error>>(())
3322/// ```
3323pub fn topographic_altitude_km(lat_deg: f64, lon_deg: f64) -> std::result::Result<f64, ItuError> {
3324    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
3325    model()
3326        .map_err(ItuError::from)?
3327        .topographic_altitude_km(lat_deg, lon_deg)
3328        .map_err(ItuError::from)
3329}
3330
3331/// Looks up annual mean surface temperature from ITU-R [P.1510](https://www.itu.int/rec/R-REC-P.1510).
3332///
3333/// This provides the site temperature used by default slant-path gas
3334/// calculations when `SlantPathOptions::t` is not supplied.
3335///
3336/// # Parameters
3337///
3338/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3339/// - `lon_deg`: station geodetic longitude in degrees.
3340///
3341/// # Returns
3342///
3343/// Annual mean surface temperature in kelvin.
3344///
3345/// # Errors
3346///
3347/// Returns an error if coordinates are invalid or model data cannot be loaded.
3348///
3349/// # Example
3350///
3351/// ```
3352/// # fn data_available() -> bool {
3353/// #     cfg!(feature = "data")
3354/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3355/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3356/// #             .join("data/1510/v1_lat.npz")
3357/// #             .exists()
3358/// # }
3359/// # if data_available() {
3360/// let temp_k = itu_rs::surface_mean_temperature_k(45.4215, -75.6972)?;
3361///
3362/// assert!(temp_k > 0.0);
3363/// # }
3364/// # Ok::<(), Box<dyn std::error::Error>>(())
3365/// ```
3366pub fn surface_mean_temperature_k(
3367    lat_deg: f64,
3368    lon_deg: f64,
3369) -> std::result::Result<f64, ItuError> {
3370    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
3371    model()
3372        .map_err(ItuError::from)?
3373        .surface_mean_temperature_k(lat_deg, lon_deg)
3374        .map_err(ItuError::from)
3375}
3376
3377/// Looks up monthly mean surface temperature from ITU-R [P.1510](https://www.itu.int/rec/R-REC-P.1510).
3378///
3379/// This is the mean air temperature 2 m above the surface for a specific
3380/// calendar month. [P.837](https://www.itu.int/rec/R-REC-P.837) uses these monthly temperatures when deriving rain
3381/// occurrence statistics from monthly rainfall totals.
3382///
3383/// # Parameters
3384///
3385/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3386/// - `lon_deg`: station geodetic longitude in degrees.
3387/// - `month`: month number, where `1` is January and `12` is December.
3388///
3389/// # Returns
3390///
3391/// Monthly mean surface temperature in kelvin.
3392///
3393/// # Errors
3394///
3395/// Returns an error if coordinates are invalid, `month` is outside `1..=12`,
3396/// or model data cannot be loaded.
3397///
3398/// # Example
3399///
3400/// ```
3401/// # fn data_available() -> bool {
3402/// #     cfg!(feature = "data")
3403/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3404/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3405/// #             .join("data/1510/v1_t_month01.npz")
3406/// #             .exists()
3407/// # }
3408/// # if data_available() {
3409/// let temp_k = itu_rs::surface_month_mean_temperature_k(45.4215, -75.6972, 1)?;
3410///
3411/// assert!(temp_k > 0.0);
3412/// # }
3413/// # Ok::<(), Box<dyn std::error::Error>>(())
3414/// ```
3415pub fn surface_month_mean_temperature_k(
3416    lat_deg: f64,
3417    lon_deg: f64,
3418    month: u8,
3419) -> std::result::Result<f64, ItuError> {
3420    validate_lat_lon(lat_deg, lon_deg)
3421        .and_then(|_| validate_month(month))
3422        .map_err(ItuError::from)?;
3423    model()
3424        .map_err(ItuError::from)?
3425        .surface_month_mean_temperature_k(lat_deg, lon_deg, month)
3426        .map_err(ItuError::from)
3427}
3428
3429/// Computes standard-atmosphere temperature from ITU-R [P.835](https://www.itu.int/rec/R-REC-P.835).
3430///
3431/// The [P.835](https://www.itu.int/rec/R-REC-P.835) reference atmosphere supplies a temperature profile by geometric
3432/// height. This is useful when a site-specific temperature is not available or
3433/// when evaluating gas attenuation at a layer height.
3434///
3435/// # Parameters
3436///
3437/// - `h_km`: geometric height above mean sea level in kilometres. Must be
3438///   non-negative.
3439///
3440/// # Returns
3441///
3442/// Standard-atmosphere temperature in kelvin.
3443///
3444/// # Errors
3445///
3446/// Returns an error if `h_km` is not finite, is negative, or model data cannot
3447/// be loaded.
3448///
3449/// # Example
3450///
3451/// ```
3452/// # fn data_available() -> bool {
3453/// #     cfg!(feature = "data")
3454/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3455/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3456/// #             .join("data/1511/v2_lat.npz")
3457/// #             .exists()
3458/// # }
3459/// # if data_available() {
3460/// let temp_k = itu_rs::standard_temperature_k(1.0)?;
3461///
3462/// assert!(temp_k > 0.0);
3463/// # }
3464/// # Ok::<(), Box<dyn std::error::Error>>(())
3465/// ```
3466pub fn standard_temperature_k(h_km: f64) -> std::result::Result<f64, ItuError> {
3467    validate_nonnegative("h_km", h_km).map_err(ItuError::from)?;
3468    Ok(model()
3469        .map_err(ItuError::from)?
3470        .standard_temperature_k(h_km))
3471}
3472
3473/// Computes standard-atmosphere pressure from ITU-R [P.835](https://www.itu.int/rec/R-REC-P.835).
3474///
3475/// The pressure profile is used by the gas model when surface pressure is not
3476/// supplied explicitly.
3477///
3478/// # Parameters
3479///
3480/// - `h_km`: geometric height above mean sea level in kilometres. Must be
3481///   non-negative.
3482///
3483/// # Returns
3484///
3485/// Dry-air atmospheric pressure in hPa.
3486///
3487/// # Errors
3488///
3489/// Returns an error if `h_km` is not finite, is negative, or model data cannot
3490/// be loaded.
3491///
3492/// # Example
3493///
3494/// ```
3495/// # fn data_available() -> bool {
3496/// #     cfg!(feature = "data")
3497/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3498/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3499/// #             .join("data/1511/v2_lat.npz")
3500/// #             .exists()
3501/// # }
3502/// # if data_available() {
3503/// let pressure_hpa = itu_rs::standard_pressure_hpa(1.0)?;
3504///
3505/// assert!(pressure_hpa > 0.0);
3506/// # }
3507/// # Ok::<(), Box<dyn std::error::Error>>(())
3508/// ```
3509pub fn standard_pressure_hpa(h_km: f64) -> std::result::Result<f64, ItuError> {
3510    validate_nonnegative("h_km", h_km).map_err(ItuError::from)?;
3511    Ok(model().map_err(ItuError::from)?.standard_pressure_hpa(h_km))
3512}
3513
3514/// Computes standard water-vapour density from ITU-R [P.835](https://www.itu.int/rec/R-REC-P.835).
3515///
3516/// This applies the [P.835](https://www.itu.int/rec/R-REC-P.835) exponential decrease of water-vapour density with
3517/// height from a surface reference density.
3518///
3519/// # Parameters
3520///
3521/// - `h_km`: geometric height above mean sea level in kilometres. Must be
3522///   non-negative.
3523/// - `rho0_gm3`: surface water-vapour density in g/m^3. Must be non-negative.
3524///
3525/// # Returns
3526///
3527/// Water-vapour density at `h_km` in g/m^3.
3528///
3529/// # Errors
3530///
3531/// Returns an error if either input is not finite, if either input is negative,
3532/// or if model data cannot be loaded.
3533///
3534/// # Example
3535///
3536/// ```
3537/// # fn data_available() -> bool {
3538/// #     cfg!(feature = "data")
3539/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3540/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3541/// #             .join("data/1511/v2_lat.npz")
3542/// #             .exists()
3543/// # }
3544/// # if data_available() {
3545/// let rho_gm3 = itu_rs::standard_water_vapour_density_gm3(1.0, 7.5)?;
3546///
3547/// assert!(rho_gm3 >= 0.0);
3548/// # }
3549/// # Ok::<(), Box<dyn std::error::Error>>(())
3550/// ```
3551pub fn standard_water_vapour_density_gm3(
3552    h_km: f64,
3553    rho0_gm3: f64,
3554) -> std::result::Result<f64, ItuError> {
3555    validate_nonnegative("h_km", h_km)
3556        .and_then(|_| validate_nonnegative("rho0_gm3", rho0_gm3))
3557        .map_err(ItuError::from)?;
3558    Ok(model()
3559        .map_err(ItuError::from)?
3560        .standard_water_vapour_density_gm3(h_km, rho0_gm3))
3561}
3562
3563/// Looks up surface water-vapour density from ITU-R [P.836](https://www.itu.int/rec/R-REC-P.836).
3564///
3565/// Surface water-vapour density is near-ground absolute humidity. The slant-path
3566/// gas model uses this value when `SlantPathOptions::rho_gm3` is not supplied.
3567///
3568/// # Parameters
3569///
3570/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3571/// - `lon_deg`: station geodetic longitude in degrees.
3572/// - `p`: percentage of time exceeded, such as `1.0` for 1%. Must be positive.
3573/// - `alt_km`: station altitude in kilometres. Finite values are accepted so
3574///   sites below sea level can be represented.
3575///
3576/// # Returns
3577///
3578/// Surface water-vapour density in g/m^3.
3579///
3580/// # Errors
3581///
3582/// Returns an error if inputs fail validation or model data cannot be loaded.
3583///
3584/// # Example
3585///
3586/// ```
3587/// # fn data_available() -> bool {
3588/// #     cfg!(feature = "data")
3589/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3590/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3591/// #             .join("data/836/v6_lat.npz")
3592/// #             .exists()
3593/// # }
3594/// # if data_available() {
3595/// let rho_gm3 = itu_rs::surface_water_vapour_density_gm3(
3596///     45.4215, -75.6972, 1.0, 0.1,
3597/// )?;
3598///
3599/// assert!(rho_gm3.is_finite());
3600/// # }
3601/// # Ok::<(), Box<dyn std::error::Error>>(())
3602/// ```
3603pub fn surface_water_vapour_density_gm3(
3604    lat_deg: f64,
3605    lon_deg: f64,
3606    p: f64,
3607    alt_km: f64,
3608) -> std::result::Result<f64, ItuError> {
3609    validate_lat_lon(lat_deg, lon_deg)
3610        .and_then(|_| validate_p(p))
3611        .and_then(|_| validate_finite("alt_km", alt_km))
3612        .map_err(ItuError::from)?;
3613    model()
3614        .map_err(ItuError::from)?
3615        .surface_water_vapour_density_gm3(lat_deg, lon_deg, p, alt_km)
3616        .map_err(ItuError::from)
3617}
3618
3619/// Looks up total columnar water-vapour content from ITU-R [P.836](https://www.itu.int/rec/R-REC-P.836).
3620///
3621/// Total water vapour content is the vertically integrated water-vapour mass
3622/// above the site. The approximate [P.676](https://www.itu.int/rec/R-REC-P.676) gas path uses this when
3623/// `SlantPathOptions::v_t_kgm2` is not supplied.
3624///
3625/// # Parameters
3626///
3627/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3628/// - `lon_deg`: station geodetic longitude in degrees.
3629/// - `p`: percentage of time exceeded. Must be positive.
3630/// - `alt_km`: station altitude in kilometres.
3631///
3632/// # Returns
3633///
3634/// Total columnar water-vapour content in kg/m^2.
3635///
3636/// # Errors
3637///
3638/// Returns an error if inputs fail validation or model data cannot be loaded.
3639///
3640/// # Example
3641///
3642/// ```
3643/// # fn data_available() -> bool {
3644/// #     cfg!(feature = "data")
3645/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3646/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3647/// #             .join("data/836/v6_lat.npz")
3648/// #             .exists()
3649/// # }
3650/// # if data_available() {
3651/// let vapour_kgm2 = itu_rs::total_water_vapour_content_kgm2(
3652///     45.4215, -75.6972, 1.0, 0.1,
3653/// )?;
3654///
3655/// assert!(vapour_kgm2.is_finite());
3656/// # }
3657/// # Ok::<(), Box<dyn std::error::Error>>(())
3658/// ```
3659pub fn total_water_vapour_content_kgm2(
3660    lat_deg: f64,
3661    lon_deg: f64,
3662    p: f64,
3663    alt_km: f64,
3664) -> std::result::Result<f64, ItuError> {
3665    validate_lat_lon(lat_deg, lon_deg)
3666        .and_then(|_| validate_p(p))
3667        .and_then(|_| validate_finite("alt_km", alt_km))
3668        .map_err(ItuError::from)?;
3669    model()
3670        .map_err(ItuError::from)?
3671        .total_water_vapour_content_kgm2(lat_deg, lon_deg, p, alt_km)
3672        .map_err(ItuError::from)
3673}
3674
3675/// Looks up rainfall rate exceeded for 0.01% of an average year from ITU-R [P.837](https://www.itu.int/rec/R-REC-P.837).
3676///
3677/// This reference rain rate, usually written `R_0.01`, anchors the [P.618](https://www.itu.int/rec/R-REC-P.618) rain
3678/// fade calculation. Other time percentages are derived from it.
3679///
3680/// # Parameters
3681///
3682/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3683/// - `lon_deg`: station geodetic longitude in degrees.
3684///
3685/// # Returns
3686///
3687/// Rainfall rate in mm/h exceeded for 0.01% of an average year.
3688///
3689/// # Errors
3690///
3691/// Returns an error if coordinates are invalid or model data cannot be loaded.
3692///
3693/// # Example
3694///
3695/// ```
3696/// # fn data_available() -> bool {
3697/// #     cfg!(feature = "data")
3698/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3699/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3700/// #             .join("data/837/v7_lat_r001.npz")
3701/// #             .exists()
3702/// # }
3703/// # if data_available() {
3704/// let r001_mmh = itu_rs::rainfall_rate_r001_mmh(45.4215, -75.6972)?;
3705///
3706/// assert!(r001_mmh >= 0.0);
3707/// # }
3708/// # Ok::<(), Box<dyn std::error::Error>>(())
3709/// ```
3710pub fn rainfall_rate_r001_mmh(lat_deg: f64, lon_deg: f64) -> std::result::Result<f64, ItuError> {
3711    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
3712    model()
3713        .map_err(ItuError::from)?
3714        .rainfall_rate_r001_mmh(lat_deg, lon_deg)
3715        .map_err(ItuError::from)
3716}
3717
3718/// Computes annual rain probability from ITU-R [P.837](https://www.itu.int/rec/R-REC-P.837).
3719///
3720/// This is the percentage of an average year during which rain occurs at the
3721/// site. The model derives it from [P.1510](https://www.itu.int/rec/R-REC-P.1510) monthly surface temperature and
3722/// [P.837](https://www.itu.int/rec/R-REC-P.837) monthly rainfall-total maps.
3723///
3724/// # Parameters
3725///
3726/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3727/// - `lon_deg`: station geodetic longitude in degrees.
3728///
3729/// # Returns
3730///
3731/// Percentage probability of rain in an average year.
3732///
3733/// # Errors
3734///
3735/// Returns an error if coordinates are invalid or model data cannot be loaded.
3736///
3737/// # Example
3738///
3739/// ```
3740/// # fn data_available() -> bool {
3741/// #     cfg!(feature = "data")
3742/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3743/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3744/// #             .join("data/837/v7_mt_month01.npz")
3745/// #             .exists()
3746/// # }
3747/// # if data_available() {
3748/// let p0 = itu_rs::rainfall_probability_percent(45.4215, -75.6972)?;
3749///
3750/// assert!((0.0..=100.0).contains(&p0));
3751/// # }
3752/// # Ok::<(), Box<dyn std::error::Error>>(())
3753/// ```
3754pub fn rainfall_probability_percent(
3755    lat_deg: f64,
3756    lon_deg: f64,
3757) -> std::result::Result<f64, ItuError> {
3758    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
3759    model()
3760        .map_err(ItuError::from)?
3761        .rainfall_probability_percent(lat_deg, lon_deg)
3762        .map_err(ItuError::from)
3763}
3764
3765/// Computes rainfall rate exceeded for a requested time percentage from ITU-R [P.837](https://www.itu.int/rec/R-REC-P.837).
3766///
3767/// The returned rate `R_p` is the rain rate exceeded for `p` percent of an
3768/// average year. For `p = 0.01`, this is equivalent to
3769/// [`rainfall_rate_r001_mmh`].
3770///
3771/// # Parameters
3772///
3773/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3774/// - `lon_deg`: station geodetic longitude in degrees.
3775/// - `p`: percentage of time exceeded. Must be positive.
3776///
3777/// # Returns
3778///
3779/// Rainfall rate in mm/h.
3780///
3781/// # Errors
3782///
3783/// Returns an error if inputs fail validation or model data cannot be loaded.
3784///
3785/// # Example
3786///
3787/// ```
3788/// # fn data_available() -> bool {
3789/// #     cfg!(feature = "data")
3790/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3791/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3792/// #             .join("data/837/v7_mt_month01.npz")
3793/// #             .exists()
3794/// # }
3795/// # if data_available() {
3796/// let rain_mmh = itu_rs::rainfall_rate_mmh(45.4215, -75.6972, 0.1)?;
3797///
3798/// assert!(rain_mmh >= 0.0);
3799/// # }
3800/// # Ok::<(), Box<dyn std::error::Error>>(())
3801/// ```
3802pub fn rainfall_rate_mmh(lat_deg: f64, lon_deg: f64, p: f64) -> std::result::Result<f64, ItuError> {
3803    validate_lat_lon(lat_deg, lon_deg)
3804        .and_then(|_| validate_p(p))
3805        .map_err(ItuError::from)?;
3806    model()
3807        .map_err(ItuError::from)?
3808        .rainfall_rate_mmh(lat_deg, lon_deg, p)
3809        .map_err(ItuError::from)
3810}
3811
3812/// Inverts ITU-R [P.837](https://www.itu.int/rec/R-REC-P.837) rainfall-rate statistics.
3813///
3814/// This returns the percentage of an average year for which the supplied rain
3815/// rate is exceeded at the site.
3816///
3817/// # Parameters
3818///
3819/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3820/// - `lon_deg`: station geodetic longitude in degrees.
3821/// - `rainfall_rate_mmh`: rainfall rate in mm/h. Must be non-negative.
3822///
3823/// # Returns
3824///
3825/// Percentage of time exceeded.
3826///
3827/// # Errors
3828///
3829/// Returns an error if inputs fail validation or model data cannot be loaded.
3830///
3831/// # Example
3832///
3833/// ```
3834/// # fn data_available() -> bool {
3835/// #     cfg!(feature = "data")
3836/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3837/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3838/// #             .join("data/837/v7_mt_month01.npz")
3839/// #             .exists()
3840/// # }
3841/// # if data_available() {
3842/// let p = itu_rs::unavailability_from_rainfall_rate_percent(45.4215, -75.6972, 10.0)?;
3843///
3844/// assert!(p > 0.0);
3845/// # }
3846/// # Ok::<(), Box<dyn std::error::Error>>(())
3847/// ```
3848pub fn unavailability_from_rainfall_rate_percent(
3849    lat_deg: f64,
3850    lon_deg: f64,
3851    rainfall_rate_mmh: f64,
3852) -> std::result::Result<f64, ItuError> {
3853    validate_lat_lon(lat_deg, lon_deg)
3854        .and_then(|_| validate_nonnegative("rainfall_rate_mmh", rainfall_rate_mmh))
3855        .map_err(ItuError::from)?;
3856    model()
3857        .map_err(ItuError::from)?
3858        .unavailability_from_rainfall_rate_percent(lat_deg, lon_deg, rainfall_rate_mmh)
3859        .map_err(ItuError::from)
3860}
3861
3862/// Looks up zero-degree isotherm height from ITU-R [P.839](https://www.itu.int/rec/R-REC-P.839).
3863///
3864/// This is the mean altitude of the 0 degrees Celsius isotherm above mean sea
3865/// level. [P.839](https://www.itu.int/rec/R-REC-P.839) rain height is obtained by adding 0.36 km to this height.
3866///
3867/// # Parameters
3868///
3869/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3870/// - `lon_deg`: station geodetic longitude in degrees.
3871///
3872/// # Returns
3873///
3874/// Zero-degree isotherm height in kilometres above mean sea level.
3875///
3876/// # Errors
3877///
3878/// Returns an error if coordinates are invalid or model data cannot be loaded.
3879///
3880/// # Example
3881///
3882/// ```
3883/// # fn data_available() -> bool {
3884/// #     cfg!(feature = "data")
3885/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3886/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3887/// #             .join("data/839/v4_esa0height.npz")
3888/// #             .exists()
3889/// # }
3890/// # if data_available() {
3891/// let h0_km = itu_rs::zero_isotherm_height_km(45.4215, -75.6972)?;
3892///
3893/// assert!(h0_km.is_finite());
3894/// # }
3895/// # Ok::<(), Box<dyn std::error::Error>>(())
3896/// ```
3897pub fn zero_isotherm_height_km(lat_deg: f64, lon_deg: f64) -> std::result::Result<f64, ItuError> {
3898    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
3899    model()
3900        .map_err(ItuError::from)?
3901        .zero_isotherm_height_km(lat_deg, lon_deg)
3902        .map_err(ItuError::from)
3903}
3904
3905/// Looks up rain height from ITU-R [P.839](https://www.itu.int/rec/R-REC-P.839).
3906///
3907/// Rain height approximates the altitude above which rain is no longer present.
3908/// It is used with station height and elevation angle to determine the slant
3909/// path length through rain.
3910///
3911/// # Parameters
3912///
3913/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
3914/// - `lon_deg`: station geodetic longitude in degrees.
3915///
3916/// # Returns
3917///
3918/// Rain height in kilometres above mean sea level.
3919///
3920/// # Errors
3921///
3922/// Returns an error if coordinates are invalid or model data cannot be loaded.
3923///
3924/// # Example
3925///
3926/// ```
3927/// # fn data_available() -> bool {
3928/// #     cfg!(feature = "data")
3929/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3930/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3931/// #             .join("data/839/v4_esalat.npz")
3932/// #             .exists()
3933/// # }
3934/// # if data_available() {
3935/// let h_rain_km = itu_rs::rain_height_km(45.4215, -75.6972)?;
3936///
3937/// assert!(h_rain_km.is_finite());
3938/// # }
3939/// # Ok::<(), Box<dyn std::error::Error>>(())
3940/// ```
3941pub fn rain_height_km(lat_deg: f64, lon_deg: f64) -> std::result::Result<f64, ItuError> {
3942    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
3943    model()
3944        .map_err(ItuError::from)?
3945        .rain_height_km(lat_deg, lon_deg)
3946        .map_err(ItuError::from)
3947}
3948
3949/// Computes ITU-R [P.838](https://www.itu.int/rec/R-REC-P.838) rain specific attenuation coefficients.
3950///
3951/// The returned `(k, alpha)` coefficients convert rainfall rate `R` to specific
3952/// rain attenuation with `gamma_R = k * R^alpha`. Frequency, elevation, and
3953/// polarization affect drop-shape and polarization coupling in the model.
3954///
3955/// # Parameters
3956///
3957/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
3958/// - `elevation_deg`: path elevation angle above the horizon in degrees, in
3959///   `(0, 90)`.
3960/// - `tau_deg`: polarization tilt angle in degrees, in `[0, 90]`.
3961///
3962/// # Returns
3963///
3964/// `(k, alpha)` [P.838](https://www.itu.int/rec/R-REC-P.838) coefficients for use with rainfall rate in mm/h.
3965///
3966/// # Errors
3967///
3968/// Returns an error if inputs fail validation or model data cannot be loaded.
3969///
3970/// # Example
3971///
3972/// ```
3973/// # fn data_available() -> bool {
3974/// #     cfg!(feature = "data")
3975/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
3976/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3977/// #             .join("data/1511/v2_lat.npz")
3978/// #             .exists()
3979/// # }
3980/// # if data_available() {
3981/// let (k, alpha) = itu_rs::rain_specific_attenuation_coefficients(
3982///     12.0, 30.0, 45.0,
3983/// )?;
3984///
3985/// assert!(k.is_finite());
3986/// assert!(alpha.is_finite());
3987/// # }
3988/// # Ok::<(), Box<dyn std::error::Error>>(())
3989/// ```
3990pub fn rain_specific_attenuation_coefficients(
3991    freq_ghz: f64,
3992    elevation_deg: f64,
3993    tau_deg: f64,
3994) -> std::result::Result<(f64, f64), ItuError> {
3995    validate_positive("freq_ghz", freq_ghz)
3996        .and_then(|_| validate_elevation_deg(elevation_deg))
3997        .and_then(|_| validate_tau_deg(tau_deg))
3998        .map_err(ItuError::from)?;
3999    Ok(model()
4000        .map_err(ItuError::from)?
4001        .rain_specific_attenuation_coefficients(freq_ghz, elevation_deg, tau_deg))
4002}
4003
4004/// Computes ITU-R [P.838](https://www.itu.int/rec/R-REC-P.838) rain specific attenuation in dB/km.
4005///
4006/// This applies the [P.838](https://www.itu.int/rec/R-REC-P.838) relation `gamma_R = k * R^alpha` for a supplied
4007/// rainfall rate and path geometry.
4008///
4009/// # Parameters
4010///
4011/// - `rainfall_rate_mmh`: rainfall rate in mm/h. Must be non-negative.
4012/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4013/// - `elevation_deg`: path elevation angle above the horizon in degrees, in
4014///   `(0, 90)`.
4015/// - `tau_deg`: polarization tilt angle in degrees, in `[0, 90]`.
4016///
4017/// # Returns
4018///
4019/// Specific rain attenuation in dB/km.
4020///
4021/// # Errors
4022///
4023/// Returns an error if inputs fail validation or model data cannot be loaded.
4024///
4025/// # Example
4026///
4027/// ```
4028/// # fn data_available() -> bool {
4029/// #     cfg!(feature = "data")
4030/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4031/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4032/// #             .join("data/1511/v2_lat.npz")
4033/// #             .exists()
4034/// # }
4035/// # if data_available() {
4036/// let gamma_db_per_km = itu_rs::rain_specific_attenuation_db_per_km(
4037///     28.0, 12.0, 30.0, 45.0,
4038/// )?;
4039///
4040/// assert!(gamma_db_per_km >= 0.0);
4041/// # }
4042/// # Ok::<(), Box<dyn std::error::Error>>(())
4043/// ```
4044pub fn rain_specific_attenuation_db_per_km(
4045    rainfall_rate_mmh: f64,
4046    freq_ghz: f64,
4047    elevation_deg: f64,
4048    tau_deg: f64,
4049) -> std::result::Result<f64, ItuError> {
4050    validate_nonnegative("rainfall_rate_mmh", rainfall_rate_mmh)
4051        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
4052        .and_then(|_| validate_elevation_deg(elevation_deg))
4053        .and_then(|_| validate_tau_deg(tau_deg))
4054        .map_err(ItuError::from)?;
4055    Ok(model()
4056        .map_err(ItuError::from)?
4057        .rain_specific_attenuation_db_per_km(rainfall_rate_mmh, freq_ghz, elevation_deg, tau_deg))
4058}
4059
4060/// Looks up reduced cloud liquid water content from ITU-R [P.840](https://www.itu.int/rec/R-REC-P.840).
4061///
4062/// Reduced cloud liquid water content is the cloud liquid column used by the
4063/// [P.840](https://www.itu.int/rec/R-REC-P.840) cloud attenuation model for the requested time percentage.
4064///
4065/// # Parameters
4066///
4067/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4068/// - `lon_deg`: station geodetic longitude in degrees.
4069/// - `p`: percentage of time exceeded. Must be positive.
4070///
4071/// # Returns
4072///
4073/// Reduced cloud liquid water content in kg/m^2.
4074///
4075/// # Errors
4076///
4077/// Returns an error if inputs fail validation or model data cannot be loaded.
4078///
4079/// # Example
4080///
4081/// ```
4082/// # fn data_available() -> bool {
4083/// #     cfg!(feature = "data")
4084/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4085/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4086/// #             .join("data/840/v9_lat.npz")
4087/// #             .exists()
4088/// # }
4089/// # if data_available() {
4090/// let lred_kgm2 = itu_rs::cloud_reduced_liquid_kgm2(45.4215, -75.6972, 1.0)?;
4091///
4092/// assert!(lred_kgm2 >= 0.0);
4093/// # }
4094/// # Ok::<(), Box<dyn std::error::Error>>(())
4095/// ```
4096pub fn cloud_reduced_liquid_kgm2(
4097    lat_deg: f64,
4098    lon_deg: f64,
4099    p: f64,
4100) -> std::result::Result<f64, ItuError> {
4101    validate_lat_lon(lat_deg, lon_deg)
4102        .and_then(|_| validate_p(p))
4103        .map_err(ItuError::from)?;
4104    model()
4105        .map_err(ItuError::from)?
4106        .cloud_reduced_liquid_kgm2(lat_deg, lon_deg, p)
4107        .map_err(ItuError::from)
4108}
4109
4110/// Computes the [P.840](https://www.itu.int/rec/R-REC-P.840) cloud liquid-water mass absorption coefficient.
4111///
4112/// The coefficient describes how efficiently cloud liquid water absorbs radio
4113/// waves at the supplied frequency before local temperature is applied by the
4114/// full specific attenuation coefficient.
4115///
4116/// # Parameters
4117///
4118/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4119///
4120/// # Returns
4121///
4122/// Liquid-water mass absorption coefficient used by [P.840](https://www.itu.int/rec/R-REC-P.840).
4123///
4124/// # Errors
4125///
4126/// Returns an error if `freq_ghz` is not finite, is not positive, or model data
4127/// cannot be loaded.
4128///
4129/// # Example
4130///
4131/// ```
4132/// # fn data_available() -> bool {
4133/// #     cfg!(feature = "data")
4134/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4135/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4136/// #             .join("data/1511/v2_lat.npz")
4137/// #             .exists()
4138/// # }
4139/// # if data_available() {
4140/// let coeff = itu_rs::cloud_liquid_mass_absorption_coefficient(12.0)?;
4141///
4142/// assert!(coeff.is_finite());
4143/// # }
4144/// # Ok::<(), Box<dyn std::error::Error>>(())
4145/// ```
4146pub fn cloud_liquid_mass_absorption_coefficient(
4147    freq_ghz: f64,
4148) -> std::result::Result<f64, ItuError> {
4149    validate_positive("freq_ghz", freq_ghz).map_err(ItuError::from)?;
4150    Ok(model()
4151        .map_err(ItuError::from)?
4152        .cloud_liquid_mass_absorption_coefficient(freq_ghz))
4153}
4154
4155/// Computes the [P.840](https://www.itu.int/rec/R-REC-P.840) cloud specific attenuation coefficient.
4156///
4157/// This coefficient converts reduced liquid water content into cloud attenuation
4158/// for a given radio frequency and cloud temperature.
4159///
4160/// # Parameters
4161///
4162/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4163/// - `temp_c`: cloud temperature in degrees Celsius. Must be above absolute
4164///   zero.
4165///
4166/// # Returns
4167///
4168/// Cloud specific attenuation coefficient in dB/km per g/m^3 as defined by
4169/// [P.840](https://www.itu.int/rec/R-REC-P.840) for the implemented model.
4170///
4171/// # Errors
4172///
4173/// Returns an error if inputs fail validation or model data cannot be loaded.
4174///
4175/// # Example
4176///
4177/// ```
4178/// # fn data_available() -> bool {
4179/// #     cfg!(feature = "data")
4180/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4181/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4182/// #             .join("data/1511/v2_lat.npz")
4183/// #             .exists()
4184/// # }
4185/// # if data_available() {
4186/// let coeff = itu_rs::cloud_specific_attenuation_coefficient(12.0, 0.0)?;
4187///
4188/// assert!(coeff.is_finite());
4189/// # }
4190/// # Ok::<(), Box<dyn std::error::Error>>(())
4191/// ```
4192pub fn cloud_specific_attenuation_coefficient(
4193    freq_ghz: f64,
4194    temp_c: f64,
4195) -> std::result::Result<f64, ItuError> {
4196    validate_positive("freq_ghz", freq_ghz)
4197        .and_then(|_| validate_finite("temp_c", temp_c))
4198        .and_then(|_| {
4199            if temp_c <= -273.15 {
4200                Err("temp_c must be > -273.15".to_string())
4201            } else {
4202                Ok(())
4203            }
4204        })
4205        .map_err(ItuError::from)?;
4206    Ok(model()
4207        .map_err(ItuError::from)?
4208        .cloud_specific_attenuation_coefficients(freq_ghz, temp_c))
4209}
4210
4211/// Computes cloud attenuation from ITU-R [P.840](https://www.itu.int/rec/R-REC-P.840).
4212///
4213/// Cloud attenuation is the path attenuation caused by liquid water in clouds
4214/// along an Earth-space slant path. If `lred_kgm2` is not supplied, the [P.840](https://www.itu.int/rec/R-REC-P.840)
4215/// map is used for the requested site and time percentage.
4216///
4217/// # Parameters
4218///
4219/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4220/// - `lon_deg`: station geodetic longitude in degrees.
4221/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
4222/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4223/// - `p`: percentage of time exceeded. Must be positive.
4224/// - `lred_kgm2`: optional reduced cloud liquid water content in kg/m^2. When
4225///   `None`, it is looked up from [P.840](https://www.itu.int/rec/R-REC-P.840).
4226///
4227/// # Returns
4228///
4229/// Cloud attenuation in dB.
4230///
4231/// # Errors
4232///
4233/// Returns an error if inputs fail validation or model data cannot be loaded.
4234///
4235/// # Example
4236///
4237/// ```
4238/// # fn data_available() -> bool {
4239/// #     cfg!(feature = "data")
4240/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4241/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4242/// #             .join("data/840/v9_lat.npz")
4243/// #             .exists()
4244/// # }
4245/// # if data_available() {
4246/// let cloud_db = itu_rs::cloud_attenuation_db(
4247///     45.4215, -75.6972, 30.0, 12.0, 1.0, None,
4248/// )?;
4249///
4250/// assert!(cloud_db >= 0.0);
4251/// # }
4252/// # Ok::<(), Box<dyn std::error::Error>>(())
4253/// ```
4254#[allow(clippy::too_many_arguments)]
4255pub fn cloud_attenuation_db(
4256    lat_deg: f64,
4257    lon_deg: f64,
4258    elevation_deg: f64,
4259    freq_ghz: f64,
4260    p: f64,
4261    lred_kgm2: Option<f64>,
4262) -> std::result::Result<f64, ItuError> {
4263    validate_lat_lon(lat_deg, lon_deg)
4264        .and_then(|_| validate_elevation_deg(elevation_deg))
4265        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
4266        .and_then(|_| validate_p(p))
4267        .and_then(|_| validate_optional_nonnegative("lred_kgm2", lred_kgm2))
4268        .map_err(ItuError::from)?;
4269    model()
4270        .map_err(ItuError::from)?
4271        .cloud_attenuation_db(lat_deg, lon_deg, elevation_deg, freq_ghz, p, lred_kgm2)
4272        .map_err(ItuError::from)
4273}
4274
4275/// Looks up [P.840](https://www.itu.int/rec/R-REC-P.840) lognormal cloud liquid-water distribution coefficients.
4276///
4277/// These coefficients approximate the annual statistics of reduced cloud
4278/// liquid water content with a lognormal distribution.
4279///
4280/// # Parameters
4281///
4282/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4283/// - `lon_deg`: station geodetic longitude in degrees.
4284///
4285/// # Returns
4286///
4287/// `(m, sigma, pclw_percent)`, where `m` and `sigma` are the lognormal mean and
4288/// standard deviation and `pclw_percent` is the percentage probability of
4289/// non-zero cloud liquid water.
4290///
4291/// # Errors
4292///
4293/// Returns an error if coordinates are invalid or model data cannot be loaded.
4294///
4295/// # Example
4296///
4297/// ```
4298/// # fn data_available() -> bool {
4299/// #     cfg!(feature = "data")
4300/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4301/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4302/// #             .join("data/840/v9_ml.npz")
4303/// #             .exists()
4304/// # }
4305/// # if data_available() {
4306/// let (_m, sigma, pclw) = itu_rs::lognormal_approximation_coefficients(
4307///     45.4215, -75.6972,
4308/// )?;
4309///
4310/// assert!(sigma.is_finite());
4311/// assert!(pclw >= 0.0);
4312/// # }
4313/// # Ok::<(), Box<dyn std::error::Error>>(())
4314/// ```
4315pub fn lognormal_approximation_coefficients(
4316    lat_deg: f64,
4317    lon_deg: f64,
4318) -> std::result::Result<(f64, f64, f64), ItuError> {
4319    validate_lat_lon(lat_deg, lon_deg).map_err(ItuError::from)?;
4320    model()
4321        .map_err(ItuError::from)?
4322        .lognormal_approximation_coefficients(lat_deg, lon_deg)
4323        .map_err(ItuError::from)
4324}
4325
4326/// Computes cloud attenuation with the [P.840](https://www.itu.int/rec/R-REC-P.840) lognormal approximation.
4327///
4328/// This uses the mapped lognormal liquid-water coefficients instead of direct
4329/// reduced liquid-water percentile maps. It returns zero when the requested
4330/// exceedance percentage is outside the non-zero cloud-water probability at the
4331/// site.
4332///
4333/// # Parameters
4334///
4335/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4336/// - `lon_deg`: station geodetic longitude in degrees.
4337/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
4338/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4339/// - `p`: percentage of time exceeded. Must be positive.
4340///
4341/// # Returns
4342///
4343/// Cloud attenuation in dB.
4344///
4345/// # Errors
4346///
4347/// Returns an error if inputs fail validation or model data cannot be loaded.
4348///
4349/// # Example
4350///
4351/// ```
4352/// # fn data_available() -> bool {
4353/// #     cfg!(feature = "data")
4354/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4355/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4356/// #             .join("data/840/v9_ml.npz")
4357/// #             .exists()
4358/// # }
4359/// # if data_available() {
4360/// let attenuation_db = itu_rs::cloud_attenuation_lognormal_db(
4361///     45.4215, -75.6972, 30.0, 12.0, 1.0,
4362/// )?;
4363///
4364/// assert!(attenuation_db >= 0.0);
4365/// # }
4366/// # Ok::<(), Box<dyn std::error::Error>>(())
4367/// ```
4368pub fn cloud_attenuation_lognormal_db(
4369    lat_deg: f64,
4370    lon_deg: f64,
4371    elevation_deg: f64,
4372    freq_ghz: f64,
4373    p: f64,
4374) -> std::result::Result<f64, ItuError> {
4375    validate_lat_lon(lat_deg, lon_deg)
4376        .and_then(|_| validate_elevation_deg(elevation_deg))
4377        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
4378        .and_then(|_| validate_p(p))
4379        .map_err(ItuError::from)?;
4380    model()
4381        .map_err(ItuError::from)?
4382        .cloud_attenuation_lognormal_db(lat_deg, lon_deg, elevation_deg, freq_ghz, p)
4383        .map_err(ItuError::from)
4384}
4385
4386/// Computes wet-term radio refractivity from ITU-R [P.453](https://www.itu.int/rec/R-REC-P.453).
4387///
4388/// Wet-term refractivity is the water-vapour part of atmospheric radio
4389/// refractivity. It is used by scintillation calculations and by atmospheric
4390/// refractive-index estimates.
4391///
4392/// # Parameters
4393///
4394/// - `e_hpa`: water-vapour partial pressure in hPa. Must be non-negative.
4395/// - `temp_c`: air temperature in degrees Celsius. Must be above absolute
4396///   zero.
4397///
4398/// # Returns
4399///
4400/// Wet-term radio refractivity in N-units.
4401///
4402/// # Errors
4403///
4404/// Returns an error if inputs fail validation or model data cannot be loaded.
4405///
4406/// # Example
4407///
4408/// ```
4409/// # fn data_available() -> bool {
4410/// #     cfg!(feature = "data")
4411/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4412/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4413/// #             .join("data/1511/v2_lat.npz")
4414/// #             .exists()
4415/// # }
4416/// # if data_available() {
4417/// let n_wet = itu_rs::wet_term_radio_refractivity(12.0, 15.0)?;
4418///
4419/// assert!(n_wet >= 0.0);
4420/// # }
4421/// # Ok::<(), Box<dyn std::error::Error>>(())
4422/// ```
4423pub fn wet_term_radio_refractivity(e_hpa: f64, temp_c: f64) -> std::result::Result<f64, ItuError> {
4424    validate_nonnegative("e_hpa", e_hpa)
4425        .and_then(|_| validate_finite("temp_c", temp_c))
4426        .and_then(|_| {
4427            if temp_c <= -273.15 {
4428                Err("temp_c must be > -273.15".to_string())
4429            } else {
4430                Ok(())
4431            }
4432        })
4433        .map_err(ItuError::from)?;
4434    Ok(model()
4435        .map_err(ItuError::from)?
4436        .wet_term_radio_refractivity(e_hpa, temp_c))
4437}
4438
4439/// Computes dry-term radio refractivity from ITU-R [P.453](https://www.itu.int/rec/R-REC-P.453).
4440///
4441/// Dry-term refractivity is the dry-air pressure contribution to atmospheric
4442/// radio refractivity.
4443///
4444/// # Parameters
4445///
4446/// - `pd_hpa`: dry-air partial pressure in hPa. Must be non-negative.
4447/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
4448///
4449/// # Returns
4450///
4451/// Dry-term radio refractivity in N-units.
4452///
4453/// # Errors
4454///
4455/// Returns an error if inputs fail validation or model data cannot be loaded.
4456///
4457/// # Example
4458///
4459/// ```
4460/// let n_dry = itu_rs::dry_term_radio_refractivity(1000.0, 288.15)?;
4461///
4462/// assert!(n_dry > 0.0);
4463/// # Ok::<(), Box<dyn std::error::Error>>(())
4464/// ```
4465pub fn dry_term_radio_refractivity(pd_hpa: f64, temp_k: f64) -> std::result::Result<f64, ItuError> {
4466    validate_nonnegative("pd_hpa", pd_hpa)
4467        .and_then(|_| validate_positive("temp_k", temp_k))
4468        .map_err(ItuError::from)?;
4469    Ok(model()
4470        .map_err(ItuError::from)?
4471        .dry_term_radio_refractivity(pd_hpa, temp_k))
4472}
4473
4474/// Computes radio refractive index from ITU-R [P.453](https://www.itu.int/rec/R-REC-P.453).
4475///
4476/// This converts dry-air pressure, water-vapour pressure, and temperature into
4477/// the dimensionless radio refractive index `n`. The value is close to `1.0`
4478/// in the lower atmosphere.
4479///
4480/// # Parameters
4481///
4482/// - `pd_hpa`: dry-air partial pressure in hPa. Must be non-negative.
4483/// - `e_hpa`: water-vapour partial pressure in hPa. Must be non-negative.
4484/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
4485///
4486/// # Returns
4487///
4488/// Dimensionless radio refractive index.
4489///
4490/// # Errors
4491///
4492/// Returns an error if inputs fail validation or model data cannot be loaded.
4493///
4494/// # Example
4495///
4496/// ```
4497/// # fn data_available() -> bool {
4498/// #     cfg!(feature = "data")
4499/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4500/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4501/// #             .join("data/1511/v2_lat.npz")
4502/// #             .exists()
4503/// # }
4504/// # if data_available() {
4505/// let n = itu_rs::radio_refractive_index(1000.0, 12.0, 288.15)?;
4506///
4507/// assert!(n > 1.0);
4508/// # }
4509/// # Ok::<(), Box<dyn std::error::Error>>(())
4510/// ```
4511pub fn radio_refractive_index(
4512    pd_hpa: f64,
4513    e_hpa: f64,
4514    temp_k: f64,
4515) -> std::result::Result<f64, ItuError> {
4516    validate_nonnegative("pd_hpa", pd_hpa)
4517        .and_then(|_| validate_nonnegative("e_hpa", e_hpa))
4518        .and_then(|_| validate_positive("temp_k", temp_k))
4519        .map_err(ItuError::from)?;
4520    Ok(model()
4521        .map_err(ItuError::from)?
4522        .radio_refractive_index(pd_hpa, e_hpa, temp_k))
4523}
4524
4525/// Computes water-vapour pressure from ITU-R [P.453](https://www.itu.int/rec/R-REC-P.453).
4526///
4527/// This converts air temperature, total pressure, and relative humidity into
4528/// water-vapour partial pressure. It is useful when supplying local
4529/// scintillation meteorology instead of using map-derived wet refractivity.
4530///
4531/// # Parameters
4532///
4533/// - `temp_c`: air temperature in degrees Celsius. Must be above absolute
4534///   zero.
4535/// - `pressure_hpa`: total atmospheric pressure in hPa. Must be positive.
4536/// - `humidity_percent`: relative humidity in percent, in `[0, 100]`.
4537///
4538/// # Returns
4539///
4540/// Water-vapour partial pressure in hPa.
4541///
4542/// # Errors
4543///
4544/// Returns an error if inputs fail validation or model data cannot be loaded.
4545///
4546/// # Example
4547///
4548/// ```
4549/// # fn data_available() -> bool {
4550/// #     cfg!(feature = "data")
4551/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4552/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4553/// #             .join("data/1511/v2_lat.npz")
4554/// #             .exists()
4555/// # }
4556/// # if data_available() {
4557/// let e_hpa = itu_rs::water_vapour_pressure_hpa(15.0, 1000.0, 60.0)?;
4558///
4559/// assert!(e_hpa >= 0.0);
4560/// # }
4561/// # Ok::<(), Box<dyn std::error::Error>>(())
4562/// ```
4563pub fn water_vapour_pressure_hpa(
4564    temp_c: f64,
4565    pressure_hpa: f64,
4566    humidity_percent: f64,
4567) -> std::result::Result<f64, ItuError> {
4568    validate_finite("temp_c", temp_c)
4569        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
4570        .and_then(|_| validate_finite("humidity_percent", humidity_percent))
4571        .and_then(|_| {
4572            if temp_c <= -273.15 {
4573                Err("temp_c must be > -273.15".to_string())
4574            } else if !(0.0..=100.0).contains(&humidity_percent) {
4575                Err("humidity_percent must be in [0, 100]".to_string())
4576            } else {
4577                Ok(())
4578            }
4579        })
4580        .map_err(ItuError::from)?;
4581    Ok(model().map_err(ItuError::from)?.water_vapour_pressure_hpa(
4582        temp_c,
4583        pressure_hpa,
4584        humidity_percent,
4585    ))
4586}
4587
4588/// Computes saturation vapour pressure from ITU-R [P.453](https://www.itu.int/rec/R-REC-P.453).
4589///
4590/// Saturation vapour pressure is the water-vapour partial pressure at
4591/// saturation for a given air temperature, total pressure, and hydrometeor
4592/// phase. [`water_vapour_pressure_hpa`] multiplies this value by relative
4593/// humidity.
4594///
4595/// # Parameters
4596///
4597/// - `temp_c`: air temperature in degrees Celsius. Must be above absolute zero.
4598/// - `pressure_hpa`: total atmospheric pressure in hPa. Must be positive.
4599/// - `hydrometeor`: saturation over liquid water or ice.
4600///
4601/// # Returns
4602///
4603/// Saturation vapour pressure in hPa.
4604///
4605/// # Errors
4606///
4607/// Returns an error if inputs fail validation or model data cannot be loaded.
4608///
4609/// # Example
4610///
4611/// ```
4612/// let e_s = itu_rs::saturation_vapour_pressure_hpa(
4613///     15.0,
4614///     1000.0,
4615///     itu_rs::HydrometeorType::Water,
4616/// )?;
4617///
4618/// assert!(e_s > 0.0);
4619/// # Ok::<(), Box<dyn std::error::Error>>(())
4620/// ```
4621pub fn saturation_vapour_pressure_hpa(
4622    temp_c: f64,
4623    pressure_hpa: f64,
4624    hydrometeor: HydrometeorType,
4625) -> std::result::Result<f64, ItuError> {
4626    validate_finite("temp_c", temp_c)
4627        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
4628        .and_then(|_| {
4629            if temp_c <= -273.15 {
4630                Err("temp_c must be > -273.15".to_string())
4631            } else {
4632                Ok(())
4633            }
4634        })
4635        .map_err(ItuError::from)?;
4636    Ok(model()
4637        .map_err(ItuError::from)?
4638        .saturation_vapour_pressure_hpa(temp_c, pressure_hpa, hydrometeor))
4639}
4640
4641/// Looks up wet-term radio refractivity maps from ITU-R [P.453](https://www.itu.int/rec/R-REC-P.453).
4642///
4643/// This map lookup provides wet-term radio refractivity exceeded for a requested
4644/// time percentage. Scintillation calculations use it when local humidity,
4645/// temperature, and pressure are not supplied.
4646///
4647/// # Parameters
4648///
4649/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4650/// - `lon_deg`: station geodetic longitude in degrees.
4651/// - `p`: percentage of time exceeded. Must be positive.
4652///
4653/// # Returns
4654///
4655/// Wet-term radio refractivity in N-units.
4656///
4657/// # Errors
4658///
4659/// Returns an error if inputs fail validation or model data cannot be loaded.
4660///
4661/// # Example
4662///
4663/// ```
4664/// # fn data_available() -> bool {
4665/// #     cfg!(feature = "data")
4666/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4667/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4668/// #             .join("data/453/v13_lat_n.npz")
4669/// #             .exists()
4670/// # }
4671/// # if data_available() {
4672/// let n_wet = itu_rs::map_wet_term_radio_refractivity(45.4215, -75.6972, 50.0)?;
4673///
4674/// assert!(n_wet.is_finite());
4675/// # }
4676/// # Ok::<(), Box<dyn std::error::Error>>(())
4677/// ```
4678pub fn map_wet_term_radio_refractivity(
4679    lat_deg: f64,
4680    lon_deg: f64,
4681    p: f64,
4682) -> std::result::Result<f64, ItuError> {
4683    validate_lat_lon(lat_deg, lon_deg)
4684        .and_then(|_| validate_p(p))
4685        .map_err(ItuError::from)?;
4686    model()
4687        .map_err(ItuError::from)?
4688        .map_wet_term_radio_refractivity(lat_deg, lon_deg, p)
4689        .map_err(ItuError::from)
4690}
4691
4692/// Looks up the [P.453](https://www.itu.int/rec/R-REC-P.453) refractivity gradient exceeded in the lowest 65 m.
4693///
4694/// `DN65` describes the vertical gradient of radio refractivity near the
4695/// surface. It is used by terrestrial propagation models that depend on
4696/// low-altitude refractivity structure.
4697///
4698/// # Parameters
4699///
4700/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4701/// - `lon_deg`: station geodetic longitude in degrees.
4702/// - `p`: available percentage of time exceeded from [P.453](https://www.itu.int/rec/R-REC-P.453).
4703///
4704/// # Returns
4705///
4706/// Refractivity gradient exceeded for `p` percent of an average year.
4707///
4708/// # Errors
4709///
4710/// Returns an error if inputs fail validation, `p` is not one of the available
4711/// [P.453](https://www.itu.int/rec/R-REC-P.453) DN map percentages, or model data cannot be loaded.
4712///
4713/// # Example
4714///
4715/// ```
4716/// # fn data_available() -> bool {
4717/// #     cfg!(feature = "data")
4718/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4719/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4720/// #             .join("data/453/v12_dn65m_50d00_v1.npz")
4721/// #             .exists()
4722/// # }
4723/// # if data_available() {
4724/// let gradient = itu_rs::dn65(45.4215, -75.6972, 50.0)?;
4725///
4726/// assert!(gradient.is_finite());
4727/// # }
4728/// # Ok::<(), Box<dyn std::error::Error>>(())
4729/// ```
4730pub fn dn65(lat_deg: f64, lon_deg: f64, p: f64) -> std::result::Result<f64, ItuError> {
4731    validate_lat_lon(lat_deg, lon_deg)
4732        .and_then(|_| validate_supported_p("p", p, &P453_DN_LEVELS))
4733        .map_err(ItuError::from)?;
4734    model()
4735        .map_err(ItuError::from)?
4736        .dn65(lat_deg, lon_deg, p)
4737        .map_err(ItuError::from)
4738}
4739
4740/// Looks up the [P.453](https://www.itu.int/rec/R-REC-P.453) refractivity gradient exceeded over the lowest 1 km.
4741///
4742/// `DN1` describes the refractivity gradient over the first kilometre above
4743/// the surface. It is separate from `DN65` because different propagation
4744/// methods use different layer depths.
4745///
4746/// # Parameters
4747///
4748/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4749/// - `lon_deg`: station geodetic longitude in degrees.
4750/// - `p`: available percentage of time exceeded from [P.453](https://www.itu.int/rec/R-REC-P.453).
4751///
4752/// # Returns
4753///
4754/// Refractivity gradient exceeded for `p` percent of an average year.
4755///
4756/// # Errors
4757///
4758/// Returns an error if inputs fail validation, `p` is not one of the available
4759/// [P.453](https://www.itu.int/rec/R-REC-P.453) DN map percentages, or model data cannot be loaded.
4760///
4761/// # Example
4762///
4763/// ```
4764/// # fn data_available() -> bool {
4765/// #     cfg!(feature = "data")
4766/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4767/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4768/// #             .join("data/453/v12_dn_50d00_v1.npz")
4769/// #             .exists()
4770/// # }
4771/// # if data_available() {
4772/// let gradient = itu_rs::dn1(45.4215, -75.6972, 50.0)?;
4773///
4774/// assert!(gradient.is_finite());
4775/// # }
4776/// # Ok::<(), Box<dyn std::error::Error>>(())
4777/// ```
4778pub fn dn1(lat_deg: f64, lon_deg: f64, p: f64) -> std::result::Result<f64, ItuError> {
4779    validate_lat_lon(lat_deg, lon_deg)
4780        .and_then(|_| validate_supported_p("p", p, &P453_DN_LEVELS))
4781        .map_err(ItuError::from)?;
4782    model()
4783        .map_err(ItuError::from)?
4784        .dn1(lat_deg, lon_deg, p)
4785        .map_err(ItuError::from)
4786}
4787
4788/// Computes inter-annual variability from ITU-R [P.678](https://www.itu.int/rec/R-REC-P.678).
4789///
4790/// This is the variance of year-to-year exceedance statistics for rain-rate or
4791/// rain-attenuation phenomena at a site. Unlike most crate APIs, `p_fraction`
4792/// is a probability fraction, not a percentage; `0.001` means 0.1%.
4793///
4794/// # Parameters
4795///
4796/// - `p_fraction`: long-term exceedance probability as a fraction, in
4797///   `[0.0001, 0.02]`.
4798/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4799/// - `lon_deg`: station geodetic longitude in degrees.
4800///
4801/// # Returns
4802///
4803/// Total inter-annual variance as a dimensionless fraction squared.
4804///
4805/// # Errors
4806///
4807/// Returns an error if inputs fail validation or model data cannot be loaded.
4808///
4809/// # Example
4810///
4811/// ```
4812/// # fn data_available() -> bool {
4813/// #     cfg!(feature = "data")
4814/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4815/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4816/// #             .join("data/678/v3_climatic_ratio.npz")
4817/// #             .exists()
4818/// # }
4819/// # if data_available() {
4820/// let variance = itu_rs::inter_annual_variability(0.001, 45.4215, -75.6972)?;
4821///
4822/// assert!(variance >= 0.0);
4823/// # }
4824/// # Ok::<(), Box<dyn std::error::Error>>(())
4825/// ```
4826pub fn inter_annual_variability(
4827    p_fraction: f64,
4828    lat_deg: f64,
4829    lon_deg: f64,
4830) -> std::result::Result<f64, ItuError> {
4831    validate_p678_fraction("p_fraction", p_fraction)
4832        .and_then(|_| validate_lat_lon(lat_deg, lon_deg))
4833        .map_err(ItuError::from)?;
4834    model()
4835        .map_err(ItuError::from)?
4836        .inter_annual_variability(p_fraction, lat_deg, lon_deg)
4837        .map_err(ItuError::from)
4838}
4839
4840/// Computes exceedance risk from ITU-R [P.678](https://www.itu.int/rec/R-REC-P.678).
4841///
4842/// This is the percentage risk that a randomly chosen year exceeds
4843/// `pr_fraction` when the long-term exceedance probability is `p_fraction`.
4844/// Both probability inputs are fractions, not percentages.
4845///
4846/// # Parameters
4847///
4848/// - `p_fraction`: long-term exceedance probability as a fraction, in
4849///   `[0.0001, 0.02]`.
4850/// - `pr_fraction`: annual exceedance threshold as a fraction, in `[0, 1]`.
4851/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
4852/// - `lon_deg`: station geodetic longitude in degrees.
4853///
4854/// # Returns
4855///
4856/// Risk as a percentage. When `pr_fraction == p_fraction`, the result is
4857/// `50.0`.
4858///
4859/// # Errors
4860///
4861/// Returns an error if inputs fail validation or model data cannot be loaded.
4862///
4863/// # Example
4864///
4865/// ```
4866/// # fn data_available() -> bool {
4867/// #     cfg!(feature = "data")
4868/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4869/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4870/// #             .join("data/678/v3_climatic_ratio.npz")
4871/// #             .exists()
4872/// # }
4873/// # if data_available() {
4874/// let risk = itu_rs::risk_of_exceedance(0.001, 0.001, 45.4215, -75.6972)?;
4875///
4876/// assert!((risk - 50.0).abs() < 1e-6);
4877/// # }
4878/// # Ok::<(), Box<dyn std::error::Error>>(())
4879/// ```
4880pub fn risk_of_exceedance(
4881    p_fraction: f64,
4882    pr_fraction: f64,
4883    lat_deg: f64,
4884    lon_deg: f64,
4885) -> std::result::Result<f64, ItuError> {
4886    validate_p678_fraction("p_fraction", p_fraction)
4887        .and_then(|_| validate_probability_fraction("pr_fraction", pr_fraction))
4888        .and_then(|_| validate_lat_lon(lat_deg, lon_deg))
4889        .map_err(ItuError::from)?;
4890    model()
4891        .map_err(ItuError::from)?
4892        .risk_of_exceedance(p_fraction, pr_fraction, lat_deg, lon_deg)
4893        .map_err(ItuError::from)
4894}
4895
4896/// Computes dry-air specific attenuation from ITU-R [P.676](https://www.itu.int/rec/R-REC-P.676) in dB/km.
4897///
4898/// This is the oxygen and dry-air part of exact gaseous attenuation for a local
4899/// atmospheric state. It does not include water-vapour line attenuation.
4900///
4901/// # Parameters
4902///
4903/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4904/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
4905/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative because it
4906///   contributes to line broadening even in the dry-air term.
4907/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
4908///
4909/// # Returns
4910///
4911/// Dry-air specific attenuation in dB/km.
4912///
4913/// # Errors
4914///
4915/// Returns an error if inputs fail validation or model data cannot be loaded.
4916///
4917/// # Example
4918///
4919/// ```
4920/// # fn data_available() -> bool {
4921/// #     cfg!(feature = "data")
4922/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4923/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4924/// #             .join("data/676/v13_lines_oxygen.txt")
4925/// #             .exists()
4926/// # }
4927/// # if data_available() {
4928/// let gamma0 = itu_rs::gamma0_exact_db_per_km(12.0, 1008.0, 7.5, 289.2)?;
4929///
4930/// assert!(gamma0 >= 0.0);
4931/// # }
4932/// # Ok::<(), Box<dyn std::error::Error>>(())
4933/// ```
4934pub fn gamma0_exact_db_per_km(
4935    freq_ghz: f64,
4936    pressure_hpa: f64,
4937    rho_gm3: f64,
4938    temp_k: f64,
4939) -> std::result::Result<f64, ItuError> {
4940    validate_positive("freq_ghz", freq_ghz)
4941        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
4942        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
4943        .and_then(|_| validate_positive("temp_k", temp_k))
4944        .map_err(ItuError::from)?;
4945    model()
4946        .map_err(ItuError::from)?
4947        .gamma0_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)
4948        .map_err(ItuError::from)
4949}
4950
4951/// Computes water-vapour specific attenuation from ITU-R [P.676](https://www.itu.int/rec/R-REC-P.676) in dB/km.
4952///
4953/// This is the water-vapour line and continuum part of exact gaseous
4954/// attenuation for a local atmospheric state.
4955///
4956/// # Parameters
4957///
4958/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
4959/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
4960/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative.
4961/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
4962///
4963/// # Returns
4964///
4965/// Water-vapour specific attenuation in dB/km.
4966///
4967/// # Errors
4968///
4969/// Returns an error if inputs fail validation or model data cannot be loaded.
4970///
4971/// # Example
4972///
4973/// ```
4974/// # fn data_available() -> bool {
4975/// #     cfg!(feature = "data")
4976/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
4977/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4978/// #             .join("data/676/v13_lines_water_vapour.txt")
4979/// #             .exists()
4980/// # }
4981/// # if data_available() {
4982/// let gammaw = itu_rs::gammaw_exact_db_per_km(12.0, 1008.0, 7.5, 289.2)?;
4983///
4984/// assert!(gammaw >= 0.0);
4985/// # }
4986/// # Ok::<(), Box<dyn std::error::Error>>(())
4987/// ```
4988pub fn gammaw_exact_db_per_km(
4989    freq_ghz: f64,
4990    pressure_hpa: f64,
4991    rho_gm3: f64,
4992    temp_k: f64,
4993) -> std::result::Result<f64, ItuError> {
4994    validate_positive("freq_ghz", freq_ghz)
4995        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
4996        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
4997        .and_then(|_| validate_positive("temp_k", temp_k))
4998        .map_err(ItuError::from)?;
4999    model()
5000        .map_err(ItuError::from)?
5001        .gammaw_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)
5002        .map_err(ItuError::from)
5003}
5004
5005/// Computes total specific gaseous attenuation from ITU-R [P.676](https://www.itu.int/rec/R-REC-P.676) in dB/km.
5006///
5007/// This is the sum of dry-air and water-vapour specific attenuation for a local
5008/// atmospheric state.
5009///
5010/// # Parameters
5011///
5012/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5013/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
5014/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative.
5015/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
5016///
5017/// # Returns
5018///
5019/// Total gaseous specific attenuation in dB/km.
5020///
5021/// # Errors
5022///
5023/// Returns an error if inputs fail validation or model data cannot be loaded.
5024///
5025/// # Example
5026///
5027/// ```
5028/// # fn data_available() -> bool {
5029/// #     cfg!(feature = "data")
5030/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5031/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5032/// #             .join("data/676/v13_lines_oxygen.txt")
5033/// #             .exists()
5034/// # }
5035/// # if data_available() {
5036/// let gamma = itu_rs::gamma_exact_db_per_km(12.0, 1008.0, 7.5, 289.2)?;
5037///
5038/// assert!(gamma >= 0.0);
5039/// # }
5040/// # Ok::<(), Box<dyn std::error::Error>>(())
5041/// ```
5042pub fn gamma_exact_db_per_km(
5043    freq_ghz: f64,
5044    pressure_hpa: f64,
5045    rho_gm3: f64,
5046    temp_k: f64,
5047) -> std::result::Result<f64, ItuError> {
5048    validate_positive("freq_ghz", freq_ghz)
5049        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
5050        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
5051        .and_then(|_| validate_positive("temp_k", temp_k))
5052        .map_err(ItuError::from)?;
5053    model()
5054        .map_err(ItuError::from)?
5055        .gamma_exact_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)
5056        .map_err(ItuError::from)
5057}
5058
5059/// Computes the [P.676](https://www.itu.int/rec/R-REC-P.676) dry-air approximate compatibility attenuation in dB/km.
5060///
5061/// [P.676-13](https://www.itu.int/rec/R-REC-P.676) does not define a separate approximate `gamma0` formula, so this
5062/// compatibility helper returns exact total specific attenuation, matching the
5063/// behavior of [`python-itu-r`](https://github.com/inigodelportillo/ITU-Rpy).
5064///
5065/// # Parameters
5066///
5067/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5068/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
5069/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative.
5070/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
5071///
5072/// # Returns
5073///
5074/// Dry-air specific attenuation in dB/km.
5075///
5076/// # Errors
5077///
5078/// Returns an error if inputs fail validation or model data cannot be loaded.
5079///
5080/// # Example
5081///
5082/// ```
5083/// let gamma0 = itu_rs::gamma0_approx_db_per_km(12.0, 1008.0, 7.5, 289.2)?;
5084/// assert!(gamma0 >= 0.0);
5085/// # Ok::<(), Box<dyn std::error::Error>>(())
5086/// ```
5087pub fn gamma0_approx_db_per_km(
5088    freq_ghz: f64,
5089    pressure_hpa: f64,
5090    rho_gm3: f64,
5091    temp_k: f64,
5092) -> std::result::Result<f64, ItuError> {
5093    gamma_exact_db_per_km(freq_ghz, pressure_hpa, rho_gm3, temp_k)
5094}
5095
5096/// Computes the [P.676](https://www.itu.int/rec/R-REC-P.676) water-vapour approximate specific attenuation in dB/km.
5097///
5098/// [P.676-13](https://www.itu.int/rec/R-REC-P.676) does not define a separate approximate `gammaw` formula, so this
5099/// compatibility helper returns the exact total specific attenuation, matching
5100/// the behavior of [`python-itu-r`](https://github.com/inigodelportillo/ITU-Rpy).
5101///
5102/// # Parameters
5103///
5104/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5105/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
5106/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative.
5107/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
5108///
5109/// # Returns
5110///
5111/// Specific gaseous attenuation in dB/km.
5112///
5113/// # Errors
5114///
5115/// Returns an error if inputs fail validation or model data cannot be loaded.
5116///
5117/// # Example
5118///
5119/// ```
5120/// let gammaw = itu_rs::gammaw_approx_db_per_km(12.0, 1008.0, 7.5, 289.2)?;
5121/// assert!(gammaw >= 0.0);
5122/// # Ok::<(), Box<dyn std::error::Error>>(())
5123/// ```
5124pub fn gammaw_approx_db_per_km(
5125    freq_ghz: f64,
5126    pressure_hpa: f64,
5127    rho_gm3: f64,
5128    temp_k: f64,
5129) -> std::result::Result<f64, ItuError> {
5130    gamma_exact_db_per_km(freq_ghz, pressure_hpa, rho_gm3, temp_k)
5131}
5132
5133/// Computes [P.676](https://www.itu.int/rec/R-REC-P.676) equivalent heights for dry air and water vapour.
5134///
5135/// Equivalent heights convert local specific gaseous attenuation into an
5136/// approximate slant-path attenuation. The returned dry-air and water-vapour
5137/// heights are used by the approximate [P.676](https://www.itu.int/rec/R-REC-P.676) path calculation.
5138///
5139/// # Parameters
5140///
5141/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5142/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
5143/// - `rho_gm3`: surface water-vapour density in g/m^3. Must be non-negative.
5144/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
5145///
5146/// # Returns
5147///
5148/// `(h0_km, hw_km)`, the dry-air and water-vapour equivalent heights in
5149/// kilometres.
5150///
5151/// # Errors
5152///
5153/// Returns an error if inputs fail validation or model data cannot be loaded.
5154///
5155/// # Example
5156///
5157/// ```
5158/// # fn data_available() -> bool {
5159/// #     cfg!(feature = "data")
5160/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5161/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5162/// #             .join("data/676/v13_h0_coefficients.txt")
5163/// #             .exists()
5164/// # }
5165/// # if data_available() {
5166/// let (h0_km, hw_km) = itu_rs::slant_inclined_path_equivalent_height_km(
5167///     12.0, 1008.0, 7.5, 289.2,
5168/// )?;
5169///
5170/// assert!(h0_km.is_finite());
5171/// assert!(hw_km.is_finite());
5172/// # }
5173/// # Ok::<(), Box<dyn std::error::Error>>(())
5174/// ```
5175pub fn slant_inclined_path_equivalent_height_km(
5176    freq_ghz: f64,
5177    pressure_hpa: f64,
5178    rho_gm3: f64,
5179    temp_k: f64,
5180) -> std::result::Result<(f64, f64), ItuError> {
5181    validate_positive("freq_ghz", freq_ghz)
5182        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
5183        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
5184        .and_then(|_| validate_positive("temp_k", temp_k))
5185        .map_err(ItuError::from)?;
5186    model()
5187        .map_err(ItuError::from)?
5188        .slant_inclined_path_equivalent_height_v13(freq_ghz, pressure_hpa, rho_gm3, temp_k)
5189        .map_err(ItuError::from)
5190}
5191
5192/// Computes [P.676](https://www.itu.int/rec/R-REC-P.676) zenith water-vapour attenuation.
5193///
5194/// This estimates the water-vapour attenuation for a vertical path above a
5195/// station. It is one input to the approximate gaseous slant-path attenuation.
5196///
5197/// # Parameters
5198///
5199/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5200/// - `v_t_kgm2`: total columnar water-vapour content in kg/m^2. Must be
5201///   non-negative.
5202/// - `h_km`: station altitude in kilometres. Must be non-negative.
5203///
5204/// # Returns
5205///
5206/// Zenith water-vapour attenuation in dB.
5207///
5208/// # Errors
5209///
5210/// Returns an error if inputs fail validation or model data cannot be loaded.
5211///
5212/// # Example
5213///
5214/// ```
5215/// # fn data_available() -> bool {
5216/// #     cfg!(feature = "data")
5217/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5218/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5219/// #             .join("data/676/v13_h0_coefficients.txt")
5220/// #             .exists()
5221/// # }
5222/// # if data_available() {
5223/// let zenith_db = itu_rs::zenith_water_vapour_attenuation_db(12.0, 22.5, 0.4)?;
5224///
5225/// assert!(zenith_db >= 0.0);
5226/// # }
5227/// # Ok::<(), Box<dyn std::error::Error>>(())
5228/// ```
5229pub fn zenith_water_vapour_attenuation_db(
5230    freq_ghz: f64,
5231    v_t_kgm2: f64,
5232    h_km: f64,
5233) -> std::result::Result<f64, ItuError> {
5234    validate_positive("freq_ghz", freq_ghz)
5235        .and_then(|_| validate_nonnegative("v_t_kgm2", v_t_kgm2))
5236        .and_then(|_| validate_nonnegative("h_km", h_km))
5237        .map_err(ItuError::from)?;
5238    model()
5239        .map_err(ItuError::from)?
5240        .zenith_water_vapour_attenuation_db(freq_ghz, v_t_kgm2, h_km)
5241        .map_err(ItuError::from)
5242}
5243
5244/// Computes gaseous attenuation on an Earth-space slant path from ITU-R [P.676](https://www.itu.int/rec/R-REC-P.676).
5245///
5246/// Gaseous attenuation is absorption by oxygen and water vapour along the
5247/// Earth-space path. Use `exact = true` for the layer-integrated model or
5248/// `exact = false` for the faster approximate model based on equivalent
5249/// heights and zenith water-vapour attenuation.
5250///
5251/// # Parameters
5252///
5253/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5254/// - `elevation_deg`: path elevation angle above the horizon in degrees, in
5255///   `(0, 90)`.
5256/// - `rho_gm3`: surface water-vapour density in g/m^3. Must be non-negative.
5257/// - `pressure_hpa`: surface atmospheric pressure in hPa. Must be positive.
5258/// - `temp_k`: surface air temperature in kelvin. Must be positive.
5259/// - `v_t_kgm2`: total columnar water-vapour content in kg/m^2. Must be
5260///   non-negative.
5261/// - `h_km`: station altitude in kilometres. Must be non-negative.
5262/// - `exact`: when `true`, use exact layer integration; when `false`, use the
5263///   approximate slant-path method.
5264///
5265/// # Returns
5266///
5267/// Gaseous slant-path attenuation in dB.
5268///
5269/// # Errors
5270///
5271/// Returns an error if inputs fail validation or model data cannot be loaded.
5272///
5273/// # Example
5274///
5275/// ```
5276/// # fn data_available() -> bool {
5277/// #     cfg!(feature = "data")
5278/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5279/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5280/// #             .join("data/676/v13_lines_oxygen.txt")
5281/// #             .exists()
5282/// # }
5283/// # if data_available() {
5284/// let gas_db = itu_rs::gaseous_attenuation_slant_path_db(
5285///     12.0, 30.0, 7.5, 1008.0, 289.2, 22.5, 0.4, false,
5286/// )?;
5287///
5288/// assert!(gas_db >= 0.0);
5289/// # }
5290/// # Ok::<(), Box<dyn std::error::Error>>(())
5291/// ```
5292#[allow(clippy::too_many_arguments)]
5293pub fn gaseous_attenuation_slant_path_db(
5294    freq_ghz: f64,
5295    elevation_deg: f64,
5296    rho_gm3: f64,
5297    pressure_hpa: f64,
5298    temp_k: f64,
5299    v_t_kgm2: f64,
5300    h_km: f64,
5301    exact: bool,
5302) -> std::result::Result<f64, ItuError> {
5303    validate_positive("freq_ghz", freq_ghz)
5304        .and_then(|_| validate_elevation_deg(elevation_deg))
5305        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
5306        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
5307        .and_then(|_| validate_positive("temp_k", temp_k))
5308        .and_then(|_| validate_nonnegative("v_t_kgm2", v_t_kgm2))
5309        .and_then(|_| validate_nonnegative("h_km", h_km))
5310        .map_err(ItuError::from)?;
5311    model()
5312        .map_err(ItuError::from)?
5313        .gaseous_attenuation_slant_path_v13(
5314            freq_ghz,
5315            elevation_deg,
5316            rho_gm3,
5317            pressure_hpa,
5318            temp_k,
5319            v_t_kgm2,
5320            h_km,
5321            exact,
5322        )
5323        .map_err(ItuError::from)
5324}
5325
5326/// Computes gaseous attenuation on a terrestrial path from ITU-R [P.676](https://www.itu.int/rec/R-REC-P.676).
5327///
5328/// This multiplies local gaseous specific attenuation by path length for a
5329/// terrestrial path through the same atmospheric state.
5330///
5331/// # Parameters
5332///
5333/// - `path_length_km`: terrestrial path length in kilometres. Must be
5334///   non-negative.
5335/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5336/// - `elevation_deg`: path elevation angle in degrees. It is validated for
5337///   consistency with other [P.676](https://www.itu.int/rec/R-REC-P.676) path APIs.
5338/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative.
5339/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
5340/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
5341/// - `mode`: approximate or exact [P.676](https://www.itu.int/rec/R-REC-P.676) path mode.
5342///
5343/// # Returns
5344///
5345/// Gaseous attenuation in dB.
5346///
5347/// # Errors
5348///
5349/// Returns an error if inputs fail validation or model data cannot be loaded.
5350///
5351/// # Example
5352///
5353/// ```
5354/// let attenuation_db = itu_rs::gaseous_attenuation_terrestrial_path_db(
5355///     10.0, 12.0, 30.0, 7.5, 1008.0, 289.2, itu_rs::GasPathMode::Approximate,
5356/// )?;
5357/// assert!(attenuation_db >= 0.0);
5358/// # Ok::<(), Box<dyn std::error::Error>>(())
5359/// ```
5360pub fn gaseous_attenuation_terrestrial_path_db(
5361    path_length_km: f64,
5362    freq_ghz: f64,
5363    elevation_deg: f64,
5364    rho_gm3: f64,
5365    pressure_hpa: f64,
5366    temp_k: f64,
5367    mode: GasPathMode,
5368) -> std::result::Result<f64, ItuError> {
5369    validate_nonnegative("path_length_km", path_length_km)
5370        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
5371        .and_then(|_| validate_elevation_deg(elevation_deg))
5372        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
5373        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
5374        .and_then(|_| validate_positive("temp_k", temp_k))
5375        .map_err(ItuError::from)?;
5376    model()
5377        .map_err(ItuError::from)?
5378        .gaseous_attenuation_terrestrial_path_db(
5379            path_length_km,
5380            freq_ghz,
5381            rho_gm3,
5382            pressure_hpa,
5383            temp_k,
5384            mode,
5385        )
5386        .map_err(ItuError::from)
5387}
5388
5389/// Computes gaseous attenuation on an inclined path from ITU-R [P.676](https://www.itu.int/rec/R-REC-P.676).
5390///
5391/// The path starts at `h1_km` and ends at `h2_km`, both above mean sea level.
5392/// [P.676](https://www.itu.int/rec/R-REC-P.676) restricts this method to terminal heights up to 10 km.
5393///
5394/// # Parameters
5395///
5396/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5397/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5398/// - `rho_gm3`: water-vapour density in g/m^3. Must be non-negative.
5399/// - `pressure_hpa`: atmospheric pressure in hPa. Must be positive.
5400/// - `temp_k`: absolute air temperature in kelvin. Must be positive.
5401/// - `h1_km`: transmitter altitude in kilometres. Must be in `[0, 10]`.
5402/// - `h2_km`: receiver altitude in kilometres. Must be in `[0, 10]`.
5403/// - `mode`: approximate or exact [P.676](https://www.itu.int/rec/R-REC-P.676) path mode.
5404///
5405/// # Returns
5406///
5407/// Gaseous attenuation in dB.
5408///
5409/// # Errors
5410///
5411/// Returns an error if inputs fail validation or model data cannot be loaded.
5412///
5413/// # Example
5414///
5415/// ```
5416/// let attenuation_db = itu_rs::gaseous_attenuation_inclined_path_db(
5417///     12.0, 30.0, 7.5, 1008.0, 289.2, 0.4, 2.0, itu_rs::GasPathMode::Approximate,
5418/// )?;
5419/// assert!(attenuation_db.is_finite());
5420/// # Ok::<(), Box<dyn std::error::Error>>(())
5421/// ```
5422#[allow(clippy::too_many_arguments)]
5423pub fn gaseous_attenuation_inclined_path_db(
5424    freq_ghz: f64,
5425    elevation_deg: f64,
5426    rho_gm3: f64,
5427    pressure_hpa: f64,
5428    temp_k: f64,
5429    h1_km: f64,
5430    h2_km: f64,
5431    mode: GasPathMode,
5432) -> std::result::Result<f64, ItuError> {
5433    validate_positive("freq_ghz", freq_ghz)
5434        .and_then(|_| validate_elevation_deg(elevation_deg))
5435        .and_then(|_| validate_nonnegative("rho_gm3", rho_gm3))
5436        .and_then(|_| validate_positive("pressure_hpa", pressure_hpa))
5437        .and_then(|_| validate_positive("temp_k", temp_k))
5438        .and_then(|_| validate_nonnegative("h1_km", h1_km))
5439        .and_then(|_| validate_nonnegative("h2_km", h2_km))
5440        .map_err(ItuError::from)?;
5441    if h1_km > 10.0 || h2_km > 10.0 {
5442        return Err(ItuError::from(
5443            "h1_km and h2_km must be <= 10 for P.676 inclined paths".to_string(),
5444        ));
5445    }
5446    model()
5447        .map_err(ItuError::from)?
5448        .gaseous_attenuation_inclined_path_db(
5449            freq_ghz,
5450            elevation_deg,
5451            rho_gm3,
5452            pressure_hpa,
5453            temp_k,
5454            h1_km,
5455            h2_km,
5456            mode,
5457        )
5458        .map_err(ItuError::from)
5459}
5460
5461/// Computes rain attenuation from ITU-R [P.618](https://www.itu.int/rec/R-REC-P.618).
5462///
5463/// Rain attenuation is the fade contribution from precipitation along the
5464/// Earth-space path. The model combines local rain rate, rain height, station
5465/// height, path geometry, frequency, time percentage, and polarization.
5466///
5467/// # Parameters
5468///
5469/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
5470/// - `lon_deg`: station geodetic longitude in degrees.
5471/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5472/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5473/// - `hs_km`: station altitude above mean sea level in kilometres. Any finite
5474///   value is accepted.
5475/// - `p`: percentage of time exceeded. Must be positive.
5476/// - `r001_mmh`: optional rainfall rate exceeded for 0.01% of an average year
5477///   in mm/h. When `None`, [P.837](https://www.itu.int/rec/R-REC-P.837) data are used.
5478/// - `tau_deg`: polarization tilt angle in degrees, in `[0, 90]`.
5479/// - `l_s_km`: optional slant-path length through rain in kilometres. When
5480///   `None`, [P.618](https://www.itu.int/rec/R-REC-P.618) derives it from rain height and elevation.
5481///
5482/// # Returns
5483///
5484/// Rain attenuation in dB for the requested time percentage.
5485///
5486/// # Errors
5487///
5488/// Returns an error if inputs fail validation or model data cannot be loaded.
5489///
5490/// # Example
5491///
5492/// ```
5493/// # fn data_available() -> bool {
5494/// #     cfg!(feature = "data")
5495/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5496/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5497/// #             .join("data/837/v7_lat_r001.npz")
5498/// #             .exists()
5499/// # }
5500/// # if data_available() {
5501/// let rain_db = itu_rs::rain_attenuation_db(
5502///     45.4215, -75.6972, 12.0, 30.0, 0.4, 1.0, None, 45.0, None,
5503/// )?;
5504///
5505/// assert!(rain_db >= 0.0);
5506/// # }
5507/// # Ok::<(), Box<dyn std::error::Error>>(())
5508/// ```
5509#[allow(clippy::too_many_arguments)]
5510pub fn rain_attenuation_db(
5511    lat_deg: f64,
5512    lon_deg: f64,
5513    freq_ghz: f64,
5514    elevation_deg: f64,
5515    hs_km: f64,
5516    p: f64,
5517    r001_mmh: Option<f64>,
5518    tau_deg: f64,
5519    l_s_km: Option<f64>,
5520) -> std::result::Result<f64, ItuError> {
5521    validate_lat_lon(lat_deg, lon_deg)
5522        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
5523        .and_then(|_| validate_elevation_deg(elevation_deg))
5524        .and_then(|_| validate_finite("hs_km", hs_km))
5525        .and_then(|_| validate_p(p))
5526        .and_then(|_| validate_optional_nonnegative("r001_mmh", r001_mmh))
5527        .and_then(|_| validate_tau_deg(tau_deg))
5528        .and_then(|_| validate_optional_positive("l_s_km", l_s_km))
5529        .map_err(ItuError::from)?;
5530    model()
5531        .map_err(ItuError::from)?
5532        .rain_attenuation_db(
5533            lat_deg,
5534            lon_deg,
5535            freq_ghz,
5536            elevation_deg,
5537            hs_km,
5538            p,
5539            r001_mmh,
5540            tau_deg,
5541            l_s_km,
5542        )
5543        .map_err(ItuError::from)
5544}
5545
5546/// Computes rain-attenuation occurrence probability from ITU-R [P.618](https://www.itu.int/rec/R-REC-P.618).
5547///
5548/// This estimates the probability that rain attenuation occurs on the slant
5549/// path, using [P.837](https://www.itu.int/rec/R-REC-P.837) rain probability by default.
5550///
5551/// # Parameters
5552///
5553/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
5554/// - `lon_deg`: station geodetic longitude in degrees.
5555/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5556/// - `hs_km`: optional station altitude in kilometres. When `None`, [P.1511](https://www.itu.int/rec/R-REC-P.1511)
5557///   topographic altitude is used.
5558/// - `l_s_km`: optional slant-path length through rain in kilometres.
5559/// - `p0_percent`: optional rain probability at the station in percent.
5560///
5561/// # Returns
5562///
5563/// Probability of rain attenuation on the slant path in percent.
5564///
5565/// # Errors
5566///
5567/// Returns an error if inputs fail validation or model data cannot be loaded.
5568///
5569/// # Example
5570///
5571/// ```
5572/// # fn data_available() -> bool {
5573/// #     cfg!(feature = "data")
5574/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5575/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/837/v7_mt_month01.npz").exists()
5576/// # }
5577/// # if data_available() {
5578/// let p = itu_rs::rain_attenuation_probability_percent(
5579///     45.4215, -75.6972, 30.0, Some(0.4), None, None,
5580/// )?;
5581/// assert!(p >= 0.0);
5582/// # }
5583/// # Ok::<(), Box<dyn std::error::Error>>(())
5584/// ```
5585pub fn rain_attenuation_probability_percent(
5586    lat_deg: f64,
5587    lon_deg: f64,
5588    elevation_deg: f64,
5589    hs_km: Option<f64>,
5590    l_s_km: Option<f64>,
5591    p0_percent: Option<f64>,
5592) -> std::result::Result<f64, ItuError> {
5593    validate_lat_lon(lat_deg, lon_deg)
5594        .and_then(|_| validate_elevation_deg(elevation_deg))
5595        .and_then(|_| validate_optional_positive("l_s_km", l_s_km))
5596        .map_err(ItuError::from)?;
5597    if let Some(hs_km) = hs_km {
5598        validate_finite("hs_km", hs_km).map_err(ItuError::from)?;
5599    }
5600    if let Some(p0_percent) = p0_percent {
5601        validate_positive("p0_percent", p0_percent).map_err(ItuError::from)?;
5602        if p0_percent >= 100.0 {
5603            return Err(ItuError::from("p0_percent must be in (0, 100)".to_string()));
5604        }
5605    }
5606    model()
5607        .map_err(ItuError::from)?
5608        .rain_attenuation_probability_percent(
5609            lat_deg,
5610            lon_deg,
5611            elevation_deg,
5612            hs_km,
5613            l_s_km,
5614            p0_percent,
5615        )
5616        .map_err(ItuError::from)
5617}
5618
5619/// Fits [P.618](https://www.itu.int/rec/R-REC-P.618) rain attenuation statistics to a lognormal model.
5620///
5621/// The fit relates rain attenuation exceeded probabilities below `p_k_percent`
5622/// to log attenuation values.
5623///
5624/// # Parameters
5625///
5626/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
5627/// - `lon_deg`: station geodetic longitude in degrees.
5628/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5629/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5630/// - `hs_km`: station altitude in kilometres.
5631/// - `p_k_percent`: rain occurrence probability threshold in percent.
5632/// - `tau_deg`: polarization tilt angle in degrees, in `[0, 90]`.
5633///
5634/// # Returns
5635///
5636/// `(sigma_ln_a, m_ln_a)`, the standard deviation and mean of the fitted
5637/// logarithmic attenuation distribution.
5638///
5639/// # Errors
5640///
5641/// Returns an error if inputs fail validation, the fit is underdetermined, or
5642/// model data cannot be loaded.
5643///
5644/// # Example
5645///
5646/// ```
5647/// # fn data_available() -> bool {
5648/// #     cfg!(feature = "data")
5649/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5650/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/837/v7_lat_r001.npz").exists()
5651/// # }
5652/// # if data_available() {
5653/// let (sigma, mean) = itu_rs::fit_rain_attenuation_to_lognormal(
5654///     45.4215, -75.6972, 12.0, 30.0, 0.4, 10.0, 45.0,
5655/// )?;
5656/// assert!(sigma.is_finite());
5657/// assert!(mean.is_finite());
5658/// # }
5659/// # Ok::<(), Box<dyn std::error::Error>>(())
5660/// ```
5661pub fn fit_rain_attenuation_to_lognormal(
5662    lat_deg: f64,
5663    lon_deg: f64,
5664    freq_ghz: f64,
5665    elevation_deg: f64,
5666    hs_km: f64,
5667    p_k_percent: f64,
5668    tau_deg: f64,
5669) -> std::result::Result<(f64, f64), ItuError> {
5670    validate_lat_lon(lat_deg, lon_deg)
5671        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
5672        .and_then(|_| validate_elevation_deg(elevation_deg))
5673        .and_then(|_| validate_finite("hs_km", hs_km))
5674        .and_then(|_| validate_positive("p_k_percent", p_k_percent))
5675        .and_then(|_| validate_tau_deg(tau_deg))
5676        .map_err(ItuError::from)?;
5677    model()
5678        .map_err(ItuError::from)?
5679        .fit_rain_attenuation_to_lognormal(
5680            lat_deg,
5681            lon_deg,
5682            freq_ghz,
5683            elevation_deg,
5684            hs_km,
5685            p_k_percent,
5686            tau_deg,
5687        )
5688        .map_err(ItuError::from)
5689}
5690
5691/// Computes [P.618](https://www.itu.int/rec/R-REC-P.618) site-diversity rain outage probability.
5692///
5693/// This estimates the joint probability that both Earth-space paths exceed
5694/// their specified rain attenuation thresholds.
5695///
5696/// # Parameters
5697///
5698/// - `lat1_deg`, `lon1_deg`: first station coordinates in degrees.
5699/// - `a1_db`: first path attenuation threshold in dB. Must be positive.
5700/// - `elevation1_deg`: first path elevation angle in degrees.
5701/// - `lat2_deg`, `lon2_deg`: second station coordinates in degrees.
5702/// - `a2_db`: second path attenuation threshold in dB. Must be positive.
5703/// - `elevation2_deg`: second path elevation angle in degrees.
5704/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5705/// - `tau_deg`: polarization tilt angle in degrees, in `[0, 90]`.
5706/// - `hs1_km`, `hs2_km`: optional station altitudes in kilometres.
5707///
5708/// # Returns
5709///
5710/// Joint outage probability in percent.
5711///
5712/// # Errors
5713///
5714/// Returns an error if inputs fail validation or model data cannot be loaded.
5715///
5716/// # Example
5717///
5718/// ```
5719/// # fn data_available() -> bool {
5720/// #     cfg!(feature = "data")
5721/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5722/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/837/v7_mt_month01.npz").exists()
5723/// # }
5724/// # if data_available() {
5725/// let outage = itu_rs::site_diversity_rain_outage_probability_percent(
5726///     45.4215, -75.6972, 5.0, 30.0,
5727///     45.6215, -75.3972, 5.0, 30.0,
5728///     12.0, 45.0, Some(0.4), Some(0.4),
5729/// )?;
5730/// assert!(outage >= 0.0);
5731/// # }
5732/// # Ok::<(), Box<dyn std::error::Error>>(())
5733/// ```
5734#[allow(clippy::too_many_arguments)]
5735pub fn site_diversity_rain_outage_probability_percent(
5736    lat1_deg: f64,
5737    lon1_deg: f64,
5738    a1_db: f64,
5739    elevation1_deg: f64,
5740    lat2_deg: f64,
5741    lon2_deg: f64,
5742    a2_db: f64,
5743    elevation2_deg: f64,
5744    freq_ghz: f64,
5745    tau_deg: f64,
5746    hs1_km: Option<f64>,
5747    hs2_km: Option<f64>,
5748) -> std::result::Result<f64, ItuError> {
5749    validate_lat_lon(lat1_deg, lon1_deg)
5750        .and_then(|_| validate_lat_lon(lat2_deg, lon2_deg))
5751        .and_then(|_| validate_positive("a1_db", a1_db))
5752        .and_then(|_| validate_positive("a2_db", a2_db))
5753        .and_then(|_| validate_elevation_deg(elevation1_deg))
5754        .and_then(|_| validate_elevation_deg(elevation2_deg))
5755        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
5756        .and_then(|_| validate_tau_deg(tau_deg))
5757        .map_err(ItuError::from)?;
5758    if let Some(hs1_km) = hs1_km {
5759        validate_finite("hs1_km", hs1_km).map_err(ItuError::from)?;
5760    }
5761    if let Some(hs2_km) = hs2_km {
5762        validate_finite("hs2_km", hs2_km).map_err(ItuError::from)?;
5763    }
5764    model()
5765        .map_err(ItuError::from)?
5766        .site_diversity_rain_outage_probability_percent(
5767            lat1_deg,
5768            lon1_deg,
5769            a1_db,
5770            elevation1_deg,
5771            lat2_deg,
5772            lon2_deg,
5773            a2_db,
5774            elevation2_deg,
5775            freq_ghz,
5776            tau_deg,
5777            hs1_km,
5778            hs2_km,
5779        )
5780        .map_err(ItuError::from)
5781}
5782
5783/// Computes rain cross-polarization discrimination from ITU-R [P.618](https://www.itu.int/rec/R-REC-P.618).
5784///
5785/// This converts co-polar rain attenuation statistics into the rain-plus-ice
5786/// cross-polarization discrimination not exceeded for `p` percent of time.
5787///
5788/// # Parameters
5789///
5790/// - `attenuation_db`: co-polar rain attenuation in dB. Must be positive.
5791/// - `freq_ghz`: carrier frequency in GHz. Must be in `[4, 55]`.
5792/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5793/// - `p`: percentage of time. Must be positive.
5794/// - `tau_deg`: polarization tilt angle in degrees, in `[0, 90]`.
5795///
5796/// # Returns
5797///
5798/// Cross-polarization discrimination in dB.
5799///
5800/// # Errors
5801///
5802/// Returns an error if inputs fail validation.
5803///
5804/// # Example
5805///
5806/// ```
5807/// let xpd_db = itu_rs::rain_cross_polarization_discrimination_db(
5808///     10.0, 12.0, 30.0, 0.1, 45.0,
5809/// )?;
5810/// assert!(xpd_db.is_finite());
5811/// # Ok::<(), Box<dyn std::error::Error>>(())
5812/// ```
5813pub fn rain_cross_polarization_discrimination_db(
5814    attenuation_db: f64,
5815    freq_ghz: f64,
5816    elevation_deg: f64,
5817    p: f64,
5818    tau_deg: f64,
5819) -> std::result::Result<f64, ItuError> {
5820    validate_positive("attenuation_db", attenuation_db)
5821        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
5822        .and_then(|_| validate_elevation_deg(elevation_deg))
5823        .and_then(|_| validate_p(p))
5824        .and_then(|_| validate_tau_deg(tau_deg))
5825        .map_err(ItuError::from)?;
5826    if !(4.0..=55.0).contains(&freq_ghz) {
5827        return Err(ItuError::from("freq_ghz must be in [4, 55]".to_string()));
5828    }
5829    Ok(model()
5830        .map_err(ItuError::from)?
5831        .rain_cross_polarization_discrimination_db(
5832            attenuation_db,
5833            freq_ghz,
5834            elevation_deg,
5835            p,
5836            tau_deg,
5837        ))
5838}
5839
5840#[allow(clippy::too_many_arguments)]
5841fn validate_scintillation_inputs(
5842    lat_deg: f64,
5843    lon_deg: f64,
5844    freq_ghz: f64,
5845    elevation_deg: f64,
5846    dish_m: f64,
5847    eta: f64,
5848    temp_c: Option<f64>,
5849    humidity_percent: Option<f64>,
5850    pressure_hpa: Option<f64>,
5851    h_l_m: f64,
5852) -> Result<(), String> {
5853    validate_lat_lon(lat_deg, lon_deg)
5854        .and_then(|_| validate_positive("freq_ghz", freq_ghz))
5855        .and_then(|_| validate_elevation_deg(elevation_deg))
5856        .and_then(|_| validate_positive("dish_m", dish_m))
5857        .and_then(|_| validate_positive("h_l_m", h_l_m))
5858        .and_then(|_| validate_finite("eta", eta))?;
5859    if eta > 1.0 {
5860        return Err("eta must be in (0, 1]".to_string());
5861    }
5862
5863    match (temp_c, humidity_percent, pressure_hpa) {
5864        (None, None, None) => Ok(()),
5865        (Some(t), Some(h), Some(p_hpa)) => validate_finite("temp_c", t)
5866            .and_then(|_| {
5867                if t <= -273.15 {
5868                    Err("temp_c must be > -273.15".to_string())
5869                } else {
5870                    Ok(())
5871                }
5872            })
5873            .and_then(|_| validate_finite("humidity_percent", h))
5874            .and_then(|_| {
5875                if !(0.0..=100.0).contains(&h) {
5876                    Err("humidity_percent must be in [0, 100]".to_string())
5877                } else {
5878                    Ok(())
5879                }
5880            })
5881            .and_then(|_| validate_positive("pressure_hpa", p_hpa)),
5882        _ => {
5883            Err("temp_c, humidity_percent, and pressure_hpa must be supplied together".to_string())
5884        }
5885    }
5886}
5887
5888/// Computes the [P.618](https://www.itu.int/rec/R-REC-P.618) scintillation standard deviation in dB.
5889///
5890/// This is the standard deviation of short-term tropospheric scintillation
5891/// amplitude fluctuations before converting to a fade depth for a requested
5892/// time percentage.
5893///
5894/// # Parameters
5895///
5896/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
5897/// - `lon_deg`: station geodetic longitude in degrees.
5898/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5899/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5900/// - `dish_m`: physical antenna diameter in metres. Must be positive.
5901/// - `eta`: aperture efficiency factor, in `(0, 1]`.
5902/// - `temp_c`, `humidity_percent`, `pressure_hpa`: optional local meteorology.
5903///   Supply all three together to compute wet refractivity from local
5904///   conditions, or supply all as `None` to use [P.453](https://www.itu.int/rec/R-REC-P.453) map data.
5905/// - `h_l_m`: turbulent layer height in metres. Must be positive.
5906///
5907/// # Returns
5908///
5909/// Scintillation standard deviation in dB.
5910///
5911/// # Errors
5912///
5913/// Returns an error if inputs fail validation, if only part of the optional
5914/// meteorology set is supplied, or if model data cannot be loaded.
5915///
5916/// # Example
5917///
5918/// ```
5919/// # fn data_available() -> bool {
5920/// #     cfg!(feature = "data")
5921/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
5922/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5923/// #             .join("data/453/v13_lat_n.npz")
5924/// #             .exists()
5925/// # }
5926/// # if data_available() {
5927/// let sigma_db = itu_rs::scintillation_sigma_db(
5928///     45.4215, -75.6972, 12.0, 30.0, 1.2, 0.5, None, None, None, 1000.0,
5929/// )?;
5930///
5931/// assert!(sigma_db >= 0.0);
5932/// # }
5933/// # Ok::<(), Box<dyn std::error::Error>>(())
5934/// ```
5935#[allow(clippy::too_many_arguments)]
5936pub fn scintillation_sigma_db(
5937    lat_deg: f64,
5938    lon_deg: f64,
5939    freq_ghz: f64,
5940    elevation_deg: f64,
5941    dish_m: f64,
5942    eta: f64,
5943    temp_c: Option<f64>,
5944    humidity_percent: Option<f64>,
5945    pressure_hpa: Option<f64>,
5946    h_l_m: f64,
5947) -> std::result::Result<f64, ItuError> {
5948    validate_scintillation_inputs(
5949        lat_deg,
5950        lon_deg,
5951        freq_ghz,
5952        elevation_deg,
5953        dish_m,
5954        eta,
5955        temp_c,
5956        humidity_percent,
5957        pressure_hpa,
5958        h_l_m,
5959    )
5960    .map_err(ItuError::from)?;
5961    model()
5962        .map_err(ItuError::from)?
5963        .scintillation_sigma_db(
5964            lat_deg,
5965            lon_deg,
5966            freq_ghz,
5967            elevation_deg,
5968            dish_m,
5969            eta,
5970            temp_c,
5971            humidity_percent,
5972            pressure_hpa,
5973            h_l_m,
5974        )
5975        .map_err(ItuError::from)
5976}
5977
5978/// Computes scintillation attenuation from ITU-R [P.618](https://www.itu.int/rec/R-REC-P.618).
5979///
5980/// This converts the scintillation standard deviation into an attenuation
5981/// exceeded for the requested time percentage. It represents the fade
5982/// contribution from tropospheric turbulence.
5983///
5984/// # Parameters
5985///
5986/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
5987/// - `lon_deg`: station geodetic longitude in degrees.
5988/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
5989/// - `elevation_deg`: path elevation angle in degrees, in `(0, 90)`.
5990/// - `p`: percentage of time exceeded. Must be positive.
5991/// - `dish_m`: physical antenna diameter in metres. Must be positive.
5992/// - `eta`: aperture efficiency factor, in `(0, 1]`.
5993/// - `temp_c`, `humidity_percent`, `pressure_hpa`: optional local meteorology.
5994///   Supply all three together, or supply all as `None` to use [P.453](https://www.itu.int/rec/R-REC-P.453) map data.
5995/// - `h_l_m`: turbulent layer height in metres. Must be positive.
5996///
5997/// # Returns
5998///
5999/// Scintillation attenuation in dB.
6000///
6001/// # Errors
6002///
6003/// Returns an error if inputs fail validation, if only part of the optional
6004/// meteorology set is supplied, or if model data cannot be loaded.
6005///
6006/// # Example
6007///
6008/// ```
6009/// # fn data_available() -> bool {
6010/// #     cfg!(feature = "data")
6011/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
6012/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6013/// #             .join("data/453/v13_lat_n.npz")
6014/// #             .exists()
6015/// # }
6016/// # if data_available() {
6017/// let scint_db = itu_rs::scintillation_attenuation_db(
6018///     45.4215, -75.6972, 12.0, 30.0, 1.0, 1.2, 0.5,
6019///     None, None, None, 1000.0,
6020/// )?;
6021///
6022/// assert!(scint_db >= 0.0);
6023/// # }
6024/// # Ok::<(), Box<dyn std::error::Error>>(())
6025/// ```
6026#[allow(clippy::too_many_arguments)]
6027pub fn scintillation_attenuation_db(
6028    lat_deg: f64,
6029    lon_deg: f64,
6030    freq_ghz: f64,
6031    elevation_deg: f64,
6032    p: f64,
6033    dish_m: f64,
6034    eta: f64,
6035    temp_c: Option<f64>,
6036    humidity_percent: Option<f64>,
6037    pressure_hpa: Option<f64>,
6038    h_l_m: f64,
6039) -> std::result::Result<f64, ItuError> {
6040    validate_p(p)
6041        .and_then(|_| {
6042            validate_scintillation_inputs(
6043                lat_deg,
6044                lon_deg,
6045                freq_ghz,
6046                elevation_deg,
6047                dish_m,
6048                eta,
6049                temp_c,
6050                humidity_percent,
6051                pressure_hpa,
6052                h_l_m,
6053            )
6054        })
6055        .map_err(ItuError::from)?;
6056    model()
6057        .map_err(ItuError::from)?
6058        .scintillation_attenuation_db(
6059            lat_deg,
6060            lon_deg,
6061            freq_ghz,
6062            elevation_deg,
6063            p,
6064            dish_m,
6065            eta,
6066            temp_c,
6067            humidity_percent,
6068            pressure_hpa,
6069            h_l_m,
6070        )
6071        .map_err(ItuError::from)
6072}
6073
6074/// Computes gas-only atmospheric attenuation for one elevation angle.
6075///
6076/// This uses the same default environmental lookups as the supported
6077/// slant-path attenuation path, but disables rain, cloud, and scintillation
6078/// contributions.
6079///
6080/// # Parameters
6081///
6082/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
6083/// - `lon_deg`: station geodetic longitude in degrees.
6084/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
6085/// - `elevation_deg`: path elevation angle above the horizon in degrees, in
6086///   `(0, 90)`.
6087/// - `p`: percentage of time exceeded. Must be positive.
6088/// - `d_m`: physical antenna diameter in metres. It is accepted for API
6089///   compatibility with the full slant-path call and must be positive, but
6090///   gas-only attenuation does not use antenna size physically.
6091///
6092/// # Returns
6093///
6094/// Gaseous attenuation in dB.
6095///
6096/// # Errors
6097///
6098/// Returns an error if inputs fail validation or model data cannot be loaded.
6099///
6100/// # Example
6101///
6102/// ```
6103/// # fn data_available() -> bool {
6104/// #     cfg!(feature = "data")
6105/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
6106/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6107/// #             .join("data/1511/v2_lat.npz")
6108/// #             .exists()
6109/// # }
6110/// # if data_available() {
6111/// let gas_db = itu_rs::gas_attenuation_default(
6112///     45.4215, -75.6972, 12.0, 30.0, 0.1, 1.2,
6113/// )?;
6114///
6115/// assert!(gas_db.is_finite());
6116/// # }
6117/// # Ok::<(), Box<dyn std::error::Error>>(())
6118/// ```
6119pub fn gas_attenuation_default(
6120    lat_deg: f64,
6121    lon_deg: f64,
6122    freq_ghz: f64,
6123    elevation_deg: f64,
6124    p: f64,
6125    d_m: f64,
6126) -> std::result::Result<f64, ItuError> {
6127    validate_common_inputs(lat_deg, lon_deg, freq_ghz, p, d_m)
6128        .and_then(|_| validate_elevation_deg(elevation_deg))
6129        .map_err(ItuError::from)?;
6130    model()
6131        .map_err(ItuError::from)?
6132        .atmospheric_attenuation_default_gas_only(lat_deg, lon_deg, freq_ghz, elevation_deg, p, d_m)
6133        .map_err(ItuError::from)
6134}
6135
6136/// Computes gas-only atmospheric attenuation for multiple elevation angles.
6137///
6138/// Elevation values are validated exactly. Use only values in the open interval
6139/// `(0, 90)`.
6140///
6141/// # Parameters
6142///
6143/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
6144/// - `lon_deg`: station geodetic longitude in degrees.
6145/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
6146/// - `elevation_deg`: slice of path elevation angles in degrees. Every value
6147///   must be in `(0, 90)`.
6148/// - `p`: percentage of time exceeded. Must be positive.
6149/// - `d_m`: physical antenna diameter in metres. It is validated for
6150///   compatibility with the full slant-path API but is not used by gas-only
6151///   attenuation.
6152///
6153/// # Returns
6154///
6155/// One gaseous attenuation value in dB for each supplied elevation angle, in
6156/// the same order as the input slice.
6157///
6158/// # Errors
6159///
6160/// Returns an error if any input fails validation or model data cannot be
6161/// loaded.
6162///
6163/// # Example
6164///
6165/// ```
6166/// # fn data_available() -> bool {
6167/// #     cfg!(feature = "data")
6168/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
6169/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6170/// #             .join("data/1511/v2_lat.npz")
6171/// #             .exists()
6172/// # }
6173/// # if data_available() {
6174/// let elevations = [5.0, 30.0, 89.0];
6175/// let gas = itu_rs::gas_attenuation_default_many(
6176///     45.4215, -75.6972, 12.0, &elevations, 0.1, 1.2,
6177/// )?;
6178///
6179/// assert_eq!(gas.len(), elevations.len());
6180/// # }
6181/// # Ok::<(), Box<dyn std::error::Error>>(())
6182/// ```
6183pub fn gas_attenuation_default_many(
6184    lat_deg: f64,
6185    lon_deg: f64,
6186    freq_ghz: f64,
6187    elevation_deg: &[f64],
6188    p: f64,
6189    d_m: f64,
6190) -> std::result::Result<Vec<f64>, ItuError> {
6191    gas_attenuation_default_many_checked(lat_deg, lon_deg, freq_ghz, elevation_deg, p, d_m)
6192}
6193
6194/// Computes gas-only atmospheric attenuation for multiple elevation angles.
6195///
6196/// This is retained as a compatibility alias for callers that want the name to
6197/// emphasize strict validation.
6198///
6199/// # Parameters
6200///
6201/// - `lat_deg`: station geodetic latitude in degrees, in `[-90, 90]`.
6202/// - `lon_deg`: station geodetic longitude in degrees.
6203/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
6204/// - `elevation_deg`: slice of path elevation angles in degrees. Every value
6205///   must be in `(0, 90)`.
6206/// - `p`: percentage of time exceeded. Must be positive.
6207/// - `d_m`: physical antenna diameter in metres. Must be positive.
6208///
6209/// # Returns
6210///
6211/// One gaseous attenuation value in dB for each supplied elevation angle.
6212///
6213/// # Errors
6214///
6215/// Returns an error if any input fails validation or model data cannot be
6216/// loaded.
6217///
6218/// # Example
6219///
6220/// ```
6221/// # fn data_available() -> bool {
6222/// #     cfg!(feature = "data")
6223/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
6224/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6225/// #             .join("data/1511/v2_lat.npz")
6226/// #             .exists()
6227/// # }
6228/// # if data_available() {
6229/// let elevations = [10.0, 45.0];
6230/// let gas = itu_rs::gas_attenuation_default_many_checked(
6231///     45.4215, -75.6972, 12.0, &elevations, 0.1, 1.2,
6232/// )?;
6233///
6234/// assert_eq!(gas.len(), elevations.len());
6235/// # }
6236/// # Ok::<(), Box<dyn std::error::Error>>(())
6237/// ```
6238pub fn gas_attenuation_default_many_checked(
6239    lat_deg: f64,
6240    lon_deg: f64,
6241    freq_ghz: f64,
6242    elevation_deg: &[f64],
6243    p: f64,
6244    d_m: f64,
6245) -> std::result::Result<Vec<f64>, ItuError> {
6246    validate_common_inputs(lat_deg, lon_deg, freq_ghz, p, d_m).map_err(ItuError::from)?;
6247    let mut out = Vec::with_capacity(elevation_deg.len());
6248    let model = model().map_err(ItuError::from)?;
6249    for &el in elevation_deg {
6250        validate_elevation_deg(el).map_err(ItuError::from)?;
6251        out.push(
6252            model
6253                .atmospheric_attenuation_default_gas_only(lat_deg, lon_deg, freq_ghz, el, p, d_m)
6254                .map_err(ItuError::from)?,
6255        );
6256    }
6257    Ok(out)
6258}
6259
6260/// Computes total atmospheric attenuation for one Earth-space slant path.
6261///
6262/// The returned [`SlantPathContributions`] contains gas, cloud, rain,
6263/// scintillation, and total attenuation in dB. Use [`SlantPathOptions`] to
6264/// override environmental inputs, select exact gaseous attenuation, or disable
6265/// individual components.
6266///
6267/// # Parameters
6268///
6269/// - `lat_deg`: Earth station geodetic latitude in degrees, in `[-90, 90]`.
6270/// - `lon_deg`: Earth station geodetic longitude in degrees.
6271/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
6272/// - `elevation_deg`: link elevation angle above the local horizon in degrees,
6273///   in `(0, 90)`.
6274/// - `p`: percentage of time attenuation is exceeded. Must be positive; for
6275///   example, `0.1` means 0.1% of an average year.
6276/// - `d_m`: physical antenna diameter in metres. It affects scintillation fade
6277///   through aperture averaging and must be positive.
6278/// - `options`: environmental overrides and contribution switches. Defaults
6279///   look up missing environmental values from ITU-R maps and include all
6280///   supported components.
6281///
6282/// # Returns
6283///
6284/// A [`SlantPathContributions`] value containing gas, cloud, rain,
6285/// scintillation, and combined total attenuation in dB.
6286///
6287/// # Errors
6288///
6289/// Returns an error if inputs or options fail validation, or if required model
6290/// data cannot be loaded.
6291///
6292/// # Example
6293///
6294/// ```
6295/// # fn data_available() -> bool {
6296/// #     cfg!(feature = "data")
6297/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
6298/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6299/// #             .join("data/1511/v2_lat.npz")
6300/// #             .exists()
6301/// # }
6302/// # if data_available() {
6303/// use itu_rs::{atmospheric_attenuation_slant_path, SlantPathOptions};
6304///
6305/// let options = SlantPathOptions {
6306///     exact: true,
6307///     ..SlantPathOptions::default()
6308/// };
6309///
6310/// let attenuation = atmospheric_attenuation_slant_path(
6311///     10.0, 20.0, 18.0, 17.5, 0.7, 0.8, options,
6312/// )?;
6313///
6314/// assert!(attenuation.total_db.is_finite());
6315/// assert!(attenuation.gas_db.is_finite());
6316/// # }
6317/// # Ok::<(), Box<dyn std::error::Error>>(())
6318/// ```
6319#[allow(clippy::too_many_arguments)]
6320pub fn atmospheric_attenuation_slant_path(
6321    lat_deg: f64,
6322    lon_deg: f64,
6323    freq_ghz: f64,
6324    elevation_deg: f64,
6325    p: f64,
6326    d_m: f64,
6327    options: SlantPathOptions,
6328) -> std::result::Result<SlantPathContributions, ItuError> {
6329    rust_itur_slant_path_scalar(lat_deg, lon_deg, freq_ghz, elevation_deg, p, d_m, options)
6330        .map_err(ItuError::from)
6331}
6332
6333/// Computes atmospheric attenuation for multiple elevation angles.
6334///
6335/// This is the preferred API when sweeping elevation angles for a fixed site,
6336/// frequency, time percentage, antenna diameter, and option set.
6337///
6338/// # Parameters
6339///
6340/// - `lat_deg`: Earth station geodetic latitude in degrees, in `[-90, 90]`.
6341/// - `lon_deg`: Earth station geodetic longitude in degrees.
6342/// - `freq_ghz`: carrier frequency in GHz. Must be positive.
6343/// - `elevation_deg`: slice of link elevation angles in degrees. Every value
6344///   must be in `(0, 90)`.
6345/// - `p`: percentage of time attenuation is exceeded. Must be positive.
6346/// - `d_m`: physical antenna diameter in metres. Must be positive.
6347/// - `options`: environmental overrides and contribution switches applied to
6348///   every elevation angle.
6349///
6350/// # Returns
6351///
6352/// One [`SlantPathContributions`] value per elevation angle, in input order.
6353///
6354/// # Errors
6355///
6356/// Returns an error if common inputs, options, or any elevation angle fail
6357/// validation, or if required model data cannot be loaded.
6358///
6359/// # Example
6360///
6361/// ```
6362/// # fn data_available() -> bool {
6363/// #     cfg!(feature = "data")
6364/// #         || std::env::var_os("ITU_RS_DATA_DIR").is_some()
6365/// #         || std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6366/// #             .join("data/1511/v2_lat.npz")
6367/// #             .exists()
6368/// # }
6369/// # if data_available() {
6370/// use itu_rs::{atmospheric_attenuation_slant_path_many, SlantPathOptions};
6371///
6372/// let elevations = [5.0, 17.5, 45.0, 89.0];
6373/// let attenuation = atmospheric_attenuation_slant_path_many(
6374///     45.4215,
6375///     -75.6972,
6376///     12.0,
6377///     &elevations,
6378///     0.1,
6379///     1.2,
6380///     SlantPathOptions::default(),
6381/// )?;
6382///
6383/// assert_eq!(attenuation.len(), elevations.len());
6384/// assert!(attenuation.iter().all(|item| item.total_db.is_finite()));
6385/// # }
6386/// # Ok::<(), Box<dyn std::error::Error>>(())
6387/// ```
6388#[allow(clippy::too_many_arguments)]
6389pub fn atmospheric_attenuation_slant_path_many(
6390    lat_deg: f64,
6391    lon_deg: f64,
6392    freq_ghz: f64,
6393    elevation_deg: &[f64],
6394    p: f64,
6395    d_m: f64,
6396    options: SlantPathOptions,
6397) -> std::result::Result<Vec<SlantPathContributions>, ItuError> {
6398    validate_common_inputs(lat_deg, lon_deg, freq_ghz, p, d_m)
6399        .and_then(|_| validate_options(options))
6400        .map_err(ItuError::from)?;
6401
6402    elevation_deg
6403        .iter()
6404        .map(|&el| rust_itur_slant_path_scalar(lat_deg, lon_deg, freq_ghz, el, p, d_m, options))
6405        .collect::<std::result::Result<Vec<_>, _>>()
6406        .map_err(ItuError::from)
6407}