Skip to content

5. Examples of DEVS Models

Román Cárdenas edited this page Feb 1, 2023 · 7 revisions

Here we illustrate how to develop DEVS models with Cadmium 2. All the examples explained here are implemented and available in the example folder of the repo.

GPT Model

The Generator-Processor-Transducer model is a classic example in the DEVS community. This model consists of three atomic models (Generator, Processor, and Transducer) and a coupled model (GPT) that interconnects the atomic models. The GPT model looks as follows:

Diagram of the GPT Coupled DEVS Model.

The Generator model creates new a new job every jobPeriod seconds and sends them via its outGenerated port. The Processor model receives newly generated jobs through its inGenerated port and processes them in a first-come-first-served basis. The Processor model tasks processingTime seconds to process a new job. The Processor model sends processed jobs via its outProcessed port. The Transducer model monitors the generated and processed jobs and computes some statistics (e.g., job processing throughput). After obsTime seconds, it sends a message via its outStop port to tell the Generator model to stop creating new jobs. The Generator model receives this message via its inStop port.

All the code for this example is available in the example/efp_gpt folder of the repo.

Port Data Types

We must first implement all the custom data types of the ports in our model. In this scenario, there are only two port data types: bool and Job. The bool data type is a built-in data type of C++, so we don't have to worry about it. However, the Job data type is specific of our model, and we must first define it. You can find its implementation in the example/efp_gpt/include/job.hpp file.

struct Job {
    int id;                //!< Job ID number.
    double timeGenerated;  //!< Time in which the job was created.
    double timeProcessed;  //!< Time in which the job was processed. If -1, the job has not been processed yet.

    Job(int id, double timeGenerated): id(id), timeGenerated(timeGenerated), timeProcessed(-1) {}
};

The id field corresponds to the unique ID of the job. The time in which the job was created is timeGenerated, and timeProcessed defines the time in which the job was processed. When creating a new Job message, the Generator model must define an ID and a generation time. However, as the job has not been processed yet, timeProcessed is initially set to -1.

In Cadmium 2, all the port data types of your model must implement the insertion (<<) operator. In this way, Cadmium will be able to log messages during a simulation. We implement this operator as follows:

#include <iostream>

std::ostream& operator<<(std::ostream& out, const Job& j) {
    out << "{" << j.id << "," << j.timeGenerated << "," << j.timeProcessed << "}";
    return out;
}

With this operator, a job which ID is 1 that has been generated at t=3.5 but has not been processed yet will be represented as "{1,3.5,-1}". You can implement the << operator as you want.

Atomic Models

Now it's time to implement all the atomic models in out model. We will illustrate the whole process with the Generator model as an example. The Processor and Transducer models are implemented similarly. You can find all the code for the implementation in the example/efp_gpt/include/generator.hpp file

Implementation of the Generator Model

First, we must define the data type used to represent the Generator atomic model state. The GeneratorState struct represents the state of the Generator model:

struct GeneratorState {
    double clock;  //!< Current simulation time.
    double sigma;  //!< Time to wait before triggering the next internal transition function.
    int jobCount;  //!< Number of jobs generated by the Generator model so far.
    
    GeneratorState(): clock(), sigma(), jobCount() {}
};

The clock and sigma attributes are useful for keeping track of the current simulation time and the time remaining before triggering the next internal transition of our atomic model. Finally, jobCount tells us how many jobs the Generator model has produced so far. By default, all the state attributes are initialized to 0.

In Cadmium 2, all the atomic model state data types of your model must implement the insertion (<<) operator. In this way, Cadmium will be able to log model states during a simulation. We implement this operator as follows:

std::ostream& operator<<(std::ostream& out, const GeneratorState& s) {
    out << s.jobCount;
    return out;
}

With this operator, the state of the Generator is represented as the number of jobs generated so far. Now, let's implement our Generator model:

#include <cadmium/core/modeling/atomic.hpp>  //! We need to import the cadmium::Atomic<S> class

class Generator: public cadmium::Atomic<GeneratorState> {  //! Atomic models MUST inherit from the cadmium::Atomic<S> class
  private:
    double jobPeriod;                                  //!< Time to wait between Job generations.
  public:
    cadmium::Port<bool> inStop;          //!< Input Port for receiving stop generating Job objects.
    cadmium::BigPort<Job> outGenerated;  //!< Output Big Port for sending new Job objects to be processed.

    Generator(const std::string& id, double jobPeriod): cadmium::Atomic<GeneratorState>(id, GeneratorState()), jobPeriod(jobPeriod) {
        inStop = addInPort<bool>("inStop");
        outGenerated = addOutBigPort<Job>("outGenerated");
    }
    ...

Atomic models must inherit from the cadmium::Atomic<S> class. S is a template argument that tells Cadmium which data type is used to represent the atomic model state. We must include<cadmium/core/modeling/atomic.hpp> to use the cadmium::Atomic<S> class. The jobPeriod private attribute corresponds to the job generation period. Generator will create a new job every jobPeriod seconds.

All the ports of our model are defined as public attributes. Ports are implemented in the cadmium::Port<T> class, where T is a template argument that indicates the port data type. The Generator model has two ports: inStop (i.e., the input port for receiving commands from the Transducer to stop producing jobs) and outGenerated (i.e., the output port for sending newly generated jobs to the Processor. Note that outGenerated is a BigPort<T>. Big ports are better for big messages. Maybe Job is not big enough to use BigPort<T>, but we wanted to show you how to use it.

As Generator inherits from the cadmium::Atomic<S> class, we MUST override all the pure virtual methods of cadmium::Atomic<S>. These are the output, internalTransition, externalTransition, and timeAdvance methods. The timeAdvance function returns the time to wait before triggering the model's output and internalTransition functions. In this case, as the state of the Generator model keeps track of this time in GeneratorState::sigma, we just have to return the value of sigma:

    ...
    double timeAdvance(const GeneratorState& s) const override {
        return s.sigma;
    }
    ...

Note that s is a constant reference to the current state of the model. We can read the state and return a value depending on in, but under no circumstances can we modify the model's state in the timeAdvance function. Then, we implement the output and internalTransition functions:

    ...
    void output(const GeneratorState& s) const override {
        outGenerated->addMessage(Job(s.jobCount, s.clock + s.sigma));
    }

    void internalTransition(GeneratorState& s) const override {
        s.clock += s.sigma;
        s.sigma = jobPeriod;
        s.jobCount += 1;
    }
    ...

Every time the output function is triggered, the Generator model creates a new Job and adds it to the outGenerated port. The job ID is set the number of jobs created so far by the Generator and Job::timeGenerated is set to the current simulation time. Simulation time is obtained from the current state of Generator. Again, s is a reference to the current model state. Note that the output function is triggered just before executing the internalTransition function. Therefore, s.clock does not consider yet the time passed since the last state transition. This is why we set Job::timeGenerated to s.clock + s.sigma. This "trick" in output functions is pretty common, and you will probably use it in most of your models if you need to provide timing information in your output messages.

The internalTransition function is triggered right after executing the output function. The internalTransition CAN (in fact, MUST) modify the model state. That is why s is not a constant reference and we can modify it. Every time we include clock in our mode state, we must update clock to consider the time that passed since the last state transition. As Generator creates new jobs periodically, we set sigma to jobPeriod. Finally, we increment by one jobCount, as we have just sent a new job when triggering the output function.

Every time the Generator model receives an input message, it triggers its externalTransition function:

    ...
    void externalTransition(GeneratorState& s, double e) const override {
        s.clock += e;
        s.sigma = std::max(s.sigma - e, 0.);
        if (!inStop->empty() && inStop->getBag().back()) {
            s.sigma = std::numeric_limits<double>::infinity();
        }
    }
}

Again, the externalTransition CAN (in fact, MUST) modify the model state. That is why s is not a constant reference and we can modify it. Every time we include clock and sigma in our mode state, we must update these to consider the time that elapsed since the last state transition (i.e., e). We always add e to clock and subtract e to sigma. I decided to check that sigma is always greater than or equal to 0 in case something goes wrong. However, this should never happen, and therefore you can get rid of the std::max thing.

After updating clock and sigma, we are ready to read all input messages. In this case, Generator only has one input port (inStop). inStop->empty() returns true if the inStop port is empty. Thus, if the port is not empty, we get the last message in the port and check if we receive the command of stopping with inStop->getBag().back(). If so, we set sigma to infinity and stop creating new jobs.

Implementation of the other atomic models.

Implementing the other atomic models is a similar process. We recommend you to go to the example/efp_gpt/include folder and take a look. However, we want to show you the external delta of the Transducer, as it iterates over all the input messages:

void externalTransition(TransducerState& s, double e) const override {
    s.clock += e;
    s.sigma -= e;
    for (auto& job: inGenerated->getBag()) {
        s.nJobsGenerated += 1;
        std::cout << "Job " << job->id << " generated at t = " << s.clock << std::endl;
    }
    for (auto& job: inProcessed->getBag()) {
        s.nJobsProcessed += 1;
        s.totalTA += job->timeProcessed - job->timeGenerated;
        std::cout << "Job " << job->id << " processed at t = " << s.clock << std::endl;
    }
}

5. GPT Coupled Model

Now, let's create the GPT coupled model. Our GPT class inherits from cadmium::Coupled`.

#include <cadmium/core/modeling/coupled.hpp>  // We need to include this header to use the Coupled class

class GPT : public cadmium::Coupled {
  public
    GPT(const std::string& id, double jobPeriod, double processingTime, double obsTime): cadmium::Coupled(id) {
        // We first add the components. Note that it is similar to adding ports!
        auto generator = addComponent<Generator>("generator", jobPeriod);
        auto processor = addComponent<Processor>("processor", processingTime);
        auto transducer = addComponent<Transducer>("transducer", obsTime);
        
        // Then, we can add couplings by selecting the right ports:
        addCoupling(generator->outGenerated, processor->inGenerated);
        addCoupling(generator->outGenerated, transducer->inGenerated);
        addCoupling(processor->outProcessed, transducer->inProcessed);
        addCoupling(transducer->outStop, generator->inStop);
    }
};

And... that's it! We have implemented the GPT model with Cadmium 2. Enjoy!

EFP Model

The Experimental Frame-Processor (EFP) model is equivalent to the GPT model, but it has nested coupled models. We want to show you that nesting coupled models is very easy too!

Diagram of the EFP Coupled DEVS Model.

The EF model contains the generator and the transducer models. It also has input and output ports. Adding ports to a coupled models is exactly the same as with atomic models.

class EF: public cadmium::Coupled {
  public:
    cadmium::BigPort<Job> inProcessed;   //!< Input Port for processed Job objects.
    cadmium::BigPort<Job> outGenerated;  //!< Output Port for sending new Job objects to be processed.

    EF(const std::string& id, double jobPeriod, double obsTime): cadmium::Coupled(id) {
        // First we create the ports...
        inProcessed = addInBigPort<Job>("inProcessed");
        outGenerated = addOutBigPort<Job>("outGenerated");
        
        // ... then we add the components...
        auto generator = addComponent<Generator>("generator", jobPeriod);
        auto transducer = addComponent<Transducer>("transducer", obsTime);
        
        // ... and finally we add all the couplings
        addCoupling(inProcessed, transducer->inProcessed);
        addCoupling(transducer->outStop, generator->inStop);
        addCoupling(generator->outGenerated, transducer->inGenerated);
        addCoupling(generator->outGenerated, outGenerated);
    }
};

Finally, the EFP model combines the EF model with the Processor model:

class EFP : public cadmium::Coupled {
  public:
    EFP(const std::string& id, double jobPeriod, double processingTime, double obsTime) : cadmium::Coupled(id) {
        // Look! the addComponent method adapts to the constructor function!
        auto ef = addComponent<EF>("ef", jobPeriod, obsTime);
        auto processor = addComponent<Processor>("processor", processingTime);

        addCoupling(ef->outGenerated, processor->inGenerated);
        addCoupling(processor->outProcessed, ef->inProcessed);
    }
};