1use crate::angle;
4
5#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Current {
8 pub set_deg: f64,
14 pub drift_kn: f64,
16}
17
18#[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#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct SteerSolution {
41 pub heading_deg: f64,
43 pub sog_kn: f64,
45}
46
47#[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 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 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
92fn 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 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 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 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 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 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 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}