sim: add simulation facade and integration example

This commit is contained in:
ApsisRS contributors 2026-09-26 22:52:51 +08:00
parent d595c1ce3c
commit 241b6bf073
10 changed files with 1025 additions and 0 deletions

11
Cargo.lock generated
View File

@ -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"

View File

@ -197,3 +197,7 @@ required-features = ["physics"]
[[example]]
name = "integrate_ops"
required-features = ["ops"]
[[example]]
name = "integrate_sim"
required-features = ["sim"]

View File

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

View File

@ -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

View File

@ -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}"),
}
}
}

View File

@ -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<String>,
}
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<String>) -> 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 {}

View File

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

View File

@ -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<Activity>,
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<Activity> {
&self.engine
}
/// Returns the engine for modification.
pub const fn engine_mut(&mut self) -> &mut Engine<Activity> {
&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<F>(&mut self, mut handler: F) -> RunReport
where
F: FnMut(&mut Engine<Activity>, &mut Recorder, apsis_event::Event<Activity>),
{
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<String>, 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<String>,
value: f64,
) -> bool {
self.recorder.record(time, kind, label, value)
}
}

View File

@ -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<Self, SimulationError> {
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<Self, SimulationError> {
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<Self, SimulationError> {
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<TaskId> {
self.result.deferred()
}
/// Returns the tasks that cannot be placed at all.
pub fn infeasible(&self) -> Vec<TaskId> {
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)
}
}

View File

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