ops: add schedule conflict detection
This commit is contained in:
parent
305db01140
commit
f8fd175266
|
|
@ -31,6 +31,12 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "apsis-conflict"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-resource",
|
||||
"apsis-schedule",
|
||||
"apsis-task",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-constellation"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
//! The conflict type and the report that collects them.
|
||||
|
||||
use apsis_resource::ResourceId;
|
||||
use apsis_task::TaskId;
|
||||
use core::fmt;
|
||||
|
||||
/// The kind of problem a conflict describes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum ConflictKind {
|
||||
/// Two placements need more of a resource than it has.
|
||||
Capacity,
|
||||
/// A placement falls outside the time window of its task.
|
||||
Window,
|
||||
/// A placement does not agree with its task.
|
||||
Requirement,
|
||||
/// A placement falls outside the availability of a resource.
|
||||
Availability,
|
||||
/// A task that must follow another starts too early.
|
||||
Precedence,
|
||||
}
|
||||
|
||||
impl fmt::Display for ConflictKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let text = match self {
|
||||
Self::Capacity => "capacity",
|
||||
Self::Window => "window",
|
||||
Self::Requirement => "requirement",
|
||||
Self::Availability => "availability",
|
||||
Self::Precedence => "precedence",
|
||||
};
|
||||
f.write_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// One problem found in a schedule.
|
||||
///
|
||||
/// The conflict names the tasks involved and, where it applies, the resource
|
||||
/// and the instant at which the problem occurs. That is enough for an
|
||||
/// operator to locate the placement without re-deriving the analysis.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Conflict {
|
||||
/// The kind of problem.
|
||||
pub kind: ConflictKind,
|
||||
/// The task the conflict is centred on.
|
||||
pub task: TaskId,
|
||||
/// The other task involved, for a conflict between two placements.
|
||||
pub other: Option<TaskId>,
|
||||
/// The resource involved, where the conflict is about one.
|
||||
pub resource: Option<ResourceId>,
|
||||
/// The time at which the problem occurs, in seconds.
|
||||
///
|
||||
/// For a capacity conflict this is the instant at which the demand
|
||||
/// exceeds the capacity. For a window conflict it is the offending edge
|
||||
/// of the placement.
|
||||
pub at: f64,
|
||||
/// A description of the problem.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Conflict {
|
||||
/// Creates a conflict.
|
||||
pub fn new(kind: ConflictKind, task: TaskId, at: f64, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
task,
|
||||
other: None,
|
||||
resource: None,
|
||||
at,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a copy of this conflict involving another task.
|
||||
pub fn with_other(mut self, other: TaskId) -> Self {
|
||||
self.other = Some(other);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a copy of this conflict involving a resource.
|
||||
pub fn with_resource(mut self, resource: ResourceId) -> Self {
|
||||
self.resource = Some(resource);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns `true` when the conflict concerns a placement that should be
|
||||
/// moved rather than one that should be dropped.
|
||||
///
|
||||
/// A capacity or availability conflict can often be repaired by moving
|
||||
/// the placement, while a requirement conflict means the placement does
|
||||
/// not belong in the schedule at all.
|
||||
pub const fn is_movable(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
ConflictKind::Capacity | ConflictKind::Availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Conflict {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} conflict on {} at {}: {}",
|
||||
self.kind, self.task, self.at, self.detail
|
||||
)?;
|
||||
if let Some(other) = self.other {
|
||||
write!(f, " (with {other})")?;
|
||||
}
|
||||
if let Some(resource) = self.resource {
|
||||
write!(f, " (on {resource})")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The conflicts found in a schedule.
|
||||
///
|
||||
/// The report is ordered by time and then by kind, so that reading it
|
||||
/// follows the plan. The counts by kind are provided because a planner
|
||||
/// usually wants to know how many of each problem there are before deciding
|
||||
/// what to do.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ConflictReport {
|
||||
conflicts: Vec<Conflict>,
|
||||
offered_tasks: usize,
|
||||
placed_tasks: usize,
|
||||
}
|
||||
|
||||
impl ConflictReport {
|
||||
/// Creates a report from a list of conflicts and the task counts.
|
||||
///
|
||||
/// The conflicts are sorted by time, then by kind, then by task, so that
|
||||
/// the order does not depend on the order they were found.
|
||||
pub fn new(mut conflicts: Vec<Conflict>, offered_tasks: usize, placed_tasks: usize) -> Self {
|
||||
conflicts.sort_by(|first, second| {
|
||||
first
|
||||
.at
|
||||
.partial_cmp(&second.at)
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
.then_with(|| kind_order(first.kind).cmp(&kind_order(second.kind)))
|
||||
.then_with(|| first.task.cmp(&second.task))
|
||||
.then_with(|| first.other.cmp(&second.other))
|
||||
});
|
||||
Self {
|
||||
conflicts,
|
||||
offered_tasks,
|
||||
placed_tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conflicts, in time order.
|
||||
pub fn conflicts(&self) -> &[Conflict] {
|
||||
&self.conflicts
|
||||
}
|
||||
|
||||
/// Returns the number of conflicts.
|
||||
pub fn len(&self) -> usize {
|
||||
self.conflicts.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when there are no conflicts.
|
||||
///
|
||||
/// This is the conventional spelling of [`ConflictReport::is_clear`], and
|
||||
/// the two always agree.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// Returns `true` when there are no conflicts.
|
||||
///
|
||||
/// A clear report means the schedule is feasible with respect to every
|
||||
/// check this crate performs, not that it is optimal.
|
||||
pub fn is_clear(&self) -> bool {
|
||||
self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// Returns `true` when there is at least one conflict.
|
||||
pub fn has_conflicts(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the number of conflicts of one kind.
|
||||
pub fn count_of(&self, kind: ConflictKind) -> usize {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| conflict.kind == kind)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Returns the conflicts of one kind, in time order.
|
||||
pub fn of_kind(&self, kind: ConflictKind) -> Vec<&Conflict> {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| conflict.kind == kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the tasks involved in at least one conflict.
|
||||
///
|
||||
/// A task appears once even when it has several conflicts, which is what
|
||||
/// a repair pass wants: the set of tasks to reconsider.
|
||||
pub fn conflicting_tasks(&self) -> Vec<TaskId> {
|
||||
let mut tasks: Vec<TaskId> = Vec::new();
|
||||
for conflict in &self.conflicts {
|
||||
if !tasks.contains(&conflict.task) {
|
||||
tasks.push(conflict.task);
|
||||
}
|
||||
if let Some(other) = conflict.other {
|
||||
if !tasks.contains(&other) {
|
||||
tasks.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks
|
||||
}
|
||||
|
||||
/// Returns the number of tasks that were offered to the schedule.
|
||||
pub fn offered_tasks(&self) -> usize {
|
||||
self.offered_tasks
|
||||
}
|
||||
|
||||
/// Returns the number of tasks that were placed.
|
||||
pub fn placed_tasks(&self) -> usize {
|
||||
self.placed_tasks
|
||||
}
|
||||
|
||||
/// Returns a one line summary of the report.
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"{} conflict(s) among {} placed of {} offered task(s)",
|
||||
self.conflicts.len(),
|
||||
self.placed_tasks,
|
||||
self.offered_tasks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a sort key that groups the kinds in a readable order.
|
||||
///
|
||||
/// The order runs from the problems that invalidate a plan to the ones that
|
||||
/// merely make it expensive, so that reading the report top to bottom
|
||||
/// addresses the most serious problems first.
|
||||
const fn kind_order(kind: ConflictKind) -> u8 {
|
||||
match kind {
|
||||
ConflictKind::Requirement => 0,
|
||||
ConflictKind::Window => 1,
|
||||
ConflictKind::Capacity => 2,
|
||||
ConflictKind::Availability => 3,
|
||||
ConflictKind::Precedence => 4,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
//! The conflict detection itself.
|
||||
|
||||
use crate::conflict::{Conflict, ConflictKind, ConflictReport};
|
||||
use apsis_resource::{ResourceId, ResourceSet};
|
||||
use apsis_schedule::Schedule;
|
||||
use apsis_task::{Task, TaskId};
|
||||
|
||||
/// A precedence constraint: one task must finish before another starts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Precedence {
|
||||
/// The task that must finish first.
|
||||
pub before: TaskId,
|
||||
/// The task that must start after.
|
||||
pub after: TaskId,
|
||||
}
|
||||
|
||||
impl Precedence {
|
||||
/// Creates a precedence constraint.
|
||||
pub const fn new(before: TaskId, after: TaskId) -> Self {
|
||||
Self { before, after }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the placements of a task, which is at most one.
|
||||
pub fn placements_of(schedule: &Schedule, task: TaskId) -> Option<&apsis_schedule::Placement> {
|
||||
schedule.placement_of(task)
|
||||
}
|
||||
|
||||
/// Returns every conflict in a schedule.
|
||||
///
|
||||
/// The checks are performed in full: the report contains every problem found,
|
||||
/// not just the first. That is deliberate, because a planner that is handed a
|
||||
/// plan usually wants to know how bad it is before deciding whether to repair
|
||||
/// or rebuild.
|
||||
///
|
||||
/// The checks are:
|
||||
///
|
||||
/// - every placement's task exists in the list, and the durations agree;
|
||||
/// - every placement falls inside its task's window;
|
||||
/// - no resource exceeds its capacity at any instant;
|
||||
/// - every placement falls inside the availability windows of each resource
|
||||
/// it requires.
|
||||
///
|
||||
/// The resource sweep examines the boundaries of the placements, because the
|
||||
/// total demand on a resource is constant between them, so the check is exact
|
||||
/// and takes time proportional to the number of placements rather than to the
|
||||
/// length of the plan.
|
||||
pub fn detect(schedule: &Schedule, tasks: &[Task], resources: &ResourceSet) -> ConflictReport {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// The requirement and window checks need only the placement and its
|
||||
// task.
|
||||
for placement in schedule.placements() {
|
||||
let task = tasks.iter().find(|task| task.id == placement.task);
|
||||
let Some(task) = task else {
|
||||
conflicts.push(
|
||||
Conflict::new(
|
||||
ConflictKind::Requirement,
|
||||
placement.task,
|
||||
placement.start,
|
||||
"the task is not in the task list",
|
||||
)
|
||||
.with_resource(ResourceId::new(0)),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if (placement.duration - task.duration).abs() > 1e-12 {
|
||||
conflicts.push(Conflict::new(
|
||||
ConflictKind::Requirement,
|
||||
placement.task,
|
||||
placement.start,
|
||||
format!(
|
||||
"placed for {} s but the task lasts {} s",
|
||||
placement.duration, task.duration
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if placement.start < task.earliest_start {
|
||||
conflicts.push(Conflict::new(
|
||||
ConflictKind::Window,
|
||||
placement.task,
|
||||
placement.start,
|
||||
format!(
|
||||
"starts at {}, before the window opens at {}",
|
||||
placement.start, task.earliest_start
|
||||
),
|
||||
));
|
||||
}
|
||||
if placement.end() > task.latest_end {
|
||||
conflicts.push(Conflict::new(
|
||||
ConflictKind::Window,
|
||||
placement.task,
|
||||
placement.end(),
|
||||
format!(
|
||||
"ends at {}, after the window closes at {}",
|
||||
placement.end(),
|
||||
task.latest_end
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The availability check: a placement must fall inside a window of every
|
||||
// resource it requires.
|
||||
for placement in schedule.placements() {
|
||||
let Some(task) = tasks.iter().find(|task| task.id == placement.task) else {
|
||||
continue;
|
||||
};
|
||||
for requirement in &task.requirements {
|
||||
let Some(resource) = resources.get(requirement.resource) else {
|
||||
conflicts.push(
|
||||
Conflict::new(
|
||||
ConflictKind::Requirement,
|
||||
placement.task,
|
||||
placement.start,
|
||||
format!("{} does not exist", requirement.resource),
|
||||
)
|
||||
.with_resource(requirement.resource),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// A demand greater than the capacity can never be met, which is a
|
||||
// requirement problem rather than a placement problem.
|
||||
if requirement.demand > resource.capacity() + 1e-9 {
|
||||
conflicts.push(
|
||||
Conflict::new(
|
||||
ConflictKind::Requirement,
|
||||
placement.task,
|
||||
placement.start,
|
||||
format!(
|
||||
"demand {} exceeds the capacity {} of {}",
|
||||
requirement.demand,
|
||||
resource.capacity(),
|
||||
resource.id
|
||||
),
|
||||
)
|
||||
.with_resource(requirement.resource),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !resource.is_available_over(placement.start, placement.duration) {
|
||||
conflicts.push(
|
||||
Conflict::new(
|
||||
ConflictKind::Availability,
|
||||
placement.task,
|
||||
placement.start,
|
||||
format!(
|
||||
"{} is not available from {} for {} s",
|
||||
resource.id, placement.start, placement.duration
|
||||
),
|
||||
)
|
||||
.with_resource(requirement.resource),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The capacity check, one resource at a time.
|
||||
for resource in resources.resources() {
|
||||
// Each event is a time, a change in demand, and the task responsible,
|
||||
// so that a conflict can name the pair.
|
||||
let mut events: Vec<(f64, f64, TaskId)> = Vec::new();
|
||||
for placement in schedule.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, placement.task));
|
||||
events.push((placement.end(), -demand, placement.task));
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A release sorts before an acquisition at the same instant, so 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)
|
||||
})
|
||||
.then_with(|| first.2.cmp(&second.2))
|
||||
});
|
||||
|
||||
let mut current = 0.0;
|
||||
let mut holders: Vec<TaskId> = Vec::new();
|
||||
for (time, change, task) in events {
|
||||
if change > 0.0 {
|
||||
holders.push(task);
|
||||
} else {
|
||||
holders.retain(|holder| *holder != task);
|
||||
}
|
||||
current += change;
|
||||
if current > resource.capacity() + 1e-9 {
|
||||
// Report the pair that first pushes the resource over, so
|
||||
// that the conflict names two tasks rather than only the one
|
||||
// that arrived last.
|
||||
let mut involved = holders.clone();
|
||||
involved.sort();
|
||||
let first = involved.first().copied();
|
||||
let second = involved.get(1).copied();
|
||||
let detail = format!(
|
||||
"demand {} exceeds the capacity {} of {}",
|
||||
current,
|
||||
resource.capacity(),
|
||||
resource.id
|
||||
);
|
||||
let mut conflict =
|
||||
Conflict::new(ConflictKind::Capacity, first.unwrap_or(task), time, detail)
|
||||
.with_resource(resource.id);
|
||||
if let Some(second) = second {
|
||||
conflict = conflict.with_other(second);
|
||||
}
|
||||
conflicts.push(conflict);
|
||||
// Report each resource once, at the first instant it is over
|
||||
// committed. A second report at a later instant would add
|
||||
// nothing a repair pass can use.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConflictReport::new(conflicts, tasks.len(), schedule.len())
|
||||
}
|
||||
|
||||
/// Returns the precedence conflicts among a set of constraints.
|
||||
///
|
||||
/// A precedence conflict occurs when the task that must come second starts
|
||||
/// before the one that must come first has finished. A constraint whose
|
||||
/// tasks are not both placed is not a conflict, because nothing has been
|
||||
/// promised about them.
|
||||
///
|
||||
/// The constraints are checked as given, so they must be acyclic. A cycle is
|
||||
/// a contradiction in the caller's own model and is not detected here.
|
||||
pub fn detect_precedence(schedule: &Schedule, constraints: &[Precedence]) -> Vec<Conflict> {
|
||||
let mut conflicts = Vec::new();
|
||||
for constraint in constraints {
|
||||
let (Some(before), Some(after)) = (
|
||||
schedule.placement_of(constraint.before),
|
||||
schedule.placement_of(constraint.after),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if after.start < before.end() {
|
||||
conflicts.push(
|
||||
Conflict::new(
|
||||
ConflictKind::Precedence,
|
||||
constraint.after,
|
||||
after.start,
|
||||
format!(
|
||||
"{} must follow {} but starts at {} before it ends at {}",
|
||||
constraint.after,
|
||||
constraint.before,
|
||||
after.start,
|
||||
before.end()
|
||||
),
|
||||
)
|
||||
.with_other(constraint.before),
|
||||
);
|
||||
}
|
||||
}
|
||||
conflicts
|
||||
}
|
||||
|
|
@ -1 +1,58 @@
|
|||
//! Resource and time conflict detection.
|
||||
//!
|
||||
//! A schedule can be wrong in several ways, and this crate finds them. It
|
||||
//! reports conflicts rather than refusing them, so that a plan loaded from a
|
||||
//! file can be checked in full and every problem listed at once instead of
|
||||
//! one per attempt.
|
||||
//!
|
||||
//! Five kinds of conflict are detected:
|
||||
//!
|
||||
//! - **Capacity**: two placements need more of a resource at the same instant
|
||||
//! than the resource has.
|
||||
//! - **Window**: a placement starts before its task may, or ends after it
|
||||
//! must.
|
||||
//! - **Requirement**: a placement's task is not known, or its duration does
|
||||
//! not match the schedule.
|
||||
//! - **Availability**: a placement falls outside the availability windows of
|
||||
//! a resource it needs, so the resource would not actually be there.
|
||||
//! - **Precedence**: a task that must follow another starts before it ends.
|
||||
//!
|
||||
//! The distinction between a capacity conflict and an availability conflict
|
||||
//! matters in practice: the first says the plan is over committed, the second
|
||||
//! says the plan asks for a resource at a time when it is switched off or out
|
||||
//! of view, which is a different repair.
|
||||
//!
|
||||
//! ```
|
||||
//! use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||
//! use apsis_schedule::Schedule;
|
||||
//! use apsis_task::{Task, TaskId};
|
||||
//! use apsis_conflict::{ConflictKind, detect};
|
||||
//!
|
||||
//! 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 task = Task::new(TaskId::new(0), 60.0)
|
||||
//! .try_require(ResourceId::new(0), 1.0)
|
||||
//! .expect("the resource is new");
|
||||
//! let mut schedule = Schedule::new();
|
||||
//! schedule.place(&task, 100.0).expect("the task fits");
|
||||
//!
|
||||
//! let report = detect(&schedule, &[task], &resources);
|
||||
//! assert!(report.is_clear());
|
||||
//! assert_eq!(report.conflicts().len(), 0);
|
||||
//! let _ = ConflictKind::Capacity;
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod conflict;
|
||||
mod detect;
|
||||
|
||||
pub use conflict::{Conflict, ConflictKind, ConflictReport};
|
||||
pub use detect::{Precedence, detect, detect_precedence, placements_of};
|
||||
|
||||
pub use apsis_resource::{ResourceId, ResourceSet};
|
||||
pub use apsis_schedule::Schedule;
|
||||
pub use apsis_task::{Task, TaskId};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,410 @@
|
|||
//! Tests for conflict detection.
|
||||
|
||||
use apsis_conflict::{Conflict, ConflictKind, Precedence, Schedule, detect, detect_precedence};
|
||||
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||
use apsis_task::{Task, TaskId};
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// The standard resource set used by the tests.
|
||||
fn resources() -> ResourceSet {
|
||||
ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)])
|
||||
.expect("the identifiers are distinct")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sound_schedule_has_no_conflicts() {
|
||||
let resources = resources();
|
||||
let tasks = vec![task(0, 60.0, &[0]), task(1, 30.0, &[1])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||
schedule.place(&tasks[1], 100.0).expect("fits");
|
||||
|
||||
let report = detect(&schedule, &tasks, &resources);
|
||||
assert!(report.is_clear());
|
||||
assert!(report.is_empty());
|
||||
assert!(!report.has_conflicts());
|
||||
assert_eq!(report.len(), 0);
|
||||
assert_eq!(report.placed_tasks(), 2);
|
||||
assert_eq!(report.offered_tasks(), 2);
|
||||
assert!(report.conflicting_tasks().is_empty());
|
||||
assert!(report.summary().contains("0 conflict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capacity_conflict_is_found() {
|
||||
let resources = resources();
|
||||
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 report = detect(&schedule, &tasks, &resources);
|
||||
assert!(report.has_conflicts());
|
||||
assert_eq!(report.count_of(ConflictKind::Capacity), 1);
|
||||
let conflict = report.of_kind(ConflictKind::Capacity)[0];
|
||||
assert_eq!(conflict.resource, Some(ResourceId::new(0)));
|
||||
// The conflict names both tasks, so a repair pass knows the pair.
|
||||
assert!(conflict.other.is_some());
|
||||
// The instant reported is the start of the second placement.
|
||||
assert_eq!(conflict.at, 30.0);
|
||||
assert!(conflict.is_movable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_to_back_placements_do_not_conflict() {
|
||||
let resources = resources();
|
||||
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!(detect(&schedule, &tasks, &resources).is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_conflict_is_found_at_the_start() {
|
||||
let resources = resources();
|
||||
let tasks = [task(0, 60.0, &[0]).with_window(100.0, 1_000.0)];
|
||||
let mut schedule = Schedule::new();
|
||||
// The schedule refuses a placement before the window, so the conflict is
|
||||
// constructed directly to test the detector.
|
||||
schedule.place(&tasks[0], 100.0).expect("fits");
|
||||
// A task list whose window has moved since the schedule was built.
|
||||
let moved = vec![task(0, 60.0, &[0]).with_window(200.0, 1_000.0)];
|
||||
|
||||
let report = detect(&schedule, &moved, &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Window), 1);
|
||||
let conflict = report.of_kind(ConflictKind::Window)[0];
|
||||
assert_eq!(conflict.at, 100.0);
|
||||
assert!(!conflict.is_movable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_conflict_is_found_at_the_end() {
|
||||
let resources = resources();
|
||||
let tasks = [task(0, 60.0, &[0]).with_window(0.0, 1_000.0)];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 100.0).expect("fits");
|
||||
// A task list whose window closes earlier than the placement ends.
|
||||
let shortened = vec![task(0, 60.0, &[0]).with_window(0.0, 120.0)];
|
||||
|
||||
let report = detect(&schedule, &shortened, &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Window), 1);
|
||||
let conflict = report.of_kind(ConflictKind::Window)[0];
|
||||
// The instant reported is the end of the placement.
|
||||
assert_eq!(conflict.at, 160.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requirement_conflict_is_found_for_a_missing_task() {
|
||||
let resources = resources();
|
||||
let tasks = [task(0, 60.0, &[0])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||
|
||||
// The task list is empty, so the placement has no task.
|
||||
let report = detect(&schedule, &[], &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
|
||||
assert_eq!(report.placed_tasks(), 1);
|
||||
assert_eq!(report.offered_tasks(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requirement_conflict_is_found_for_a_duration_mismatch() {
|
||||
let resources = resources();
|
||||
let original = task(0, 60.0, &[0]);
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&original, 0.0).expect("fits");
|
||||
|
||||
let changed = vec![task(0, 90.0, &[0])];
|
||||
let report = detect(&schedule, &changed, &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requirement_conflict_is_found_for_a_missing_resource() {
|
||||
let resources = resources();
|
||||
let tasks = vec![task(0, 60.0, &[9])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||
|
||||
let report = detect(&schedule, &tasks, &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
|
||||
let conflict = report.of_kind(ConflictKind::Requirement)[0];
|
||||
assert_eq!(conflict.resource, Some(ResourceId::new(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requirement_conflict_is_found_for_an_impossible_demand() {
|
||||
let resources = resources();
|
||||
// A demand of two on a resource of capacity one can never be met.
|
||||
let tasks = vec![
|
||||
Task::new(TaskId::new(0), 60.0)
|
||||
.try_require(ResourceId::new(0), 2.0)
|
||||
.expect("the resource is new"),
|
||||
];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||
|
||||
let report = detect(&schedule, &tasks, &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
|
||||
let conflict = report.of_kind(ConflictKind::Requirement)[0];
|
||||
assert!(conflict.detail.contains("exceeds the capacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_availability_conflict_is_found_outside_the_windows() {
|
||||
// The resource is available only from 200 to 300 seconds.
|
||||
let mut limited = Resource::new(ResourceId::new(0), 1.0);
|
||||
limited
|
||||
.set_windows(&[Window::new(200.0, 300.0)])
|
||||
.expect("the window is usable");
|
||||
let resources = ResourceSet::from_resources(vec![limited]).expect("distinct");
|
||||
|
||||
let tasks = vec![task(0, 60.0, &[0])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||
|
||||
let report = detect(&schedule, &tasks, &resources);
|
||||
assert_eq!(report.count_of(ConflictKind::Availability), 1);
|
||||
let conflict = report.of_kind(ConflictKind::Availability)[0];
|
||||
assert_eq!(conflict.resource, Some(ResourceId::new(0)));
|
||||
assert!(conflict.is_movable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_availability_conflict_is_found_for_a_placement_that_overruns_a_window() {
|
||||
// The resource is available only from 0 to 120 seconds, so a placement
|
||||
// from 100 for 60 seconds overruns it.
|
||||
let mut limited = Resource::new(ResourceId::new(0), 1.0);
|
||||
limited
|
||||
.set_windows(&[Window::new(0.0, 120.0)])
|
||||
.expect("the window is usable");
|
||||
let resources = ResourceSet::from_resources(vec![limited]).expect("distinct");
|
||||
|
||||
let tasks = vec![task(0, 60.0, &[0])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 100.0).expect("fits");
|
||||
assert_eq!(
|
||||
detect(&schedule, &tasks, &resources).count_of(ConflictKind::Availability),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placement_inside_the_availability_has_no_conflict() {
|
||||
let resources = resources();
|
||||
let tasks = vec![task(0, 60.0, &[0])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 200.0).expect("fits");
|
||||
assert!(detect(&schedule, &tasks, &resources).is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_conflicts_are_reported_together() {
|
||||
// The detector reports every problem, not just the first.
|
||||
let resources = resources();
|
||||
let tasks = [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");
|
||||
|
||||
// A task list that also moves the window of the first task.
|
||||
let changed = vec![
|
||||
task(0, 60.0, &[0]).with_window(100.0, 1_000.0),
|
||||
task(1, 60.0, &[0]),
|
||||
];
|
||||
let report = detect(&schedule, &changed, &resources);
|
||||
assert!(report.count_of(ConflictKind::Capacity) >= 1);
|
||||
assert!(report.count_of(ConflictKind::Window) >= 1);
|
||||
assert!(report.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_is_ordered_by_time() {
|
||||
let resources = resources();
|
||||
// The placements are made inside a wide window, then the task list is
|
||||
// changed so that both placements fall before the new window opens. The
|
||||
// schedule refuses such a placement, which is why the changed list is
|
||||
// used for the check.
|
||||
let placed = [task(0, 60.0, &[0]), task(1, 60.0, &[1])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&placed[0], 100.0).expect("fits");
|
||||
schedule.place(&placed[1], 300.0).expect("fits");
|
||||
|
||||
let changed = vec![
|
||||
task(0, 60.0, &[0]).with_window(500.0, 1_000.0),
|
||||
task(1, 60.0, &[1]).with_window(500.0, 1_000.0),
|
||||
];
|
||||
let report = detect(&schedule, &changed, &resources);
|
||||
assert!(report.len() >= 2);
|
||||
let times: Vec<f64> = report.conflicts().iter().map(|c| c.at).collect();
|
||||
let mut sorted = times.clone();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
|
||||
assert_eq!(times, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_lists_the_conflicting_tasks_once_each() {
|
||||
let resources = resources();
|
||||
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 report = detect(&schedule, &tasks, &resources);
|
||||
let tasks_involved = report.conflicting_tasks();
|
||||
// Both tasks are involved, and each appears once.
|
||||
assert!(tasks_involved.contains(&TaskId::new(0)));
|
||||
assert!(tasks_involved.contains(&TaskId::new(1)));
|
||||
assert_eq!(tasks_involved.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counts_by_kind_sum_to_the_total() {
|
||||
let resources = resources();
|
||||
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 report = detect(&schedule, &tasks, &resources);
|
||||
let summed = report.count_of(ConflictKind::Capacity)
|
||||
+ report.count_of(ConflictKind::Window)
|
||||
+ report.count_of(ConflictKind::Requirement)
|
||||
+ report.count_of(ConflictKind::Availability)
|
||||
+ report.count_of(ConflictKind::Precedence);
|
||||
assert_eq!(summed, report.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_precedence_violation_is_found() {
|
||||
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[1])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 100.0).expect("fits");
|
||||
// The second task must follow the first, but starts inside it.
|
||||
schedule.place(&tasks[1], 120.0).expect("fits");
|
||||
|
||||
let conflicts = detect_precedence(
|
||||
&schedule,
|
||||
&[Precedence::new(TaskId::new(0), TaskId::new(1))],
|
||||
);
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
let conflict = &conflicts[0];
|
||||
assert_eq!(conflict.kind, ConflictKind::Precedence);
|
||||
assert_eq!(conflict.task, TaskId::new(1));
|
||||
assert_eq!(conflict.other, Some(TaskId::new(0)));
|
||||
assert_eq!(conflict.at, 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_satisfied_precedence_has_no_conflict() {
|
||||
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[1])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 100.0).expect("fits");
|
||||
// Starting exactly at the end of the first is allowed, because the
|
||||
// placement interval is half-open.
|
||||
schedule.place(&tasks[1], 160.0).expect("fits");
|
||||
assert!(
|
||||
detect_precedence(
|
||||
&schedule,
|
||||
&[Precedence::new(TaskId::new(0), TaskId::new(1))]
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_precedence_over_an_unplaced_task_is_ignored() {
|
||||
let tasks = [task(0, 60.0, &[0])];
|
||||
let mut schedule = Schedule::new();
|
||||
schedule.place(&tasks[0], 100.0).expect("fits");
|
||||
// The second task is not placed, so nothing has been promised.
|
||||
assert!(
|
||||
detect_precedence(
|
||||
&schedule,
|
||||
&[Precedence::new(TaskId::new(0), TaskId::new(1))]
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
detect_precedence(
|
||||
&schedule,
|
||||
&[Precedence::new(TaskId::new(1), TaskId::new(0))]
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_displays_its_parts() {
|
||||
let conflict = Conflict::new(
|
||||
ConflictKind::Capacity,
|
||||
TaskId::new(0),
|
||||
30.0,
|
||||
"over committed",
|
||||
)
|
||||
.with_other(TaskId::new(1))
|
||||
.with_resource(ResourceId::new(2));
|
||||
let text = conflict.to_string();
|
||||
assert!(text.contains("capacity"), "{text}");
|
||||
assert!(text.contains("task 0"), "{text}");
|
||||
assert!(text.contains("task 1"), "{text}");
|
||||
assert!(text.contains("resource 2"), "{text}");
|
||||
assert!(text.contains("30"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_conflict_kinds_display_their_names() {
|
||||
assert_eq!(ConflictKind::Capacity.to_string(), "capacity");
|
||||
assert_eq!(ConflictKind::Window.to_string(), "window");
|
||||
assert_eq!(ConflictKind::Requirement.to_string(), "requirement");
|
||||
assert_eq!(ConflictKind::Availability.to_string(), "availability");
|
||||
assert_eq!(ConflictKind::Precedence.to_string(), "precedence");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movability_is_reported_by_kind() {
|
||||
assert!(Conflict::new(ConflictKind::Capacity, TaskId::new(0), 0.0, "").is_movable());
|
||||
assert!(Conflict::new(ConflictKind::Availability, TaskId::new(0), 0.0, "").is_movable());
|
||||
assert!(!Conflict::new(ConflictKind::Window, TaskId::new(0), 0.0, "").is_movable());
|
||||
assert!(!Conflict::new(ConflictKind::Requirement, TaskId::new(0), 0.0, "").is_movable());
|
||||
assert!(!Conflict::new(ConflictKind::Precedence, TaskId::new(0), 0.0, "").is_movable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_can_be_built_directly() {
|
||||
use apsis_conflict::ConflictReport;
|
||||
let report = ConflictReport::new(
|
||||
vec![Conflict::new(
|
||||
ConflictKind::Window,
|
||||
TaskId::new(0),
|
||||
5.0,
|
||||
"late",
|
||||
)],
|
||||
3,
|
||||
2,
|
||||
);
|
||||
assert_eq!(report.len(), 1);
|
||||
assert_eq!(report.offered_tasks(), 3);
|
||||
assert_eq!(report.placed_tasks(), 2);
|
||||
assert!(report.summary().contains("1 conflict"));
|
||||
}
|
||||
Loading…
Reference in New Issue