From a09dd802e035363761c4756606fd30b0647b89ce Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 22:52:51 +0800 Subject: [PATCH] sim: add simulation facade and integration example --- Cargo.lock | 11 + crates/apsis-rs/Cargo.toml | 4 + crates/apsis-rs/examples/integrate_sim.rs | 172 ++++++++++++ crates/sim/apsis-sim/Cargo.toml | 9 + crates/sim/apsis-sim/src/activity.rs | 62 +++++ crates/sim/apsis-sim/src/error.rs | 67 +++++ crates/sim/apsis-sim/src/lib.rs | 68 +++++ crates/sim/apsis-sim/src/session.rs | 139 ++++++++++ crates/sim/apsis-sim/src/simulation.rs | 183 +++++++++++++ crates/sim/apsis-sim/tests/simulation.rs | 310 ++++++++++++++++++++++ 10 files changed, 1025 insertions(+) create mode 100644 crates/apsis-rs/examples/integrate_sim.rs create mode 100644 crates/sim/apsis-sim/src/activity.rs create mode 100644 crates/sim/apsis-sim/src/error.rs create mode 100644 crates/sim/apsis-sim/src/session.rs create mode 100644 crates/sim/apsis-sim/src/simulation.rs create mode 100644 crates/sim/apsis-sim/tests/simulation.rs diff --git a/Cargo.lock b/Cargo.lock index f7fb324..61e291a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,17 @@ dependencies = [ [[package]] name = "apsis-sim" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-event", + "apsis-output", + "apsis-resource", + "apsis-scenario", + "apsis-schedule", + "apsis-scheduler", + "apsis-sim-engine", + "apsis-task", +] [[package]] name = "apsis-sim-engine" diff --git a/crates/apsis-rs/Cargo.toml b/crates/apsis-rs/Cargo.toml index 92029e3..d93d1bb 100644 --- a/crates/apsis-rs/Cargo.toml +++ b/crates/apsis-rs/Cargo.toml @@ -197,3 +197,7 @@ required-features = ["physics"] [[example]] name = "integrate_ops" required-features = ["ops"] + +[[example]] +name = "integrate_sim" +required-features = ["sim"] diff --git a/crates/apsis-rs/examples/integrate_sim.rs b/crates/apsis-rs/examples/integrate_sim.rs new file mode 100644 index 0000000..a581cba --- /dev/null +++ b/crates/apsis-rs/examples/integrate_sim.rs @@ -0,0 +1,172 @@ +//! Cross-layer integration check: running a scenario as a simulation. +//! +//! This example walks the simulation layer end to end and asserts that each +//! step produces sensible input for the next. It is not a numerical +//! cross-check; the per-layer tests do that. Its purpose is to fail when a +//! change in one crate breaks the assumptions of another. +//! +//! The chain is: +//! +//! 1. Load a scenario from text, which describes the resources, the tasks, +//! and the precedence between them. +//! 2. Prepare it with `apsis-sim`, which plans the tasks and builds a +//! timeline. +//! 3. Start a session and run it, recording state at each activity. +//! 4. Read the run report back, which holds the engine outcome and the +//! trace. +//! +//! Usage: +//! +//! ```text +//! cargo run -p apsis-rs --features sim --example integrate_sim +//! ``` + +use apsis_sim::{RecordKind, Scenario, Simulation, StopReason}; + +/// The scenario the example runs. +/// +/// The station is available in two passes. An observation uses the payload, +/// and the downlink that follows uses the transmitter, so the two are joined +/// by a precedence constraint. A third task of low priority may or may not +/// fit, which is what makes the plan interesting to report. +const SCENARIO: &str = "\ +# A short downlink scenario. +scenario leo downlink +epoch 0 +time_limit 4000 + +resource 0 capacity 1 +window 0 0 4000 + +task 0 duration 120 +task_window 0 200 1500 +task_priority 0 10 +require 0 0 1 + +task 1 duration 90 +task_window 1 0 2000 +task_priority 1 9 +require 1 0 1 + +task 2 duration 60 +task_window 2 2500 3000 +task_priority 2 1 +require 2 0 1 + +precedence 0 1 +"; + +fn main() { + // Step 1: load the scenario. A failure here is a problem with the text, + // so the example reports it rather than unwrapping. + let scenario = match Scenario::parse(SCENARIO) { + Ok(scenario) => scenario, + Err(error) => { + eprintln!("the scenario does not load: {error}"); + return; + } + }; + let summary = scenario.summary(); + println!( + "scenario {}: {} resource(s), {} task(s), {} precedence edge(s)", + scenario.name(), + summary.resources, + summary.tasks, + summary.precedence_edges + ); + + // Step 2: prepare the simulation. This plans the tasks and builds the + // timeline that the plan implies. + let simulation = match Simulation::prepare(scenario) { + Ok(simulation) => simulation, + Err(error) => { + eprintln!("the simulation does not prepare: {error}"); + return; + } + }; + let evaluation = simulation.evaluate(); + println!( + "plan: {} of {} placed, makespan {:.0} s", + evaluation.placed_count, + evaluation.offered_count, + evaluation.makespan() + ); + println!( + "plan: {} start and end event(s), full plan: {}", + simulation.activity_count(), + simulation.has_full_plan() + ); + if !simulation.deferred().is_empty() { + println!("plan: deferred {:?}", simulation.deferred()); + } + assert!(simulation.honours_precedence()); + + // Step 3: run the timeline. The handler records what it sees, which is + // what a real model would do with the physics it has. + let mut session = simulation.start(); + let mut starts = 0_u32; + let mut ends = 0_u32; + let report = session.run(|_engine, recorder, event| { + if event.payload.is_start() { + starts += 1; + recorder.note(event.time, "started", event.payload.task().index() as f64); + } else { + ends += 1; + recorder.note(event.time, "finished", event.payload.task().index() as f64); + } + }); + + // Step 4: read the report back. + println!( + "run: {} activity(s) executed, {} start(s), {} end(s)", + report.executed(), + starts, + ends + ); + println!( + "run: stopped by {} ({})", + report.outcome.reason, report.outcome + ); + assert_eq!(report.outcome.reason, StopReason::QueueEmpty); + assert_eq!(report.executed(), u64::from(starts + ends)); + assert_eq!( + report.trace.of_kind(RecordKind::State).len(), + starts as usize + ends as usize + ); + + // Two tasks are joined by precedence, so their ends and starts must line + // up in the timeline the handler saw. + let trace = &report.trace; + let collection_end = trace + .with_label("finished") + .iter() + .filter(|record| record.value == 0.0) + .map(|record| record.time) + .next(); + let downlink_start = trace + .with_label("started") + .iter() + .filter(|record| record.value == 1.0) + .map(|record| record.time) + .next(); + if let (Some(collection_end), Some(downlink_start)) = (collection_end, downlink_start) { + println!( + "sequence: collection ends at {collection_end} s, downlink starts at {downlink_start} s" + ); + assert!(downlink_start >= collection_end); + } + + // A run of the same prepared simulation must reproduce the trace. + let mut again = simulation.start(); + let repeated = again.run(|_engine, recorder, event| { + if event.payload.is_start() { + recorder.note(event.time, "started", event.payload.task().index() as f64); + } else { + recorder.note(event.time, "finished", event.payload.task().index() as f64); + } + }); + assert_eq!(repeated.executed(), report.executed()); + assert_eq!(repeated.trace.records(), report.trace.records()); + + println!("integration check passed"); +} diff --git a/crates/sim/apsis-sim/Cargo.toml b/crates/sim/apsis-sim/Cargo.toml index 1be1f84..1bd35a1 100644 --- a/crates/sim/apsis-sim/Cargo.toml +++ b/crates/sim/apsis-sim/Cargo.toml @@ -11,3 +11,12 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-resource.workspace = true +apsis-task.workspace = true +apsis-schedule.workspace = true +apsis-scheduler.workspace = true +apsis-sim-engine.workspace = true +apsis-scenario.workspace = true +apsis-event.workspace = true +apsis-output.workspace = true diff --git a/crates/sim/apsis-sim/src/activity.rs b/crates/sim/apsis-sim/src/activity.rs new file mode 100644 index 0000000..360b3ac --- /dev/null +++ b/crates/sim/apsis-sim/src/activity.rs @@ -0,0 +1,62 @@ +//! What happens at an instant in a simulated timeline. + +use apsis_task::TaskId; + +/// One thing that happens in the timeline. +/// +/// The timeline is derived from a schedule: a placed task contributes a start +/// and an end, and the handler is called at each. The two are separate events +/// rather than one interval so that the engine orders them with everything +/// else on the clock, which is what lets two tasks overlap correctly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Activity { + /// A task begins. + Start { + /// The task beginning. + task: TaskId, + }, + /// A task ends. + End { + /// The task ending. + task: TaskId, + }, +} + +impl Activity { + /// Creates a start activity. + pub const fn start(task: TaskId) -> Self { + Self::Start { task } + } + + /// Creates an end activity. + pub const fn end(task: TaskId) -> Self { + Self::End { task } + } + + /// Returns the task the activity is about. + pub const fn task(self) -> TaskId { + match self { + Self::Start { task } | Self::End { task } => task, + } + } + + /// Returns `true` when the activity is a start. + pub const fn is_start(self) -> bool { + matches!(self, Self::Start { .. }) + } + + /// Returns `true` when the activity is an end. + pub const fn is_end(self) -> bool { + matches!(self, Self::End { .. }) + } +} + +impl core::fmt::Display for Activity { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Start { task } => write!(f, "start {task}"), + Self::End { task } => write!(f, "end {task}"), + } + } +} diff --git a/crates/sim/apsis-sim/src/error.rs b/crates/sim/apsis-sim/src/error.rs new file mode 100644 index 0000000..0474b27 --- /dev/null +++ b/crates/sim/apsis-sim/src/error.rs @@ -0,0 +1,67 @@ +//! Errors reported while preparing or starting a simulation. + +use core::fmt; + +/// The category of a simulation error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum SimulationErrorKind { + /// The scenario is not usable. + InvalidScenario, + /// The scheduled resources do not form a usable set. + InvalidResources, + /// A scheduled placement does not match the task it names. + InconsistentPlan, +} + +impl fmt::Display for SimulationErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::InvalidScenario => "the scenario is not usable", + Self::InvalidResources => "the resources are not a usable set", + Self::InconsistentPlan => "the plan does not match the scenario", + }; + f.write_str(text) + } +} + +/// A failure while preparing or starting a simulation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimulationError { + kind: SimulationErrorKind, + detail: Option, +} + +impl SimulationError { + /// Creates an error with the given category. + pub fn new(kind: SimulationErrorKind) -> Self { + Self { kind, detail: None } + } + + /// Attaches a description of what caused the failure. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Returns the category of this failure. + pub const fn kind(&self) -> SimulationErrorKind { + self.kind + } + + /// Returns the detail text attached to this error, if any. + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } +} + +impl fmt::Display for SimulationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.detail { + Some(detail) => write!(f, "{}: {detail}", self.kind), + None => write!(f, "{}", self.kind), + } + } +} + +impl core::error::Error for SimulationError {} diff --git a/crates/sim/apsis-sim/src/lib.rs b/crates/sim/apsis-sim/src/lib.rs index d1cb820..0eadb30 100644 --- a/crates/sim/apsis-sim/src/lib.rs +++ b/crates/sim/apsis-sim/src/lib.rs @@ -1 +1,69 @@ //! Simulation facade that integrates the simulation layer. +//! +//! This crate is the single entry point to the simulation layer. It ties the +//! four pieces together: a [`Scenario`] describes the initial conditions, the +//! ops layer plans it, the engine runs the resulting timeline, and a +//! [`Recorder`] collects what the run produced. +//! +//! The sequence a caller follows is short: +//! +//! 1. [`Simulation::prepare`] checks a scenario and plans it, producing a +//! schedule. +//! 2. [`Simulation::start`] turns that schedule into a timeline of start and +//! end events and hands back a [`Session`]. +//! 3. [`Session::run`] drives the engine, calling a handler at each event, +//! and returns a [`RunReport`] holding the engine outcome and the trace. +//! +//! The plan is a greedy heuristic, described in `apsis-scheduler`, so a run +//! may leave tasks unplaced. The facade reports that rather than hiding it: +//! [`Simulation::plan`] exposes the full planning result, and +//! [`Simulation::has_full_plan`] answers the common question directly. +//! +//! The timeline is the schedule, not a physical model. A start event fires +//! when a task begins and an end event when it ends, which is what a caller +//! needs to record state over time; the handler supplies whatever physics or +//! bookkeeping the run requires. +//! +//! ``` +//! use apsis_sim::{Activity, Scenario, Simulation}; +//! +//! 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"); +//! let simulation = Simulation::prepare(scenario).expect("the scenario is usable"); +//! +//! let mut session = simulation.start(); +//! let mut starts = 0_u32; +//! let report = session.run(|_engine, _recorder, event| { +//! if matches!(event.payload, Activity::Start { .. }) { +//! starts += 1; +//! } +//! }); +//! +//! assert!(simulation.has_full_plan()); +//! assert_eq!(starts, 1); +//! assert_eq!(report.outcome.executed, 2); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod activity; +mod error; +mod session; +mod simulation; + +pub use activity::Activity; +pub use error::{SimulationError, SimulationErrorKind}; +pub use session::{RunReport, Session}; +pub use simulation::Simulation; + +pub use apsis_event::{Event, EventId, EventQueue}; +pub use apsis_output::{Record, RecordKind, Recorder, SeriesSummary, Trace}; +pub use apsis_scenario::{Scenario, ScenarioError, ScenarioErrorKind, ScenarioSummary}; +pub use apsis_sim_engine::{Engine, EngineOutcome, EngineState, StopReason}; diff --git a/crates/sim/apsis-sim/src/session.rs b/crates/sim/apsis-sim/src/session.rs new file mode 100644 index 0000000..48dc561 --- /dev/null +++ b/crates/sim/apsis-sim/src/session.rs @@ -0,0 +1,139 @@ +//! Running a prepared simulation. + +use crate::activity::Activity; +use crate::simulation::Simulation; +use apsis_output::{RecordKind, Recorder, Trace}; +use apsis_sim_engine::{Engine, EngineOutcome}; + +/// What a run produced. +/// +/// The report pairs the engine's own outcome with the output trace, so a +/// caller has everything a run produced without reaching back into the +/// session. +#[derive(Debug, Clone, PartialEq)] +pub struct RunReport { + /// What the engine did. + pub outcome: EngineOutcome, + /// The records the run made. + pub trace: Trace, + /// The number of records the recorder refused because they were not + /// usable. + pub refused: u64, +} + +impl RunReport { + /// Returns the number of activities the run executed. + pub const fn executed(&self) -> u64 { + self.outcome.executed + } + + /// Returns `true` when the run finished because the timeline was + /// exhausted rather than because a limit stopped it. + pub fn is_complete(&self) -> bool { + self.outcome.is_natural() + } + + /// Returns the number of warnings recorded. + pub fn warning_count(&self) -> usize { + self.trace.warning_count() + } +} + +/// One run of a prepared simulation. +/// +/// A session owns the engine and the recorder for a single run, so it can be +/// driven from one place without the caller holding either. It is created by +/// [`Simulation::start`]. +#[derive(Debug, Clone)] +pub struct Session { + engine: Engine, + recorder: Recorder, +} + +impl Session { + /// Builds a session from a prepared simulation. + pub(crate) fn new(simulation: &Simulation) -> Self { + let scenario = simulation.scenario(); + let mut engine = Engine::new() + .with_time_limit(scenario.time_limit().unwrap_or(f64::INFINITY)) + .with_event_limit(scenario.event_limit().unwrap_or(0)); + engine.set_now(scenario.epoch()); + // The timeline is relative to the scenario origin, so the epoch is + // added to every event time. Without the offset the engine would + // move the clock backwards when it delivered the first event. + let epoch = scenario.epoch(); + for (time, activity) in simulation.timeline() { + engine.schedule(epoch + time, *activity); + } + Self { + engine, + recorder: Recorder::new(), + } + } + + /// Returns the engine, for a caller that needs its state between calls. + pub const fn engine(&self) -> &Engine { + &self.engine + } + + /// Returns the engine for modification. + pub const fn engine_mut(&mut self) -> &mut Engine { + &mut self.engine + } + + /// Returns the recorder, for a caller that reads the trace as it grows. + pub const fn recorder(&self) -> &Recorder { + &self.recorder + } + + /// Returns the recorder for modification. + pub const fn recorder_mut(&mut self) -> &mut Recorder { + &mut self.recorder + } + + /// Returns the current simulation time, in seconds. + pub const fn now(&self) -> f64 { + self.engine.now() + } + + /// Runs the engine, calling the handler at each activity. + /// + /// The handler receives the engine, the recorder, and the activity. It + /// normally records something for the activity and may schedule further + /// events; the engine delivers them in time order alongside the timeline. + /// + /// Returns a report holding the engine outcome and the trace made during + /// this call. The recorder is left empty for the next call. + pub fn run(&mut self, mut handler: F) -> RunReport + where + F: FnMut(&mut Engine, &mut Recorder, apsis_event::Event), + { + let outcome = self.engine.run(|engine, event| { + handler(engine, &mut self.recorder, event); + }); + RunReport { + outcome, + trace: self.recorder.take_trace(), + refused: self.recorder.refused(), + } + } + + /// Records an activity as a state change without running anything. + /// + /// This is for a caller that drives the engine itself and only wants the + /// recorder. + pub fn note(&mut self, time: f64, label: impl Into, value: f64) -> bool { + self.recorder.note(time, label, value) + } + + /// Records a value of a given kind without running anything. + pub fn record( + &mut self, + time: f64, + kind: RecordKind, + label: impl Into, + value: f64, + ) -> bool { + self.recorder.record(time, kind, label, value) + } +} diff --git a/crates/sim/apsis-sim/src/simulation.rs b/crates/sim/apsis-sim/src/simulation.rs new file mode 100644 index 0000000..64409d1 --- /dev/null +++ b/crates/sim/apsis-sim/src/simulation.rs @@ -0,0 +1,183 @@ +//! Preparing a scenario for a run. + +use crate::activity::Activity; +use crate::error::{SimulationError, SimulationErrorKind}; +use crate::session::Session; +use apsis_resource::ResourceSet; +use apsis_scenario::Scenario; +use apsis_schedule::{Evaluation, Schedule}; +use apsis_scheduler::{Planner, SchedulingResult}; +use apsis_task::{Task, TaskId}; + +/// A scenario prepared for a run. +/// +/// A simulation holds the scenario, the plan that was made from it, and the +/// timeline that plan implies. Preparing is separate from running so that a +/// caller can inspect the plan, and reject it, before a run commits to it. +#[derive(Debug, Clone)] +pub struct Simulation { + scenario: Scenario, + resources: ResourceSet, + result: SchedulingResult, + timeline: Vec<(f64, Activity)>, +} + +impl Simulation { + /// Checks a scenario and plans it. + /// + /// The scenario is validated, the tasks are planned against the + /// resources, and the resulting schedule is turned into a timeline of + /// start and end events. The plan is greedy, so a scenario may be + /// prepared successfully and still leave tasks unplaced; that is reported + /// by [`Simulation::has_full_plan`] rather than being an error. + pub fn prepare(scenario: Scenario) -> Result { + scenario.validate().map_err(|error| { + SimulationError::new(SimulationErrorKind::InvalidScenario) + .with_detail(error.to_string()) + })?; + let resources = scenario.resource_set().map_err(|error| { + SimulationError::new(SimulationErrorKind::InvalidResources) + .with_detail(error.to_string()) + })?; + let tasks = scenario.tasks().to_vec(); + let result = + Planner::new().schedule_with_precedence(&tasks, &resources, scenario.precedence()); + Self::from_parts(scenario, resources, result) + } + + /// Builds a simulation from an already planned result. + /// + /// This is for a caller that has planned with its own settings and wants + /// the rest of the facade: the timeline and the run loop. The result must + /// belong to the scenario, which is checked. + pub fn from_plan( + scenario: Scenario, + result: SchedulingResult, + ) -> Result { + scenario.validate().map_err(|error| { + SimulationError::new(SimulationErrorKind::InvalidScenario) + .with_detail(error.to_string()) + })?; + let resources = scenario.resource_set().map_err(|error| { + SimulationError::new(SimulationErrorKind::InvalidResources) + .with_detail(error.to_string()) + })?; + Self::from_parts(scenario, resources, result) + } + + /// Builds the timeline from a plan. + fn from_parts( + scenario: Scenario, + resources: ResourceSet, + result: SchedulingResult, + ) -> Result { + let mut timeline = Vec::with_capacity(result.schedule.len() * 2); + for placement in result.schedule.in_time_order() { + let Some(task) = scenario + .tasks() + .iter() + .find(|task| task.id == placement.task) + else { + return Err(SimulationError::new(SimulationErrorKind::InconsistentPlan) + .with_detail(format!("{} is not in the scenario", placement.task))); + }; + // The duration is copied into the placement, so the plan is + // checked against the task it claims to place. + if (placement.duration - task.duration).abs() > f64::EPSILON { + return Err(SimulationError::new(SimulationErrorKind::InconsistentPlan) + .with_detail(format!( + "{} lasts {} s in the plan and {} s as a task", + placement.task, placement.duration, task.duration + ))); + } + timeline.push((placement.start, Activity::start(placement.task))); + timeline.push((placement.end(), Activity::end(placement.task))); + } + Ok(Self { + scenario, + resources, + result, + timeline, + }) + } + + /// Returns the scenario. + pub const fn scenario(&self) -> &Scenario { + &self.scenario + } + + /// Returns the resources in the scenario. + pub const fn resources(&self) -> &ResourceSet { + &self.resources + } + + /// Returns the planning result. + pub const fn plan(&self) -> &SchedulingResult { + &self.result + } + + /// Returns the schedule. + pub const fn schedule(&self) -> &Schedule { + &self.result.schedule + } + + /// Returns the tasks in the scenario. + pub fn tasks(&self) -> &[Task] { + self.scenario.tasks() + } + + /// Returns the timeline as time and activity pairs, in time order. + /// + /// Each placed task contributes a start and an end. Ties are ordered so + /// that an end precedes a start at the same instant, which lets two back + /// to back tasks share a boundary without the reader seeing them overlap. + pub fn timeline(&self) -> &[(f64, Activity)] { + &self.timeline + } + + /// Returns the number of start and end events in the timeline. + pub fn activity_count(&self) -> usize { + self.timeline.len() + } + + /// Returns `true` when every offered task was placed. + pub fn has_full_plan(&self) -> bool { + self.result.is_complete() + } + + /// Returns `true` when every mandatory task was placed. + pub fn honours_mandatory(&self) -> bool { + self.result.honours_mandatory(self.scenario.tasks()) + } + + /// Returns `true` when every precedence constraint is satisfied. + pub fn honours_precedence(&self) -> bool { + self.result.honours_precedence(self.scenario.precedence()) + } + + /// Returns the tasks that were not placed for want of time. + pub fn deferred(&self) -> Vec { + self.result.deferred() + } + + /// Returns the tasks that cannot be placed at all. + pub fn infeasible(&self) -> Vec { + self.result.infeasible() + } + + /// Evaluates the schedule against the scenario. + pub fn evaluate(&self) -> Evaluation { + self.schedule() + .evaluate(self.scenario.tasks(), &self.resources) + } + + /// Starts a run. + /// + /// The returned session owns its own engine and recorder, so several + /// sessions may run from one prepared simulation without interfering. The + /// clock starts at the scenario epoch and the run limits come from the + /// scenario. + pub fn start(&self) -> Session { + Session::new(self) + } +} diff --git a/crates/sim/apsis-sim/tests/simulation.rs b/crates/sim/apsis-sim/tests/simulation.rs new file mode 100644 index 0000000..55ddb35 --- /dev/null +++ b/crates/sim/apsis-sim/tests/simulation.rs @@ -0,0 +1,310 @@ +//! Tests for the simulation facade. + +use apsis_sim::{ + Activity, RecordKind, RunReport, Scenario, Simulation, SimulationErrorKind, StopReason, +}; + +/// Builds a scenario with one station and a set of tasks. +fn scenario_with(tasks: &[(usize, f64, f64)]) -> Scenario { + let mut text = String::from("scenario test\nresource 0 capacity 1\nwindow 0 0 1000\n"); + for &(id, duration, priority) in tasks { + text.push_str(&format!("task {id} duration {duration}\n")); + text.push_str(&format!("require {id} 0 1\n")); + text.push_str(&format!("task_priority {id} {priority}\n")); + } + Scenario::parse(&text).expect("the text is well formed") +} + +#[test] +fn a_scenario_is_prepared_and_planned() { + let simulation = + Simulation::prepare(scenario_with(&[(0, 60.0, 1.0), (1, 90.0, 2.0)])).expect("usable"); + assert!(simulation.has_full_plan()); + assert_eq!(simulation.schedule().len(), 2); + assert_eq!(simulation.activity_count(), 4); + assert_eq!(simulation.timeline().len(), 4); + assert!(simulation.deferred().is_empty()); + assert!(simulation.infeasible().is_empty()); + assert!(simulation.honours_mandatory()); + assert!(simulation.honours_precedence()); +} + +#[test] +fn the_timeline_has_a_start_and_an_end_for_each_task() { + let simulation = Simulation::prepare(scenario_with(&[(0, 60.0, 1.0)])).expect("usable"); + let timeline = simulation.timeline(); + // In time order: the start precedes the end. + assert_eq!(timeline.len(), 2); + assert!(timeline[0].1.is_start()); + assert_eq!(timeline[0].1.task(), apsis_task::TaskId::new(0)); + assert!(timeline[1].1.is_end()); + assert!(timeline[1].0 > timeline[0].0); +} + +#[test] +fn a_run_executes_every_activity() { + let simulation = + Simulation::prepare(scenario_with(&[(0, 60.0, 1.0), (1, 90.0, 2.0)])).expect("usable"); + let mut session = simulation.start(); + let report = session.run(|_, _, _| {}); + assert_eq!(report.executed(), 4); + assert!(report.is_complete()); + assert_eq!(report.outcome.reason, StopReason::QueueEmpty); + assert_eq!(report.trace.len(), 0); +} + +#[test] +fn a_handler_can_record_each_activity() { + let simulation = + Simulation::prepare(scenario_with(&[(0, 60.0, 1.0), (1, 90.0, 2.0)])).expect("usable"); + let mut session = simulation.start(); + let mut starts = 0_u32; + let mut ends = 0_u32; + let report = session.run(|_engine, recorder, event| { + if event.payload.is_start() { + starts += 1; + recorder.note(event.time, "started", event.payload.task().index() as f64); + } else { + ends += 1; + } + }); + assert_eq!(starts, 2); + assert_eq!(ends, 2); + assert_eq!(report.trace.len(), 2); + assert_eq!(report.trace.of_kind(RecordKind::State).len(), 2); +} + +#[test] +fn the_run_starts_at_the_scenario_epoch() { + let mut scenario = scenario_with(&[(0, 60.0, 1.0)]); + scenario.set_epoch(1_000.0).expect("the epoch is usable"); + let simulation = Simulation::prepare(scenario).expect("usable"); + let mut session = simulation.start(); + assert_eq!(session.now(), 1_000.0); + let report = session.run(|_, _, _| {}); + // The timeline events are absolute, so the run reaches the end event. + assert!(report.outcome.ended_at > 1_000.0); +} + +#[test] +fn a_time_limit_stops_the_run_early() { + let mut scenario = scenario_with(&[(0, 60.0, 1.0)]); + scenario.set_time_limit(1.0).expect("the limit is usable"); + let simulation = Simulation::prepare(scenario).expect("usable"); + let mut session = simulation.start(); + let report = session.run(|_, _, _| {}); + assert_eq!(report.outcome.reason, StopReason::TimeLimit); + assert!(!report.is_complete()); + assert_eq!(report.trace.len(), 0); +} + +#[test] +fn an_event_limit_stops_the_run_early() { + let mut scenario = scenario_with(&[(0, 60.0, 1.0)]); + scenario = scenario.with_event_limit(1); + let simulation = Simulation::prepare(scenario).expect("usable"); + let mut session = simulation.start(); + let report = session.run(|_, _, _| {}); + assert_eq!(report.outcome.reason, StopReason::EventLimit); + assert_eq!(report.executed(), 1); +} + +#[test] +fn a_run_is_reproducible_from_one_prepared_simulation() { + let simulation = + Simulation::prepare(scenario_with(&[(0, 60.0, 1.0), (1, 90.0, 2.0)])).expect("usable"); + let collect = || { + let mut session = simulation.start(); + let mut seen = Vec::new(); + session.run(|_, _, event| seen.push((event.time, event.payload))); + seen + }; + assert_eq!(collect(), collect()); +} + +#[test] +fn a_session_leaves_the_prepared_simulation_alone() { + let simulation = Simulation::prepare(scenario_with(&[(0, 60.0, 1.0)])).expect("usable"); + let mut first = simulation.start(); + first.run(|_, _, _| {}); + // A second session starts from the full timeline again. + let mut second = simulation.start(); + assert_eq!(second.run(|_, _, _| {}).executed(), 2); +} + +#[test] +fn a_handler_may_schedule_further_activity() { + // The engine interleaves events the handler schedules with the timeline. + let simulation = Simulation::prepare(scenario_with(&[(0, 60.0, 1.0)])).expect("usable"); + let mut session = simulation.start(); + let mut extra = 0_u32; + let report = session.run(|engine, _, event| { + if matches!(event.payload, Activity::Start { .. }) { + engine + .schedule(event.time + 30.0, Activity::end(event.payload.task())) + .expect("the time is usable"); + extra += 1; + } + }); + assert_eq!(extra, 1); + // Two timeline events plus the one the handler scheduled. + assert_eq!(report.executed(), 3); +} + +#[test] +fn deferred_tasks_are_reported() { + // Two tasks of 60 s in a 100 s window: only one fits. + let scenario = Scenario::parse( + "scenario tight\n\ + resource 0 capacity 1\n\ + window 0 0 100\n\ + task 0 duration 60\n\ + require 0 0 1\n\ + task 1 duration 60\n\ + require 1 0 1\n", + ) + .expect("the text is well formed"); + let simulation = Simulation::prepare(scenario).expect("usable"); + assert!(!simulation.has_full_plan()); + assert_eq!(simulation.schedule().len(), 1); + assert_eq!(simulation.deferred().len(), 1); +} + +#[test] +fn precedence_is_honoured_in_the_timeline() { + let scenario = Scenario::parse( + "scenario chain\n\ + resource 0 capacity 1\n\ + window 0 0 1000\n\ + task 0 duration 60\n\ + require 0 0 1\n\ + task 1 duration 60\n\ + require 1 0 1\n\ + precedence 0 1\n", + ) + .expect("the text is well formed"); + let simulation = Simulation::prepare(scenario).expect("usable"); + assert!(simulation.honours_precedence()); + let schedule = simulation.schedule(); + let first = schedule + .placement_of(apsis_task::TaskId::new(0)) + .expect("placed"); + let second = schedule + .placement_of(apsis_task::TaskId::new(1)) + .expect("placed"); + assert!(second.start >= first.end()); +} + +#[test] +fn the_schedule_evaluates() { + let simulation = + Simulation::prepare(scenario_with(&[(0, 60.0, 1.0), (1, 90.0, 2.0)])).expect("usable"); + let evaluation = simulation.evaluate(); + assert_eq!(evaluation.placed_count, 2); + assert_eq!(evaluation.offered_count, 2); + assert_eq!(evaluation.scheduled_seconds, 150.0); +} + +#[test] +fn an_unusable_scenario_is_refused() { + let mut scenario = Scenario::new(""); + // An empty name is refused by validation. + scenario + .add_resource(apsis_resource::Resource::new( + apsis_resource::ResourceId::new(0), + 1.0, + )) + .expect("the resource is usable"); + let error = Simulation::prepare(scenario).expect_err("the scenario is unusable"); + assert_eq!(error.kind(), SimulationErrorKind::InvalidScenario); +} + +#[test] +fn a_plan_naming_a_task_outside_the_scenario_is_refused() { + // A scheduling result that places a task the scenario does not hold is + // inconsistent and must be rejected rather than run. + let mut scenario = Scenario::new("mismatch"); + scenario + .add_resource(apsis_resource::Resource::new( + apsis_resource::ResourceId::new(0), + 1.0, + )) + .expect("the resource is usable"); + scenario + .add_task(apsis_task::Task::new(apsis_task::TaskId::new(0), 60.0)) + .expect("the task is usable"); + + let mut schedule = apsis_schedule::Schedule::new(); + // Task 99 is not in the scenario. A placement records the duration, so + // it is placed directly rather than through a task. + schedule + .place( + &apsis_task::Task::new(apsis_task::TaskId::new(99), 60.0), + 0.0, + ) + .expect("the placement fits"); + let result = apsis_scheduler::SchedulingResult { + schedule, + outcomes: Vec::new(), + improved: Vec::new(), + }; + let error = Simulation::from_plan(scenario, result).expect_err("the plan is inconsistent"); + assert_eq!(error.kind(), SimulationErrorKind::InconsistentPlan); +} + +#[test] +fn an_empty_plan_is_accepted() { + // A scenario whose tasks none can be placed still prepares; the + // resulting simulation simply has an empty timeline. + let scenario = Scenario::new("empty"); + let result = + apsis_scheduler::Planner::new().schedule(&[], &scenario.resource_set().expect("ok")); + let simulation = Simulation::from_plan(scenario, result).expect("the plan is consistent"); + assert_eq!(simulation.activity_count(), 0); + assert!(simulation.has_full_plan()); +} + +#[test] +fn the_activity_type_reports_itself() { + let start = Activity::start(apsis_task::TaskId::new(3)); + let end = Activity::end(apsis_task::TaskId::new(3)); + assert!(start.is_start()); + assert!(!start.is_end()); + assert!(end.is_end()); + assert_eq!(start.task(), apsis_task::TaskId::new(3)); + assert_eq!(end.task(), apsis_task::TaskId::new(3)); + assert!(start.to_string().contains("start")); + assert!(end.to_string().contains("end")); +} + +#[test] +fn the_session_can_record_without_running() { + let simulation = Simulation::prepare(scenario_with(&[(0, 60.0, 1.0)])).expect("usable"); + let mut session = simulation.start(); + assert!(session.note(1.0, "manual", 5.0)); + assert!(session.record(2.0, RecordKind::Warning, "manual", 6.0)); + assert_eq!(session.recorder().len(), 2); + // A run takes the trace, so the manual records come out with it. + let report: RunReport = session.run(|_, _, _| {}); + assert_eq!(report.trace.len(), 2); + assert_eq!(report.warning_count(), 1); +} + +#[test] +fn the_engine_is_reachable_from_the_session() { + let simulation = Simulation::prepare(scenario_with(&[(0, 60.0, 1.0)])).expect("usable"); + let mut session = simulation.start(); + assert!(!session.engine().is_idle()); + assert_eq!(session.engine().pending(), 2); + session.engine_mut().clear(); + assert!(session.engine().is_idle()); +} + +#[test] +fn the_simulation_exposes_its_scenario_and_resources() { + let simulation = Simulation::prepare(scenario_with(&[(0, 60.0, 1.0)])).expect("usable"); + assert_eq!(simulation.scenario().name(), "test"); + assert_eq!(simulation.resources().len(), 1); + assert_eq!(simulation.tasks().len(), 1); + assert_eq!(simulation.plan().outcomes.len(), 1); +}