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::{Error, Result, WindField};
12use nalgebra::Vector3;
13
14/// Earth's mean radius for the local-tangent metre projection.
15const EARTH_RADIUS_M: f64 = 6_371_000.0;
16
17/// Horizontal wind vector from a speed and a wind-FROM direction in the frame
18/// convention (0° = from +x, a headwind; 90° = from +z, the right).
19fn horizontal(speed_mps: f64, direction_from_deg: f64) -> Vector3<f64> {
20    let toward = (direction_from_deg + 180.0).to_radians();
21    Vector3::new(speed_mps * toward.cos(), 0.0, speed_mps * toward.sin())
22}
23
24/// One surface wind observation, in the terrain grid's frame (metres).
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Monitor {
27    /// Easting (frame x), metres.
28    pub x_m: f64,
29    /// Northing (frame z), metres.
30    pub z_m: f64,
31    /// Wind speed, metres per second.
32    pub speed_mps: f64,
33    /// Wind-FROM direction in the frame convention: 0° = from +x (a
34    /// headwind), 90° = from +z, the right.
35    pub direction_from_deg: f64,
36}
37
38impl Monitor {
39    /// An observation already in the grid's metre frame.
40    pub fn at(x_m: f64, z_m: f64, speed_mps: f64, direction_from_deg: f64) -> Self {
41        Self {
42            x_m,
43            z_m,
44            speed_mps,
45            direction_from_deg,
46        }
47    }
48
49    /// A geographic reading, projected into local metres and rotated from
50    /// compass degrees into the frame. `ref_lat`/`ref_lon` is the point
51    /// that maps to the frame's origin, and `north_azimuth_deg` is the
52    /// compass direction of the frame's +x axis.
53    pub fn from_geo(
54        lat_deg: f64,
55        lon_deg: f64,
56        speed_mps: f64,
57        dir_compass_from_deg: f64,
58        ref_lat_deg: f64,
59        ref_lon_deg: f64,
60        north_azimuth_deg: f64,
61    ) -> Self {
62        let (x_m, z_m) = local_en_m(lat_deg, lon_deg, ref_lat_deg, ref_lon_deg);
63        Self {
64            x_m,
65            z_m,
66            speed_mps,
67            direction_from_deg: (dir_compass_from_deg - north_azimuth_deg).rem_euclid(360.0),
68        }
69    }
70}
71
72/// Local equirectangular projection: `(east, north)` metres from the
73/// reference point, for the small domains a wind network covers. Not for
74/// continental scales.
75pub fn local_en_m(lat_deg: f64, lon_deg: f64, ref_lat_deg: f64, ref_lon_deg: f64) -> (f64, f64) {
76    let dlat = (lat_deg - ref_lat_deg).to_radians();
77    let dlon = (lon_deg - ref_lon_deg).to_radians();
78    let east = EARTH_RADIUS_M * dlon * ref_lat_deg.to_radians().cos();
79    let north = EARTH_RADIUS_M * dlat;
80    (east, north)
81}
82
83/// Parse a monitor file of one reading per line:
84/// `latitude,longitude,speed_ms,direction_compass_from_deg`, `#` comments
85/// and a header line are skipped. Coordinates are projected via
86/// [`local_en_m`] and directions rotated into the frame.
87pub fn monitors_from_geo_csv(
88    text: &str,
89    ref_lat_deg: f64,
90    ref_lon_deg: f64,
91    north_azimuth_deg: f64,
92) -> Result<Vec<Monitor>> {
93    let mut monitors = Vec::new();
94    for (number, line) in text.lines().enumerate() {
95        let line = line.trim();
96        if line.is_empty() || line.starts_with('#') {
97            continue;
98        }
99        let fields: Vec<&str> = line.split(',').collect();
100        if fields.len() < 4 {
101            return Err(Error::invalid(
102                "monitor csv",
103                format!(
104                    "line {}: expected lat,lon,speed_ms,dir_compass_deg",
105                    number + 1
106                ),
107            ));
108        }
109        let num = |token: &str, what: &str| -> Result<f64> {
110            token.trim().parse().map_err(|_| {
111                Error::invalid(
112                    "monitor csv",
113                    format!("line {}: {what} '{token}' is not numeric", number + 1),
114                )
115            })
116        };
117        let (lat, lon) = (num(fields[0], "lat")?, num(fields[1], "lon")?);
118        let speed = num(fields[2], "speed")?;
119        let dir = num(fields[3], "direction")?;
120        if !lat.is_finite()
121            || lat.abs() > 90.0
122            || !lon.is_finite()
123            || !speed.is_finite()
124            || speed < 0.0
125            || !dir.is_finite()
126        {
127            return Err(Error::invalid(
128                "monitor csv",
129                format!(
130                    "line {}: readings must be finite with nonnegative speed",
131                    number + 1
132                ),
133            ));
134        }
135        monitors.push(Monitor::from_geo(
136            lat,
137            lon,
138            speed,
139            dir,
140            ref_lat_deg,
141            ref_lon_deg,
142            north_azimuth_deg,
143        ));
144    }
145    Ok(monitors)
146}
147
148/// Terrain heights on an axis-aligned, uniform grid in metres. Row-major:
149/// `heights[i * ncols + j]` is column `j` (east), row `i` (north). Position
150/// `y` is up; the grid lies in the `x`–`z` plane.
151#[derive(Debug, Clone)]
152pub struct HeightGrid {
153    /// Frame-x of the first column centre.
154    pub origin_x: f64,
155    /// Frame-z of the first row centre.
156    pub origin_y: f64,
157    /// Column spacing, metres.
158    pub dx: f64,
159    /// Row spacing, metres.
160    pub dy: f64,
161    /// Number of columns (east).
162    pub ncols: usize,
163    /// Number of rows (north).
164    pub nrows: usize,
165    /// Row-major height samples, `ncols * nrows`.
166    pub heights: Vec<f64>,
167}
168
169impl HeightGrid {
170    /// Build and validate a grid.
171    pub fn new(
172        origin_x: f64,
173        origin_y: f64,
174        dx: f64,
175        dy: f64,
176        ncols: usize,
177        nrows: usize,
178        heights: Vec<f64>,
179    ) -> Result<Self> {
180        if dx <= 0.0 || dy <= 0.0 || !dx.is_finite() || !dy.is_finite() {
181            return Err(Error::invalid("height grid", "spacing must be positive"));
182        }
183        if ncols < 2 || nrows < 2 {
184            return Err(Error::invalid("height grid", "needs at least 2x2 cells"));
185        }
186        if heights.len() != ncols * nrows {
187            return Err(Error::invalid(
188                "height grid",
189                "height count does not match the grid shape",
190            ));
191        }
192        if !heights.iter().all(|h| h.is_finite()) {
193            return Err(Error::invalid("height grid", "heights must be finite"));
194        }
195        Ok(Self {
196            origin_x,
197            origin_y,
198            dx,
199            dy,
200            ncols,
201            nrows,
202            heights,
203        })
204    }
205
206    /// Terrain height at a horizontal position, bilinear between samples;
207    /// clamps at the grid edges.
208    pub fn height_at(&self, x: f64, z: f64) -> f64 {
209        let fx = ((x - self.origin_x) / self.dx).clamp(0.0, self.ncols as f64 - 1.0);
210        let fy = ((z - self.origin_y) / self.dy).clamp(0.0, self.nrows as f64 - 1.0);
211        let (j, i) = (fx.floor() as usize, fy.floor() as usize);
212        let (rx, ry) = (fx - j as f64, fy - i as f64);
213        let j1 = (j + 1).min(self.ncols - 1);
214        let i1 = (i + 1).min(self.nrows - 1);
215        let f = |i: usize, j: usize| self.heights[i * self.ncols + j];
216        (1.0 - ry) * ((1.0 - rx) * f(i, j) + rx * f(i, j1))
217            + ry * ((1.0 - rx) * f(i1, j) + rx * f(i1, j1))
218    }
219
220    /// A grid from an ASCII ESRI `.asc` raster (projected, metres): header
221    /// `ncols/nrows/xllcorner/yllcorner/cellsize`, then row-major heights
222    /// (north-to-south, west-to-east).
223    pub fn from_ascii(text: &str) -> Result<Self> {
224        let mut ncols = None;
225        let mut nrows = None;
226        let mut xll = None;
227        let mut yll = None;
228        let mut cellsize = None;
229        let mut heights = Vec::new();
230        for line in text.lines() {
231            let line = line.trim();
232            if line.is_empty() {
233                continue;
234            }
235            let mut parts = line.split_whitespace();
236            match parts.next().unwrap_or("").to_ascii_lowercase().as_str() {
237                "ncols" => ncols = parse_asc_num(parts.next()),
238                "nrows" => nrows = parse_asc_num(parts.next()),
239                "xllcorner" | "xllcenter" => xll = parse_asc_num(parts.next()),
240                "yllcorner" | "yllcenter" => yll = parse_asc_num(parts.next()),
241                "cellsize" => cellsize = parse_asc_num(parts.next()),
242                "nodata_value" => continue,
243                _ => {
244                    for token in line.split_whitespace() {
245                        if let Ok(v) = token.parse::<f64>() {
246                            heights.push(v);
247                        }
248                    }
249                }
250            }
251        }
252        let ncols = ncols.ok_or_else(|| Error::invalid("ascii dem", "missing ncols"))? as usize;
253        let nrows = nrows.ok_or_else(|| Error::invalid("ascii dem", "missing nrows"))? as usize;
254        let cellsize = cellsize.ok_or_else(|| Error::invalid("ascii dem", "missing cellsize"))?;
255        let (xll, yll) = (
256            xll.ok_or_else(|| Error::invalid("ascii dem", "missing xllcorner"))?,
257            yll.ok_or_else(|| Error::invalid("ascii dem", "missing yllcorner"))?,
258        );
259        if heights.len() != ncols * nrows {
260            return Err(Error::invalid(
261                "ascii dem",
262                "height count does not match header",
263            ));
264        }
265        // ESRI ASCII is "English reading order": row 1 is the north (top)
266        // edge, which is already our row 0 — no reversal. Note the corner
267        // (xllcorner/yllcorner) is the *lower-left* corner, not the upper
268        // left, so the grid origin is at y = yll, the south edge. `new`
269        // treats `origin` as the first (row 0 = north) sample's corner; to
270        // stay consistent with that contract we keep heights in file order
271        // (north first) and set the northing origin to the north edge.
272        let north_y = yll + cellsize * nrows as f64;
273        Self::new(xll, north_y, cellsize, cellsize, ncols, nrows, heights)
274    }
275
276    /// A grid from raw metres data already in north-up row-major order
277    /// (row 0 = north), with the origin at the north-west sample corner.
278    pub fn from_north_up(
279        origin_x: f64,
280        origin_north_y: f64,
281        dx: f64,
282        dy: f64,
283        ncols: usize,
284        nrows: usize,
285        heights: Vec<f64>,
286    ) -> Result<Self> {
287        Self::new(origin_x, origin_north_y, dx, dy, ncols, nrows, heights)
288    }
289}
290
291fn parse_asc_num(token: Option<&str>) -> Option<f64> {
292    token.and_then(|s| s.parse().ok())
293}
294
295/// A 1°×1° SRTM tile: 1201² samples for SRTM-3 (3 arc-sec ≈ 90 m), 3601²
296/// for SRTM-1 (1 arc-sec ≈ 30 m). `.hgt` files are 16-bit big-endian
297/// signed metres, recorded north→south (top row is the northern edge),
298/// west→east, sample values at the tile's corners (not centres).
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum SrtmResolution {
301    /// 3601 samples per side (~30 m), SRTM-1.
302    OneArcSecond,
303    /// 1201 samples per side (~90 m), SRTM-3.
304    ThreeArcSeconds,
305}
306
307impl SrtmResolution {
308    /// Samples per tile side: 3601 (SRTM-1) or 1201 (SRTM-3).
309    pub fn samples(self) -> usize {
310        match self {
311            Self::OneArcSecond => 3601,
312            Self::ThreeArcSeconds => 1201,
313        }
314    }
315}
316
317/// Load an SRTM `.hgt` tile into a [`HeightGrid`] on a local equirectangular
318/// projection (metres), with `(north_lat, west_lon)` as the tile's corner
319/// and the given point mapping to the frame origin.
320///
321/// The tile's own north-west corner is the natural reference, but a caller
322/// with a launch site in mind can recentre with `ref_lat`/`ref_lon`.
323pub fn hgt_grid(
324    data: &[u8],
325    resolution: SrtmResolution,
326    north_lat: f64,
327    west_lon: f64,
328    ref_lat: f64,
329    ref_lon: f64,
330) -> Result<HeightGrid> {
331    let samples = resolution.samples();
332    let expected = samples * samples * 2;
333    if data.len() != expected {
334        return Err(Error::invalid(
335            "hgt",
336            format!("expected {expected} bytes, got {}", data.len()),
337        ));
338    }
339    let mut heights = Vec::with_capacity(samples * samples);
340    for chunk in data.chunks_exact(2) {
341        heights.push(f64::from(i16::from_be_bytes([chunk[0], chunk[1]])));
342    }
343    // SRTM is also north→south; file sample 0 is the north edge = our row 0,
344    // so no reversal (same "English reading order" row-major as ASCII).
345    // Project the tile corners and the reference into local metres. The
346    // grid is north-up (row 0 = north, +z north), origin at the NW corner,
347    // so dx is east spacing and dy is the (positive) north spacing.
348    let nw = local_en_m(north_lat, west_lon, north_lat, west_lon); // (0, 0)
349    let se = local_en_m(north_lat - 1.0, west_lon + 1.0, north_lat, west_lon);
350    let dx = (se.0 - nw.0) / (samples - 1) as f64;
351    let north_spacing = (nw.1 - se.1) / (samples - 1) as f64;
352    let (ref_x, ref_z) = local_en_m(ref_lat, ref_lon, north_lat, west_lon);
353    HeightGrid::new(
354        nw.0 - ref_x,
355        nw.1 - ref_z,
356        dx,
357        north_spacing,
358        samples,
359        samples,
360        heights,
361    )
362}
363
364/// Load a GeoTIFF DEM into a [`HeightGrid`]. Uses the lightweight `tiff`
365/// crate behind a `tiff` feature (off by default, keeping the core
366/// zero-dependency); strips are decoded into metres directly, so a
367/// projected (metre) single-band DEM works as-is, while a geographic
368/// (degree) grid needs a proj step the caller supplies.
369#[cfg(feature = "tiff")]
370pub fn geotiff_grid(data: &[u8]) -> Result<HeightGrid> {
371    let mut decoder = tiff::decoder::Decoder::new(std::io::Cursor::new(data))
372        .map_err(|e| Error::invalid("geotiff", format!("cannot open: {e}")))?;
373    let (width, height) = decoder
374        .dimensions()
375        .map_err(|e| Error::invalid("geotiff", format!("cannot read dimensions: {e}")))?;
376    let (width, height) = (width as usize, height as usize);
377    let values = match decoder
378        .read_image()
379        .map_err(|e| Error::invalid("geotiff", format!("cannot decode: {e}")))?
380    {
381        tiff::decoder::DecodingResult::F32(data) => data.into_iter().map(f64::from).collect(),
382        tiff::decoder::DecodingResult::F64(data) => data,
383        tiff::decoder::DecodingResult::U8(data) => data.into_iter().map(f64::from).collect(),
384        tiff::decoder::DecodingResult::U16(data) => data.into_iter().map(f64::from).collect(),
385        tiff::decoder::DecodingResult::U32(data) => data.into_iter().map(f64::from).collect(),
386        tiff::decoder::DecodingResult::I8(data) => data.into_iter().map(f64::from).collect(),
387        tiff::decoder::DecodingResult::I16(data) => data.into_iter().map(f64::from).collect(),
388        tiff::decoder::DecodingResult::I32(data) => data.into_iter().map(f64::from).collect(),
389        tiff::decoder::DecodingResult::U64(data) => data.into_iter().map(|v| v as f64).collect(),
390        tiff::decoder::DecodingResult::I64(data) => data.into_iter().map(|v| v as f64).collect(),
391        _ => {
392            return Err(Error::invalid(
393                "geotiff",
394                "unsupported sample format (expected integer or float samples)",
395            ))
396        }
397    };
398    if values.len() != width * height {
399        return Err(Error::invalid(
400            "geotiff",
401            "decoded size does not match header",
402        ));
403    }
404    // Top-left origin, rows north→south; reverse into north-up rows.
405    let mut north_up = Vec::with_capacity(values.len());
406    for row in values.chunks_exact(width).rev() {
407        north_up.extend_from_slice(row);
408    }
409    // No geotransform extraction: origin is unknown from pixels alone, so
410    // the grid is indexed from (0, 0) with unit spacing; callers with a
411    // geotransform should translate/scale into `HeightGrid::new` themselves.
412    HeightGrid::new(0.0, 0.0, 1.0, 1.0, width, height, north_up)
413}
414
415/// A diagnostic terrain-adjusted wind field.
416#[derive(Debug, Clone)]
417pub struct TerrainFlowField {
418    grid: HeightGrid,
419    /// Fixed above-ground sampling levels, ascending.
420    levels_agl: Vec<f64>,
421    /// Adjusted horizontal wind per level, row-major `u` and `v` (frame x
422    /// and z), `levels * ncols * nrows` each.
423    u: Vec<f64>,
424    v: Vec<f64>,
425}
426
427impl TerrainFlowField {
428    /// Build a terrain-adjusted field. `base` seeds the first guess;
429    /// `monitors` are surface wind observations `(x, z, speed_mps,
430    /// from_deg)` blended near the ground and relaxed toward the base field
431    /// with height. `obs_length_m` sets the horizontal influence radius,
432    /// `surface_decay_m` the vertical relaxation of monitor influence, and
433    /// `terrain_scale_m` the depth over which the terrain forcing (flow
434    /// rising over the ground) decays.
435    pub fn adjust(
436        grid: HeightGrid,
437        base: &dyn WindField,
438        monitors: &[Monitor],
439        levels_agl: &[f64],
440        obs_length_m: f64,
441        surface_decay_m: f64,
442        terrain_scale_m: f64,
443    ) -> Result<Self> {
444        if levels_agl.is_empty() || levels_agl.iter().any(|h| !h.is_finite() || *h < 0.0) {
445            return Err(Error::invalid(
446                "levels_agl",
447                "must be finite and nonnegative",
448            ));
449        }
450        if obs_length_m <= 0.0 || surface_decay_m <= 0.0 || terrain_scale_m <= 0.0 {
451            return Err(Error::invalid("terrain", "length scales must be positive"));
452        }
453        if levels_agl.is_empty() || levels_agl.iter().any(|h| !h.is_finite() || *h < 0.0) {
454            return Err(Error::invalid(
455                "levels_agl",
456                "must be finite and nonnegative",
457            ));
458        }
459        if obs_length_m <= 0.0 || surface_decay_m <= 0.0 {
460            return Err(Error::invalid("terrain", "length scales must be positive"));
461        }
462        for m in monitors {
463            if !m.x_m.is_finite() || !m.z_m.is_finite() || !m.direction_from_deg.is_finite() {
464                return Err(Error::invalid("monitor", "must be finite"));
465            }
466            if !m.speed_mps.is_finite() || m.speed_mps < 0.0 {
467                return Err(Error::invalid("monitor", "speed must be nonnegative"));
468            }
469        }
470        let (ncols, nrows) = (grid.ncols, grid.nrows);
471        let n = ncols * nrows;
472        let inv_dx2 = 1.0 / (grid.dx * grid.dx);
473        let inv_dy2 = 1.0 / (grid.dy * grid.dy);
474        let diag = -2.0 * (inv_dx2 + inv_dy2);
475        let omega = 1.6;
476        let mut u = Vec::with_capacity(levels_agl.len() * n);
477        let mut v = Vec::with_capacity(levels_agl.len() * n);
478        let mut phi = vec![0.0; n];
479        for &level in levels_agl {
480            // First guess: base field + monitor correction, relaxed with height.
481            let mut u0 = vec![0.0; n];
482            let mut v0 = vec![0.0; n];
483            for i in 0..nrows {
484                for j in 0..ncols {
485                    let x = grid.origin_x + j as f64 * grid.dx;
486                    let z = grid.origin_y + i as f64 * grid.dy;
487                    let height = grid.heights[i * ncols + j] + level;
488                    let bg = base.wind(0.0, Vector3::new(x, height, z));
489                    let (mut cx, mut cz, mut wsum) = (0.0, 0.0, 0.0);
490                    for m in monitors {
491                        let mw = horizontal(m.speed_mps, m.direction_from_deg);
492                        let bg_m = base.wind(
493                            0.0,
494                            Vector3::new(m.x_m, grid.height_at(m.x_m, m.z_m), m.z_m),
495                        );
496                        let dx = x - m.x_m;
497                        let dz = z - m.z_m;
498                        let weight = (-(dx * dx + dz * dz) / (obs_length_m * obs_length_m)).exp()
499                            * (-level / surface_decay_m).exp();
500                        cx += weight * (mw.x - bg_m.x);
501                        cz += weight * (mw.z - bg_m.z);
502                        wsum += weight;
503                    }
504                    if wsum > 0.0 {
505                        // A regularizer keeps a lone distant monitor from
506                        // overriding the background everywhere.
507                        let w_floor = 0.1;
508                        cx /= wsum + w_floor;
509                        cz /= wsum + w_floor;
510                    }
511                    u0[i * ncols + j] = bg.x + cx;
512                    v0[i * ncols + j] = bg.z + cz;
513                }
514            }
515            // Solve div(u) = 0, u = u0 - grad(phi), including the
516            // terrain-induced vertical divergence: air forced up a slope
517            // converges horizontally, air descending into a lee diverges.
518            // laplacian(phi) = div_h(u0) - (u0 . grad(h)) * exp(-z / H) / H.
519            let level_decay = (-level / terrain_scale_m).exp() / terrain_scale_m;
520            let slopes = |u0x: f64, u0z: f64, i: usize, j: usize| -> f64 {
521                let dhdx = (grid.heights[i * ncols + (j + 1).min(ncols - 1)]
522                    - grid.heights[i * ncols + j.saturating_sub(1)])
523                    / (2.0 * grid.dx);
524                let dhdz = (grid.heights[(i + 1).min(nrows - 1) * ncols + j]
525                    - grid.heights[i.saturating_sub(1) * ncols + j])
526                    / (2.0 * grid.dy);
527                u0x * dhdx + u0z * dhdz
528            };
529            phi.fill(0.0);
530            for _ in 0..4000 {
531                let mut max_step = 0.0_f64;
532                for i in 1..nrows - 1 {
533                    for j in 1..ncols - 1 {
534                        let ip = (i + 1).min(nrows - 1);
535                        let im = i.saturating_sub(1);
536                        let jp = (j + 1).min(ncols - 1);
537                        let jm = j.saturating_sub(1);
538                        let lap = (phi[i * ncols + jp] + phi[i * ncols + jm]) * inv_dx2
539                            + (phi[ip * ncols + j] + phi[im * ncols + j]) * inv_dy2
540                            + diag * phi[i * ncols + j];
541                        let div = (u0[i * ncols + jp] - u0[i * ncols + jm]) / (2.0 * grid.dx)
542                            + (v0[ip * ncols + j] - v0[im * ncols + j]) / (2.0 * grid.dy);
543                        let rhs =
544                            div - slopes(u0[i * ncols + j], v0[i * ncols + j], i, j) * level_decay;
545                        let updated = phi[i * ncols + j] + omega * (rhs - lap) / diag;
546                        max_step = max_step.max((updated - phi[i * ncols + j]).abs());
547                        phi[i * ncols + j] = updated;
548                    }
549                }
550                if max_step < 1e-6 {
551                    break;
552                }
553            }
554            for i in 0..nrows {
555                for j in 0..ncols {
556                    let edge = i == 0 || i == nrows - 1 || j == 0 || j == ncols - 1;
557                    if edge {
558                        // The domain is embedded in a larger uniform field: the
559                        // boundary holds the background wind.
560                        u.push(u0[i * ncols + j]);
561                        v.push(v0[i * ncols + j]);
562                        continue;
563                    }
564                    let jp = (j + 1).min(ncols - 1);
565                    let jm = j.saturating_sub(1);
566                    let ip = (i + 1).min(nrows - 1);
567                    let im = i.saturating_sub(1);
568                    let gx = (phi[i * ncols + jp] - phi[i * ncols + jm]) / (2.0 * grid.dx);
569                    let gz = (phi[ip * ncols + j] - phi[im * ncols + j]) / (2.0 * grid.dy);
570                    u.push(u0[i * ncols + j] - gx);
571                    v.push(v0[i * ncols + j] - gz);
572                }
573            }
574        }
575        Ok(Self {
576            grid,
577            levels_agl: levels_agl.to_vec(),
578            u,
579            v,
580        })
581    }
582
583    fn sample_level(&self, level: usize, x: f64, z: f64) -> (f64, f64) {
584        let (ncols, nrows) = (self.grid.ncols, self.grid.nrows);
585        let fx = ((x - self.grid.origin_x) / self.grid.dx).clamp(0.0, ncols as f64 - 1.0);
586        let fy = ((z - self.grid.origin_y) / self.grid.dy).clamp(0.0, nrows as f64 - 1.0);
587        let (j, i) = (fx.floor() as usize, fy.floor() as usize);
588        let (rx, ry) = (fx - j as f64, fy - i as f64);
589        let j1 = (j + 1).min(ncols - 1);
590        let i1 = (i + 1).min(nrows - 1);
591        let base = level * ncols * nrows;
592        let g = |arr: &[f64], i: usize, j: usize| arr[base + i * ncols + j];
593        let sample = |arr: &[f64]| {
594            (1.0 - ry) * ((1.0 - rx) * g(arr, i, j) + rx * g(arr, i, j1))
595                + ry * ((1.0 - rx) * g(arr, i1, j) + rx * g(arr, i1, j1))
596        };
597        (sample(&self.u), sample(&self.v))
598    }
599
600    /// Horizontal wind at a world position: nearest above-ground level.
601    pub fn horizontal_at(&self, x: f64, y: f64, z: f64) -> (f64, f64) {
602        let agl = y - self.grid.height_at(x, z);
603        let mut level = 0;
604        for (k, &h) in self.levels_agl.iter().enumerate() {
605            if agl <= h {
606                level = k;
607                break;
608            }
609            level = k;
610        }
611        self.sample_level(level, x, z)
612    }
613}
614
615impl WindField for TerrainFlowField {
616    fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
617        let (u, v) = self.horizontal_at(position_m.x, position_m.y, position_m.z);
618        Vector3::new(u, 0.0, v)
619    }
620}