ops: add schedule representation and evaluation
This commit is contained in:
parent
f388d9cdaf
commit
305db01140
|
|
@ -266,6 +266,11 @@ version = "0.1.0"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-schedule"
|
name = "apsis-schedule"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"apsis-core",
|
||||||
|
"apsis-resource",
|
||||||
|
"apsis-task",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-scheduler"
|
name = "apsis-scheduler"
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,6 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
apsis-resource.workspace = true
|
||||||
|
apsis-task.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -1 +1,46 @@
|
||||||
//! Schedule representation and evaluation.
|
//! 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};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue