-
Notifications
You must be signed in to change notification settings - Fork 1
PDEVS
Atomic discrete event models are defined in Quartz by subclassing the Quartz::AtomicModel base class.
They allow to model timed, reactive and autonomous systems.
Their behaviour is described through state transitions and other functions, triggered by the simulator as events occur.
Which state transition is triggered depends on the type of event that occurs, which can be either:
- an internal event, which is planned autonomously by the model to occur at a certain point in time;
- or an external event, which may occur at any point in time. It is unplanned by the model and is used to carry one or multiple input values received through input ports so the model can react to it accordingly.
As there are many subtleties to define such system, the remainder of this page is better read entirely. However, you can jump to a subsection if you're familiar with Quartz or with the PDEVS formalism:
Let's take a running example to incrementally describe the behaviour of a simple system. We will model a cashier, a commonly used abstraction in Queuing theory that features a timed, autonomous and reactive behaviour.
In our abstraction, the cashier is either available waiting to process clients, busy serving a client or unavailable because he leaves. When available, the cashier have to serve clients (one at a time) as they arrive by processing a given amount of articles. He becomes busy for a while, the processing time being proportional to the number of articles to process. Once finished, the cashier outputs the processed articles and signals he is now available unless he leaves, in which case he will signal its availability later.
Before starting to define our state transitions, let's declare the state and I/O interface of our cashier model:
class Cashier < Quartz::AtomicModel
input :articles
output :available, :articles
state do
enum Phase
Available
Busy
Unavailable
end
parameter throughput = 0.4 # articles per time unit
var phase = Phase::Available
var number_of_articles = 0
end
endArticles are received through the input port :article and passed back through the output port having the same name. The available signal is sent through the output port :available.
As for the state, a phase variable is used to indicate in which phase the cashier currently is: processing articles, doing something else or waiting for clients.
The variable number_of_articles indicates how many articles are to be processed.
The throughput parameter indicates the mean processing time per article of the cashier.
Disclaimer: the default throughput is arbitrarily chosen and may not be realistic. Choosing the right parameters is usually the task of domain experts.
Note: we didn't specified a time precision for the model since it defaults to the base time unit, which is fine for our abstraction. A time unit will represent a second in our case.
When available, our cashier waits clients for a maximum amout of 5 minutes, otherwise he leaves for doing other stuff but always comes back every 15 minutes. When he is busy, he stays that way the amount of time necessary to process the articles.
We will define the time spent in each state thanks to the #time_advance method.
The time advance method is used by the simulator to determine when the next internal event will be scheduled. In other words, it allows you to specify how much time you want to spend in the current state.
It is called after each state transition to give a chance the model to evolve autonomously.
It is also called when the simulation is initialized, to know which models will first be scheduled.
The expected return type is a Duration (see time representation).
An implementation for our requirements can be something like:
class Cashier < Quartz::AtomicModel
# ...
def time_advance : Quartz::Duration
case state.phase
when .available?
5 * 60.time_units
when .unavailable?
15 * 60.time_units
else
Quartz.duration(state.number_of_articles / throughput)
end
end
endNote: An important thing to remember is that you are not supposed to mutate the state from the time advance method, doing so may lead to an unexpected behaviour.
Congratulations! You completed an important step in the definition of a discrete event model, which is planning your future events.
So what now?
When a planned event expires, it is said to be due. The simulator will trigger an internal state transition to update the model state via the #internal_transition.
Right before calling this transition, a chance is given to your model to send outputs for the expiring state through the #output method.
The simulator will always call the methods in this order, unless an external event comes perturbate the sequence (more on that later):
#time_advance#output#internal_transition
Given our requirements, the model should send outputs in all 3 states.
The number of articles processed at the end of Busy state should be sent to the :articles output port, and a flag indicating whether the cashier may process clients for each state should be sent through the :available port.
Sending values to ports is done using the #post method:
class Cashier < Quartz::AtomicModel
# ...
def output
if state.phase.busy?
post state.number_of_articles, on: :articles
end
going_available = state.phase.busy? || state.phase.unavailable?
post going_available, on: :available
end
endNote: Like the #time_advance method, you are not supposed to mutate the state from the #output method, doing so may lead to an unexpected behaviour. Also, #post is only meant to be used in the context of the #output method.
An important thing to notice is that when the cashier is in the Available state, we send the opposite on the :available port.
This is because the output method is called when the state expires.
Thus, it is about to become unavailable.
This can be counterintuitive in some cases, but this is how the underlying formalism on which Quartz is based is defined.
But this also makes a lot of sense for other cases, such as sending the number of processed articles at the end of the Busy state.
Once our outputs are sent, we may change our expiring state via the #internal_transition function.
The new state will depend on whether the cashier is currently available.
If so, it will become unavailable because it means the cashier spent 5 minutes waiting for clients and decide to leave.
If not it will become available, because it either means the cashier comes back or that he just finished processing the previous client.
class Cashier < Quartz::AtomicModel
# ...
def internal_transition
state.number_of_articles = 0
state.phase = if state.phase.available?
State::Phase::Unavailable
else
State::Phase::Available
end
end
endNote: you can also assign a new state using the #state= method.
Congratulations! you defined your first autonomous discrete event model with Quartz, with outputs.
Let's review what will happen if we simulate our cashier model as-is:
- In the initial state, the cashier is in the
Activestate phase. - The simulator calls the
#time_advancefunction, which returns a duration of 300s in our case (5min). - The
Activestate expires (no inputs comes perturbate the model). The#outputfunction is called, which outputsfalseto the:availableoutput port.#internal_transitionis called and the model goes to theUnavailablestate. - The
#time_advancefunction is called again. The current state will expire after 900s (15min), and so on.
Such a simulation can go on forever unless a termination condition is set. Note that the simulation may stop on its own if the model returns an infinite duration from #time_advance.
Let's now focus on another state transition expected to be defined by an atomic model: the external transition, which may be triggered at any point in time. No matter the current state planned duration, the external transition will perturbate the model. This is exactly what we should expect from a reactive system.
For example, when our cashier is available, he plans to become unavailable after 5min if no client shows up to avoid sitting indefinitely. If our cashier were a robot, waiting indefinitely would be modelled by planning an infinite duration for the available state. No matter the duration, what is important here is that we expect a client to interrupt this available state at some point.
In Quartz, you should define the #external_transition(messages) method to react to external inputs.
The messages parameter is a Hash mapping input ports to a list of input values (Hash(InputPort, Array(Any))).
This means multiple inputs can arrive at the same time, even on the same input port.
When input events occurs, the following methods are called:
-
#external_transition(messages)to change the state according to the inputs. -
#time_advanceto plan the new future internal event.
Here's a first attempt in writing our cashier external transition:
class Cashier < Quartz::AtomicModel
# ...
def external_transition(messages)
if state.phase.available?
n = messages[input_port(:articles)].map(&.as_i).sum
state.number_of_articles = n
state.phase = State::Phase::Busy
else
raise "No inputs should arrive when #{self} is unavailable"
end
end
endAs you can see, we only consider inputs when our cashier is available.
In our case, we only have one input port, so we know for sure that messages contains the input_port(:articles) key with associated input(s).
As already said, it is possible that multiple inputs arrive simultaneously even on the same input port. For our cashier, it would mean that several clients arrive at the same time, each with their own shopping cart full of articles. What is going to be our policy then? We can either decide to process the sum of all articles as if it were only one client, or we can decide to select one of them, again according to some policy (randomly, based on a property, ...).
In the proposed external transition, we took the first option.
We update the cashier state with the number of articles to process and change its phase to Busy.
Note: If we chose to select only one client, the others will be silently ignored and their articles will never be processed unless the cashier has a way to put them back on the system, e.g. via a dedicated output port.
When our cashier is not available, an exception is raised. While this may be useful to detect the mis-use of a model, there are more graceful ways to handle such cases. Read on to know how.
An important thing to notice is that the output function is not called before the external transition, unlike it is for the internal transition. In this sense, atomic models are closer to Moore machines than to Mealy machines because the outputs are only based on the current state, and not on the inputs.
This means that sending an output immediately after receiving an input requires the use of a transient state, which is completely fictional and only introduced for modelling purposes. The planned duration associated to such state is a duration of 0 (which is allowed) and triggers the output method.
In our cashier example, we don't need this to output the number of articles, which already has a dedicated state (busy). However, we do need introduce a transient state to signal the cashier is not available anymore.
We need to revise our model a bit for such purpose:
class Cashier < Quartz::AtomicModel
# ...
state do
enum Phase
Available
GoingBusy # transient state
Busy
Unavailable
end
end
def external_transition(messages)
if state.phase.available?
n = messages[input_port(:articles)].map(&.as_i).sum
state.number_of_articles = n
state.phase = State::Phase::GoingBusy # going to transient state
else
raise "No inputs should arrive when #{self} is unavailable"
end
end
def output
if state.phase.busy?
post state.number_of_articles, on: :articles
end
going_available = state.phase.busy? || state.phase.unavailable? # updated
post going_available, on: :available
end
def internal_transition
state.number_of_articles = 0
state.phase = if state.phase.available?
State::Phase::Unavailable
elsif state.phase.going_busy? # updated
State::Phase::Busy
else
State::Phase::Available
end
end
endTo keep track of how much time passed since the last state change, the elapsed time variable can be used.
It is available through the #elapsed method and its value is updated along with the simulation before each state transition.
This variable is crucial for the following use cases.
Let's decide to change our external transition to ignore clients coming in while the cashier is busy or unavailable, instead of raising an exception.
Pretty straightforward! Our revised transition would look like this:
def external_transition(messages)
if state.phase.available?
n = messages[input_port(:articles)].map(&.as_i).sum
state.number_of_articles = n
state.phase = State::Phase::GoingBusy
end
endSpoiler alert: it's wrong.
While this seems straightforward, we have to keep in mind that right after #external_transition is called, the #time_advance method is also called so that the simulator knows how much time the cashier wants to stay in the same state.
So if our cashier is in the Busy state for 25s (the time necessary to process 10 articles) and a client interrupts the cashier 15 seconds after he started processing articles, the time advance function is going to return again 25s (Quartz.duration(state.number_of_articles / throughput)). The cashier will spend in total 35s in the busy state instead of 25.
To avoid such issue, we have take the elapsed time into account.
A common way to handle this is to introduce a state variable that represents the planned duration associated with a state, e.g. var planned_duration = 0.time_units.
Here's a new version of our model with such technique:
class Cashier < Quartz::AtomicModel
input :articles
output :available, :articles
state do
enum Phase
Available
GoingBusy
Busy
Unavailable
end
parameter throughput = 0.4 # articles per time unit
var phase = Phase::Available
var number_of_articles = 0
var planned_duration : Quartz::Duration = 5 * 60.time_units
end
def time_advance : Quartz::Duration
state.planned_duration
end
def output
if state.phase.busy?
post state.number_of_articles, on: :articles
end
going_available = state.phase.busy? || state.phase.unavailable?
post going_available, on: :available
end
def external_transition(msg)
if state.phase.available?
n = msg[input_port(:articles)].map(&.as_i).sum
state.planned_duration = Quartz.duration(n / throughput)
state.number_of_articles = n
state.phase = State::Phase::GoingBusy
else
# reschedules an internal event as before interruption
state.planned_duration -= self.elapsed
end
end
def internal_transition
if state.phase.available?
state.phase = State::Phase::Unavailable
state.planned_duration = 15 * 60.time_units
elsif state.phase.going_busy?
state.phase = State::Phase::Busy
state.planned_duration = 0.time_units
else
state.phase = State::Phase::Available
state.planned_duration = 5 * 60.time_units
end
end
endThe elapsed time can also be set during initialization. It will be taken into account by the simulator to calculate the next internal event, according to this formula :
next_internal_event = model.time_advance - model.elapsedThis can be useful to initialize or restore a simulation in a particular state.
For example, if we consider a model where multiple cashiers works in parallel, the initial elapsed value can be used to shift the time at which each of them goes unavailable, to avoid all cashiers leaving at the same time if no clients showed up for 5 minutes.
cashier1 = Cashier.new("cashier_1")
cashier2 = Cashier.new("cashier_2")
cashier2.elapsed = 150.time_unitsIf we simulate those two cashiers, their breaks will be staggered to increase the amount of time clients can actually be served. The time sequence would be as follows if no input occurs:
- After 2m30, cashier 2 takes a break for 15min
- At time 5m, cashier 1 takes a break for 15min
- At time 17m30, cashier 2 comes back
- At time 20m, cashier 1 comes back
The last important thing to consider is the possibility that an internal event may expires at the same time an input event occurs.
This situation is known as an event collision and can be specicically adressed. A specific state transition, called confluent transition is used and literally represent the union of an internal event with an external event. It has a default behavior that can be overriden for the purpose of your model.
Its default definition is as follows:
def confluent_transition(messages)
internal_transition
external_transition(messages)
endWhile this is an appropriate handling of the conflict in many cases, you may want to reverse the applied order of transitions, or define a specific behaviour altogether.
Note: In any case, that means that outputs are already sent (recall the sequence of transitions).
In our example, such conflict can occur if a client arrives when the cashier finishes with a first client.
While you learned how to effectively mix autonomous and reactive behaviour, this section provides examples to common patterns, such as generators and collectors. The former are purely autonomous while the latter are purely reactive.
As their names suggest, generators are used to send values over one or several outputs ports and don't expect any input.
They are typically used to generate a sequence of values, from a statistical distribution or from data points stored in a file or in a database.
Below is an example that generates random numbers between 0 and 1 from a uniform distribution every 1 time unit:
class RandomGenerator < Quartz::AtomicModel
output :out
state { parameter prng = Random.new }
def time_advance : Quartz::Duration
1.time_unit
end
def output
post prng.rand, on: :out
end
def internal_transition
end
def external_transition(bag)
end
endCollectors are the exact opposive of generators. They feature a passive behaviour, waiting indefinitely until inputs wakes them up to do a task.
Quartz provides the PassiveBehavior mix-in to help define such models.
It defines an empty behaviour to the #output, #internal_transition and defines a #time_advance methods that returns an infinite duration.
Below is an example that counts the total number of inputs received:
class Counter < Quartz::AtomicModel
include Quartz::PassiveBehavior
state { var count = 0 }
def external_transition(messages)
self.each_input_port do |input_port|
if messages.has_key?(input_port)
state.count += messages[input_port].size
end
end
end
endIn the next section you'll learn how to couple models together to form a network of components. Yay!