network: add handover policy with hysteresis and dwell time

This commit is contained in:
ApsisRS contributors 2026-09-26 16:04:52 +08:00
parent 033c85a338
commit 0bc127277f
7 changed files with 1074 additions and 0 deletions

4
Cargo.lock generated
View File

@ -100,6 +100,10 @@ dependencies = [
[[package]]
name = "apsis-handover"
version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-math",
]
[[package]]
name = "apsis-interference"

View File

@ -11,3 +11,5 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true
apsis-math.workspace = true

View File

@ -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<usize> {
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<usize>,
candidates: &[Candidate],
) -> Result<HandoverDecision, HandoverError> {
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<usize>,
last_handover_seconds: Option<f64>,
candidates: &[Candidate],
seconds: f64,
) -> Result<HandoverDecision, HandoverError> {
decide_internal(policy, current, last_handover_seconds, candidates, seconds)
}
/// Applies the rules with an optional dwell time.
fn decide_internal(
policy: &HandoverPolicy,
current: Option<usize>,
last_handover_seconds: Option<f64>,
candidates: &[Candidate],
seconds: f64,
) -> Result<HandoverDecision, HandoverError> {
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)
}

View File

@ -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<String>,
}
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<String>) -> 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 {}

View File

@ -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};

View File

@ -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<usize>,
last_handover_seconds: Option<f64>,
history: Vec<HandoverRecord>,
}
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<usize> {
self.serving
}
/// Returns the time of the last handover, if there has been one.
pub fn last_handover_seconds(&self) -> Option<f64> {
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<crate::decision::HandoverDecision, HandoverError> {
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<f64> {
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<f64> {
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()
}
}

View File

@ -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, &not_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);
}