winds-engine v0.1.0
Deterministic wind profiles and radiosonde validation for trajectory simulation, in Rust.
winds-engine = { git = "https://github.com/ajokela/winds-engine" }
What it does
WindField— one trait,wind(time_s, position_m). Stateless and deterministic: the same time and position always give the same wind, so integrator substeps and seeded reruns reproduce exactly.ConstantField— uniform wind everywhere.ProfileField/ProfileLevel— a piecewise-linear vertical profile. Speed interpolates linearly with height and direction along the shortest arc (350° to 10° passes through 0°); the nearest level holds above and below. Levels are checked and sorted.LogProfile— log-law boundary layer from a reference speed, a reference height and a roughness length; calm at and below the roughness length, constant direction.EkmanProfile— the Ekman spiral for constant eddy viscosity, from a geostrophic speed and direction, a latitude and an eddy viscosity: calm at the ground, turning toward the geostrophic wind aloft. At latitude 0 there is no Coriolis turning, so it is calm at every height.Sounding— parses a deliberately trivial line format (altitude_m speed_mps direction_from_deglines, plus#comments, where# station:and# valid:set the header fields), so radiosonde archives convert offline. Tested against a real 115-level KMPX sounding from the Iowa State IEM RAOB archive.validate— scores aProfileFieldagainst a sounding at the sounding's own levels: speed RMSE, circular direction RMSE, and mean absolute speed error per layer (0–1, 1–3, 3–6, 6–10 km, then the rest).
Zero dependencies. No network, no wall clock, no unseeded randomness.
Conventions
- Frame — x downrange, y up, z to the right: right-handed, the frame ballistics-engine uses.
- Directions — where the wind comes from, degrees clockwise: 0° is a headwind, 90° is from the right.
- Units — m/s for wind, metres for heights and positions, seconds for time. These horizontal fields have no vertical wind.
Heights
Sounding heights are metres above sea level, and to_profile_field() keeps them there, so the profile reads directly in a frame whose y is altitude above sea level, like rockets-engine's. The layers in validate use the same sea-level heights.
LogProfile and EkmanProfile take y as height above the ground, where they are calm. In a sea-level frame, read them at y − ground elevation. This crate has no offset wrapper, so convert in one place only: shifting the sounding too would move every wind by the site elevation.
Example
use winds_engine::analytic::LogProfile;
use winds_engine::{validate, ProfileField, ProfileLevel, Sounding, WindField};
fn main() -> winds_engine::Result<()> {
// altitude_m speed_mps direction_from_deg; heights above sea level
let sounding = Sounding::parse("287 0.0 0\n305 3.09 125\n610 3.60 145\n")?;
// The profile keeps those heights: read it at y = altitude above sea level.
let measured = sounding.to_profile_field()?;
let wind = measured.wind(0.0, [0.0, 500.0, 0.0]);
let (speed, from) = (wind.speed_mps(), wind.direction_from_deg());
println!("{speed:.2} m/s from {from:.0} deg");
// Score a candidate profile, on the same sea-level heights, against it.
let candidate = ProfileField::new(vec![
ProfileLevel { altitude_m: 287.0, speed_mps: 0.0, direction_from_deg: 0.0 },
ProfileLevel { altitude_m: 610.0, speed_mps: 4.0, direction_from_deg: 140.0 },
])?;
let report = validate::validate(&candidate, &sounding);
println!("speed RMSE {:.2} m/s", report.speed_rmse_mps);
// A log law is calm at the ground: convert the datum here, once.
let ground_m = 287.0;
let log = LogProfile::new(8.0, 10.0, 0.03, 270.0)?;
let low = log.wind(0.0, [0.0, 500.0 - ground_m, 0.0]);
println!("{:.2} m/s", low.speed_mps());
Ok(())
}
Needs Rust 1.89 or newer. The README walks through the same sounding.
Where it fits
- ballistics-engine — winds-engine is where the wind-field types were staged. ballistics-engine's own wind-field core, not yet released, was adapted from them but has a different API (nalgebra vectors in and out, no
Windtype, its own error type). Moving winds-engine onto it changes this crate's public API, so that will be a deliberate breaking release, not a drop-in swap. - rockets-engine — the consumer it is built for: sounding profiles keep sea-level heights, the same height datum as rockets-engine's frame. rockets-engine does not depend on winds-engine yet; the plan feeds it wind through ballistics-engine's wind-field types, so it waits on the breaking move above.
Status
Version 0.1.0, staging. Not published on crates.io, so there is no docs.rs page; use it as a git dependency (above). It is a library only, with no CLI and no browser terminal.
Planned, not built: GRIB2 forecast ingestion (HRRR/GFS), with only the download step behind an opt-in feature, and more sounding validation days.