APIC – Models development guide

Best Practices — Model Architecture & Usage

Model development guide

This page explains the structure, lifecycle, and capabilities of a Model in the Sim Engine using the provided model2 sample as the reference. 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).

Overview

Models encapsulate simulation behavior. Each model has a lifecycle and a collection of state controllers. Models can run in parallel with other models; dependencies between models should be expressed explicitly so the scheduler can respect ordering.

See How to Build an APIC System for Software Engineers for where defining models and their dependencies fits into the overall system-engineering workflow.

Primary responsibilities of a model:

  • Register and initialize state controllers and resources
  • React to engine lifecycle events (Reset, Init, Run, Stop, Freeze, FirstRun)
  • Execute time-critical logic inside OnRun()
  • Trigger async actions and pass descriptors to background threads (lockless)
  • Publish/subscribe to data streams and recorders

Model Lifecycle Methods

The sample model defines standard overridable methods. Each is called by the engine at specific moments:

Model Constructor

Called once when the model is created. Use to:

  • Initialize one time objects. for example:
    
            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);
    
  • Configure the CPU affinity for the model. The example below sets it to use Core 0 and Core 2:
    
        cores = { true, false, true };
    
  • Set the thread priority. The example below sets the thread to the highest priority level:
    
        eThreadPriority = EThreadPriority::Highest;
    

Updating a Model's Version

Every model passes its name, version, and core affinity to the CModelBase base constructor:

C{{ModelName}}::C{{ModelName}}() : CModelBase("{{ModelName}}", "1.00", {{{CoreAffinityArray}}})
{
}

The version is a free-form string — there's no separate build step to keep it in sync. To "release" a new version of a model, just update that string literal. It's shown live per-model in DIPanel's Core States and System Layout's Status view, so mismatched versions across a deployment are easy to spot. Drivers work the same way — see the driver layer constructor.

OnInit()

Called once when the model is created or the engine initializes. Use to:

  • Initialize state controllers (e.g. CStatesController::AddController<T>("Group"))
  • Set initial state statuses (SetStateStatus(...))
  • Configure stream triggers and callbacks (SetStreamTrigger, SetStreamBlockReceiveCB, SetStreamBlockPreSendCB, SetStreamBlockPostReceiveAcceptCB)
  • Call global initializers like OGIInit() (only once from one model)
  • Register a custom keyboard handler, if this model needs to react to key presses (KeyListener::SetCallbackHandler(...))

Custom Keyboard Handling

Register a handler once from OnInit(), then react to keys in the callback:

void C{{ModelName}}::OnInit()
{
    KeyListener::SetCallbackHandler(HandleKeyEvent);
}

void HandleKeyEvent(const KeyEvent& k)
{
    auto key = ::toupper(k.key);
    if (key == 'X') {
        // Do your logic for key 'X' pressed by the user
    }
    // Optional: fall through to the engine's default keyboard handler
    // CSimEngine::HandleKeyEvent(k);
}
Only one handler is active at a time — registering a new one replaces the previous. To restore the engine's built-in handler, call CSimEngine::SetupMainKeyboardHandler().

OnFirstRun()

Called the first time the model enters running state. Use for one-time start-up actions that must happen when the engine starts running (not just initialization).

OnRun()

Main per-frame execution callback. This is the hot path and must be fast and deterministic. Typical tasks:

  • Execute each state controller (CStatesController::ExecuteState("Group"))
  • Send or enqueue outputs to streams or drivers (CDrivers::SendBlock)
  • Start async tasks without waiting for completion
  • Collect lightweight profiling metrics or increment in-memory histograms

OnReset()

Called when the simulation is reset. Use to:

  • Set the model's minor frame frequency via SetMinorFrame() (e.g., -1 = every simulator minor frame)
  • Fetch shared resources (LRUs) and initialize pointers
  • Reset internal counters and state

OnStop() and OnFreeze()

Called when the model is stopped or temporarily frozen. Use to safely quiesce resources, stop background operations, or mark state controllers as paused.

OnStateChanged()

Callback invoked when the Sim Engine state changes (e.g., Run <-> Step <-> Stop). Use to react to global engine state transitions.

States and 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.

Why group states?

  • Modularity: group related behaviors (communication, initialization, health checks)
  • Control: pause or reset groups independently
  • Sequencing: controllers allow deterministic ordering inside a model

Common operations

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


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

Rates & Dependencies

Each model executes at a configured minor frame rate. Rates can be:

  • -1 — run every simulator minor frame
  • 0 — disabled (does not run)
  • >0 — run every X simulator frames

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

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.

Streams, IO & Recording

Models can publish or subscribe to streams. Use stream triggers to run a model only when specific data arrives. Register callbacks 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);
  • SetStreamBlockPreSendCB — called just before the raw data is sent out. Useful for calculating a CRC or otherwise finalizing the raw buffer before transmission.
  • SetStreamBlockPostReceiveAcceptCB — called just after the raw data is received, before it is accepted. Useful for validating the raw message. If it returns false, the message counter is not incremented and the registered stream trigger is not called.
  • SetStreamBlockReceiveCB — called after a received block has been accepted.

For recording, prefer pushing 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.

Raw ↔ Eng Conversion

Every ICD block generated by DBSimGenerator has a raw form (the exact bytes on the wire) and an eng (engineering) form — the ordinary C++ value your model code reads and writes. The conversion is called once per LRU — it converts every block belonging to that LRU in one call, not one call per block. Convert explicitly in OnRun():

void C{{ModelName}}::OnRun()
{
    // Convert Raw to Eng (after new raw data has arrived on a stream) — converts all blocks of this LRU
    CLRUsProj::{{LRUName}}->Raw2Eng();

    // ... read/modify the eng-side elements of any of this LRU's blocks here ...

    // If you modified eng values and need them sent out, convert back before returning:
    CLRUsProj::{{LRUName}}->Eng2Raw();
}

Which conversion a given block uses is a property of that block, set once in DBSimGenerator, not something you choose per call:

ConventionMeaning
NoRawThe block has no physical/binary raw representation (e.g. purely internal/software-only data) — there is nothing to convert.
LElsbLittle-Endian byte order, bits numbered from the Least-Significant Bit (bit 0 = LSB) — the natural C/C++ convention.
LEmsbLittle-Endian byte order, bits numbered from the Most-Significant Bit.
BElsbBig-Endian byte order (network/wire order), bits numbered from the LSB.
BEmsbBig-Endian byte order, bits numbered from the MSB — common in avionics/MIL-STD style ICDs.
You don't need to implement any of this by hand — DBSimGenerator generates the correct Raw2Eng()/Eng2Raw() code for each block's chosen convention. You only need to know which convention a block uses when cross-checking its layout against an external ICD document or a driver's wire format.

Skip Unchanged Data

Use CSimEngine::CValueChangeGate to skip processing an element that hasn't changed since the last call — useful right after a Raw2Eng() conversion, before doing any expensive work with the converted value.

Async Commands

Async tasks

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

  • Do not block the real-time OnRun() while waiting for async work.
  • Check task availability before starting to avoid duplicate runs (tasks->IsTaskBusy()).
  • Prefer to enqueue and forget; use status flags or callbacks to detect completion.

Syncing an async thread to the frame cycle

When an async thread needs to run in lockstep with CoreEngine frames (rather than fire-and-forget), register it with CSimEngine::PhaseCoordinator and wait on a barrier for its assigned phase:

void C{{ModelName}}::AsyncCmd(void* p1, void* p2, void* p3)
{
    auto& barrier = CSimEngine::PhaseCoordinator.GetBarrier({{Phase}});
    barrier.Register();
    uint64_t generation = 0;
    while (true)
    {
        barrier.WaitForPhase(generation);
        // ... do work for this phase ...
        barrier.NotifyDone();
    }
    barrier.Unregister();
}

Guarding against multiple calls per minor frame

A model can have several stream triggers or callbacks that all funnel into the same shared logic. If more than one of them can fire within the same minor frame, guard the shared code with CSimEngine::CMinorFrameGate so it only executes once per frame no matter how many times it's called:

static CSimEngine::CMinorFrameGate frameGate;
if (!frameGate.ShouldRun())
    return;

// this code runs at most once per CoreEngine minor frame

Profiling

Keep profiling lightweight in the hot path: use scope profilers or running averages. Avoid heavy logging. Examples from the sample:

// 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());

Typical Model Flow (Example)

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

Integration & Best Practices

  • Pre-allocate all model-local buffers and structures in OnInit().
  • Avoid blocking operations in OnRun(). Use lockless handoffs to background threads.
  • Set SetMinorFrame() appropriately in OnReset() depending on how often the model should run.
  • Use state controllers to group related functionality and to enable/disable features at runtime.
  • Prefer descriptive, small messages to the recorder (pointer or index) rather than raw blobs in the hot path.
  • Document cross-model dependencies and ensure the scheduler respects ordering.