winds-engine 0.4.0

Deterministic wind profiles and radiosonde validation for trajectory simulation
Documentation
<!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() -&gt; <span class="t">winds_engine</span>::<span class="t">Result</span>&lt;()&gt; {
    <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(&amp;candidate, &amp;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&lt;dyn WindField&gt;</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>