How to Develop Simulation Models

Implement a model's lifecycle callbacks, group its states, and integrate streams, async tasks, and profiling safely.

This guide walks through building a Model in the Sim Engine: its lifecycle methods, how to organize states into controllers, how to configure rates and dependencies, and how to safely use streams, async tasks, and profiling in the hot path.

1. Understand What a Model Is

A Model is a logical unit that runs at a configured rate, may depend on other models, and contains state controllers that group related states (play/reset/pause). Models can run in parallel; declare dependencies explicitly so the scheduler can respect ordering. A model's primary responsibilities:

2. Implement the Lifecycle Methods

MethodWhen it's called / what to do
ConstructorCalled once when the model is created. Initialize one-time objects, configure CPU affinity, and set thread priority.
OnInit()Called once at creation/engine init. Initialize state controllers, set initial state statuses, configure stream triggers and send/receive callbacks, and call global initializers like OGIInit() (only from one model).
OnFirstRun()Called the first time the model enters running state. Use for one-time start-up actions tied to the engine actually running.
OnRun()Main per-frame execution callback (the hot path). Execute state controllers, send/enqueue outputs, start async tasks without waiting, and collect lightweight profiling.
OnReset()Called on simulation reset. Set the model's minor frame frequency via SetMinorFrame(), fetch shared resources (LRUs), and reset internal counters/state.
OnStop() / OnFreeze()Called when the model is stopped or frozen. Safely quiesce resources, stop background operations, or pause state controllers.
OnStateChanged()Called when the Sim Engine's global state changes (Run/Step/Stop). React to global transitions.

Example constructor setup — one-time thread, affinity, and priority configuration:

const int numOfTasks = 10;
tasks = std::make_unique>CTasks<(numOfTasks);
hAsyncThread = tasks->AddThread("AsyncThreadName1",
    [](void* pObj, void* param1, void* param2, void* param3) {
        ((CMyModel*)pObj)->AsyncCmd(nullptr, nullptr, nullptr);
    },
this);

// Use Core 0 and Core 2
cores = { true, false, true };

// Highest thread priority
eThreadPriority = EThreadPriority::Highest;

3. Group States into State Controllers

Inside a model, states are grouped into logical State Controllers. Each controller manages a group of states and has its own play/pause/reset controls. Group states to get:

// Add controller and set status
CStatesController::AddController<CCommCheck>("CommChecks");
CStatesController::SetStateStatus("CommChecks", CACState::EStateStatus::ePlay);

// Execute controllers in OnRun
CStatesController::ExecuteState("CommChecks");

4. Configure Rates and Dependencies

Each model executes at a configured minor frame rate:

Multiple models may run in parallel. If model A depends on model B, declare that dependency (via configuration or the scheduler) so execution is ordered correctly.

Example: a sensor model may run at 1 kHz while a heavy physics model runs at 125 Hz. The scheduler should ensure data produced by the physics model is propagated deterministically (e.g., via timestamped buffers) to the higher-rate consumer.

5. Connect Streams, IO, and Recording

Models can publish or subscribe to streams. Use stream triggers to run a model only when specific data arrives. There are three callback hooks around the raw send/receive path:

// Execute only when a block arrives on StreamName/BlockName
SetStreamTrigger("StreamName", "LRUName.BlockName");

// Called just before the raw block is sent out
SetStreamBlockPreSendCB("StreamName", "LRUName.BlockName", &Cmodel2::OnPreSendCB);

// Called just after the raw block is received, before it is accepted
SetStreamBlockPostReceiveAcceptCB("StreamName", "LRUName.BlockName", &Cmodel2::OnPostReceiveAcceptCB);

// Register receive callback
SetStreamBlockReceiveCB("StreamName", "LRUName.BlockName", &Cmodel2::OnReceiveCB);

All three callbacks share the same signature, which now includes the block name:

void Cmodel2::OnReceiveCB(const char* blockName, const uint8_t* raw, uint64_t size);
void Cmodel2::OnPreSendCB(const char* blockName, const uint8_t* raw, uint64_t size);
bool Cmodel2::OnPostReceiveAcceptCB(const char* blockName, const uint8_t* raw, uint64_t size);

For recording, push small descriptors into a lockless SPSC ring in the hot path and let the recording thread serialize or write the large data off the critical path.

6. Add Async Commands and Profiling Safely

For long-running or blocking operations (file I/O, network, complex computation), use a thread pool or async task manager, and follow these rules:

Keep profiling lightweight in the hot path — use scope profilers or running averages, and avoid heavy logging:

// scope profiler
{
static CRunningAverage runningAverage;
CSimScopeProfiler simScopeProfiler("profilingTaskName", &runningAverage);
}

// manual timing
auto startT = std::chrono::high_resolution_clock::now();
// ... work ...
runningAverage.add(std::chrono::duration_cast(
std::chrono::high_resolution_clock::now() - startT).count());

7. Follow the Typical Model Flow

OnInit():
- Initialize states and controllers
- Set stream triggers and callbacks
- Pre-allocate buffers

OnReset():
- Set minor frame using SetMinorFrame()
- Reset state controllers and counters

OnFirstRun():
- One-time start tasks (timers, warm-up)

OnRun():
- Execute state controllers (fast, deterministic)
- Enqueue record descriptors into ring buffer
- Optionally start async tasks (non-blocking)

OnStop()/OnFreeze():
- Quiesce background threads, flush buffers

8. Apply Integration Best Practices

Danger zones: blocking I/O in OnRun(), dynamic allocation inside the hot path, and uncontrolled async thread spawns. Tip: call OGIInit() from exactly one model if OGI is included, and let only one model enable global memory monitors.