1
0
forked from ROMEO/nexosim
nexosim/asynchronix/examples/espresso_machine.rs
Serge Barral f458377308 Make it possible to cancel current-time events
This is a pretty large patch that impacts the API.

Until now, it was not possible to cancel events that were scheduled for
the current simulation time slice, making it necessary for the user to
use complex workarounds (see former version of the espresso machine
example).

The new implementation makes this possible but the generation of a key
associated to an event has now a non-negligible cost (basicaly it
creates three references to an Arc). For this reason, the API now
defaults to NOT creating a key, and new methods were added for
situations when the event may need to be cancelled and a key is
necessary.

See the much simplified implementation of the espresso machine example
for a motivating case.
2023-07-21 14:23:20 +02:00

449 lines
15 KiB
Rust

//! Example: espresso coffee machine.
//!
//! This example demonstrates in particular:
//!
//! * non-trivial state machines,
//! * cancellation of events,
//! * model initialization,
//! * simulation monitoring with event slots.
//!
//! ```text
//! flow rate
//! ┌─────────────────────────────────────────────┐
//! │ (≥0) │
//! │ ┌────────────┐ │
//! └───▶│ │ │
//! added volume │ Water tank ├────┐ │
//! Water fill ●───────────────────▶│ │ │ │
//! (>0) └────────────┘ │ │
//! │ │
//! water sense │ │
//! ┌──────────────────────┘ │
//! │ (empty|not empty) │
//! │ │
//! │ ┌────────────┐ ┌────────────┐ │
//! brew time └───▶│ │ command │ │ │
//! Brew time dial ●───────────────────▶│ Controller ├─────────▶│ Water pump ├───┘
//! (>0) ┌───▶│ │ (on|off) │ │
//! │ └────────────┘ └────────────┘
//! trigger │
//! Brew command ●───────────────┘
//! (-)
//! ```
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use asynchronix::model::{InitializedModel, Model, Output};
use asynchronix::simulation::{Mailbox, SimInit};
use asynchronix::time::{EventKey, MonotonicTime, Scheduler};
/// Water pump.
pub struct Pump {
/// Actual volumetric flow rate [m³·s⁻¹] -- output port.
pub flow_rate: Output<f64>,
/// Nominal volumetric flow rate in operation [m³·s⁻¹] -- constant.
nominal_flow_rate: f64,
}
impl Pump {
/// Creates a pump with the specified nominal flow rate [m³·s⁻¹].
pub fn new(nominal_flow_rate: f64) -> Self {
Self {
nominal_flow_rate,
flow_rate: Output::default(),
}
}
/// Main ON/OFF command -- input port.
pub async fn command(&mut self, cmd: PumpCommand) {
let flow_rate = match cmd {
PumpCommand::On => self.nominal_flow_rate,
PumpCommand::Off => 0.0,
};
self.flow_rate.send(flow_rate).await;
}
}
impl Model for Pump {}
/// Espresso machine controller.
pub struct Controller {
/// Pump command -- output port.
pub pump_cmd: Output<PumpCommand>,
/// Brew time setting [s] -- internal state.
brew_time: Duration,
/// Current water sense state.
water_sense: WaterSenseState,
/// Event key, which if present indicates that the machine is currently
/// brewing -- internal state.
stop_brew_key: Option<EventKey>,
}
impl Controller {
/// Default brew time [s].
const DEFAULT_BREW_TIME: Duration = Duration::new(25, 0);
/// Creates an espresso machine controller.
pub fn new() -> Self {
Self {
brew_time: Self::DEFAULT_BREW_TIME,
pump_cmd: Output::default(),
stop_brew_key: None,
water_sense: WaterSenseState::Empty, // will be overridden during init
}
}
/// Signals a change in the water sensing state -- input port.
pub async fn water_sense(&mut self, state: WaterSenseState) {
// Check if the tank just got empty.
if state == WaterSenseState::Empty && self.water_sense == WaterSenseState::NotEmpty {
// If a brew was ongoing, we must cancel it.
if let Some(key) = self.stop_brew_key.take() {
key.cancel_event();
self.pump_cmd.send(PumpCommand::Off).await;
}
}
self.water_sense = state;
}
/// Sets the timing for the next brews [s] -- input port.
pub async fn brew_time(&mut self, brew_time: Duration) {
// Panic if the duration is null.
assert!(!brew_time.is_zero());
self.brew_time = brew_time;
}
/// Starts brewing or cancels the current brew -- input port.
pub async fn brew_cmd(&mut self, _: (), scheduler: &Scheduler<Self>) {
// If a brew was ongoing, sending the brew command is interpreted as a
// request to cancel it.
if let Some(key) = self.stop_brew_key.take() {
self.pump_cmd.send(PumpCommand::Off).await;
// Abort the scheduled call to `stop_brew()`.
key.cancel_event();
return;
}
// If there is no water, do nothing.
if self.water_sense == WaterSenseState::Empty {
return;
}
// Schedule the `stop_brew()` method and turn on the pump.
self.stop_brew_key = Some(
scheduler
.schedule_keyed_event_in(self.brew_time, Self::stop_brew, ())
.unwrap(),
);
self.pump_cmd.send(PumpCommand::On).await;
}
/// Stops brewing.
async fn stop_brew(&mut self) {
if self.stop_brew_key.take().is_some() {
self.pump_cmd.send(PumpCommand::Off).await;
}
}
}
impl Model for Controller {}
/// ON/OFF pump command.
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum PumpCommand {
On,
Off,
}
/// Water tank.
pub struct Tank {
/// Water sensor -- output port.
pub water_sense: Output<WaterSenseState>,
/// Volume of water [m³] -- internal state.
volume: f64,
/// State that exists when the mass flow rate is non-zero -- internal state.
dynamic_state: Option<TankDynamicState>,
}
impl Tank {
/// Creates a new tank with the specified amount of water [m³].
///
/// The initial flow rate is assumed to be zero.
pub fn new(water_volume: f64) -> Self {
assert!(water_volume >= 0.0);
Self {
volume: water_volume,
dynamic_state: None,
water_sense: Output::default(),
}
}
/// Water volume added [m³] -- input port.
pub async fn fill(&mut self, added_volume: f64, scheduler: &Scheduler<Self>) {
// Ignore zero and negative values. We could also impose a maximum based
// on tank capacity.
if added_volume <= 0.0 {
return;
}
let was_empty = self.volume == 0.0;
// Account for the added water.
self.volume += added_volume;
// If the current flow rate is non-zero, compute the current volume and
// schedule a new update.
if let Some(state) = self.dynamic_state.take() {
// Abort the scheduled call to `set_empty()`.
state.set_empty_key.cancel_event();
// Update the volume, saturating at 0 in case of rounding errors.
let time = scheduler.time();
let elapsed_time = time.duration_since(state.last_volume_update).as_secs_f64();
self.volume = (self.volume - state.flow_rate * elapsed_time).max(0.0);
self.schedule_empty(state.flow_rate, time, scheduler).await;
// There is no need to broadcast the state of the water sense since
// it could not be previously `Empty` (otherwise the dynamic state
// would not exist).
return;
}
if was_empty {
self.water_sense.send(WaterSenseState::NotEmpty).await;
}
}
/// Flow rate [m³·s⁻¹] -- input port.
///
/// # Panics
///
/// This method will panic if the flow rate is negative.
pub async fn set_flow_rate(&mut self, flow_rate: f64, scheduler: &Scheduler<Self>) {
assert!(flow_rate >= 0.0);
let time = scheduler.time();
// If the flow rate was non-zero up to now, update the volume.
if let Some(state) = self.dynamic_state.take() {
// Abort the scheduled call to `set_empty()`.
state.set_empty_key.cancel_event();
// Update the volume, saturating at 0 in case of rounding errors.
let elapsed_time = time.duration_since(state.last_volume_update).as_secs_f64();
self.volume = (self.volume - state.flow_rate * elapsed_time).max(0.0);
}
self.schedule_empty(flow_rate, time, scheduler).await;
}
/// Schedules a callback for when the tank becomes empty.
///
/// Pre-conditions:
/// - `flow_rate` cannot be negative.
/// - `self.volume` should be up to date,
/// - `self.dynamic_state` should be `None`.
async fn schedule_empty(
&mut self,
flow_rate: f64,
time: MonotonicTime,
scheduler: &Scheduler<Self>,
) {
// Determine when the tank will be empty at the current flow rate.
let duration_until_empty = if self.volume == 0.0 {
0.0
} else {
self.volume / flow_rate
};
if duration_until_empty.is_infinite() {
// The flow rate is zero or very close to zero, so there is not
// need to plan an update since the tank will never become
// empty.
return;
}
let duration_until_empty = Duration::from_secs_f64(duration_until_empty);
// Schedule the next update.
match scheduler.schedule_keyed_event_in(duration_until_empty, Self::set_empty, ()) {
Ok(set_empty_key) => {
let state = TankDynamicState {
last_volume_update: time,
set_empty_key,
flow_rate,
};
self.dynamic_state = Some(state);
}
Err(_) => {
// The duration was null so the tank is already empty.
self.volume = 0.0;
self.water_sense.send(WaterSenseState::Empty).await;
}
}
}
/// Updates the state of the tank to indicate that there is no more water.
async fn set_empty(&mut self) {
self.volume = 0.0;
self.dynamic_state = None;
self.water_sense.send(WaterSenseState::Empty).await;
}
}
impl Model for Tank {
/// Broadcasts the initial state of the water sense.
fn init(
mut self,
_scheduler: &Scheduler<Self>,
) -> Pin<Box<dyn Future<Output = InitializedModel<Self>> + Send + '_>> {
Box::pin(async move {
self.water_sense
.send(if self.volume == 0.0 {
WaterSenseState::Empty
} else {
WaterSenseState::NotEmpty
})
.await;
self.into()
})
}
}
/// Dynamic state of the tank that exists when and only when the mass flow rate
/// is non-zero.
struct TankDynamicState {
last_volume_update: MonotonicTime,
set_empty_key: EventKey,
flow_rate: f64,
}
/// Water level in the tank.
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum WaterSenseState {
Empty,
NotEmpty,
}
fn main() {
// ---------------
// Bench assembly.
// ---------------
// Models.
// The constant mass flow rate assumption is of course a gross
// simplification, so the flow rate is set to an expected average over the
// whole extraction [m³·s⁻¹].
let pump_flow_rate = 4.5e-6;
// Start with 1.5l in the tank [m³].
let init_tank_volume = 1.5e-3;
let mut pump = Pump::new(pump_flow_rate);
let mut controller = Controller::new();
let mut tank = Tank::new(init_tank_volume);
// Mailboxes.
let pump_mbox = Mailbox::new();
let controller_mbox = Mailbox::new();
let tank_mbox = Mailbox::new();
// Connections.
controller.pump_cmd.connect(Pump::command, &pump_mbox);
tank.water_sense
.connect(Controller::water_sense, &controller_mbox);
pump.flow_rate.connect(Tank::set_flow_rate, &tank_mbox);
// Model handles for simulation.
let mut flow_rate = pump.flow_rate.connect_slot().0;
let controller_addr = controller_mbox.address();
let tank_addr = tank_mbox.address();
// Start time (arbitrary since models do not depend on absolute time).
let t0 = MonotonicTime::EPOCH;
// Assembly and initialization.
let mut simu = SimInit::new()
.add_model(controller, controller_mbox)
.add_model(pump, pump_mbox)
.add_model(tank, tank_mbox)
.init(t0);
// ----------
// Simulation.
// ----------
// Check initial conditions.
let mut t = t0;
assert_eq!(simu.time(), t);
// Brew one espresso shot with the default brew time.
simu.send_event(Controller::brew_cmd, (), &controller_addr);
assert_eq!(flow_rate.take(), Some(pump_flow_rate));
simu.step();
t += Controller::DEFAULT_BREW_TIME;
assert_eq!(simu.time(), t);
assert_eq!(flow_rate.take(), Some(0.0));
// Drink too much coffee.
let volume_per_shot = pump_flow_rate * Controller::DEFAULT_BREW_TIME.as_secs_f64();
let shots_per_tank = (init_tank_volume / volume_per_shot) as u64; // YOLO--who cares about floating-point rounding errors?
for _ in 0..(shots_per_tank - 1) {
simu.send_event(Controller::brew_cmd, (), &controller_addr);
assert_eq!(flow_rate.take(), Some(pump_flow_rate));
simu.step();
t += Controller::DEFAULT_BREW_TIME;
assert_eq!(simu.time(), t);
assert_eq!(flow_rate.take(), Some(0.0));
}
// Check that the tank becomes empty before the completion of the next shot.
simu.send_event(Controller::brew_cmd, (), &controller_addr);
simu.step();
assert!(simu.time() < t + Controller::DEFAULT_BREW_TIME);
t = simu.time();
assert_eq!(flow_rate.take(), Some(0.0));
// Try to brew another shot while the tank is still empty.
simu.send_event(Controller::brew_cmd, (), &controller_addr);
assert!(flow_rate.take().is_none());
// Change the brew time and fill up the tank.
let brew_time = Duration::new(30, 0);
simu.send_event(Controller::brew_time, brew_time, &controller_addr);
simu.send_event(Tank::fill, 1.0e-3, tank_addr);
simu.send_event(Controller::brew_cmd, (), &controller_addr);
assert_eq!(flow_rate.take(), Some(pump_flow_rate));
simu.step();
t += brew_time;
assert_eq!(simu.time(), t);
assert_eq!(flow_rate.take(), Some(0.0));
// Interrupt the brew after 15s by pressing again the brew button.
simu.schedule_event_in(
Duration::from_secs(15),
Controller::brew_cmd,
(),
&controller_addr,
)
.unwrap();
simu.send_event(Controller::brew_cmd, (), &controller_addr);
assert_eq!(flow_rate.take(), Some(pump_flow_rate));
simu.step();
t += Duration::from_secs(15);
assert_eq!(simu.time(), t);
assert_eq!(flow_rate.take(), Some(0.0));
}