1use std::time::Duration;
10
11use nav_math::{Position, angle, great_circle};
12
13const PREDICTOR_MINUTES: f64 = 6.0;
21
22const MIN_HEADING_LINE_NM: f64 = 0.15;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct VesselState {
29 pub position: Position,
31 pub cog_deg: f64,
33 pub sog_kn: f64,
35 pub heading_deg: Option<f64>,
41}
42
43impl VesselState {
44 #[must_use]
47 pub fn predictor_end(&self) -> Position {
48 let distance_nm = self.sog_kn * (PREDICTOR_MINUTES / 60.0);
49 great_circle::destination(self.position, self.cog_deg, distance_nm)
50 }
51
52 #[must_use]
60 pub fn heading_line_end(&self) -> Option<Position> {
61 let heading_deg = self.heading_deg?;
62 let distance_nm = (self.sog_kn * (PREDICTOR_MINUTES / 60.0)).max(MIN_HEADING_LINE_NM);
63 Some(great_circle::destination(
64 self.position,
65 heading_deg,
66 distance_nm,
67 ))
68 }
69}
70
71#[derive(Debug, Clone, Copy, Default)]
78pub struct FixBuilder {
79 position: Option<Position>,
80 cog_deg: Option<f64>,
81 sog_kn: Option<f64>,
82 heading_deg: Option<f64>,
83}
84
85impl FixBuilder {
86 #[must_use]
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn set_position(&mut self, position: Position) {
96 self.position = Some(position);
97 }
98
99 pub fn set_course_over_ground(&mut self, deg: f64) {
101 self.cog_deg = Some(deg);
102 }
103
104 pub fn set_speed_over_ground(&mut self, kn: f64) {
106 self.sog_kn = Some(kn);
107 }
108
109 pub fn set_heading(&mut self, deg: f64) {
112 self.heading_deg = Some(deg);
113 }
114
115 #[must_use]
121 pub fn complete(self) -> Option<VesselState> {
122 Some(VesselState {
123 position: self.position?,
124 cog_deg: self.cog_deg?,
125 sog_kn: self.sog_kn?,
126 heading_deg: self.heading_deg,
127 })
128 }
129}
130
131#[derive(Debug, Clone)]
138pub struct Simulator {
139 state: VesselState,
140 turn_rate_deg_s: f64,
143}
144
145impl Simulator {
146 #[must_use]
149 pub fn new() -> Self {
150 Self {
151 state: VesselState {
152 position: Position::new(45.5500, 13.7300),
153 cog_deg: 300.0,
154 sog_kn: 6.2,
155 heading_deg: Some(297.0),
156 },
157 turn_rate_deg_s: 0.35,
158 }
159 }
160
161 pub fn tick(&mut self, dt: Duration) -> VesselState {
163 let seconds = dt.as_secs_f64();
164
165 self.state.cog_deg = angle::norm_360(self.state.cog_deg + self.turn_rate_deg_s * seconds);
166 self.state.heading_deg = Some(angle::norm_360(self.state.cog_deg - 3.0));
170
171 let distance_nm = self.state.sog_kn * (seconds / 3600.0);
172 self.state.position =
173 great_circle::destination(self.state.position, self.state.cog_deg, distance_nm);
174
175 self.state
176 }
177
178 #[must_use]
180 pub fn state(&self) -> VesselState {
181 self.state
182 }
183}
184
185impl Default for Simulator {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct SimulatedWind {
194 pub direction_true_deg: f64,
196 pub speed_kn: f64,
198}
199
200#[must_use]
214pub fn simulated_wind(now: Duration) -> SimulatedWind {
215 let t = now.as_secs_f64();
216 SimulatedWind {
217 direction_true_deg: angle::norm_360(270.0 + 15.0 * (t / 40.0).sin()),
218 speed_kn: 12.0 + 3.0 * (t / 25.0).sin(),
219 }
220}
221
222#[must_use]
226pub fn format_latitude(lat_deg: f64) -> String {
227 let hemisphere = if lat_deg < 0.0 { 'S' } else { 'N' };
228 format!("{}{hemisphere}", degrees_minutes(lat_deg.abs(), 2))
229}
230
231#[must_use]
233pub fn format_longitude(lon_deg: f64) -> String {
234 let hemisphere = if lon_deg < 0.0 { 'W' } else { 'E' };
235 format!("{}{hemisphere}", degrees_minutes(lon_deg.abs(), 3))
236}
237
238fn degrees_minutes(value: f64, degree_width: usize) -> String {
239 let mut degrees = value.trunc();
240 let mut minutes = (value - degrees) * 60.0;
241
242 if minutes >= 59.995 {
245 minutes = 0.0;
246 degrees += 1.0;
247 }
248
249 format!("{:0degree_width$.0}\u{00b0}{:05.2}\u{2032}", degrees, minutes)
250}
251
252#[cfg(test)]
253mod simulated_wind_tests {
254 use super::*;
255
256 #[test]
257 fn it_stays_within_a_believable_range() {
258 for secs in 0..600 {
259 let wind = simulated_wind(Duration::from_secs(secs));
260 assert!((0.0..360.0).contains(&wind.direction_true_deg), "{wind:?}");
261 assert!(wind.speed_kn > 0.0, "{wind:?}");
262 }
263 }
264
265 #[test]
266 fn it_is_deterministic_from_the_same_clock() {
267 let a = simulated_wind(Duration::from_secs(123));
268 let b = simulated_wind(Duration::from_secs(123));
269 assert_eq!(a, b);
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn close(a: f64, b: f64, tol: f64) -> bool {
278 (a - b).abs() < tol
279 }
280
281 #[test]
282 fn the_predictor_is_a_tenth_of_the_speed_in_miles() {
283 let state = VesselState {
285 position: Position::new(45.0, 13.0),
286 cog_deg: 0.0,
287 sog_kn: 6.0,
288 heading_deg: Some(0.0),
289 };
290 let run = great_circle::distance_nm(state.position, state.predictor_end());
291 assert!(close(run, 0.6, 1e-9), "got {run}");
292 }
293
294 #[test]
295 fn a_stopped_vessel_has_no_predictor() {
296 let state = VesselState {
297 position: Position::new(45.0, 13.0),
298 cog_deg: 90.0,
299 sog_kn: 0.0,
300 heading_deg: Some(90.0),
301 };
302 let run = great_circle::distance_nm(state.position, state.predictor_end());
307 assert!(close(run, 0.0, 1e-12), "stopped vessel predicted {run} NM ahead");
308 }
309
310 #[test]
311 fn the_heading_line_survives_the_vessel_stopping() {
312 let state = VesselState {
313 position: Position::new(45.0, 13.0),
314 cog_deg: 90.0,
315 sog_kn: 0.0,
316 heading_deg: Some(45.0),
317 };
318 let end = state.heading_line_end().expect("a compass");
319 let run = great_circle::distance_nm(state.position, end);
320 assert!(close(run, MIN_HEADING_LINE_NM, 1e-9), "got {run}");
321 let brg = great_circle::initial_bearing_deg(state.position, end);
323 assert!(close(brg, 45.0, 1e-6), "heading line ran off on {brg}");
324 }
325
326 #[test]
327 fn under_way_the_two_lines_are_the_same_length_and_differ_only_in_angle() {
328 let state = VesselState {
329 position: Position::new(45.0, 13.0),
330 cog_deg: 300.0,
331 sog_kn: 6.2,
332 heading_deg: Some(297.0),
333 };
334 let cog_run = great_circle::distance_nm(state.position, state.predictor_end());
335 let heading_end = state.heading_line_end().expect("a compass");
336 let hdg_run = great_circle::distance_nm(state.position, heading_end);
337 assert!(close(cog_run, hdg_run, 1e-9), "{cog_run} vs {hdg_run}");
338
339 let separation = angle::diff(
340 great_circle::initial_bearing_deg(state.position, state.predictor_end()),
341 great_circle::initial_bearing_deg(state.position, heading_end),
342 );
343 assert!(close(separation, 3.0, 1e-6), "got {separation}");
344 }
345
346 #[test]
347 fn ticking_moves_the_vessel_at_its_speed() {
348 let mut sim = Simulator::new();
349 sim.turn_rate_deg_s = 0.0; let before = sim.state();
351 let after = sim.tick(Duration::from_secs(3600));
352 let run = great_circle::distance_nm(before.position, after.position);
353 assert!(close(run, before.sog_kn, 1e-3), "one hour ran {run} NM");
354 }
355
356 #[test]
357 fn the_simulated_course_stays_a_valid_bearing() {
358 let mut sim = Simulator::new();
359 for _ in 0..2000 {
360 let s = sim.tick(Duration::from_secs(1));
361 assert!((0.0..360.0).contains(&s.cog_deg), "cog {} left range", s.cog_deg);
362 let heading = s.heading_deg.expect("the simulator has a compass");
363 assert!((0.0..360.0).contains(&heading), "heading {heading} left range");
364 }
365 }
366
367 #[test]
368 fn a_vessel_without_a_compass_gets_no_heading_line() {
369 let state = VesselState {
372 position: Position::new(45.0, 13.0),
373 cog_deg: 90.0,
374 sog_kn: 5.0,
375 heading_deg: None,
376 };
377 assert!(state.heading_line_end().is_none());
378 assert!(great_circle::distance_nm(state.position, state.predictor_end()) > 0.0);
380 }
381
382 #[test]
383 fn positions_are_degrees_and_decimal_minutes() {
384 assert_eq!(format_latitude(45.55), "45\u{00b0}33.00\u{2032}N");
385 assert_eq!(format_longitude(13.73), "013\u{00b0}43.80\u{2032}E");
386 }
387
388 #[test]
389 fn the_southern_and_western_hemispheres_are_not_negative_numbers() {
390 assert_eq!(format_latitude(-33.8688), "33\u{00b0}52.13\u{2032}S");
391 assert_eq!(format_longitude(-5.2), "005\u{00b0}12.00\u{2032}W");
392 }
393
394 #[test]
395 fn sixty_minutes_carries_into_the_degree() {
396 assert_eq!(format_latitude(45.999_99), "46\u{00b0}00.00\u{2032}N");
398 }
399
400 #[test]
401 fn a_builder_with_only_a_position_is_not_a_state() {
402 let mut builder = FixBuilder::new();
403 builder.set_position(Position::new(45.5, 13.7));
404 assert!(builder.complete().is_none(), "position alone is not a state");
405
406 builder.set_course_over_ground(300.0);
407 assert!(builder.complete().is_none());
408
409 builder.set_speed_over_ground(6.2);
410 assert!(builder.complete().is_some());
411 }
412
413 #[test]
414 fn a_builder_with_no_compass_still_produces_a_state() {
415 let mut builder = FixBuilder::new();
416 builder.set_position(Position::new(45.5, 13.7));
417 builder.set_course_over_ground(300.0);
418 builder.set_speed_over_ground(6.2);
419
420 let state = builder.complete().expect("a state");
421 assert!(state.heading_deg.is_none());
422 }
423}