Files
SPLAT-Parallel-with-Rust/src/itwom.rs
T
cescobar 9d7d3bac45 Commit inicial: splat-rs — fork/port a Rust de SPLAT!
Fork de SPLAT! (John A. Magliacane, KD2BD, 2002-2014), bajo GPLv2 heredada
del original. El núcleo de cálculo de propagación (ITWOM v3.0, Sid Shumate)
se mantiene sin modificar en cpp/itwom3.0.cpp, llamado vía FFI. El resto
del pipeline (lectura de formatos SPLAT!, reportes, mapas, KML, gnuplot)
se reescribió en Rust, con paralelización nativa vía rayon reemplazando
el paralelismo por múltiples procesos del original. Validado contra el
corpus golden de SPLAT! con drift < 0.02 dB en cálculos ITWOM.
2026-08-23 01:50:41 -05:00

227 lines
6.3 KiB
Rust

//! 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<f64> = (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}"
);
}
}
}