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}