use kiddo::{ImmutableKdTree, SquaredEuclidean};
use rayon::prelude::*;
use std::f64::consts::PI;
use swarmkit_sailing::WindSource;
use swarmkit_sailing::spherical::{LatLon, LonLatBbox, Wind};
use crate::{TimedWeatherRow, WeatherRow, WindSample};
#[expect(
clippy::float_cmp,
reason = "antimeridian boundary check on an exactly-representable -180.0."
)]
fn wrap_lon_query(lon: f32) -> f32 {
let wrapped = ((lon + 540.0).rem_euclid(360.0)) - 180.0;
if wrapped == -180.0 { 180.0 } else { wrapped }
}
#[derive(Clone)]
enum SpatialIndex {
Grid {
origin_x: f32,
origin_y: f32,
step_x: f32,
step_y: f32,
nx: usize,
ny: usize,
},
Tree(ImmutableKdTree<f32, 2>),
}
#[derive(Clone, Copy, Debug)]
pub struct GridLayout {
pub origin_x: f32,
pub origin_y: f32,
pub step_x: f32,
pub step_y: f32,
pub nx: usize,
pub ny: usize,
}
#[derive(Clone)]
pub struct WindMap {
index: SpatialIndex,
rows: Vec<WeatherRow>,
}
impl WindMap {
pub fn generate(size_x: f32, size_y: f32, density: f32) -> Self {
let cols = (size_x / density).floor() as usize + 1;
let rows_count = (size_y / density).floor() as usize + 1;
let mut rows = Vec::with_capacity(cols * rows_count);
for i in 0..cols {
for j in 0..rows_count {
rows.push(WeatherRow {
lon: i as f32 * density,
lat: j as f32 * density,
sample: WindSample {
speed: 0.0,
direction: 0.0,
},
});
}
}
Self::new(rows)
}
pub fn generate_random(
size_x: f32,
size_y: f32,
density: f32,
speed_range: std::ops::Range<f32>,
) -> Self {
Self::generate_random_with_rng(size_x, size_y, density, speed_range, &mut rand::rng())
}
pub fn generate_random_with_rng<R: rand::Rng + rand::RngExt>(
size_x: f32,
size_y: f32,
density: f32,
speed_range: std::ops::Range<f32>,
rng: &mut R,
) -> Self {
let cols = (size_x / density).floor() as usize + 1;
let rows_count = (size_y / density).floor() as usize + 1;
let mut rows = Vec::with_capacity(cols * rows_count);
for i in 0..cols {
for j in 0..rows_count {
rows.push(WeatherRow {
lon: i as f32 * density,
lat: j as f32 * density,
sample: WindSample {
speed: rng.random_range(speed_range.clone()),
direction: rng.random_range(0.0..360.0),
},
});
}
}
Self::new(rows)
}
pub fn new(rows: Vec<WeatherRow>) -> Self {
if let Some(layout) = detect_grid_layout(&rows) {
let rows = reorder_to_grid(rows, &layout);
return Self {
index: SpatialIndex::Grid {
origin_x: layout.origin_x,
origin_y: layout.origin_y,
step_x: layout.step_x,
step_y: layout.step_y,
nx: layout.nx,
ny: layout.ny,
},
rows,
};
}
let positions: Vec<[f32; 2]> = rows.iter().map(|r| [r.lon, r.lat]).collect();
let tree = ImmutableKdTree::new_from_slice(&positions);
Self {
index: SpatialIndex::Tree(tree),
rows,
}
}
pub fn from_grid(rows: Vec<WeatherRow>, layout: GridLayout) -> Self {
debug_assert_eq!(
rows.len(),
layout.nx * layout.ny,
"row count must match grid layout",
);
Self {
index: SpatialIndex::Grid {
origin_x: layout.origin_x,
origin_y: layout.origin_y,
step_x: layout.step_x,
step_y: layout.step_y,
nx: layout.nx,
ny: layout.ny,
},
rows,
}
}
pub fn query(&self, x: f32, y: f32) -> WindSample {
let x = wrap_lon_query(x);
match &self.index {
SpatialIndex::Grid {
origin_x,
origin_y,
step_x,
step_y,
nx,
ny,
} => {
let nx = *nx;
let ny = *ny;
let fx = (x - origin_x) / step_x;
let fy = (y - origin_y) / step_y;
let i_lo = fx.floor().clamp(0.0, (nx - 1) as f32) as usize;
let i_hi = fx.ceil().clamp(0.0, (nx - 1) as f32) as usize;
let j_lo = fy.floor().clamp(0.0, (ny - 1) as f32) as usize;
let j_hi = fy.ceil().clamp(0.0, (ny - 1) as f32) as usize;
let corners = [
i_lo * ny + j_lo,
i_hi * ny + j_lo,
i_lo * ny + j_hi,
i_hi * ny + j_hi,
];
idw_blend(&self.rows, &corners, x, y)
}
SpatialIndex::Tree(tree) => {
let Some(k) = std::num::NonZero::new(self.rows.len().min(4)) else {
return WindSample {
speed: 0.0,
direction: 0.0,
};
};
let nearest = tree.nearest_n::<SquaredEuclidean>(&[x, y], k);
let mut weight_sum = 0.0f32;
let mut speed_sum = 0.0f32;
let mut sin_sum = 0.0f32;
let mut cos_sum = 0.0f32;
for neighbor in &nearest {
if neighbor.distance == 0.0 {
let sample = &self.rows[neighbor.item as usize].sample;
return WindSample {
speed: sample.speed,
direction: sample.direction,
};
}
let w = 1.0 / neighbor.distance;
let sample = &self.rows[neighbor.item as usize].sample;
weight_sum += w;
speed_sum += w * sample.speed;
let dir_rad = sample.direction.to_radians();
sin_sum += w * dir_rad.sin();
cos_sum += w * dir_rad.cos();
}
WindSample {
speed: speed_sum / weight_sum,
direction: sin_sum.atan2(cos_sum).to_degrees().rem_euclid(360.0),
}
}
}
}
pub fn set_sample(&mut self, index: usize, speed: f32, direction: f32) -> Option<()> {
let row = self.rows.get_mut(index)?;
row.sample.speed = speed;
row.sample.direction = direction;
Some(())
}
pub fn query_circle(&self, x: f32, y: f32, radius: f32) -> Vec<&WeatherRow> {
self.query_circle_indices(x, y, radius)
.into_iter()
.map(|i| &self.rows[i])
.collect()
}
pub fn rows(&self) -> &[WeatherRow] {
&self.rows
}
pub fn grid_layout(&self) -> Option<GridLayout> {
match self.index {
SpatialIndex::Grid {
origin_x,
origin_y,
step_x,
step_y,
nx,
ny,
} => Some(GridLayout {
origin_x,
origin_y,
step_x,
step_y,
nx,
ny,
}),
SpatialIndex::Tree(_) => None,
}
}
pub fn grid_step(&self) -> Option<f32> {
match self.index {
SpatialIndex::Grid { step_x, step_y, .. } => Some(step_x.min(step_y)),
SpatialIndex::Tree(_) => None,
}
}
pub fn query_circle_indices(&self, x: f32, y: f32, radius: f32) -> Vec<usize> {
let x = wrap_lon_query(x);
match &self.index {
SpatialIndex::Grid {
origin_x,
origin_y,
step_x,
step_y,
nx,
ny,
} => {
let r = radius.abs();
let r2 = r * r;
let nx = *nx;
let ny = *ny;
let i_min = ((x - r - origin_x) / step_x)
.floor()
.clamp(0.0, (nx - 1) as f32) as usize;
let i_max = ((x + r - origin_x) / step_x)
.ceil()
.clamp(0.0, (nx - 1) as f32) as usize;
let j_min = ((y - r - origin_y) / step_y)
.floor()
.clamp(0.0, (ny - 1) as f32) as usize;
let j_max = ((y + r - origin_y) / step_y)
.ceil()
.clamp(0.0, (ny - 1) as f32) as usize;
let mut out = Vec::new();
for i in i_min..=i_max {
for j in j_min..=j_max {
let idx = i * ny + j;
let row = &self.rows[idx];
let dx = x - row.lon;
let dy = y - row.lat;
if dx * dx + dy * dy <= r2 {
out.push(idx);
}
}
}
out
}
SpatialIndex::Tree(tree) => tree
.within::<SquaredEuclidean>(&[x, y], radius * radius)
.iter()
.map(|n| n.item as usize)
.collect(),
}
}
}
fn idw_blend(rows: &[WeatherRow], indices: &[usize], x: f32, y: f32) -> WindSample {
let mut weight_sum = 0.0f32;
let mut speed_sum = 0.0f32;
let mut sin_sum = 0.0f32;
let mut cos_sum = 0.0f32;
for &idx in indices {
let r = &rows[idx];
let dx = x - r.lon;
let dy = y - r.lat;
let d2 = dx * dx + dy * dy;
if d2 == 0.0 {
return WindSample {
speed: r.sample.speed,
direction: r.sample.direction,
};
}
let w = 1.0 / d2;
weight_sum += w;
speed_sum += w * r.sample.speed;
let dir_rad = r.sample.direction.to_radians();
sin_sum += w * dir_rad.sin();
cos_sum += w * dir_rad.cos();
}
WindSample {
speed: speed_sum / weight_sum,
direction: sin_sum.atan2(cos_sum).to_degrees().rem_euclid(360.0),
}
}
fn detect_grid_layout(rows: &[WeatherRow]) -> Option<GridLayout> {
if rows.len() < 4 {
return None;
}
let cmp = |a: &f32, b: &f32| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal);
let mut xs: Vec<f32> = rows.iter().map(|r| r.lon).collect();
let mut ys: Vec<f32> = rows.iter().map(|r| r.lat).collect();
xs.sort_by(cmp);
ys.sort_by(cmp);
xs.dedup();
ys.dedup();
let nx = xs.len();
let ny = ys.len();
if nx < 2 || ny < 2 {
return None;
}
if nx.checked_mul(ny)? != rows.len() {
return None;
}
let origin_x = xs[0];
let origin_y = ys[0];
let step_x = xs[1] - xs[0];
let step_y = ys[1] - ys[0];
if step_x <= 0.0 || step_y <= 0.0 || !step_x.is_finite() || !step_y.is_finite() {
return None;
}
let eps_x = step_x * 1e-3;
let eps_y = step_y * 1e-3;
for w in xs.windows(2) {
if (w[1] - w[0] - step_x).abs() > eps_x {
return None;
}
}
for w in ys.windows(2) {
if (w[1] - w[0] - step_y).abs() > eps_y {
return None;
}
}
let mut seen = vec![false; nx * ny];
for row in rows {
let Ok(i) = usize::try_from(((row.lon - origin_x) / step_x).round() as i64) else {
return None;
};
let Ok(j) = usize::try_from(((row.lat - origin_y) / step_y).round() as i64) else {
return None;
};
if i >= nx || j >= ny {
return None;
}
let slot = i * ny + j;
if seen[slot] {
return None;
}
seen[slot] = true;
}
Some(GridLayout {
origin_x,
origin_y,
step_x,
step_y,
nx,
ny,
})
}
fn reorder_to_grid(rows: Vec<WeatherRow>, layout: &GridLayout) -> Vec<WeatherRow> {
let mut slots: Vec<Option<WeatherRow>> = (0..layout.nx * layout.ny).map(|_| None).collect();
for row in rows {
let i = ((row.lon - layout.origin_x) / layout.step_x).round() as usize;
let j = ((row.lat - layout.origin_y) / layout.step_y).round() as usize;
slots[i * layout.ny + j] = Some(row);
}
slots
.into_iter()
.map(|o| o.expect("detect_grid_layout proved complete coverage"))
.collect()
}
const DEFAULT_CROSSFADE_FRAMES: f32 = 5.0;
fn blend_samples(lo: &WindSample, hi: &WindSample, alpha: f32) -> WindSample {
let inv = 1.0 - alpha;
let speed = lo.speed * inv + hi.speed * alpha;
let lo_rad = lo.direction.to_radians();
let hi_rad = hi.direction.to_radians();
let sin_blend = lo_rad.sin() * inv + hi_rad.sin() * alpha;
let cos_blend = lo_rad.cos() * inv + hi_rad.cos() * alpha;
let direction = sin_blend.atan2(cos_blend).to_degrees().rem_euclid(360.0);
WindSample { speed, direction }
}
#[derive(Clone)]
pub struct TimedWindMap {
step_seconds: f32,
crossfade_seconds: f32,
time_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
frames: Vec<WindMap>,
}
impl TimedWindMap {
pub fn new(frames: Vec<WindMap>, step_seconds: f32) -> Self {
assert!(
!frames.is_empty(),
"TimedWindMap must have at least one frame"
);
assert!(
step_seconds > 0.0,
"TimedWindMap step_seconds must be > 0, got {step_seconds}"
);
Self {
step_seconds,
crossfade_seconds: DEFAULT_CROSSFADE_FRAMES * step_seconds,
time_range: None,
frames,
}
}
pub fn with_crossfade_seconds(mut self, secs: f32) -> Self {
self.crossfade_seconds = secs.max(0.0);
self
}
pub fn with_time_range(
mut self,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
) -> Self {
let (a, b) = if start <= end {
(start, end)
} else {
(end, start)
};
self.time_range = Some((a, b));
self
}
pub fn time_range(
&self,
) -> Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> {
self.time_range
}
pub fn crossfade_seconds(&self) -> f32 {
self.crossfade_seconds
}
pub fn cycle_seconds(&self) -> f32 {
self.duration_seconds() + self.crossfade_seconds
}
pub fn generate(
size_x: f32,
size_y: f32,
density: f32,
frame_count: usize,
step_seconds: f32,
) -> Self {
let frames = (0..frame_count)
.map(|_| WindMap::generate(size_x, size_y, density))
.collect();
Self::new(frames, step_seconds)
}
pub fn generate_random(
size_x: f32,
size_y: f32,
density: f32,
frame_count: usize,
step_seconds: f32,
speed_range: std::ops::Range<f32>,
) -> Self {
Self::generate_random_with_rng(
size_x,
size_y,
density,
frame_count,
step_seconds,
speed_range,
&mut rand::rng(),
)
}
pub fn generate_random_with_rng<R: rand::Rng + rand::RngExt>(
size_x: f32,
size_y: f32,
density: f32,
frame_count: usize,
step_seconds: f32,
speed_range: std::ops::Range<f32>,
rng: &mut R,
) -> Self {
let frames = (0..frame_count)
.map(|_| {
WindMap::generate_random_with_rng(size_x, size_y, density, speed_range.clone(), rng)
})
.collect();
Self::new(frames, step_seconds)
}
#[expect(
clippy::float_cmp,
reason = "timestamps are copied byte-for-byte from input rows; \
exact equality determines whether two rows belong to the \
same frame."
)]
pub fn from_timed_rows(mut rows: Vec<TimedWeatherRow>) -> Option<Self> {
if rows.is_empty() {
return None;
}
rows.sort_by(|a, b| {
a.t_seconds
.partial_cmp(&b.t_seconds)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut frame_rows: Vec<Vec<WeatherRow>> = vec![Vec::new()];
let mut frame_times: Vec<f32> = vec![rows[0].t_seconds];
let mut current_t = rows[0].t_seconds;
let mut idx = 0usize;
for trow in rows {
if trow.t_seconds != current_t {
frame_rows.push(Vec::new());
frame_times.push(trow.t_seconds);
current_t = trow.t_seconds;
idx += 1;
}
frame_rows[idx].push(WeatherRow {
lon: trow.lon,
lat: trow.lat,
sample: trow.sample,
});
}
let step_seconds = if frame_times.len() < 2 {
1.0
} else {
frame_times[1] - frame_times[0]
};
let frames = frame_rows.into_iter().map(WindMap::new).collect();
Some(Self::new(frames, step_seconds))
}
pub fn frames(&self) -> &[WindMap] {
&self.frames
}
pub fn frame(&self, idx: usize) -> Option<&WindMap> {
self.frames.get(idx)
}
pub fn frame_mut(&mut self, idx: usize) -> Option<&mut WindMap> {
self.frames.get_mut(idx)
}
pub fn frame_count(&self) -> usize {
self.frames.len()
}
pub fn step_seconds(&self) -> f32 {
self.step_seconds
}
pub fn duration_seconds(&self) -> f32 {
self.frames.len().saturating_sub(1) as f32 * self.step_seconds
}
pub fn to_timed_rows(&self) -> Vec<TimedWeatherRow> {
let mut out = Vec::with_capacity(self.frames.iter().map(|f| f.rows().len()).sum());
for (k, frame) in self.frames.iter().enumerate() {
let t = k as f32 * self.step_seconds;
for row in frame.rows() {
out.push(TimedWeatherRow {
lon: row.lon,
lat: row.lat,
t_seconds: t,
sample: row.sample.clone(),
});
}
}
out
}
pub fn query(&self, x: f32, y: f32, t_seconds: f32) -> WindSample {
let n = self.frames.len();
if n == 1 {
return self.frames[0].query(x, y);
}
let data_end = self.duration_seconds();
let cycle = data_end + self.crossfade_seconds;
let t_mod = if cycle > 0.0 {
t_seconds.rem_euclid(cycle)
} else {
0.0
};
let (lo_idx, hi_idx, alpha) = if t_mod <= data_end {
let frame_idx_f = t_mod / self.step_seconds;
let lo = (frame_idx_f.floor() as usize).min(n - 1);
let hi = (lo + 1).min(n - 1);
(lo, hi, frame_idx_f - lo as f32)
} else {
let alpha = (t_mod - data_end) / self.crossfade_seconds;
(n - 1, 0, alpha)
};
let s_lo = self.frames[lo_idx].query(x, y);
if lo_idx == hi_idx || alpha == 0.0 {
return s_lo;
}
let s_hi = self.frames[hi_idx].query(x, y);
blend_samples(&s_lo, &s_hi, alpha)
}
pub fn synthesize_frame_at(&self, t_seconds: f32) -> WindMap {
let template = &self.frames[0];
let rows: Vec<WeatherRow> = template
.rows()
.iter()
.map(|row| WeatherRow {
lon: row.lon,
lat: row.lat,
sample: self.query(row.lon, row.lat, t_seconds),
})
.collect();
match template.grid_layout() {
Some(layout) => WindMap::from_grid(rows, layout),
None => WindMap::new(rows),
}
}
pub fn bake(&self, bounds: BakeBounds) -> BakedWindMap {
BakedWindMap::from_timed_map(self, bounds)
}
}
#[derive(Copy, Clone, Debug)]
pub struct BakeBounds {
pub bbox: LonLatBbox,
pub step: f64,
pub coord_scale: f64,
}
#[derive(Clone, Debug)]
pub struct BakedWindMap {
pub(crate) grid: Vec<Wind>,
pub(crate) nx: usize,
pub(crate) ny: usize,
pub(crate) nt: usize,
pub(crate) x_min: f64,
pub(crate) y_min: f64,
pub(crate) step: f64,
pub(crate) t_step_seconds: f64,
pub(crate) crossfade_seconds: f64,
pub(crate) coord_scale: f64,
}
impl BakedWindMap {
pub fn nx(&self) -> usize {
self.nx
}
pub fn ny(&self) -> usize {
self.ny
}
pub fn nt(&self) -> usize {
self.nt
}
pub fn x_min(&self) -> f64 {
self.x_min
}
pub fn y_min(&self) -> f64 {
self.y_min
}
pub fn step(&self) -> f64 {
self.step
}
pub fn t_step_seconds(&self) -> f64 {
self.t_step_seconds
}
fn from_timed_map(map: &TimedWindMap, bounds: BakeBounds) -> Self {
assert!(bounds.step > 0.0, "BakeBounds::step must be > 0");
assert!(
bounds.coord_scale > 0.0,
"BakeBounds::coord_scale must be > 0"
);
assert!(
bounds.bbox.lat_max >= bounds.bbox.lat_min,
"BakeBounds: lat_max < lat_min",
);
let lon_min = bounds.bbox.lon_min;
let lon_max_unwrapped = bounds.bbox.lon_max_unwrapped();
let lat_min = bounds.bbox.lat_min;
let lat_max = bounds.bbox.lat_max;
let nx = ((lon_max_unwrapped - lon_min) / bounds.step).ceil() as usize + 1;
let ny = ((lat_max - lat_min) / bounds.step).ceil() as usize + 1;
let nt = map.frame_count();
let t_step_seconds = f64::from(map.step_seconds());
const KNOTS_TO_MS: f64 = 1852.0 / 3600.0;
let frames = map.frames();
let mut grid = vec![Wind::zero(); nx * ny * nt];
grid.par_chunks_mut(nt)
.enumerate()
.for_each(|(cell_idx, chunk)| {
let j = cell_idx / nx;
let i = cell_idx % nx;
let x = lon_min + i as f64 * bounds.step;
let y = lat_min + j as f64 * bounds.step;
for (k, frame) in frames.iter().enumerate() {
let sample = frame.query(x as f32, y as f32);
let dir_rad = f64::from(sample.direction) * PI / 180.0;
let speed_ms = f64::from(sample.speed) * KNOTS_TO_MS;
chunk[k] = Wind::new(-speed_ms * dir_rad.sin(), -speed_ms * dir_rad.cos());
}
});
Self {
grid,
nx,
ny,
nt,
x_min: lon_min,
y_min: lat_min,
step: bounds.step,
t_step_seconds,
crossfade_seconds: f64::from(map.crossfade_seconds()),
coord_scale: bounds.coord_scale,
}
}
}
impl WindSource for BakedWindMap {
fn sample_wind(&self, location: LatLon, t: f64) -> Wind {
let scaled_lon = location.lon / self.coord_scale;
let scaled_lat = location.lat / self.coord_scale;
let mut x = scaled_lon;
let x_max = self.x_min + (self.nx as f64 - 1.0) * self.step;
if x < self.x_min {
x += 360.0;
} else if x > x_max {
x -= 360.0;
}
let ix = ((x - self.x_min) / self.step)
.round()
.clamp(0.0, (self.nx - 1) as f64) as usize;
let iy = ((scaled_lat - self.y_min) / self.step)
.round()
.clamp(0.0, (self.ny - 1) as f64) as usize;
let nt = self.nt.max(1);
let base = (iy * self.nx + ix) * self.nt;
if nt == 1 || self.t_step_seconds <= 0.0 {
return self.grid[base];
}
let data_end = (nt as f64 - 1.0) * self.t_step_seconds;
let cycle = data_end + self.crossfade_seconds;
let t_mod = if cycle > 0.0 {
t.rem_euclid(cycle)
} else {
0.0
};
let (it, it_hi, alpha) = if t_mod <= data_end {
let t_idx_f = t_mod / self.t_step_seconds;
let it_floor = t_idx_f.floor();
let it = (it_floor as i64).clamp(0, (nt - 1) as i64) as usize;
let it_hi = (it + 1).min(nt - 1);
(it, it_hi, t_idx_f - it_floor)
} else {
let alpha = (t_mod - data_end) / self.crossfade_seconds;
(nt - 1, 0, alpha)
};
let v_lo = self.grid[base + it];
if it == it_hi || alpha == 0.0 {
return v_lo;
}
let v_hi = self.grid[base + it_hi];
let inv = 1.0 - alpha;
Wind::new(
v_lo.east_mps * inv + v_hi.east_mps * alpha,
v_lo.north_mps * inv + v_hi.north_mps * alpha,
)
}
}
#[cfg(test)]
mod crossfade_tests {
use super::*;
use crate::WindSample;
fn fixture() -> TimedWindMap {
let row = |speed, direction| {
vec![WeatherRow {
lon: 0.0,
lat: 0.0,
sample: WindSample { speed, direction },
}]
};
let frames = vec![WindMap::new(row(10.0, 0.0)), WindMap::new(row(20.0, 90.0))];
TimedWindMap::new(frames, 3600.0)
}
#[test]
#[expect(
clippy::float_cmp,
reason = "exact equality is the contract being tested"
)]
fn cycle_and_crossfade_defaults() {
let m = fixture();
assert_eq!(m.step_seconds(), 3600.0);
assert_eq!(m.duration_seconds(), 3600.0);
assert_eq!(m.crossfade_seconds(), 5.0 * 3600.0);
assert_eq!(m.cycle_seconds(), 3600.0 + 5.0 * 3600.0);
}
#[test]
fn at_data_end_returns_last_frame_exactly() {
let m = fixture();
let s = m.query(0.0, 0.0, m.duration_seconds());
assert!((s.speed - 20.0).abs() < 1e-4);
assert!((s.direction - 90.0).abs() < 1e-4);
}
#[test]
fn at_cycle_returns_first_frame_exactly() {
let m = fixture();
let s = m.query(0.0, 0.0, m.cycle_seconds());
assert!((s.speed - 10.0).abs() < 1e-4);
assert!((s.direction - 0.0).abs() < 1e-4);
}
#[test]
fn midpoint_of_crossfade_blends_50_50() {
let m = fixture();
let t = m.duration_seconds() + m.crossfade_seconds() / 2.0;
let s = m.query(0.0, 0.0, t);
assert!((s.speed - 15.0).abs() < 1e-3, "speed = {}", s.speed);
let wrapped = ((s.direction - 45.0 + 540.0) % 360.0) - 180.0;
assert!(wrapped.abs() < 1e-3, "direction = {}", s.direction);
}
#[test]
fn continuity_across_cycle_boundary() {
let m = fixture();
let eps = 12.0_f32;
let s_near_start = m.query(0.0, 0.0, eps);
let s_after_cycle = m.query(0.0, 0.0, m.cycle_seconds() + eps);
assert!((s_near_start.speed - s_after_cycle.speed).abs() < 1e-3);
let dir_wrapped =
((s_near_start.direction - s_after_cycle.direction + 540.0) % 360.0) - 180.0;
assert!(dir_wrapped.abs() < 1e-3);
}
#[test]
fn baked_crossfade_matches_timed_at_endpoints() {
let m = fixture();
let bbox = swarmkit_sailing::spherical::LonLatBbox::new(0.0, 0.0, 0.0, 0.0);
let baked = m.bake(BakeBounds {
bbox,
step: 0.25,
coord_scale: 1.0,
});
let loc = swarmkit_sailing::spherical::LatLon::new(0.0, 0.0);
let knots_to_ms = 1852.0 / 3600.0;
let w0 = baked.sample_wind(loc, 0.0);
assert!(w0.east_mps.abs() < 1e-3);
assert!((w0.north_mps + 10.0 * knots_to_ms).abs() < 1e-3);
let t_mid = f64::from(m.duration_seconds()) + f64::from(m.crossfade_seconds()) / 2.0;
let w_mid = baked.sample_wind(loc, t_mid);
assert!((w_mid.east_mps + 10.0 * knots_to_ms).abs() < 1e-2);
assert!((w_mid.north_mps + 5.0 * knots_to_ms).abs() < 1e-2);
}
}