aprs/
smart_beaconing.rs

1//! `SmartBeaconing` algorithm for adaptive APRS beacon timing.
2//!
3//! Implements the `HamHUD` `SmartBeaconing` algorithm v2.1 by Tony Arnerich
4//! (KD7TA) and Jason Townsend (KD7TA), which adjusts beacon interval based
5//! on speed and course changes:
6//! - Stopped or slow: beacon every `slow_rate` seconds
7//! - Fast: beacon every `fast_rate` seconds (linearly interpolated)
8//! - Course change: immediate beacon if heading changed more than the
9//!   **speed-dependent** turn threshold, computed as:
10//!
11//!   ```text
12//!   turn_threshold = turn_min + (turn_slope * 10) / speed_kmh
13//!   ```
14//!
15//! This makes slow-moving stations less likely to emit turn-triggered
16//! beacons from small steering inputs, while fast-moving stations beacon
17//! on relatively small heading changes.
18//!
19//! Per Operating Tips §14 and User Manual Chapter 14, the TH-D75 exposes
20//! eight parameters via Menu 540-547:
21//!
22//! | Menu | Name | Default | Our field |
23//! |-----:|------|--------:|-----------|
24//! | 540 | L Spd        | 5 km/h   | `low_speed_kmh` |
25//! | 541 | H Spd        | 70 km/h  | `high_speed_kmh` |
26//! | 542 | L Rate       | 30 min   | `slow_rate_secs` |
27//! | 543 | H Rate       | 180 s    | `fast_rate_secs` |
28//! | 544 | Turn Slope   | 26       | `turn_slope` |
29//! | 545 | Turn Thresh  | 28°      | `turn_min_deg` |
30//! | 546 | Turn Time    | 30 s     | `turn_time_secs` |
31//!
32//! # Time handling
33//!
34//! Per the crate-level convention, this module is sans-io and never calls
35//! `std::time::Instant::now()` internally. Every stateful method accepts
36//! a `now: Instant` parameter; callers (typically the tokio shell) read
37//! the wall clock once per iteration and thread it down.
38
39use std::time::{Duration, Instant};
40
41/// Configuration for the `SmartBeaconing` algorithm.
42///
43/// Matches the TH-D75 Menu 540-547 settings and the `HamHUD` `SmartBeaconing`
44/// v2.1 parameter set.
45#[derive(Debug, Clone, PartialEq)]
46pub struct SmartBeaconingConfig {
47    /// Speed threshold below which `slow_rate` is used (km/h). Default: 5.
48    /// Corresponds to TH-D75 Menu 540 (L Spd).
49    pub low_speed_kmh: f64,
50    /// Speed at/above which `fast_rate` is used (km/h). Default: 70.
51    /// Corresponds to TH-D75 Menu 541 (H Spd).
52    pub high_speed_kmh: f64,
53    /// Beacon interval when stopped/slow (seconds). Default: 1800 (30 min).
54    /// Corresponds to TH-D75 Menu 542 (L Rate).
55    pub slow_rate_secs: u32,
56    /// Beacon interval at high speed (seconds). Default: 180 (3 min).
57    /// Corresponds to TH-D75 Menu 543 (H Rate).
58    pub fast_rate_secs: u32,
59    /// Turn slope scalar used in the speed-dependent turn threshold
60    /// formula `turn_min + (turn_slope * 10) / speed_kmh`. Default: 26.
61    /// Corresponds to TH-D75 Menu 544 (Turn Slope).
62    pub turn_slope: u16,
63    /// Minimum heading change for a turn beacon, in degrees. Applied as
64    /// the `turn_min` term in the threshold formula. Default: 28.
65    /// Corresponds to TH-D75 Menu 545 (Turn Thresh).
66    pub turn_min_deg: f64,
67    /// Minimum time between turn-triggered beacons (seconds). Default: 15.
68    /// Corresponds to TH-D75 Menu 546 (Turn Time).
69    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/// Reason a `SmartBeacon` was triggered at a given moment.
87///
88/// Returned by [`SmartBeaconing::beacon_reason`]. Useful for logging or
89/// UI display — `SmartBeaconing` has three distinct trigger conditions,
90/// and users often want to know which one fired.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum BeaconReason {
93    /// First beacon of the session — nothing sent yet.
94    First,
95    /// Time-based interval elapsed since the previous beacon.
96    TimeExpired,
97    /// Heading change exceeded the (speed-dependent) turn threshold.
98    Turn,
99}
100
101/// `SmartBeaconing` runtime state.
102///
103/// The algorithm starts in [`BeaconState::Uninitialized`] and transitions
104/// to [`BeaconState::Running`] the first time a beacon is recorded via
105/// [`SmartBeaconing::beacon_sent_with`]. The state holds the last
106/// beacon's course and speed so subsequent turn-threshold checks have
107/// the reference data they need.
108#[derive(Debug, Clone, PartialEq)]
109pub enum BeaconState {
110    /// No beacon has been sent yet — first call to `should_beacon` /
111    /// `beacon_reason` will return `Some(BeaconReason::First)`.
112    Uninitialized,
113    /// At least one beacon has been sent. Carries the timestamp and
114    /// the (course, speed) recorded at that beacon.
115    Running {
116        /// When the last beacon was transmitted.
117        last_beacon_time: Instant,
118        /// Course in degrees at the last beacon, or `None` if the
119        /// caller used [`SmartBeaconing::beacon_sent`] without
120        /// supplying one.
121        last_course: Option<f64>,
122        /// Speed in km/h at the last beacon, or `None` if unknown.
123        last_speed: Option<f64>,
124    },
125}
126
127/// `SmartBeaconing` algorithm for adaptive APRS position beacon timing.
128///
129/// Adjusts beacon interval based on speed and course changes:
130/// - Stopped or slow: beacon every `slow_rate` seconds
131/// - Fast: beacon every `fast_rate` seconds
132/// - Course change: immediate beacon if heading changed > `turn_threshold`
133///
134/// Per Operating Tips §14: `SmartBeaconing` settings are Menu 540-547.
135#[derive(Debug)]
136pub struct SmartBeaconing {
137    /// Algorithm parameters.
138    config: SmartBeaconingConfig,
139    /// Runtime state machine.
140    state: BeaconState,
141}
142
143impl SmartBeaconing {
144    /// Create a new `SmartBeaconing` instance with the given configuration.
145    #[must_use]
146    pub const fn new(config: SmartBeaconingConfig) -> Self {
147        Self {
148            config,
149            state: BeaconState::Uninitialized,
150        }
151    }
152
153    /// Return a snapshot of the current state machine.
154    #[must_use]
155    pub const fn state(&self) -> &BeaconState {
156        &self.state
157    }
158
159    /// Check if a beacon should be sent now, given current speed and course.
160    ///
161    /// `now` is the current wall-clock time, injected by the caller so
162    /// this module remains sans-io.
163    #[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    /// Classify why (if at all) a beacon is due at the current speed and
169    /// course. Returns `None` if no beacon should be sent yet, otherwise
170    /// a [`BeaconReason`] identifying which condition tripped.
171    ///
172    /// `now` is the current wall-clock time, injected by the caller so
173    /// this module remains sans-io.
174    #[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        // First beacon: always send.
182        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    /// Compute the current turn threshold (in degrees) for the given speed
214    /// using the `HamHUD` formula:
215    ///
216    /// ```text
217    /// turn_threshold = turn_min + (turn_slope * 10) / speed_kmh
218    /// ```
219    #[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    /// Mark that a beacon was just sent. Updates the internal state
228    /// with the supplied time, preserving any previously-recorded course
229    /// and speed.
230    ///
231    /// `now` is the wall-clock time at which the beacon was sent.
232    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    /// Mark that a beacon was just sent with the given speed and course.
249    ///
250    /// `now` is the wall-clock time at which the beacon was sent.
251    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    /// Get the current recommended interval in seconds.
260    ///
261    /// Based on the last known speed, or `slow_rate` if no speed data.
262    #[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    /// Compute the beacon interval for a given speed.
274    ///
275    /// Linear interpolation between `slow_rate` at `low_speed` and
276    /// `fast_rate` at `high_speed`.
277    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        // Linear interpolation.
286        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
301/// Compute the absolute heading change between two courses (0-360),
302/// accounting for the wraparound at 360/0.
303fn 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        // Stopped / slow: threshold is infinity, no turn beacon possible.
333        assert!(sb.current_turn_threshold(0.0).is_infinite());
334        assert!(sb.current_turn_threshold(5.0).is_infinite());
335
336        // At 60 km/h: 28 + (26 * 10) / 60 ≈ 32.333
337        let t60 = sb.current_turn_threshold(60.0);
338        assert!((t60 - (28.0 + 260.0 / 60.0)).abs() < 1e-9);
339
340        // At 10 km/h: 28 + 26 = 54 degrees (need big turn to beacon).
341        let t10 = sb.current_turn_threshold(10.0);
342        assert!((t10 - 54.0).abs() < 1e-9);
343
344        // At 120 km/h (high speed): 28 + (260 / 120) ≈ 30.167
345        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        // Midpoint speed = (5 + 70) / 2 = 37.5 km/h
374        // Midpoint rate = (1800 + 180) / 2 = 990 secs
375        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        // First beacon is always true.
427        assert!(sb.should_beacon(0.0, 0.0, t0));
428        sb.beacon_sent_with(0.0, 0.0, t0);
429
430        // Immediately after, should not beacon (interval not elapsed).
431        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        // 350 to 10 = 20 degrees, not 340.
443        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, // No minimum turn time for test simplicity.
458            ..SmartBeaconingConfig::default()
459        });
460
461        // Send initial beacon heading north.
462        assert!(sb.should_beacon(3.0, 0.0, t0));
463        sb.beacon_sent_with(3.0, 0.0, t0);
464
465        // Large heading change but at low speed — should NOT trigger.
466        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, // No minimum turn time for test simplicity.
474            ..SmartBeaconingConfig::default()
475        });
476
477        // Send initial beacon heading north at high speed.
478        assert!(sb.should_beacon(75.0, 0.0, t0));
479        sb.beacon_sent_with(75.0, 0.0, t0);
480
481        // Course change above turn_threshold (28 deg) at high speed
482        // should trigger an immediate beacon.
483        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        // Send initial beacon heading north at high speed.
495        assert!(sb.should_beacon(75.0, 0.0, t0));
496        sb.beacon_sent_with(75.0, 0.0, t0);
497
498        // Course change below turn_threshold (28 deg) should NOT trigger.
499        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        // At low speed, slow_rate is 1800 secs. Advance past interval.
510        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        // Default turn_time_secs is 15 — less than 15 secs and the turn
522        // beacon is suppressed even when the angle threshold is met.
523        assert!(sb.should_beacon(75.0, 0.0, t0));
524        sb.beacon_sent_with(75.0, 0.0, t0);
525
526        // 5 seconds after the beacon, a 45-degree turn should NOT fire —
527        // the turn_time_secs gate is still closed.
528        let t5 = t0 + Duration::from_secs(5);
529        assert_eq!(sb.beacon_reason(75.0, 45.0, t5), None);
530
531        // 16 seconds after, the gate is open and the turn beacon fires.
532        let t16 = t0 + Duration::from_secs(16);
533        assert_eq!(sb.beacon_reason(75.0, 45.0, t16), Some(BeaconReason::Turn));
534    }
535}