//! Safe Rust wrapper around the ITWOM / Longley-Rice FFI. //! //! The underlying C++ ITWOM implementation has function-local `static` //! scratch variables. Our vendored copy (`cpp/itwom3.0.cpp`) changes those //! to `thread_local`, which gives every thread its own independent state //! so parallel calls do not race. use std::ffi::{c_char, c_double, c_int}; unsafe extern "C" { fn splat_point_to_point( elev: *mut c_double, tht_m: c_double, rht_m: c_double, eps_dielect: c_double, sgm_conductivity: c_double, eno_ns_surfref: c_double, frq_mhz: c_double, radio_climate: c_int, pol: c_int, conf: c_double, rel: c_double, dbloss: *mut c_double, strmode: *mut c_char, strmode_len: c_int, errnum: *mut c_int, ); fn splat_point_to_point_itm( elev: *mut c_double, tht_m: c_double, rht_m: c_double, eps_dielect: c_double, sgm_conductivity: c_double, eno_ns_surfref: c_double, frq_mhz: c_double, radio_climate: c_int, pol: c_int, conf: c_double, rel: c_double, dbloss: *mut c_double, strmode: *mut c_char, strmode_len: c_int, errnum: *mut c_int, ); fn splat_itwom_version() -> c_double; } #[derive(Debug, Clone, Copy)] pub struct LrParams { pub eps_dielect: f64, pub sgm_conductivity: f64, pub eno_ns_surfref: f64, pub frq_mhz: f64, pub radio_climate: i32, pub pol: i32, pub conf: f64, pub rel: f64, } #[derive(Debug, Clone)] pub struct PathLoss { pub db_loss: f64, pub mode: String, pub err: i32, } #[derive(Debug, Clone, Copy)] pub enum Model { Itwom, LongleyRice, } /// Compute path loss along a terrain profile. /// /// `elev` layout matches the Longley-Rice convention: /// - `elev[0]`: number of samples minus one (as f64) /// - `elev[1]`: distance between samples in meters /// - `elev[2..]`: terrain elevations in meters /// /// Safe to call from multiple threads concurrently (each thread has its /// own ITWOM internal state via `thread_local`). pub fn path_loss( model: Model, elev: &mut [f64], tht_m: f64, rht_m: f64, params: &LrParams, ) -> PathLoss { let mut db_loss: f64 = 0.0; let mut err: i32 = 0; let mut mode_buf = [0u8; 64]; invoke(model, elev, tht_m, rht_m, params, &mut db_loss, &mut mode_buf, &mut err); let end = mode_buf.iter().position(|&b| b == 0).unwrap_or(mode_buf.len()); let mode = String::from_utf8_lossy(&mode_buf[..end]).into_owned(); PathLoss { db_loss, mode, err } } /// Loss-only fast path: identical math to `path_loss`, but skips the /// UTF-8 validation + `String` allocation for the propagation-mode label. /// Used by the parallel radial sweep where the mode is discarded anyway — /// at ~200 M invocations per full map, keeping the allocator quiet measurably /// reduces rayon contention. pub fn path_loss_db( model: Model, elev: &mut [f64], tht_m: f64, rht_m: f64, params: &LrParams, ) -> f64 { let mut db_loss: f64 = 0.0; let mut err: i32 = 0; let mut mode_buf = [0u8; 1]; // unused, but FFI requires a valid pointer invoke(model, elev, tht_m, rht_m, params, &mut db_loss, &mut mode_buf, &mut err); db_loss } #[inline] fn invoke( model: Model, elev: &mut [f64], tht_m: f64, rht_m: f64, params: &LrParams, db_loss: &mut f64, mode_buf: &mut [u8], err: &mut i32, ) { unsafe { let f = match model { Model::Itwom => splat_point_to_point, Model::LongleyRice => splat_point_to_point_itm, }; f( elev.as_mut_ptr(), tht_m, rht_m, params.eps_dielect, params.sgm_conductivity, params.eno_ns_surfref, params.frq_mhz, params.radio_climate, params.pol, params.conf, params.rel, db_loss, mode_buf.as_mut_ptr().cast(), mode_buf.len() as c_int, err, ); } } pub fn itwom_version() -> f64 { unsafe { splat_itwom_version() } } #[cfg(test)] mod tests { use super::*; #[test] fn version_is_sane() { let v = itwom_version(); assert!(v >= 2.0 && v < 10.0, "unexpected ITWOM version: {v}"); } #[test] fn flat_path_returns_finite_loss() { // 10 km flat path at sea level, 30 m antennas, 426 MHz vertical. let n_minus_1 = 99.0_f64; let delta = 100.0_f64; // meters between samples let mut elev = vec![n_minus_1, delta]; elev.extend(std::iter::repeat_n(0.0_f64, (n_minus_1 as usize) + 1)); let p = LrParams { eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0, frq_mhz: 426.0, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5, }; let r = path_loss(Model::Itwom, &mut elev, 30.0, 30.0, &p); assert!(r.db_loss.is_finite(), "loss was not finite: {:?}", r); assert!(r.db_loss > 80.0 && r.db_loss < 200.0, "implausible loss: {:?}", r); } #[test] fn parallel_calls_do_not_race() { use rayon::prelude::*; let p = LrParams { eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0, frq_mhz: 426.0, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5, }; let n_minus_1 = 99.0_f64; let delta = 100.0_f64; let mut elev = vec![n_minus_1, delta]; elev.extend(std::iter::repeat_n(0.0_f64, (n_minus_1 as usize) + 1)); // Run 1024 identical calls across the rayon pool. If thread_local // isolation is broken, we get data races / NaNs / differing results. let results: Vec = (0..1024_u32) .into_par_iter() .map(|_| path_loss(Model::Itwom, &mut elev.clone(), 30.0, 30.0, &p).db_loss) .collect(); let first = results[0]; assert!(first.is_finite()); for (i, r) in results.iter().enumerate() { assert_eq!( r.to_bits(), first.to_bits(), "bit-diff at index {i}: {r} vs {first}" ); } } }