1use crate::{Position, angle, rhumb};
4
5#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct TackLegs {
45 pub starboard_nm: f64,
47 pub port_nm: f64,
49 pub starboard_heading_deg: f64,
51 pub port_heading_deg: f64,
53}
54
55impl TackLegs {
56 #[must_use]
58 pub fn total_nm(&self) -> f64 {
59 self.starboard_nm + self.port_nm
60 }
61}
62
63#[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 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
117pub const LAYLINE_LENGTH_FACTOR: f64 = 1.3;
125
126pub const TACK_RAY_LENGTH_NM: f64 = 1.0;
134
135#[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#[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 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 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 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 assert!(tack_legs(90.0, 10.0, 0.0, 45.0).is_none());
253 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 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 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 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 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 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 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 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 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 assert!(360.0 - tight_bearing < 360.0 - wide_bearing);
406 }
407}