Skip to main content

navcore_math/
polar.rs

1//! Boat performance polars, and the tactical numbers built on one.
2//!
3//! A polar table states a boat's expected speed for a given wind angle
4//! and strength, values that depend on hull and rig and cannot be
5//! computed from a formula. [`crate::sailing::tack_legs`] and
6//! [`crate::sailing::close_hauled_headings_deg`] take the beat angle as
7//! a caller-stated number; [`Polar::best_upwind_angle_deg`] derives it
8//! instead, from the polar itself.
9//!
10//! # What is not modelled
11//!
12//! Sea state, reefing points, and crew skill are not modelled. A polar
13//! is a prediction, usually from a velocity prediction program or a
14//! measured racing record, not a live reading. Nothing here compares it
15//! against how the boat is actually sailing.
16
17use crate::{angle, sailing};
18
19/// Something wrong with a polar table.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum PolarError {
22    /// The text was not a polar table at all -- too few lines, or a
23    /// header or row that did not parse as numbers.
24    Malformed(String),
25    /// Parsed, but the shape does not make sense as a table -- fewer than
26    /// two wind speeds or angles, a row of the wrong length, or an axis
27    /// that is not strictly ascending.
28    InvalidShape(String),
29}
30
31impl std::fmt::Display for PolarError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Malformed(reason) | Self::InvalidShape(reason) => write!(f, "{reason}"),
35        }
36    }
37}
38
39impl std::error::Error for PolarError {}
40
41/// A boat's expected speed for every true wind angle and speed on a grid.
42///
43/// Symmetric port/starboard by construction: every lookup folds its own
44/// angle to `0..=180` first -- see [`Polar::boat_speed_kn`] -- because a
45/// hull does not sail differently on the other tack.
46#[derive(Debug, Clone, PartialEq)]
47pub struct Polar {
48    /// True wind speeds the table is defined at, knots, strictly
49    /// ascending.
50    tws_kn: Vec<f64>,
51    /// True wind angles the table is defined at, degrees `0..=180`,
52    /// strictly ascending.
53    twa_deg: Vec<f64>,
54    /// Boat speed in knots, one row per `twa_deg` entry, one column per
55    /// `tws_kn` entry -- the same layout a polar file's own rows and
56    /// columns are already in.
57    boat_speed_kn: Vec<Vec<f64>>,
58}
59
60impl Polar {
61    /// Builds a table from its own axes and the speed at every
62    /// intersection.
63    ///
64    /// # Errors
65    ///
66    /// [`PolarError::InvalidShape`] when either axis has fewer than two
67    /// points, either axis is not strictly ascending, `twa_deg` strays
68    /// outside `0..=180`, or `boat_speed_kn`'s own shape does not match
69    /// `twa_deg.len()` rows of `tws_kn.len()` columns each.
70    pub fn new(tws_kn: Vec<f64>, twa_deg: Vec<f64>, boat_speed_kn: Vec<Vec<f64>>) -> Result<Self, PolarError> {
71        if tws_kn.len() < 2 {
72            return Err(PolarError::InvalidShape("fewer than two wind speeds".to_owned()));
73        }
74        if twa_deg.len() < 2 {
75            return Err(PolarError::InvalidShape("fewer than two wind angles".to_owned()));
76        }
77        if !tws_kn.windows(2).all(|pair| pair[0] < pair[1]) {
78            return Err(PolarError::InvalidShape("wind speeds are not strictly ascending".to_owned()));
79        }
80        if !twa_deg.windows(2).all(|pair| pair[0] < pair[1]) {
81            return Err(PolarError::InvalidShape("wind angles are not strictly ascending".to_owned()));
82        }
83        if twa_deg[0] < 0.0 || *twa_deg.last().expect("checked len >= 2") > 180.0 {
84            return Err(PolarError::InvalidShape("wind angles must lie within 0..=180".to_owned()));
85        }
86        if boat_speed_kn.len() != twa_deg.len() {
87            return Err(PolarError::InvalidShape(format!(
88                "expected {} rows of boat speed, one per wind angle, got {}",
89                twa_deg.len(),
90                boat_speed_kn.len()
91            )));
92        }
93        if let Some(bad_row) = boat_speed_kn.iter().find(|row| row.len() != tws_kn.len()) {
94            return Err(PolarError::InvalidShape(format!(
95                "every row must have {} boat speeds, one per wind speed, found one with {}",
96                tws_kn.len(),
97                bad_row.len()
98            )));
99        }
100
101        Ok(Self { tws_kn, twa_deg, boat_speed_kn })
102    }
103
104    /// Parses the common tab/space-separated polar table format: a header
105    /// row of wind speeds (its own first cell, conventionally `twa/tws`,
106    /// ignored), then one row per wind angle -- the angle itself, then
107    /// one boat speed per wind speed column, in the same order as the
108    /// header.
109    ///
110    /// # Errors
111    ///
112    /// [`PolarError::Malformed`] when the text does not have this shape
113    /// at all; [`PolarError::InvalidShape`] when [`Polar::new`]'s own
114    /// checks fail on what was read.
115    pub fn parse(text: &str) -> Result<Self, PolarError> {
116        let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
117
118        let header = lines.next().ok_or_else(|| PolarError::Malformed("empty table".to_owned()))?;
119        let mut header_fields = header.split_whitespace();
120        // The header's own first cell names the table ("twa/tws"
121        // conventionally), not a number -- skipped rather than parsed.
122        header_fields.next();
123        let tws_kn: Vec<f64> = header_fields
124            .map(|field| {
125                field.parse::<f64>().map_err(|_| PolarError::Malformed(format!("bad wind speed: {field}")))
126            })
127            .collect::<Result<_, _>>()?;
128
129        let mut twa_deg = Vec::new();
130        let mut boat_speed_kn = Vec::new();
131        for line in lines {
132            let mut fields = line.split_whitespace();
133            let angle_deg = fields
134                .next()
135                .ok_or_else(|| PolarError::Malformed("row with no wind angle".to_owned()))?
136                .parse::<f64>()
137                .map_err(|_| PolarError::Malformed(format!("bad wind angle in row: {line}")))?;
138            let speeds: Vec<f64> = fields
139                .map(|field| {
140                    field.parse::<f64>().map_err(|_| PolarError::Malformed(format!("bad boat speed: {field}")))
141                })
142                .collect::<Result<_, _>>()?;
143            twa_deg.push(angle_deg);
144            boat_speed_kn.push(speeds);
145        }
146
147        Self::new(tws_kn, twa_deg, boat_speed_kn)
148    }
149
150    /// The boat's expected speed at `twa_deg`/`tws_kn`, bilinearly
151    /// interpolated between the table's own grid points.
152    ///
153    /// `twa_deg` is folded to `0..=180` first -- see this type's own doc
154    /// on why the table itself only ever states one side. A wind speed or
155    /// angle outside the table's own range is clamped to its nearest edge
156    /// rather than extrapolated: a polar has no honest opinion about a
157    /// wind speed nobody sailed it in.
158    #[must_use]
159    pub fn boat_speed_kn(&self, twa_deg: f64, tws_kn: f64) -> f64 {
160        let twa_deg = fold_wind_angle_deg(twa_deg);
161
162        let (twa_lo, twa_hi, twa_t) = bracket(&self.twa_deg, twa_deg);
163        let (tws_lo, tws_hi, tws_t) = bracket(&self.tws_kn, tws_kn);
164
165        let at = |twa_index: usize, tws_index: usize| self.boat_speed_kn[twa_index][tws_index];
166
167        // Bilinear: interpolate along wind speed at each of the two
168        // bracketing angles, then interpolate that pair along angle.
169        let low_angle = lerp(at(twa_lo, tws_lo), at(twa_lo, tws_hi), tws_t);
170        let high_angle = lerp(at(twa_hi, tws_lo), at(twa_hi, tws_hi), tws_t);
171        lerp(low_angle, high_angle, twa_t)
172    }
173
174    /// Velocity made good directly to windward at `twa_deg`/`tws_kn`,
175    /// using this table's own predicted boat speed --
176    /// [`sailing::vmg_to_wind_kn`] fed from [`Polar::boat_speed_kn`]
177    /// rather than a measured one.
178    #[must_use]
179    pub fn vmg_to_wind_kn(&self, twa_deg: f64, tws_kn: f64) -> f64 {
180        sailing::vmg_to_wind_kn(self.boat_speed_kn(twa_deg, tws_kn), twa_deg)
181    }
182
183    /// The true wind angle that makes the most progress to windward at
184    /// `tws_kn`, searched across the upwind half of the table (`0..90`)
185    /// -- the "actual" beat angle real laylines can offer once a polar
186    /// exists, in place of a mariner-stated constant.
187    ///
188    /// Scanned in fine steps rather than solved analytically: a polar's
189    /// own boat-speed curve has no closed form, and VMG-to-wind is
190    /// unimodal across a beat for every real polar this was checked
191    /// against, so a dense scan finds the same peak an analytic solver
192    /// would, without needing one.
193    #[must_use]
194    pub fn best_upwind_angle_deg(&self, tws_kn: f64) -> f64 {
195        best_angle_in_range(0.0, 90.0, |twa_deg| self.vmg_to_wind_kn(twa_deg, tws_kn))
196    }
197
198    /// The true wind angle that makes the most progress downwind at
199    /// `tws_kn`, searched across `90..180` -- the downwind counterpart to
200    /// [`Polar::best_upwind_angle_deg`], for a boat whose polar makes
201    /// sailing angles downwind faster than a dead run.
202    #[must_use]
203    pub fn best_downwind_angle_deg(&self, tws_kn: f64) -> f64 {
204        // Downwind VMG is negative by vmg_to_wind_kn's own convention
205        // (see that function's own doc); "best" downwind means most
206        // negative, i.e. minimised rather than maximised.
207        best_angle_in_range(90.0, 180.0, |twa_deg| -self.vmg_to_wind_kn(twa_deg, tws_kn))
208    }
209
210    /// The wind speeds this table is defined at, knots, ascending.
211    ///
212    /// For a caller that wants to walk the table's own grid -- writing it
213    /// back out, whether as [`Polar::to_text`] or in another shape
214    /// entirely, such as Signal K's own `performance.polars`.
215    #[must_use]
216    pub fn tws_kn(&self) -> &[f64] {
217        &self.tws_kn
218    }
219
220    /// The wind angles this table is defined at, degrees `0..=180`,
221    /// ascending. See [`Polar::tws_kn`] for why this is exposed.
222    #[must_use]
223    pub fn twa_deg(&self) -> &[f64] {
224        &self.twa_deg
225    }
226
227    /// Writes this table back out in the same tab-separated format
228    /// [`Polar::parse`] reads. `Polar::parse(&polar.to_text())` round
229    /// trips, to the precision written -- the format an unrelated
230    /// mariner's own sailing software can most likely already open.
231    #[must_use]
232    pub fn to_text(&self) -> String {
233        let mut lines = Vec::with_capacity(self.twa_deg.len() + 1);
234
235        let header: Vec<String> =
236            std::iter::once("twa/tws".to_owned()).chain(self.tws_kn.iter().map(|tws| format!("{tws:.2}"))).collect();
237        lines.push(header.join("\t"));
238
239        for (row_index, twa_deg) in self.twa_deg.iter().enumerate() {
240            let mut fields = vec![format!("{twa_deg:.1}")];
241            fields.extend(self.boat_speed_kn[row_index].iter().map(|speed| format!("{speed:.2}")));
242            lines.push(fields.join("\t"));
243        }
244
245        lines.join("\n")
246    }
247}
248
249/// Folds any wind angle to the `0..=180` a polar table is stored over --
250/// a hull sails identically on either tack, so `-45` and `225` both mean
251/// the same 45 degrees off the bow that `45` does.
252fn fold_wind_angle_deg(twa_deg: f64) -> f64 {
253    let wrapped = angle::norm_360(twa_deg);
254    if wrapped > 180.0 { 360.0 - wrapped } else { wrapped }
255}
256
257/// The two indices in `axis` bracketing `value`, and where between them
258/// it falls, `0.0..=1.0`. Clamped at either end rather than extrapolated
259/// -- see [`Polar::boat_speed_kn`]'s own doc.
260fn bracket(axis: &[f64], value: f64) -> (usize, usize, f64) {
261    if value <= axis[0] {
262        return (0, 0, 0.0);
263    }
264    let last = axis.len() - 1;
265    if value >= axis[last] {
266        return (last, last, 0.0);
267    }
268    let hi = axis.iter().position(|&x| x > value).unwrap_or(last);
269    let lo = hi - 1;
270    let t = (value - axis[lo]) / (axis[hi] - axis[lo]);
271    (lo, hi, t)
272}
273
274fn lerp(a: f64, b: f64, t: f64) -> f64 {
275    a + (b - a) * t
276}
277
278/// The angle in `[lo, hi]` (inclusive) that maximises `score`, scanned at
279/// a fine, fixed step -- see [`Polar::best_upwind_angle_deg`]'s own doc
280/// for why a scan rather than a solver.
281fn best_angle_in_range(lo: f64, hi: f64, score: impl Fn(f64) -> f64) -> f64 {
282    const STEP_DEG: f64 = 0.1;
283    let steps = ((hi - lo) / STEP_DEG).round() as usize;
284    (0..=steps)
285        .map(|i| lo + f64::from(u32::try_from(i).unwrap_or(u32::MAX)) * STEP_DEG)
286        .max_by(|&a, &b| score(a).partial_cmp(&score(b)).expect("polar-derived VMG is never NaN"))
287        .unwrap_or(lo)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    /// A real-shaped cruiser-racer polar, the kind a velocity prediction
295    /// program hands back -- zero speed head to wind, rising through the
296    /// beat, peaking on a reach, tailing off downwind.
297    const SAMPLE: &str = "\
298twa/tws\t6\t8\t10\t12\t14\t16\t20
2990\t0\t0\t0\t0\t0\t0\t0
30052\t5.24\t6.05\t6.41\t6.66\t6.77\t6.81\t6.87
30160\t5.55\t6.28\t6.6\t6.83\t6.95\t7.03\t7.06
30275\t5.79\t6.48\t6.85\t7.12\t7.28\t7.34\t7.36
30390\t5.79\t6.55\t6.98\t7.32\t7.52\t7.6\t7.63
304110\t5.59\t6.51\t7.11\t7.55\t7.83\t7.98\t8.09
305120\t5.28\t6.19\t6.85\t7.42\t7.83\t8.11\t8.34
306135\t4.65\t5.55\t6.19\t6.79\t7.29\t7.67\t8.19
307150\t4.03\t4.81\t5.4\t5.94\t6.42\t6.87\t7.62
308170\t3.55\t4.24\t4.76\t5.24\t5.66\t6.06\t6.72
309180\t3.45\t4.12\t4.62\t5.09\t5.5\t5.89\t6.53";
310
311    fn close(a: f64, b: f64, tol: f64) -> bool {
312        (a - b).abs() < tol
313    }
314
315    #[test]
316    fn the_sample_table_parses_with_its_own_shape() {
317        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
318        assert_eq!(polar.tws_kn.len(), 7);
319        assert_eq!(polar.twa_deg.len(), 11);
320        assert_eq!(polar.boat_speed_kn.len(), 11);
321        assert!(polar.boat_speed_kn.iter().all(|row| row.len() == 7));
322    }
323
324    #[test]
325    // A boat speed in the sample table happens to read like an
326    // approximation of tau; it is a knots figure from the fixture, not a
327    // mistyped constant.
328    #[allow(clippy::approx_constant)]
329    fn a_grid_point_is_read_back_exactly() {
330        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
331        assert!(close(polar.boat_speed_kn(90.0, 12.0), 7.32, 1e-9));
332        assert!(close(polar.boat_speed_kn(60.0, 8.0), 6.28, 1e-9));
333    }
334
335    #[test]
336    fn boat_speed_interpolates_between_two_wind_speeds() {
337        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
338        // Exactly halfway between the 10 and 12 kn columns at TWA 90:
339        // 6.98 and 7.32.
340        let halfway = polar.boat_speed_kn(90.0, 11.0);
341        assert!(close(halfway, (6.98 + 7.32) / 2.0, 1e-9), "{halfway}");
342    }
343
344    #[test]
345    fn boat_speed_interpolates_between_two_wind_angles() {
346        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
347        // Exactly halfway between TWA 75 and TWA 90 at 12 kn: 7.12 and
348        // 7.32.
349        let halfway = polar.boat_speed_kn(82.5, 12.0);
350        assert!(close(halfway, (7.12 + 7.32) / 2.0, 1e-9), "{halfway}");
351    }
352
353    #[test]
354    fn a_wind_speed_below_the_table_is_clamped_not_extrapolated() {
355        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
356        assert!(close(polar.boat_speed_kn(90.0, 0.0), polar.boat_speed_kn(90.0, 6.0), 1e-9));
357    }
358
359    #[test]
360    fn a_wind_speed_above_the_table_is_clamped_not_extrapolated() {
361        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
362        assert!(close(polar.boat_speed_kn(90.0, 100.0), polar.boat_speed_kn(90.0, 20.0), 1e-9));
363    }
364
365    #[test]
366    fn an_angle_past_180_mirrors_onto_the_stored_side() {
367        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
368        assert!(close(polar.boat_speed_kn(225.0, 12.0), polar.boat_speed_kn(135.0, 12.0), 1e-9));
369    }
370
371    #[test]
372    fn a_negative_angle_mirrors_the_same_way() {
373        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
374        assert!(close(polar.boat_speed_kn(-52.0, 12.0), polar.boat_speed_kn(52.0, 12.0), 1e-9));
375    }
376
377    #[test]
378    fn vmg_to_wind_matches_the_sailing_module_fed_the_same_speed() {
379        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
380        let speed = polar.boat_speed_kn(60.0, 12.0);
381        assert!(close(polar.vmg_to_wind_kn(60.0, 12.0), sailing::vmg_to_wind_kn(speed, 60.0), 1e-9));
382    }
383
384    #[test]
385    fn the_best_upwind_angle_actually_beats_its_neighbours() {
386        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
387        let best = polar.best_upwind_angle_deg(12.0);
388        assert!((0.0..90.0).contains(&best), "{best}");
389
390        let best_vmg = polar.vmg_to_wind_kn(best, 12.0);
391        assert!(best_vmg >= polar.vmg_to_wind_kn(best - 1.0, 12.0));
392        assert!(best_vmg >= polar.vmg_to_wind_kn(best + 1.0, 12.0));
393        // And it genuinely beats a coarse sweep across the whole beat,
394        // not just its own immediate neighbours.
395        for degrees in (0..90).step_by(5) {
396            assert!(best_vmg >= polar.vmg_to_wind_kn(f64::from(degrees), 12.0) - 1e-9);
397        }
398    }
399
400    #[test]
401    fn the_best_downwind_angle_actually_beats_its_neighbours() {
402        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
403        let best = polar.best_downwind_angle_deg(12.0);
404        assert!((90.0..180.0).contains(&best), "{best}");
405
406        let best_vmg = polar.vmg_to_wind_kn(best, 12.0);
407        for degrees in (90..180).step_by(5) {
408            assert!(best_vmg <= polar.vmg_to_wind_kn(f64::from(degrees), 12.0) + 1e-9);
409        }
410    }
411
412    #[test]
413    fn a_stronger_breeze_points_higher_on_this_sample() {
414        // The everyday observation this sample's own shape happens to
415        // show: more breeze lets the boat point closer to the wind for
416        // the same or better VMG. Not a universal law of polars -- just a
417        // property of this one, worth a regression test since laylines
418        // will lean on it.
419        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
420        let light = polar.best_upwind_angle_deg(6.0);
421        let strong = polar.best_upwind_angle_deg(16.0);
422        assert!(strong <= light, "{strong} vs {light}");
423    }
424
425    #[test]
426    fn the_axes_are_exposed_for_a_caller_writing_the_table_out() {
427        let polar = Polar::parse(SAMPLE).expect("a well-formed table");
428        assert_eq!(polar.tws_kn(), &[6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 20.0]);
429        assert_eq!(polar.twa_deg().first(), Some(&0.0));
430        assert_eq!(polar.twa_deg().last(), Some(&180.0));
431    }
432
433    #[test]
434    fn writing_a_table_out_and_reading_it_back_round_trips() {
435        let original = Polar::parse(SAMPLE).expect("a well-formed table");
436        let text = original.to_text();
437        let read_back = Polar::parse(&text).expect("what this just wrote is itself a well-formed table");
438
439        assert_eq!(read_back.tws_kn(), original.tws_kn());
440        assert_eq!(read_back.twa_deg(), original.twa_deg());
441        for &twa_deg in original.twa_deg() {
442            for &tws_kn in original.tws_kn() {
443                // to_text formats to two decimal places, so the round
444                // trip is exact to that precision, not bit-exact.
445                assert!(
446                    close(read_back.boat_speed_kn(twa_deg, tws_kn), original.boat_speed_kn(twa_deg, tws_kn), 1e-2),
447                    "twa {twa_deg} tws {tws_kn}"
448                );
449            }
450        }
451    }
452
453    #[test]
454    fn garbage_text_does_not_parse() {
455        assert!(matches!(Polar::parse("not a polar table"), Err(PolarError::Malformed(_))));
456        assert!(matches!(Polar::parse(""), Err(PolarError::Malformed(_))));
457    }
458
459    #[test]
460    fn a_row_with_the_wrong_number_of_speeds_is_rejected() {
461        let bad = "twa/tws\t6\t8\n60\t5.5";
462        assert!(matches!(Polar::parse(bad), Err(PolarError::InvalidShape(_))));
463    }
464
465    #[test]
466    fn too_few_wind_speeds_is_rejected() {
467        assert!(matches!(
468            Polar::new(vec![10.0], vec![0.0, 90.0], vec![vec![0.0], vec![5.0]]),
469            Err(PolarError::InvalidShape(_))
470        ));
471    }
472
473    #[test]
474    fn too_few_wind_angles_is_rejected() {
475        assert!(matches!(
476            Polar::new(vec![10.0, 20.0], vec![90.0], vec![vec![5.0, 6.0]]),
477            Err(PolarError::InvalidShape(_))
478        ));
479    }
480
481    #[test]
482    fn a_non_ascending_axis_is_rejected() {
483        assert!(matches!(
484            Polar::new(vec![10.0, 5.0], vec![0.0, 90.0], vec![vec![0.0, 0.0], vec![5.0, 6.0]]),
485            Err(PolarError::InvalidShape(_))
486        ));
487    }
488
489    #[test]
490    fn an_angle_outside_0_to_180_is_rejected() {
491        assert!(matches!(
492            Polar::new(vec![10.0, 20.0], vec![-10.0, 90.0], vec![vec![0.0, 0.0], vec![5.0, 6.0]]),
493            Err(PolarError::InvalidShape(_))
494        ));
495        assert!(matches!(
496            Polar::new(vec![10.0, 20.0], vec![90.0, 190.0], vec![vec![5.0, 6.0], vec![5.0, 6.0]]),
497            Err(PolarError::InvalidShape(_))
498        ));
499    }
500}