Skip to main content

winds_engine/
terrain.rs

1//! Diagnostic mass-consistent terrain wind (NOABL/CONMESS-style).
2//!
3//! A terrain height grid (in metres) plus a background [`WindField`] and
4//! optional surface wind monitors become a terrain-adjusted field: each
5//! fixed above-ground level is adjusted independently to satisfy 2-D
6//! horizontal mass conservation (`div(u) = 0` with `u = u0 − ∇φ`), so flow
7//! is deflected over and around terrain. This is the cheap diagnostic tier
8//! of terrain flow, not a full CFD solve. Entirely offline and
9//! deterministic.
10
11use crate::field::{Wind, WindField};
12use crate::{Error, Result};
13
14/// Earth's mean radius for the local-tangent metre projection.
15const EARTH_RADIUS_M: f64 = 6_371_000.0;
16
17/// One surface wind observation, in the terrain grid's frame (metres).
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Monitor {
20    /// Easting (frame x), metres.
21    pub x_m: f64,
22    /// Northing (frame z), metres.
23    pub z_m: f64,
24    /// Wind speed, metres per second.
25    pub speed_mps: f64,
26    /// Meteorological FROM-direction in the frame convention: 0° = from
27    /// +x (a headwind), 90° = from the right (see [`Wind::from_speed_direction`]).
28    pub direction_from_deg: f64,
29}
30
31impl Monitor {
32    /// An observation already in the grid's metre frame.
33    pub fn at(x_m: f64, z_m: f64, speed_mps: f64, direction_from_deg: f64) -> Self {
34        Self {
35            x_m,
36            z_m,
37            speed_mps,
38            direction_from_deg,
39        }
40    }
41
42    /// A geographic reading, projected into local metres and rotated from
43    /// compass degrees into the frame. `ref_lat`/`ref_lon` is the point
44    /// that maps to the frame's origin, and `north_azimuth_deg` is the
45    /// compass direction of the frame's +x axis.
46    pub fn from_geo(
47        lat_deg: f64,
48        lon_deg: f64,
49        speed_mps: f64,
50        dir_compass_from_deg: f64,
51        ref_lat_deg: f64,
52        ref_lon_deg: f64,
53        north_azimuth_deg: f64,
54    ) -> Self {
55        let (x_m, z_m) = local_en_m(lat_deg, lon_deg, ref_lat_deg, ref_lon_deg);
56        Self {
57            x_m,
58            z_m,
59            speed_mps,
60            direction_from_deg: (dir_compass_from_deg - north_azimuth_deg).rem_euclid(360.0),
61        }
62    }
63}
64
65/// Local equirectangular projection: `(east, north)` metres from the
66/// reference point, for the small domains a wind network covers. Not for
67/// continental scales.
68pub fn local_en_m(lat_deg: f64, lon_deg: f64, ref_lat_deg: f64, ref_lon_deg: f64) -> (f64, f64) {
69    let dlat = (lat_deg - ref_lat_deg).to_radians();
70    let dlon = (lon_deg - ref_lon_deg).to_radians();
71    let east = EARTH_RADIUS_M * dlon * ref_lat_deg.to_radians().cos();
72    let north = EARTH_RADIUS_M * dlat;
73    (east, north)
74}
75
76/// Parse a monitor file of one reading per line:
77/// `latitude,longitude,speed_ms,direction_compass_from_deg`, `#` comments
78/// and a header line are skipped. Coordinates are projected via
79/// [`local_en_m`] and directions rotated into the frame.
80pub fn monitors_from_geo_csv(
81    text: &str,
82    ref_lat_deg: f64,
83    ref_lon_deg: f64,
84    north_azimuth_deg: f64,
85) -> Result<Vec<Monitor>> {
86    let mut monitors = Vec::new();
87    for (number, line) in text.lines().enumerate() {
88        let line = line.trim();
89        if line.is_empty() || line.starts_with('#') {
90            continue;
91        }
92        let fields: Vec<&str> = line.split(',').collect();
93        if fields.len() < 4 {
94            return Err(Error::invalid(
95                "monitor csv",
96                format!(
97                    "line {}: expected lat,lon,speed_ms,dir_compass_deg",
98                    number + 1
99                ),
100            ));
101        }
102        let num = |token: &str, what: &str| -> Result<f64> {
103            token.trim().parse().map_err(|_| {
104                Error::invalid(
105                    "monitor csv",
106                    format!("line {}: {what} '{token}' is not numeric", number + 1),
107                )
108            })
109        };
110        let (lat, lon) = (num(fields[0], "lat")?, num(fields[1], "lon")?);
111        let speed = num(fields[2], "speed")?;
112        let dir = num(fields[3], "direction")?;
113        if !lat.is_finite()
114            || lat.abs() > 90.0
115            || !lon.is_finite()
116            || !speed.is_finite()
117            || speed < 0.0
118            || !dir.is_finite()
119        {
120            return Err(Error::invalid(
121                "monitor csv",
122                format!(
123                    "line {}: readings must be finite with nonnegative speed",
124                    number + 1
125                ),
126            ));
127        }
128        monitors.push(Monitor::from_geo(
129            lat,
130            lon,
131            speed,
132            dir,
133            ref_lat_deg,
134            ref_lon_deg,
135            north_azimuth_deg,
136        ));
137    }
138    Ok(monitors)
139}
140
141/// Terrain heights on an axis-aligned, uniform grid in metres. Row-major:
142/// `heights[i * ncols + j]` is column `j` (east), row `i` (north). Position
143/// `y` is up; the grid lies in the `x`–`z` plane.
144#[derive(Debug, Clone)]
145pub struct HeightGrid {
146    /// Frame-x of the first column centre.
147    pub origin_x: f64,
148    /// Frame-z of the first row centre.
149    pub origin_y: f64,
150    /// Column spacing, metres.
151    pub dx: f64,
152    /// Row spacing, metres.
153    pub dy: f64,
154    /// Number of columns (east).
155    pub ncols: usize,
156    /// Number of rows (north).
157    pub nrows: usize,
158    /// Row-major height samples, `ncols * nrows`.
159    pub heights: Vec<f64>,
160}
161
162impl HeightGrid {
163    /// Build and validate a grid.
164    pub fn new(
165        origin_x: f64,
166        origin_y: f64,
167        dx: f64,
168        dy: f64,
169        ncols: usize,
170        nrows: usize,
171        heights: Vec<f64>,
172    ) -> Result<Self> {
173        if dx <= 0.0 || dy <= 0.0 || !dx.is_finite() || !dy.is_finite() {
174            return Err(Error::invalid("height grid", "spacing must be positive"));
175        }
176        if ncols < 2 || nrows < 2 {
177            return Err(Error::invalid("height grid", "needs at least 2x2 cells"));
178        }
179        if heights.len() != ncols * nrows {
180            return Err(Error::invalid(
181                "height grid",
182                "height count does not match the grid shape",
183            ));
184        }
185        if !heights.iter().all(|h| h.is_finite()) {
186            return Err(Error::invalid("height grid", "heights must be finite"));
187        }
188        Ok(Self {
189            origin_x,
190            origin_y,
191            dx,
192            dy,
193            ncols,
194            nrows,
195            heights,
196        })
197    }
198
199    /// Terrain height at a horizontal position, bilinear between samples;
200    /// clamps at the grid edges.
201    pub fn height_at(&self, x: f64, z: f64) -> f64 {
202        let fx = ((x - self.origin_x) / self.dx).clamp(0.0, self.ncols as f64 - 1.0);
203        let fy = ((z - self.origin_y) / self.dy).clamp(0.0, self.nrows as f64 - 1.0);
204        let (j, i) = (fx.floor() as usize, fy.floor() as usize);
205        let (rx, ry) = (fx - j as f64, fy - i as f64);
206        let j1 = (j + 1).min(self.ncols - 1);
207        let i1 = (i + 1).min(self.nrows - 1);
208        let f = |i: usize, j: usize| self.heights[i * self.ncols + j];
209        (1.0 - ry) * ((1.0 - rx) * f(i, j) + rx * f(i, j1))
210            + ry * ((1.0 - rx) * f(i1, j) + rx * f(i1, j1))
211    }
212
213    /// A grid from an ASCII ESRI `.asc` raster (projected, metres): header
214    /// `ncols/nrows/xllcorner/yllcorner/cellsize`, then row-major heights
215    /// (north-to-south, west-to-east).
216    pub fn from_ascii(text: &str) -> Result<Self> {
217        let mut ncols = None;
218        let mut nrows = None;
219        let mut xll = None;
220        let mut yll = None;
221        let mut cellsize = None;
222        let mut heights = Vec::new();
223        for line in text.lines() {
224            let line = line.trim();
225            if line.is_empty() {
226                continue;
227            }
228            let mut parts = line.split_whitespace();
229            match parts.next().unwrap_or("").to_ascii_lowercase().as_str() {
230                "ncols" => ncols = parse_asc_num(parts.next()),
231                "nrows" => nrows = parse_asc_num(parts.next()),
232                "xllcorner" | "xllcenter" => xll = parse_asc_num(parts.next()),
233                "yllcorner" | "yllcenter" => yll = parse_asc_num(parts.next()),
234                "cellsize" => cellsize = parse_asc_num(parts.next()),
235                "nodata_value" => continue,
236                _ => {
237                    for token in line.split_whitespace() {
238                        if let Ok(v) = token.parse::<f64>() {
239                            heights.push(v);
240                        }
241                    }
242                }
243            }
244        }
245        let ncols = ncols.ok_or_else(|| Error::invalid("ascii dem", "missing ncols"))? as usize;
246        let nrows = nrows.ok_or_else(|| Error::invalid("ascii dem", "missing nrows"))? as usize;
247        let cellsize = cellsize.ok_or_else(|| Error::invalid("ascii dem", "missing cellsize"))?;
248        let (xll, yll) = (
249            xll.ok_or_else(|| Error::invalid("ascii dem", "missing xllcorner"))?,
250            yll.ok_or_else(|| Error::invalid("ascii dem", "missing yllcorner"))?,
251        );
252        if heights.len() != ncols * nrows {
253            return Err(Error::invalid(
254                "ascii dem",
255                "height count does not match header",
256            ));
257        }
258        // Rows are north→south; our rows run +z (north), so reverse them.
259        let mut north_up = Vec::with_capacity(heights.len());
260        for row in heights.chunks_exact(ncols).rev() {
261            north_up.extend_from_slice(row);
262        }
263        Self::new(xll, yll, cellsize, cellsize, ncols, nrows, north_up)
264    }
265}
266
267fn parse_asc_num(token: Option<&str>) -> Option<f64> {
268    token.and_then(|s| s.parse().ok())
269}
270
271/// A diagnostic terrain-adjusted wind field.
272#[derive(Debug, Clone)]
273pub struct TerrainFlowField {
274    grid: HeightGrid,
275    /// Fixed above-ground sampling levels, ascending.
276    levels_agl: Vec<f64>,
277    /// Adjusted horizontal wind per level, row-major `u` and `v` (frame x
278    /// and z), `levels * ncols * nrows` each.
279    u: Vec<f64>,
280    v: Vec<f64>,
281}
282
283impl TerrainFlowField {
284    /// Build a terrain-adjusted field. `base` seeds the first guess;
285    /// `monitors` are surface wind observations `(x, z, speed_mps,
286    /// from_deg)` blended near the ground and relaxed toward the base field
287    /// with height. `obs_length_m` sets the horizontal influence radius,
288    /// `surface_decay_m` the vertical relaxation of monitor influence, and
289    /// `terrain_scale_m` the depth over which the terrain forcing (flow
290    /// rising over the ground) decays.
291    pub fn adjust(
292        grid: HeightGrid,
293        base: &dyn WindField,
294        monitors: &[Monitor],
295        levels_agl: &[f64],
296        obs_length_m: f64,
297        surface_decay_m: f64,
298        terrain_scale_m: f64,
299    ) -> Result<Self> {
300        if levels_agl.is_empty() || levels_agl.iter().any(|h| !h.is_finite() || *h < 0.0) {
301            return Err(Error::invalid(
302                "levels_agl",
303                "must be finite and nonnegative",
304            ));
305        }
306        if obs_length_m <= 0.0 || surface_decay_m <= 0.0 || terrain_scale_m <= 0.0 {
307            return Err(Error::invalid("terrain", "length scales must be positive"));
308        }
309        if levels_agl.is_empty() || levels_agl.iter().any(|h| !h.is_finite() || *h < 0.0) {
310            return Err(Error::invalid(
311                "levels_agl",
312                "must be finite and nonnegative",
313            ));
314        }
315        if obs_length_m <= 0.0 || surface_decay_m <= 0.0 {
316            return Err(Error::invalid("terrain", "length scales must be positive"));
317        }
318        for m in monitors {
319            if !m.x_m.is_finite() || !m.z_m.is_finite() || !m.direction_from_deg.is_finite() {
320                return Err(Error::invalid("monitor", "must be finite"));
321            }
322            if !m.speed_mps.is_finite() || m.speed_mps < 0.0 {
323                return Err(Error::invalid("monitor", "speed must be nonnegative"));
324            }
325        }
326        let (ncols, nrows) = (grid.ncols, grid.nrows);
327        let n = ncols * nrows;
328        let inv_dx2 = 1.0 / (grid.dx * grid.dx);
329        let inv_dy2 = 1.0 / (grid.dy * grid.dy);
330        let diag = -2.0 * (inv_dx2 + inv_dy2);
331        let omega = 1.6;
332        let mut u = Vec::with_capacity(levels_agl.len() * n);
333        let mut v = Vec::with_capacity(levels_agl.len() * n);
334        let mut phi = vec![0.0; n];
335        for &level in levels_agl {
336            // First guess: base field + monitor correction, relaxed with height.
337            let mut u0 = vec![0.0; n];
338            let mut v0 = vec![0.0; n];
339            for i in 0..nrows {
340                for j in 0..ncols {
341                    let x = grid.origin_x + j as f64 * grid.dx;
342                    let z = grid.origin_y + i as f64 * grid.dy;
343                    let height = grid.heights[i * ncols + j] + level;
344                    let bg = base.wind(0.0, [x, height, z]);
345                    let (mut cx, mut cz, mut wsum) = (0.0, 0.0, 0.0);
346                    for m in monitors {
347                        let mw = Wind::from_speed_direction(m.speed_mps, m.direction_from_deg);
348                        let bg_m = base.wind(0.0, [m.x_m, grid.height_at(m.x_m, m.z_m), m.z_m]);
349                        let dx = x - m.x_m;
350                        let dz = z - m.z_m;
351                        let weight = (-(dx * dx + dz * dz) / (obs_length_m * obs_length_m)).exp()
352                            * (-level / surface_decay_m).exp();
353                        cx += weight * (mw.x_mps - bg_m.x_mps);
354                        cz += weight * (mw.z_mps - bg_m.z_mps);
355                        wsum += weight;
356                    }
357                    if wsum > 0.0 {
358                        // A regularizer keeps a lone distant monitor from
359                        // overriding the background everywhere.
360                        let w_floor = 0.1;
361                        cx /= wsum + w_floor;
362                        cz /= wsum + w_floor;
363                    }
364                    u0[i * ncols + j] = bg.x_mps + cx;
365                    v0[i * ncols + j] = bg.z_mps + cz;
366                }
367            }
368            // Solve div(u) = 0, u = u0 - grad(phi), including the
369            // terrain-induced vertical divergence: air forced up a slope
370            // converges horizontally, air descending into a lee diverges.
371            // laplacian(phi) = div_h(u0) - (u0 . grad(h)) * exp(-z / H) / H.
372            let level_decay = (-level / terrain_scale_m).exp() / terrain_scale_m;
373            let slopes = |u0x: f64, u0z: f64, i: usize, j: usize| -> f64 {
374                let dhdx = (grid.heights[i * ncols + (j + 1).min(ncols - 1)]
375                    - grid.heights[i * ncols + j.saturating_sub(1)])
376                    / (2.0 * grid.dx);
377                let dhdz = (grid.heights[(i + 1).min(nrows - 1) * ncols + j]
378                    - grid.heights[i.saturating_sub(1) * ncols + j])
379                    / (2.0 * grid.dy);
380                u0x * dhdx + u0z * dhdz
381            };
382            phi.fill(0.0);
383            for _ in 0..4000 {
384                let mut max_step = 0.0_f64;
385                for i in 1..nrows - 1 {
386                    for j in 1..ncols - 1 {
387                        let ip = (i + 1).min(nrows - 1);
388                        let im = i.saturating_sub(1);
389                        let jp = (j + 1).min(ncols - 1);
390                        let jm = j.saturating_sub(1);
391                        let lap = (phi[i * ncols + jp] + phi[i * ncols + jm]) * inv_dx2
392                            + (phi[ip * ncols + j] + phi[im * ncols + j]) * inv_dy2
393                            + diag * phi[i * ncols + j];
394                        let div = (u0[i * ncols + jp] - u0[i * ncols + jm]) / (2.0 * grid.dx)
395                            + (v0[ip * ncols + j] - v0[im * ncols + j]) / (2.0 * grid.dy);
396                        let rhs =
397                            div - slopes(u0[i * ncols + j], v0[i * ncols + j], i, j) * level_decay;
398                        let updated = phi[i * ncols + j] + omega * (rhs - lap) / diag;
399                        max_step = max_step.max((updated - phi[i * ncols + j]).abs());
400                        phi[i * ncols + j] = updated;
401                    }
402                }
403                if max_step < 1e-6 {
404                    break;
405                }
406            }
407            for i in 0..nrows {
408                for j in 0..ncols {
409                    let edge = i == 0 || i == nrows - 1 || j == 0 || j == ncols - 1;
410                    if edge {
411                        // The domain is embedded in a larger uniform field: the
412                        // boundary holds the background wind.
413                        u.push(u0[i * ncols + j]);
414                        v.push(v0[i * ncols + j]);
415                        continue;
416                    }
417                    let jp = (j + 1).min(ncols - 1);
418                    let jm = j.saturating_sub(1);
419                    let ip = (i + 1).min(nrows - 1);
420                    let im = i.saturating_sub(1);
421                    let gx = (phi[i * ncols + jp] - phi[i * ncols + jm]) / (2.0 * grid.dx);
422                    let gz = (phi[ip * ncols + j] - phi[im * ncols + j]) / (2.0 * grid.dy);
423                    u.push(u0[i * ncols + j] - gx);
424                    v.push(v0[i * ncols + j] - gz);
425                }
426            }
427        }
428        Ok(Self {
429            grid,
430            levels_agl: levels_agl.to_vec(),
431            u,
432            v,
433        })
434    }
435
436    fn sample_level(&self, level: usize, x: f64, z: f64) -> (f64, f64) {
437        let (ncols, nrows) = (self.grid.ncols, self.grid.nrows);
438        let fx = ((x - self.grid.origin_x) / self.grid.dx).clamp(0.0, ncols as f64 - 1.0);
439        let fy = ((z - self.grid.origin_y) / self.grid.dy).clamp(0.0, nrows as f64 - 1.0);
440        let (j, i) = (fx.floor() as usize, fy.floor() as usize);
441        let (rx, ry) = (fx - j as f64, fy - i as f64);
442        let j1 = (j + 1).min(ncols - 1);
443        let i1 = (i + 1).min(nrows - 1);
444        let base = level * ncols * nrows;
445        let g = |arr: &[f64], i: usize, j: usize| arr[base + i * ncols + j];
446        let sample = |arr: &[f64]| {
447            (1.0 - ry) * ((1.0 - rx) * g(arr, i, j) + rx * g(arr, i, j1))
448                + ry * ((1.0 - rx) * g(arr, i1, j) + rx * g(arr, i1, j1))
449        };
450        (sample(&self.u), sample(&self.v))
451    }
452
453    /// Horizontal wind at a world position: nearest above-ground level.
454    pub fn horizontal_at(&self, x: f64, y: f64, z: f64) -> (f64, f64) {
455        let agl = y - self.grid.height_at(x, z);
456        let mut level = 0;
457        for (k, &h) in self.levels_agl.iter().enumerate() {
458            if agl <= h {
459                level = k;
460                break;
461            }
462            level = k;
463        }
464        self.sample_level(level, x, z)
465    }
466}
467
468impl WindField for TerrainFlowField {
469    fn wind(&self, _time_s: f64, position_m: [f64; 3]) -> Wind {
470        let (u, v) = self.horizontal_at(position_m[0], position_m[1], position_m[2]);
471        Wind {
472            x_mps: u,
473            y_mps: 0.0,
474            z_mps: v,
475        }
476    }
477}