network: add handover policy with hysteresis and dwell time
This commit is contained in:
parent
8cc2b435b3
commit
f941131011
|
|
@ -100,6 +100,10 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "apsis-handover"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-interference"
|
||||
|
|
|
|||
|
|
@ -11,3 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue