Skip to main content

navcore_math/
wind.rs

1//! The wind triangle: converting between apparent and true wind.
2//!
3//! # Which true wind
4//!
5//! There are two, and mixing them up is a real source of wrong tactical
6//! numbers. Both come out of the same formula; the difference is entirely
7//! in what the caller passes as the vessel's motion:
8//!
9//! - Pass **speed through water and heading** and you get the wind relative
10//!   to the water -- the "sailing wind". This is the one that sets the
11//!   boat's trim and the one polar tables are indexed by.
12//! - Pass **speed over ground and course over ground** and you get the wind
13//!   relative to the ground, which is what a shore observer or a weather
14//!   forecast means.
15//!
16//! In a tideway the two differ by the whole current vector. This module
17//! takes whatever it is given and names the parameter `vessel_speed_kn`
18//! rather than pretending to know which one the caller meant.
19
20use crate::angle;
21
22/// Wind speed and its angle off the bow, signed positive to starboard.
23///
24/// Used for both true and apparent wind -- they differ in meaning, not in
25/// shape, and the functions returning them say which is which.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Wind {
28    /// Wind speed in knots.
29    pub speed_kn: f64,
30    /// Angle the wind blows *from*, measured off the bow, `-180.0..=180.0`,
31    /// positive to starboard.
32    pub angle_deg: f64,
33}
34
35/// True wind from measured apparent wind and the vessel's own motion.
36///
37/// This is the direction the instruments cannot measure: a masthead unit
38/// only ever sees apparent wind, and everything tactical is computed from
39/// what this returns.
40///
41/// Apparent wind is the vector sum of true wind and the vessel's own
42/// velocity; this function subtracts the vessel's velocity back out. For
43/// 15 kn of apparent wind at 40° and a vessel making 6 kn:
44///
45/// <svg viewBox="0 0 384 235" width="384" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Vector triangle: apparent wind equals true wind plus the vessel's own velocity">
46///   <defs>
47///     <marker id="wt-arrow-vessel" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="17" markerHeight="17" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
48///       <path d="M0,0 L10,5 L0,10 z" fill="var(--link-color)"/>
49///     </marker>
50///     <marker id="wt-arrow-true" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="17" markerHeight="17" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
51///       <path d="M0,0 L10,5 L0,10 z" fill="var(--type-link-color)"/>
52///     </marker>
53///     <marker id="wt-arrow-apparent" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="17" markerHeight="17" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
54///       <path d="M0,0 L10,5 L0,10 z" fill="var(--macro-link-color)"/>
55///     </marker>
56///   </defs>
57///   <g fill="none">
58///     <line x1="84" y1="192" x2="84" y2="24" stroke="currentColor" stroke-width="1.8" stroke-dasharray="4,6" opacity="0.5"/>
59///     <line x1="84" y1="192" x2="84" y2="132" stroke="var(--link-color)" stroke-width="3.6" stroke-dasharray="9,5" marker-end="url(#wt-arrow-vessel)"/>
60///     <line x1="84" y1="127" x2="187" y2="70" stroke="var(--type-link-color)" stroke-width="3.6" marker-end="url(#wt-arrow-true)"/>
61///     <line x1="84" y1="192" x2="190" y2="67" stroke="var(--macro-link-color)" stroke-width="5.4" marker-end="url(#wt-arrow-apparent)"/>
62///     <path d="M84,194 L91,206 L91,218 L77,218 L77,206 Z" fill="currentColor" stroke="none"/>
63///   </g>
64///   <g style="font-family:var(--font-family-code);font-size:1rem" stroke="none">
65///     <text x="0" y="140" fill="var(--link-color)">vessel</text>
66///     <text x="0" y="158" fill="var(--link-color)">6 kn</text>
67///     <text x="197" y="48" fill="var(--type-link-color)">true wind</text>
68///     <text x="197" y="72" fill="var(--type-link-color)">11.1 kn @ 60°</text>
69///     <text x="106" y="222" fill="var(--macro-link-color)">apparent wind, 15 kn @ 40°</text>
70///   </g>
71/// </svg>
72#[must_use]
73pub fn true_from_apparent(apparent: Wind, vessel_speed_kn: f64) -> Wind {
74    let awa = apparent.angle_deg.to_radians();
75
76    // Work in the boat frame, x forward and y to starboard, on the vector
77    // the wind blows *from*. Subtracting the vessel's own velocity from the
78    // apparent vector leaves the true one; the component along the bow is
79    // where the vessel's speed enters.
80    let along = apparent.speed_kn * awa.cos() - vessel_speed_kn;
81    let across = apparent.speed_kn * awa.sin();
82
83    Wind {
84        speed_kn: across.hypot(along),
85        angle_deg: angle::norm_180(across.atan2(along).to_degrees()),
86    }
87}
88
89/// Apparent wind the vessel would feel given the true wind and its own
90/// motion. The exact inverse of [`true_from_apparent`].
91///
92/// Useful for predicting a tack before making it: rotate the true wind
93/// angle to the other board, run this, and you have the apparent wind the
94/// boat will settle on. Same triangle as [`true_from_apparent`], run the
95/// other way -- for 10 kn of true wind at 50° and a vessel making 5 kn:
96///
97/// <svg viewBox="0 0 384 235" width="384" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Vector triangle: apparent wind equals true wind plus the vessel's own velocity">
98///   <defs>
99///     <marker id="wt2-arrow-vessel" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="17" markerHeight="17" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
100///       <path d="M0,0 L10,5 L0,10 z" fill="var(--link-color)"/>
101///     </marker>
102///     <marker id="wt2-arrow-true" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="17" markerHeight="17" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
103///       <path d="M0,0 L10,5 L0,10 z" fill="var(--type-link-color)"/>
104///     </marker>
105///     <marker id="wt2-arrow-apparent" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="17" markerHeight="17" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
106///       <path d="M0,0 L10,5 L0,10 z" fill="var(--macro-link-color)"/>
107///     </marker>
108///   </defs>
109///   <g fill="none">
110///     <line x1="84" y1="192" x2="84" y2="24" stroke="currentColor" stroke-width="1.8" stroke-dasharray="4,6" opacity="0.5"/>
111///     <line x1="84" y1="192" x2="84" y2="147" stroke="var(--link-color)" stroke-width="3.6" stroke-dasharray="9,5" marker-end="url(#wt2-arrow-vessel)"/>
112///     <line x1="84" y1="142" x2="153" y2="89" stroke="var(--type-link-color)" stroke-width="3.6" marker-end="url(#wt2-arrow-true)"/>
113///     <line x1="84" y1="192" x2="153" y2="89" stroke="var(--macro-link-color)" stroke-width="5.4" marker-end="url(#wt2-arrow-apparent)"/>
114///     <path d="M84,194 L91,206 L91,218 L77,218 L77,206 Z" fill="currentColor" stroke="none"/>
115///   </g>
116///   <g style="font-family:var(--font-family-code);font-size:1rem" stroke="none">
117///     <text x="0" y="155" fill="var(--link-color)">vessel</text>
118///     <text x="0" y="173" fill="var(--link-color)">5 kn</text>
119///     <text x="163" y="75" fill="var(--type-link-color)">true wind</text>
120///     <text x="163" y="97" fill="var(--type-link-color)">10 kn @ 50°</text>
121///     <text x="100" y="222" fill="var(--macro-link-color)">apparent wind, 13.8 kn @ 34°</text>
122///   </g>
123/// </svg>
124#[must_use]
125pub fn apparent_from_true(true_wind: Wind, vessel_speed_kn: f64) -> Wind {
126    let twa = true_wind.angle_deg.to_radians();
127
128    let along = true_wind.speed_kn * twa.cos() + vessel_speed_kn;
129    let across = true_wind.speed_kn * twa.sin();
130
131    Wind {
132        speed_kn: across.hypot(along),
133        angle_deg: angle::norm_180(across.atan2(along).to_degrees()),
134    }
135}
136
137/// The compass direction the wind blows from, given the vessel's heading
138/// and a wind angle off the bow.
139///
140/// For a vessel heading 350° with the wind 30° off the bow to starboard:
141///
142/// <svg viewBox="0 0 320 245" width="320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Compass rose: wind direction equals heading plus wind angle off the bow">
143///   <defs>
144///     <marker id="cd1-arrow-heading" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="15" markerHeight="15" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
145///       <path d="M0,0 L10,5 L0,10 z" fill="var(--link-color)"/>
146///     </marker>
147///     <marker id="cd1-arrow-wind" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="15" markerHeight="15" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
148///       <path d="M0,0 L10,5 L0,10 z" fill="var(--macro-link-color)"/>
149///     </marker>
150///   </defs>
151///   <circle cx="120" cy="120" r="95" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
152///   <g fill="none">
153///     <line x1="120" y1="120" x2="120" y2="25" stroke="currentColor" stroke-width="1.2" stroke-dasharray="3,5" opacity="0.5"/>
154///     <path d="M113.4,82.6 A38,38 0 0,1 133.0,84.3" stroke="var(--type-link-color)" stroke-width="2"/>
155///     <line x1="120" y1="120" x2="103.5" y2="26.4" stroke="var(--link-color)" stroke-width="3.2" marker-end="url(#cd1-arrow-heading)"/>
156///     <line x1="120" y1="120" x2="152.5" y2="30.7" stroke="var(--macro-link-color)" stroke-width="3.2" marker-end="url(#cd1-arrow-wind)"/>
157///     <path d="M120,103 L127,115 L127,138 L113,138 L113,115 Z" fill="currentColor" stroke="none" transform="rotate(350 120 120)"/>
158///   </g>
159///   <g style="font-family:var(--font-family-code);font-size:1rem" stroke="none">
160///     <text x="112" y="19" fill="currentColor" opacity="0.6">N</text>
161///     <text x="0" y="182" fill="var(--link-color)">heading: 350°</text>
162///     <text x="0" y="204" fill="var(--type-link-color)">wind angle off bow: +30°</text>
163///     <text x="0" y="226" fill="var(--macro-link-color)">wind direction: 020°</text>
164///   </g>
165/// </svg>
166#[must_use]
167pub fn direction_deg(heading_deg: f64, wind_angle_deg: f64) -> f64 {
168    angle::norm_360(heading_deg + wind_angle_deg)
169}
170
171/// The wind angle off the bow, given the vessel's heading and the compass
172/// direction the wind blows from. The inverse of [`direction_deg`].
173///
174/// Same relationship as [`direction_deg`], read the other way -- same
175/// heading, same wind direction, same 30° result:
176///
177/// <svg viewBox="0 0 320 245" width="320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Compass rose: wind angle off the bow equals wind direction minus heading">
178///   <defs>
179///     <marker id="cd2-arrow-heading" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="15" markerHeight="15" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
180///       <path d="M0,0 L10,5 L0,10 z" fill="var(--link-color)"/>
181///     </marker>
182///     <marker id="cd2-arrow-wind" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="15" markerHeight="15" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
183///       <path d="M0,0 L10,5 L0,10 z" fill="var(--macro-link-color)"/>
184///     </marker>
185///   </defs>
186///   <circle cx="120" cy="120" r="95" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
187///   <g fill="none">
188///     <line x1="120" y1="120" x2="120" y2="25" stroke="currentColor" stroke-width="1.2" stroke-dasharray="3,5" opacity="0.5"/>
189///     <path d="M113.4,82.6 A38,38 0 0,1 133.0,84.3" stroke="var(--type-link-color)" stroke-width="2"/>
190///     <line x1="120" y1="120" x2="103.5" y2="26.4" stroke="var(--link-color)" stroke-width="3.2" marker-end="url(#cd2-arrow-heading)"/>
191///     <line x1="120" y1="120" x2="152.5" y2="30.7" stroke="var(--macro-link-color)" stroke-width="3.2" marker-end="url(#cd2-arrow-wind)"/>
192///     <path d="M120,103 L127,115 L127,138 L113,138 L113,115 Z" fill="currentColor" stroke="none" transform="rotate(350 120 120)"/>
193///   </g>
194///   <g style="font-family:var(--font-family-code);font-size:1rem" stroke="none">
195///     <text x="112" y="19" fill="currentColor" opacity="0.6">N</text>
196///     <text x="0" y="182" fill="var(--link-color)">heading: 350°</text>
197///     <text x="0" y="204" fill="var(--macro-link-color)">wind direction: 020°</text>
198///     <text x="0" y="226" fill="var(--type-link-color)">wind angle off bow: +30°</text>
199///   </g>
200/// </svg>
201#[must_use]
202pub fn angle_off_bow_deg(heading_deg: f64, wind_direction_deg: f64) -> f64 {
203    angle::diff(wind_direction_deg, heading_deg)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn close(a: f64, b: f64, tol: f64) -> bool {
211        (a - b).abs() < tol
212    }
213
214    #[test]
215    fn beating_makes_the_wind_forward_and_stronger() {
216        // The everyday observation: close hauled, apparent is well forward
217        // of true and blows harder.
218        let apparent = Wind { speed_kn: 10.0, angle_deg: 45.0 };
219        let t = true_from_apparent(apparent, 6.0);
220        assert!(t.angle_deg > 45.0, "true wind is further aft: {t:?}");
221        assert!(t.speed_kn < 10.0, "true wind is weaker: {t:?}");
222        assert!(close(t.speed_kn, 7.152, 1e-3), "{t:?}");
223        assert!(close(t.angle_deg, 81.3868, 1e-3), "{t:?}");
224    }
225
226    #[test]
227    fn running_dead_downwind_subtracts_boat_speed() {
228        // True 15 kn from astern, boat doing 6: 9 kn over the deck, still
229        // from astern.
230        let t = Wind { speed_kn: 15.0, angle_deg: 180.0 };
231        let a = apparent_from_true(t, 6.0);
232        assert!(close(a.speed_kn, 9.0, 1e-9), "{a:?}");
233        assert!(close(a.angle_deg.abs(), 180.0, 1e-9), "{a:?}");
234    }
235
236    #[test]
237    fn head_to_wind_adds_boat_speed() {
238        let t = Wind { speed_kn: 10.0, angle_deg: 0.0 };
239        let a = apparent_from_true(t, 5.0);
240        assert!(close(a.speed_kn, 15.0, 1e-9), "{a:?}");
241        assert!(close(a.angle_deg, 0.0, 1e-9), "{a:?}");
242    }
243
244    #[test]
245    fn becalmed_boat_sees_true_wind_unchanged() {
246        let a = Wind { speed_kn: 12.0, angle_deg: 55.0 };
247        let t = true_from_apparent(a, 0.0);
248        assert!(close(t.speed_kn, 12.0, 1e-9), "{t:?}");
249        assert!(close(t.angle_deg, 55.0, 1e-9), "{t:?}");
250    }
251
252    #[test]
253    fn the_two_conversions_invert_each_other() {
254        for awa in [-175.0, -90.0, -30.0, 0.0, 30.0, 90.0, 175.0] {
255            for aws in [3.0, 12.0, 40.0] {
256                for boat in [0.0, 5.5, 11.0] {
257                    let a = Wind { speed_kn: aws, angle_deg: awa };
258                    let back = apparent_from_true(true_from_apparent(a, boat), boat);
259                    assert!(close(back.speed_kn, aws, 1e-9), "{awa}/{aws}/{boat}");
260                    assert!(close(back.angle_deg, awa, 1e-9), "{awa}/{aws}/{boat}");
261                }
262            }
263        }
264    }
265
266    #[test]
267    fn port_tack_mirrors_starboard() {
268        let stb = true_from_apparent(Wind { speed_kn: 14.0, angle_deg: 38.0 }, 6.5);
269        let prt = true_from_apparent(Wind { speed_kn: 14.0, angle_deg: -38.0 }, 6.5);
270        assert!(close(stb.speed_kn, prt.speed_kn, 1e-12));
271        assert!(close(stb.angle_deg, -prt.angle_deg, 1e-12));
272    }
273
274    #[test]
275    fn direction_and_angle_off_bow_invert() {
276        assert!(close(direction_deg(350.0, 30.0), 20.0, 1e-9));
277        assert!(close(angle_off_bow_deg(350.0, 20.0), 30.0, 1e-9));
278        assert!(close(angle_off_bow_deg(10.0, 350.0), -20.0, 1e-9));
279    }
280}