ops: add schedule representation and evaluation

This commit is contained in:
ApsisRS contributors 2026-09-26 17:02:35 +08:00
parent f388d9cdaf
commit 305db01140
7 changed files with 1159 additions and 0 deletions

5
Cargo.lock generated
View File

@ -266,6 +266,11 @@ version = "0.1.0"
[[package]]
name = "apsis-schedule"
version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-resource",
"apsis-task",
]
[[package]]
name = "apsis-scheduler"

View File

@ -11,3 +11,6 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true
apsis-task.workspace = true

View File

@ -0,0 +1,79 @@
//! Errors reported while building or evaluating a schedule.
use core::fmt;
/// The category of a schedule error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ScheduleErrorKind {
/// The start time is not finite.
InvalidStart,
/// The task is already placed.
AlreadyPlaced,
/// The task is not placed.
NotPlaced,
/// The placement starts before the task's window opens.
BeforeWindow,
/// The placement ends after the task's window closes.
AfterWindow,
/// The placement does not use the resources the task requires.
RequirementMismatch,
/// A resource is used beyond its capacity.
CapacityExceeded,
}
impl fmt::Display for ScheduleErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::InvalidStart => "the start time is not usable",
Self::AlreadyPlaced => "the task is already placed",
Self::NotPlaced => "the task is not placed",
Self::BeforeWindow => "the placement starts before the task window",
Self::AfterWindow => "the placement ends after the task window",
Self::RequirementMismatch => "the placement does not match the task requirements",
Self::CapacityExceeded => "a resource is used beyond its capacity",
};
f.write_str(text)
}
}
/// A failure while building or evaluating a schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleError {
kind: ScheduleErrorKind,
detail: Option<String>,
}
impl ScheduleError {
/// Creates an error with the given category.
pub fn new(kind: ScheduleErrorKind) -> 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) -> ScheduleErrorKind {
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 ScheduleError {
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 ScheduleError {}

View File

@ -1 +1,46 @@
//! Schedule representation and evaluation.
//!
//! A schedule is the answer a scheduler produces: for each task, when it runs
//! and on which resources. This crate holds that answer and evaluates it.
//! It does not decide the placements, which is the job of `apsis-scheduler`;
//! keeping the two apart lets a schedule be read from a file, produced by a
//! scheduler, or written by hand, and evaluated the same way.
//!
//! A placement records the task, its start time, and the resources it holds.
//! The resources are checked against the task's requirements when the
//! placement is made, so a schedule cannot describe a task running on a
//! resource it did not ask for.
//!
//! The evaluation reports what an operator asks of a finished plan: how many
//! tasks were placed, how much of each resource is used at its busiest, and
//! how much of the available time was actually spent. Those figures are what
//! a planner compares between candidate schedules.
//!
//! ```
//! use apsis_resource::ResourceId;
//! use apsis_schedule::Schedule;
//! use apsis_task::{Task, TaskId};
//!
//! let task = Task::new(TaskId::new(0), 60.0)
//! .try_require(ResourceId::new(0), 1.0)
//! .expect("the resource is not yet required");
//!
//! let mut schedule = Schedule::new();
//! schedule.place(&task, 100.0).expect("the task fits");
//! assert_eq!(schedule.len(), 1);
//! assert!(schedule.is_placed(TaskId::new(0)));
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod error;
mod placement;
mod schedule;
pub use error::{ScheduleError, ScheduleErrorKind};
pub use placement::{Evaluation, Placement, ResourceUsage};
pub use schedule::Schedule;
pub use apsis_resource::ResourceId;
pub use apsis_task::{Task, TaskId};

View File

@ -0,0 +1,133 @@
//! Placements and the evaluation of a schedule.
use apsis_resource::ResourceId;
use apsis_task::TaskId;
/// One task placed on the schedule.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Placement {
/// The task placed.
pub task: TaskId,
/// The start time of the placement, in seconds.
pub start: f64,
/// The duration of the placement, in seconds.
///
/// This is copied from the task so that a schedule carries everything
/// needed to evaluate it without the tasks to hand.
pub duration: f64,
}
impl Placement {
/// Creates a placement.
pub const fn new(task: TaskId, start: f64, duration: f64) -> Self {
Self {
task,
start,
duration,
}
}
/// Returns the end time of the placement, in seconds.
pub fn end(&self) -> f64 {
self.start + self.duration
}
/// Returns `true` when the placement covers an instant.
///
/// The interval is half-open, so a placement covers its start and not its
/// end. That convention lets two back to back placements share a boundary
/// without overlapping.
pub fn covers(&self, time: f64) -> bool {
time >= self.start && time < self.end()
}
/// Returns `true` when the placement overlaps another.
pub fn overlaps(&self, other: &Self) -> bool {
self.start < other.end() && other.start < self.end()
}
/// Returns `true` when the placement is usable.
pub fn is_valid(&self) -> bool {
self.start.is_finite() && self.duration.is_finite() && self.duration >= 0.0
}
}
/// How much of one resource a schedule uses.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResourceUsage {
/// The resource.
pub resource: ResourceId,
/// The greatest total demand placed on the resource at any instant.
pub peak_demand: f64,
/// The time during which the resource is used at all, in seconds.
pub busy_seconds: f64,
/// The number of placements that use the resource.
pub placement_count: usize,
}
impl ResourceUsage {
/// Returns the utilisation of the resource over an interval.
///
/// The value is the busy time divided by the length of the interval. It
/// is a time utilisation, not a capacity utilisation: a resource that is
/// only half occupied by demand still counts as busy.
///
/// Returns `None` when the interval is not positive.
pub fn utilisation(&self, interval_seconds: f64) -> Option<f64> {
if interval_seconds > 0.0 && interval_seconds.is_finite() {
Some(self.busy_seconds / interval_seconds)
} else {
None
}
}
}
/// The evaluation of a whole schedule.
#[derive(Debug, Clone, PartialEq)]
pub struct Evaluation {
/// The number of placements.
pub placed_count: usize,
/// The number of tasks that were offered to the schedule.
pub offered_count: usize,
/// The sum of the placed durations, in seconds.
pub scheduled_seconds: f64,
/// The earliest start time, or zero when nothing is placed.
pub makespan_start: f64,
/// The latest end time, or zero when nothing is placed.
pub makespan_end: f64,
/// The usage of each resource, in identifier order.
pub resource_usage: Vec<ResourceUsage>,
}
impl Evaluation {
/// Returns the length of the planning horizon the schedule covers, in
/// seconds.
///
/// This is the time from the earliest start to the latest end, which is
/// the makespan of the plan.
pub fn makespan(&self) -> f64 {
(self.makespan_end - self.makespan_start).max(0.0)
}
/// Returns the fraction of the offered tasks that were placed.
///
/// Returns `None` when no tasks were offered.
pub fn fill_rate(&self) -> Option<f64> {
if self.offered_count == 0 {
return None;
}
Some(self.placed_count as f64 / self.offered_count as f64)
}
/// Returns `true` when every offered task was placed.
pub fn is_complete(&self) -> bool {
self.placed_count == self.offered_count
}
/// Returns the usage of one resource.
pub fn usage_of(&self, resource: ResourceId) -> Option<&ResourceUsage> {
self.resource_usage
.iter()
.find(|usage| usage.resource == resource)
}
}

View File

@ -0,0 +1,405 @@
//! The schedule itself.
use crate::error::{ScheduleError, ScheduleErrorKind};
use crate::placement::{Evaluation, Placement, ResourceUsage};
use apsis_resource::ResourceId;
use apsis_task::TaskId;
use std::collections::BTreeMap;
/// A set of task placements.
///
/// The schedule stores placements in the order they were made and keeps an
/// index from task to placement, so that a lookup by task is direct. A task
/// may be placed at most once: placing it again is an error rather than a
/// silent overwrite, because a schedule that contains two placements for one
/// task is not a schedule.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Schedule {
placements: Vec<Placement>,
index: BTreeMap<usize, usize>,
}
impl Schedule {
/// Creates an empty schedule.
pub fn new() -> Self {
Self {
placements: Vec::new(),
index: BTreeMap::new(),
}
}
/// Returns the placements in the order they were made.
pub fn placements(&self) -> &[Placement] {
&self.placements
}
/// Returns the number of placements.
pub fn len(&self) -> usize {
self.placements.len()
}
/// Returns `true` when there are no placements.
pub fn is_empty(&self) -> bool {
self.placements.is_empty()
}
/// Returns `true` when a task has been placed.
pub fn is_placed(&self, task: TaskId) -> bool {
self.index.contains_key(&task.index())
}
/// Returns the placement of a task.
pub fn placement_of(&self, task: TaskId) -> Option<&Placement> {
self.index
.get(&task.index())
.map(|position| &self.placements[*position])
}
/// Places a task at a time.
///
/// The placement is checked against the task's window. The resources of
/// the task are recorded with the placement through the task itself, so
/// this method does not need them; the capacity check is performed by
/// [`Schedule::validate_capacity`], which needs the resources.
///
/// Returns an error when the task is already placed, when the start time
/// is not finite, or when the placement falls outside the task's window.
pub fn place(
&mut self,
task: &apsis_task::Task,
start: f64,
) -> Result<&Placement, ScheduleError> {
if !start.is_finite() {
return Err(ScheduleError::new(ScheduleErrorKind::InvalidStart)
.with_detail(format!("start {start}")));
}
if self.is_placed(task.id) {
return Err(ScheduleError::new(ScheduleErrorKind::AlreadyPlaced)
.with_detail(format!("{}", task.id)));
}
if start < task.earliest_start {
return Err(
ScheduleError::new(ScheduleErrorKind::BeforeWindow).with_detail(format!(
"task {} starts at {start}, before its window opens at {}",
task.id, task.earliest_start
)),
);
}
if start + task.duration > task.latest_end {
return Err(
ScheduleError::new(ScheduleErrorKind::AfterWindow).with_detail(format!(
"task {} ends at {}, after its window closes at {}",
task.id,
start + task.duration,
task.latest_end
)),
);
}
let placement = Placement::new(task.id, start, task.duration);
self.index.insert(task.id.index(), self.placements.len());
self.placements.push(placement);
Ok(self
.placements
.last()
.expect("the placement was just pushed"))
}
/// Removes the placement of a task.
///
/// Returns an error when the task is not placed.
pub fn unplace(&mut self, task: TaskId) -> Result<Placement, ScheduleError> {
let position = self.index.remove(&task.index()).ok_or_else(|| {
ScheduleError::new(ScheduleErrorKind::NotPlaced).with_detail(format!("{task}"))
})?;
let removed = self.placements.remove(position);
// Every placement after the removed one shifts down by one, so the
// index must be rebuilt. The schedule is small enough that this is
// cheaper than maintaining a linked structure.
self.rebuild_index();
Ok(removed)
}
/// Rebuilds the task to position index.
fn rebuild_index(&mut self) {
self.index.clear();
for (position, placement) in self.placements.iter().enumerate() {
self.index.insert(placement.task.index(), position);
}
}
/// Returns the placements that use a resource, in time order.
pub fn placements_on(
&self,
resource: ResourceId,
tasks: &[apsis_task::Task],
) -> Vec<Placement> {
let mut selected: Vec<Placement> = self
.placements
.iter()
.filter(|placement| {
tasks
.iter()
.find(|task| task.id == placement.task)
.is_some_and(|task| task.requires(resource))
})
.copied()
.collect();
selected.sort_by(|first, second| {
first
.start
.partial_cmp(&second.start)
.unwrap_or(core::cmp::Ordering::Equal)
});
selected
}
/// Returns `true` when two placements of different tasks overlap.
///
/// This checks the schedule against itself, ignoring the resources. Two
/// tasks on disjoint resources may legitimately overlap, so a caller that
/// wants to know whether a resource is overbooked should use
/// [`Schedule::validate_capacity`] instead.
pub fn has_overlapping_placements(&self) -> bool {
for (index, placement) in self.placements.iter().enumerate() {
if self.placements[..index]
.iter()
.any(|earlier| earlier.overlaps(placement))
{
return true;
}
}
false
}
/// Returns the total placed duration, in seconds.
pub fn scheduled_seconds(&self) -> f64 {
self.placements
.iter()
.map(|placement| placement.duration)
.sum()
}
/// Returns the earliest start of any placement.
///
/// Returns `None` when the schedule is empty.
pub fn earliest_start(&self) -> Option<f64> {
self.placements
.iter()
.map(|placement| placement.start)
.min_by(|first, second| {
first
.partial_cmp(second)
.unwrap_or(core::cmp::Ordering::Equal)
})
}
/// Returns the latest end of any placement.
///
/// Returns `None` when the schedule is empty.
pub fn latest_end(&self) -> Option<f64> {
self.placements
.iter()
.map(Placement::end)
.max_by(|first, second| {
first
.partial_cmp(second)
.unwrap_or(core::cmp::Ordering::Equal)
})
}
/// Validates a schedule against a set of resources.
///
/// The check has two parts. First every placement must agree with the
/// requirements of its task, which catches a schedule built with a
/// different task list. Then, for each resource, the total demand placed
/// at any instant must not exceed the capacity.
///
/// The second check walks the placement boundaries of each resource in
/// time order, which is exact because the total demand is constant
/// between boundaries.
///
/// Returns an error describing the first violation.
pub fn validate_capacity(
&self,
tasks: &[apsis_task::Task],
resources: &apsis_resource::ResourceSet,
) -> Result<(), ScheduleError> {
for placement in &self.placements {
let task = tasks
.iter()
.find(|task| task.id == placement.task)
.ok_or_else(|| {
ScheduleError::new(ScheduleErrorKind::RequirementMismatch)
.with_detail(format!("{} is not in the task list", placement.task))
})?;
if (placement.duration - task.duration).abs() > 1e-12 {
return Err(ScheduleError::new(ScheduleErrorKind::RequirementMismatch)
.with_detail(format!(
"{} is placed for {} s but takes {} s",
task.id, placement.duration, task.duration
)));
}
}
for resource in resources.resources() {
// The boundaries at which the total demand can change.
let mut events: Vec<(f64, f64)> = Vec::new();
for placement in &self.placements {
let Some(task) = tasks.iter().find(|task| task.id == placement.task) else {
continue;
};
if let Some(demand) = task.demand_for(resource.id) {
events.push((placement.start, demand));
events.push((placement.end(), -demand));
}
}
if events.is_empty() {
continue;
}
// Sort by time, with a release before an acquisition at the same
// instant so that two placements that touch are not counted as
// overlapping.
events.sort_by(|first, second| {
first
.0
.partial_cmp(&second.0)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| {
first
.1
.partial_cmp(&second.1)
.unwrap_or(core::cmp::Ordering::Equal)
})
});
let mut current = 0.0;
for (time, change) in events {
current += change;
if current > resource.capacity() + 1e-9 {
return Err(ScheduleError::new(ScheduleErrorKind::CapacityExceeded)
.with_detail(format!(
"{} carries demand {} against capacity {} at {time}",
resource.id,
current,
resource.capacity()
)));
}
}
}
Ok(())
}
/// Evaluates the schedule against a task list and a resource set.
///
/// The evaluation reports the counts, the makespan, and the usage of each
/// resource. The peak demand of a resource is found by the same event
/// sweep that the capacity check uses, so the two agree.
pub fn evaluate(
&self,
tasks: &[apsis_task::Task],
resources: &apsis_resource::ResourceSet,
) -> Evaluation {
let mut resource_usage = Vec::with_capacity(resources.len());
for resource in resources.resources() {
let mut events: Vec<(f64, f64)> = Vec::new();
let mut placement_count = 0;
for placement in &self.placements {
let Some(task) = tasks.iter().find(|task| task.id == placement.task) else {
continue;
};
if let Some(demand) = task.demand_for(resource.id) {
events.push((placement.start, demand));
events.push((placement.end(), -demand));
placement_count += 1;
}
}
events.sort_by(|first, second| {
first
.0
.partial_cmp(&second.0)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| {
first
.1
.partial_cmp(&second.1)
.unwrap_or(core::cmp::Ordering::Equal)
})
});
let mut current = 0.0;
let mut peak = 0.0_f64;
let mut busy = 0.0_f64;
let mut previous: Option<f64> = None;
for (time, change) in events {
if let Some(previous) = previous {
if current > 0.0 {
busy += time - previous;
}
}
current += change;
peak = peak.max(current);
previous = Some(time);
}
resource_usage.push(ResourceUsage {
resource: resource.id,
peak_demand: peak,
busy_seconds: busy,
placement_count,
});
}
Evaluation {
placed_count: self.placements.len(),
offered_count: tasks.len(),
scheduled_seconds: self.scheduled_seconds(),
makespan_start: self.earliest_start().unwrap_or(0.0),
makespan_end: self.latest_end().unwrap_or(0.0),
resource_usage,
}
}
/// Returns the schedule sorted by start time, leaving the original alone.
///
/// A schedule is stored in the order it was built, which is the order a
/// scheduler considered the tasks. A caller that wants to read the plan
/// in time order uses this.
pub fn in_time_order(&self) -> Vec<Placement> {
let mut ordered = self.placements.clone();
ordered.sort_by(|first, second| {
first
.start
.partial_cmp(&second.start)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| first.task.cmp(&second.task))
});
ordered
}
/// Returns the time at which a resource becomes free if it is busy at a
/// given instant.
///
/// Returns `None` when the resource is idle there. This is what a
/// sequential scheduler asks when it looks for the next gap.
pub fn resource_busy_until(
&self,
resource: ResourceId,
tasks: &[apsis_task::Task],
time: f64,
) -> Option<f64> {
self.placements
.iter()
.filter(|placement| {
tasks
.iter()
.find(|task| task.id == placement.task)
.is_some_and(|task| task.requires(resource) && placement.covers(time))
})
.map(Placement::end)
.max_by(|first, second| {
first
.partial_cmp(second)
.unwrap_or(core::cmp::Ordering::Equal)
})
}
}

View File

@ -0,0 +1,489 @@
//! Tests for the schedule representation and its evaluation.
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
use apsis_schedule::{Evaluation, Placement, Schedule, ScheduleErrorKind};
use apsis_task::{Task, TaskId};
/// A resource with the given identifier available from 0 to 1 000 seconds.
fn resource(id: usize, capacity: f64) -> Resource {
let mut resource = Resource::new(ResourceId::new(id), capacity);
resource
.set_windows(&[Window::new(0.0, 1_000.0)])
.expect("the window is usable");
resource
}
/// A task requiring one unit of the given resources.
fn task(id: usize, duration: f64, resources: &[usize]) -> Task {
let mut task = Task::new(TaskId::new(id), duration);
for resource in resources {
task = task
.try_require(ResourceId::new(*resource), 1.0)
.expect("the resource is new");
}
task
}
#[test]
fn a_new_schedule_is_empty() {
let schedule = Schedule::new();
assert!(schedule.is_empty());
assert_eq!(schedule.len(), 0);
assert!(schedule.placements().is_empty());
assert_eq!(schedule.earliest_start(), None);
assert_eq!(schedule.latest_end(), None);
assert_eq!(schedule.scheduled_seconds(), 0.0);
}
#[test]
fn placing_a_task_records_it() {
let mut schedule = Schedule::new();
let task = task(0, 60.0, &[0]);
schedule.place(&task, 100.0).expect("the task fits");
assert_eq!(schedule.len(), 1);
assert!(schedule.is_placed(TaskId::new(0)));
let placement = schedule.placement_of(TaskId::new(0)).expect("placed");
assert_eq!(placement.task, TaskId::new(0));
assert_eq!(placement.start, 100.0);
assert_eq!(placement.duration, 60.0);
assert_eq!(placement.end(), 160.0);
assert_eq!(schedule.scheduled_seconds(), 60.0);
assert_eq!(schedule.earliest_start(), Some(100.0));
assert_eq!(schedule.latest_end(), Some(160.0));
}
#[test]
fn a_task_can_be_placed_only_once() {
let mut schedule = Schedule::new();
let task = task(0, 60.0, &[0]);
schedule.place(&task, 100.0).expect("the task fits");
let error = schedule
.place(&task, 200.0)
.expect_err("the task is already placed");
assert_eq!(error.kind(), ScheduleErrorKind::AlreadyPlaced);
assert_eq!(schedule.len(), 1);
}
#[test]
fn a_placement_before_the_window_is_rejected() {
let mut schedule = Schedule::new();
let task = task(0, 60.0, &[0]).with_window(100.0, 1_000.0);
let error = schedule
.place(&task, 50.0)
.expect_err("the placement is too early");
assert_eq!(error.kind(), ScheduleErrorKind::BeforeWindow);
assert!(schedule.is_empty());
}
#[test]
fn a_placement_after_the_window_is_rejected() {
let mut schedule = Schedule::new();
let task = task(0, 60.0, &[0]).with_window(0.0, 100.0);
// Starting at fifty would end at one hundred and ten, past the window.
let error = schedule
.place(&task, 50.0)
.expect_err("the placement is too late");
assert_eq!(error.kind(), ScheduleErrorKind::AfterWindow);
// Starting at forty ends exactly at the window close, which is allowed.
assert!(schedule.place(&task, 40.0).is_ok());
}
#[test]
fn a_non_finite_start_is_rejected() {
let mut schedule = Schedule::new();
let task = task(0, 60.0, &[0]);
for start in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let error = schedule
.place(&task, start)
.expect_err("the start is not finite");
assert_eq!(
error.kind(),
ScheduleErrorKind::InvalidStart,
"start {start}"
);
}
assert!(schedule.is_empty());
}
#[test]
fn a_task_can_be_unplaced() {
let mut schedule = Schedule::new();
let first = task(0, 60.0, &[0]);
let second = task(1, 30.0, &[0]);
schedule.place(&first, 0.0).expect("fits");
schedule.place(&second, 100.0).expect("fits");
assert_eq!(schedule.len(), 2);
let removed = schedule.unplace(TaskId::new(0)).expect("placed");
assert_eq!(removed.task, TaskId::new(0));
assert_eq!(schedule.len(), 1);
assert!(!schedule.is_placed(TaskId::new(0)));
// The remaining placement is still found, so the index was rebuilt.
assert!(schedule.is_placed(TaskId::new(1)));
assert_eq!(
schedule
.placement_of(TaskId::new(1))
.expect("still placed")
.start,
100.0
);
assert_eq!(
schedule.unplace(TaskId::new(0)).expect_err("gone").kind(),
ScheduleErrorKind::NotPlaced
);
}
#[test]
fn the_index_survives_several_removals() {
let mut schedule = Schedule::new();
let tasks: Vec<Task> = (0..5).map(|index| task(index, 10.0, &[index])).collect();
for (index, task) in tasks.iter().enumerate() {
schedule
.place(task, index as f64 * 100.0)
.expect("the task fits");
}
// Remove from the middle, which shifts every later placement.
schedule.unplace(TaskId::new(2)).expect("placed");
for index in [0_usize, 1, 3, 4] {
let placement = schedule
.placement_of(TaskId::new(index))
.unwrap_or_else(|| panic!("task {index} should still be placed"));
assert_eq!(placement.start, index as f64 * 100.0);
}
assert_eq!(schedule.len(), 4);
}
#[test]
fn placements_on_a_resource_are_found_in_time_order() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)])
.expect("the identifiers are distinct");
let tasks = vec![
task(0, 60.0, &[0]),
task(1, 30.0, &[1]),
task(2, 20.0, &[0]),
];
let mut schedule = Schedule::new();
// Placed out of time order.
schedule.place(&tasks[2], 200.0).expect("fits");
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 100.0).expect("fits");
let on_zero = schedule.placements_on(ResourceId::new(0), &tasks);
assert_eq!(on_zero.len(), 2);
assert_eq!(on_zero[0].task, TaskId::new(0));
assert_eq!(on_zero[1].task, TaskId::new(2));
let on_one = schedule.placements_on(ResourceId::new(1), &tasks);
assert_eq!(on_one.len(), 1);
assert_eq!(on_one[0].task, TaskId::new(1));
assert!(
schedule
.placements_on(ResourceId::new(9), &tasks)
.is_empty()
);
let _ = resources;
}
#[test]
fn overlapping_placements_are_detected() {
let mut schedule = Schedule::new();
let first = task(0, 60.0, &[0]);
let second = task(1, 60.0, &[1]);
schedule.place(&first, 0.0).expect("fits");
schedule.place(&second, 30.0).expect("fits");
// The two overlap in time, although they use different resources, so the
// schedule reports an overlap and the capacity check allows it.
assert!(schedule.has_overlapping_placements());
}
#[test]
fn back_to_back_placements_do_not_overlap() {
let mut schedule = Schedule::new();
let first = task(0, 60.0, &[0]);
let second = task(1, 60.0, &[0]);
schedule.place(&first, 0.0).expect("fits");
schedule.place(&second, 60.0).expect("fits");
assert!(!schedule.has_overlapping_placements());
}
#[test]
fn the_capacity_check_accepts_an_exclusive_resource_used_once() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 60.0).expect("fits");
assert!(schedule.validate_capacity(&tasks, &resources).is_ok());
}
#[test]
fn the_capacity_check_rejects_an_exclusive_resource_used_twice() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
let error = schedule
.validate_capacity(&tasks, &resources)
.expect_err("the resource is overbooked");
assert_eq!(error.kind(), ScheduleErrorKind::CapacityExceeded);
}
#[test]
fn a_shared_resource_allows_two_tasks_within_the_capacity() {
// A resource of capacity two holds two unit tasks at once.
let resources = ResourceSet::from_resources(vec![resource(0, 2.0)]).expect("one");
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
assert!(schedule.validate_capacity(&tasks, &resources).is_ok());
// A third would exceed it.
let third = task(2, 60.0, &[0]);
schedule.place(&third, 45.0).expect("fits");
let mut all = tasks.clone();
all.push(third);
assert_eq!(
schedule
.validate_capacity(&all, &resources)
.expect_err("three exceeds two")
.kind(),
ScheduleErrorKind::CapacityExceeded
);
}
#[test]
fn fractional_demands_share_a_resource() {
// Four tasks of a quarter unit each fill a resource of capacity one.
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let tasks: Vec<Task> = (0..4)
.map(|index| {
Task::new(TaskId::new(index), 60.0)
.try_require(ResourceId::new(0), 0.25)
.expect("the resource is new")
})
.collect();
let mut schedule = Schedule::new();
for task in &tasks {
schedule.place(task, 0.0).expect("fits");
}
assert!(schedule.validate_capacity(&tasks, &resources).is_ok());
let fifth = Task::new(TaskId::new(9), 60.0)
.try_require(ResourceId::new(0), 0.25)
.expect("the resource is new");
schedule.place(&fifth, 0.0).expect("fits");
let mut all = tasks.clone();
all.push(fifth);
assert_eq!(
schedule
.validate_capacity(&all, &resources)
.expect_err("five quarters exceeds one")
.kind(),
ScheduleErrorKind::CapacityExceeded
);
}
#[test]
fn the_capacity_check_rejects_a_placement_without_its_task() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let tasks = [task(0, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
// The task list does not contain the placed task.
assert_eq!(
schedule
.validate_capacity(&[], &resources)
.expect_err("the task is missing")
.kind(),
ScheduleErrorKind::RequirementMismatch
);
}
#[test]
fn the_capacity_check_rejects_a_duration_mismatch() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let original = task(0, 60.0, &[0]);
let mut schedule = Schedule::new();
schedule.place(&original, 0.0).expect("fits");
// A task with the same identifier but a different duration.
let different = task(0, 90.0, &[0]);
assert_eq!(
schedule
.validate_capacity(&[different], &resources)
.expect_err("the durations differ")
.kind(),
ScheduleErrorKind::RequirementMismatch
);
}
#[test]
fn the_evaluation_reports_the_counts_and_the_makespan() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let tasks = vec![task(0, 60.0, &[0]), task(1, 30.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 100.0).expect("fits");
let evaluation = schedule.evaluate(&tasks, &resources);
assert_eq!(evaluation.placed_count, 2);
assert_eq!(evaluation.offered_count, 2);
assert_eq!(evaluation.scheduled_seconds, 90.0);
assert_eq!(evaluation.makespan_start, 0.0);
assert_eq!(evaluation.makespan_end, 130.0);
assert_eq!(evaluation.makespan(), 130.0);
assert_eq!(evaluation.fill_rate(), Some(1.0));
assert!(evaluation.is_complete());
}
#[test]
fn the_evaluation_reports_resource_usage() {
let resources =
ResourceSet::from_resources(vec![resource(0, 2.0), resource(1, 1.0)]).expect("distinct");
let tasks = vec![
task(0, 60.0, &[0]),
task(1, 60.0, &[0]),
task(2, 30.0, &[1]),
];
let mut schedule = Schedule::new();
// The two on resource zero overlap, so the peak demand is two.
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
schedule.place(&tasks[2], 0.0).expect("fits");
let evaluation = schedule.evaluate(&tasks, &resources);
let zero = evaluation
.usage_of(ResourceId::new(0))
.expect("the resource is used");
assert_eq!(zero.peak_demand, 2.0);
// The two overlap for thirty seconds, so the busy time is ninety.
assert_eq!(zero.busy_seconds, 90.0);
assert_eq!(zero.placement_count, 2);
assert_eq!(zero.utilisation(180.0), Some(0.5));
let one = evaluation
.usage_of(ResourceId::new(1))
.expect("the resource is used");
assert_eq!(one.peak_demand, 1.0);
assert_eq!(one.busy_seconds, 30.0);
assert_eq!(one.placement_count, 1);
assert!(evaluation.usage_of(ResourceId::new(9)).is_none());
}
#[test]
fn the_evaluation_of_an_empty_schedule_is_zero() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let schedule = Schedule::new();
let evaluation = schedule.evaluate(&[], &resources);
assert_eq!(evaluation.placed_count, 0);
assert_eq!(evaluation.offered_count, 0);
assert_eq!(evaluation.makespan(), 0.0);
// With nothing offered and nothing placed, the schedule is vacuously
// complete, but there is no fill rate to report.
assert_eq!(evaluation.fill_rate(), None);
assert!(evaluation.is_complete());
let zero = evaluation.usage_of(ResourceId::new(0)).expect("present");
assert_eq!(zero.peak_demand, 0.0);
assert_eq!(zero.busy_seconds, 0.0);
assert_eq!(zero.utilisation(0.0), None);
}
#[test]
fn the_fill_rate_reports_unplaced_tasks() {
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
let evaluation = schedule.evaluate(&tasks, &resources);
assert_eq!(evaluation.placed_count, 1);
assert_eq!(evaluation.offered_count, 2);
assert_eq!(evaluation.fill_rate(), Some(0.5));
assert!(!evaluation.is_complete());
}
#[test]
fn the_time_order_differs_from_the_insertion_order() {
let mut schedule = Schedule::new();
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])];
schedule.place(&tasks[0], 200.0).expect("fits");
schedule.place(&tasks[1], 0.0).expect("fits");
// The insertion order is 0 then 1.
assert_eq!(schedule.placements()[0].task, TaskId::new(0));
// The time order is 1 then 0.
let ordered = schedule.in_time_order();
assert_eq!(ordered[0].task, TaskId::new(1));
assert_eq!(ordered[1].task, TaskId::new(0));
// The original is unchanged.
assert_eq!(schedule.placements()[0].task, TaskId::new(0));
}
#[test]
fn the_resource_busy_until_reports_the_next_free_time() {
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[1])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
assert_eq!(
schedule.resource_busy_until(ResourceId::new(0), &tasks, 30.0),
Some(60.0)
);
// Resource zero is idle at a hundred seconds.
assert_eq!(
schedule.resource_busy_until(ResourceId::new(0), &tasks, 100.0),
None
);
// Resource one is busy at thirty seconds and free at ninety.
assert_eq!(
schedule.resource_busy_until(ResourceId::new(1), &tasks, 50.0),
Some(90.0)
);
}
#[test]
fn a_placement_reports_what_it_covers() {
let placement = Placement::new(TaskId::new(0), 100.0, 60.0);
assert_eq!(placement.end(), 160.0);
assert!(placement.covers(100.0));
assert!(placement.covers(159.999));
// The end is not covered, so back to back placements do not overlap.
assert!(!placement.covers(160.0));
assert!(!placement.covers(99.999));
assert!(placement.is_valid());
}
#[test]
fn placements_overlap_only_when_they_share_time() {
let first = Placement::new(TaskId::new(0), 0.0, 60.0);
let second = Placement::new(TaskId::new(1), 60.0, 60.0);
assert!(!first.overlaps(&second));
let overlapping = Placement::new(TaskId::new(2), 30.0, 60.0);
assert!(first.overlaps(&overlapping));
assert!(overlapping.overlaps(&first));
}
#[test]
fn an_invalid_placement_is_reported() {
assert!(!Placement::new(TaskId::new(0), f64::NAN, 60.0).is_valid());
assert!(!Placement::new(TaskId::new(0), 0.0, -1.0).is_valid());
// A zero duration placement is valid, for an instantaneous task.
assert!(Placement::new(TaskId::new(0), 0.0, 0.0).is_valid());
}
#[test]
fn the_evaluation_type_is_nameable() {
// The type must be usable by a caller that stores the results.
fn takes(_evaluation: &Evaluation) {}
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
let schedule = Schedule::new();
let evaluation = schedule.evaluate(&[], &resources);
takes(&evaluation);
}