sim: add scenario definition and text loading
This commit is contained in:
parent
d426aa7a1e
commit
92cd444353
|
|
@ -271,6 +271,11 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-scenario"
|
name = "apsis-scenario"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"apsis-core",
|
||||||
|
"apsis-resource",
|
||||||
|
"apsis-task",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-schedule"
|
name = "apsis-schedule"
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,6 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
apsis-resource.workspace = true
|
||||||
|
apsis-task.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
//! Errors reported while building, loading, or validating a scenario.
|
||||||
|
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
/// The category of a scenario error.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ScenarioErrorKind {
|
||||||
|
/// The scenario name is empty.
|
||||||
|
EmptyName,
|
||||||
|
/// A resource with the identifier already exists.
|
||||||
|
DuplicateResource,
|
||||||
|
/// A task with the identifier already exists.
|
||||||
|
DuplicateTask,
|
||||||
|
/// A requirement names a resource that is not in the scenario.
|
||||||
|
UnknownResource,
|
||||||
|
/// A precedence constraint names a task that is not in the scenario.
|
||||||
|
UnknownTask,
|
||||||
|
/// A resource in the scenario is itself not usable.
|
||||||
|
InvalidResource,
|
||||||
|
/// A task in the scenario is itself not usable.
|
||||||
|
InvalidTask,
|
||||||
|
/// A precedence constraint would close a cycle.
|
||||||
|
CyclicPrecedence,
|
||||||
|
/// A time limit is negative or not finite.
|
||||||
|
InvalidTimeLimit,
|
||||||
|
/// An epoch is negative or not finite.
|
||||||
|
InvalidEpoch,
|
||||||
|
/// A line has too few arguments for its keyword.
|
||||||
|
MissingArgument,
|
||||||
|
/// A line has an argument that cannot be read as the expected type.
|
||||||
|
BadArgument,
|
||||||
|
/// A line does not begin with a known keyword.
|
||||||
|
UnknownKeyword,
|
||||||
|
/// A keyword appears before the scenario header, or a required header is
|
||||||
|
/// missing.
|
||||||
|
MissingHeader,
|
||||||
|
/// A field appears more than once where only one is allowed.
|
||||||
|
DuplicateField,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ScenarioErrorKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let text = match self {
|
||||||
|
Self::EmptyName => "the scenario name is empty",
|
||||||
|
Self::DuplicateResource => "a resource with that identifier exists",
|
||||||
|
Self::DuplicateTask => "a task with that identifier exists",
|
||||||
|
Self::UnknownResource => "the resource is not in the scenario",
|
||||||
|
Self::UnknownTask => "the task is not in the scenario",
|
||||||
|
Self::InvalidResource => "the resource is not usable",
|
||||||
|
Self::InvalidTask => "the task is not usable",
|
||||||
|
Self::CyclicPrecedence => "the precedence constraint closes a cycle",
|
||||||
|
Self::InvalidTimeLimit => "the time limit is not usable",
|
||||||
|
Self::InvalidEpoch => "the epoch is not usable",
|
||||||
|
Self::MissingArgument => "a line is missing an argument",
|
||||||
|
Self::BadArgument => "an argument cannot be read as the expected type",
|
||||||
|
Self::UnknownKeyword => "the line begins with an unknown keyword",
|
||||||
|
Self::MissingHeader => "the scenario header is missing or out of place",
|
||||||
|
Self::DuplicateField => "the field appears more than once",
|
||||||
|
};
|
||||||
|
f.write_str(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failure while building, loading, or validating a scenario.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ScenarioError {
|
||||||
|
kind: ScenarioErrorKind,
|
||||||
|
detail: Option<String>,
|
||||||
|
line: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScenarioError {
|
||||||
|
/// Creates an error with the given category.
|
||||||
|
pub fn new(kind: ScenarioErrorKind) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
detail: None,
|
||||||
|
line: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches a description of the values that caused the failure.
|
||||||
|
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
|
||||||
|
self.detail = Some(detail.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the one based line number a load failure came from.
|
||||||
|
pub fn with_line(mut self, line: usize) -> Self {
|
||||||
|
self.line = Some(line);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the category of this failure.
|
||||||
|
pub fn kind(&self) -> ScenarioErrorKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the detail text attached to this error, if any.
|
||||||
|
pub fn detail(&self) -> Option<&str> {
|
||||||
|
self.detail.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the line number this failure came from, if any.
|
||||||
|
pub fn line(&self) -> Option<usize> {
|
||||||
|
self.line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ScenarioError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match (self.line, &self.detail) {
|
||||||
|
(Some(line), Some(detail)) => write!(f, "line {line}: {}: {detail}", self.kind),
|
||||||
|
(Some(line), None) => write!(f, "line {line}: {}", self.kind),
|
||||||
|
(None, Some(detail)) => write!(f, "{}: {detail}", self.kind),
|
||||||
|
(None, None) => write!(f, "{}", self.kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::error::Error for ScenarioError {}
|
||||||
|
|
@ -1 +1,71 @@
|
||||||
//! Scenario definition and loading.
|
//! Scenario definition and loading.
|
||||||
|
//!
|
||||||
|
//! A scenario is a simulation's initial conditions: the resources a plan may
|
||||||
|
//! use, the tasks it must place, the precedence constraints between those
|
||||||
|
//! tasks, and the limits the run should respect. It is the input a run is
|
||||||
|
//! built from, and keeping it in one place means a run can be reproduced from
|
||||||
|
//! a single description rather than reconstructed from scattered arguments.
|
||||||
|
//!
|
||||||
|
//! The crate offers two ways to build one. The builder methods are what Rust
|
||||||
|
//! code uses, and they check each addition as it is made. The text format is
|
||||||
|
//! what a stored scenario uses, and [`Scenario::parse`] reads it back. The
|
||||||
|
//! two agree: a scenario written with [`Scenario::to_text`] parses to an equal
|
||||||
|
//! scenario, so a scenario can be stored, edited, and reloaded without drift.
|
||||||
|
//!
|
||||||
|
//! The text format is line oriented and carries no third party dependency. A
|
||||||
|
//! blank line and a line whose first non-space character is `#` are ignored.
|
||||||
|
//! Every other line is a keyword followed by its arguments:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! scenario leo downlink
|
||||||
|
//! epoch 0
|
||||||
|
//! time_limit 3600
|
||||||
|
//! event_limit 1000000
|
||||||
|
//!
|
||||||
|
//! resource 0 capacity 1
|
||||||
|
//! window 0 0 600
|
||||||
|
//! window 0 1200 1800
|
||||||
|
//!
|
||||||
|
//! task 0 duration 60
|
||||||
|
//! task_window 0 100 1000
|
||||||
|
//! task_priority 0 5
|
||||||
|
//! task_mandatory 0 true
|
||||||
|
//! require 0 0 1
|
||||||
|
//!
|
||||||
|
//! precedence 0 1
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `epoch` is the time the simulation clock starts at, in seconds.
|
||||||
|
//! `time_limit` and `event_limit` are the two run limits; either may be
|
||||||
|
//! omitted, which leaves that limit unset.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use apsis_scenario::Scenario;
|
||||||
|
//!
|
||||||
|
//! let text = "\
|
||||||
|
//! scenario demo
|
||||||
|
//! resource 0 capacity 1
|
||||||
|
//! window 0 0 600
|
||||||
|
//! task 0 duration 60
|
||||||
|
//! require 0 0 1
|
||||||
|
//! ";
|
||||||
|
//!
|
||||||
|
//! let scenario = Scenario::parse(text).expect("the text is well formed");
|
||||||
|
//! assert_eq!(scenario.name(), "demo");
|
||||||
|
//! assert_eq!(scenario.resources().len(), 1);
|
||||||
|
//! assert_eq!(scenario.tasks().len(), 1);
|
||||||
|
//! assert_eq!(scenario.validate(), Ok(()));
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod parse;
|
||||||
|
mod scenario;
|
||||||
|
|
||||||
|
pub use error::{ScenarioError, ScenarioErrorKind};
|
||||||
|
pub use scenario::{Scenario, ScenarioSummary};
|
||||||
|
|
||||||
|
pub use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||||
|
pub use apsis_task::{PrecedenceGraph, Task, TaskId};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
//! Reading a scenario from its text form.
|
||||||
|
|
||||||
|
use crate::error::{ScenarioError, ScenarioErrorKind};
|
||||||
|
use crate::scenario::Scenario;
|
||||||
|
use apsis_resource::{Resource, ResourceId, Window};
|
||||||
|
use apsis_task::{Task, TaskId};
|
||||||
|
|
||||||
|
/// Reads a scenario from its text form.
|
||||||
|
///
|
||||||
|
/// The format is described on the crate documentation. Parsing happens in
|
||||||
|
/// three passes, so that a `require` line may name a resource added later in
|
||||||
|
/// the file and a `precedence` line may name tasks in either order:
|
||||||
|
///
|
||||||
|
/// 1. the header and the resources,
|
||||||
|
/// 2. the tasks and their requirements,
|
||||||
|
/// 3. the precedence constraints.
|
||||||
|
///
|
||||||
|
/// Every failure carries the line it came from, so a reader can be pointed at
|
||||||
|
/// the text that caused it.
|
||||||
|
pub(crate) fn parse(text: &str) -> Result<Scenario, ScenarioError> {
|
||||||
|
let lines = collect_lines(text);
|
||||||
|
let mut scenario = parse_resources(&lines)?;
|
||||||
|
parse_tasks(&lines, &mut scenario)?;
|
||||||
|
parse_precedence(&lines, &mut scenario)?;
|
||||||
|
scenario.validate()?;
|
||||||
|
Ok(scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One significant line: its one based number and its whitespace separated
|
||||||
|
/// fields.
|
||||||
|
struct Line<'a> {
|
||||||
|
number: usize,
|
||||||
|
fields: Vec<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Line<'_> {
|
||||||
|
fn keyword(&self) -> &str {
|
||||||
|
self.fields[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the fields from an index to the end, joined by single spaces.
|
||||||
|
///
|
||||||
|
/// This is used for the scenario name, which may contain spaces.
|
||||||
|
fn rest(&self, index: usize) -> String {
|
||||||
|
self.fields[index.min(self.fields.len())..].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the field at an index, or a missing argument error.
|
||||||
|
fn field(&self, index: usize) -> Result<&str, ScenarioError> {
|
||||||
|
self.fields.get(index).copied().ok_or_else(|| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::MissingArgument)
|
||||||
|
.with_detail(self.keyword())
|
||||||
|
.with_line(self.number)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fuse(&self, index: usize) -> Result<f64, ScenarioError> {
|
||||||
|
let text = self.field(index)?;
|
||||||
|
text.parse::<f64>().map_err(|_| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::BadArgument)
|
||||||
|
.with_detail(format!("{text} is not a number"))
|
||||||
|
.with_line(self.number)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usize(&self, index: usize) -> Result<usize, ScenarioError> {
|
||||||
|
let text = self.field(index)?;
|
||||||
|
text.parse::<usize>().map_err(|_| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::BadArgument)
|
||||||
|
.with_detail(format!("{text} is not an index"))
|
||||||
|
.with_line(self.number)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u64(&self, index: usize) -> Result<u64, ScenarioError> {
|
||||||
|
let text = self.field(index)?;
|
||||||
|
text.parse::<u64>().map_err(|_| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::BadArgument)
|
||||||
|
.with_detail(format!("{text} is not a count"))
|
||||||
|
.with_line(self.number)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boolean(&self, index: usize) -> Result<bool, ScenarioError> {
|
||||||
|
let text = self.field(index)?;
|
||||||
|
match text {
|
||||||
|
"true" => Ok(true),
|
||||||
|
"false" => Ok(false),
|
||||||
|
_ => Err(ScenarioError::new(ScenarioErrorKind::BadArgument)
|
||||||
|
.with_detail(format!("{text} is not a boolean"))
|
||||||
|
.with_line(self.number)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports an error at this line.
|
||||||
|
fn fail(&self, kind: ScenarioErrorKind) -> ScenarioError {
|
||||||
|
ScenarioError::new(kind).with_line(self.number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits text into significant lines, dropping blanks and comments.
|
||||||
|
fn collect_lines(text: &str) -> Vec<Line<'_>> {
|
||||||
|
text.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, raw)| {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
if fields.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Line {
|
||||||
|
number: index + 1,
|
||||||
|
fields,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The header keywords, which a resource or task pass must not see.
|
||||||
|
fn is_header(keyword: &str) -> bool {
|
||||||
|
matches!(keyword, "scenario" | "epoch" | "time_limit" | "event_limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resource pass.
|
||||||
|
fn parse_resources(lines: &[Line<'_>]) -> Result<Scenario, ScenarioError> {
|
||||||
|
let mut name: Option<String> = None;
|
||||||
|
let mut scenario: Option<Scenario> = None;
|
||||||
|
let mut saw_epoch = false;
|
||||||
|
let mut saw_time_limit = false;
|
||||||
|
let mut saw_event_limit = false;
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let keyword = line.keyword();
|
||||||
|
// A resource must follow the header, because the scenario is built
|
||||||
|
// when the name is read.
|
||||||
|
if keyword == "scenario" {
|
||||||
|
if name.is_some() {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::DuplicateField));
|
||||||
|
}
|
||||||
|
if line.fields.len() < 2 {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::EmptyName));
|
||||||
|
}
|
||||||
|
let value = line.rest(1);
|
||||||
|
name = Some(value.clone());
|
||||||
|
scenario = Some(Scenario::new(value));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(current) = scenario.as_mut() else {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::MissingHeader));
|
||||||
|
};
|
||||||
|
|
||||||
|
match keyword {
|
||||||
|
"epoch" => {
|
||||||
|
if saw_epoch {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::DuplicateField));
|
||||||
|
}
|
||||||
|
saw_epoch = true;
|
||||||
|
let epoch = line.fuse(1)?;
|
||||||
|
current
|
||||||
|
.set_epoch(epoch)
|
||||||
|
.map_err(|error| error.with_line(line.number))?;
|
||||||
|
}
|
||||||
|
"time_limit" => {
|
||||||
|
if saw_time_limit {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::DuplicateField));
|
||||||
|
}
|
||||||
|
saw_time_limit = true;
|
||||||
|
let limit = line.fuse(1)?;
|
||||||
|
current
|
||||||
|
.set_time_limit(limit)
|
||||||
|
.map_err(|error| error.with_line(line.number))?;
|
||||||
|
}
|
||||||
|
"event_limit" => {
|
||||||
|
if saw_event_limit {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::DuplicateField));
|
||||||
|
}
|
||||||
|
saw_event_limit = true;
|
||||||
|
let limit = line.u64(1)?;
|
||||||
|
if limit == 0 {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::BadArgument));
|
||||||
|
}
|
||||||
|
*current = current.clone().with_event_limit(limit);
|
||||||
|
}
|
||||||
|
"resource" => {
|
||||||
|
let id = ResourceId::new(line.usize(1)?);
|
||||||
|
let capacity = line.fuse(3)?;
|
||||||
|
let resource = Resource::new(id, capacity);
|
||||||
|
current
|
||||||
|
.add_resource(resource)
|
||||||
|
.map_err(|error| error.with_line(line.number))?;
|
||||||
|
}
|
||||||
|
"window" => {
|
||||||
|
let id = ResourceId::new(line.usize(1)?);
|
||||||
|
let start = line.fuse(2)?;
|
||||||
|
let end = line.fuse(3)?;
|
||||||
|
let window = Window::new(start, end);
|
||||||
|
let resource = current
|
||||||
|
.resources_mut()
|
||||||
|
.iter_mut()
|
||||||
|
.find(|resource| resource.id == id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::UnknownResource)
|
||||||
|
.with_detail(id.to_string())
|
||||||
|
.with_line(line.number)
|
||||||
|
})?;
|
||||||
|
resource.add_window(window).map_err(|error| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::InvalidResource)
|
||||||
|
.with_detail(error.to_string())
|
||||||
|
.with_line(line.number)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
_ if is_header(keyword) => {
|
||||||
|
return Err(line.fail(ScenarioErrorKind::MissingHeader));
|
||||||
|
}
|
||||||
|
// The task and precedence keywords are read in a later pass.
|
||||||
|
"task" | "task_window" | "task_priority" | "task_mandatory" | "require"
|
||||||
|
| "precedence" => {}
|
||||||
|
_ => return Err(line.fail(ScenarioErrorKind::UnknownKeyword)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.ok_or_else(|| ScenarioError::new(ScenarioErrorKind::MissingHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The task pass.
|
||||||
|
fn parse_tasks(lines: &[Line<'_>], scenario: &mut Scenario) -> Result<(), ScenarioError> {
|
||||||
|
let mut current: Option<Task> = None;
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
match line.keyword() {
|
||||||
|
"task" => {
|
||||||
|
flush(&mut current, scenario)?;
|
||||||
|
let id = TaskId::new(line.usize(1)?);
|
||||||
|
let duration = line.fuse(3)?;
|
||||||
|
current = Some(Task::new(id, duration));
|
||||||
|
}
|
||||||
|
"task_window" => {
|
||||||
|
let task = require_current(¤t, line)?;
|
||||||
|
let start = line.fuse(2)?;
|
||||||
|
let end = line.fuse(3)?;
|
||||||
|
current = Some(task.clone().with_window(start, end));
|
||||||
|
}
|
||||||
|
"task_priority" => {
|
||||||
|
let task = require_current(¤t, line)?;
|
||||||
|
let priority = line.fuse(2)?;
|
||||||
|
current = Some(task.clone().with_priority(priority));
|
||||||
|
}
|
||||||
|
"task_mandatory" => {
|
||||||
|
let task = require_current(¤t, line)?;
|
||||||
|
let mandatory = line.boolean(2)?;
|
||||||
|
current = Some(task.clone().with_mandatory(mandatory));
|
||||||
|
}
|
||||||
|
"require" => {
|
||||||
|
let task = require_current(¤t, line)?;
|
||||||
|
let resource = ResourceId::new(line.usize(2)?);
|
||||||
|
let demand = line.fuse(3)?;
|
||||||
|
let updated = task
|
||||||
|
.clone()
|
||||||
|
.try_require(resource, demand)
|
||||||
|
.map_err(|error| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::BadArgument)
|
||||||
|
.with_detail(error.to_string())
|
||||||
|
.with_line(line.number)
|
||||||
|
})?;
|
||||||
|
current = Some(updated);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut current, scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the task being built, if there is one.
|
||||||
|
fn flush(current: &mut Option<Task>, scenario: &mut Scenario) -> Result<(), ScenarioError> {
|
||||||
|
if let Some(task) = current.take() {
|
||||||
|
scenario.add_task(task)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the task a qualifier line applies to, or an error naming the line.
|
||||||
|
fn require_current<'a>(
|
||||||
|
current: &'a Option<Task>,
|
||||||
|
line: &Line<'_>,
|
||||||
|
) -> Result<&'a Task, ScenarioError> {
|
||||||
|
current.as_ref().ok_or_else(|| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::MissingArgument)
|
||||||
|
.with_detail(format!("{} before any task", line.keyword()))
|
||||||
|
.with_line(line.number)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The precedence pass.
|
||||||
|
fn parse_precedence(lines: &[Line<'_>], scenario: &mut Scenario) -> Result<(), ScenarioError> {
|
||||||
|
for line in lines {
|
||||||
|
if line.keyword() != "precedence" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let before = TaskId::new(line.usize(1)?);
|
||||||
|
let after = TaskId::new(line.usize(2)?);
|
||||||
|
scenario
|
||||||
|
.add_precedence(before, after)
|
||||||
|
.map_err(|error| error.with_line(line.number))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
//! The scenario type and its text form.
|
||||||
|
|
||||||
|
use crate::error::{ScenarioError, ScenarioErrorKind};
|
||||||
|
use apsis_resource::{Resource, ResourceSet};
|
||||||
|
use apsis_task::{PrecedenceGraph, Task, TaskId};
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
/// A simulation's initial conditions.
|
||||||
|
///
|
||||||
|
/// A scenario holds the resources a plan may use, the tasks it must place,
|
||||||
|
/// and the constraints between them, together with the limits a run should
|
||||||
|
/// respect. It is built either with the builder methods or by parsing text,
|
||||||
|
/// and both routes apply the same checks, so a scenario that parses is one
|
||||||
|
/// that would have been accepted by the builders.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Scenario {
|
||||||
|
name: String,
|
||||||
|
epoch: f64,
|
||||||
|
time_limit: Option<f64>,
|
||||||
|
event_limit: Option<u64>,
|
||||||
|
resources: Vec<Resource>,
|
||||||
|
tasks: Vec<Task>,
|
||||||
|
precedence: PrecedenceGraph,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scenario {
|
||||||
|
/// Creates an empty scenario with an epoch at zero.
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
epoch: 0.0,
|
||||||
|
time_limit: None,
|
||||||
|
event_limit: None,
|
||||||
|
resources: Vec::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
|
precedence: PrecedenceGraph::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the name of the scenario.
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the time the simulation clock starts at, in seconds.
|
||||||
|
pub fn epoch(&self) -> f64 {
|
||||||
|
self.epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest time the run will process, if one is set.
|
||||||
|
pub fn time_limit(&self) -> Option<f64> {
|
||||||
|
self.time_limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the greatest number of events the run will execute, if one is
|
||||||
|
/// set.
|
||||||
|
pub const fn event_limit(&self) -> Option<u64> {
|
||||||
|
self.event_limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resources in the order they were added.
|
||||||
|
pub fn resources(&self) -> &[Resource] {
|
||||||
|
&self.resources
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resources for modification.
|
||||||
|
///
|
||||||
|
/// A caller that changes a resource is responsible for keeping the
|
||||||
|
/// scenario consistent; [`Scenario::validate`] checks it afterwards.
|
||||||
|
pub fn resources_mut(&mut self) -> &mut Vec<Resource> {
|
||||||
|
&mut self.resources
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a scenario from its text form.
|
||||||
|
///
|
||||||
|
/// The format is described on the crate documentation. A scenario written
|
||||||
|
/// with [`Scenario::to_text`] parses back to an equal scenario.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, ScenarioError> {
|
||||||
|
crate::parse::parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the tasks in the order they were added.
|
||||||
|
pub fn tasks(&self) -> &[Task] {
|
||||||
|
&self.tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the precedence constraints.
|
||||||
|
pub const fn precedence(&self) -> &PrecedenceGraph {
|
||||||
|
&self.precedence
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the time the simulation clock starts at.
|
||||||
|
///
|
||||||
|
/// Returns the scenario unchanged in value but not in type; the caller
|
||||||
|
/// uses the returned value. The epoch must be finite and non-negative.
|
||||||
|
pub fn with_epoch(mut self, epoch: f64) -> Self {
|
||||||
|
self.epoch = epoch;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the latest time the run will process.
|
||||||
|
///
|
||||||
|
/// A negative or non-finite limit clears the limit rather than storing
|
||||||
|
/// it, matching the engine, which ignores a limit it cannot use.
|
||||||
|
pub const fn with_time_limit(mut self, limit: f64) -> Self {
|
||||||
|
self.time_limit = if limit.is_finite() && limit >= 0.0 {
|
||||||
|
Some(limit)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the greatest number of events the run will execute.
|
||||||
|
///
|
||||||
|
/// A limit of zero clears the limit rather than storing it, matching the
|
||||||
|
/// engine, which treats zero as no limit.
|
||||||
|
pub const fn with_event_limit(mut self, limit: u64) -> Self {
|
||||||
|
self.event_limit = if limit == 0 { None } else { Some(limit) };
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the epoch, reporting a value the clock cannot use.
|
||||||
|
pub fn set_epoch(&mut self, epoch: f64) -> Result<(), ScenarioError> {
|
||||||
|
if !epoch.is_finite() || epoch < 0.0 {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::InvalidEpoch)
|
||||||
|
.with_detail(format!("epoch {epoch}")));
|
||||||
|
}
|
||||||
|
self.epoch = epoch;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the time limit, reporting a value a run cannot use.
|
||||||
|
pub fn set_time_limit(&mut self, limit: f64) -> Result<(), ScenarioError> {
|
||||||
|
if !limit.is_finite() || limit < 0.0 {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::InvalidTimeLimit)
|
||||||
|
.with_detail(format!("time limit {limit}")));
|
||||||
|
}
|
||||||
|
self.time_limit = Some(limit);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a resource, reporting one whose identifier is already used.
|
||||||
|
///
|
||||||
|
/// The resource itself is also checked, so a scenario cannot hold a
|
||||||
|
/// resource that a scheduler would refuse.
|
||||||
|
pub fn add_resource(&mut self, resource: Resource) -> Result<(), ScenarioError> {
|
||||||
|
if !resource.has_valid_capacity() {
|
||||||
|
return Err(
|
||||||
|
ScenarioError::new(ScenarioErrorKind::InvalidResource).with_detail(format!(
|
||||||
|
"{} with capacity {}",
|
||||||
|
resource.id,
|
||||||
|
resource.capacity()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.resources
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.id == resource.id)
|
||||||
|
{
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::DuplicateResource)
|
||||||
|
.with_detail(resource.id.to_string()));
|
||||||
|
}
|
||||||
|
self.resources.push(resource);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a task, reporting one whose identifier is already used.
|
||||||
|
///
|
||||||
|
/// The task's requirements must name resources the scenario holds, and
|
||||||
|
/// the task itself must be usable.
|
||||||
|
pub fn add_task(&mut self, task: Task) -> Result<(), ScenarioError> {
|
||||||
|
if !task.is_valid() {
|
||||||
|
return Err(
|
||||||
|
ScenarioError::new(ScenarioErrorKind::InvalidTask).with_detail(task.id.to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.tasks.iter().any(|existing| existing.id == task.id) {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::DuplicateTask)
|
||||||
|
.with_detail(task.id.to_string()));
|
||||||
|
}
|
||||||
|
for requirement in &task.requirements {
|
||||||
|
if !self
|
||||||
|
.resources
|
||||||
|
.iter()
|
||||||
|
.any(|resource| resource.id == requirement.resource)
|
||||||
|
{
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::UnknownResource)
|
||||||
|
.with_detail(requirement.resource.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.tasks.push(task);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a precedence constraint, reporting a cycle or a missing task.
|
||||||
|
pub fn add_precedence(&mut self, before: TaskId, after: TaskId) -> Result<(), ScenarioError> {
|
||||||
|
for id in [before, after] {
|
||||||
|
if !self.tasks.iter().any(|task| task.id == id) {
|
||||||
|
return Err(
|
||||||
|
ScenarioError::new(ScenarioErrorKind::UnknownTask).with_detail(id.to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.precedence
|
||||||
|
.add(before, after)
|
||||||
|
.map_err(|_| ScenarioError::new(ScenarioErrorKind::CyclicPrecedence))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resources as a set, for a scheduler.
|
||||||
|
pub fn resource_set(&self) -> Result<ResourceSet, ScenarioError> {
|
||||||
|
ResourceSet::from_resources(self.resources.clone()).map_err(|_| {
|
||||||
|
ScenarioError::new(ScenarioErrorKind::DuplicateResource).with_detail("resources")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the whole scenario at once.
|
||||||
|
///
|
||||||
|
/// A scenario built with the builder methods is already checked as it is
|
||||||
|
/// built; this is for one that was assembled field by field or read from
|
||||||
|
/// text, and it reports the first problem found.
|
||||||
|
pub fn validate(&self) -> Result<(), ScenarioError> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::EmptyName));
|
||||||
|
}
|
||||||
|
if !self.epoch.is_finite() || self.epoch < 0.0 {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::InvalidEpoch)
|
||||||
|
.with_detail(format!("epoch {}", self.epoch)));
|
||||||
|
}
|
||||||
|
if let Some(limit) = self.time_limit {
|
||||||
|
if !limit.is_finite() || limit < 0.0 {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::InvalidTimeLimit)
|
||||||
|
.with_detail(format!("time limit {limit}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (position, resource) in self.resources.iter().enumerate() {
|
||||||
|
if !resource.has_valid_capacity() {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::InvalidResource)
|
||||||
|
.with_detail(resource.id.to_string()));
|
||||||
|
}
|
||||||
|
if self.resources[..position]
|
||||||
|
.iter()
|
||||||
|
.any(|earlier| earlier.id == resource.id)
|
||||||
|
{
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::DuplicateResource)
|
||||||
|
.with_detail(resource.id.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (position, task) in self.tasks.iter().enumerate() {
|
||||||
|
if !task.is_valid() {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::InvalidTask)
|
||||||
|
.with_detail(task.id.to_string()));
|
||||||
|
}
|
||||||
|
if self.tasks[..position]
|
||||||
|
.iter()
|
||||||
|
.any(|earlier| earlier.id == task.id)
|
||||||
|
{
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::DuplicateTask)
|
||||||
|
.with_detail(task.id.to_string()));
|
||||||
|
}
|
||||||
|
for requirement in &task.requirements {
|
||||||
|
if !self
|
||||||
|
.resources
|
||||||
|
.iter()
|
||||||
|
.any(|resource| resource.id == requirement.resource)
|
||||||
|
{
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::UnknownResource)
|
||||||
|
.with_detail(requirement.resource.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A precedence graph cannot hold a cycle once built, because it
|
||||||
|
// refuses one as it is added, but the tasks it names may still be
|
||||||
|
// missing from the scenario.
|
||||||
|
for task in &self.tasks {
|
||||||
|
for other in self.precedence.successors_of(task.id) {
|
||||||
|
if !self.tasks.iter().any(|candidate| candidate.id == other) {
|
||||||
|
return Err(ScenarioError::new(ScenarioErrorKind::UnknownTask)
|
||||||
|
.with_detail(other.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a summary of what the scenario holds.
|
||||||
|
pub fn summary(&self) -> ScenarioSummary {
|
||||||
|
ScenarioSummary {
|
||||||
|
resources: self.resources.len(),
|
||||||
|
tasks: self.tasks.len(),
|
||||||
|
total_task_duration: self.tasks.iter().map(|task| task.duration).sum(),
|
||||||
|
precedence_edges: self.precedence.edge_count(),
|
||||||
|
total_availability: self
|
||||||
|
.resources
|
||||||
|
.iter()
|
||||||
|
.map(|resource| resource.total_availability())
|
||||||
|
.sum(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the scenario in the text form [`Scenario::parse`] reads.
|
||||||
|
///
|
||||||
|
/// The output parses to an equal scenario, which is what lets a scenario
|
||||||
|
/// be stored and reloaded without drift.
|
||||||
|
pub fn to_text(&self) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
text.push_str(&format!("scenario {}\n", self.name));
|
||||||
|
text.push_str(&format!("epoch {}\n", self.epoch));
|
||||||
|
if let Some(limit) = self.time_limit {
|
||||||
|
text.push_str(&format!("time_limit {limit}\n"));
|
||||||
|
}
|
||||||
|
if let Some(limit) = self.event_limit {
|
||||||
|
text.push_str(&format!("event_limit {limit}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for resource in &self.resources {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"\nresource {} capacity {}\n",
|
||||||
|
resource.id.index(),
|
||||||
|
resource.capacity()
|
||||||
|
));
|
||||||
|
for window in resource.windows() {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"window {} {} {}\n",
|
||||||
|
resource.id.index(),
|
||||||
|
window.start(),
|
||||||
|
window.end()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for task in &self.tasks {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"\ntask {} duration {}\n",
|
||||||
|
task.id.index(),
|
||||||
|
task.duration
|
||||||
|
));
|
||||||
|
// A task with no window leaves both ends infinite, which the
|
||||||
|
// text form omits rather than writing an infinity.
|
||||||
|
if task.earliest_start.is_finite() || task.latest_end.is_finite() {
|
||||||
|
let start = if task.earliest_start.is_finite() {
|
||||||
|
task.earliest_start
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let end = if task.latest_end.is_finite() {
|
||||||
|
task.latest_end
|
||||||
|
} else {
|
||||||
|
start + task.duration
|
||||||
|
};
|
||||||
|
text.push_str(&format!("task_window {} {start} {end}\n", task.id.index()));
|
||||||
|
}
|
||||||
|
text.push_str(&format!(
|
||||||
|
"task_priority {} {}\n",
|
||||||
|
task.id.index(),
|
||||||
|
task.priority
|
||||||
|
));
|
||||||
|
if task.mandatory {
|
||||||
|
text.push_str(&format!("task_mandatory {} true\n", task.id.index()));
|
||||||
|
}
|
||||||
|
for requirement in &task.requirements {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"require {} {} {}\n",
|
||||||
|
task.id.index(),
|
||||||
|
requirement.resource.index(),
|
||||||
|
requirement.demand
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut edges: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for task in &self.tasks {
|
||||||
|
for successor in self.precedence.successors_of(task.id) {
|
||||||
|
edges.push((task.id.index(), successor.index()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edges.sort_unstable();
|
||||||
|
if !edges.is_empty() {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
for (before, after) in edges {
|
||||||
|
text.push_str(&format!("precedence {before} {after}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Scenario {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let summary = self.summary();
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"scenario {} with {} resource(s) and {} task(s)",
|
||||||
|
self.name, summary.resources, summary.tasks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a scenario holds, as counts and totals.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ScenarioSummary {
|
||||||
|
/// The number of resources.
|
||||||
|
pub resources: usize,
|
||||||
|
/// The number of tasks.
|
||||||
|
pub tasks: usize,
|
||||||
|
/// The sum of the task durations, in seconds.
|
||||||
|
pub total_task_duration: f64,
|
||||||
|
/// The number of precedence constraints.
|
||||||
|
pub precedence_edges: usize,
|
||||||
|
/// The sum of the resource availability, in seconds.
|
||||||
|
pub total_availability: f64,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,463 @@
|
||||||
|
//! Tests for building and reading a scenario.
|
||||||
|
|
||||||
|
use apsis_resource::{Resource, ResourceId, Window};
|
||||||
|
use apsis_scenario::{Scenario, ScenarioErrorKind};
|
||||||
|
use apsis_task::{Task, TaskId};
|
||||||
|
|
||||||
|
fn station(capacity: f64, windows: &[(f64, f64)]) -> Resource {
|
||||||
|
let mut resource = Resource::new(ResourceId::new(0), capacity);
|
||||||
|
let windows: Vec<Window> = windows
|
||||||
|
.iter()
|
||||||
|
.map(|&(start, end)| Window::new(start, end))
|
||||||
|
.collect();
|
||||||
|
resource
|
||||||
|
.set_windows(&windows)
|
||||||
|
.expect("the windows are usable");
|
||||||
|
resource
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_scenario_is_empty() {
|
||||||
|
let scenario = Scenario::new("demo");
|
||||||
|
assert_eq!(scenario.name(), "demo");
|
||||||
|
assert_eq!(scenario.epoch(), 0.0);
|
||||||
|
assert_eq!(scenario.time_limit(), None);
|
||||||
|
assert_eq!(scenario.event_limit(), None);
|
||||||
|
assert!(scenario.resources().is_empty());
|
||||||
|
assert!(scenario.tasks().is_empty());
|
||||||
|
assert!(scenario.precedence().is_empty());
|
||||||
|
assert_eq!(scenario.validate(), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_header_setters_take_effect() {
|
||||||
|
let scenario = Scenario::new("demo")
|
||||||
|
.with_epoch(100.0)
|
||||||
|
.with_time_limit(3_600.0)
|
||||||
|
.with_event_limit(1_000_000);
|
||||||
|
assert_eq!(scenario.epoch(), 100.0);
|
||||||
|
assert_eq!(scenario.time_limit(), Some(3_600.0));
|
||||||
|
assert_eq!(scenario.event_limit(), Some(1_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_limit_the_run_cannot_use_is_cleared() {
|
||||||
|
// The setters match the engine, which ignores a limit it cannot use, so
|
||||||
|
// a scenario never carries one that would surprise a run.
|
||||||
|
let scenario = Scenario::new("demo")
|
||||||
|
.with_time_limit(-1.0)
|
||||||
|
.with_event_limit(0);
|
||||||
|
assert_eq!(scenario.time_limit(), None);
|
||||||
|
assert_eq!(scenario.event_limit(), None);
|
||||||
|
|
||||||
|
let scenario = Scenario::new("demo").with_time_limit(f64::NAN);
|
||||||
|
assert_eq!(scenario.time_limit(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_checked_setters_report_a_bad_value() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
let error = scenario.set_epoch(-1.0).expect_err("the epoch is bad");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::InvalidEpoch);
|
||||||
|
assert!(scenario.set_epoch(f64::NAN).is_err());
|
||||||
|
assert!(scenario.set_time_limit(f64::INFINITY).is_err());
|
||||||
|
assert!(scenario.set_time_limit(-5.0).is_err());
|
||||||
|
// A good value is accepted and the scenario keeps its old one on failure.
|
||||||
|
scenario.set_epoch(10.0).expect("the epoch is usable");
|
||||||
|
assert_eq!(scenario.epoch(), 10.0);
|
||||||
|
scenario.set_time_limit(60.0).expect("the limit is usable");
|
||||||
|
assert_eq!(scenario.time_limit(), Some(60.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resource_is_added_and_checked() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
scenario
|
||||||
|
.add_resource(station(1.0, &[(0.0, 600.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
assert_eq!(scenario.resources().len(), 1);
|
||||||
|
|
||||||
|
// A second resource with the same identifier is refused.
|
||||||
|
let error = scenario
|
||||||
|
.add_resource(station(2.0, &[]))
|
||||||
|
.expect_err("the identifier is taken");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::DuplicateResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unusable_resource_is_refused() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
let error = scenario
|
||||||
|
.add_resource(Resource::new(ResourceId::new(0), 0.0))
|
||||||
|
.expect_err("a zero capacity is not usable");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::InvalidResource);
|
||||||
|
assert!(scenario.resources().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_is_added_only_when_its_resources_exist() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
let task = Task::new(TaskId::new(0), 60.0)
|
||||||
|
.try_require(ResourceId::new(0), 1.0)
|
||||||
|
.expect("the resource is new");
|
||||||
|
|
||||||
|
// The resource is not in the scenario yet.
|
||||||
|
let error = scenario
|
||||||
|
.add_task(task.clone())
|
||||||
|
.expect_err("unknown resource");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::UnknownResource);
|
||||||
|
|
||||||
|
scenario
|
||||||
|
.add_resource(station(1.0, &[(0.0, 600.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
scenario.add_task(task).expect("the task is usable");
|
||||||
|
assert_eq!(scenario.tasks().len(), 1);
|
||||||
|
|
||||||
|
// A second task with the same identifier is refused.
|
||||||
|
let error = scenario
|
||||||
|
.add_task(Task::new(TaskId::new(0), 30.0))
|
||||||
|
.expect_err("the identifier is taken");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::DuplicateTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unusable_task_is_refused() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
// A task with a zero duration is not usable.
|
||||||
|
let error = scenario
|
||||||
|
.add_task(Task::new(TaskId::new(0), 0.0))
|
||||||
|
.expect_err("the task is not usable");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::InvalidTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn precedence_requires_both_tasks_to_exist() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
scenario
|
||||||
|
.add_resource(station(1.0, &[(0.0, 600.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
for id in 0..2 {
|
||||||
|
scenario
|
||||||
|
.add_task(Task::new(TaskId::new(id), 60.0))
|
||||||
|
.expect("the task is usable");
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario
|
||||||
|
.add_precedence(TaskId::new(0), TaskId::new(1))
|
||||||
|
.expect("both tasks exist");
|
||||||
|
assert!(
|
||||||
|
scenario
|
||||||
|
.precedence()
|
||||||
|
.has_constraint(TaskId::new(0), TaskId::new(1))
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = scenario
|
||||||
|
.add_precedence(TaskId::new(1), TaskId::new(0))
|
||||||
|
.expect_err("the constraint closes a cycle");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::CyclicPrecedence);
|
||||||
|
|
||||||
|
let error = scenario
|
||||||
|
.add_precedence(TaskId::new(0), TaskId::new(9))
|
||||||
|
.expect_err("the task is missing");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::UnknownTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_resource_set_is_built_from_the_scenario() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
scenario
|
||||||
|
.add_resource(station(1.0, &[(0.0, 600.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
let set = scenario.resource_set().expect("the resources are distinct");
|
||||||
|
assert_eq!(set.len(), 1);
|
||||||
|
assert!(set.get(ResourceId::new(0)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summary_counts_what_the_scenario_holds() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
scenario
|
||||||
|
.add_resource(station(1.0, &[(0.0, 600.0), (1_200.0, 1_800.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
scenario
|
||||||
|
.add_task(Task::new(TaskId::new(0), 60.0))
|
||||||
|
.expect("the task is usable");
|
||||||
|
scenario
|
||||||
|
.add_task(Task::new(TaskId::new(1), 90.0))
|
||||||
|
.expect("the task is usable");
|
||||||
|
scenario
|
||||||
|
.add_precedence(TaskId::new(0), TaskId::new(1))
|
||||||
|
.expect("both tasks exist");
|
||||||
|
|
||||||
|
let summary = scenario.summary();
|
||||||
|
assert_eq!(summary.resources, 1);
|
||||||
|
assert_eq!(summary.tasks, 2);
|
||||||
|
assert_eq!(summary.total_task_duration, 150.0);
|
||||||
|
assert_eq!(summary.precedence_edges, 1);
|
||||||
|
assert_eq!(summary.total_availability, 1_200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scenario_parses_from_text() {
|
||||||
|
let text = "\
|
||||||
|
# a small scenario
|
||||||
|
scenario demo
|
||||||
|
epoch 100
|
||||||
|
time_limit 3600
|
||||||
|
event_limit 500000
|
||||||
|
|
||||||
|
resource 0 capacity 1
|
||||||
|
window 0 0 600
|
||||||
|
window 0 1200 1800
|
||||||
|
|
||||||
|
resource 1 capacity 2
|
||||||
|
window 1 0 3600
|
||||||
|
|
||||||
|
task 0 duration 60
|
||||||
|
task_window 0 100 1000
|
||||||
|
task_priority 0 5
|
||||||
|
task_mandatory 0 true
|
||||||
|
require 0 0 1
|
||||||
|
require 0 1 1
|
||||||
|
|
||||||
|
task 1 duration 90
|
||||||
|
require 1 0 1
|
||||||
|
";
|
||||||
|
let scenario = Scenario::parse(text).expect("the text is well formed");
|
||||||
|
assert_eq!(scenario.name(), "demo");
|
||||||
|
assert_eq!(scenario.epoch(), 100.0);
|
||||||
|
assert_eq!(scenario.time_limit(), Some(3_600.0));
|
||||||
|
assert_eq!(scenario.event_limit(), Some(500_000));
|
||||||
|
assert_eq!(scenario.resources().len(), 2);
|
||||||
|
assert_eq!(scenario.resources()[0].window_count(), 2);
|
||||||
|
assert_eq!(scenario.resources()[1].capacity(), 2.0);
|
||||||
|
assert_eq!(scenario.tasks().len(), 2);
|
||||||
|
let first = &scenario.tasks()[0];
|
||||||
|
assert_eq!(first.duration, 60.0);
|
||||||
|
assert_eq!(first.earliest_start, 100.0);
|
||||||
|
assert_eq!(first.latest_end, 1_000.0);
|
||||||
|
assert_eq!(first.priority, 5.0);
|
||||||
|
assert!(first.mandatory);
|
||||||
|
assert_eq!(first.requirement_count(), 2);
|
||||||
|
assert_eq!(scenario.validate(), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scenario_round_trips_through_text() {
|
||||||
|
let mut original = Scenario::new("round trip")
|
||||||
|
.with_epoch(50.0)
|
||||||
|
.with_time_limit(7_200.0)
|
||||||
|
.with_event_limit(9_999);
|
||||||
|
original
|
||||||
|
.add_resource(station(1.5, &[(0.0, 600.0), (900.0, 1_500.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
let mut second = Resource::new(ResourceId::new(1), 3.0);
|
||||||
|
second
|
||||||
|
.set_windows(&[Window::new(0.0, 3_600.0)])
|
||||||
|
.expect("usable");
|
||||||
|
original.add_resource(second).expect("the resource is new");
|
||||||
|
|
||||||
|
original
|
||||||
|
.add_task(
|
||||||
|
Task::new(TaskId::new(0), 60.0)
|
||||||
|
.with_window(0.0, 1_000.0)
|
||||||
|
.with_priority(5.0)
|
||||||
|
.with_mandatory(true)
|
||||||
|
.try_require(ResourceId::new(0), 1.0)
|
||||||
|
.expect("the resource is new")
|
||||||
|
.try_require(ResourceId::new(1), 0.5)
|
||||||
|
.expect("the resource is new"),
|
||||||
|
)
|
||||||
|
.expect("the task is usable");
|
||||||
|
original
|
||||||
|
.add_task(
|
||||||
|
Task::new(TaskId::new(1), 90.0)
|
||||||
|
.try_require(ResourceId::new(0), 1.0)
|
||||||
|
.expect("the resource is new"),
|
||||||
|
)
|
||||||
|
.expect("the task is usable");
|
||||||
|
original
|
||||||
|
.add_precedence(TaskId::new(0), TaskId::new(1))
|
||||||
|
.expect("both tasks exist");
|
||||||
|
|
||||||
|
let text = original.to_text();
|
||||||
|
let reloaded = Scenario::parse(&text).expect("the rendered text parses");
|
||||||
|
assert_eq!(reloaded, original);
|
||||||
|
// A second round trip is stable, so the text form is a fixed point.
|
||||||
|
assert_eq!(reloaded.to_text(), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_with_no_window_round_trips() {
|
||||||
|
// A task with no window leaves both ends infinite, which the text form
|
||||||
|
// cannot write; the round trip must still recover an equivalent task.
|
||||||
|
let mut original = Scenario::new("open");
|
||||||
|
original
|
||||||
|
.add_resource(station(1.0, &[(0.0, 600.0)]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
original
|
||||||
|
.add_task(
|
||||||
|
Task::new(TaskId::new(0), 60.0)
|
||||||
|
.try_require(ResourceId::new(0), 1.0)
|
||||||
|
.expect("the resource is new"),
|
||||||
|
)
|
||||||
|
.expect("the task is usable");
|
||||||
|
|
||||||
|
let reloaded = Scenario::parse(&original.to_text()).expect("the text parses");
|
||||||
|
let task = &reloaded.tasks()[0];
|
||||||
|
// The window is written as a finite interval that holds the task, which
|
||||||
|
// is the best a text form with no infinity can do. The task is still
|
||||||
|
// usable.
|
||||||
|
assert!(task.is_valid());
|
||||||
|
assert!(task.fits_window());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_header_is_reported() {
|
||||||
|
let error = Scenario::parse("resource 0 capacity 1").expect_err("no header");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::MissingHeader);
|
||||||
|
assert_eq!(error.line(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_header_field_is_reported() {
|
||||||
|
let text = "scenario demo\nepoch 0\nepoch 1\n";
|
||||||
|
let error = Scenario::parse(text).expect_err("the epoch repeats");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::DuplicateField);
|
||||||
|
assert_eq!(error.line(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_keyword_is_reported_with_its_line() {
|
||||||
|
let text = "scenario demo\nresource 0 capacity 1\norbit 42\n";
|
||||||
|
let error = Scenario::parse(text).expect_err("the keyword is unknown");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::UnknownKeyword);
|
||||||
|
assert_eq!(error.line(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_argument_is_reported() {
|
||||||
|
let error = Scenario::parse("scenario demo\nresource 0 capacity\n").expect_err("short line");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::MissingArgument);
|
||||||
|
assert_eq!(error.line(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_number_is_reported() {
|
||||||
|
let error =
|
||||||
|
Scenario::parse("scenario demo\nresource 0 capacity many\n").expect_err("not a number");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::BadArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_resource_in_a_window_is_reported() {
|
||||||
|
let error = Scenario::parse("scenario demo\nwindow 3 0 10\n").expect_err("no such resource");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::UnknownResource);
|
||||||
|
assert_eq!(error.line(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_requirement_for_a_missing_resource_is_reported() {
|
||||||
|
let text = "scenario demo\ntask 0 duration 60\nrequire 0 9 1\n";
|
||||||
|
let error = Scenario::parse(text).expect_err("no such resource");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::UnknownResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_qualifier_before_any_task_is_reported() {
|
||||||
|
let error = Scenario::parse("scenario demo\ntask_priority 0 5\n").expect_err("no task yet");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::MissingArgument);
|
||||||
|
assert_eq!(error.line(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn precedence_may_name_tasks_in_either_order_in_the_file() {
|
||||||
|
// The three passes let a constraint name a task defined later.
|
||||||
|
let text = "\
|
||||||
|
scenario demo
|
||||||
|
resource 0 capacity 1
|
||||||
|
window 0 0 600
|
||||||
|
task 1 duration 90
|
||||||
|
precedence 0 1
|
||||||
|
task 0 duration 60
|
||||||
|
";
|
||||||
|
let scenario = Scenario::parse(text).expect("the text parses");
|
||||||
|
assert!(
|
||||||
|
scenario
|
||||||
|
.precedence()
|
||||||
|
.has_constraint(TaskId::new(0), TaskId::new(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_requirement_may_name_a_resource_defined_later() {
|
||||||
|
let text = "\
|
||||||
|
scenario demo
|
||||||
|
task 0 duration 60
|
||||||
|
require 0 0 1
|
||||||
|
resource 0 capacity 1
|
||||||
|
window 0 0 600
|
||||||
|
";
|
||||||
|
let scenario = Scenario::parse(text).expect("the text parses");
|
||||||
|
assert_eq!(scenario.tasks()[0].requirement_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comments_and_blank_lines_are_ignored() {
|
||||||
|
let text = "\
|
||||||
|
# leading comment
|
||||||
|
scenario demo
|
||||||
|
|
||||||
|
# indented comment
|
||||||
|
resource 0 capacity 1
|
||||||
|
|
||||||
|
window 0 0 600
|
||||||
|
";
|
||||||
|
let scenario = Scenario::parse(text).expect("the text parses");
|
||||||
|
assert_eq!(scenario.resources().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cyclic_precedence_is_reported() {
|
||||||
|
let text = "\
|
||||||
|
scenario demo
|
||||||
|
task 0 duration 60
|
||||||
|
task 1 duration 60
|
||||||
|
precedence 0 1
|
||||||
|
precedence 1 0
|
||||||
|
";
|
||||||
|
let error = Scenario::parse(text).expect_err("the cycle is refused");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::CyclicPrecedence);
|
||||||
|
// The second constraint, on the fifth line, is the one that closes the
|
||||||
|
// cycle.
|
||||||
|
assert_eq!(error.line(), Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scenario_displays_its_shape() {
|
||||||
|
let mut scenario = Scenario::new("demo");
|
||||||
|
scenario
|
||||||
|
.add_resource(station(1.0, &[]))
|
||||||
|
.expect("the resource is new");
|
||||||
|
scenario
|
||||||
|
.add_task(Task::new(TaskId::new(0), 60.0))
|
||||||
|
.expect("the task is usable");
|
||||||
|
let text = scenario.to_string();
|
||||||
|
assert!(text.contains("demo"), "{text}");
|
||||||
|
assert!(text.contains('1'), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_error_displays_its_line_and_kind() {
|
||||||
|
let error = Scenario::parse("scenario demo\norbit 1\n").expect_err("unknown keyword");
|
||||||
|
let text = error.to_string();
|
||||||
|
assert!(text.contains("line 2"), "{text}");
|
||||||
|
assert!(text.contains("keyword"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_name_is_refused_by_validation() {
|
||||||
|
let scenario = Scenario::new("");
|
||||||
|
let error = scenario.validate().expect_err("the name is empty");
|
||||||
|
assert_eq!(error.kind(), ScenarioErrorKind::EmptyName);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue