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}