1use nav_math::polar::{Polar, PolarError};
20use serde_json::{Value, json};
21
22use crate::delta::KNOTS_PER_METRE_PER_SECOND;
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum PolarResourceError {
27 Malformed(String),
31 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#[derive(Debug, Clone, PartialEq)]
56pub struct PolarResource {
57 pub id: String,
59 pub name: String,
63 pub description: Option<String>,
67 pub source_label: Option<String>,
72 pub polar: Polar,
74}
75
76pub 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 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
169fn 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#[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 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}