<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>winds-engine — deterministic wind profiles for Rust</title>
<meta name="description" content="winds-engine: deterministic wind profiles and radiosonde validation for trajectory simulation, in Rust. Wind fields, log and Ekman boundary layers, sounding parsing and profile scoring.">
<link rel="canonical" href="https://winds.rs/">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230c0f14'/%3E%3Cg stroke='%233dd6b5' stroke-width='3' stroke-linecap='round'%3E%3Cpath d='M7 24h8M7 16h13M7 8h18'/%3E%3C/g%3E%3C/svg%3E">
<style>
:root {
color-scheme: dark light;
--bg: #0c0f14;
--panel: #131822;
--edge: #232c3d;
--text: #dbe4f0;
--dim: #8b98ac;
--accent: #3dd6b5;
--accent2: #ab9dff;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--tok-k: #3dd6b5; /* keywords */
--tok-t: #ab9dff; /* types */
--tok-m: #f0a6ca; /* macros */
--tok-n: #f2b86b; /* numbers */
--tok-c: #7d8ca2; /* comments */
--tok-s: #c3e88d; /* strings */
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f7fa;
--panel: #ffffff;
--edge: #d6dde7;
--text: #17202b;
--dim: #5b6878;
--accent: #087a65;
--accent2: #5a46d0;
--tok-k: #0a7f69;
--tok-t: #5a46d0;
--tok-m: #b4237a;
--tok-n: #a35f00;
--tok-c: #667385;
--tok-s: #3f7d1c;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 17px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
min-height: 100vh;
}
main { max-width: 780px; margin: 0 auto; padding: 64px 24px 80px; }
header { margin-bottom: 40px; }
h1 {
font-family: var(--mono);
font-size: 2.4rem;
letter-spacing: -0.02em;
}
h1 .ver { color: var(--accent); font-size: 1.1rem; vertical-align: super; }
.tagline { color: var(--dim); font-size: 1.15rem; margin-top: 6px; }
.install {
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 8px;
font-family: var(--mono);
padding: 14px 18px;
margin: 28px 0 36px;
overflow-x: auto;
white-space: nowrap;
}
.install .p { color: var(--dim); user-select: none; }
h2 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent2);
margin: 34px 0 12px;
}
p + p, p + ul, ul + p { margin-top: 10px; }
ul { list-style: none; }
li { padding: 5px 0 5px 22px; position: relative; }
li::before { content: "▸"; color: var(--accent); position: absolute; left: 0; }
li b { font-weight: 600; }
code { font-family: var(--mono); font-size: 0.86em; }
p code, li code {
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 4px;
padding: 0 4px;
overflow-wrap: anywhere;
}
a { color: var(--accent2); text-decoration: none; }
a:hover { text-decoration: underline; }
.links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 14px; }
.links a {
font-family: var(--mono);
font-size: 0.95rem;
color: var(--text);
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 8px;
padding: 10px 16px;
transition: border-color 0.15s, color 0.15s;
}
.links a:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
footer {
margin-top: 56px;
padding-top: 20px;
border-top: 1px solid var(--edge);
color: var(--dim);
font-size: 0.9rem;
}
.code {
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 8px;
padding: 14px 18px;
font-family: var(--mono);
font-size: 0.78rem;
line-height: 1.5;
overflow-x: auto;
}
.code code { font-size: inherit; }
.code .k { color: var(--tok-k); }
.code .t { color: var(--tok-t); }
.code .m { color: var(--tok-m); }
.code .n { color: var(--tok-n); }
.code .c { color: var(--tok-c); }
.code .s { color: var(--tok-s); }
.note { color: var(--dim); font-size: 0.85rem; margin-top: 8px; }
.profile { display: block; width: 100%; height: auto; margin: 8px 0 4px; }
.profile .axis { stroke: var(--edge); stroke-width: 1.5; }
.profile .env { stroke: var(--dim); stroke-width: 1; stroke-dasharray: 2 6; fill: none; opacity: 0.6; }
.profile .sl { stroke: var(--edge); stroke-width: 2; }
.profile .flow {
stroke: var(--accent);
stroke-width: 2;
stroke-dasharray: 14 22;
animation: drift linear infinite;
}
.profile .head { fill: var(--accent); }
.profile .lbl { fill: var(--dim); font: 11px var(--mono); }
.profile .f1 { animation-duration: 1.26s; }
.profile .f2 { animation-duration: 1.03s; }
.profile .f3 { animation-duration: 0.85s; }
.profile .f4 { animation-duration: 0.71s; }
.profile .f5 { animation-duration: 0.6s; }
@keyframes drift { to { stroke-dashoffset: -36; } }
@media (prefers-reduced-motion: reduce) {
.profile .flow { animation: none; }
}
@media (max-width: 520px) {
main { padding: 40px 16px 64px; }
h1 { font-size: 2rem; }
.code { padding: 12px 14px; }
.install { font-size: 0.82rem; padding: 12px 14px; }
.profile .lbl { display: none; }
}
</style>
<script defer src="https://analytics.tinymachines.io/script.js" data-website-id="3bc10fa9-3bc0-4b25-b027-1db319bae7a2" data-domains="winds.rs,www.winds.rs" data-do-not-track="true" data-exclude-search="true" data-exclude-hash="true"></script>
</head>
<body>
<main>
<header>
<h1>winds-engine <span class="ver">v0.1.1</span></h1>
<p class="tagline">Deterministic wind profiles and radiosonde validation for trajectory simulation, in Rust.</p>
<svg class="profile" viewBox="0 0 780 170" aria-hidden="true">
<line class="axis" x1="40" y1="160" x2="40" y2="10"/>
<line class="axis" x1="24" y1="160" x2="756" y2="160"/>
<text class="lbl" x="50" y="16">y up</text>
<text class="lbl" x="752" y="150" text-anchor="end">x downrange</text>
<polyline class="env" points="40,160 369,146 441,120 526,92 621,60 730,24"/>
<line class="sl" x1="40" y1="146" x2="361" y2="146"/>
<line class="sl" x1="40" y1="120" x2="433" y2="120"/>
<line class="sl" x1="40" y1="92" x2="518" y2="92"/>
<line class="sl" x1="40" y1="60" x2="613" y2="60"/>
<line class="sl" x1="40" y1="24" x2="722" y2="24"/>
<line class="flow f1" x1="40" y1="146" x2="361" y2="146"/>
<line class="flow f2" x1="40" y1="120" x2="433" y2="120"/>
<line class="flow f3" x1="40" y1="92" x2="518" y2="92"/>
<line class="flow f4" x1="40" y1="60" x2="613" y2="60"/>
<line class="flow f5" x1="40" y1="24" x2="722" y2="24"/>
<path class="head" d="M 359 141 L 369 146 L 359 151 Z"/>
<path class="head" d="M 431 115 L 441 120 L 431 125 Z"/>
<path class="head" d="M 516 87 L 526 92 L 516 97 Z"/>
<path class="head" d="M 611 55 L 621 60 L 611 65 Z"/>
<path class="head" d="M 720 19 L 730 24 L 720 29 Z"/>
</svg>
</header>
<div class="install"><span class="p"># Cargo.toml</span><br>winds-engine = "0.1"</div>
<h2>What it does</h2>
<ul>
<li><b><code>WindField</code></b> — one trait, <code>wind(time_s, position_m)</code>. Stateless and deterministic: the same time and position always give the same wind, so integrator substeps and seeded reruns reproduce exactly.</li>
<li><b><code>ConstantField</code></b> — uniform wind everywhere.</li>
<li><b><code>ProfileField</code> / <code>ProfileLevel</code></b> — 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.</li>
<li><b><code>LogProfile</code></b> — log-law boundary layer from a reference speed, a reference height and a roughness length; calm at and below the roughness length, constant direction.</li>
<li><b><code>EkmanProfile</code></b> — 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.</li>
<li><b><code>Sounding</code></b> — parses a deliberately trivial line format (<code>altitude_m speed_mps direction_from_deg</code> lines, plus <code>#</code> comments, where <code># station:</code> and <code># valid:</code> set the header fields), so radiosonde archives convert offline. Tested against a real 115-level KMPX sounding from the Iowa State IEM RAOB archive.</li>
<li><b><code>validate</code></b> — scores a <code>ProfileField</code> against 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).</li>
</ul>
<p>Zero dependencies. No network, no wall clock, no unseeded randomness.</p>
<h2>Conventions</h2>
<ul>
<li><b>Frame</b> — x downrange, y up, z to the right: right-handed, the frame ballistics-engine uses.</li>
<li><b>Directions</b> — where the wind comes <em>from</em>, degrees clockwise: 0° is a headwind, 90° is from the right.</li>
<li><b>Units</b> — m/s for wind, metres for heights and positions, seconds for time. These horizontal fields have no vertical wind.</li>
</ul>
<h2>Heights</h2>
<p>Sounding heights are metres above sea level, and <code>to_profile_field()</code> 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 <code>validate</code> use the same sea-level heights.</p>
<p><code>LogProfile</code> and <code>EkmanProfile</code> take y as height above the ground, where they are calm. In a sea-level frame, read them at <code>y − ground elevation</code>. This crate has no offset wrapper, so convert in one place only: shifting the sounding too would move every wind by the site elevation.</p>
<h2>Example</h2>
<pre class="code"><code><span class="k">use</span> <span class="t">winds_engine</span>::analytic::<span class="t">LogProfile</span>;
<span class="k">use</span> <span class="t">winds_engine</span>::{validate, <span class="t">ProfileField</span>, <span class="t">ProfileLevel</span>, <span class="t">Sounding</span>, <span class="t">WindField</span>};
<span class="k">fn</span> main() -> <span class="t">winds_engine</span>::<span class="t">Result</span><()> {
<span class="c">// altitude_m speed_mps direction_from_deg; heights above sea level</span>
<span class="k">let</span> sounding = <span class="t">Sounding</span>::parse(<span class="s">"287 0.0 0\n305 3.09 125\n610 3.60 145\n"</span>)?;
<span class="c">// The profile keeps those heights: read it at y = altitude above sea level.</span>
<span class="k">let</span> measured = sounding.to_profile_field()?;
<span class="k">let</span> wind = measured.wind(<span class="n">0.0</span>, [<span class="n">0.0</span>, <span class="n">500.0</span>, <span class="n">0.0</span>]);
<span class="k">let</span> (speed, from) = (wind.speed_mps(), wind.direction_from_deg());
<span class="m">println!</span>(<span class="s">"{speed:.2} m/s from {from:.0} deg"</span>);
<span class="c">// Score a candidate profile, on the same sea-level heights, against it.</span>
<span class="k">let</span> candidate = <span class="t">ProfileField</span>::new(<span class="m">vec!</span>[
<span class="t">ProfileLevel</span> { altitude_m: <span class="n">287.0</span>, speed_mps: <span class="n">0.0</span>, direction_from_deg: <span class="n">0.0</span> },
<span class="t">ProfileLevel</span> { altitude_m: <span class="n">610.0</span>, speed_mps: <span class="n">4.0</span>, direction_from_deg: <span class="n">140.0</span> },
])?;
<span class="k">let</span> report = validate::validate(&candidate, &sounding);
<span class="m">println!</span>(<span class="s">"speed RMSE {:.2} m/s"</span>, report.speed_rmse_mps);
<span class="c">// A log law is calm at the ground: convert the datum here, once.</span>
<span class="k">let</span> ground_m = <span class="n">287.0</span>;
<span class="k">let</span> log = <span class="t">LogProfile</span>::new(<span class="n">8.0</span>, <span class="n">10.0</span>, <span class="n">0.03</span>, <span class="n">270.0</span>)?;
<span class="k">let</span> low = log.wind(<span class="n">0.0</span>, [<span class="n">0.0</span>, <span class="n">500.0</span> - ground_m, <span class="n">0.0</span>]);
<span class="m">println!</span>(<span class="s">"{:.2} m/s"</span>, low.speed_mps());
<span class="t">Ok</span>(())
}</code></pre>
<p class="note">Needs Rust 1.89 or newer. The README walks through the same sounding.</p>
<h2>Where it fits</h2>
<ul>
<li><b>ballistics-engine</b> — 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 <code>Wind</code> type, 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.</li>
<li><b>rockets-engine</b> — the consumer it is built for: sounding profiles keep sea-level heights, the same height datum as rockets-engine's frame. Since rockets-engine 0.16.0 it depends on winds-engine: <code>BallisticForces</code>, the weathercock and the recovery descent share an <code>Arc<dyn WindField></code>, and the CLI flies <code>--wind-profile</code> and <code>--wind-sounding</code>. When the ballistics-engine wind-field release lands, winds-engine moves to the kernel API in a deliberate breaking release and rockets-engine follows.</li>
</ul>
<h2>Status</h2>
<p>Version 0.1.1, published on <a href="https://crates.io/crates/winds-engine">crates.io</a>; API docs at <a href="https://docs.rs/winds-engine">docs.rs/winds-engine</a>. It is a library only, with no CLI and no browser terminal.</p>
<p class="note">Planned, not built: GRIB2 forecast ingestion (HRRR/GFS), with only the download step behind an opt-in feature, and more sounding validation days.</p>
<h2>Get it</h2>
<div class="links">
<a href="https://github.com/ajokela/winds-engine">github</a>
<a href="https://rockets.rs">rockets.rs</a>
<a href="https://ballistics.rs">ballistics.rs</a>
</div>
<footer>
Sister sites: <a href="https://rockets.rs">rockets.rs</a> · <a href="https://ballistics.rs">ballistics.rs</a> · MIT licensed
</footer>
</main>
</body>
</html>