sim: add simulation facade and integration example
This commit is contained in:
parent
d595c1ce3c
commit
241b6bf073
|
|
@ -313,6 +313,17 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-sim"
|
name = "apsis-sim"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "apsis-sim-engine"
|
name = "apsis-sim-engine"
|
||||||
|
|
|
||||||
|
|
@ -197,3 +197,7 @@ required-features = ["physics"]
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "integrate_ops"
|
name = "integrate_ops"
|
||||||
required-features = ["ops"]
|
required-features = ["ops"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "integrate_sim"
|
||||||
|
required-features = ["sim"]
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -11,3 +11,12 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[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
|
||||||
|
|
|
||||||
|
|
@ -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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -1 +1,69 @@
|
||||||
//! Simulation facade that integrates the simulation layer.
|
//! 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};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue