From 92cd44435352962676a3a0d7f6f7f8f217256635 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 22:48:27 +0800 Subject: [PATCH] sim: add scenario definition and text loading --- Cargo.lock | 5 + crates/sim/apsis-scenario/Cargo.toml | 3 + crates/sim/apsis-scenario/src/error.rs | 122 ++++++ crates/sim/apsis-scenario/src/lib.rs | 70 +++ crates/sim/apsis-scenario/src/parse.rs | 310 +++++++++++++ crates/sim/apsis-scenario/src/scenario.rs | 414 +++++++++++++++++ crates/sim/apsis-scenario/tests/scenario.rs | 463 ++++++++++++++++++++ 7 files changed, 1387 insertions(+) create mode 100644 crates/sim/apsis-scenario/src/error.rs create mode 100644 crates/sim/apsis-scenario/src/parse.rs create mode 100644 crates/sim/apsis-scenario/src/scenario.rs create mode 100644 crates/sim/apsis-scenario/tests/scenario.rs diff --git a/Cargo.lock b/Cargo.lock index 8dda1b7..ff92e58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,6 +271,11 @@ dependencies = [ [[package]] name = "apsis-scenario" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-resource", + "apsis-task", +] [[package]] name = "apsis-schedule" diff --git a/crates/sim/apsis-scenario/Cargo.toml b/crates/sim/apsis-scenario/Cargo.toml index 72b97a2..694316b 100644 --- a/crates/sim/apsis-scenario/Cargo.toml +++ b/crates/sim/apsis-scenario/Cargo.toml @@ -11,3 +11,6 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-resource.workspace = true +apsis-task.workspace = true diff --git a/crates/sim/apsis-scenario/src/error.rs b/crates/sim/apsis-scenario/src/error.rs new file mode 100644 index 0000000..68f8db0 --- /dev/null +++ b/crates/sim/apsis-scenario/src/error.rs @@ -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, + line: Option, +} + +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) -> 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 { + 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 {} diff --git a/crates/sim/apsis-scenario/src/lib.rs b/crates/sim/apsis-scenario/src/lib.rs index fa3c6dd..07f8764 100644 --- a/crates/sim/apsis-scenario/src/lib.rs +++ b/crates/sim/apsis-scenario/src/lib.rs @@ -1 +1,71 @@ //! 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}; diff --git a/crates/sim/apsis-scenario/src/parse.rs b/crates/sim/apsis-scenario/src/parse.rs new file mode 100644 index 0000000..023942f --- /dev/null +++ b/crates/sim/apsis-scenario/src/parse.rs @@ -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 { + 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 { + let text = self.field(index)?; + text.parse::().map_err(|_| { + ScenarioError::new(ScenarioErrorKind::BadArgument) + .with_detail(format!("{text} is not a number")) + .with_line(self.number) + }) + } + + fn usize(&self, index: usize) -> Result { + let text = self.field(index)?; + text.parse::().map_err(|_| { + ScenarioError::new(ScenarioErrorKind::BadArgument) + .with_detail(format!("{text} is not an index")) + .with_line(self.number) + }) + } + + fn u64(&self, index: usize) -> Result { + let text = self.field(index)?; + text.parse::().map_err(|_| { + ScenarioError::new(ScenarioErrorKind::BadArgument) + .with_detail(format!("{text} is not a count")) + .with_line(self.number) + }) + } + + fn boolean(&self, index: usize) -> Result { + 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> { + 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 { + let mut name: Option = None; + let mut scenario: Option = 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 = 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, 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, + 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(()) +} diff --git a/crates/sim/apsis-scenario/src/scenario.rs b/crates/sim/apsis-scenario/src/scenario.rs new file mode 100644 index 0000000..352c364 --- /dev/null +++ b/crates/sim/apsis-scenario/src/scenario.rs @@ -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, + event_limit: Option, + resources: Vec, + tasks: Vec, + precedence: PrecedenceGraph, +} + +impl Scenario { + /// Creates an empty scenario with an epoch at zero. + pub fn new(name: impl Into) -> 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 { + self.time_limit + } + + /// Returns the greatest number of events the run will execute, if one is + /// set. + pub const fn event_limit(&self) -> Option { + 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 { + &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 { + 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::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, +} diff --git a/crates/sim/apsis-scenario/tests/scenario.rs b/crates/sim/apsis-scenario/tests/scenario.rs new file mode 100644 index 0000000..904c036 --- /dev/null +++ b/crates/sim/apsis-scenario/tests/scenario.rs @@ -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 = 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); +}