Skip to main content

navcore_signalk/
polar.rs

1//! A boat's own performance polar, in Signal K's own shape.
2//!
3//! `performance.polars` (a map of UUID to polar), `performance.activePolar`
4//! (which UUID is in effect) and `performance.activePolarData` (that
5//! one's own data) are a real, schema-defined convention -- see
6//! `@signalk/signalk-schema`'s own `performance.json` group. Reading and
7//! writing that shape is what this module is for; deciding when to fetch
8//! or publish it, and which UUID is active, is a caller's concern, the
9//! same distance [`crate::delta`] keeps from deciding when to open a
10//! connection.
11//!
12//! # Why this is stored on the server, not locally
13//!
14//! A polar stored only on one device is the same kind of state
15//! `routes`/`waypoints` already avoid caching locally, for the same
16//! reason: every device on the boat should see the same polar,
17//! regardless of which one built or edited it.
18
19use nav_math::polar::{Polar, PolarError};
20use serde_json::{Value, json};
21
22use crate::delta::KNOTS_PER_METRE_PER_SECOND;
23
24/// Something wrong with a polar resource on the wire.
25#[derive(Debug, Clone, PartialEq)]
26pub enum PolarResourceError {
27    /// The JSON did not have the shape `performance.polars`'s own schema
28    /// requires -- a missing required field, a `windData` entry with no
29    /// `angleData`, or the like.
30    Malformed(String),
31    /// The shape was right, but the table it described was not --
32    /// see [`PolarError`].
33    Table(PolarError),
34}
35
36impl std::fmt::Display for PolarResourceError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Malformed(reason) => write!(f, "{reason}"),
40            Self::Table(error) => write!(f, "{error}"),
41        }
42    }
43}
44
45impl std::error::Error for PolarResourceError {}
46
47impl From<PolarError> for PolarResourceError {
48    fn from(error: PolarError) -> Self {
49        Self::Table(error)
50    }
51}
52
53/// A polar, with the identifying metadata Signal K's own schema carries
54/// alongside the table itself -- one entry of `performance.polars`.
55#[derive(Debug, Clone, PartialEq)]
56pub struct PolarResource {
57    /// The UUID this polar is keyed by under `performance.polars`.
58    pub id: String,
59    /// A boat generally has more than one of these -- full sail, reefed,
60    /// with a spinnaker -- and this is the only field the schema actually
61    /// requires for telling them apart.
62    pub name: String,
63    /// Free text -- a boat model, sail configuration, or conditions this
64    /// was built or measured under. The schema leaves this to the
65    /// mariner, and so does this type.
66    pub description: Option<String>,
67    /// The `source.label` Signal K's own schema asks for when a source is
68    /// stated at all. Everything else that object can carry is not this
69    /// crate's concern, the same distance [`crate::delta::Reading::source`]
70    /// already keeps.
71    pub source_label: Option<String>,
72    /// The table itself.
73    pub polar: Polar,
74}
75
76/// Reads one polar resource -- the object at one UUID under
77/// `performance.polars`, or the whole of `performance.activePolarData`.
78///
79/// # Errors
80///
81/// [`PolarResourceError::Malformed`] when a required field is missing or
82/// not the type the schema states, including a `windData` whose entries
83/// do not all share the same set of wind angles -- this reader does not
84/// guess how to merge two different angle grids into one table.
85/// [`PolarResourceError::Table`] when the numbers read are otherwise not
86/// a valid table -- see [`Polar::new`].
87pub fn parse(value: &Value) -> Result<PolarResource, PolarResourceError> {
88    let id = field_str(value, "id")?.to_owned();
89    let name = field_str(value, "name")?.to_owned();
90    let description = value.get("description").and_then(Value::as_str).map(str::to_owned);
91    let source_label =
92        value.get("source").and_then(|source| source.get("label")).and_then(Value::as_str).map(str::to_owned);
93
94    let wind_data = value
95        .get("windData")
96        .and_then(Value::as_array)
97        .ok_or_else(|| PolarResourceError::Malformed("polar has no windData array".to_owned()))?;
98    if wind_data.is_empty() {
99        return Err(PolarResourceError::Malformed("windData is empty".to_owned()));
100    }
101
102    let mut columns: Vec<(f64, Vec<f64>)> = Vec::with_capacity(wind_data.len());
103    let mut twa_deg: Option<Vec<f64>> = None;
104
105    for entry in wind_data {
106        let tws_kn = entry
107            .get("trueWindSpeed")
108            .and_then(Value::as_f64)
109            .ok_or_else(|| PolarResourceError::Malformed("windData entry has no trueWindSpeed".to_owned()))?
110            * KNOTS_PER_METRE_PER_SECOND;
111
112        let angle_data = entry
113            .get("angleData")
114            .and_then(Value::as_array)
115            .ok_or_else(|| PolarResourceError::Malformed("windData entry has no angleData".to_owned()))?;
116
117        let mut angles = Vec::with_capacity(angle_data.len());
118        let mut speeds = Vec::with_capacity(angle_data.len());
119        for point in angle_data {
120            let pair = point
121                .as_array()
122                .ok_or_else(|| PolarResourceError::Malformed("angleData entry is not an array".to_owned()))?;
123            let angle_rad = pair
124                .first()
125                .and_then(Value::as_f64)
126                .ok_or_else(|| PolarResourceError::Malformed("angleData entry has no angle".to_owned()))?;
127            let speed_kn = pair
128                .get(1)
129                .and_then(Value::as_f64)
130                .ok_or_else(|| PolarResourceError::Malformed("angleData entry has no speed".to_owned()))?
131                * KNOTS_PER_METRE_PER_SECOND;
132            angles.push(angle_rad.to_degrees());
133            speeds.push(speed_kn);
134        }
135
136        match &twa_deg {
137            None => twa_deg = Some(angles),
138            Some(existing) if existing == &angles => {}
139            Some(_) => {
140                return Err(PolarResourceError::Malformed(
141                    "windData entries do not all share the same wind angles".to_owned(),
142                ));
143            }
144        }
145
146        columns.push((tws_kn, speeds));
147    }
148
149    // Signal K states no ordering on windData; Polar::new requires
150    // strictly ascending wind speeds, so this sorts the same readings
151    // rather than asking the server to have sent them in a particular
152    // order.
153    columns.sort_by(|(a, _), (b, _)| a.total_cmp(b));
154
155    let twa_deg = twa_deg.expect("windData was checked non-empty above");
156    let tws_kn: Vec<f64> = columns.iter().map(|(tws, _)| *tws).collect();
157
158    let mut boat_speed_kn = vec![Vec::with_capacity(tws_kn.len()); twa_deg.len()];
159    for (_, speeds) in &columns {
160        for (row, &speed) in boat_speed_kn.iter_mut().zip(speeds) {
161            row.push(speed);
162        }
163    }
164
165    let polar = Polar::new(tws_kn, twa_deg, boat_speed_kn)?;
166    Ok(PolarResource { id, name, description, source_label, polar })
167}
168
169/// The field's string value, or a [`PolarResourceError::Malformed`]
170/// naming which required field was missing.
171fn field_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, PolarResourceError> {
172    value
173        .get(field)
174        .and_then(Value::as_str)
175        .ok_or_else(|| PolarResourceError::Malformed(format!("polar has no {field}")))
176}
177
178/// Writes one polar resource back out in Signal K's own shape, suitable
179/// for a `PUT` to `performance.polars.<id>` or `performance.activePolarData`.
180///
181/// `optimalBeats`/`optimalGybes` are filled in from
182/// [`Polar::best_upwind_angle_deg`]/[`Polar::best_downwind_angle_deg`] --
183/// computed here rather than carried on [`PolarResource`] itself, since
184/// they are a function of the table, never a second, independently
185/// stated fact that could drift from it.
186#[must_use]
187pub fn to_json(resource: &PolarResource) -> Value {
188    let polar = &resource.polar;
189
190    let wind_data: Vec<Value> = polar
191        .tws_kn()
192        .iter()
193        .map(|&tws_kn| {
194            let angle_data: Vec<Value> = polar
195                .twa_deg()
196                .iter()
197                .map(|&twa_deg| {
198                    json!([
199                        twa_deg.to_radians(),
200                        polar.boat_speed_kn(twa_deg, tws_kn) / KNOTS_PER_METRE_PER_SECOND,
201                        polar.vmg_to_wind_kn(twa_deg, tws_kn) / KNOTS_PER_METRE_PER_SECOND,
202                    ])
203                })
204                .collect();
205
206            let optimal = |angle_deg: f64| {
207                json!([[angle_deg.to_radians(), polar.boat_speed_kn(angle_deg, tws_kn) / KNOTS_PER_METRE_PER_SECOND]])
208            };
209
210            json!({
211                "trueWindSpeed": tws_kn / KNOTS_PER_METRE_PER_SECOND,
212                "angleData": angle_data,
213                "optimalBeats": optimal(polar.best_upwind_angle_deg(tws_kn)),
214                "optimalGybes": optimal(polar.best_downwind_angle_deg(tws_kn)),
215            })
216        })
217        .collect();
218
219    json!({
220        "id": resource.id,
221        "name": resource.name,
222        "description": resource.description,
223        "source": resource.source_label.as_ref().map(|label| json!({ "label": label })),
224        "windData": wind_data,
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    const SAMPLE_JSON: &str = r#"{
233        "id": "d3f1c2a0-1111-4a2b-9c3d-000000000001",
234        "name": "Full main and genoa",
235        "description": "Sunbeam 36, measured",
236        "source": {"label": "polar-builder"},
237        "windData": [
238            {
239                "trueWindSpeed": 4.11,
240                "angleData": [[0.9076, 3.3], [1.5708, 3.7]]
241            },
242            {
243                "trueWindSpeed": 6.17,
244                "angleData": [[0.9076, 4.1], [1.5708, 4.6]]
245            }
246        ]
247    }"#;
248
249    fn close(a: f64, b: f64, tol: f64) -> bool {
250        (a - b).abs() < tol
251    }
252
253    #[test]
254    fn a_real_shaped_resource_parses() {
255        let resource = parse(&serde_json::from_str(SAMPLE_JSON).unwrap()).expect("well-formed");
256        assert_eq!(resource.id, "d3f1c2a0-1111-4a2b-9c3d-000000000001");
257        assert_eq!(resource.name, "Full main and genoa");
258        assert_eq!(resource.description.as_deref(), Some("Sunbeam 36, measured"));
259        assert_eq!(resource.source_label.as_deref(), Some("polar-builder"));
260    }
261
262    #[test]
263    fn wind_speed_and_angle_convert_into_this_workspaces_units() {
264        let resource = parse(&serde_json::from_str(SAMPLE_JSON).unwrap()).expect("well-formed");
265        // 4.11 m/s is roughly 8 kn; 0.9076 rad is 52 degrees -- the same
266        // sample figures the nav-math polar fixture uses, converted the
267        // other way and rounded to two decimals for the fixture, hence
268        // the wider tolerance on the speed.
269        assert!(close(resource.polar.tws_kn()[0], 8.0, 5e-2), "{:?}", resource.polar.tws_kn());
270        assert!(close(resource.polar.twa_deg()[0], 52.0, 1e-1), "{:?}", resource.polar.twa_deg());
271    }
272
273    #[test]
274    fn wind_data_out_of_order_is_sorted_not_rejected() {
275        let reordered = r#"{
276            "id": "x", "name": "x",
277            "windData": [
278                {"trueWindSpeed": 6.17, "angleData": [[0.9, 4.6], [1.5, 5.0]]},
279                {"trueWindSpeed": 4.11, "angleData": [[0.9, 3.7], [1.5, 4.0]]}
280            ]
281        }"#;
282        let resource = parse(&serde_json::from_str(reordered).unwrap()).expect("sorted, not rejected");
283        assert!(resource.polar.tws_kn()[0] < resource.polar.tws_kn()[1]);
284    }
285
286    #[test]
287    fn mismatched_wind_angles_across_entries_are_rejected() {
288        let mismatched = r#"{
289            "id": "x", "name": "x",
290            "windData": [
291                {"trueWindSpeed": 4.11, "angleData": [[0.9, 3.3], [1.5, 3.7]]},
292                {"trueWindSpeed": 6.17, "angleData": [[0.9, 4.1]]}
293            ]
294        }"#;
295        assert!(matches!(
296            parse(&serde_json::from_str(mismatched).unwrap()),
297            Err(PolarResourceError::Malformed(_))
298        ));
299    }
300
301    #[test]
302    fn a_missing_required_field_is_rejected() {
303        assert!(matches!(
304            parse(&serde_json::from_str(r#"{"name": "x", "windData": []}"#).unwrap()),
305            Err(PolarResourceError::Malformed(_))
306        ));
307    }
308
309    #[test]
310    fn writing_a_resource_out_and_reading_it_back_round_trips() {
311        let original = parse(&serde_json::from_str(SAMPLE_JSON).unwrap()).expect("well-formed");
312        let round_tripped = parse(&to_json(&original)).expect("what this just wrote is itself well-formed");
313
314        assert_eq!(round_tripped.id, original.id);
315        assert_eq!(round_tripped.name, original.name);
316        assert_eq!(round_tripped.description, original.description);
317        assert_eq!(round_tripped.source_label, original.source_label);
318        for &twa_deg in original.polar.twa_deg() {
319            for &tws_kn in original.polar.tws_kn() {
320                assert!(close(
321                    round_tripped.polar.boat_speed_kn(twa_deg, tws_kn),
322                    original.polar.boat_speed_kn(twa_deg, tws_kn),
323                    1e-6
324                ));
325            }
326        }
327    }
328
329    #[test]
330    fn a_written_resource_carries_optimal_beats_and_gybes() {
331        let original = parse(&serde_json::from_str(SAMPLE_JSON).unwrap()).expect("well-formed");
332        let written = to_json(&original);
333        let first_column = &written["windData"][0];
334        assert!(first_column["optimalBeats"].is_array());
335        assert!(first_column["optimalGybes"].is_array());
336    }
337}