From f94113101117869a507d87e130d64796719be5af Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 16:04:52 +0800 Subject: [PATCH] network: add handover policy with hysteresis and dwell time --- Cargo.lock | 4 + crates/network/apsis-handover/Cargo.toml | 2 + crates/network/apsis-handover/src/decision.rs | 264 ++++++++++++ crates/network/apsis-handover/src/error.rs | 70 ++++ crates/network/apsis-handover/src/lib.rs | 57 +++ crates/network/apsis-handover/src/policy.rs | 287 +++++++++++++ .../network/apsis-handover/tests/decision.rs | 390 ++++++++++++++++++ 7 files changed, 1074 insertions(+) create mode 100644 crates/network/apsis-handover/src/decision.rs create mode 100644 crates/network/apsis-handover/src/error.rs create mode 100644 crates/network/apsis-handover/src/policy.rs create mode 100644 crates/network/apsis-handover/tests/decision.rs diff --git a/Cargo.lock b/Cargo.lock index 4e25778..3c0b777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,10 @@ dependencies = [ [[package]] name = "apsis-handover" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-math", +] [[package]] name = "apsis-interference" diff --git a/crates/network/apsis-handover/Cargo.toml b/crates/network/apsis-handover/Cargo.toml index 2712599..6cb6d23 100644 --- a/crates/network/apsis-handover/Cargo.toml +++ b/crates/network/apsis-handover/Cargo.toml @@ -11,3 +11,5 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-math.workspace = true diff --git a/crates/network/apsis-handover/src/decision.rs b/crates/network/apsis-handover/src/decision.rs new file mode 100644 index 0000000..7abb380 --- /dev/null +++ b/crates/network/apsis-handover/src/decision.rs @@ -0,0 +1,264 @@ +//! The handover decision and the rules that produce it. + +use crate::error::{HandoverError, HandoverErrorKind}; +use crate::policy::HandoverPolicy; + +/// A satellite that could serve the terminal. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Candidate { + /// The satellite identifier. + pub satellite: usize, + /// The quality of the link to this satellite. + /// + /// The meaning is the caller's: an elevation angle, a received power, a + /// carrier to noise ratio, or any other measure of how good the link is. + /// The policy thresholds are expressed in the same units. The value must + /// be finite. + pub quality: f64, + /// The time until this satellite stops being available, in seconds. + /// + /// This is the remaining time in the access window. It lets the policy + /// prefer a satellite that will last, and it is reported so a caller can + /// plan the next decision. + pub remaining_seconds: f64, +} + +impl Candidate { + /// Creates a candidate. + pub const fn new(satellite: usize, quality: f64, remaining_seconds: f64) -> Self { + Self { + satellite, + quality, + remaining_seconds, + } + } + + /// Returns `true` when the candidate is usable. + pub fn is_valid(&self) -> bool { + self.quality.is_finite() + && self.remaining_seconds.is_finite() + && self.remaining_seconds >= 0.0 + } +} + +/// What the terminal should do at one instant. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum HandoverDecision { + /// Keep the current server. + Stay, + /// Switch from one satellite to another. + HandOver { + /// The satellite being left. + from: usize, + /// The satellite being taken up. + to: usize, + /// The quality of the satellite being left. + from_quality: f64, + /// The quality of the satellite being taken up. + to_quality: f64, + }, + /// Establish service for the first time. + Acquire { + /// The satellite to use. + satellite: usize, + }, + /// Give up service, because no candidate meets the minimum quality. + Drop, +} + +impl HandoverDecision { + /// Returns `true` when the decision changes the serving satellite. + pub const fn is_change(self) -> bool { + matches!(self, Self::HandOver { .. } | Self::Acquire { .. }) + } + + /// Returns the satellite the terminal serves after the decision. + /// + /// Returns `None` for [`HandoverDecision::Drop`] and for + /// [`HandoverDecision::Stay`] with no current server. + pub const fn resulting_satellite(self) -> Option { + match self { + Self::HandOver { to, .. } => Some(to), + Self::Acquire { satellite } => Some(satellite), + Self::Stay => None, + Self::Drop => None, + } + } + + /// Returns a short name for the decision. + pub const fn name(self) -> &'static str { + match self { + Self::Stay => "stay", + Self::HandOver { .. } => "hand over", + Self::Acquire { .. } => "acquire", + Self::Drop => "drop", + } + } +} + +/// Validates a candidate list against the policy. +fn validate(policy: &HandoverPolicy, candidates: &[Candidate]) -> Result<(), HandoverError> { + policy.validate()?; + if candidates.is_empty() { + return Err(HandoverError::new(HandoverErrorKind::NoCandidates)); + } + for (index, candidate) in candidates.iter().enumerate() { + if !candidate.is_valid() { + return Err( + HandoverError::new(HandoverErrorKind::InvalidQuality).with_detail(format!( + "candidate {index} has quality {} and {} s remaining", + candidate.quality, candidate.remaining_seconds + )), + ); + } + // A duplicate would make the choice between two entries ambiguous + // and would let a caller weight a satellite by listing it twice. + if candidates[..index] + .iter() + .any(|earlier| earlier.satellite == candidate.satellite) + { + return Err( + HandoverError::new(HandoverErrorKind::DuplicateSatellite).with_detail(format!( + "satellite {} appears at index {index}", + candidate.satellite + )), + ); + } + } + Ok(()) +} + +/// Returns the decision implied by the policy, without a dwell time. +/// +/// This is the form to use when the terminal makes an independent decision +/// at each instant and the caller tracks the history itself. Use +/// [`decide_with_history`] when the dwell time must be enforced. +/// +/// The rules, in the order they are applied: +/// +/// 1. With no current server, acquire the best candidate, or drop when none +/// meets the minimum quality. +/// 2. When the current server is not among the candidates, it has become +/// unavailable, so switch to the best candidate or drop when none meets +/// the minimum quality. +/// 3. When the current server is below the minimum quality, abandon it for +/// the best candidate, or drop when none meets the minimum quality. +/// 4. Otherwise stay, unless a candidate exceeds the current server's +/// quality by more than the margin. +/// +/// Returns an error when the candidates or the policy are not usable. +pub fn decide( + policy: &HandoverPolicy, + current: Option, + candidates: &[Candidate], +) -> Result { + decide_internal(policy, current, None, candidates, 0.0) +} + +/// Returns the decision implied by the policy, enforcing the dwell time. +/// +/// This adds one rule to those of [`decide`]. When the time since the last +/// handover is less than the dwell time, a handover that would otherwise +/// happen for a quality gain is refused and the terminal stays. A handover +/// that is forced, because the current server has become unavailable or has +/// fallen below the minimum quality, is allowed regardless: refusing it +/// would drop the link, which is worse than the churn the dwell time +/// prevents. +/// +/// Returns an error when the candidates or the policy are not usable. +pub fn decide_with_history( + policy: &HandoverPolicy, + current: Option, + last_handover_seconds: Option, + candidates: &[Candidate], + seconds: f64, +) -> Result { + decide_internal(policy, current, last_handover_seconds, candidates, seconds) +} + +/// Applies the rules with an optional dwell time. +fn decide_internal( + policy: &HandoverPolicy, + current: Option, + last_handover_seconds: Option, + candidates: &[Candidate], + seconds: f64, +) -> Result { + validate(policy, candidates)?; + + let best = policy + .best(candidates) + .expect("the candidate list is not empty"); + let current_candidate = current.and_then(|satellite| { + candidates + .iter() + .find(|candidate| candidate.satellite == satellite) + }); + + // Rule 1: no current server, so acquire the best that meets the + // minimum, or drop. + let Some(current_satellite) = current else { + return Ok(if best.quality >= policy.minimum_quality { + HandoverDecision::Acquire { + satellite: best.satellite, + } + } else { + HandoverDecision::Drop + }); + }; + + // Rule 2: the current server is no longer a candidate, so it has become + // unavailable and the terminal must move. + let Some(current_candidate) = current_candidate else { + return Ok(if best.quality >= policy.minimum_quality { + HandoverDecision::HandOver { + from: current_satellite, + to: best.satellite, + from_quality: 0.0, + to_quality: best.quality, + } + } else { + HandoverDecision::Drop + }); + }; + + // Rule 3: the current server has fallen below the minimum, so abandon + // it. This is a forced handover and the dwell time does not apply. + if current_candidate.quality < policy.minimum_quality { + if best.satellite == current_satellite { + // The best candidate is the current server, which is too weak, + // so there is nothing to move to. + return Ok(HandoverDecision::Drop); + } + return Ok(if best.quality >= policy.minimum_quality { + HandoverDecision::HandOver { + from: current_satellite, + to: best.satellite, + from_quality: current_candidate.quality, + to_quality: best.quality, + } + } else { + HandoverDecision::Drop + }); + } + + // Rule 4: consider a handover for a quality gain, which the dwell time + // may refuse. + if best.satellite != current_satellite + && best.quality > current_candidate.quality + policy.handover_margin + { + if let Some(last) = last_handover_seconds { + if seconds - last < policy.minimum_dwell_seconds { + return Ok(HandoverDecision::Stay); + } + } + return Ok(HandoverDecision::HandOver { + from: current_satellite, + to: best.satellite, + from_quality: current_candidate.quality, + to_quality: best.quality, + }); + } + + Ok(HandoverDecision::Stay) +} diff --git a/crates/network/apsis-handover/src/error.rs b/crates/network/apsis-handover/src/error.rs new file mode 100644 index 0000000..138ea8c --- /dev/null +++ b/crates/network/apsis-handover/src/error.rs @@ -0,0 +1,70 @@ +//! Errors reported while making a handover decision. + +use core::fmt; + +/// The category of a handover error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum HandoverErrorKind { + /// The candidate list is empty. + NoCandidates, + /// The same satellite appears more than once in the candidate list. + DuplicateSatellite, + /// A quality value is outside its range or not finite. + InvalidQuality, + /// A policy parameter is not usable. + InvalidPolicy, +} + +impl fmt::Display for HandoverErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::NoCandidates => "there are no candidates to choose from", + Self::DuplicateSatellite => "a satellite appears more than once", + Self::InvalidQuality => "a quality value is not usable", + Self::InvalidPolicy => "a policy parameter is not usable", + }; + f.write_str(text) + } +} + +/// A failure while making a handover decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandoverError { + kind: HandoverErrorKind, + detail: Option, +} + +impl HandoverError { + /// Creates an error with the given category. + pub fn new(kind: HandoverErrorKind) -> Self { + Self { kind, detail: None } + } + + /// Attaches a description of the values that caused the failure. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Returns the category of this failure. + pub fn kind(&self) -> HandoverErrorKind { + self.kind + } + + /// Returns the detail text attached to this error, if any. + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } +} + +impl fmt::Display for HandoverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.detail { + Some(detail) => write!(f, "{}: {detail}", self.kind), + None => write!(f, "{}", self.kind), + } + } +} + +impl core::error::Error for HandoverError {} diff --git a/crates/network/apsis-handover/src/lib.rs b/crates/network/apsis-handover/src/lib.rs index dfaefbe..7683502 100644 --- a/crates/network/apsis-handover/src/lib.rs +++ b/crates/network/apsis-handover/src/lib.rs @@ -1 +1,58 @@ //! Handover decision logic for mobile terminals. +//! +//! A terminal that can be served by more than one satellite must choose one, +//! and it must change its choice when the current server becomes unsuitable. +//! This crate makes that decision. It does not compute access windows, which +//! belong to the access layer; it takes a list of candidate satellites with +//! their quality and decides whether to hand over. +//! +//! Two effects oppose each other and both must be in the policy: +//! +//! - **Handing over too late** loses the link. If the current server drops +//! below the terminal's minimum, the link fails before the switch happens. +//! - **Handing over too often** wastes capacity and time. Each handover costs +//! signalling and interrupts the data flow, so a terminal that switches on +//! every small fluctuation performs poorly even though each individual +//! choice looked better. +//! +//! The policy therefore has three parts: a threshold at which the current +//! server is considered unusable, a margin by which a candidate must beat +//! the current server before it is worth switching to, and a minimum time +//! between handovers. The margin is what suppresses oscillation, and it is +//! the hysteresis of the decision. +//! +//! Source: the threshold, margin, and dwell time structure is the one used +//! for handover in cellular and satellite networks, as described for example +//! in ITU-R Recommendation M.1184 on the technical and operational +//! characteristics of mobile satellite systems, and in the general treatment +//! of handover hysteresis in mobile radio. +//! +//! ``` +//! use apsis_handover::{Candidate, HandoverPolicy, HandoverDecision, decide}; +//! +//! let policy = HandoverPolicy { +//! minimum_quality: 0.2, +//! handover_margin: 0.1, +//! minimum_dwell_seconds: 5.0, +//! }; +//! +//! // The current server is fine and the alternative is only slightly +//! // better, so nothing happens. +//! let candidates = [ +//! Candidate::new(1, 0.8, 100.0), +//! Candidate::new(2, 0.85, 90.0), +//! ]; +//! let decision = decide(&policy, Some(1), &candidates).expect("valid input"); +//! assert_eq!(decision, HandoverDecision::Stay); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod decision; +mod error; +mod policy; + +pub use decision::{Candidate, HandoverDecision, decide, decide_with_history}; +pub use error::{HandoverError, HandoverErrorKind}; +pub use policy::{HandoverPolicy, HandoverRecord, HandoverTracker}; diff --git a/crates/network/apsis-handover/src/policy.rs b/crates/network/apsis-handover/src/policy.rs new file mode 100644 index 0000000..532e5ff --- /dev/null +++ b/crates/network/apsis-handover/src/policy.rs @@ -0,0 +1,287 @@ +//! The handover policy, its record of past decisions, and the tracker. + +use crate::decision::Candidate; +use crate::error::{HandoverError, HandoverErrorKind}; + +/// The parameters of a handover policy. +/// +/// The three parameters work together. The minimum quality decides when the +/// current server must be abandoned, the margin decides when a better +/// candidate is worth the switch, and the dwell time limits how often +/// switches may happen at all. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HandoverPolicy { + /// The quality below which the current server is abandoned regardless of + /// what else is available. + /// + /// Quality is a number the caller defines, and the policy is expressed + /// in the same units. A value of 0.2 on a normalised scale means the + /// server must stay above a fifth of full quality. + pub minimum_quality: f64, + /// The amount by which a candidate must exceed the current server's + /// quality before a handover is worthwhile. + /// + /// This is the hysteresis that suppresses oscillation between two + /// satellites of similar quality. + pub handover_margin: f64, + /// The minimum time between handovers, in seconds. + /// + /// A handover is refused when the previous one was more recent than + /// this, unless the current server has fallen below the minimum quality, + /// in which case the link would fail anyway. + pub minimum_dwell_seconds: f64, +} + +impl Default for HandoverPolicy { + /// Returns a policy with a minimum quality of 0.2, a margin of 0.1, and + /// a dwell time of five seconds. + fn default() -> Self { + Self { + minimum_quality: 0.2, + handover_margin: 0.1, + minimum_dwell_seconds: 5.0, + } + } +} + +impl HandoverPolicy { + /// Creates a policy from its three parameters. + pub const fn new( + minimum_quality: f64, + handover_margin: f64, + minimum_dwell_seconds: f64, + ) -> Self { + Self { + minimum_quality, + handover_margin, + minimum_dwell_seconds, + } + } + + /// Returns `true` when the policy parameters are usable. + /// + /// The margin must not be negative, since a negative margin would make a + /// handover worth doing to a worse candidate, and the dwell time must + /// not be negative. The minimum quality may be anything finite, because + /// a caller may place it outside the range of the quality scale to + /// disable the forced handover. + pub fn is_valid(&self) -> bool { + self.minimum_quality.is_finite() + && self.handover_margin.is_finite() + && self.handover_margin >= 0.0 + && self.minimum_dwell_seconds.is_finite() + && self.minimum_dwell_seconds >= 0.0 + } + + /// Validates the policy, returning the reason when it is not usable. + pub fn validate(&self) -> Result<(), HandoverError> { + if !self.is_valid() { + return Err( + HandoverError::new(HandoverErrorKind::InvalidPolicy).with_detail(format!( + "minimum quality {}, margin {}, dwell {}", + self.minimum_quality, self.handover_margin, self.minimum_dwell_seconds + )), + ); + } + Ok(()) + } + + /// Returns the best candidate under the policy. + /// + /// The best candidate is the one with the greatest quality, with ties + /// broken by the lower satellite identifier so that the result is + /// deterministic. + pub fn best<'a>(&self, candidates: &'a [Candidate]) -> Option<&'a Candidate> { + candidates.iter().max_by(|first, second| { + first + .quality + .partial_cmp(&second.quality) + .unwrap_or(core::cmp::Ordering::Equal) + // Reversed, so that among equal qualities the lower + // identifier wins in the maximum. + .then_with(|| second.satellite.cmp(&first.satellite)) + }) + } +} + +/// A record of one handover that has occurred. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HandoverRecord { + /// The satellite handed over from. + pub from: usize, + /// The satellite handed over to. + pub to: usize, + /// The time of the handover, in seconds. + pub seconds: f64, + /// The quality of the satellite handed over from. + pub from_quality: f64, + /// The quality of the satellite handed over to. + pub to_quality: f64, +} + +impl HandoverRecord { + /// Creates a record. + pub const fn new( + from: usize, + to: usize, + seconds: f64, + from_quality: f64, + to_quality: f64, + ) -> Self { + Self { + from, + to, + seconds, + from_quality, + to_quality, + } + } + + /// Returns the gain in quality that the handover achieved. + pub fn quality_gain(&self) -> f64 { + self.to_quality - self.from_quality + } +} + +/// Tracks the serving satellite and the history of handovers. +/// +/// The tracker owns the policy and the decision history, so a caller only +/// has to supply the candidates at each step. It is the form a simulation or +/// a control loop uses, where the decision made at one step affects the one +/// at the next through the dwell time. +#[derive(Debug, Clone, PartialEq)] +pub struct HandoverTracker { + policy: HandoverPolicy, + serving: Option, + last_handover_seconds: Option, + history: Vec, +} + +impl HandoverTracker { + /// Creates a tracker with no serving satellite. + pub fn new(policy: HandoverPolicy) -> Self { + Self { + policy, + serving: None, + last_handover_seconds: None, + history: Vec::new(), + } + } + + /// Creates a tracker that is already serving a satellite. + pub fn serving(policy: HandoverPolicy, satellite: usize) -> Self { + Self { + policy, + serving: Some(satellite), + last_handover_seconds: None, + history: Vec::new(), + } + } + + /// Returns the policy. + pub fn policy(&self) -> HandoverPolicy { + self.policy + } + + /// Returns the satellite currently being served, if any. + pub fn current(&self) -> Option { + self.serving + } + + /// Returns the time of the last handover, if there has been one. + pub fn last_handover_seconds(&self) -> Option { + self.last_handover_seconds + } + + /// Returns the handover history, in time order. + pub fn history(&self) -> &[HandoverRecord] { + &self.history + } + + /// Returns the number of handovers that have occurred. + pub fn handover_count(&self) -> usize { + self.history.len() + } + + /// Applies the policy to a set of candidates at a time. + /// + /// The tracker updates its state according to the decision and returns + /// that decision, so a caller can both react to it and rely on the + /// tracker having recorded it. + /// + /// Returns an error when the candidates or the policy are not usable. + pub fn step( + &mut self, + seconds: f64, + candidates: &[Candidate], + ) -> Result { + let decision = crate::decision::decide_with_history( + &self.policy, + self.serving, + self.last_handover_seconds, + candidates, + seconds, + )?; + match decision { + crate::decision::HandoverDecision::HandOver { + from, + to, + from_quality, + to_quality, + } => { + self.history.push(HandoverRecord::new( + from, + to, + seconds, + from_quality, + to_quality, + )); + self.serving = Some(to); + self.last_handover_seconds = Some(seconds); + } + crate::decision::HandoverDecision::Acquire { satellite } => { + self.serving = Some(satellite); + self.last_handover_seconds = Some(seconds); + } + crate::decision::HandoverDecision::Stay => {} + crate::decision::HandoverDecision::Drop => { + self.serving = None; + } + } + Ok(decision) + } + + /// Returns the mean time between handovers, in seconds. + /// + /// Returns `None` when fewer than two handovers have occurred, because a + /// single interval is not a mean. + pub fn mean_interval_seconds(&self) -> Option { + if self.history.len() < 2 { + return None; + } + let span = self.history.last()?.seconds - self.history.first()?.seconds; + Some(span / (self.history.len() - 1) as f64) + } + + /// Returns the number of handovers per unit time over the history. + /// + /// The rate is measured across the whole history, from the first + /// handover to the last. Returns `None` when there are fewer than two + /// handovers or when they all occur at the same instant. + pub fn handover_rate_per_second(&self) -> Option { + let interval = self.mean_interval_seconds()?; + if interval <= 0.0 { + return None; + } + Some(1.0 / interval) + } + + /// Returns the total quality gained by handing over. + /// + /// The value is the sum over the history, which can be negative when a + /// handover was forced by a falling quality rather than chosen for a + /// gain. + pub fn total_quality_gain(&self) -> f64 { + self.history.iter().map(HandoverRecord::quality_gain).sum() + } +} diff --git a/crates/network/apsis-handover/tests/decision.rs b/crates/network/apsis-handover/tests/decision.rs new file mode 100644 index 0000000..aa84467 --- /dev/null +++ b/crates/network/apsis-handover/tests/decision.rs @@ -0,0 +1,390 @@ +//! Tests for handover decisions. + +use apsis_handover::{ + Candidate, HandoverDecision, HandoverErrorKind, HandoverPolicy, HandoverRecord, + HandoverTracker, decide, decide_with_history, +}; + +/// The default policy: minimum quality 0.2, margin 0.1, dwell 5 s. +fn policy() -> HandoverPolicy { + HandoverPolicy::default() +} + +/// A candidate with the given identifier and quality. +fn candidate(satellite: usize, quality: f64) -> Candidate { + Candidate::new(satellite, quality, 100.0) +} + +#[test] +fn the_default_policy_has_the_documented_values() { + let policy = policy(); + assert_eq!(policy.minimum_quality, 0.2); + assert_eq!(policy.handover_margin, 0.1); + assert_eq!(policy.minimum_dwell_seconds, 5.0); + assert!(policy.is_valid()); +} + +#[test] +fn a_policy_is_validated() { + assert!(HandoverPolicy::new(0.2, 0.0, 0.0).is_valid()); + // A negative margin would make a handover worthwhile to a worse + // candidate. + assert!(!HandoverPolicy::new(0.2, -0.1, 5.0).is_valid()); + assert!(!HandoverPolicy::new(0.2, 0.1, -1.0).is_valid()); + assert!(!HandoverPolicy::new(f64::NAN, 0.1, 5.0).is_valid()); + assert_eq!( + HandoverPolicy::new(0.2, -0.1, 5.0) + .validate() + .expect_err("negative margin") + .kind(), + HandoverErrorKind::InvalidPolicy + ); +} + +#[test] +fn the_best_candidate_has_the_greatest_quality() { + let candidates = [candidate(1, 0.3), candidate(2, 0.9), candidate(3, 0.5)]; + let best = policy().best(&candidates).expect("non-empty"); + assert_eq!(best.satellite, 2); +} + +#[test] +fn ties_are_broken_by_the_lower_identifier() { + let candidates = [candidate(5, 0.7), candidate(2, 0.7), candidate(9, 0.7)]; + let best = policy().best(&candidates).expect("non-empty"); + assert_eq!(best.satellite, 2); +} + +#[test] +fn a_terminal_with_no_server_acquires_the_best_candidate() { + let candidates = [candidate(1, 0.5), candidate(2, 0.8)]; + let decision = decide(&policy(), None, &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Acquire { satellite: 2 }); + assert!(decision.is_change()); + assert_eq!(decision.resulting_satellite(), Some(2)); +} + +#[test] +fn a_terminal_with_no_server_drops_when_nothing_is_good_enough() { + let candidates = [candidate(1, 0.1), candidate(2, 0.15)]; + let decision = decide(&policy(), None, &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Drop); + assert!(!decision.is_change()); + assert_eq!(decision.resulting_satellite(), None); +} + +#[test] +fn a_good_current_server_is_kept() { + // The alternative is better, but by less than the margin. + let candidates = [candidate(1, 0.80), candidate(2, 0.85)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Stay); +} + +#[test] +fn a_clearly_better_candidate_causes_a_handover() { + // The alternative beats the current server by more than the margin. + let candidates = [candidate(1, 0.50), candidate(2, 0.80)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!( + decision, + HandoverDecision::HandOver { + from: 1, + to: 2, + from_quality: 0.50, + to_quality: 0.80, + } + ); + assert_eq!(decision.resulting_satellite(), Some(2)); +} + +#[test] +fn the_margin_is_the_threshold_for_a_handover() { + let policy = policy(); + // Exactly at the margin is not enough, because the comparison is + // strict. + let at_margin = [candidate(1, 0.5), candidate(2, 0.6)]; + assert_eq!( + decide(&policy, Some(1), &at_margin).expect("valid"), + HandoverDecision::Stay + ); + + // Just above the margin is enough. + let above = [candidate(1, 0.5), candidate(2, 0.600_1)]; + assert!(decide(&policy, Some(1), &above).expect("valid").is_change()); +} + +#[test] +fn a_zero_margin_hands_over_to_any_better_candidate() { + let policy = HandoverPolicy::new(0.2, 0.0, 0.0); + let candidates = [candidate(1, 0.5), candidate(2, 0.500_1)]; + assert!( + decide(&policy, Some(1), &candidates) + .expect("valid") + .is_change() + ); +} + +#[test] +fn a_current_server_below_the_minimum_is_abandoned() { + let candidates = [candidate(1, 0.1), candidate(2, 0.3)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!( + decision, + HandoverDecision::HandOver { + from: 1, + to: 2, + from_quality: 0.1, + to_quality: 0.3, + } + ); +} + +#[test] +fn a_weak_terminal_drops_when_no_candidate_meets_the_minimum() { + let candidates = [candidate(1, 0.1), candidate(2, 0.15)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Drop); +} + +#[test] +fn a_current_server_that_disappears_causes_a_handover() { + // The current server is no longer in the candidate list, which means + // its access window has ended. + let candidates = [candidate(2, 0.6), candidate(3, 0.4)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!( + decision, + HandoverDecision::HandOver { + from: 1, + to: 2, + from_quality: 0.0, + to_quality: 0.6, + } + ); +} + +#[test] +fn a_current_server_that_disappears_and_nothing_replaces_it_drops() { + let candidates = [candidate(2, 0.1)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Drop); +} + +#[test] +fn the_only_candidate_being_the_current_server_keeps_it() { + let candidates = [candidate(1, 0.5)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Stay); +} + +#[test] +fn a_weak_current_server_with_no_alternative_drops() { + // The current server is below the minimum and is the only candidate, so + // there is nowhere to move and the service is lost. + let candidates = [candidate(1, 0.1)]; + let decision = decide(&policy(), Some(1), &candidates).expect("valid"); + assert_eq!(decision, HandoverDecision::Drop); +} + +#[test] +fn the_dwell_time_refuses_an_opportunistic_handover() { + let candidates = [candidate(1, 0.5), candidate(2, 0.9)]; + // The last handover was one second ago, which is inside the five second + // dwell time, so the better candidate is not taken. + let decision = + decide_with_history(&policy(), Some(1), Some(9.0), &candidates, 10.0).expect("valid"); + assert_eq!(decision, HandoverDecision::Stay); + + // After the dwell time the handover proceeds. + let decision = + decide_with_history(&policy(), Some(1), Some(1.0), &candidates, 10.0).expect("valid"); + assert!(decision.is_change()); +} + +#[test] +fn the_dwell_time_does_not_block_a_forced_handover() { + // The current server has fallen below the minimum, so the handover must + // happen even though the dwell time has not elapsed: refusing it would + // drop the link. + let candidates = [candidate(1, 0.1), candidate(2, 0.9)]; + let decision = + decide_with_history(&policy(), Some(1), Some(9.9), &candidates, 10.0).expect("valid"); + assert!(decision.is_change()); + assert_eq!(decision.resulting_satellite(), Some(2)); +} + +#[test] +fn the_dwell_time_does_not_block_an_acquisition() { + let candidates = [candidate(1, 0.9)]; + let decision = + decide_with_history(&policy(), None, Some(9.9), &candidates, 10.0).expect("valid"); + assert_eq!(decision, HandoverDecision::Acquire { satellite: 1 }); +} + +#[test] +fn the_dwell_time_does_not_block_a_handover_for_a_lost_server() { + let candidates = [candidate(2, 0.9)]; + let decision = + decide_with_history(&policy(), Some(1), Some(9.9), &candidates, 10.0).expect("valid"); + assert!(decision.is_change()); +} + +#[test] +fn a_zero_dwell_time_always_permits_a_handover() { + let policy = HandoverPolicy::new(0.2, 0.1, 0.0); + let candidates = [candidate(1, 0.5), candidate(2, 0.9)]; + let decision = + decide_with_history(&policy, Some(1), Some(10.0), &candidates, 10.0).expect("valid"); + assert!(decision.is_change()); +} + +#[test] +fn invalid_candidates_are_rejected() { + let empty: [Candidate; 0] = []; + assert_eq!( + decide(&policy(), None, &empty) + .expect_err("no candidates") + .kind(), + HandoverErrorKind::NoCandidates + ); + + let duplicated = [candidate(1, 0.5), candidate(1, 0.6)]; + assert_eq!( + decide(&policy(), None, &duplicated) + .expect_err("a duplicate") + .kind(), + HandoverErrorKind::DuplicateSatellite + ); + + let not_finite = [Candidate::new(1, f64::NAN, 100.0)]; + assert_eq!( + decide(&policy(), None, ¬_finite) + .expect_err("not finite") + .kind(), + HandoverErrorKind::InvalidQuality + ); + + let negative_remaining = [Candidate::new(1, 0.5, -1.0)]; + assert_eq!( + decide(&policy(), None, &negative_remaining) + .expect_err("negative remaining time") + .kind(), + HandoverErrorKind::InvalidQuality + ); +} + +#[test] +fn the_decision_names_are_stable() { + assert_eq!(HandoverDecision::Stay.name(), "stay"); + assert_eq!( + HandoverDecision::HandOver { + from: 1, + to: 2, + from_quality: 0.1, + to_quality: 0.2 + } + .name(), + "hand over" + ); + assert_eq!(HandoverDecision::Acquire { satellite: 1 }.name(), "acquire"); + assert_eq!(HandoverDecision::Drop.name(), "drop"); +} + +#[test] +fn a_tracker_acquires_and_then_hands_over() { + let mut tracker = HandoverTracker::new(policy()); + + // First step: acquire the best satellite. + let decision = tracker + .step(0.0, &[candidate(1, 0.5), candidate(2, 0.8)]) + .expect("valid"); + assert_eq!(decision, HandoverDecision::Acquire { satellite: 2 }); + assert_eq!(tracker.current(), Some(2)); + assert_eq!(tracker.handover_count(), 0); + + // The current server stays while it remains the best. + let decision = tracker.step(1.0, &[candidate(2, 0.8)]).expect("valid"); + assert_eq!(decision, HandoverDecision::Stay); + + // A much better candidate after the dwell time causes a handover. + let decision = tracker + .step(10.0, &[candidate(2, 0.3), candidate(3, 0.9)]) + .expect("valid"); + assert_eq!(decision.resulting_satellite(), Some(3)); + assert_eq!(tracker.current(), Some(3)); + assert_eq!(tracker.handover_count(), 1); + assert_eq!(tracker.last_handover_seconds(), Some(10.0)); +} + +#[test] +fn a_tracker_records_the_handover_history() { + let mut tracker = HandoverTracker::serving(policy(), 1); + // Force two handovers by dropping the current server below the minimum. + tracker + .step(0.0, &[candidate(1, 0.1), candidate(2, 0.9)]) + .expect("valid"); + tracker + .step(1.0, &[candidate(2, 0.1), candidate(3, 0.9)]) + .expect("valid"); + + assert_eq!(tracker.handover_count(), 2); + let history = tracker.history(); + assert_eq!(history[0].from, 1); + assert_eq!(history[0].to, 2); + assert_eq!(history[1].from, 2); + assert_eq!(history[1].to, 3); + // The quality gain is the difference across each handover. + assert!((history[0].quality_gain() - 0.8).abs() < 1e-12); + assert!((tracker.total_quality_gain() - 1.6).abs() < 1e-12); +} + +#[test] +fn a_tracker_reports_the_handover_rate() { + let mut tracker = HandoverTracker::serving(policy(), 1); + tracker + .step(0.0, &[candidate(1, 0.1), candidate(2, 0.9)]) + .expect("valid"); + tracker + .step(100.0, &[candidate(2, 0.1), candidate(3, 0.9)]) + .expect("valid"); + + // Two handovers a hundred seconds apart give a mean interval of a + // hundred seconds. + assert_eq!(tracker.mean_interval_seconds(), Some(100.0)); + assert_eq!(tracker.handover_rate_per_second(), Some(0.01)); +} + +#[test] +fn a_tracker_with_fewer_than_two_handovers_has_no_rate() { + let tracker = HandoverTracker::new(policy()); + assert_eq!(tracker.mean_interval_seconds(), None); + assert_eq!(tracker.handover_rate_per_second(), None); +} + +#[test] +fn a_tracker_that_loses_all_candidates_has_no_server() { + let mut tracker = HandoverTracker::serving(policy(), 1); + let decision = tracker.step(0.0, &[candidate(2, 0.05)]).expect("valid"); + assert_eq!(decision, HandoverDecision::Drop); + assert_eq!(tracker.current(), None); +} + +#[test] +fn a_tracker_rejects_invalid_candidates() { + let mut tracker = HandoverTracker::new(policy()); + let empty: [Candidate; 0] = []; + assert_eq!( + tracker.step(0.0, &empty).expect_err("no candidates").kind(), + HandoverErrorKind::NoCandidates + ); +} + +#[test] +fn a_handover_record_reports_its_gain() { + let record = HandoverRecord::new(1, 2, 5.0, 0.3, 0.8); + assert!((record.quality_gain() - 0.5).abs() < 1e-12); + // A forced handover can show a loss. + let forced = HandoverRecord::new(1, 2, 5.0, 0.9, 0.3); + assert!(forced.quality_gain() < 0.0); +}