ops: add greedy task planner

This commit is contained in:
ApsisRS contributors 2026-09-26 17:07:12 +08:00
parent 6aeaa29dc1
commit 56a7aacd91
6 changed files with 939 additions and 0 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

@ -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<ResourceId> {
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<f64> {
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<f64> = 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<f64> = 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<TaskId> {
tasks
.iter()
.filter(|task| !task.is_valid() || !resources_have_capacity(task, resources))
.map(|task| task.id)
.collect()
}

View File

@ -1 +1,57 @@
//! Scheduling algorithms for satellite task planning. //! 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};

View File

@ -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<PlanOutcome>,
}
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<TaskId> {
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<TaskId> {
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<TaskId> {
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<TaskId> = 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<TaskId> {
let ordered = task_order(tasks);
if !self.mandatory_first {
return ordered;
}
let mandatory: Vec<TaskId> = 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 }
}
}

View File

@ -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)]);
}