Skip to main content

navcore_math/
sailing.rs

1//! Velocity made good and laylines.
2
3use crate::{Position, angle, rhumb};
4
5/// Speed made good directly towards a waypoint, knots.
6///
7/// Negative when the vessel is opening the range rather than closing it,
8/// which is the normal state on the wrong tack and must not be clamped
9/// away -- a beat is a sequence of legs that individually look bad.
10#[must_use]
11pub fn vmg_to_waypoint_kn(sog_kn: f64, cog_deg: f64, bearing_to_waypoint_deg: f64) -> f64 {
12    sog_kn * angle::diff(bearing_to_waypoint_deg, cog_deg).to_radians().cos()
13}
14
15/// Speed made good directly to windward, knots.
16///
17/// Positive is progress towards the wind, so a boat on a run returns a
18/// negative value; its downwind VMG is that number negated. One function
19/// rather than two, because upwind and downwind VMG are the same
20/// projection and splitting them invites disagreeing sign conventions.
21#[must_use]
22pub fn vmg_to_wind_kn(boat_speed_kn: f64, true_wind_angle_deg: f64) -> f64 {
23    boat_speed_kn * true_wind_angle_deg.to_radians().cos()
24}
25
26/// The two close-hauled headings for a given wind, degrees true.
27///
28/// Returned as `(starboard_tack, port_tack)`. On starboard tack the wind
29/// comes over the starboard side, which puts the heading to the left of the
30/// wind's own direction.
31#[must_use]
32pub fn close_hauled_headings_deg(
33    true_wind_direction_deg: f64,
34    beat_angle_deg: f64,
35) -> (f64, f64) {
36    (
37        angle::norm_360(true_wind_direction_deg - beat_angle_deg),
38        angle::norm_360(true_wind_direction_deg + beat_angle_deg),
39    )
40}
41
42/// How far to sail on each tack to fetch a mark dead to windward.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct TackLegs {
45    /// Distance to sail on starboard tack, nautical miles.
46    pub starboard_nm: f64,
47    /// Distance to sail on port tack, nautical miles.
48    pub port_nm: f64,
49    /// Heading on starboard tack, degrees true.
50    pub starboard_heading_deg: f64,
51    /// Heading on port tack, degrees true.
52    pub port_heading_deg: f64,
53}
54
55impl TackLegs {
56    /// Total distance sailed, which is what the beat actually costs.
57    #[must_use]
58    pub fn total_nm(&self) -> f64 {
59        self.starboard_nm + self.port_nm
60    }
61}
62
63/// Splits the distance to an upwind mark into one leg on each tack.
64///
65/// `beat_angle_deg` is the angle the boat sails off the true wind when
66/// close hauled -- a polar property of the boat, typically 35-50 degrees,
67/// which is why it is a parameter and not a constant.
68///
69/// Returns `None` when the mark does not need two tacks: it is already
70/// fetchable on one, or it is not upwind at all. That is the signal to stop
71/// drawing laylines, not an error.
72///
73/// # Model
74///
75/// Plane trigonometry on the triangle formed by the two headings and the
76/// bearing to the mark, deliberately: laylines are a tactical picture over
77/// a few miles, where the difference against a spherical solution is far
78/// below the accuracy of the beat angle it is built on. Do not reach for
79/// this over ocean distances.
80#[must_use]
81pub fn tack_legs(
82    bearing_to_mark_deg: f64,
83    distance_to_mark_nm: f64,
84    true_wind_direction_deg: f64,
85    beat_angle_deg: f64,
86) -> Option<TackLegs> {
87    if !(0.0..90.0).contains(&beat_angle_deg) || beat_angle_deg <= 0.0 {
88        return None;
89    }
90    if !distance_to_mark_nm.is_finite() || distance_to_mark_nm <= 0.0 {
91        return None;
92    }
93
94    let (stb_hdg, prt_hdg) = close_hauled_headings_deg(true_wind_direction_deg, beat_angle_deg);
95
96    // Solving d_stb * u(stb) + d_prt * u(prt) = d * u(bearing) for the two
97    // leg lengths. The determinant of that 2x2 system is sin(2*beat), which
98    // is why a beat angle of 0 or 90 has to be rejected above.
99    let denom = (2.0 * beat_angle_deg).to_radians().sin();
100    let stb =
101        distance_to_mark_nm * angle::diff(prt_hdg, bearing_to_mark_deg).to_radians().sin() / denom;
102    let prt =
103        distance_to_mark_nm * angle::diff(bearing_to_mark_deg, stb_hdg).to_radians().sin() / denom;
104
105    if stb < 0.0 || prt < 0.0 {
106        return None;
107    }
108
109    Some(TackLegs {
110        starboard_nm: stb,
111        port_nm: prt,
112        starboard_heading_deg: stb_hdg,
113        port_heading_deg: prt_hdg,
114    })
115}
116
117/// How far past the mark a layline ray reaches from [`layline_rays`], as a
118/// multiple of the vessel's own current distance to the mark -- long
119/// enough that the ray always extends past wherever the vessel actually
120/// is on the chart, short enough that it does not dominate a zoomed-in
121/// view. A fixed on-screen length cannot do this: it would either vanish
122/// at ocean scale or blot out a marina at pier scale. `pub` so every
123/// client draws the same length rather than picking its own.
124pub const LAYLINE_LENGTH_FACTOR: f64 = 1.3;
125
126/// How far a tack ray reaches from the vessel in [`tack_rays`].
127///
128/// Unlike a layline, a tack ray has no mark to scale itself against --
129/// there is no "distance to" anything -- so this is a plain figure
130/// instead: long enough to read clearly at typical coastal and harbour
131/// display scales, short enough not to run off a zoomed-in view. `pub`
132/// for the same reason as [`LAYLINE_LENGTH_FACTOR`].
133pub const TACK_RAY_LENGTH_NM: f64 = 1.0;
134
135/// The two laylines through `mark`, as ray endpoints from `mark` itself --
136/// `(mark, starboard_end, port_end)` -- or `None` when `mark` does not
137/// need a beat at all.
138///
139/// # Layline definition
140///
141/// A layline is a line through a mark, marking the boundary where
142/// tacking fetches the mark on a single close-hauled board. It requires
143/// a mark to exist, and returns `None` when the mark is already
144/// fetchable on one tack or lies dead downwind -- the same wind-triangle
145/// judgement [`tack_legs`] already makes. Each layline is the reciprocal
146/// of its own close-hauled heading, extended out from the mark: crossing
147/// it means the mark is fetchable on that tack.
148#[must_use]
149pub fn layline_rays(
150    vessel: Position,
151    mark: Position,
152    true_wind_direction_deg: f64,
153    beat_angle_deg: f64,
154) -> Option<(Position, Position, Position)> {
155    let bearing_to_mark_deg = rhumb::bearing_deg(vessel, mark);
156    let distance_to_mark_nm = rhumb::distance_nm(vessel, mark);
157
158    let legs = tack_legs(bearing_to_mark_deg, distance_to_mark_nm, true_wind_direction_deg, beat_angle_deg)?;
159
160    let length_nm = distance_to_mark_nm * LAYLINE_LENGTH_FACTOR;
161    let starboard_end =
162        rhumb::destination(mark, angle::norm_360(legs.starboard_heading_deg + 180.0), length_nm);
163    let port_end = rhumb::destination(mark, angle::norm_360(legs.port_heading_deg + 180.0), length_nm);
164
165    Some((mark, starboard_end, port_end))
166}
167
168/// The vessel's own two close-hauled headings right now, as ray endpoints
169/// from `vessel` itself -- `(starboard_end, port_end)`.
170///
171/// Unlike [`layline_rays`], there is no mark to test fetchability against
172/// and so nothing to judge: a tack always has two possible headings,
173/// whatever they are actually good for. This is simply
174/// [`close_hauled_headings_deg`], projected out to a chart-drawable
175/// length.
176#[must_use]
177pub fn tack_rays(vessel: Position, true_wind_direction_deg: f64, beat_angle_deg: f64) -> (Position, Position) {
178    let (starboard_heading_deg, port_heading_deg) =
179        close_hauled_headings_deg(true_wind_direction_deg, beat_angle_deg);
180    let starboard_end = rhumb::destination(vessel, starboard_heading_deg, TACK_RAY_LENGTH_NM);
181    let port_end = rhumb::destination(vessel, port_heading_deg, TACK_RAY_LENGTH_NM);
182    (starboard_end, port_end)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn close(a: f64, b: f64, tol: f64) -> bool {
190        (a - b).abs() < tol
191    }
192
193    #[test]
194    fn vmg_is_full_speed_straight_at_the_mark() {
195        assert!(close(vmg_to_waypoint_kn(6.0, 90.0, 90.0), 6.0, 1e-9));
196    }
197
198    #[test]
199    fn vmg_goes_negative_sailing_away() {
200        assert!(close(vmg_to_waypoint_kn(6.0, 270.0, 90.0), -6.0, 1e-9));
201    }
202
203    #[test]
204    fn vmg_across_the_bearing_is_zero() {
205        assert!(close(vmg_to_waypoint_kn(6.0, 0.0, 90.0), 0.0, 1e-9));
206    }
207
208    #[test]
209    fn pinching_beats_footing_only_up_to_a_point() {
210        // The classic trade-off, and a check that the projection has the
211        // right shape: 30 deg at 4 kn makes less to windward than 45 deg at
212        // 6 kn, even though it points higher.
213        let pinched = vmg_to_wind_kn(4.0, 30.0);
214        let footed = vmg_to_wind_kn(6.0, 45.0);
215        assert!(footed > pinched, "{footed} vs {pinched}");
216    }
217
218    #[test]
219    fn running_gives_negative_vmg_to_windward() {
220        assert!(vmg_to_wind_kn(7.0, 170.0) < 0.0);
221    }
222
223    #[test]
224    fn close_hauled_headings_straddle_the_wind() {
225        let (stb, prt) = close_hauled_headings_deg(0.0, 45.0);
226        assert!(close(stb, 315.0, 1e-9));
227        assert!(close(prt, 45.0, 1e-9));
228    }
229
230    #[test]
231    fn a_mark_dead_upwind_splits_evenly() {
232        let legs = tack_legs(0.0, 10.0, 0.0, 45.0).unwrap();
233        assert!(close(legs.starboard_nm, legs.port_nm, 1e-9), "{legs:?}");
234        assert!(close(legs.starboard_nm, 7.0710678, 1e-6), "{legs:?}");
235        // Beating 45 degrees costs sqrt(2) times the straight-line distance.
236        assert!(close(legs.total_nm(), 10.0 * 2f64.sqrt(), 1e-6), "{legs:?}");
237    }
238
239    #[test]
240    fn a_mark_off_the_wind_axis_favours_one_tack() {
241        // Wind from north, mark 30 degrees east of dead upwind: mostly port
242        // tack (heading 045), with a short starboard hitch to finish.
243        let legs = tack_legs(30.0, 10.0, 0.0, 45.0).unwrap();
244        assert!(close(legs.starboard_nm, 2.5882, 1e-3), "{legs:?}");
245        assert!(close(legs.port_nm, 9.6593, 1e-3), "{legs:?}");
246        assert!(legs.port_nm > legs.starboard_nm, "{legs:?}");
247    }
248
249    #[test]
250    fn a_fetchable_mark_needs_no_laylines() {
251        // On the beam: laying it needs no tack at all.
252        assert!(tack_legs(90.0, 10.0, 0.0, 45.0).is_none());
253        // And dead downwind is not a beat either.
254        assert!(tack_legs(180.0, 10.0, 0.0, 45.0).is_none());
255    }
256
257    #[test]
258    fn exactly_on_the_layline_puts_everything_on_one_tack() {
259        // Mark bearing 315 with wind from north is the starboard layline.
260        let legs = tack_legs(315.0, 10.0, 0.0, 45.0).unwrap();
261        assert!(close(legs.starboard_nm, 10.0, 1e-6), "{legs:?}");
262        assert!(close(legs.port_nm, 0.0, 1e-6), "{legs:?}");
263    }
264
265    #[test]
266    fn a_narrower_beat_angle_costs_less_distance() {
267        let tight = tack_legs(0.0, 10.0, 0.0, 35.0).unwrap();
268        let wide = tack_legs(0.0, 10.0, 0.0, 50.0).unwrap();
269        assert!(tight.total_nm() < wide.total_nm());
270    }
271
272    #[test]
273    fn nonsense_beat_angles_are_rejected() {
274        assert!(tack_legs(0.0, 10.0, 0.0, 0.0).is_none());
275        assert!(tack_legs(0.0, 10.0, 0.0, 90.0).is_none());
276        assert!(tack_legs(0.0, 10.0, 0.0, -10.0).is_none());
277        assert!(tack_legs(0.0, 0.0, 0.0, 45.0).is_none());
278    }
279
280    #[test]
281    fn the_solution_actually_reaches_the_mark() {
282        // Sail both legs as vectors and check we land on the mark -- the
283        // property the linear solve is supposed to have.
284        for bearing in [350.0, 0.0, 15.0, 340.0] {
285            let legs = tack_legs(bearing, 8.0, 0.0, 42.0).unwrap();
286            let leg_vec = |d: f64, h: f64| {
287                let r = h.to_radians();
288                (d * r.sin(), d * r.cos())
289            };
290            let (e1, n1) = leg_vec(legs.starboard_nm, legs.starboard_heading_deg);
291            let (e2, n2) = leg_vec(legs.port_nm, legs.port_heading_deg);
292            let (te, tn) = leg_vec(8.0, bearing);
293            assert!(close(e1 + e2, te, 1e-6), "east off for {bearing}");
294            assert!(close(n1 + n2, tn, 1e-6), "north off for {bearing}");
295        }
296    }
297
298    #[test]
299    fn a_mark_dead_upwind_gets_two_symmetric_laylines() {
300        let vessel = Position::new(45.0, 13.0);
301        let mark = rhumb::destination(vessel, 0.0, 10.0);
302        let (from, starboard_end, port_end) =
303            layline_rays(vessel, mark, 0.0, 45.0).expect("a beat needs laylines");
304        assert_eq!(from, mark);
305
306        // Both ends sit on the mark's own laylines -- reciprocal
307        // close-hauled headings from a wind straight from the north -- and
308        // therefore equally far from the vessel's own bearing to the
309        // mark, mirrored port and starboard.
310        let starboard_bearing = rhumb::bearing_deg(mark, starboard_end);
311        let port_bearing = rhumb::bearing_deg(mark, port_end);
312        assert!((starboard_bearing - 135.0).abs() < 1e-6, "{starboard_bearing}");
313        assert!((port_bearing - 225.0).abs() < 1e-6, "{port_bearing}");
314    }
315
316    #[test]
317    fn a_ray_reaches_past_the_vessel_not_just_to_it() {
318        let vessel = Position::new(45.0, 13.0);
319        let mark = rhumb::destination(vessel, 10.0, 5.0);
320        let (from, starboard_end, _port_end) =
321            layline_rays(vessel, mark, 0.0, 45.0).expect("a beat needs laylines");
322        // The starboard layline passes near the vessel (it is the one on
323        // the vessel's own side, for a mark bearing 10 degrees off a wind
324        // from the north) -- its ray must extend beyond that point, not
325        // stop short of it.
326        let to_vessel_nm = rhumb::distance_nm(from, vessel);
327        let to_ray_end_nm = rhumb::distance_nm(from, starboard_end);
328        assert!(to_ray_end_nm > to_vessel_nm, "{to_ray_end_nm} vs {to_vessel_nm}");
329    }
330
331    #[test]
332    fn a_mark_already_fetchable_on_one_tack_needs_no_laylines() {
333        let vessel = Position::new(45.0, 13.0);
334        // On the beam relative to a wind from the north: no beat at all,
335        // the same case tack_legs's own tests cover.
336        let mark = rhumb::destination(vessel, 90.0, 5.0);
337        assert!(layline_rays(vessel, mark, 0.0, 45.0).is_none());
338    }
339
340    #[test]
341    fn a_mark_dead_downwind_needs_no_laylines_either() {
342        let vessel = Position::new(45.0, 13.0);
343        let mark = rhumb::destination(vessel, 180.0, 5.0);
344        assert!(layline_rays(vessel, mark, 0.0, 45.0).is_none());
345    }
346
347    #[test]
348    fn a_tighter_beat_angle_narrows_the_laylines() {
349        let vessel = Position::new(45.0, 13.0);
350        let mark = rhumb::destination(vessel, 5.0, 8.0);
351        let (from, tight_starboard, _) =
352            layline_rays(vessel, mark, 0.0, 30.0).expect("still a beat at 30 degrees");
353        let (_, wide_starboard, _) =
354            layline_rays(vessel, mark, 0.0, 50.0).expect("still a beat at 50 degrees");
355
356        // Both starboard laylines run back towards the wind from a
357        // heading close to due south (the reciprocal of a close-hauled
358        // course near due north); the tighter angle's should sit closer
359        // to the mark's own true bearing back to the vessel.
360        let vessel_bearing_from_mark = rhumb::bearing_deg(from, vessel);
361        let tight_bearing = rhumb::bearing_deg(from, tight_starboard);
362        let wide_bearing = rhumb::bearing_deg(from, wide_starboard);
363        let tight_off = angle::diff(vessel_bearing_from_mark, tight_bearing).abs();
364        let wide_off = angle::diff(vessel_bearing_from_mark, wide_bearing).abs();
365        assert!(tight_off < wide_off, "{tight_off} vs {wide_off}");
366    }
367
368    #[test]
369    fn a_wind_straight_from_the_north_gives_symmetric_tack_rays() {
370        let vessel = Position::new(45.0, 13.0);
371        let (starboard_end, port_end) = tack_rays(vessel, 0.0, 45.0);
372
373        // Starboard tack: wind over the starboard side, heading to the
374        // left of the wind's own direction -- 315 degrees for a wind from
375        // due north at 45 degrees off it. Port tack mirrors it at 045.
376        let starboard_bearing = rhumb::bearing_deg(vessel, starboard_end);
377        let port_bearing = rhumb::bearing_deg(vessel, port_end);
378        assert!((starboard_bearing - 315.0).abs() < 1e-6, "{starboard_bearing}");
379        assert!((port_bearing - 45.0).abs() < 1e-6, "{port_bearing}");
380    }
381
382    #[test]
383    fn tack_rays_need_no_mark_and_never_disappear() {
384        // The one property that tells a tack ray apart from a layline:
385        // there is no fetchability to fail, so this always returns
386        // something, even on the beam or dead downwind -- the two cases
387        // that clear a layline entirely.
388        let vessel = Position::new(45.0, 13.0);
389        let (beam_starboard, beam_port) = tack_rays(vessel, 90.0, 45.0);
390        assert_ne!(beam_starboard, vessel);
391        assert_ne!(beam_port, vessel);
392    }
393
394    #[test]
395    fn a_tighter_beat_angle_narrows_the_tack_rays_too() {
396        let vessel = Position::new(45.0, 13.0);
397        let (tight_starboard, _) = tack_rays(vessel, 0.0, 30.0);
398        let (wide_starboard, _) = tack_rays(vessel, 0.0, 50.0);
399
400        let tight_bearing = rhumb::bearing_deg(vessel, tight_starboard);
401        let wide_bearing = rhumb::bearing_deg(vessel, wide_starboard);
402        // Starboard tack sits to the left of the wind (north, 0 degrees):
403        // a narrower beat angle points closer to it, i.e. a smaller
404        // bearing measured the short way round from 360.
405        assert!(360.0 - tight_bearing < 360.0 - wide_bearing);
406    }
407}