Skip to main content

kinavis_kernel/
environment.rs

1//! Environment ports and the resolved sample.
2//!
3//! Magnetic model, current atlas, wind forecast and deviation table all answer
4//! a question for a place and time. The choice of model (WMM or IGRF, tidal
5//! atlas or GRIB, swung table or Smith coefficients) is not the kernel's; the
6//! kernel defines the ports ([`MagneticModel`], [`CurrentModel`],
7//! [`WindModel`], [`TideModel`], [`LeewayModel`], [`CompassModel`]) and the
8//! answer types ([`MagneticField`], [`Current`], [`Wind`], height of tide as a
9//! [`Distance`] above chart datum).
10//!
11//! An [`EnvironmentSample`] holds the answers resolved for one point and
12//! instant, for calculations that must not know the source. No port is
13//! implemented here; implementations live in `kinavis` or in satellite crates
14//! with the model data.
15//!
16//! The compass model sits here because it is used together with the magnetic
17//! one: variation from the field, deviation from the ship's magnetism, together
18//! converting compass to true.
19
20use core::fmt;
21
22use crate::angle::{CompassCourse, Deviation, TrueCourse, Variation};
23use crate::error::{ensure_range, KernelError, Result};
24use crate::geodesy::GeodeticPoint;
25use crate::math;
26use crate::position::Position;
27use crate::time::{Instant, Utc};
28use crate::units::{Angle, Distance, Speed};
29
30/// Maximum magnitude of a field component, nT.
31///
32/// Total intensity is below 70 000 nT everywhere at the surface and decreases
33/// with height; larger values indicate a unit error (gauss, µT) and are
34/// rejected.
35pub const MAX_FIELD_NANOTESLA: f64 = 100_000.0;
36
37// ---------------------------------------------------------------------------
38// The value types
39// ---------------------------------------------------------------------------
40
41/// Earth's magnetic field at a point: north, east, down components in nT.
42///
43/// Derived quantities — [`declination`], [`inclination`], horizontal and total
44/// intensity — are computed from the components, not stored.
45///
46/// [`declination`]: Self::declination
47/// [`inclination`]: Self::inclination
48#[derive(Debug, Clone, Copy, PartialEq)]
49#[cfg_attr(
50    feature = "serde",
51    derive(serde::Serialize, serde::Deserialize),
52    serde(try_from = "MagneticFieldComponents", into = "MagneticFieldComponents")
53)]
54pub struct MagneticField {
55    north: f64,
56    east: f64,
57    down: f64,
58}
59
60impl MagneticField {
61    /// Field from north, east, down components in nT.
62    ///
63    /// # Errors
64    ///
65    /// [`KernelError::NotFinite`] for a non-finite component;
66    /// [`KernelError::OutOfRange`] beyond ±[`MAX_FIELD_NANOTESLA`].
67    pub fn from_ned_nanotesla(north: f64, east: f64, down: f64) -> Result<Self> {
68        ensure_range(
69            "field north component",
70            north,
71            -MAX_FIELD_NANOTESLA,
72            MAX_FIELD_NANOTESLA,
73        )?;
74        ensure_range(
75            "field east component",
76            east,
77            -MAX_FIELD_NANOTESLA,
78            MAX_FIELD_NANOTESLA,
79        )?;
80        ensure_range(
81            "field down component",
82            down,
83            -MAX_FIELD_NANOTESLA,
84            MAX_FIELD_NANOTESLA,
85        )?;
86        Ok(Self { north, east, down })
87    }
88
89    /// North component, nT.
90    #[must_use]
91    pub const fn north_nanotesla(&self) -> f64 {
92        self.north
93    }
94
95    /// East component, nT.
96    #[must_use]
97    pub const fn east_nanotesla(&self) -> f64 {
98        self.east
99    }
100
101    /// Down component, nT; positive in the northern magnetic hemisphere.
102    #[must_use]
103    pub const fn down_nanotesla(&self) -> f64 {
104        self.down
105    }
106
107    /// Horizontal intensity `H`, nT: the component that aligns a compass.
108    ///
109    /// Small near the magnetic poles, where a compass becomes sluggish and then
110    /// unusable.
111    #[must_use]
112    pub fn horizontal_intensity_nanotesla(&self) -> f64 {
113        math::hypot(self.north, self.east)
114    }
115
116    /// Total intensity `F`, nT.
117    #[must_use]
118    pub fn total_intensity_nanotesla(&self) -> f64 {
119        math::hypot(self.horizontal_intensity_nanotesla(), self.down)
120    }
121
122    /// Declination `D` (magnetic variation), east positive.
123    ///
124    /// Undefined where `H` is zero and reported as zero; check
125    /// [`horizontal_intensity_nanotesla`](Self::horizontal_intensity_nanotesla)
126    /// before relying on a compass there.
127    #[must_use]
128    pub fn declination(&self) -> Variation {
129        let degrees = math::to_degrees(math::atan2(self.east, self.north));
130        // `atan2` is within ±180°, the variation range.
131        Variation::new(degrees).unwrap_or(Variation::ZERO)
132    }
133
134    /// Inclination `I` (dip): angle below the horizontal, positive down.
135    #[must_use]
136    pub fn inclination(&self) -> Angle {
137        let radians = math::atan2(self.down, self.horizontal_intensity_nanotesla());
138        // `atan2` of finite arguments is finite.
139        Angle::from_radians(radians).unwrap_or(Angle::ZERO)
140    }
141}
142
143impl fmt::Display for MagneticField {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        write!(
146            f,
147            "D {}, I {:.1}°, H {:.0} nT, F {:.0} nT",
148            self.declination(),
149            self.inclination().degrees(),
150            self.horizontal_intensity_nanotesla(),
151            self.total_intensity_nanotesla()
152        )
153    }
154}
155
156/// Serialised form of [`MagneticField`]: named components, validated on
157/// deserialisation. Units are part of the key names.
158#[cfg(feature = "serde")]
159#[derive(serde::Serialize, serde::Deserialize)]
160// The unit suffix is part of the wire format.
161#[allow(clippy::struct_field_names)]
162struct MagneticFieldComponents {
163    north_nanotesla: f64,
164    east_nanotesla: f64,
165    down_nanotesla: f64,
166}
167
168#[cfg(feature = "serde")]
169impl TryFrom<MagneticFieldComponents> for MagneticField {
170    type Error = KernelError;
171
172    fn try_from(components: MagneticFieldComponents) -> Result<Self> {
173        Self::from_ned_nanotesla(
174            components.north_nanotesla,
175            components.east_nanotesla,
176            components.down_nanotesla,
177        )
178    }
179}
180
181#[cfg(feature = "serde")]
182impl From<MagneticField> for MagneticFieldComponents {
183    fn from(field: MagneticField) -> Self {
184        Self {
185            north_nanotesla: field.north,
186            east_nanotesla: field.east,
187            down_nanotesla: field.down,
188        }
189    }
190}
191
192/// Current: set and drift.
193#[derive(Debug, Clone, Copy, PartialEq)]
194#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
195pub struct Current {
196    /// Direction the current flows towards.
197    ///
198    /// Undefined when `drift` is zero; reported as `000°`.
199    pub set: TrueCourse,
200    /// Current speed.
201    pub drift: Speed,
202}
203
204impl Current {
205    /// Slack water.
206    pub const SLACK: Self = Self {
207        set: TrueCourse::NORTH,
208        drift: Speed::ZERO,
209    };
210}
211
212/// True wind: direction from and speed.
213///
214/// Named by the direction it blows *from*, speed never negative. Apparent wind
215/// is a different quantity.
216#[derive(Debug, Clone, Copy, PartialEq)]
217#[cfg_attr(
218    feature = "serde",
219    derive(serde::Serialize, serde::Deserialize),
220    serde(try_from = "WindComponents", into = "WindComponents")
221)]
222pub struct Wind {
223    from: TrueCourse,
224    speed: Speed,
225}
226
227impl Wind {
228    /// Calm.
229    pub const CALM: Self = Self {
230        from: TrueCourse::NORTH,
231        speed: Speed::ZERO,
232    };
233
234    /// Wind from `from` at `speed`.
235    ///
236    /// # Errors
237    ///
238    /// [`KernelError::OutOfRange`] for a negative speed.
239    pub fn new(from: TrueCourse, speed: Speed) -> Result<Self> {
240        if speed.is_negative() {
241            return Err(KernelError::OutOfRange {
242                parameter: "wind speed",
243                value: speed.knots(),
244                min: 0.0,
245                max: f64::INFINITY,
246            });
247        }
248        Ok(Self { from, speed })
249    }
250
251    /// Direction the wind blows from.
252    ///
253    /// Undefined at zero speed; reported as `000°`.
254    #[must_use]
255    pub const fn from(&self) -> TrueCourse {
256        self.from
257    }
258
259    /// Direction the wind blows towards: reciprocal of [`from`](Self::from).
260    #[must_use]
261    pub fn towards(&self) -> TrueCourse {
262        self.from.reciprocal()
263    }
264
265    /// Wind speed, non-negative.
266    #[must_use]
267    pub const fn speed(&self) -> Speed {
268        self.speed
269    }
270}
271
272impl fmt::Display for Wind {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(f, "{} from {}", self.speed, self.from)
275    }
276}
277
278/// Serialised form of [`Wind`], validated on deserialisation.
279#[cfg(feature = "serde")]
280#[derive(serde::Serialize, serde::Deserialize)]
281struct WindComponents {
282    from: TrueCourse,
283    speed: Speed,
284}
285
286#[cfg(feature = "serde")]
287impl TryFrom<WindComponents> for Wind {
288    type Error = KernelError;
289
290    fn try_from(components: WindComponents) -> Result<Self> {
291        Self::new(components.from, components.speed)
292    }
293}
294
295#[cfg(feature = "serde")]
296impl From<Wind> for WindComponents {
297    fn from(wind: Wind) -> Self {
298        Self {
299            from: wind.from,
300            speed: wind.speed,
301        }
302    }
303}
304
305/// Motion through the water, as input to a leeway model.
306///
307/// Heading and speed through the water, not over the ground: leeway acts
308/// relative to the water; current is added afterwards.
309#[derive(Debug, Clone, Copy, PartialEq)]
310#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
311pub struct VesselMotion {
312    heading: TrueCourse,
313    speed_through_water: Speed,
314}
315
316impl VesselMotion {
317    /// Vessel on `heading` at `speed_through_water`.
318    #[must_use]
319    pub const fn new(heading: TrueCourse, speed_through_water: Speed) -> Self {
320        Self {
321            heading,
322            speed_through_water,
323        }
324    }
325
326    /// True heading.
327    #[must_use]
328    pub const fn heading(&self) -> TrueCourse {
329        self.heading
330    }
331
332    /// Speed through the water; negative for sternway.
333    #[must_use]
334    pub const fn speed_through_water(&self) -> Speed {
335        self.speed_through_water
336    }
337}
338
339// ---------------------------------------------------------------------------
340// The ports
341// ---------------------------------------------------------------------------
342
343/// Earth's magnetic field: WMM, IGRF, compass rose, constant.
344///
345/// Outside its validity interval an implementation returns
346/// [`KernelError::OutsideValidity`] rather than extrapolating. The point
347/// includes height because the field decreases with it.
348pub trait MagneticModel {
349    /// Field at `at`, time `when`.
350    ///
351    /// # Errors
352    ///
353    /// [`KernelError::OutsideValidity`] outside the model's validity; any other
354    /// error the model data raises.
355    fn field_at(&self, at: GeodeticPoint, when: Instant<Utc>) -> Result<MagneticField>;
356}
357
358/// Ship's magnetism as compass deviation.
359///
360/// Swung table, Smith coefficients or calibrated fluxgate. Deviation is a
361/// function of the compass course (what is read on the card), not the magnetic
362/// course; the inverse is a solve performed by the algorithms.
363pub trait CompassModel {
364    /// Deviation on `course`.
365    ///
366    /// # Errors
367    ///
368    /// Any error the model raises: table too sparse, course not covered.
369    fn deviation(&self, course: CompassCourse) -> Result<Deviation>;
370}
371
372/// Current: constant, tidal, GRIB, ocean model.
373pub trait CurrentModel {
374    /// Current at `at`, time `when`.
375    ///
376    /// # Errors
377    ///
378    /// [`KernelError::OutsideValidity`] outside the model's coverage; any other
379    /// error the model data raises.
380    fn current_at(&self, at: Position, when: Instant<Utc>) -> Result<Current>;
381}
382
383/// True wind: forecast, observation, constant.
384pub trait WindModel {
385    /// Wind at `at`, time `when`.
386    ///
387    /// # Errors
388    ///
389    /// [`KernelError::OutsideValidity`] outside the model's coverage; any other
390    /// error the model data raises.
391    fn wind_at(&self, at: Position, when: Instant<Utc>) -> Result<Wind>;
392}
393
394/// Tide: tables, harmonic prediction, gauge.
395///
396/// Returns the height of water above chart datum, so depth = charted depth +
397/// height of tide.
398pub trait TideModel {
399    /// Height of tide at `at`, time `when`.
400    ///
401    /// # Errors
402    ///
403    /// [`KernelError::OutsideValidity`] outside the model's coverage; any other
404    /// error the model data raises.
405    fn height_of_tide(&self, at: Position, when: Instant<Utc>) -> Result<Distance>;
406}
407
408/// Leeway model for a hull.
409///
410/// Leeway depends on windage, draught and speed, hence a port rather than a
411/// formula. Returns the angle between heading and water track.
412pub trait LeewayModel {
413    /// Leeway for `motion` under `wind`, positive when set to starboard of the
414    /// heading.
415    ///
416    /// # Errors
417    ///
418    /// Any error the model raises, e.g. speed outside its fitted range.
419    fn leeway(&self, motion: VesselMotion, wind: Wind) -> Result<Angle>;
420}
421
422// ---------------------------------------------------------------------------
423// The sample
424// ---------------------------------------------------------------------------
425
426/// Environment resolved for one point and instant.
427///
428/// Values, not sources: built from whichever models are available and passed to
429/// calculations that need no port. Unresolved quantities are `None`, never
430/// zero.
431#[derive(Debug, Clone, Copy, PartialEq)]
432#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
433pub struct EnvironmentSample {
434    point: GeodeticPoint,
435    when: Instant<Utc>,
436    magnetic: Option<MagneticField>,
437    current: Option<Current>,
438    wind: Option<Wind>,
439    /// Optional in the serialised form, for samples stored without it.
440    #[cfg_attr(feature = "serde", serde(default))]
441    tide: Option<Distance>,
442}
443
444impl EnvironmentSample {
445    /// Empty sample for `point` at `when`.
446    #[must_use]
447    pub const fn at(point: GeodeticPoint, when: Instant<Utc>) -> Self {
448        Self {
449            point,
450            when,
451            magnetic: None,
452            current: None,
453            wind: None,
454            tide: None,
455        }
456    }
457
458    /// Sets the magnetic field.
459    #[must_use]
460    pub const fn with_magnetic(mut self, field: MagneticField) -> Self {
461        self.magnetic = Some(field);
462        self
463    }
464
465    /// Sets the current.
466    #[must_use]
467    pub const fn with_current(mut self, current: Current) -> Self {
468        self.current = Some(current);
469        self
470    }
471
472    /// Sets the wind.
473    #[must_use]
474    pub const fn with_wind(mut self, wind: Wind) -> Self {
475        self.wind = Some(wind);
476        self
477    }
478
479    /// Sets the height of tide above chart datum.
480    ///
481    /// Where there is no tide, set [`Distance::ZERO`]: an unset tide means "not
482    /// asked", and calculations needing it report that instead of assuming
483    /// zero.
484    #[must_use]
485    pub const fn with_tide(mut self, height: Distance) -> Self {
486        self.tide = Some(height);
487        self
488    }
489
490    /// Point of the sample.
491    #[must_use]
492    pub const fn point(&self) -> GeodeticPoint {
493        self.point
494    }
495
496    /// Time of the sample.
497    #[must_use]
498    pub const fn when(&self) -> Instant<Utc> {
499        self.when
500    }
501
502    /// Magnetic field, if resolved.
503    #[must_use]
504    pub const fn magnetic(&self) -> Option<MagneticField> {
505        self.magnetic
506    }
507
508    /// Current, if resolved.
509    #[must_use]
510    pub const fn current(&self) -> Option<Current> {
511        self.current
512    }
513
514    /// Wind, if resolved.
515    #[must_use]
516    pub const fn wind(&self) -> Option<Wind> {
517        self.wind
518    }
519
520    /// Height of tide above chart datum, if resolved.
521    #[must_use]
522    pub const fn tide(&self) -> Option<Distance> {
523        self.tide
524    }
525}
526
527#[cfg(test)]
528#[allow(clippy::unwrap_used, clippy::float_cmp)]
529mod tests {
530    use alloc::format;
531
532    use super::*;
533    use crate::geodesy::Height;
534    use crate::units::Distance;
535
536    fn somewhere() -> GeodeticPoint {
537        GeodeticPoint::new(
538            Position::from_degrees(50.0, -4.0).unwrap(),
539            Height::above_ellipsoid(Distance::ZERO),
540        )
541    }
542
543    fn noon() -> Instant<Utc> {
544        Instant::from_unix_seconds(1_700_000_000)
545    }
546
547    #[test]
548    fn the_navigators_quantities_follow_from_the_components() {
549        // Field 30° east of north, dip 60°.
550        let horizontal = 20_000.0;
551        let field = MagneticField::from_ned_nanotesla(
552            horizontal * math::cos(math::to_radians(30.0)),
553            horizontal * math::sin(math::to_radians(30.0)),
554            horizontal * math::tan(math::to_radians(60.0)),
555        )
556        .unwrap();
557        assert!((field.declination().degrees() - 30.0).abs() < 1e-9);
558        assert!((field.inclination().degrees() - 60.0).abs() < 1e-9);
559        assert!((field.horizontal_intensity_nanotesla() - horizontal).abs() < 1e-6);
560        assert!((field.total_intensity_nanotesla() - 40_000.0).abs() < 1e-6);
561    }
562
563    #[test]
564    fn declination_is_west_negative_and_dip_is_up_negative_in_the_south() {
565        let field = MagneticField::from_ned_nanotesla(20_000.0, -5_000.0, -30_000.0).unwrap();
566        assert!(field.declination().degrees() < 0.0);
567        assert!(field.inclination().degrees() < 0.0);
568        assert_eq!(format!("{}", field.declination()), "14.0°W");
569    }
570
571    #[test]
572    fn a_field_that_cannot_be_the_earths_is_refused() {
573        assert!(matches!(
574            MagneticField::from_ned_nanotesla(f64::NAN, 0.0, 0.0),
575            Err(KernelError::NotFinite { .. })
576        ));
577        // µT/gauss confusion.
578        assert!(matches!(
579            MagneticField::from_ned_nanotesla(0.0, 0.0, 500_000.0),
580            Err(KernelError::OutOfRange {
581                parameter: "field down component",
582                ..
583            })
584        ));
585    }
586
587    #[test]
588    fn a_vanishing_horizontal_field_has_no_declination_and_says_so_quietly() {
589        let field = MagneticField::from_ned_nanotesla(0.0, 0.0, 50_000.0).unwrap();
590        assert_eq!(field.horizontal_intensity_nanotesla(), 0.0);
591        assert_eq!(field.declination(), Variation::ZERO);
592        assert!((field.inclination().degrees() - 90.0).abs() < 1e-9);
593    }
594
595    #[test]
596    fn the_field_is_displayed_as_a_navigator_would_write_it() {
597        let field = MagneticField::from_ned_nanotesla(17_320.508, 10_000.0, 40_000.0).unwrap();
598        assert_eq!(
599            format!("{field}"),
600            "D 30.0°E, I 63.4°, H 20000 nT, F 44721 nT"
601        );
602    }
603
604    #[test]
605    fn a_wind_is_named_by_where_it_comes_from() {
606        let wind = Wind::new(
607            TrueCourse::new(270.0).unwrap(),
608            Speed::from_knots(15.0).unwrap(),
609        )
610        .unwrap();
611        assert_eq!(wind.from().degrees(), 270.0);
612        assert_eq!(wind.towards().degrees(), 90.0);
613        assert_eq!(wind.speed().knots(), 15.0);
614        assert_eq!(format!("{wind}"), "15.0 kn from 270.0°T");
615        assert_eq!(Wind::CALM.speed(), Speed::ZERO);
616    }
617
618    #[test]
619    fn a_wind_cannot_blow_backwards() {
620        assert!(matches!(
621            Wind::new(TrueCourse::NORTH, Speed::from_knots(-1.0).unwrap()),
622            Err(KernelError::OutOfRange {
623                parameter: "wind speed",
624                ..
625            })
626        ));
627    }
628
629    #[test]
630    fn slack_water_is_no_current() {
631        assert_eq!(Current::SLACK.drift, Speed::ZERO);
632    }
633
634    /// Constant implementation of every port: minimal stand-ins, and proof that
635    /// ports can be implemented outside the crate.
636    struct Constant;
637
638    impl MagneticModel for Constant {
639        fn field_at(&self, _: GeodeticPoint, when: Instant<Utc>) -> Result<MagneticField> {
640            if when < noon() {
641                return Err(KernelError::OutsideValidity {
642                    data: "magnetic model",
643                });
644            }
645            MagneticField::from_ned_nanotesla(19_000.0, -1_000.0, 45_000.0)
646        }
647    }
648
649    impl CompassModel for Constant {
650        fn deviation(&self, course: CompassCourse) -> Result<Deviation> {
651            Deviation::new(2.0 * math::sin(course.radians()))
652        }
653    }
654
655    impl CurrentModel for Constant {
656        fn current_at(&self, _: Position, _: Instant<Utc>) -> Result<Current> {
657            Ok(Current {
658                set: TrueCourse::new(45.0)?,
659                drift: Speed::from_knots(1.5)?,
660            })
661        }
662    }
663
664    impl WindModel for Constant {
665        fn wind_at(&self, _: Position, _: Instant<Utc>) -> Result<Wind> {
666            Wind::new(TrueCourse::new(200.0)?, Speed::from_knots(20.0)?)
667        }
668    }
669
670    impl LeewayModel for Constant {
671        fn leeway(&self, motion: VesselMotion, wind: Wind) -> Result<Angle> {
672            // Toy model: 5° downwind, whichever side.
673            let relative =
674                crate::angle::wrap180(wind.from().degrees() - motion.heading().degrees());
675            Angle::from_degrees(if relative < 0.0 { 5.0 } else { -5.0 })
676        }
677    }
678
679    #[test]
680    fn the_ports_are_implementable_and_a_sample_is_built_from_their_answers() {
681        let point = somewhere();
682        let when = noon();
683        let sample = EnvironmentSample::at(point, when)
684            .with_magnetic(Constant.field_at(point, when).unwrap())
685            .with_current(Constant.current_at(point.position(), when).unwrap())
686            .with_wind(Constant.wind_at(point.position(), when).unwrap());
687        assert_eq!(sample.point(), point);
688        assert_eq!(sample.when(), when);
689        assert!((sample.magnetic().unwrap().declination().degrees() + 3.0128).abs() < 1e-3);
690        assert_eq!(sample.current().unwrap().drift.knots(), 1.5);
691        assert_eq!(sample.wind().unwrap().from().degrees(), 200.0);
692    }
693
694    #[test]
695    fn a_sample_is_honest_about_what_was_not_resolved() {
696        let sample = EnvironmentSample::at(somewhere(), noon());
697        assert_eq!(sample.magnetic(), None);
698        assert_eq!(sample.current(), None);
699        assert_eq!(sample.wind(), None);
700        assert_eq!(sample.tide(), None);
701
702        // A tide resolved as zero is an answer, not a gap.
703        let slack = sample.with_tide(Distance::ZERO);
704        assert_eq!(slack.tide(), Some(Distance::ZERO));
705    }
706
707    #[test]
708    fn a_model_refuses_rather_than_guesses_outside_its_validity() {
709        let earlier = Instant::from_unix_seconds(1_600_000_000);
710        assert!(matches!(
711            Constant.field_at(somewhere(), earlier),
712            Err(KernelError::OutsideValidity {
713                data: "magnetic model"
714            })
715        ));
716    }
717
718    #[test]
719    fn deviation_and_leeway_ports_answer_for_a_course() {
720        let deviation = Constant
721            .deviation(CompassCourse::new(90.0).unwrap())
722            .unwrap();
723        assert!((deviation.degrees() - 2.0).abs() < 1e-9);
724
725        let motion = VesselMotion::new(TrueCourse::NORTH, Speed::from_knots(6.0).unwrap());
726        assert_eq!(motion.heading(), TrueCourse::NORTH);
727        assert_eq!(motion.speed_through_water().knots(), 6.0);
728        // Wind on the port beam sets the vessel to starboard.
729        let from_port = Wind::new(
730            TrueCourse::new(270.0).unwrap(),
731            Speed::from_knots(20.0).unwrap(),
732        )
733        .unwrap();
734        assert_eq!(Constant.leeway(motion, from_port).unwrap().degrees(), 5.0);
735        let from_starboard = Wind::new(
736            TrueCourse::new(90.0).unwrap(),
737            Speed::from_knots(20.0).unwrap(),
738        )
739        .unwrap();
740        assert_eq!(
741            Constant.leeway(motion, from_starboard).unwrap().degrees(),
742            -5.0
743        );
744    }
745
746    #[cfg(feature = "serde")]
747    #[test]
748    fn serde_round_trips_and_validates_on_the_way_in() {
749        let field = MagneticField::from_ned_nanotesla(19_000.0, -1_000.0, 45_000.0).unwrap();
750        let json = serde_json::to_string(&field).unwrap();
751        assert_eq!(
752            json,
753            r#"{"north_nanotesla":19000.0,"east_nanotesla":-1000.0,"down_nanotesla":45000.0}"#
754        );
755        assert_eq!(serde_json::from_str::<MagneticField>(&json).unwrap(), field);
756        assert!(serde_json::from_str::<MagneticField>(
757            r#"{"north_nanotesla":1e9,"east_nanotesla":0.0,"down_nanotesla":0.0}"#
758        )
759        .is_err());
760
761        let wind = Wind::new(
762            TrueCourse::new(200.0).unwrap(),
763            Speed::from_knots(20.0).unwrap(),
764        )
765        .unwrap();
766        let json = serde_json::to_string(&wind).unwrap();
767        assert_eq!(serde_json::from_str::<Wind>(&json).unwrap(), wind);
768        assert!(serde_json::from_str::<Wind>(r#"{"from":200.0,"speed":-1.0}"#).is_err());
769
770        let sample = EnvironmentSample::at(somewhere(), noon())
771            .with_magnetic(field)
772            .with_wind(wind)
773            .with_tide(Distance::from_metres(2.5).unwrap());
774        let json = serde_json::to_string(&sample).unwrap();
775        assert_eq!(
776            serde_json::from_str::<EnvironmentSample>(&json).unwrap(),
777            sample
778        );
779
780        // A sample stored without a tide reads back without one.
781        let older = json.replace(",\"tide\":", ",\"ignored\":");
782        assert_ne!(older, json);
783        let read = serde_json::from_str::<EnvironmentSample>(&older).unwrap();
784        assert_eq!(read.tide(), None);
785        assert_eq!(read.wind(), Some(wind));
786    }
787}