From 72640083af454cde87bfb470ff8e8efb8f1c9620 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 16:57:54 +0800 Subject: [PATCH] ops: add resources with availability windows and capacity --- Cargo.lock | 3 + crates/ops/apsis-resource/Cargo.toml | 1 + crates/ops/apsis-resource/src/error.rs | 76 +++++ crates/ops/apsis-resource/src/lib.rs | 44 +++ crates/ops/apsis-resource/src/resource.rs | 339 ++++++++++++++++++++ crates/ops/apsis-resource/src/window.rs | 240 ++++++++++++++ crates/ops/apsis-resource/tests/resource.rs | 312 ++++++++++++++++++ crates/ops/apsis-resource/tests/window.rs | 260 +++++++++++++++ 8 files changed, 1275 insertions(+) create mode 100644 crates/ops/apsis-resource/src/error.rs create mode 100644 crates/ops/apsis-resource/src/resource.rs create mode 100644 crates/ops/apsis-resource/src/window.rs create mode 100644 crates/ops/apsis-resource/tests/resource.rs create mode 100644 crates/ops/apsis-resource/tests/window.rs diff --git a/Cargo.lock b/Cargo.lock index a02f1c4..74e75c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,9 @@ version = "0.1.0" [[package]] name = "apsis-resource" version = "0.1.0" +dependencies = [ + "apsis-core", +] [[package]] name = "apsis-rf" diff --git a/crates/ops/apsis-resource/Cargo.toml b/crates/ops/apsis-resource/Cargo.toml index 7add1d2..15bcac7 100644 --- a/crates/ops/apsis-resource/Cargo.toml +++ b/crates/ops/apsis-resource/Cargo.toml @@ -11,3 +11,4 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true diff --git a/crates/ops/apsis-resource/src/error.rs b/crates/ops/apsis-resource/src/error.rs new file mode 100644 index 0000000..fd1f244 --- /dev/null +++ b/crates/ops/apsis-resource/src/error.rs @@ -0,0 +1,76 @@ +//! Errors reported while building or querying a resource. + +use core::fmt; + +/// The category of a resource error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ResourceErrorKind { + /// The end of an interval precedes its start. + ReversedInterval, + /// A time or duration is not finite. + NotFinite, + /// A duration is negative. + NegativeDuration, + /// The capacity is not positive and finite. + InvalidCapacity, + /// No resource has the requested identifier. + NoSuchResource, + /// A resource with the identifier already exists. + DuplicateResource, +} + +impl fmt::Display for ResourceErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::ReversedInterval => "the interval ends before it starts", + Self::NotFinite => "a time is not finite", + Self::NegativeDuration => "the duration is negative", + Self::InvalidCapacity => "the capacity is not usable", + Self::NoSuchResource => "no such resource", + Self::DuplicateResource => "a resource with that identifier exists", + }; + f.write_str(text) + } +} + +/// A failure while building or querying a resource. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceError { + kind: ResourceErrorKind, + detail: Option, +} + +impl ResourceError { + /// Creates an error with the given category. + pub fn new(kind: ResourceErrorKind) -> 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) -> ResourceErrorKind { + 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 ResourceError { + 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 ResourceError {} diff --git a/crates/ops/apsis-resource/src/lib.rs b/crates/ops/apsis-resource/src/lib.rs index 64b410f..b6820ff 100644 --- a/crates/ops/apsis-resource/src/lib.rs +++ b/crates/ops/apsis-resource/src/lib.rs @@ -1 +1,45 @@ //! Resource modeling for satellite operations. +//! +//! A resource is anything a task can occupy: a satellite, a ground station, a +//! transmitter, an onboard recorder. This crate models the two things a +//! scheduler needs to know about one: when it is available, and how much of +//! it a task consumes. +//! +//! Availability is expressed as a set of disjoint intervals on a single time +//! axis, called windows. A resource is usable at an instant when that instant +//! falls inside one of its windows. The windows may come from anywhere: the +//! access analysis in `apsis-access` produces them for a ground station, and +//! an operator enters maintenance periods by hand. +//! +//! Capacity is a positive number whose meaning is the caller's. A satellite +//! that can run one observation at a time has a capacity of one; a recorder +//! has a capacity in megabits; a power bus has one in watts. Two tasks +//! conflict only when their demands together exceed the capacity, which lets +//! the same type express exclusive and shared resources. +//! +//! Times are in seconds from an origin chosen by the caller, so the same +//! model serves a scenario clock, an elapsed time, and a Julian date offset. +//! +//! ``` +//! use apsis_resource::{Resource, ResourceId, Window}; +//! +//! // A station available from 100 to 200 seconds. +//! let mut resource = Resource::new(ResourceId::new(0), 1.0); +//! resource.add_window(Window::new(100.0, 200.0)).expect("the window is usable"); +//! +//! assert!(resource.is_available_at(150.0)); +//! assert!(!resource.is_available_at(250.0)); +//! assert!(resource.is_available_over(120.0, 80.0)); +//! assert!(!resource.is_available_over(150.0, 100.0)); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod error; +mod resource; +mod window; + +pub use error::{ResourceError, ResourceErrorKind}; +pub use resource::{Resource, ResourceId, ResourceSet}; +pub use window::{Interval, Window, merge_windows, subtract_window}; diff --git a/crates/ops/apsis-resource/src/resource.rs b/crates/ops/apsis-resource/src/resource.rs new file mode 100644 index 0000000..9b83f9f --- /dev/null +++ b/crates/ops/apsis-resource/src/resource.rs @@ -0,0 +1,339 @@ +//! Resources, their availability, and their capacity. + +use crate::error::{ResourceError, ResourceErrorKind}; +use crate::window::{Interval, Window, merge_windows, subtract_window}; + +/// An identifier for a resource. +/// +/// The identifier is an index chosen by the caller. It is deliberately opaque +/// to this crate so that a caller can use whatever numbering its own model +/// provides. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ResourceId(pub usize); + +impl ResourceId { + /// Creates an identifier. + pub const fn new(index: usize) -> Self { + Self(index) + } + + /// Returns the index. + pub const fn index(self) -> usize { + self.0 + } +} + +impl core::fmt::Display for ResourceId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "resource {}", self.0) + } +} + +/// A resource with availability windows and a capacity. +/// +/// The capacity is a positive number whose unit the caller chooses. A task +/// that occupies more than the capacity of a resource cannot be scheduled on +/// it at all; two tasks conflict when their demands together exceed the +/// capacity at some instant. +#[derive(Debug, Clone, PartialEq)] +pub struct Resource { + /// The identifier of this resource. + pub id: ResourceId, + /// The capacity, in a unit the caller chooses. + /// + /// A capacity of one with unit demands gives an exclusive resource, where + /// no two tasks overlap. A larger capacity, or fractional demands, gives + /// a shared resource. + capacity: f64, + /// The availability windows, kept merged and in time order. + windows: Vec, +} + +impl Resource { + /// Creates a resource with no availability and the given capacity. + /// + /// A resource with no windows is never available, so a caller adds + /// windows before using it. That is the safe default: a resource whose + /// availability was forgotten is unavailable rather than always + /// available. + pub const fn new(id: ResourceId, capacity: f64) -> Self { + Self { + id, + capacity, + windows: Vec::new(), + } + } + + /// Returns the capacity. + pub const fn capacity(&self) -> f64 { + self.capacity + } + + /// Sets the capacity. + /// + /// Returns an error when the capacity is not positive and finite. + pub fn set_capacity(&mut self, capacity: f64) -> Result<(), ResourceError> { + if !capacity.is_finite() || capacity <= 0.0 { + return Err(ResourceError::new(ResourceErrorKind::InvalidCapacity) + .with_detail(format!("capacity {capacity}"))); + } + self.capacity = capacity; + Ok(()) + } + + /// Returns `true` when the capacity is usable. + pub fn has_valid_capacity(&self) -> bool { + self.capacity.is_finite() && self.capacity > 0.0 + } + + /// Returns the availability windows, merged and in time order. + pub fn windows(&self) -> &[Window] { + &self.windows + } + + /// Returns the number of availability windows. + pub fn window_count(&self) -> usize { + self.windows.len() + } + + /// Returns `true` when the resource has no availability at all. + pub fn is_empty(&self) -> bool { + self.windows.is_empty() + } + + /// Replaces the availability with a list of windows. + /// + /// The windows are merged, so the stored form is canonical whatever order + /// the caller supplies them in. + /// + /// Returns an error when a window is not usable. + pub fn set_windows(&mut self, windows: &[Window]) -> Result<(), ResourceError> { + self.windows = merge_windows(windows)?; + Ok(()) + } + + /// Adds one availability window. + /// + /// The window is merged into the existing set. + /// + /// Returns an error when the window is not usable. + pub fn add_window(&mut self, window: Window) -> Result<(), ResourceError> { + window.validate()?; + let mut all = self.windows.clone(); + all.push(window); + self.windows = merge_windows(&all)?; + Ok(()) + } + + /// Removes an availability window. + /// + /// The window is subtracted from the existing set, which may split a + /// stored window in two. + /// + /// Returns an error when the window is not usable. + pub fn remove_window(&mut self, removed: Window) -> Result<(), ResourceError> { + self.windows = subtract_window(&self.windows, removed)?; + Ok(()) + } + + /// Returns `true` when the resource is available at an instant. + /// + /// An instantaneous query is a query of zero duration, which is satisfied + /// when the instant is inside a window. At the end of a window the + /// resource is not available, because the window is half-open. + pub fn is_available_at(&self, time: f64) -> bool { + self.windows + .iter() + .any(|window| window.interval.contains(time)) + } + + /// Returns `true` when the resource is available for a whole interval. + /// + /// A resource can satisfy a request of zero duration whenever it is + /// available at the requested instant, so a zero duration is checked as + /// an instant. + pub fn is_available_over(&self, start: f64, duration: f64) -> bool { + if !start.is_finite() || !duration.is_finite() || duration < 0.0 { + return false; + } + if duration == 0.0 { + return self.is_available_at(start); + } + let requested = Interval::new(start, start + duration); + self.windows + .iter() + .any(|window| window.interval.contains_interval(&requested)) + } + + /// Returns the availability that falls inside an interval. + /// + /// The result is the intersection of the stored windows with the query, + /// which is what a scheduler needs to know when it asks what part of its + /// planning horizon a resource is usable for. + pub fn availability_within(&self, query: Interval) -> Vec { + self.windows + .iter() + .filter_map(|window| window.interval.intersection(&query)) + .map(Window::from_interval) + .collect() + } + + /// Returns the total availability, in seconds. + /// + /// The windows are disjoint, so the durations add. + pub fn total_availability(&self) -> f64 { + self.windows.iter().map(Window::duration).sum() + } + + /// Returns the first window that can hold a request. + /// + /// This is the earliest time at which the resource can satisfy a request + /// of the given duration, which is what a greedy scheduler asks. Returns + /// `None` when no window is long enough. + pub fn earliest_fit(&self, duration: f64) -> Option { + if !duration.is_finite() || duration < 0.0 { + return None; + } + self.windows + .iter() + .find(|window| window.duration() >= duration) + .map(|window| Window::new(window.start(), window.start() + duration)) + } + + /// Returns the first window that can hold a request starting no earlier + /// than a given time. + /// + /// Returns `None` when no window is both late enough and long enough. + pub fn earliest_fit_after(&self, not_before: f64, duration: f64) -> Option { + if !not_before.is_finite() || !duration.is_finite() || duration < 0.0 { + return None; + } + self.windows.iter().find_map(|window| { + let start = window.start().max(not_before); + let end = start + duration; + (end <= window.end()).then(|| Window::new(start, end)) + }) + } + + /// Returns the time at which the resource stops being available. + /// + /// Returns `None` when the resource is not available at the given + /// instant, and infinity when the window that contains it is unbounded. + pub fn busy_until(&self, time: f64) -> Option { + self.windows + .iter() + .find(|window| window.interval.contains(time)) + .map(|window| window.end()) + } +} + +/// A collection of resources, indexed by identifier. +/// +/// The set keeps the resources in the order they were added and provides the +/// lookups a scheduler needs: by identifier, and by the resource that has a +/// window covering an instant. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ResourceSet { + resources: Vec, +} + +impl ResourceSet { + /// Creates an empty set. + pub const fn new() -> Self { + Self { + resources: Vec::new(), + } + } + + /// Creates a set from a list of resources. + /// + /// Returns an error when two resources share an identifier, because a + /// lookup would then be ambiguous. + pub fn from_resources(resources: Vec) -> Result { + let mut set = Self::new(); + for resource in resources { + set.insert(resource)?; + } + Ok(set) + } + + /// Inserts a resource. + /// + /// Returns an error when the identifier is already present. + pub fn insert(&mut self, resource: Resource) -> Result<(), ResourceError> { + if self.get(resource.id).is_some() { + return Err(ResourceError::new(ResourceErrorKind::DuplicateResource) + .with_detail(format!("{}", resource.id))); + } + self.resources.push(resource); + Ok(()) + } + + /// Returns the resource with the given identifier. + pub fn get(&self, id: ResourceId) -> Option<&Resource> { + self.resources.iter().find(|resource| resource.id == id) + } + + /// Returns the resource with the given identifier for modification. + pub fn get_mut(&mut self, id: ResourceId) -> Option<&mut Resource> { + self.resources.iter_mut().find(|resource| resource.id == id) + } + + /// Removes a resource. + /// + /// Returns an error when no resource has the identifier. + pub fn remove(&mut self, id: ResourceId) -> Result { + let index = self + .resources + .iter() + .position(|resource| resource.id == id) + .ok_or_else(|| { + ResourceError::new(ResourceErrorKind::NoSuchResource).with_detail(format!("{id}")) + })?; + Ok(self.resources.remove(index)) + } + + /// Returns the resources in the order they were added. + pub fn resources(&self) -> &[Resource] { + &self.resources + } + + /// Returns the number of resources. + pub fn len(&self) -> usize { + self.resources.len() + } + + /// Returns `true` when there are no resources. + pub fn is_empty(&self) -> bool { + self.resources.is_empty() + } + + /// Returns the identifiers of the resources available at an instant. + /// + /// The result is in the order the resources were added. + pub fn available_at(&self, time: f64) -> Vec { + self.resources + .iter() + .filter(|resource| resource.is_available_at(time)) + .map(|resource| resource.id) + .collect() + } + + /// Returns the identifiers of the resources available for a whole + /// interval. + pub fn available_over(&self, start: f64, duration: f64) -> Vec { + self.resources + .iter() + .filter(|resource| resource.is_available_over(start, duration)) + .map(|resource| resource.id) + .collect() + } + + /// Returns the total availability of every resource, in seconds. + pub fn total_availability(&self) -> f64 { + self.resources + .iter() + .map(Resource::total_availability) + .sum() + } +} diff --git a/crates/ops/apsis-resource/src/window.rs b/crates/ops/apsis-resource/src/window.rs new file mode 100644 index 0000000..78bda9f --- /dev/null +++ b/crates/ops/apsis-resource/src/window.rs @@ -0,0 +1,240 @@ +//! Availability windows and the interval algebra over them. + +use crate::error::{ResourceError, ResourceErrorKind}; +use core::fmt; + +/// A half-open interval of time, `[start, end)`. +/// +/// The half-open convention is the one that makes intervals tile without +/// overlap or gap: an interval that ends where another begins shares only the +/// instant at the boundary, which belongs to the second. Two intervals are +/// therefore disjoint when the end of the first is at or before the start of +/// the second. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Interval { + /// The start of the interval, in seconds. + pub start: f64, + /// The end of the interval, in seconds. + pub end: f64, +} + +impl Interval { + /// Creates an interval. + pub const fn new(start: f64, end: f64) -> Self { + Self { start, end } + } + + /// Returns the duration of the interval, in seconds. + /// + /// The value is zero for an empty interval and positive otherwise. + pub fn duration(&self) -> f64 { + (self.end - self.start).max(0.0) + } + + /// Returns `true` when the interval contains no time. + pub fn is_empty(&self) -> bool { + self.end <= self.start + } + + /// Returns `true` when the interval is finite and ordered. + pub fn is_valid(&self) -> bool { + self.start.is_finite() && self.end.is_finite() && self.end >= self.start + } + + /// Returns `true` when an instant lies inside the interval. + /// + /// The start is included and the end is excluded, so a boundary instant + /// belongs to the interval that begins there. + pub fn contains(&self, time: f64) -> bool { + time >= self.start && time < self.end + } + + /// Returns `true` when the interval fully contains another. + /// + /// An empty interval is contained by any interval that contains its + /// instant, so a zero length query inside the range is satisfied. + pub fn contains_interval(&self, other: &Self) -> bool { + if other.is_empty() { + return other.start >= self.start && other.start <= self.end; + } + other.start >= self.start && other.end <= self.end + } + + /// Returns `true` when two intervals share any time. + /// + /// Two intervals that meet at a point do not overlap, because the point + /// belongs to only one of them under the half-open convention. + pub fn overlaps(&self, other: &Self) -> bool { + self.start < other.end && other.start < self.end + } + + /// Returns the intersection of two intervals, or `None` when they do not + /// overlap. + pub fn intersection(&self, other: &Self) -> Option { + if !self.overlaps(other) { + return None; + } + Some(Self::new( + self.start.max(other.start), + self.end.min(other.end), + )) + } + + /// Returns the shortest interval containing both. + pub fn hull(&self, other: &Self) -> Self { + Self::new(self.start.min(other.start), self.end.max(other.end)) + } + + /// Returns `true` when the two intervals touch or overlap, so that they + /// can be merged without leaving a gap. + pub fn is_adjacent_to(&self, other: &Self) -> bool { + self.start <= other.end && other.start <= self.end + } +} + +impl fmt::Display for Interval { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}, {})", self.start, self.end) + } +} + +/// An availability window of a resource. +/// +/// This is an [`Interval`] under a name that reads better where the caller is +/// describing when a resource can be used rather than doing interval algebra. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Window { + /// The interval during which the resource is available. + pub interval: Interval, +} + +impl Window { + /// Creates a window from a start and an end time. + pub const fn new(start: f64, end: f64) -> Self { + Self { + interval: Interval::new(start, end), + } + } + + /// Creates a window from an interval. + pub const fn from_interval(interval: Interval) -> Self { + Self { interval } + } + + /// Returns the start of the window, in seconds. + pub const fn start(&self) -> f64 { + self.interval.start + } + + /// Returns the end of the window, in seconds. + pub const fn end(&self) -> f64 { + self.interval.end + } + + /// Returns the duration of the window, in seconds. + pub fn duration(&self) -> f64 { + self.interval.duration() + } + + /// Returns `true` when the window is usable. + pub fn is_valid(&self) -> bool { + self.interval.is_valid() + } + + /// Validates the window, returning the reason when it is not usable. + pub fn validate(&self) -> Result<(), ResourceError> { + if !self.interval.start.is_finite() || !self.interval.end.is_finite() { + return Err(ResourceError::new(ResourceErrorKind::NotFinite) + .with_detail(format!("window {}", self.interval))); + } + if self.interval.end < self.interval.start { + return Err(ResourceError::new(ResourceErrorKind::ReversedInterval) + .with_detail(format!("window {}", self.interval))); + } + Ok(()) + } +} + +impl fmt::Display for Window { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.interval) + } +} + +/// Merges a list of windows into a list of disjoint windows in time order. +/// +/// Windows that overlap or touch are combined, and empty windows are dropped. +/// The result is the canonical form of a set of availability windows: sorted, +/// disjoint, and non-empty, so that two sets can be compared directly. +/// +/// Returns an error when a window is not usable. +pub fn merge_windows(windows: &[Window]) -> Result, ResourceError> { + for window in windows { + window.validate()?; + } + let mut intervals: Vec = windows + .iter() + .map(|window| window.interval) + .filter(|interval| !interval.is_empty()) + .collect(); + if intervals.is_empty() { + return Ok(Vec::new()); + } + // Sorting on the start and then the end gives a deterministic order for + // intervals with equal starts. + intervals.sort_by(|first, second| { + first + .start + .partial_cmp(&second.start) + .unwrap_or(core::cmp::Ordering::Equal) + .then_with(|| { + first + .end + .partial_cmp(&second.end) + .unwrap_or(core::cmp::Ordering::Equal) + }) + }); + + let mut merged: Vec = Vec::with_capacity(intervals.len()); + for interval in intervals { + match merged.last_mut() { + Some(current) if current.is_adjacent_to(&interval) => { + current.end = current.end.max(interval.end); + } + _ => merged.push(interval), + } + } + Ok(merged.into_iter().map(Window::from_interval).collect()) +} + +/// Removes a window from each of a list of windows. +/// +/// The result is the same set of availability with the removed window taken +/// out. Windows that are entirely inside the removed one disappear, and one +/// that contains it is split in two. +/// +/// Returns an error when any input window is not usable. +pub fn subtract_window(windows: &[Window], removed: Window) -> Result, ResourceError> { + removed.validate()?; + let merged = merge_windows(windows)?; + if removed.interval.is_empty() { + return Ok(merged); + } + + let mut result = Vec::with_capacity(merged.len() + 1); + for window in merged { + match window.interval.intersection(&removed.interval) { + None => result.push(window), + Some(overlap) => { + // The part before the removed window, and the part after it. + if overlap.start > window.interval.start { + result.push(Window::new(window.interval.start, overlap.start)); + } + if overlap.end < window.interval.end { + result.push(Window::new(overlap.end, window.interval.end)); + } + } + } + } + Ok(result) +} diff --git a/crates/ops/apsis-resource/tests/resource.rs b/crates/ops/apsis-resource/tests/resource.rs new file mode 100644 index 0000000..526ffc7 --- /dev/null +++ b/crates/ops/apsis-resource/tests/resource.rs @@ -0,0 +1,312 @@ +//! Tests for resources and the set that holds them. + +use apsis_resource::{Interval, Resource, ResourceErrorKind, ResourceId, ResourceSet, Window}; + +/// A resource with one window from 100 to 200 seconds. +fn station() -> Resource { + let mut resource = Resource::new(ResourceId::new(0), 1.0); + resource + .add_window(Window::new(100.0, 200.0)) + .expect("the window is usable"); + resource +} + +#[test] +fn a_new_resource_has_no_availability() { + let resource = Resource::new(ResourceId::new(3), 1.0); + assert!(resource.is_empty()); + assert_eq!(resource.window_count(), 0); + assert_eq!(resource.total_availability(), 0.0); + assert!(!resource.is_available_at(0.0)); + assert_eq!(resource.id, ResourceId::new(3)); + assert!(!resource.is_available_at(f64::NAN)); +} + +#[test] +fn adding_a_window_makes_the_resource_available() { + let resource = station(); + assert_eq!(resource.window_count(), 1); + assert_eq!(resource.total_availability(), 100.0); + assert!(resource.is_available_at(150.0)); + assert!(!resource.is_available_at(50.0)); + assert!(!resource.is_available_at(250.0)); +} + +#[test] +fn availability_is_half_open_at_the_edges() { + let resource = station(); + assert!(resource.is_available_at(100.0)); + assert!(resource.is_available_at(199.999)); + // The end instant belongs to the next interval, so the resource stops + // being available exactly there. + assert!(!resource.is_available_at(200.0)); +} + +#[test] +fn availability_over_a_duration_requires_the_whole_span() { + let resource = station(); + assert!(resource.is_available_over(100.0, 100.0)); + assert!(resource.is_available_over(120.0, 80.0)); + assert!(resource.is_available_over(150.0, 50.0)); + // A request that reaches past the window fails, even by a little. + assert!(!resource.is_available_over(150.0, 50.001)); + assert!(!resource.is_available_over(99.0, 2.0)); +} + +#[test] +fn a_zero_duration_request_is_an_instantaneous_query() { + let resource = station(); + assert!(resource.is_available_over(150.0, 0.0)); + assert!(!resource.is_available_over(250.0, 0.0)); + // The half-open boundary applies here too. + assert!(!resource.is_available_over(200.0, 0.0)); +} + +#[test] +fn a_request_with_an_invalid_duration_is_refused() { + let resource = station(); + assert!(!resource.is_available_over(100.0, -1.0)); + assert!(!resource.is_available_over(100.0, f64::NAN)); + assert!(!resource.is_available_over(f64::NAN, 10.0)); + assert!(!resource.is_available_over(100.0, f64::INFINITY)); +} + +#[test] +fn adding_overlapping_windows_merges_them() { + let mut resource = Resource::new(ResourceId::new(0), 1.0); + resource.add_window(Window::new(0.0, 10.0)).expect("usable"); + resource.add_window(Window::new(5.0, 15.0)).expect("usable"); + // The two overlap, so the stored form is one window. + assert_eq!(resource.window_count(), 1); + assert_eq!(resource.windows()[0], Window::new(0.0, 15.0)); + assert_eq!(resource.total_availability(), 15.0); +} + +#[test] +fn adding_disjoint_windows_keeps_them_separate() { + let mut resource = Resource::new(ResourceId::new(0), 1.0); + resource + .add_window(Window::new(30.0, 40.0)) + .expect("usable"); + resource.add_window(Window::new(0.0, 10.0)).expect("usable"); + assert_eq!(resource.window_count(), 2); + // The stored order is time order, not insertion order. + assert_eq!(resource.windows()[0].start(), 0.0); + assert_eq!(resource.windows()[1].start(), 30.0); +} + +#[test] +fn removing_a_window_splits_availability() { + let mut resource = station(); + resource + .remove_window(Window::new(140.0, 160.0)) + .expect("usable"); + assert_eq!(resource.window_count(), 2); + assert_eq!(resource.windows()[0], Window::new(100.0, 140.0)); + assert_eq!(resource.windows()[1], Window::new(160.0, 200.0)); + assert!(!resource.is_available_at(150.0)); + assert!(resource.is_available_at(130.0)); + assert!(resource.is_available_at(180.0)); +} + +#[test] +fn setting_the_windows_replaces_them() { + let mut resource = station(); + resource + .set_windows(&[Window::new(0.0, 10.0), Window::new(20.0, 30.0)]) + .expect("usable"); + assert_eq!(resource.window_count(), 2); + assert!(!resource.is_available_at(150.0)); + assert!(resource.is_available_at(5.0)); + + // Setting nothing removes all availability. + resource.set_windows(&[]).expect("usable"); + assert!(resource.is_empty()); +} + +#[test] +fn availability_within_a_query_is_the_intersection() { + let resource = station(); + let within = resource.availability_within(Interval::new(150.0, 250.0)); + assert_eq!(within, vec![Window::new(150.0, 200.0)]); + + let outside = resource.availability_within(Interval::new(300.0, 400.0)); + assert!(outside.is_empty()); + + let covering = resource.availability_within(Interval::new(0.0, 1000.0)); + assert_eq!(covering, resource.windows().to_vec()); +} + +#[test] +fn the_earliest_fit_is_the_first_window_long_enough() { + let mut resource = Resource::new(ResourceId::new(0), 1.0); + resource + .set_windows(&[Window::new(0.0, 10.0), Window::new(30.0, 60.0)]) + .expect("usable"); + + // A request of five seconds fits in the first window. + assert_eq!(resource.earliest_fit(5.0), Some(Window::new(0.0, 5.0))); + // A request of twenty does not, so it takes the second. + assert_eq!(resource.earliest_fit(20.0), Some(Window::new(30.0, 50.0))); + // A request longer than any window has no answer. + assert_eq!(resource.earliest_fit(40.0), None); + // An instantaneous request fits at the start of the first window. + assert_eq!(resource.earliest_fit(0.0), Some(Window::new(0.0, 0.0))); + assert_eq!(resource.earliest_fit(-1.0), None); + assert_eq!(resource.earliest_fit(f64::NAN), None); +} + +#[test] +fn the_earliest_fit_after_a_time_respects_the_lower_bound() { + let mut resource = Resource::new(ResourceId::new(0), 1.0); + resource + .set_windows(&[Window::new(0.0, 10.0), Window::new(30.0, 60.0)]) + .expect("usable"); + + // Starting no earlier than five, the first window can still hold it. + assert_eq!( + resource.earliest_fit_after(5.0, 4.0), + Some(Window::new(5.0, 9.0)) + ); + // A request of eight seconds starting at five does not fit, because the + // window ends at ten, so the second window is used. + assert_eq!( + resource.earliest_fit_after(5.0, 8.0), + Some(Window::new(30.0, 38.0)) + ); + // Starting after the second window begins, the request starts there. + assert_eq!( + resource.earliest_fit_after(40.0, 10.0), + Some(Window::new(40.0, 50.0)) + ); + assert_eq!(resource.earliest_fit_after(100.0, 1.0), None); + assert_eq!(resource.earliest_fit_after(f64::NAN, 1.0), None); +} + +#[test] +fn the_busy_until_time_is_the_end_of_the_containing_window() { + let resource = station(); + assert_eq!(resource.busy_until(150.0), Some(200.0)); + assert_eq!(resource.busy_until(50.0), None); +} + +#[test] +fn the_capacity_is_validated() { + let mut resource = Resource::new(ResourceId::new(0), 1.0); + assert!(resource.has_valid_capacity()); + assert!(resource.set_capacity(2.5).is_ok()); + assert_eq!(resource.capacity(), 2.5); + + for capacity in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let error = resource + .set_capacity(capacity) + .expect_err("the capacity is not usable"); + assert_eq!(error.kind(), ResourceErrorKind::InvalidCapacity); + } + // The capacity is unchanged after a failed set. + assert_eq!(resource.capacity(), 2.5); + + let invalid = Resource::new(ResourceId::new(0), -1.0); + assert!(!invalid.has_valid_capacity()); +} + +#[test] +fn a_set_holds_resources_by_identifier() { + let mut set = ResourceSet::new(); + assert!(set.is_empty()); + set.insert(station()).expect("the identifier is new"); + set.insert(Resource::new(ResourceId::new(1), 2.0)) + .expect("the identifier is new"); + assert_eq!(set.len(), 2); + + let found = set.get(ResourceId::new(0)).expect("the resource exists"); + assert_eq!(found.capacity(), 1.0); + assert!(set.get(ResourceId::new(9)).is_none()); + assert_eq!(set.resources().len(), 2); +} + +#[test] +fn a_duplicate_identifier_is_rejected() { + let mut set = ResourceSet::new(); + set.insert(station()).expect("the identifier is new"); + let error = set + .insert(Resource::new(ResourceId::new(0), 5.0)) + .expect_err("the identifier is taken"); + assert_eq!(error.kind(), ResourceErrorKind::DuplicateResource); + assert_eq!(set.len(), 1); +} + +#[test] +fn a_set_can_be_built_from_a_list() { + let set = ResourceSet::from_resources(vec![station(), Resource::new(ResourceId::new(1), 1.0)]) + .expect("the identifiers are distinct"); + assert_eq!(set.len(), 2); + + let error = + ResourceSet::from_resources(vec![station(), Resource::new(ResourceId::new(0), 1.0)]) + .expect_err("the identifiers collide"); + assert_eq!(error.kind(), ResourceErrorKind::DuplicateResource); +} + +#[test] +fn a_resource_can_be_removed() { + let mut set = ResourceSet::new(); + set.insert(station()).expect("new"); + let removed = set.remove(ResourceId::new(0)).expect("the resource exists"); + assert_eq!(removed.id, ResourceId::new(0)); + assert!(set.is_empty()); + assert_eq!( + set.remove(ResourceId::new(0)).expect_err("gone").kind(), + ResourceErrorKind::NoSuchResource + ); +} + +#[test] +fn a_resource_can_be_modified_through_the_set() { + let mut set = ResourceSet::new(); + set.insert(station()).expect("new"); + let resource = set + .get_mut(ResourceId::new(0)) + .expect("the resource exists"); + resource + .add_window(Window::new(300.0, 400.0)) + .expect("usable"); + assert_eq!( + set.get(ResourceId::new(0)).expect("exists").window_count(), + 2 + ); + assert!(set.get_mut(ResourceId::new(9)).is_none()); +} + +#[test] +fn the_set_reports_availability() { + let mut set = ResourceSet::new(); + set.insert(station()).expect("new"); + set.insert(Resource::new(ResourceId::new(1), 1.0)) + .expect("new"); + + // Only the first resource has any availability. + assert_eq!(set.available_at(150.0), vec![ResourceId::new(0)]); + assert!(set.available_at(50.0).is_empty()); + assert_eq!(set.available_over(120.0, 50.0), vec![ResourceId::new(0)]); + assert!(set.available_over(120.0, 200.0).is_empty()); + assert_eq!(set.total_availability(), 100.0); +} + +#[test] +fn the_identifier_displays_its_index() { + assert_eq!(ResourceId::new(4).to_string(), "resource 4"); + assert_eq!(ResourceId::new(4).index(), 4); + assert_eq!(ResourceId::new(4), ResourceId(4)); +} + +#[test] +fn identifiers_are_ordered() { + // Ordering lets a caller sort a list of resources deterministically. + let mut ids = [ResourceId::new(3), ResourceId::new(1), ResourceId::new(2)]; + ids.sort(); + assert_eq!( + ids, + [ResourceId::new(1), ResourceId::new(2), ResourceId::new(3)] + ); +} diff --git a/crates/ops/apsis-resource/tests/window.rs b/crates/ops/apsis-resource/tests/window.rs new file mode 100644 index 0000000..7fb9e9a --- /dev/null +++ b/crates/ops/apsis-resource/tests/window.rs @@ -0,0 +1,260 @@ +//! Tests for availability windows and the interval algebra. + +use apsis_resource::{Interval, ResourceErrorKind, Window, merge_windows, subtract_window}; + +#[test] +fn an_interval_reports_its_duration() { + assert_eq!(Interval::new(10.0, 30.0).duration(), 20.0); + // An empty or reversed interval has no duration. + assert_eq!(Interval::new(30.0, 30.0).duration(), 0.0); + assert_eq!(Interval::new(30.0, 10.0).duration(), 0.0); +} + +#[test] +fn emptiness_and_validity_are_reported() { + assert!(Interval::new(5.0, 5.0).is_empty()); + assert!(Interval::new(5.0, 4.0).is_empty()); + assert!(!Interval::new(4.0, 5.0).is_empty()); + assert!(Interval::new(4.0, 5.0).is_valid()); + // A reversed interval is not valid, although it is empty. + assert!(!Interval::new(5.0, 4.0).is_valid()); + assert!(!Interval::new(f64::NAN, 5.0).is_valid()); + assert!(!Interval::new(4.0, f64::INFINITY).is_valid()); +} + +#[test] +fn containment_is_half_open() { + let interval = Interval::new(10.0, 20.0); + // The start is inside and the end is not. + assert!(!interval.contains(9.999)); + assert!(interval.contains(10.0)); + assert!(interval.contains(15.0)); + assert!(interval.contains(19.999)); + assert!(!interval.contains(20.0)); +} + +#[test] +fn containing_an_interval_includes_the_boundaries() { + let outer = Interval::new(0.0, 100.0); + assert!(outer.contains_interval(&Interval::new(10.0, 20.0))); + // The boundaries are included, so an interval that shares an end is + // contained. + assert!(outer.contains_interval(&Interval::new(0.0, 100.0))); + assert!(outer.contains_interval(&Interval::new(0.0, 50.0))); + assert!(outer.contains_interval(&Interval::new(50.0, 100.0))); + // An interval that reaches past the end is not. + assert!(!outer.contains_interval(&Interval::new(50.0, 100.1))); + assert!(!outer.contains_interval(&Interval::new(-0.1, 50.0))); +} + +#[test] +fn an_empty_interval_is_contained_when_its_instant_is() { + let outer = Interval::new(0.0, 100.0); + assert!(outer.contains_interval(&Interval::new(50.0, 50.0))); + assert!(!outer.contains_interval(&Interval::new(200.0, 200.0))); +} + +#[test] +fn intervals_that_meet_do_not_overlap() { + // The half-open convention means the boundary instant belongs to the + // second interval only. + let first = Interval::new(0.0, 10.0); + let second = Interval::new(10.0, 20.0); + assert!(!first.overlaps(&second)); + assert!(!second.overlaps(&first)); + assert!(first.is_adjacent_to(&second)); +} + +#[test] +fn overlapping_intervals_report_their_intersection() { + let first = Interval::new(0.0, 10.0); + let second = Interval::new(5.0, 15.0); + assert!(first.overlaps(&second)); + assert_eq!(first.intersection(&second), Some(Interval::new(5.0, 10.0))); + assert_eq!(second.intersection(&first), first.intersection(&second)); + // Disjoint intervals have no intersection. + assert_eq!( + Interval::new(0.0, 1.0).intersection(&Interval::new(2.0, 3.0)), + None + ); +} + +#[test] +fn the_hull_covers_both_intervals() { + let first = Interval::new(0.0, 10.0); + let second = Interval::new(5.0, 15.0); + assert_eq!(first.hull(&second), Interval::new(0.0, 15.0)); + assert_eq!(first.hull(&second), second.hull(&first)); +} + +#[test] +fn merging_combines_overlapping_windows() { + let windows = [ + Window::new(0.0, 10.0), + Window::new(5.0, 15.0), + Window::new(30.0, 40.0), + ]; + let merged = merge_windows(&windows).expect("the windows are usable"); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0], Window::new(0.0, 15.0)); + assert_eq!(merged[1], Window::new(30.0, 40.0)); +} + +#[test] +fn merging_combines_windows_that_touch() { + // Two windows that meet share a boundary instant, which belongs to the + // second, so the union is one continuous window. + let merged = merge_windows(&[Window::new(0.0, 10.0), Window::new(10.0, 20.0)]) + .expect("the windows are usable"); + assert_eq!(merged, vec![Window::new(0.0, 20.0)]); +} + +#[test] +fn merging_drops_empty_windows() { + // A zero length window is valid but contributes no availability, so it + // is dropped. A reversed window is a caller error and is rejected. + let merged = merge_windows(&[ + Window::new(5.0, 5.0), + Window::new(0.0, 10.0), + Window::new(50.0, 50.0), + ]) + .expect("the windows are usable"); + assert_eq!(merged, vec![Window::new(0.0, 10.0)]); +} + +#[test] +fn merging_sorts_windows_into_time_order() { + let merged = merge_windows(&[ + Window::new(30.0, 40.0), + Window::new(0.0, 10.0), + Window::new(15.0, 20.0), + ]) + .expect("the windows are usable"); + assert_eq!(merged[0].start(), 0.0); + assert_eq!(merged[1].start(), 15.0); + assert_eq!(merged[2].start(), 30.0); +} + +#[test] +fn merging_an_empty_list_gives_an_empty_list() { + assert!(merge_windows(&[]).expect("nothing to check").is_empty()); +} + +#[test] +fn merging_is_idempotent() { + // The canonical form must be a fixed point, so that comparing two sets + // is a direct comparison. + let windows = [ + Window::new(0.0, 10.0), + Window::new(5.0, 15.0), + Window::new(30.0, 40.0), + ]; + let once = merge_windows(&windows).expect("usable"); + let twice = merge_windows(&once).expect("usable"); + assert_eq!(once, twice); +} + +#[test] +fn a_reversed_window_is_rejected() { + let error = merge_windows(&[Window::new(10.0, 0.0)]).expect_err("reversed"); + assert_eq!(error.kind(), ResourceErrorKind::ReversedInterval); + + let error = Window::new(10.0, 0.0).validate().expect_err("reversed"); + assert_eq!(error.kind(), ResourceErrorKind::ReversedInterval); +} + +#[test] +fn a_non_finite_window_is_rejected() { + assert_eq!( + Window::new(f64::NAN, 1.0) + .validate() + .expect_err("not finite") + .kind(), + ResourceErrorKind::NotFinite + ); + assert_eq!( + merge_windows(&[Window::new(0.0, f64::INFINITY)]) + .expect_err("not finite") + .kind(), + ResourceErrorKind::NotFinite + ); +} + +#[test] +fn subtracting_removes_the_middle_of_a_window() { + let windows = [Window::new(0.0, 100.0)]; + let result = subtract_window(&windows, Window::new(30.0, 40.0)).expect("usable"); + assert_eq!( + result, + vec![Window::new(0.0, 30.0), Window::new(40.0, 100.0)] + ); +} + +#[test] +fn subtracting_the_whole_of_a_window_leaves_nothing() { + let result = + subtract_window(&[Window::new(0.0, 10.0)], Window::new(0.0, 10.0)).expect("usable"); + assert!(result.is_empty()); + + // A window that covers more than the stored one also removes it. + let result = + subtract_window(&[Window::new(0.0, 10.0)], Window::new(-5.0, 15.0)).expect("usable"); + assert!(result.is_empty()); +} + +#[test] +fn subtracting_the_start_trims_the_window() { + let result = subtract_window(&[Window::new(0.0, 10.0)], Window::new(0.0, 4.0)).expect("usable"); + assert_eq!(result, vec![Window::new(4.0, 10.0)]); +} + +#[test] +fn subtracting_the_end_trims_the_window() { + let result = + subtract_window(&[Window::new(0.0, 10.0)], Window::new(6.0, 10.0)).expect("usable"); + assert_eq!(result, vec![Window::new(0.0, 6.0)]); +} + +#[test] +fn subtracting_a_disjoint_window_changes_nothing() { + let windows = [Window::new(0.0, 10.0), Window::new(20.0, 30.0)]; + let result = subtract_window(&windows, Window::new(12.0, 18.0)).expect("usable"); + assert_eq!(result, windows.to_vec()); +} + +#[test] +fn subtracting_an_empty_window_changes_nothing() { + let windows = [Window::new(0.0, 10.0)]; + let result = subtract_window(&windows, Window::new(5.0, 5.0)).expect("usable"); + assert_eq!(result, windows.to_vec()); +} + +#[test] +fn subtracting_from_several_windows_affects_each() { + let windows = [Window::new(0.0, 10.0), Window::new(20.0, 30.0)]; + let result = subtract_window(&windows, Window::new(5.0, 25.0)).expect("usable"); + assert_eq!(result, vec![Window::new(0.0, 5.0), Window::new(25.0, 30.0)]); +} + +#[test] +fn subtracting_merges_the_windows_first() { + // The two overlapping windows form one, which the subtraction then + // splits. Working on the unmerged list would give a different answer. + let windows = [Window::new(0.0, 10.0), Window::new(5.0, 15.0)]; + let result = subtract_window(&windows, Window::new(8.0, 12.0)).expect("usable"); + assert_eq!(result, vec![Window::new(0.0, 8.0), Window::new(12.0, 15.0)]); +} + +#[test] +fn a_window_displays_its_interval() { + assert_eq!(Window::new(1.5, 2.5).to_string(), "[1.5, 2.5)"); + assert_eq!(Interval::new(0.0, 1.0).to_string(), "[0, 1)"); +} + +#[test] +fn a_window_can_be_built_from_an_interval() { + let interval = Interval::new(3.0, 7.0); + let window = Window::from_interval(interval); + assert_eq!(window.interval, interval); + assert_eq!(window.duration(), 4.0); +}