1use crate::{angle, sailing};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum PolarError {
22 Malformed(String),
25 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#[derive(Debug, Clone, PartialEq)]
47pub struct Polar {
48 tws_kn: Vec<f64>,
51 twa_deg: Vec<f64>,
54 boat_speed_kn: Vec<Vec<f64>>,
58}
59
60impl Polar {
61 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 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 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 #[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 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 #[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 #[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 #[must_use]
203 pub fn best_downwind_angle_deg(&self, tws_kn: f64) -> f64 {
204 best_angle_in_range(90.0, 180.0, |twa_deg| -self.vmg_to_wind_kn(twa_deg, tws_kn))
208 }
209
210 #[must_use]
216 pub fn tws_kn(&self) -> &[f64] {
217 &self.tws_kn
218 }
219
220 #[must_use]
223 pub fn twa_deg(&self) -> &[f64] {
224 &self.twa_deg
225 }
226
227 #[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
249fn 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
257fn 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
278fn 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 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 #[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 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 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 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 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 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}