1use std::time::{Duration, Instant};
40
41#[derive(Debug, Clone, PartialEq)]
46pub struct SmartBeaconingConfig {
47 pub low_speed_kmh: f64,
50 pub high_speed_kmh: f64,
53 pub slow_rate_secs: u32,
56 pub fast_rate_secs: u32,
59 pub turn_slope: u16,
63 pub turn_min_deg: f64,
67 pub turn_time_secs: u32,
70}
71
72impl Default for SmartBeaconingConfig {
73 fn default() -> Self {
74 Self {
75 low_speed_kmh: 5.0,
76 high_speed_kmh: 70.0,
77 slow_rate_secs: 1800,
78 fast_rate_secs: 180,
79 turn_slope: 26,
80 turn_min_deg: 28.0,
81 turn_time_secs: 15,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum BeaconReason {
93 First,
95 TimeExpired,
97 Turn,
99}
100
101#[derive(Debug, Clone, PartialEq)]
109pub enum BeaconState {
110 Uninitialized,
113 Running {
116 last_beacon_time: Instant,
118 last_course: Option<f64>,
122 last_speed: Option<f64>,
124 },
125}
126
127#[derive(Debug)]
136pub struct SmartBeaconing {
137 config: SmartBeaconingConfig,
139 state: BeaconState,
141}
142
143impl SmartBeaconing {
144 #[must_use]
146 pub const fn new(config: SmartBeaconingConfig) -> Self {
147 Self {
148 config,
149 state: BeaconState::Uninitialized,
150 }
151 }
152
153 #[must_use]
155 pub const fn state(&self) -> &BeaconState {
156 &self.state
157 }
158
159 #[must_use]
164 pub fn should_beacon(&mut self, speed_kmh: f64, course_deg: f64, now: Instant) -> bool {
165 self.beacon_reason(speed_kmh, course_deg, now).is_some()
166 }
167
168 #[must_use]
175 pub fn beacon_reason(
176 &mut self,
177 speed_kmh: f64,
178 course_deg: f64,
179 now: Instant,
180 ) -> Option<BeaconReason> {
181 let BeaconState::Running {
183 last_beacon_time,
184 last_course,
185 ..
186 } = self.state
187 else {
188 return Some(BeaconReason::First);
189 };
190
191 let elapsed = now.duration_since(last_beacon_time);
192 let interval = Duration::from_secs(u64::from(self.compute_interval(speed_kmh)));
193
194 if elapsed >= interval {
195 return Some(BeaconReason::TimeExpired);
196 }
197
198 if speed_kmh > self.config.low_speed_kmh
199 && let Some(last_course) = last_course
200 {
201 let turn = heading_delta(last_course, course_deg);
202 let threshold = self.current_turn_threshold(speed_kmh);
203 if turn >= threshold
204 && elapsed >= Duration::from_secs(u64::from(self.config.turn_time_secs))
205 {
206 return Some(BeaconReason::Turn);
207 }
208 }
209
210 None
211 }
212
213 #[must_use]
220 pub fn current_turn_threshold(&self, speed_kmh: f64) -> f64 {
221 if speed_kmh <= self.config.low_speed_kmh {
222 return f64::INFINITY;
223 }
224 self.config.turn_min_deg + (f64::from(self.config.turn_slope) * 10.0) / speed_kmh
225 }
226
227 pub const fn beacon_sent(&mut self, now: Instant) {
233 let (prev_course, prev_speed) = match self.state {
234 BeaconState::Uninitialized => (None, None),
235 BeaconState::Running {
236 last_course,
237 last_speed,
238 ..
239 } => (last_course, last_speed),
240 };
241 self.state = BeaconState::Running {
242 last_beacon_time: now,
243 last_course: prev_course,
244 last_speed: prev_speed,
245 };
246 }
247
248 pub const fn beacon_sent_with(&mut self, speed_kmh: f64, course_deg: f64, now: Instant) {
252 self.state = BeaconState::Running {
253 last_beacon_time: now,
254 last_course: Some(course_deg),
255 last_speed: Some(speed_kmh),
256 };
257 }
258
259 #[must_use]
263 pub fn current_interval_secs(&self) -> u32 {
264 match &self.state {
265 BeaconState::Running {
266 last_speed: Some(s),
267 ..
268 } => self.compute_interval(*s),
269 _ => self.config.slow_rate_secs,
270 }
271 }
272
273 fn compute_interval(&self, speed_kmh: f64) -> u32 {
278 if speed_kmh <= self.config.low_speed_kmh {
279 return self.config.slow_rate_secs;
280 }
281 if speed_kmh >= self.config.high_speed_kmh {
282 return self.config.fast_rate_secs;
283 }
284
285 let speed_range = self.config.high_speed_kmh - self.config.low_speed_kmh;
287 let rate_range =
288 f64::from(self.config.slow_rate_secs) - f64::from(self.config.fast_rate_secs);
289 let fraction = (speed_kmh - self.config.low_speed_kmh) / speed_range;
290
291 #[expect(
292 clippy::cast_possible_truncation,
293 clippy::cast_sign_loss,
294 reason = "interval is bounded by slow_rate_secs (u32) and fraction is in [0,1]"
295 )]
296 let interval = fraction.mul_add(-rate_range, f64::from(self.config.slow_rate_secs)) as u32;
297 interval
298 }
299}
300
301fn heading_delta(a: f64, b: f64) -> f64 {
304 let mut delta = (b - a).abs();
305 if delta > 180.0 {
306 delta = 360.0 - delta;
307 }
308 delta
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 type TestResult = Result<(), Box<dyn std::error::Error>>;
316
317 #[test]
318 fn default_config_values() {
319 let cfg = SmartBeaconingConfig::default();
320 assert!((cfg.low_speed_kmh - 5.0).abs() < f64::EPSILON);
321 assert!((cfg.high_speed_kmh - 70.0).abs() < f64::EPSILON);
322 assert_eq!(cfg.slow_rate_secs, 1800);
323 assert_eq!(cfg.fast_rate_secs, 180);
324 assert_eq!(cfg.turn_slope, 26);
325 assert!((cfg.turn_min_deg - 28.0).abs() < f64::EPSILON);
326 assert_eq!(cfg.turn_time_secs, 15);
327 }
328
329 #[test]
330 fn turn_threshold_matches_hamhud_formula() {
331 let sb = SmartBeaconing::new(SmartBeaconingConfig::default());
332 assert!(sb.current_turn_threshold(0.0).is_infinite());
334 assert!(sb.current_turn_threshold(5.0).is_infinite());
335
336 let t60 = sb.current_turn_threshold(60.0);
338 assert!((t60 - (28.0 + 260.0 / 60.0)).abs() < 1e-9);
339
340 let t10 = sb.current_turn_threshold(10.0);
342 assert!((t10 - 54.0).abs() < 1e-9);
343
344 let threshold_120 = sb.current_turn_threshold(120.0);
346 assert!((threshold_120 - (28.0 + 260.0 / 120.0)).abs() < 1e-9);
347 }
348
349 #[test]
350 fn first_beacon_always_true() {
351 let t0 = Instant::now();
352 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
353 assert!(sb.should_beacon(0.0, 0.0, t0));
354 }
355
356 #[test]
357 fn interval_at_low_speed() {
358 let sb = SmartBeaconing::new(SmartBeaconingConfig::default());
359 assert_eq!(sb.compute_interval(0.0), 1800);
360 assert_eq!(sb.compute_interval(5.0), 1800);
361 }
362
363 #[test]
364 fn interval_at_high_speed() {
365 let sb = SmartBeaconing::new(SmartBeaconingConfig::default());
366 assert_eq!(sb.compute_interval(70.0), 180);
367 assert_eq!(sb.compute_interval(100.0), 180);
368 }
369
370 #[test]
371 fn interval_interpolation_midpoint() {
372 let sb = SmartBeaconing::new(SmartBeaconingConfig::default());
373 let interval = sb.compute_interval(37.5);
376 assert!((f64::from(interval) - 990.0).abs() < 2.0);
377 }
378
379 #[test]
380 fn current_interval_without_speed_data() {
381 let sb = SmartBeaconing::new(SmartBeaconingConfig::default());
382 assert_eq!(sb.current_interval_secs(), 1800);
383 }
384
385 #[test]
386 fn current_interval_with_speed_data() {
387 let t0 = Instant::now();
388 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
389 sb.beacon_sent_with(70.0, 0.0, t0);
390 assert_eq!(sb.current_interval_secs(), 180);
391 }
392
393 #[test]
394 fn beacon_sent_updates_state() {
395 let t0 = Instant::now();
396 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
397 assert!(matches!(sb.state(), BeaconState::Uninitialized));
398 sb.beacon_sent(t0);
399 assert!(matches!(sb.state(), BeaconState::Running { .. }));
400 }
401
402 #[test]
403 fn beacon_sent_with_stores_course_and_speed() -> TestResult {
404 let t0 = Instant::now();
405 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
406 sb.beacon_sent_with(50.0, 270.0, t0);
407 let BeaconState::Running {
408 last_course,
409 last_speed,
410 ..
411 } = sb.state()
412 else {
413 return Err("expected Running state".into());
414 };
415 let speed = last_speed.ok_or("expected last_speed to be Some")?;
416 let course = last_course.ok_or("expected last_course to be Some")?;
417 assert!((speed - 50.0).abs() < f64::EPSILON);
418 assert!((course - 270.0).abs() < f64::EPSILON);
419 Ok(())
420 }
421
422 #[test]
423 fn no_beacon_immediately_after_send() {
424 let t0 = Instant::now();
425 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
426 assert!(sb.should_beacon(0.0, 0.0, t0));
428 sb.beacon_sent_with(0.0, 0.0, t0);
429
430 assert!(!sb.should_beacon(0.0, 0.0, t0));
432 }
433
434 #[test]
435 fn heading_delta_simple() {
436 assert!((heading_delta(0.0, 90.0) - 90.0).abs() < f64::EPSILON);
437 assert!((heading_delta(90.0, 0.0) - 90.0).abs() < f64::EPSILON);
438 }
439
440 #[test]
441 fn heading_delta_wraparound() {
442 assert!((heading_delta(350.0, 10.0) - 20.0).abs() < f64::EPSILON);
444 assert!((heading_delta(10.0, 350.0) - 20.0).abs() < f64::EPSILON);
445 }
446
447 #[test]
448 fn heading_delta_opposite() {
449 assert!((heading_delta(0.0, 180.0) - 180.0).abs() < f64::EPSILON);
450 assert!((heading_delta(90.0, 270.0) - 180.0).abs() < f64::EPSILON);
451 }
452
453 #[test]
454 fn turn_beacon_not_triggered_at_low_speed() {
455 let t0 = Instant::now();
456 let mut sb = SmartBeaconing::new(SmartBeaconingConfig {
457 turn_time_secs: 0, ..SmartBeaconingConfig::default()
459 });
460
461 assert!(sb.should_beacon(3.0, 0.0, t0));
463 sb.beacon_sent_with(3.0, 0.0, t0);
464
465 assert!(!sb.should_beacon(3.0, 90.0, t0));
467 }
468
469 #[test]
470 fn turn_beacon_triggered_at_high_speed() {
471 let t0 = Instant::now();
472 let mut sb = SmartBeaconing::new(SmartBeaconingConfig {
473 turn_time_secs: 0, ..SmartBeaconingConfig::default()
475 });
476
477 assert!(sb.should_beacon(75.0, 0.0, t0));
479 sb.beacon_sent_with(75.0, 0.0, t0);
480
481 assert!(sb.should_beacon(75.0, 45.0, t0));
484 }
485
486 #[test]
487 fn turn_beacon_below_threshold_no_trigger() {
488 let t0 = Instant::now();
489 let mut sb = SmartBeaconing::new(SmartBeaconingConfig {
490 turn_time_secs: 0,
491 ..SmartBeaconingConfig::default()
492 });
493
494 assert!(sb.should_beacon(75.0, 0.0, t0));
496 sb.beacon_sent_with(75.0, 0.0, t0);
497
498 assert!(!sb.should_beacon(75.0, 20.0, t0));
500 }
501
502 #[test]
503 fn time_expired_triggers_beacon_after_interval() {
504 let t0 = Instant::now();
505 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
506 assert!(sb.should_beacon(0.0, 0.0, t0));
507 sb.beacon_sent_with(0.0, 0.0, t0);
508
509 let later = t0 + Duration::from_secs(1801);
511 assert_eq!(
512 sb.beacon_reason(0.0, 0.0, later),
513 Some(BeaconReason::TimeExpired),
514 );
515 }
516
517 #[test]
518 fn turn_time_gates_turn_beacon() {
519 let t0 = Instant::now();
520 let mut sb = SmartBeaconing::new(SmartBeaconingConfig::default());
521 assert!(sb.should_beacon(75.0, 0.0, t0));
524 sb.beacon_sent_with(75.0, 0.0, t0);
525
526 let t5 = t0 + Duration::from_secs(5);
529 assert_eq!(sb.beacon_reason(75.0, 45.0, t5), None);
530
531 let t16 = t0 + Duration::from_secs(16);
533 assert_eq!(sb.beacon_reason(75.0, 45.0, t16), Some(BeaconReason::Turn));
534 }
535}