From 56a7aacd91ec69b56aba2fac7354f9e811b22166 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 17:07:12 +0800 Subject: [PATCH] ops: add greedy task planner --- Cargo.lock | 6 + crates/ops/apsis-scheduler/Cargo.toml | 4 + crates/ops/apsis-scheduler/src/greedy.rs | 220 ++++++++++ crates/ops/apsis-scheduler/src/lib.rs | 56 +++ crates/ops/apsis-scheduler/src/planner.rs | 222 ++++++++++ crates/ops/apsis-scheduler/tests/planner.rs | 431 ++++++++++++++++++++ 6 files changed, 939 insertions(+) create mode 100644 crates/ops/apsis-scheduler/src/greedy.rs create mode 100644 crates/ops/apsis-scheduler/src/planner.rs create mode 100644 crates/ops/apsis-scheduler/tests/planner.rs diff --git a/Cargo.lock b/Cargo.lock index db3c5c8..613e3e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,6 +281,12 @@ dependencies = [ [[package]] name = "apsis-scheduler" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-resource", + "apsis-schedule", + "apsis-task", +] [[package]] name = "apsis-sgp4" diff --git a/crates/ops/apsis-scheduler/Cargo.toml b/crates/ops/apsis-scheduler/Cargo.toml index 78471a7..9b85519 100644 --- a/crates/ops/apsis-scheduler/Cargo.toml +++ b/crates/ops/apsis-scheduler/Cargo.toml @@ -11,3 +11,7 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-resource.workspace = true +apsis-task.workspace = true +apsis-schedule.workspace = true diff --git a/crates/ops/apsis-scheduler/src/greedy.rs b/crates/ops/apsis-scheduler/src/greedy.rs new file mode 100644 index 0000000..87fbe5c --- /dev/null +++ b/crates/ops/apsis-scheduler/src/greedy.rs @@ -0,0 +1,220 @@ +//! The building blocks a greedy scheduler needs. + +use apsis_resource::{ResourceId, ResourceSet}; +use apsis_schedule::Schedule; +use apsis_task::{Task, TaskId}; + +/// Returns `true` when every resource of a task has the capacity to hold its +/// demand. +/// +/// A task that demands more of a resource than the resource has can never be +/// scheduled on it, so this is checked before the search for a time. Returns +/// `false` when a required resource does not exist. +pub fn resources_have_capacity(task: &Task, resources: &ResourceSet) -> bool { + task.requirements.iter().all(|requirement| { + resources + .get(requirement.resource) + .is_some_and(|resource| requirement.demand <= resource.capacity() + 1e-9) + }) +} + +/// Returns the total demand placed on each resource at an instant, given a +/// schedule and the tasks it contains. +/// +/// The result is in the order the resources appear in the set. A resource +/// that is not in the set is omitted. +pub fn demand_at( + schedule: &Schedule, + tasks: &[Task], + resources: &ResourceSet, + time: f64, +) -> Vec<(ResourceId, f64)> { + resources + .resources() + .iter() + .map(|resource| { + let total: f64 = schedule + .placements() + .iter() + .filter(|placement| placement.covers(time)) + .filter_map(|placement| { + tasks + .iter() + .find(|task| task.id == placement.task) + .and_then(|task| task.demand_for(resource.id)) + }) + .sum(); + (resource.id, total) + }) + .collect() +} + +/// Returns the identifiers of the resources that are free at an instant. +/// +/// A resource is free when its availability windows cover the instant and +/// the demand already placed on it leaves room for one more unit. This is +/// the query a watermark scheduler makes when it looks for a satellite with +/// spare capacity. +pub fn resources_free_at( + schedule: &Schedule, + tasks: &[Task], + resources: &ResourceSet, + time: f64, +) -> Vec { + resources + .resources() + .iter() + .filter(|resource| resource.is_available_at(time)) + .filter(|resource| { + let placed: f64 = schedule + .placements() + .iter() + .filter(|placement| placement.covers(time)) + .filter_map(|placement| { + tasks + .iter() + .find(|task| task.id == placement.task) + .and_then(|task| task.demand_for(resource.id)) + }) + .sum(); + placed < resource.capacity() - 1e-9 + }) + .map(|resource| resource.id) + .collect() +} + +/// Returns the earliest time at which a task can be placed. +/// +/// The earliest feasible start is always one of a small set of candidate +/// times: the start of the task's window, the start of an availability +/// window of one of its resources, or the end of a placement already on one +/// of those resources. Feasibility cannot change at any other instant, +/// because the demand on every resource is constant between those events and +/// the availability is constant between them too. Enumerating that set and +/// taking the smallest feasible member therefore finds the true earliest +/// start. +/// +/// Returns `None` when the task cannot be placed anywhere in its window. +pub fn earliest_feasible_start( + task: &Task, + tasks: &[Task], + schedule: &Schedule, + resources: &ResourceSet, +) -> Option { + if !task.is_valid() || !resources_have_capacity(task, resources) { + return None; + } + if task.requirements.is_empty() { + // A task with no requirements can start as soon as its window opens. + return Some(task.earliest_start); + } + + // The candidate times, collected from every event that can change + // feasibility. + let mut candidates: Vec = vec![task.earliest_start]; + for requirement in &task.requirements { + let resource = resources.get(requirement.resource)?; + for window in resource.windows() { + candidates.push(window.start()); + } + for placement in schedule.placements() { + let overlaps = tasks + .iter() + .find(|other| other.id == placement.task) + .is_some_and(|other| other.demand_for(resource.id).is_some()); + if overlaps { + candidates.push(placement.end()); + } + } + } + + // Only times at or after the window opens, and no later than the last + // start that could still fit, are worth testing. + let latest_start = task.latest_end - task.duration; + candidates + .retain(|time| time.is_finite() && *time >= task.earliest_start && *time <= latest_start); + candidates.sort_by(|first, second| { + first + .partial_cmp(second) + .unwrap_or(core::cmp::Ordering::Equal) + }); + candidates.dedup(); + + candidates + .into_iter() + .find(|start| feasible_at(task, tasks, schedule, resources, *start)) +} + +/// Returns `true` when a task placed at a time is feasible. +/// +/// The check is that every required resource is available for the whole +/// duration and that the demand already placed on it, plus the new demand, +/// stays within the capacity at every instant of the placement. +pub fn feasible_at( + task: &Task, + tasks: &[Task], + schedule: &Schedule, + resources: &ResourceSet, + start: f64, +) -> bool { + if !start.is_finite() { + return false; + } + let end = start + task.duration; + for requirement in &task.requirements { + let Some(resource) = resources.get(requirement.resource) else { + return false; + }; + if !resource.is_available_over(start, task.duration) { + return false; + } + // The demand of the new task must fit alongside the placements that + // overlap it. Because the demand of each placement is constant over + // its own interval, it is enough to test the boundaries of the + // overlapping placements, where the total is greatest. + let mut boundaries: Vec = vec![start]; + for placement in schedule.placements() { + let Some(other) = tasks.iter().find(|other| other.id == placement.task) else { + continue; + }; + if other.demand_for(resource.id).is_none() { + continue; + } + if placement.start < end && start < placement.end() { + boundaries.push(placement.start); + } + } + for boundary in boundaries { + let placed: f64 = schedule + .placements() + .iter() + .filter(|placement| placement.covers(boundary)) + .filter_map(|placement| { + tasks + .iter() + .find(|other| other.id == placement.task) + .and_then(|other| other.demand_for(resource.id)) + }) + .sum(); + if placed + requirement.demand > resource.capacity() + 1e-9 { + return false; + } + } + } + true +} + +/// Returns the identifiers of the tasks that a set of resources cannot +/// accommodate at all. +/// +/// A task is infeasible when it demands more of a resource than the resource +/// has, when a resource it requires does not exist, or when its window is +/// shorter than its duration. These cannot be repaired by rescheduling and +/// are worth reporting separately from the tasks that were merely left out. +pub fn infeasible_tasks(tasks: &[Task], resources: &ResourceSet) -> Vec { + tasks + .iter() + .filter(|task| !task.is_valid() || !resources_have_capacity(task, resources)) + .map(|task| task.id) + .collect() +} diff --git a/crates/ops/apsis-scheduler/src/lib.rs b/crates/ops/apsis-scheduler/src/lib.rs index 75c7420..a239eab 100644 --- a/crates/ops/apsis-scheduler/src/lib.rs +++ b/crates/ops/apsis-scheduler/src/lib.rs @@ -1 +1,57 @@ //! Scheduling algorithms for satellite task planning. +//! +//! A scheduler takes a list of tasks and a set of resources and produces a +//! schedule. This crate provides a greedy scheduler and the pieces needed to +//! build others. +//! +//! Scheduling with resource constraints is intractable in general: deciding +//! whether every task fits is a bin packing problem, which is NP-hard. A +//! satellite planning problem is small, with hundreds of tasks rather than +//! millions, but an exact search is still impractical for a routine planning +//! cycle. The scheduler here is therefore a greedy heuristic. +//! +//! The algorithm sorts the tasks and places each in the earliest time at +//! which all of its resources are free together. Tasks are considered in +//! decreasing order of weighted value, which is a good heuristic: the most +//! valuable work is placed first and the least valuable fills the remaining +//! gaps. Mandatory tasks are placed first regardless of their value, because +//! a task that must run is worth more than any task that may. +//! +//! The result is not guaranteed to be optimal, and the crate reports what it +//! could not place rather than dropping it silently, so that an operator can +//! see the cost of the plan. +//! +//! ``` +//! use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +//! use apsis_scheduler::Planner; +//! use apsis_task::{Task, TaskId}; +//! +//! let mut station = Resource::new(ResourceId::new(0), 1.0); +//! station.set_windows(&[Window::new(0.0, 1_000.0)]).expect("usable"); +//! let resources = ResourceSet::from_resources(vec![station]).expect("distinct"); +//! +//! let tasks = vec![Task::new(TaskId::new(0), 60.0) +//! .try_require(ResourceId::new(0), 1.0) +//! .expect("the resource is new")]; +//! +//! let planner = Planner::new(); +//! let result = planner.schedule(&tasks, &resources); +//! assert_eq!(result.schedule.len(), 1); +//! assert!(result.is_complete()); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod greedy; +mod planner; + +pub use greedy::{ + demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at, + resources_have_capacity, +}; +pub use planner::{PlanOutcome, Planner, SchedulingResult}; + +pub use apsis_resource::{ResourceId, ResourceSet}; +pub use apsis_schedule::Schedule; +pub use apsis_task::{Task, TaskId}; diff --git a/crates/ops/apsis-scheduler/src/planner.rs b/crates/ops/apsis-scheduler/src/planner.rs new file mode 100644 index 0000000..b9f356d --- /dev/null +++ b/crates/ops/apsis-scheduler/src/planner.rs @@ -0,0 +1,222 @@ +//! The greedy planner. + +use crate::greedy::{earliest_feasible_start, infeasible_tasks}; +use apsis_resource::ResourceSet; +use apsis_schedule::Schedule; +use apsis_task::{Task, TaskId, TaskState, task_order}; + +/// What happened to one task during planning. +/// +/// The outcome pairs a task with the state the planner assigned, which is the +/// information an operator needs to see. It is separate from the schedule +/// because a task that was not placed has no placement but still has an +/// outcome worth reporting. +#[derive(Debug, Clone, PartialEq)] +pub struct PlanOutcome { + /// The task. + pub task: TaskId, + /// The state the planner assigned. + pub state: TaskState, +} + +impl PlanOutcome { + /// Returns `true` when the task was placed. + pub fn is_scheduled(&self) -> bool { + self.state.is_scheduled() + } + + /// Returns `true` when the task could not be placed for a structural + /// reason, rather than being left out for want of time. + pub fn is_infeasible(&self) -> bool { + matches!(self.state, TaskState::Infeasible { .. }) + } +} + +/// The result of planning. +#[derive(Debug, Clone, PartialEq)] +pub struct SchedulingResult { + /// The schedule that was produced. + pub schedule: Schedule, + /// The outcome of every task, in the order the planner considered them. + pub outcomes: Vec, +} + +impl SchedulingResult { + /// Returns the outcome of a task. + pub fn outcome_of(&self, task: TaskId) -> Option<&PlanOutcome> { + self.outcomes.iter().find(|outcome| outcome.task == task) + } + + /// Returns the identifiers of the tasks that were placed. + pub fn scheduled(&self) -> Vec { + self.outcomes + .iter() + .filter(|outcome| outcome.is_scheduled()) + .map(|outcome| outcome.task) + .collect() + } + + /// Returns the identifiers of the tasks that were left out. + pub fn deferred(&self) -> Vec { + self.outcomes + .iter() + .filter(|outcome| outcome.state == TaskState::Deferred) + .map(|outcome| outcome.task) + .collect() + } + + /// Returns the identifiers of the tasks that cannot be placed at all. + pub fn infeasible(&self) -> Vec { + self.outcomes + .iter() + .filter(|outcome| outcome.is_infeasible()) + .map(|outcome| outcome.task) + .collect() + } + + /// Returns `true` when every offered task was placed. + pub fn is_complete(&self) -> bool { + self.outcomes.iter().all(PlanOutcome::is_scheduled) + } + + /// Returns `true` when every mandatory task was placed. + /// + /// A plan that leaves a mandatory task unplaced is not usable, however + /// much optional work it found room for. + pub fn honours_mandatory(&self, tasks: &[Task]) -> bool { + let mandatory: Vec = tasks + .iter() + .filter(|task| task.mandatory) + .map(|task| task.id) + .collect(); + mandatory.iter().all(|id| self.schedule.is_placed(*id)) + } +} + +/// A greedy planner. +/// +/// The planner holds the settings of the search. The settings are separated +/// from the schedule so that the same planner can be applied to many task +/// sets with the same policy. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Planner { + /// Whether to place mandatory tasks before the others regardless of + /// their value. + /// + /// A mandatory task must run, so it is worth placing first even when its + /// weighted value is low: the optional tasks can use whatever time is + /// left, while the reverse order could consume the only window the + /// mandatory task had. + pub mandatory_first: bool, +} + +impl Default for Planner { + /// Returns a planner that places mandatory tasks first. + fn default() -> Self { + Self { + mandatory_first: true, + } + } +} + +impl Planner { + /// Creates a planner with the default settings. + pub const fn new() -> Self { + Self { + mandatory_first: true, + } + } + + /// Returns a copy of this planner with the mandatory placement rule set. + pub const fn with_mandatory_first(mut self, mandatory_first: bool) -> Self { + self.mandatory_first = mandatory_first; + self + } + + /// Returns the order in which the planner considers the tasks. + /// + /// Mandatory tasks come first when the planner is configured that way, + /// each group ordered by [`task_order`], which is decreasing weighted + /// value with ties broken by deadline and identifier. + pub fn order(&self, tasks: &[Task]) -> Vec { + let ordered = task_order(tasks); + if !self.mandatory_first { + return ordered; + } + let mandatory: Vec = tasks + .iter() + .filter(|task| task.mandatory) + .map(|task| task.id) + .collect(); + // The stable partition keeps the relative order within each group. + let mut result = Vec::with_capacity(ordered.len()); + result.extend(ordered.iter().filter(|id| mandatory.contains(id)).copied()); + result.extend(ordered.iter().filter(|id| !mandatory.contains(id)).copied()); + result + } + + /// Plans a set of tasks on a set of resources. + /// + /// Every task is considered once, in the order the planner chooses. A + /// task is placed at the earliest time at which all of its resources are + /// free together. A task that cannot be placed is reported as deferred, + /// or as infeasible when no placement could ever exist. + /// + /// The schedule that is returned is feasible by construction, because + /// each placement is checked against the capacity before it is made. The + /// caller may confirm that with `apsis-conflict`. + pub fn schedule(&self, tasks: &[Task], resources: &ResourceSet) -> SchedulingResult { + let mut schedule = Schedule::new(); + let mut outcomes = Vec::with_capacity(tasks.len()); + + // Tasks that cannot be placed at all are reported without a search. + let infeasible = infeasible_tasks(tasks, resources); + + for id in self.order(tasks) { + let task = tasks + .iter() + .find(|task| task.id == id) + .expect("the order was built from the task list"); + + if infeasible.contains(&id) { + let reason = if !task.is_valid() { + "the task is not usable".to_string() + } else { + format!("the resources cannot hold the demand of {id}") + }; + outcomes.push(PlanOutcome { + task: id, + state: TaskState::Infeasible { reason }, + }); + continue; + } + + match earliest_feasible_start(task, tasks, &schedule, resources) { + Some(start) => { + // The placement was checked to be feasible, so the call + // succeeds. The schedule may still refuse it if the task + // is already placed, which it is not, because each task + // is considered once. + match schedule.place(task, start) { + Ok(_) => outcomes.push(PlanOutcome { + task: id, + state: TaskState::Scheduled { start }, + }), + Err(error) => outcomes.push(PlanOutcome { + task: id, + state: TaskState::Infeasible { + reason: error.to_string(), + }, + }), + } + } + None => outcomes.push(PlanOutcome { + task: id, + state: TaskState::Deferred, + }), + } + } + + SchedulingResult { schedule, outcomes } + } +} diff --git a/crates/ops/apsis-scheduler/tests/planner.rs b/crates/ops/apsis-scheduler/tests/planner.rs new file mode 100644 index 0000000..d660fcc --- /dev/null +++ b/crates/ops/apsis-scheduler/tests/planner.rs @@ -0,0 +1,431 @@ +//! Tests for the greedy planner. + +use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +use apsis_schedule::Schedule; +use apsis_scheduler::{ + Planner, demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at, + resources_have_capacity, +}; +use apsis_task::{Task, TaskId, TaskState}; + +/// A resource 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 each named resource. +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_single_task_is_placed_at_the_opening_of_its_window() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 60.0, &[0])]; + + let result = Planner::new().schedule(&tasks, &resources); + assert_eq!(result.schedule.len(), 1); + assert!(result.is_complete()); + let placement = result + .schedule + .placement_of(TaskId::new(0)) + .expect("placed"); + // The resource is available from zero, so the task starts there. + assert_eq!(placement.start, 0.0); +} + +#[test] +fn tasks_on_one_exclusive_resource_are_placed_back_to_back() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + // Equal value, so the order falls back to the earlier deadline and then + // the identifier. + let tasks = [task(0, 60.0, &[0]), task(1, 30.0, &[0])]; + + let result = Planner::new().schedule(&tasks, &resources); + assert!(result.is_complete()); + let first = result + .schedule + .placement_of(TaskId::new(0)) + .expect("placed"); + let second = result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed"); + // The two must not overlap: the second starts at or after the end of the + // first. + assert!(second.start >= first.end(), "{second:?} against {first:?}"); + assert!(!result.schedule.has_overlapping_placements()); +} + +#[test] +fn a_task_that_does_not_fit_is_deferred() { + // The resource is available for only sixty seconds, so a task of ninety + // cannot be placed. + let mut limited = Resource::new(ResourceId::new(0), 1.0); + limited + .set_windows(&[Window::new(0.0, 60.0)]) + .expect("the window is usable"); + let resources = ResourceSet::from_resources(vec![limited]).expect("one"); + let tasks = [task(0, 90.0, &[0])]; + + let result = Planner::new().schedule(&tasks, &resources); + assert!(!result.is_complete()); + assert_eq!(result.deferred(), vec![TaskId::new(0)]); + assert_eq!( + result.outcome_of(TaskId::new(0)).expect("reported").state, + TaskState::Deferred + ); + assert!(result.schedule.is_empty()); +} + +#[test] +fn a_task_demanding_more_than_the_capacity_is_infeasible() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [Task::new(TaskId::new(0), 60.0) + .try_require(ResourceId::new(0), 2.0) + .expect("the resource is new")]; + + let result = Planner::new().schedule(&tasks, &resources); + assert_eq!(result.infeasible(), vec![TaskId::new(0)]); + assert!(result.schedule.is_empty()); + assert!(!result.is_complete()); + assert!( + result + .outcome_of(TaskId::new(0)) + .expect("reported") + .is_infeasible() + ); +} + +#[test] +fn a_task_requiring_a_missing_resource_is_infeasible() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 60.0, &[9])]; + + let result = Planner::new().schedule(&tasks, &resources); + assert_eq!(result.infeasible(), vec![TaskId::new(0)]); + assert!(result.schedule.is_empty()); +} + +#[test] +fn a_task_with_a_window_too_short_is_infeasible() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 90.0, &[0]).with_window(0.0, 60.0)]; + + let result = Planner::new().schedule(&tasks, &resources); + assert_eq!(result.infeasible(), vec![TaskId::new(0)]); +} + +#[test] +fn a_mandatory_task_is_placed_before_a_more_valuable_optional_one() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + // The optional task is worth far more, but it can use whatever time is + // left, while the mandatory task has only one window. + let optional = task(0, 60.0, &[0]) + .with_priority(100.0) + .with_window(0.0, 1_000.0); + let mandatory = task(1, 60.0, &[0]) + .with_priority(1.0) + .with_window(0.0, 100.0) + .with_mandatory(true); + let tasks = [optional, mandatory]; + + let result = Planner::new().schedule(&tasks, &resources); + assert!(result.honours_mandatory(&tasks)); + let mandatory_placement = result + .schedule + .placement_of(TaskId::new(1)) + .expect("the mandatory task is placed"); + assert_eq!(mandatory_placement.start, 0.0); + + // With the rule turned off, the optional task goes first and the + // mandatory one is placed after it, using the rest of its window. + let without = Planner::new() + .with_mandatory_first(false) + .schedule(&tasks, &resources); + let optional_placement = without + .schedule + .placement_of(TaskId::new(0)) + .expect("the optional task is placed"); + assert_eq!(optional_placement.start, 0.0); +} + +#[test] +fn the_value_ordering_places_the_most_valuable_task_first() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [ + task(0, 10.0, &[0]).with_priority(1.0), + task(1, 100.0, &[0]).with_priority(5.0), + task(2, 50.0, &[0]).with_priority(2.0), + ]; + + let result = Planner::new().schedule(&tasks, &resources); + assert!(result.is_complete()); + // The weighted values are 10, 500, and 100, so task 1 goes first. + assert_eq!( + result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed") + .start, + 0.0 + ); +} + +#[test] +fn two_tasks_on_a_shared_resource_can_overlap() { + let resources = ResourceSet::from_resources(vec![resource(0, 2.0)]).expect("one"); + let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + + let result = Planner::new().schedule(&tasks, &resources); + assert!(result.is_complete()); + let first = result + .schedule + .placement_of(TaskId::new(0)) + .expect("placed"); + let second = result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed"); + // Both start as early as possible, because the capacity holds them both. + assert_eq!(first.start, 0.0); + assert_eq!(second.start, 0.0); + assert!(result.schedule.has_overlapping_placements()); +} + +#[test] +fn a_task_is_placed_after_the_other_frees_its_resource() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + // The first task occupies the resource from 100 to 200, so the second, + // which can start no earlier than 100, must wait. + let first = task(0, 100.0, &[0]).with_window(100.0, 1_000.0); + let second = task(1, 50.0, &[0]).with_window(100.0, 1_000.0); + let tasks = [first, second]; + + let result = Planner::new().schedule(&tasks, &resources); + assert!(result.is_complete()); + let first_placement = result + .schedule + .placement_of(TaskId::new(0)) + .expect("placed"); + let second_placement = result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed"); + assert!(second_placement.start >= first_placement.end()); +} + +#[test] +fn the_earliest_start_is_the_false_of_the_window_when_nothing_blocks() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let schedule = Schedule::new(); + let tasks = [task(0, 60.0, &[0]).with_window(300.0, 1_000.0)]; + + let start = earliest_feasible_start(&tasks[0], &tasks, &schedule, &resources) + .expect("a placement exists"); + assert_eq!(start, 300.0); +} + +#[test] +fn the_earliest_start_is_none_when_the_window_is_too_short() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let schedule = Schedule::new(); + let tasks = [task(0, 90.0, &[0]).with_window(0.0, 60.0)]; + assert_eq!( + earliest_feasible_start(&tasks[0], &tasks, &schedule, &resources), + None + ); +} + +#[test] +fn the_earliest_start_accounts_for_a_blocking_placement() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let blocker = task(0, 100.0, &[0]); + let mut schedule = Schedule::new(); + schedule.place(&blocker, 0.0).expect("fits"); + + let candidate = task(1, 50.0, &[0]); + let tasks = [blocker, candidate.clone()]; + let start = earliest_feasible_start(&candidate, &tasks, &schedule, &resources) + .expect("a placement exists"); + // The resource is held until a hundred, so the earliest start is there. + assert_eq!(start, 100.0); +} + +#[test] +fn feasibility_reports_the_capacity_limit() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let existing = task(0, 100.0, &[0]); + let mut schedule = Schedule::new(); + schedule.place(&existing, 0.0).expect("fits"); + + let candidate = task(1, 50.0, &[0]); + let tasks = [existing, candidate.clone()]; + // Starting at fifty would overlap the existing placement, which the + // capacity of one does not allow. + assert!(!feasible_at( + &candidate, &tasks, &schedule, &resources, 50.0 + )); + // Starting at a hundred is free. + assert!(feasible_at( + &candidate, &tasks, &schedule, &resources, 100.0 + )); +} + +#[test] +fn feasibility_reports_the_availability_limit() { + let mut limited = Resource::new(ResourceId::new(0), 1.0); + limited + .set_windows(&[Window::new(0.0, 100.0)]) + .expect("the window is usable"); + let resources = ResourceSet::from_resources(vec![limited]).expect("one"); + let schedule = Schedule::new(); + let tasks = [task(0, 60.0, &[0])]; + + assert!(feasible_at(&tasks[0], &tasks, &schedule, &resources, 0.0)); + assert!(feasible_at(&tasks[0], &tasks, &schedule, &resources, 40.0)); + // Starting at fifty would end at one hundred and ten, past the window. + assert!(!feasible_at(&tasks[0], &tasks, &schedule, &resources, 50.0)); +} + +#[test] +fn the_capacity_check_covers_a_multiple_resource_task() { + let resources = + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct"); + // The task needs both resources at once. + let tasks = [task(0, 60.0, &[0, 1])]; + assert!(resources_have_capacity(&tasks[0], &resources)); + + // The same task with one resource missing is infeasible. + let missing = [task(0, 60.0, &[0, 9])]; + assert!(!resources_have_capacity(&missing[0], &resources)); + assert_eq!(infeasible_tasks(&missing, &resources), vec![TaskId::new(0)]); +} + +#[test] +fn the_demand_at_an_instant_is_reported_per_resource() { + let resources = + ResourceSet::from_resources(vec![resource(0, 2.0), resource(1, 1.0)]).expect("distinct"); + let tasks = [task(0, 100.0, &[0]), task(1, 100.0, &[1])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 50.0).expect("fits"); + + let demand = demand_at(&schedule, &tasks, &resources, 75.0); + assert_eq!(demand[0], (ResourceId::new(0), 1.0)); + assert_eq!(demand[1], (ResourceId::new(1), 1.0)); + // Before either placement the demand is zero. + let demand = demand_at(&schedule, &tasks, &resources, -1.0); + assert_eq!(demand[0].1, 0.0); +} + +#[test] +fn the_free_resources_at_an_instant_exclude_the_busy_ones() { + let resources = + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct"); + let tasks = [task(0, 100.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + + // At fifty seconds resource zero is full and resource one is free. + assert_eq!( + resources_free_at(&schedule, &tasks, &resources, 50.0), + vec![ResourceId::new(1)] + ); + // At a hundred and fifty both are free. + assert_eq!( + resources_free_at(&schedule, &tasks, &resources, 150.0), + vec![ResourceId::new(0), ResourceId::new(1)] + ); +} + +#[test] +fn the_planner_order_puts_mandatory_tasks_first() { + let tasks = [ + task(0, 10.0, &[0]).with_priority(10.0), + task(1, 10.0, &[0]).with_priority(1.0).with_mandatory(true), + ]; + let order = Planner::new().order(&tasks); + assert_eq!(order[0], TaskId::new(1)); + + let without = Planner::new().with_mandatory_first(false).order(&tasks); + assert_eq!(without[0], TaskId::new(0)); +} + +#[test] +fn the_result_reports_every_task() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [ + task(0, 60.0, &[0]), + task(1, 90.0, &[0]).with_window(0.0, 60.0), + Task::new(TaskId::new(2), 60.0) + .try_require(ResourceId::new(0), 5.0) + .expect("the resource is new"), + ]; + + let result = Planner::new().schedule(&tasks, &resources); + assert_eq!(result.outcomes.len(), 3); + for task in &tasks { + assert!(result.outcome_of(task.id).is_some()); + } + // Only the first can be placed. + assert_eq!(result.scheduled(), vec![TaskId::new(0)]); + assert!(!result.is_complete()); + assert!(result.honours_mandatory(&tasks)); +} + +#[test] +fn a_mandatory_task_that_cannot_be_placed_is_reported() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 90.0, &[0]) + .with_window(0.0, 60.0) + .with_mandatory(true)]; + let result = Planner::new().schedule(&tasks, &resources); + // The plan does not honour the mandatory task, which the caller can + // detect. + assert!(!result.honours_mandatory(&tasks)); + assert!(!result.is_complete()); +} + +#[test] +fn planning_an_empty_task_list_gives_an_empty_schedule() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let result = Planner::new().schedule(&[], &resources); + assert!(result.schedule.is_empty()); + assert!(result.outcomes.is_empty()); + assert!(result.is_complete()); + assert!(result.honours_mandatory(&[])); +} + +#[test] +fn the_planner_default_places_mandatory_tasks_first() { + assert_eq!(Planner::default(), Planner::new()); + assert!(Planner::new().mandatory_first); +} + +#[test] +fn the_infeasible_task_list_excludes_the_merely_deferred() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [ + // Can be placed. + task(0, 60.0, &[0]), + // Demands too much. + Task::new(TaskId::new(1), 60.0) + .try_require(ResourceId::new(0), 5.0) + .expect("the resource is new"), + // Names a resource that does not exist. + task(2, 60.0, &[9]), + ]; + let infeasible = infeasible_tasks(&tasks, &resources); + assert_eq!(infeasible, vec![TaskId::new(1), TaskId::new(2)]); +}