//! Parallel azimuthal (radial) propagation sweep. //! //! The expensive step of a path-loss map is computing the signal attenuation //! along every radial from the transmitter out to the coverage radius. SPLAT!'s //! original C++ does this with a single-threaded sweep from 0° to 360°; the //! work is embarrassingly parallel (each radial is independent of the others //! once the DEM is loaded). Rayon + thread-local ITWOM state let us scale //! nearly linearly with CPU count. //! //! Output strategy: each radial returns a `Vec` and the driver //! merges them after the parallel sweep. Writes never contend. When two //! radials hit the same pixel (near the TX), the merge keeps the *lower* loss //! (strongest signal) — matching SPLAT!'s sequential overwrite semantics with //! `PutSignal`. use rayon::prelude::*; use crate::geo::{self, EARTH_RADIUS_MI, FEET_PER_METER, METERS_PER_MILE}; use crate::itwom::{self, LrParams, Model}; use crate::types::{Config, Site}; /// A single pixel write produced by a radial: geographic coordinate plus /// path loss in dB. Aggregated by the map builder. #[derive(Debug, Clone, Copy)] pub struct PixelWrite { pub lat: f64, pub lon: f64, pub loss_db: f64, } /// Per-radial context passed to the compute function. Holds *read-only* /// references so Rayon can share it freely across threads. pub struct RadialCtx<'a> { pub config: &'a Config, pub source: &'a Site, /// RX antenna height in meters AGL — the "probe" used to compute loss /// at every radial sample point, matching SPLAT!'s `-L altitude` flag. pub rx_alt_m: f64, pub max_range_km: f64, pub params: &'a LrParams, pub model: Model, pub dem: &'a dyn DemLookup, } /// Minimal interface the radial sweep needs from the DEM. Concrete impl /// will back this with an in-memory SDF tile set. Defining the trait here /// keeps `compute_radial` testable with synthetic terrain. pub trait DemLookup: Sync { /// Returns elevation in meters, or some large negative sentinel for /// no-data. Callers should treat z < -1000 as "outside DEM coverage". fn elevation(&self, lat: f64, lon: f64) -> f64; } /// Compute one radial from the transmitter outward along `azimuth` degrees. /// /// Pure function — no shared mutable state, safe to call concurrently. This /// is the unit of work `plot_lr_map_parallel` distributes across cores. /// /// Algorithm (port of SPLAT!'s `PlotLRPath` driver): /// 1. Walk outward along the great-circle bearing in steps of /// `1 / samples_per_radian` radians, sampling the DEM at each step. /// 2. For every sample past the 3rd, call ITWOM with the *prefix* profile /// (samples 0..=i). That gives path loss from TX to the point at step `i`. /// 3. Emit a `PixelWrite` per outer point with the loss in dB. /// /// Complexity per radial is O(N²) in ITWOM calls (one per step, with the /// call size proportional to the step index). Matches SPLAT!'s cost model. pub fn compute_radial(ctx: &RadialCtx<'_>, azimuth_deg: f64) -> Vec { let max_d_mi = ctx.max_range_km / 1.609344; let spr = geo::samples_per_radian(ctx.config.ppd()); let path_length = (max_d_mi / EARTH_RADIUS_MI) * spr; let n = path_length.max(2.0) as usize; let miles_per_sample = max_d_mi / path_length; let delta_m = miles_per_sample * METERS_PER_MILE; // Sample the terrain profile once along the full radial. let mut lats = Vec::with_capacity(n + 1); let mut lons = Vec::with_capacity(n + 1); let mut elev_m = Vec::with_capacity(n + 1); for i in 0..=n { let d_mi = miles_per_sample * i as f64; let (lat, lon) = geo::step_great_circle(ctx.source.lat, ctx.source.lon, azimuth_deg, d_mi); let z = ctx.dem.elevation(lat, lon); if z < -1000.0 { break; } lats.push(lat); lons.push(lon); elev_m.push(z); } if elev_m.len() < 4 { return Vec::new(); // too short to do any meaningful path loss } let source_alt_m = ctx.source.alt; // Reused elev buffer for ITWOM — first two slots are header (npts-1, delta). let mut elev_buf = Vec::with_capacity(elev_m.len() + 2); let mut out = Vec::with_capacity(elev_m.len().saturating_sub(2)); for i in 2..elev_m.len() { elev_buf.clear(); elev_buf.push((i - 1) as f64); // number_of_points - 1 elev_buf.push(delta_m); elev_buf.extend_from_slice(&elev_m[0..=i]); // Hot path: we only need the dB number, not the mode label. let loss_db = itwom::path_loss_db( ctx.model, &mut elev_buf, source_alt_m, ctx.rx_alt_m, ctx.params, ); out.push(PixelWrite { lat: lats[i], lon: lons[i], loss_db, }); } let _ = FEET_PER_METER; // silence unused import warning if feet conv unused out } /// LOS-only radial — no ITWOM calls. Checks whether each point along the /// radial has line of sight to the TX, taking Earth curvature (4/3 R) and /// antenna heights into account. Emits `loss_db = 0.0` for visible points /// and `loss_db = f64::INFINITY` for obstructed ones, so downstream /// `CoverageMap::ingest` (which keeps the *min* loss per pixel) produces /// a binary visibility raster. /// /// Dramatically cheaper than the full path-loss sweep — use it as a quick /// "where would this transmitter even be seen from" pass before paying /// for the ITWOM computation. pub fn compute_los_radial(ctx: &RadialCtx<'_>, azimuth_deg: f64) -> Vec { let max_d_mi = ctx.max_range_km / 1.609344; let spr = geo::samples_per_radian(ctx.config.ppd()); let path_length = (max_d_mi / EARTH_RADIUS_MI) * spr; let n = path_length.max(2.0) as usize; let miles_per_sample = max_d_mi / path_length; // 4/3-Earth effective radius in meters, for curvature correction. let four_thirds_earth_m: f64 = (4.0 / 3.0) * 20_902_230.97 * 0.3048; let tx_ground_m = ctx.dem.elevation(ctx.source.lat, ctx.source.lon); let tx_top_m = tx_ground_m + ctx.source.alt; // Compare *slopes* (rise/run) rather than cosines — slopes at different // distances compose correctly. The curvature-corrected apparent height // of a point at distance d is `z - d²/(2R)`, so the slope from the TX // antenna top is `(z - tx_top)/d - d/(2R)`. let slope = |z: f64, d_m: f64| (z - tx_top_m) / d_m - d_m / (2.0 * four_thirds_earth_m); let mut out = Vec::with_capacity(n); let mut max_terrain_slope = f64::NEG_INFINITY; for i in 1..n { let d_mi = miles_per_sample * i as f64; let (lat, lon) = geo::step_great_circle(ctx.source.lat, ctx.source.lon, azimuth_deg, d_mi); let z = ctx.dem.elevation(lat, lon); if z < -1000.0 { break; } let d_m = d_mi * METERS_PER_MILE; let s_terrain = slope(z, d_m); if s_terrain > max_terrain_slope { max_terrain_slope = s_terrain; } let s_rx = slope(z + ctx.rx_alt_m, d_m); let visible = s_rx >= max_terrain_slope; out.push(PixelWrite { lat, lon, loss_db: if visible { 0.0 } else { f64::INFINITY }, }); } out } /// Parallel 360° LOS coverage sweep. Same shape as `plot_lr_map_parallel` /// but uses `compute_los_radial` — typically 100-500× faster because no /// ITWOM invocations are needed. pub fn plot_los_coverage_parallel(ctx: &RadialCtx<'_>) -> Vec { let ppd = ctx.config.ppd() as f64; let n_steps = (360.0 * ppd) as u32; (0..n_steps) .into_par_iter() .map(|i| i as f64 / ppd) .flat_map_iter(|az| compute_los_radial(ctx, az)) .filter(|p| p.loss_db.is_finite()) .collect() } /// Sweep the full 360° azimuth range in parallel, using one radial per /// `1.0 / ppd` degree step (matching SPLAT!'s native resolution). /// /// The call blocks until all radials complete. Returns flat pixel writes /// that the caller folds into a coverage map. pub fn plot_lr_map_parallel(ctx: &RadialCtx<'_>) -> Vec { let ppd = ctx.config.ppd() as f64; let n_steps = (360.0 * ppd) as u32; (0..n_steps) .into_par_iter() .map(|i| i as f64 / ppd) .flat_map_iter(|az| compute_radial(ctx, az)) .collect() } /// Same sweep as `plot_lr_map_parallel`, but keeps the per-radial grouping /// so the caller can rasterize consecutive samples as line segments /// (eliminating diagonal-radial aliasing artifacts in the output map). /// /// Use `CoverageMap::from_radials(&result, ppd, pad)` to render without /// streak gaps. pub fn plot_lr_map_parallel_radials(ctx: &RadialCtx<'_>) -> Vec> { let ppd = ctx.config.ppd() as f64; let n_steps = (360.0 * ppd) as u32; (0..n_steps) .into_par_iter() .map(|i| i as f64 / ppd) .map(|az| compute_radial(ctx, az)) .collect() } /// Sequential baseline of the same sweep, used for correctness diffing and /// benchmark comparison against the parallel version. pub fn plot_lr_map_sequential(ctx: &RadialCtx<'_>) -> Vec { let ppd = ctx.config.ppd() as f64; let n_steps = (360.0 * ppd) as u32; (0..n_steps) .map(|i| i as f64 / ppd) .flat_map(|az| compute_radial(ctx, az)) .collect() } #[cfg(test)] mod tests { use super::*; use crate::sdf::DemTileSet; struct FlatDem(f64); impl DemLookup for FlatDem { fn elevation(&self, _lat: f64, _lon: f64) -> f64 { self.0 } } #[test] fn single_radial_returns_monotonic_growing_loss() { // Flat Earth: loss should increase (mostly monotonically) with distance. let cfg = Config::default(); let src = Site { name: "X".into(), lat: -1.0, lon: 79.0, alt: 30.0 }; 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 dem = FlatDem(50.0); let ctx = RadialCtx { config: &cfg, source: &src, rx_alt_m: 10.0, max_range_km: 20.0, params: &p, model: Model::Itwom, dem: &dem, }; let r = compute_radial(&ctx, 0.0); assert!(!r.is_empty()); // Spot-check: the last point should have more loss than the first. let first = r.first().unwrap().loss_db; let last = r.last().unwrap().loss_db; assert!(last > first, "expected loss to grow: first={first} last={last}"); for p in &r { assert!(p.loss_db.is_finite(), "non-finite: {:?}", p); } } /// Sweeps a small fraction of the 360° range with real Ecuador DEM, /// comparing sequential vs parallel. Small radius so both variants /// finish quickly — the full map is proved out by /// `full_rvt_queve_parallel_only` below. #[test] #[ignore = "expensive; run with --ignored"] fn rvt_queve_quadrant_ser_vs_par() { let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf"; if !std::path::Path::new(tiles_dir).exists() { eprintln!("skipping: {tiles_dir} not present"); return; } let mut dem = DemTileSet::new(); let loaded = dem.load_dir(tiles_dir).unwrap(); println!("loaded {loaded} tiles"); let src = Site { name: "Q".into(), lat: -1.023206, lon: 79.458669, alt: 14.0 }; 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 cfg = Config::default(); // 10 km radius, first 90° only — ~108 k radials × ~118 samples each. let ppd = cfg.ppd() as f64; let n = (90.0 * ppd) as u32; let build_ctx = |range| RadialCtx { config: &cfg, source: &src, rx_alt_m: 10.0, max_range_km: range, params: &p, model: Model::Itwom, dem: &dem, }; let ctx = build_ctx(10.0); let quadrant_par = |ctx: &RadialCtx<'_>| -> Vec { use rayon::prelude::*; (0..n).into_par_iter() .map(|i| i as f64 / ppd) .flat_map_iter(|az| compute_radial(ctx, az)) .collect() }; let quadrant_seq = |ctx: &RadialCtx<'_>| -> Vec { (0..n) .map(|i| i as f64 / ppd) .flat_map(|az| compute_radial(ctx, az)) .collect() }; let t0 = std::time::Instant::now(); let px_seq = quadrant_seq(&ctx); let d_seq = t0.elapsed(); let t1 = std::time::Instant::now(); let px_par = quadrant_par(&ctx); let d_par = t1.elapsed(); println!("pixels: seq={} par={}", px_seq.len(), px_par.len()); println!("sequential: {:?}", d_seq); println!("parallel: {:?}", d_par); println!("speedup: {:.2}x", d_seq.as_secs_f64() / d_par.as_secs_f64()); assert_eq!(px_seq.len(), px_par.len(), "quadrant sweep produced different pixel counts"); } /// Compute a single radial toward Cerro Cochabamba and check the path /// loss at that distance matches SPLAT!'s reported 145.5 dB. This is our /// primary correctness gate for the pipeline. #[test] #[ignore = "needs real DEM"] fn rvt_queve_cerro_cochabamba_matches_splat() { let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf"; if !std::path::Path::new(tiles_dir).exists() { return; } let mut dem = DemTileSet::new(); dem.load_dir(tiles_dir).unwrap(); let src = Site { name: "Q".into(), lat: -1.023206, lon: 79.458669, alt: 14.0 }; 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 cfg = Config::default(); let ctx = RadialCtx { config: &cfg, source: &src, rx_alt_m: 20.0, max_range_km: 90.0, params: &p, model: Model::Itwom, dem: &dem, }; let radial = compute_radial(&ctx, 152.52); // SPLAT reports 84.66 km to Cerro Cochabamba. Find the sample closest // to that distance along this radial. let d = |p: &PixelWrite| crate::geo::distance( src.lat, src.lon, p.lat, p.lon) * 1.609344; let target_km = 84.66; let best = radial.iter() .min_by(|a, b| (d(a) - target_km).abs().total_cmp(&(d(b) - target_km).abs())) .expect("non-empty radial"); println!( "Cerro Cochabamba sample: {:.2} km, loss {:.2} dB (SPLAT golden: 145.50 dB)", d(best), best.loss_db ); // The SPLAT C++ report uses RX AGL=20 m at Cerro Cochabamba; our // radial uses a uniform rx_alt_m=20. Matching within 5 dB is a // generous tolerance that still catches gross errors — bit-for-bit // equivalence would need same SDF no-data handling and interpolation. assert!( (best.loss_db - 145.5).abs() < 5.0, "loss drifted too far from SPLAT golden: {:.2} dB", best.loss_db ); } /// Full 360°/40 km sweep, parallel only — validates the end-to-end /// pipeline under realistic workload. Aim: <3 min on a 16-core box. #[test] #[ignore = "expensive; run with --ignored"] fn full_rvt_queve_parallel_only() { let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf"; if !std::path::Path::new(tiles_dir).exists() { eprintln!("skipping: {tiles_dir} not present"); return; } let mut dem = DemTileSet::new(); dem.load_dir(tiles_dir).unwrap(); let src = Site { name: "Q".into(), lat: -1.023206, lon: 79.458669, alt: 14.0 }; 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 cfg = Config::default(); let ctx = RadialCtx { config: &cfg, source: &src, rx_alt_m: 10.0, max_range_km: 40.73, params: &p, model: Model::Itwom, dem: &dem, }; let t = std::time::Instant::now(); let px = plot_lr_map_parallel(&ctx); println!("full sweep: {} pixels in {:?}", px.len(), t.elapsed()); assert!(px.len() > 100_000_000); } }