sim: add simulation facade and integration example
This commit is contained in:
parent
2ebd85a434
commit
a09dd802e0
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -197,3 +197,7 @@ required-features = ["physics"]
|
|||
[[example]]
|
||||
name = "integrate_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
|
||||
|
||||
[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.
|
||||
//!
|
||||
//! 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