1use 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
30pub const MAX_FIELD_NANOTESLA: f64 = 100_000.0;
36
37#[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 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 #[must_use]
91 pub const fn north_nanotesla(&self) -> f64 {
92 self.north
93 }
94
95 #[must_use]
97 pub const fn east_nanotesla(&self) -> f64 {
98 self.east
99 }
100
101 #[must_use]
103 pub const fn down_nanotesla(&self) -> f64 {
104 self.down
105 }
106
107 #[must_use]
112 pub fn horizontal_intensity_nanotesla(&self) -> f64 {
113 math::hypot(self.north, self.east)
114 }
115
116 #[must_use]
118 pub fn total_intensity_nanotesla(&self) -> f64 {
119 math::hypot(self.horizontal_intensity_nanotesla(), self.down)
120 }
121
122 #[must_use]
128 pub fn declination(&self) -> Variation {
129 let degrees = math::to_degrees(math::atan2(self.east, self.north));
130 Variation::new(degrees).unwrap_or(Variation::ZERO)
132 }
133
134 #[must_use]
136 pub fn inclination(&self) -> Angle {
137 let radians = math::atan2(self.down, self.horizontal_intensity_nanotesla());
138 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#[cfg(feature = "serde")]
159#[derive(serde::Serialize, serde::Deserialize)]
160#[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#[derive(Debug, Clone, Copy, PartialEq)]
194#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
195pub struct Current {
196 pub set: TrueCourse,
200 pub drift: Speed,
202}
203
204impl Current {
205 pub const SLACK: Self = Self {
207 set: TrueCourse::NORTH,
208 drift: Speed::ZERO,
209 };
210}
211
212#[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 pub const CALM: Self = Self {
230 from: TrueCourse::NORTH,
231 speed: Speed::ZERO,
232 };
233
234 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 #[must_use]
255 pub const fn from(&self) -> TrueCourse {
256 self.from
257 }
258
259 #[must_use]
261 pub fn towards(&self) -> TrueCourse {
262 self.from.reciprocal()
263 }
264
265 #[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#[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#[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 #[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 #[must_use]
328 pub const fn heading(&self) -> TrueCourse {
329 self.heading
330 }
331
332 #[must_use]
334 pub const fn speed_through_water(&self) -> Speed {
335 self.speed_through_water
336 }
337}
338
339pub trait MagneticModel {
349 fn field_at(&self, at: GeodeticPoint, when: Instant<Utc>) -> Result<MagneticField>;
356}
357
358pub trait CompassModel {
364 fn deviation(&self, course: CompassCourse) -> Result<Deviation>;
370}
371
372pub trait CurrentModel {
374 fn current_at(&self, at: Position, when: Instant<Utc>) -> Result<Current>;
381}
382
383pub trait WindModel {
385 fn wind_at(&self, at: Position, when: Instant<Utc>) -> Result<Wind>;
392}
393
394pub trait TideModel {
399 fn height_of_tide(&self, at: Position, when: Instant<Utc>) -> Result<Distance>;
406}
407
408pub trait LeewayModel {
413 fn leeway(&self, motion: VesselMotion, wind: Wind) -> Result<Angle>;
420}
421
422#[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 #[cfg_attr(feature = "serde", serde(default))]
441 tide: Option<Distance>,
442}
443
444impl EnvironmentSample {
445 #[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 #[must_use]
460 pub const fn with_magnetic(mut self, field: MagneticField) -> Self {
461 self.magnetic = Some(field);
462 self
463 }
464
465 #[must_use]
467 pub const fn with_current(mut self, current: Current) -> Self {
468 self.current = Some(current);
469 self
470 }
471
472 #[must_use]
474 pub const fn with_wind(mut self, wind: Wind) -> Self {
475 self.wind = Some(wind);
476 self
477 }
478
479 #[must_use]
485 pub const fn with_tide(mut self, height: Distance) -> Self {
486 self.tide = Some(height);
487 self
488 }
489
490 #[must_use]
492 pub const fn point(&self) -> GeodeticPoint {
493 self.point
494 }
495
496 #[must_use]
498 pub const fn when(&self) -> Instant<Utc> {
499 self.when
500 }
501
502 #[must_use]
504 pub const fn magnetic(&self) -> Option<MagneticField> {
505 self.magnetic
506 }
507
508 #[must_use]
510 pub const fn current(&self) -> Option<Current> {
511 self.current
512 }
513
514 #[must_use]
516 pub const fn wind(&self) -> Option<Wind> {
517 self.wind
518 }
519
520 #[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 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 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 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 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 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 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 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}