#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use swarmkit_sailing::EnsembleWindSource;
use swarmkit_sailing::spherical::Wind;
use crate::wind_map::{BakeBounds, BakedWindMap, TimedWindMap};
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug)]
#[non_exhaustive]
pub enum EnsembleLoadError {
OpenDir {
path: PathBuf,
source: std::io::Error,
},
NoMembers { path: PathBuf },
Decode {
member: String,
source: crate::io::LoadError,
},
}
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Display for EnsembleLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OpenDir { path, source } => {
write!(
f,
"cannot open ensemble directory {}: {source}",
path.display()
)
}
Self::NoMembers { path } => write!(
f,
"ensemble directory {} contains no `ge[cp]*.wcav` files",
path.display()
),
Self::Decode { member, source } => {
write!(f, "decoding member {member}: {source}")
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl std::error::Error for EnsembleLoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::OpenDir { source, .. } => Some(source),
Self::Decode { source, .. } => Some(source),
Self::NoMembers { .. } => None,
}
}
}
pub struct TimedEnsembleWindMap {
members: Vec<TimedWindMap>,
member_names: Vec<String>,
}
impl TimedEnsembleWindMap {
pub fn from_members(members: Vec<TimedWindMap>, member_names: Vec<String>) -> Self {
assert_eq!(
members.len(),
member_names.len(),
"members ({}) and member_names ({}) must have matching lengths",
members.len(),
member_names.len(),
);
let mut this = Self {
members,
member_names,
};
this.normalize_frame_counts();
this
}
fn normalize_frame_counts(&mut self) {
let Some(min_frames) = self.members.iter().map(TimedWindMap::frame_count).min() else {
return;
};
let mut affected: Vec<(String, usize)> = Vec::new();
for (name, m) in self.member_names.iter().zip(self.members.iter_mut()) {
let original = m.frame_count();
if original > min_frames {
affected.push((name.clone(), original));
m.truncate_to(min_frames);
}
}
if !affected.is_empty() {
let listing: Vec<String> = affected
.iter()
.map(|(n, count)| format!("{n}={count}"))
.collect();
log::warn!(
"ensemble: clipping all members to {min_frames} frames; \
these members were longer: [{}]",
listing.join(", "),
);
}
}
pub fn member_count(&self) -> usize {
self.members.len()
}
pub fn members(&self) -> &[TimedWindMap] {
&self.members
}
pub fn member_names(&self) -> &[String] {
&self.member_names
}
pub fn bake(&self, bounds: BakeBounds) -> BakedEnsembleWindMap {
let members: Vec<BakedWindMap> = self.members.par_iter().map(|m| m.bake(bounds)).collect();
BakedEnsembleWindMap {
members,
member_names: self.member_names.clone(),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl TimedEnsembleWindMap {
pub fn load_dir(dir: &Path) -> Result<Self, EnsembleLoadError> {
let entries = std::fs::read_dir(dir).map_err(|source| EnsembleLoadError::OpenDir {
path: dir.to_path_buf(),
source,
})?;
let mut member_paths: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
if p.extension().and_then(|s| s.to_str()) != Some("wcav") {
return false;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
return false;
};
(stem.starts_with("gec") || stem.starts_with("gep"))
&& stem.len() >= 5
&& stem
.get(3..)
.is_some_and(|rest| rest.chars().all(|c| c.is_ascii_digit()))
})
.collect();
if member_paths.is_empty() {
return Err(EnsembleLoadError::NoMembers {
path: dir.to_path_buf(),
});
}
member_paths.sort();
let member_names: Vec<String> = member_paths
.iter()
.map(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned()
})
.collect();
let members: Result<Vec<TimedWindMap>, EnsembleLoadError> = member_paths
.par_iter()
.enumerate()
.map(|(i, p)| {
crate::io::load(p, 1, None).map_err(|source| EnsembleLoadError::Decode {
member: member_names[i].clone(),
source,
})
})
.collect();
Ok(Self::from_members(members?, member_names))
}
}
#[derive(Clone)]
pub struct BakedEnsembleWindMap {
members: Vec<BakedWindMap>,
member_names: Vec<String>,
}
impl BakedEnsembleWindMap {
pub fn from_members(members: Vec<BakedWindMap>, member_names: Vec<String>) -> Self {
assert_eq!(
members.len(),
member_names.len(),
"members ({}) and member_names ({}) must have matching lengths",
members.len(),
member_names.len(),
);
Self {
members,
member_names,
}
}
pub fn member_count(&self) -> usize {
self.members.len()
}
pub fn member(&self, k: usize) -> &BakedWindMap {
&self.members[k]
}
pub fn members(&self) -> &[BakedWindMap] {
&self.members
}
pub fn member_names(&self) -> &[String] {
&self.member_names
}
pub fn mean(&self) -> BakedWindMap {
assert!(
!self.members.is_empty(),
"ensemble must have at least one member to take the mean",
);
let first = &self.members[0];
for (i, m) in self.members.iter().enumerate().skip(1) {
assert_eq!(
(m.nx(), m.ny(), m.nt()),
(first.nx(), first.ny(), first.nt()),
"ensemble member {i} has mismatched grid dims vs member 0",
);
assert_eq!(
m.t_frame_offsets, first.t_frame_offsets,
"ensemble member {i} has mismatched per-frame timestamps vs member 0",
);
}
let k_f = self.members.len() as f64;
let mut grid: Vec<Wind> = Vec::with_capacity(first.grid.len());
for idx in 0..first.grid.len() {
let mut sum_east = 0.0;
let mut sum_north = 0.0;
for m in &self.members {
let w = m.grid[idx];
sum_east += w.east_mps;
sum_north += w.north_mps;
}
grid.push(Wind::new(sum_east / k_f, sum_north / k_f));
}
BakedWindMap {
grid,
nx: first.nx,
ny: first.ny,
nt: first.nt,
x_min: first.x_min,
y_min: first.y_min,
step: first.step,
t_step_seconds: first.t_step_seconds,
crossfade_seconds: first.crossfade_seconds,
t_frame_offsets: first.t_frame_offsets.clone(),
coord_scale: first.coord_scale,
}
}
}
impl EnsembleWindSource for BakedEnsembleWindMap {
type Member = BakedWindMap;
fn member_count(&self) -> usize {
self.members.len()
}
fn member(&self, k: usize) -> &Self::Member {
&self.members[k]
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
reason = "tests compare exact arithmetic on values stored as f64."
)]
use super::*;
use crate::wind_map::WindMap;
use crate::{WeatherRow, WindSample};
use swarmkit_sailing::spherical::LonLatBbox;
fn uniform_wind_map(speed: f32, direction: f32) -> WindMap {
let mut rows = Vec::with_capacity(4);
for i in 0..2 {
for j in 0..2 {
rows.push(WeatherRow {
lon: i as f32,
lat: j as f32,
sample: WindSample { speed, direction },
});
}
}
WindMap::new(rows)
}
fn uniform_timed_n_frames(speed: f32, direction: f32, n: usize) -> TimedWindMap {
let frames: Vec<WindMap> = (0..n).map(|_| uniform_wind_map(speed, direction)).collect();
TimedWindMap::new(frames, 3600.0)
}
fn uniform_timed(speed: f32, direction: f32) -> TimedWindMap {
TimedWindMap::new(vec![uniform_wind_map(speed, direction)], 3600.0)
}
#[test]
fn from_members_panics_on_length_mismatch() {
let m = vec![uniform_timed(5.0, 90.0)];
let names = vec!["gec00".to_owned(), "gep01".to_owned()];
std::panic::set_hook(Box::new(|_| {}));
let res = std::panic::catch_unwind(|| {
let _ensemble = TimedEnsembleWindMap::from_members(m, names);
});
drop(std::panic::take_hook());
assert!(res.is_err());
}
#[test]
fn mismatched_frame_counts_clip_to_shortest_at_load() {
let a = uniform_timed_n_frames(5.0, 90.0, 3);
let b = uniform_timed_n_frames(5.0, 90.0, 5);
let c = uniform_timed_n_frames(5.0, 90.0, 4);
let ensemble = TimedEnsembleWindMap::from_members(
vec![a, b, c],
vec!["gec00".to_owned(), "gep01".to_owned(), "gep02".to_owned()],
);
for (i, m) in ensemble.members().iter().enumerate() {
assert_eq!(
m.frame_count(),
3,
"member {i} should be clipped to 3 frames after normalization",
);
}
let bounds = BakeBounds {
bbox: LonLatBbox {
lon_min: 0.0,
lon_max: 1.0,
lat_min: 0.0,
lat_max: 1.0,
},
step: 1.0,
coord_scale: 1.0,
};
let baked = ensemble.bake(bounds);
for (i, m) in baked.members().iter().enumerate() {
assert_eq!(m.nt(), 3, "baked member {i} should also be at nt=3");
}
drop(baked.mean()); }
#[test]
fn bake_produces_one_baked_per_member() {
let ensemble = TimedEnsembleWindMap::from_members(
vec![uniform_timed(5.0, 90.0), uniform_timed(10.0, 180.0)],
vec!["gec00".to_owned(), "gep01".to_owned()],
);
let bounds = BakeBounds {
bbox: LonLatBbox {
lon_min: 0.0,
lon_max: 1.0,
lat_min: 0.0,
lat_max: 1.0,
},
step: 0.5,
coord_scale: 1.0,
};
let baked = ensemble.bake(bounds);
assert_eq!(baked.member_count(), 2);
assert_eq!(baked.member_names(), &["gec00", "gep01"]);
}
#[test]
fn mean_averages_uv_components() {
let east = uniform_timed(10.0, 270.0); let north = uniform_timed(10.0, 180.0); let ensemble = TimedEnsembleWindMap::from_members(
vec![east, north],
vec!["gec00".to_owned(), "gep01".to_owned()],
);
let bounds = BakeBounds {
bbox: LonLatBbox {
lon_min: 0.0,
lon_max: 1.0,
lat_min: 0.0,
lat_max: 1.0,
},
step: 1.0,
coord_scale: 1.0,
};
let baked = ensemble.bake(bounds);
let mean = baked.mean();
assert_eq!(
(mean.nx(), mean.ny(), mean.nt()),
(
baked.member(0).nx(),
baked.member(0).ny(),
baked.member(0).nt()
)
);
assert_eq!(mean.grid.len(), baked.member(0).grid.len());
}
#[test]
fn mean_of_identical_members_equals_any_member() {
let a = uniform_timed(7.5, 45.0);
let b = uniform_timed(7.5, 45.0);
let c = uniform_timed(7.5, 45.0);
let ensemble = TimedEnsembleWindMap::from_members(
vec![a, b, c],
vec!["gec00".to_owned(), "gep01".to_owned(), "gep02".to_owned()],
);
let bounds = BakeBounds {
bbox: LonLatBbox {
lon_min: 0.0,
lon_max: 1.0,
lat_min: 0.0,
lat_max: 1.0,
},
step: 0.5,
coord_scale: 1.0,
};
let baked = ensemble.bake(bounds);
let mean = baked.mean();
for (i, sample) in mean.grid.iter().enumerate() {
let expected = baked.member(0).grid[i];
assert!(
(sample.east_mps - expected.east_mps).abs() < 1e-9,
"east mismatch at {i}: {} vs {}",
sample.east_mps,
expected.east_mps,
);
assert!(
(sample.north_mps - expected.north_mps).abs() < 1e-9,
"north mismatch at {i}: {} vs {}",
sample.north_mps,
expected.north_mps,
);
}
}
}