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/// Terrain heights on an axis-aligned, uniform grid in metres. Row-major:
15/// `heights[i * ncols + j]` is column `j` (east), row `i` (north). Position
16/// `y` is up; the grid lies in the `x`–`z` plane.
17#[derive(Debug, Clone)]
18pub struct HeightGrid {
19    /// Frame-x of the first column centre.
20    pub origin_x: f64,
21    /// Frame-z of the first row centre.
22    pub origin_y: f64,
23    /// Column spacing, metres.
24    pub dx: f64,
25    /// Row spacing, metres.
26    pub dy: f64,
27    /// Number of columns (east).
28    pub ncols: usize,
29    /// Number of rows (north).
30    pub nrows: usize,
31    /// Row-major height samples, `ncols * nrows`.
32    pub heights: Vec<f64>,
33}
34
35impl HeightGrid {
36    /// Build and validate a grid.
37    pub fn new(
38        origin_x: f64,
39        origin_y: f64,
40        dx: f64,
41        dy: f64,
42        ncols: usize,
43        nrows: usize,
44        heights: Vec<f64>,
45    ) -> Result<Self> {
46        if dx <= 0.0 || dy <= 0.0 || !dx.is_finite() || !dy.is_finite() {
47            return Err(Error::invalid("height grid", "spacing must be positive"));
48        }
49        if ncols < 2 || nrows < 2 {
50            return Err(Error::invalid("height grid", "needs at least 2x2 cells"));
51        }
52        if heights.len() != ncols * nrows {
53            return Err(Error::invalid(
54                "height grid",
55                "height count does not match the grid shape",
56            ));
57        }
58        if !heights.iter().all(|h| h.is_finite()) {
59            return Err(Error::invalid("height grid", "heights must be finite"));
60        }
61        Ok(Self {
62            origin_x,
63            origin_y,
64            dx,
65            dy,
66            ncols,
67            nrows,
68            heights,
69        })
70    }
71
72    /// Terrain height at a horizontal position, bilinear between samples;
73    /// clamps at the grid edges.
74    pub fn height_at(&self, x: f64, z: f64) -> f64 {
75        let fx = ((x - self.origin_x) / self.dx).clamp(0.0, self.ncols as f64 - 1.0);
76        let fy = ((z - self.origin_y) / self.dy).clamp(0.0, self.nrows as f64 - 1.0);
77        let (j, i) = (fx.floor() as usize, fy.floor() as usize);
78        let (rx, ry) = (fx - j as f64, fy - i as f64);
79        let j1 = (j + 1).min(self.ncols - 1);
80        let i1 = (i + 1).min(self.nrows - 1);
81        let f = |i: usize, j: usize| self.heights[i * self.ncols + j];
82        (1.0 - ry) * ((1.0 - rx) * f(i, j) + rx * f(i, j1))
83            + ry * ((1.0 - rx) * f(i1, j) + rx * f(i1, j1))
84    }
85
86    /// A grid from an ASCII ESRI `.asc` raster (projected, metres): header
87    /// `ncols/nrows/xllcorner/yllcorner/cellsize`, then row-major heights
88    /// (north-to-south, west-to-east).
89    pub fn from_ascii(text: &str) -> Result<Self> {
90        let mut ncols = None;
91        let mut nrows = None;
92        let mut xll = None;
93        let mut yll = None;
94        let mut cellsize = None;
95        let mut heights = Vec::new();
96        for line in text.lines() {
97            let line = line.trim();
98            if line.is_empty() {
99                continue;
100            }
101            let mut parts = line.split_whitespace();
102            match parts.next().unwrap_or("").to_ascii_lowercase().as_str() {
103                "ncols" => ncols = parse_asc_num(parts.next()),
104                "nrows" => nrows = parse_asc_num(parts.next()),
105                "xllcorner" | "xllcenter" => xll = parse_asc_num(parts.next()),
106                "yllcorner" | "yllcenter" => yll = parse_asc_num(parts.next()),
107                "cellsize" => cellsize = parse_asc_num(parts.next()),
108                "nodata_value" => continue,
109                _ => {
110                    for token in line.split_whitespace() {
111                        if let Ok(v) = token.parse::<f64>() {
112                            heights.push(v);
113                        }
114                    }
115                }
116            }
117        }
118        let ncols = ncols.ok_or_else(|| Error::invalid("ascii dem", "missing ncols"))? as usize;
119        let nrows = nrows.ok_or_else(|| Error::invalid("ascii dem", "missing nrows"))? as usize;
120        let cellsize = cellsize.ok_or_else(|| Error::invalid("ascii dem", "missing cellsize"))?;
121        let (xll, yll) = (
122            xll.ok_or_else(|| Error::invalid("ascii dem", "missing xllcorner"))?,
123            yll.ok_or_else(|| Error::invalid("ascii dem", "missing yllcorner"))?,
124        );
125        if heights.len() != ncols * nrows {
126            return Err(Error::invalid(
127                "ascii dem",
128                "height count does not match header",
129            ));
130        }
131        // Rows are north→south; our rows run +z (north), so reverse them.
132        let mut north_up = Vec::with_capacity(heights.len());
133        for row in heights.chunks_exact(ncols).rev() {
134            north_up.extend_from_slice(row);
135        }
136        Self::new(xll, yll, cellsize, cellsize, ncols, nrows, north_up)
137    }
138}
139
140fn parse_asc_num(token: Option<&str>) -> Option<f64> {
141    token.and_then(|s| s.parse().ok())
142}
143
144/// A diagnostic terrain-adjusted wind field.
145#[derive(Debug, Clone)]
146pub struct TerrainFlowField {
147    grid: HeightGrid,
148    /// Fixed above-ground sampling levels, ascending.
149    levels_agl: Vec<f64>,
150    /// Adjusted horizontal wind per level, row-major `u` and `v` (frame x
151    /// and z), `levels * ncols * nrows` each.
152    u: Vec<f64>,
153    v: Vec<f64>,
154}
155
156impl TerrainFlowField {
157    /// Build a terrain-adjusted field. `base` seeds the first guess;
158    /// `monitors` are surface wind observations `(x, z, speed_mps,
159    /// from_deg)` blended near the ground and relaxed toward the base field
160    /// with height. `obs_length_m` sets the horizontal influence radius,
161    /// `surface_decay_m` the vertical relaxation of monitor influence, and
162    /// `terrain_scale_m` the depth over which the terrain forcing (flow
163    /// rising over the ground) decays.
164    pub fn adjust(
165        grid: HeightGrid,
166        base: &dyn WindField,
167        monitors: &[(f64, f64, f64, f64)],
168        levels_agl: &[f64],
169        obs_length_m: f64,
170        surface_decay_m: f64,
171        terrain_scale_m: f64,
172    ) -> Result<Self> {
173        if levels_agl.is_empty() || levels_agl.iter().any(|h| !h.is_finite() || *h < 0.0) {
174            return Err(Error::invalid(
175                "levels_agl",
176                "must be finite and nonnegative",
177            ));
178        }
179        if obs_length_m <= 0.0 || surface_decay_m <= 0.0 || terrain_scale_m <= 0.0 {
180            return Err(Error::invalid("terrain", "length scales must be positive"));
181        }
182        if levels_agl.is_empty() || levels_agl.iter().any(|h| !h.is_finite() || *h < 0.0) {
183            return Err(Error::invalid(
184                "levels_agl",
185                "must be finite and nonnegative",
186            ));
187        }
188        if obs_length_m <= 0.0 || surface_decay_m <= 0.0 {
189            return Err(Error::invalid("terrain", "length scales must be positive"));
190        }
191        for &(x, y, speed, dir) in monitors {
192            if !x.is_finite() || !y.is_finite() || !dir.is_finite() {
193                return Err(Error::invalid("monitor", "must be finite"));
194            }
195            if !speed.is_finite() || speed < 0.0 {
196                return Err(Error::invalid("monitor", "speed must be nonnegative"));
197            }
198        }
199        let (ncols, nrows) = (grid.ncols, grid.nrows);
200        let n = ncols * nrows;
201        let inv_dx2 = 1.0 / (grid.dx * grid.dx);
202        let inv_dy2 = 1.0 / (grid.dy * grid.dy);
203        let diag = -2.0 * (inv_dx2 + inv_dy2);
204        let omega = 1.6;
205        let mut u = Vec::with_capacity(levels_agl.len() * n);
206        let mut v = Vec::with_capacity(levels_agl.len() * n);
207        let mut phi = vec![0.0; n];
208        for &level in levels_agl {
209            // First guess: base field + monitor correction, relaxed with height.
210            let mut u0 = vec![0.0; n];
211            let mut v0 = vec![0.0; n];
212            for i in 0..nrows {
213                for j in 0..ncols {
214                    let x = grid.origin_x + j as f64 * grid.dx;
215                    let z = grid.origin_y + i as f64 * grid.dy;
216                    let height = grid.heights[i * ncols + j] + level;
217                    let bg = base.wind(0.0, [x, height, z]);
218                    let (mut cx, mut cz, mut wsum) = (0.0, 0.0, 0.0);
219                    for &(mx, mz, speed, dir) in monitors {
220                        let mw = Wind::from_speed_direction(speed, dir);
221                        let bg_m = base.wind(0.0, [mx, grid.height_at(mx, mz), mz]);
222                        let dx = x - mx;
223                        let dz = z - mz;
224                        let weight = (-(dx * dx + dz * dz) / (obs_length_m * obs_length_m)).exp()
225                            * (-level / surface_decay_m).exp();
226                        cx += weight * (mw.x_mps - bg_m.x_mps);
227                        cz += weight * (mw.z_mps - bg_m.z_mps);
228                        wsum += weight;
229                    }
230                    if wsum > 0.0 {
231                        // A regularizer keeps a lone distant monitor from
232                        // overriding the background everywhere.
233                        let w_floor = 0.1;
234                        cx /= wsum + w_floor;
235                        cz /= wsum + w_floor;
236                    }
237                    u0[i * ncols + j] = bg.x_mps + cx;
238                    v0[i * ncols + j] = bg.z_mps + cz;
239                }
240            }
241            // Solve div(u) = 0, u = u0 - grad(phi), including the
242            // terrain-induced vertical divergence: air forced up a slope
243            // converges horizontally, air descending into a lee diverges.
244            // laplacian(phi) = div_h(u0) - (u0 . grad(h)) * exp(-z / H) / H.
245            let level_decay = (-level / terrain_scale_m).exp() / terrain_scale_m;
246            let slopes = |u0x: f64, u0z: f64, i: usize, j: usize| -> f64 {
247                let dhdx = (grid.heights[i * ncols + (j + 1).min(ncols - 1)]
248                    - grid.heights[i * ncols + j.saturating_sub(1)])
249                    / (2.0 * grid.dx);
250                let dhdz = (grid.heights[(i + 1).min(nrows - 1) * ncols + j]
251                    - grid.heights[i.saturating_sub(1) * ncols + j])
252                    / (2.0 * grid.dy);
253                u0x * dhdx + u0z * dhdz
254            };
255            phi.fill(0.0);
256            for _ in 0..4000 {
257                let mut max_step = 0.0_f64;
258                for i in 1..nrows - 1 {
259                    for j in 1..ncols - 1 {
260                        let ip = (i + 1).min(nrows - 1);
261                        let im = i.saturating_sub(1);
262                        let jp = (j + 1).min(ncols - 1);
263                        let jm = j.saturating_sub(1);
264                        let lap = (phi[i * ncols + jp] + phi[i * ncols + jm]) * inv_dx2
265                            + (phi[ip * ncols + j] + phi[im * ncols + j]) * inv_dy2
266                            + diag * phi[i * ncols + j];
267                        let div = (u0[i * ncols + jp] - u0[i * ncols + jm]) / (2.0 * grid.dx)
268                            + (v0[ip * ncols + j] - v0[im * ncols + j]) / (2.0 * grid.dy);
269                        let rhs =
270                            div - slopes(u0[i * ncols + j], v0[i * ncols + j], i, j) * level_decay;
271                        let updated = phi[i * ncols + j] + omega * (rhs - lap) / diag;
272                        max_step = max_step.max((updated - phi[i * ncols + j]).abs());
273                        phi[i * ncols + j] = updated;
274                    }
275                }
276                if max_step < 1e-6 {
277                    break;
278                }
279            }
280            for i in 0..nrows {
281                for j in 0..ncols {
282                    let edge = i == 0 || i == nrows - 1 || j == 0 || j == ncols - 1;
283                    if edge {
284                        // The domain is embedded in a larger uniform field: the
285                        // boundary holds the background wind.
286                        u.push(u0[i * ncols + j]);
287                        v.push(v0[i * ncols + j]);
288                        continue;
289                    }
290                    let jp = (j + 1).min(ncols - 1);
291                    let jm = j.saturating_sub(1);
292                    let ip = (i + 1).min(nrows - 1);
293                    let im = i.saturating_sub(1);
294                    let gx = (phi[i * ncols + jp] - phi[i * ncols + jm]) / (2.0 * grid.dx);
295                    let gz = (phi[ip * ncols + j] - phi[im * ncols + j]) / (2.0 * grid.dy);
296                    u.push(u0[i * ncols + j] - gx);
297                    v.push(v0[i * ncols + j] - gz);
298                }
299            }
300        }
301        Ok(Self {
302            grid,
303            levels_agl: levels_agl.to_vec(),
304            u,
305            v,
306        })
307    }
308
309    fn sample_level(&self, level: usize, x: f64, z: f64) -> (f64, f64) {
310        let (ncols, nrows) = (self.grid.ncols, self.grid.nrows);
311        let fx = ((x - self.grid.origin_x) / self.grid.dx).clamp(0.0, ncols as f64 - 1.0);
312        let fy = ((z - self.grid.origin_y) / self.grid.dy).clamp(0.0, nrows as f64 - 1.0);
313        let (j, i) = (fx.floor() as usize, fy.floor() as usize);
314        let (rx, ry) = (fx - j as f64, fy - i as f64);
315        let j1 = (j + 1).min(ncols - 1);
316        let i1 = (i + 1).min(nrows - 1);
317        let base = level * ncols * nrows;
318        let g = |arr: &[f64], i: usize, j: usize| arr[base + i * ncols + j];
319        let sample = |arr: &[f64]| {
320            (1.0 - ry) * ((1.0 - rx) * g(arr, i, j) + rx * g(arr, i, j1))
321                + ry * ((1.0 - rx) * g(arr, i1, j) + rx * g(arr, i1, j1))
322        };
323        (sample(&self.u), sample(&self.v))
324    }
325
326    /// Horizontal wind at a world position: nearest above-ground level.
327    pub fn horizontal_at(&self, x: f64, y: f64, z: f64) -> (f64, f64) {
328        let agl = y - self.grid.height_at(x, z);
329        let mut level = 0;
330        for (k, &h) in self.levels_agl.iter().enumerate() {
331            if agl <= h {
332                level = k;
333                break;
334            }
335            level = k;
336        }
337        self.sample_level(level, x, z)
338    }
339}
340
341impl WindField for TerrainFlowField {
342    fn wind(&self, _time_s: f64, position_m: [f64; 3]) -> Wind {
343        let (u, v) = self.horizontal_at(position_m[0], position_m[1], position_m[2]);
344        Wind {
345            x_mps: u,
346            y_mps: 0.0,
347            z_mps: v,
348        }
349    }
350}