Skip to main content

navcore_math/
current.rs

1//! The current triangle: deriving set and drift, and steering to beat them.
2
3use crate::angle;
4
5/// A water current.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Current {
8    /// The direction the current flows **towards**, degrees true.
9    ///
10    /// Note this is the opposite convention to wind, which is named by the
11    /// direction it comes *from*. That is the standard marine usage and not
12    /// a slip: a north-going current sets 000, a north wind blows from 000.
13    pub set_deg: f64,
14    /// Current speed in knots.
15    pub drift_kn: f64,
16}
17
18/// The current implied by the difference between the vessel's motion
19/// through the water and over the ground.
20///
21/// Every quantity here is measured by a different instrument -- heading
22/// from the compass, speed through water from the log, course and speed
23/// over ground from GPS -- so the result carries the error of all four. A
24/// log reading 5% low produces a current that is not there.
25#[must_use]
26pub fn from_motion(heading_deg: f64, stw_kn: f64, cog_deg: f64, sog_kn: f64) -> Current {
27    let (water_e, water_n) = to_vector(heading_deg, stw_kn);
28    let (ground_e, ground_n) = to_vector(cog_deg, sog_kn);
29
30    let (e, n) = (ground_e - water_e, ground_n - water_n);
31
32    Current {
33        set_deg: angle::norm_360(e.atan2(n).to_degrees()),
34        drift_kn: e.hypot(n),
35    }
36}
37
38/// A heading that holds a desired track through a current.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct SteerSolution {
41    /// The heading to steer, degrees true.
42    pub heading_deg: f64,
43    /// The speed over ground that results, knots.
44    pub sog_kn: f64,
45}
46
47/// The heading to steer to make good `desired_track_deg` against a current,
48/// and the speed over ground it yields.
49///
50/// Returns `None` when no heading achieves it: either the cross-track
51/// component of the current exceeds the vessel's speed through the water,
52/// or it can be cancelled but only while being pushed backwards along the
53/// track. Both mean the leg cannot be laid at this speed, which is a real
54/// answer and one the caller must show rather than round away.
55#[must_use]
56pub fn heading_to_steer(
57    desired_track_deg: f64,
58    stw_kn: f64,
59    current: Current,
60) -> Option<SteerSolution> {
61    if stw_kn <= 0.0 {
62        return None;
63    }
64
65    // Split the current into components along the desired track and across
66    // it. Only the across component has to be steered against; the along
67    // component is a free gain or loss of speed.
68    let rel = angle::diff(current.set_deg, desired_track_deg).to_radians();
69    let across = current.drift_kn * rel.sin();
70    let along = current.drift_kn * rel.cos();
71
72    let sin_offset = -across / stw_kn;
73    if sin_offset.abs() > 1.0 {
74        return None;
75    }
76
77    // asin picks the offset in -90..90, i.e. the solution that still points
78    // up the track. The mirrored solution steers backwards and is never the
79    // one wanted.
80    let offset = sin_offset.asin();
81    let sog = stw_kn * offset.cos() + along;
82    if sog <= 0.0 {
83        return None;
84    }
85
86    Some(SteerSolution {
87        heading_deg: angle::norm_360(desired_track_deg + offset.to_degrees()),
88        sog_kn: sog,
89    })
90}
91
92/// (east, north) components of a speed on a bearing.
93fn to_vector(bearing_deg: f64, speed_kn: f64) -> (f64, f64) {
94    let b = bearing_deg.to_radians();
95    (speed_kn * b.sin(), speed_kn * b.cos())
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn close(a: f64, b: f64, tol: f64) -> bool {
103        (a - b).abs() < tol
104    }
105
106    #[test]
107    fn no_current_when_ground_and_water_motion_agree() {
108        let c = from_motion(90.0, 6.0, 90.0, 6.0);
109        assert!(close(c.drift_kn, 0.0, 1e-9), "{c:?}");
110    }
111
112    #[test]
113    fn a_following_current_shows_as_extra_speed() {
114        // Heading and making good 000, but 2 kn faster than the log says.
115        let c = from_motion(0.0, 5.0, 0.0, 7.0);
116        assert!(close(c.drift_kn, 2.0, 1e-9), "{c:?}");
117        assert!(close(c.set_deg, 0.0, 1e-9), "{c:?}");
118    }
119
120    #[test]
121    fn a_foul_current_shows_as_lost_speed_setting_astern() {
122        let c = from_motion(0.0, 5.0, 0.0, 3.0);
123        assert!(close(c.drift_kn, 2.0, 1e-9), "{c:?}");
124        assert!(close(c.set_deg, 180.0, 1e-9), "{c:?}");
125    }
126
127    #[test]
128    fn a_beam_current_shows_in_the_leeway_between_heading_and_cog() {
129        // Steering 000 at 5 kn but making good 045 at 5*sqrt(2)/... -- pick
130        // the clean case: 5 north plus 5 east gives 045 at 7.071.
131        let c = from_motion(0.0, 5.0, 45.0, 7.0710678);
132        assert!(close(c.set_deg, 90.0, 1e-6), "{c:?}");
133        assert!(close(c.drift_kn, 5.0, 1e-6), "{c:?}");
134    }
135
136    #[test]
137    fn steering_offsets_upstream_of_the_track() {
138        // Track due north, current setting east: crab to the east... no --
139        // steer *into* the current, so west of north.
140        let c = Current { set_deg: 90.0, drift_kn: 2.0 };
141        let s = heading_to_steer(0.0, 6.0, c).unwrap();
142        assert!(s.heading_deg > 270.0, "should steer west of north: {s:?}");
143        // sin(offset) = -2/6, offset = -19.47 deg
144        assert!(close(s.heading_deg, 360.0 - 19.4712, 1e-3), "{s:?}");
145        assert!(close(s.sog_kn, 6.0 * (19.4712_f64.to_radians()).cos(), 1e-6));
146    }
147
148    #[test]
149    fn steering_and_deriving_are_inverses() {
150        let c = Current { set_deg: 210.0, drift_kn: 1.7 };
151        let track = 55.0;
152        let s = heading_to_steer(track, 6.0, c).unwrap();
153        // Sail the solution and the resulting ground motion must be the
154        // track we asked for.
155        let derived = from_motion(s.heading_deg, 6.0, track, s.sog_kn);
156        assert!(close(derived.set_deg, c.set_deg, 1e-6), "{derived:?}");
157        assert!(close(derived.drift_kn, c.drift_kn, 1e-6), "{derived:?}");
158    }
159
160    #[test]
161    fn a_current_stronger_than_the_boat_across_the_track_has_no_solution() {
162        let c = Current { set_deg: 90.0, drift_kn: 5.0 };
163        assert!(heading_to_steer(0.0, 4.0, c).is_none());
164    }
165
166    #[test]
167    fn cancelling_the_cross_component_while_swept_backwards_has_no_solution() {
168        // Current dead against the track and faster than the boat: the
169        // across component is zero so the naive solve succeeds, but the
170        // vessel goes backwards. Must be rejected.
171        let c = Current { set_deg: 180.0, drift_kn: 8.0 };
172        assert!(heading_to_steer(0.0, 4.0, c).is_none());
173    }
174
175    #[test]
176    fn a_stopped_vessel_cannot_steer() {
177        let c = Current { set_deg: 90.0, drift_kn: 1.0 };
178        assert!(heading_to_steer(0.0, 0.0, c).is_none());
179    }
180}