Sinelabore Homepage

Modeling Guide for Simulation and Testing#

A SysML v2 textual model is a plain text file, so any editor will do and no graphical modeling tool is needed. This guide explains how to structure such a model so that the generated code can be simulated on your PC and used as a basis for testing state-based behavior.

Everything here is about the model, not about a particular tool chain: the same text generates C++ (-l cppx) or Python (-l python), and the patterns below hold for both. Where code is unavoidable — the driver loop, a platform hook — both targets are shown.

The focus is on state machines embedded in parts, executable actions (assign, control flow, loops), their interaction via ports, and the execution loop needed to run a model. For element-level syntax details see the sub-pages (Parts, Items, States, Ports, …). For the full traffic-light walkthrough see the GitHub example.

What you can do today#

Goal Supported How
Simulate state machines on a PC Yes Generate code and call process() on the top part in a loop
Choose the target language Yes -l cppx for C++, -l python for Python — the same model
Model interacting parts Yes Parts, ports, connections
Timed transitions Yes accept after … [SI::second]
Guarded transitions Yes accept when condition or if condition then
Executable action behavior Yes assign, sequencing (first/then), if/else, decide, loop/while/for — see Actions
Hardware / platform hooks Optional Empty action def plus a subclass of the generated part, when I/O is not modeled in SysML
Inject test stimuli via ports Yes Send events/data from one part to another
See what ran Yes Generate with -d: each action and accept reports the instance that ran it
Check the model as a picture Yes -l svg draws a state machine, an action or the structure of a part

SysML v2 is ideal for early executable models: validate state logic, timing, and part interaction before committing to target hardware.


1. Model Structure Patterns#

Every executable model follows the same skeleton. Keep this structure in mind before adding details.

One file or several#

A model may live in one file with several packages, or be split across files. A package becomes a namespace in C++; in Python the package name is prefixed to generated names where they would otherwise collide.

private import ScalarValues::*;

package Types {
    part def Sensor { attribute reading : Real = 1.0; }
}

package MySystem {
    private import Types::*;
    // enums, items, ports, parts, connections — and imported types
}

If an imported package is not in the current file, it is loaded from a sibling file next to the primary one, and its imports are followed in turn. Generate the primary file; the rest is pulled in. Keep the files of one model in one directory. See Packages.

Part with state machine#

State machines can only be attached to parts. Write the machine as a state usagestate controllerSm { … } — so that the attributes, ports and actions of the part stay accessible from inside it. A state definition is owned by the part but not featured by it, and standards-conforming tools reject feature access from within one.

A part that owns a machine steps it from its own process(), so nothing has to be called by name from outside:

part def Controller {
    attribute msg : DeviceEvent;

    state controllerSm {
        entry; then Idle;
        state Idle;
        accept after 1[SI::second] then Active;
        state Active;
        accept when msg == DeviceEvent::evStop then Idle;
    }
}

System composition#

To simulate interaction between components, define a system part that owns sub-parts and connects their ports:

part def MySystem {
    part master : MasterController;
    part slave  : DeviceController;
    connect master.sendPort to slave.recvPort;
}

One call to init() on the top part is all the setup there is: it constructs the sub-parts, names them and wires every connect in the model. From then on process() on that same part steps its own state machines and then the parts it contains, so a driver never addresses a sub-part by name. See Parts.

Event definitions#

Define events as enums. A machine that declares none still works — the generator creates a default event enum for it and says so with W3209.

enum def DeviceEvent {
    evStart;
    evStop;
    evError;
}

See Enumerations.


2. Action Modeling Patterns#

Actions are first-class executable behavior in SysML v2 — not just placeholders. An action body is generated into the target language with a context object that gives access to part attributes, parameters, and nested action results. Model your logic here whenever possible; reserve empty action def bodies only for hardware or platform code you intentionally keep in hand-written code.

Construct Purpose Example
assign Update attributes, pass data assign msg := m.msg;
Sequencing Ordered steps first start; then act1; then act2; then done;
Action invocation Reuse behavior then action act2:ParentWithPara;
Inline action Local one-off steps then action { assign x := n; }
decide / guarded branches Conditional flow then decide; if n==1 then Path1; else done;
if / else if / else Structured conditionals then if batLevel<4 { … } else { … }
loop / until Repeat until condition loop action charging { … } until chargeStatus <= 100;
while Pre-checked iteration while whileGuard <= 5 { assign whileGuard := whileGuard + 1; }
forin Iterate over collections for anA in a { assign anA.n := 34; }
Parameters Typed inputs/outputs in x:Real; out p:Real; assign p := x*x;
Port interaction Receive or send via ports action accept m:PortData via recvPort { assign msg := m.msg; }
perform Trigger behavior of another part then perform redLamp.setOn;

Actions can be referenced from state machines (entry …, exit …, do action …) and from other actions. Nested action usages are generated as executable code as well.

An action that never reaches a done node is not an error: each call does a bounded amount of work and reports whether it finished, yielded after one pass around a loop, or is blocked on an accept that has not been satisfied. That is what lets a while loop or a waiting accept live in a model that is stepped cyclically.

Full syntax and generated examples: Actions.

Driving other parts#

When a part owns another part, model the behavior where it belongs — on the owned part — and trigger it with perform. This keeps each part testable on its own:

part def Lamp {
    attribute switchCounter : Natural default 0;
    action setOn  { assign switchCounter := switchCounter + 1; }
    action setOff { assign switchCounter := switchCounter + 1; }
}

part def Controller {
    ref part redLamp : Lamp;

    action setRed { first start; then perform redLamp.setOn; then done; }
}

The counter inside Lamp is then a natural assertion point for a test: it records how often the lamp was actually switched, without a line of hand-written code.

When to use empty action defs#

Use an empty action def only as a marker for hardware or OS services that you do not want to model in SysML — for example setRedLED{} driving a GPIO pin. All other behavior (calculations, guards, port handling, control flow) belongs in the action body.

An empty action generates an empty body, so nothing happens at run time. To attach platform code, set a part attribute from the model and act on it in an overridden process() — see Extending with hand-written code.

/* Modeled in SysML — the generated code runs directly */
action def computeThreshold {
    in level:Integer;
    out ok:Boolean;
    assign ok := level >= 10 and level <= 90;
}

/* Hook for target hardware — implement in a subclass of the part */
action def setRedLED {}

3. State Machine Modeling Patterns#

These patterns produce clean, readable generated code and are covered by both targets.

Pattern Purpose Example
Entry → default state Define startup state entry; then Idle;
Short-form timed transition Blinking, timeouts, periodic behavior accept after 0.5[SI::second] then Off;
Long-form transition Named transition with explicit source transition t1 first Red accept after 2[SI::second] then Green;
Guard on attribute React to model data, not just time accept when msg==DeviceEvent::evStart then Running;
Entry / exit actions One-time setup/teardown per state entry setRed; performs an existing action; entry action setRedLED; declares a new empty one
Do activity Poll ports or run logic each cycle do action getMsg;
Hierarchical states Group related states (e.g. OutOfService, Operational) Nested state Operational { … }
Parallel (orthogonal) regions Concurrent regions in one state machine state sm parallel { state RA { … } state RB { … } } — see States
History (naming convention) Return to last substate Shallow: S1_H, deep: S1_HH
Final state Non-reactive end state Done; with no outgoing transitions
Send on a transition Tell another part something happened accept after 2[SI::second] do send Ev::evStart via sendPort then Done;
Exhibited machine One machine, several parts exhibit state s references SharedSM; — generated once, instantiated per part

Full traffic-light example: States.

Timed transitions#

Time values are specified in seconds only:

state deviceSm {
    entry; then Starting;

    state Starting;
    accept after 2[SI::second] then Operational;      /* 2 s   */

    state Operational;
    accept after 0.001[SI::second] then Next;         /* 1 ms  */

    state Next;
}

The generator emits the timer handling. What you have to do is call process() on the top part cyclically (see below); the elapsed time is measured against the wall clock, not against the number of cycles.

Reading data from ports in states#

A common pattern for testable models: a do action reads incoming port data into a part attribute; transitions use guards on that attribute:

action getMsg {
    action accept m:PortData via recvPort {
        assign msg := m.msg;
    }
}

state deviceSm {
    entry; then Idle;
    do action getMsg;

    state Idle;
    transition t1 first Idle accept when msg==DeviceEvent::evStart then Running;
    state Running;
}

A transition may only name states that are reachable from where it is written. A transition between two sibling states belongs in their enclosing state, not inside one of them.


4. Communication Patterns#

Parts interact through ports and connections. This is the primary mechanism for injecting test stimuli during simulation.

Pattern When to use Modeling
Unidirectional command One controller, one or more devices out portin port, connect in system part
Event + data payload Send typed messages Define item or port with attributes
Bidirectional Request/response Two port pairs in opposite directions
Timed stimulus Auto-start simulation Controller part with accept after sends via port

Example:

item def PortData {
    attribute msg : DeviceEvent;
}

port def ControlPortData {
    in item data : PortData;
}

part def Master {
    out port sendPort : ControlPortData;

    state masterSm {
        entry; then Starting;
        state Starting;
        accept after 2[SI::second] do send DeviceEvent::evStart via sendPort then Done;
        state Done;
    }
}

part def Device {
    attribute msg : DeviceEvent;
    in port recvPort : ~ControlPortData;

    action getMsg {
        action accept m : PortData via recvPort {
            assign msg := m.msg;
        }
    }

    state deviceSm {
        entry; then Idle;
        do action getMsg;

        state Idle;
        transition t1 first Idle accept when msg == DeviceEvent::evStart then Running;
        state Running;
    }
}

Transitions live inside a state machine, never directly in the part body — the accept line above belongs to masterSm.

send … via <port> works on a transition and inside an action body alike, so prefer it. The form send … to <part> is resolved on a transition only.

See Ports.


5. Running the Model#

A generated model runs in a plain driver program — a main() in C++, a script in Python. No RTOS and no target hardware are required.

Code generation#

# C++
java -cp path_to_bin_folder/* codegen.Main -p Sysml2Text -l cppx   -o system your_model.sysml

# Python
java -cp path_to_bin_folder/* codegen.Main -p Sysml2Text -l python -o system your_model.sysml

-o names the generated file — system.h or system.py. Add -d to have every action and every accept report itself while the model runs, which is the quickest way to see why a transition did not fire — see Tracing what ran.

The generated code needs the runtime that ships with the product: framework.h for C++, framework.py for Python. Put it beside the generated file. It is delivered, not generated, and you do not edit it.

The SysML v2 backends need no codegen.cfg. The generator applies the settings they require itself and prints them on the console; if no configuration file is found it says so, and for a SysML v2 model that message is informational.

Execution loop#

Call init() once, then process() once per cycle. process() walks the containment tree — it steps the state machines a part drives and then the parts it owns — so the driver only ever talks to the top part. A state machine initialises itself on its first step.

#include "system.h"
#include <chrono>
#include <thread>

using namespace MySystem;

int main() {
    MySystem system;
    system.init();                 // builds the sub-parts and wires every connect

    for (int i = 0; i < 100; ++i) {
        system.process();          // one scan of the whole model
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    return 0;
}
import time
from system import MySystem

system = MySystem()
system.init()

for _ in range(100):
    system.process()
    time.sleep(0.1)

The sleep interval is the simulation tick. Choose it smaller than your shortest timeout, so a timed transition is not missed: timeouts are measured against the clock, not against the number of cycles.

Extending with hand-written code#

Most behavior belongs in the model and is generated. Write code by hand only for what cannot or should not be expressed in SysML — hardware I/O, OS calls, and the stimulus and assertions of a test driver.

Hook Purpose
init() Constructs sub-parts and wires connections. Call the generated version from your override, then add your own wiring
process() Runs once per cycle for the part and everything it owns. Call the generated version, then add your own step
Output port addReceiver(…) / add_receiver(…) adds one more listener beside those connect made, so watching a port does not disturb the model’s own wiring
Input port Sending on the connected out port injects a stimulus; hasEvent() + getPayload() / has_event() read one by hand; setObserver(…) / set_observer(…) sees every arrival without taking it
class DeviceSim : public Device {
public:
    void init(void) override {
        Device::init();            // let the generated code build sub-parts first
        // additional wiring for the simulation
    }

    void process() override {
        Device::process();
        std::cout << instanceName() << ": msg = " << static_cast<int>(msg) << "\n";
    }
};
class DeviceSim(Device):
    def init(self) -> None:
        super().init()
        # additional wiring for the simulation

    def process(self) -> None:
        super().process()
        print(f"{self.instance_name}: msg = {self.msg}")

Watching what a part sends or receives needs no subclass at all — see Watching port values.

Actions are not methods of the part. An action def X {} becomes a type of its own — a struct XDef with operator() in C++, a class with __call__ in Python — and the part holds an instance of it. Defining X() in a subclass therefore hooks into nothing; it just adds an unrelated method. For platform code, put the call into an overridden process(), or drive it from a part attribute that the model sets with assign.

See States — Executing a model.


6. Testing Patterns#

Simulation is the foundation for testing state-based models. These approaches need nothing beyond the generated code, its runtime and a driver.

Manual exploratory testing#

Run the generated program and observe attribute changes, port traffic, and state sequences. Because the action logic is generated from the model, most behavior is visible without writing any code: generate with -d and every action, accept and transition reports itself (see below). Add printing of your own in an overridden process() where you want more.

Tracing what ran#

Trace messages are switched on at generation time: add -d to the generator command line. Neither the model nor the driver changes, and code generated without -d contains no trace calls at all.

# C++
java -cp path_to_bin_folder/* codegen.Main -p Sysml2Text -l cppx   -d -o system your_model.sysml

# Python
java -cp path_to_bin_folder/* codegen.Main -p Sysml2Text -l python -d -o system your_model.sysml

Running the program then prints one line per step, in the form debug: <context>.<name> [<kind>] <detail>:

debug: System.sensor.emitReading [action]
debug: emitReading.IfActionThen1 [action]
debug: System.monitor.getReading [action]
debug: System.monitor.acceptAction [accept] via monitorPort
debug: getReading.acceptAction [action]
debug: System.monitor.MonitorSM.Idle [transition] -> Idle
  • An action or accept that belongs directly to a part is prefixed with the instance path (System.monitor), so two parts of the same type can be told apart. A step nested in an action is prefixed with that action instead.
  • An accept names the port it took the event from; a transition names its source state and -> target.
  • The kinds you will see are action, accept and transition, plus flow and bind where values move between action pins.
  • C++ and Python print the same lines for the same model, so the trace of one target can be compared with the other line by line.

By default the lines go to standard output. The runtime lets you send them elsewhere — to a log, or into a list a test checks — or drop them:

C++ (framework.h) Python (framework.py)
Redirect Assign a function taking const TraceEvent& to traceSink() Assign a function (kind, context, name, detail) to framework.trace_sink
Silence at run time traceSink() = nullptr; framework.trace_sink = None
Remove from the build Define an empty SYSML_TRACE(kind, context, name, detail, instance) macro before including the generated header Generate without -d

A redirected trace is itself an assertion point — for example, the sequence of states a machine went through:

std::vector<std::string> transitions;
traceSink() = [&transitions](const TraceEvent& e) {
    if (e.kind == TraceKind::Transition && e.detail) transitions.push_back(e.detail);
};
import framework

transitions = []

def record(kind, context, name, detail):
    if kind == "transition":
        transitions.append(detail)

framework.trace_sink = record

Scripted stimulus testing#

Drive the model from the driver loop with a fixed sequence of port messages or timed runs. A stimulus is an ordinary send on the port the model listens to:

if (i == 50) {                                  // after 50 ticks
    ControlPortDataDef stimulus{};
    stimulus.data.msg = DeviceEvent::evError;
    system.master->sendPort.send(stimulus);
}
if i == 50:
    system.master.sendPort.send(ControlPortDataDef(data=PortData(msg=DeviceEvent.evError)))

Then check part attributes — they are updated by assign in the model’s own actions — to verify the expected behavior. A counter attribute incremented by an action is the cheapest assertion point there is, and it needs no hand-written code at all.

Watching port values#

Attributes show where a part ended up; the values on its ports show what the parts told each other on the way. Both port kinds offer a read-only tap that the driver attaches after init(). Neither needs a subclass, and neither disturbs the wiring the model’s connect statements made.

Port Tap Called Receives
Out port addReceiver(…) / add_receiver(…) On every send, beside the receivers connect added; any number may be added C++: the port type, a std::variant; Python: the payload
In port setObserver(…) / set_observer(…) On every arrival, before it is queued; one observer per port, a new one replaces the old The payload (…Def)

For port def ControlPortData the generator emits the payload type ControlPortDataDef, whose member data is the item the port carries. In C++ it also emits ControlPortData, the std::variant an out port sends, so an out-port receiver unpacks it with std::get:

// After system.init(): record everything the master sends ...
std::vector<DeviceEvent> sent;
system.master->sendPort.addReceiver([&sent](const ControlPortData& p) {
    sent.push_back(std::get<ControlPortDataDef>(p).data.msg);
});

// ... and everything that reaches the slave, whether or not it has taken it yet.
std::vector<ControlPortDataDef> arrived;
system.slave->recvPort.setObserver([&arrived](const ControlPortDataDef& p) {
    arrived.push_back(p);
});
sent = []
system.master.sendPort.add_receiver(lambda p: sent.append(p.data.msg))

arrived = []
system.slave.recvPort.set_observer(arrived.append)

After the run, compare the recorded values with the sequence you expect. Combined with a scripted stimulus this gives a complete black-box test of a part: what went in, what came out.

An observer does not consume anything — the part still takes each event itself, through its own accept on its own cycle. The difference between what arrived and what was taken is the backlog, and queueDepth() / queue_depth() reports it. A backlog that keeps growing means the part polls its port less often than it is fed.

Both taps run synchronously inside the sender’s send. Keep them cheap: record the value and return. Do not call process(), send on a port, or step a state machine from one.

Incremental modeling pattern#

  1. Model control logic, calculations, and port handling directly in SysML actions.
  2. Run the simulation and verify state transitions, guards, and attribute updates.
  3. Add empty action defs only for hardware boundaries; implement those in a subclass of the generated part when moving toward target code.

Validating the model itself#

Before testing behavior, make sure the model says what you think it says. The generator reports problems with stable codes — see Diagnostics — and a warning like W3117 or W3115 usually points at a modelling mistake, not at a tooling quirk.

Two cheap checks on top of that:

  • Draw it. -l svg renders a state machine, an action or the structure of a part from the same parsed model the code comes from. If the picture does not show what you expect, the generated code will not do what you expect either.
  • Let an editor validate it. A SysML v2 editor checks against the language itself, which is stricter than any code generator, and catches what still produces compilable code — for example feature access from inside a state def. See Tooling.

7. Supported Features (Summary)#

Category Supported
Target languages C++ (-l cppx) and Python (-l python) from the same model
Diagrams from the model (-l svg) State machine, action, part structure
Package / namespace Yes
Imports (membership / wildcard, same file) Yes — see Packages
Multi-file models Yes — sibling files, resolved recursively
Standard library Stubs for ScalarValues and ISQ only
Parts, nested parts Yes
Part / attribute / item multiplicity Yes
Attributes, enums Yes
Units on attribute values Yes — normalized, never converted; see Attributes
Default SM event enum (no @ annotation) Yes
Items Yes — see Items
ref / abstract / subsets / :>> Partial
Collections (abstract ref … [*] + subsets) Yes — see Parts
Derived values (sum, product, size, max, min) Yes — computed in init
Ports (in/out), connections Yes
send … via <port> on a transition and in an action Yes
State machines in parts (usage or definition) Yes
Exhibited state machines (exhibit state … references …) Yes — see States
process() steps a part’s machines and the parts it owns Yes
Entry, exit, do actions (reference or declare) Yes
perform an action of another part Yes — see Actions
Diagnostics with stable codes Yes — see Diagnostics
Timed transitions (after) Yes (seconds)
Guarded transitions (when, if) Yes
Parallel (orthogonal) regions Yes — see States
History states (naming convention) Yes
Final states Yes
Actions — assign, sequencing, parameters Yes
Actions — decide, if/else if/else Yes
Actions — loop/until, while, for Yes
Actions — nested usages, port accept/send Yes
Trace of what ran (-d) Yes — one line per action and accept, naming the instance
Requirements, views, analysis cases, metadata Parsed, reported with W3170, not generated
at time transitions No
Inner transitions No

Full limitation list: Missing Features.


8. Don’t do#

Anti-pattern Problem Better approach
State machine outside a part Not supported Always attach the machine to a part def
state def inside a part Part attributes and ports are not accessible from inside; conforming tools reject the model Write the machine as a usage: state sm { … } — see States
entry action X; when X already exists Declares a second, empty action; nothing happens at run time (W3117) Reference it instead: entry X;
Transition between siblings written inside one of them The target state is not reachable from there Move the transition into the enclosing state
[*] without abstract Multiplicity is dropped; you get one owned part instead of a collection abstract ref part items : T [*]; plus subsets members — see Parts
Mixing unit scales in a sum Units are normalized but never converted; 5[km] + 200[m] computes 205 (W3115) Write both operands in the same unit
Ignoring imports Duplicate type definitions across packages Prefer import Types::*; / import Types::Sensor; instead of copying defs
Blocking code in actions Simulation stalls — one scan must finish before the next starts Keep individual action steps short; use state machines for long-running processes
Empty defs for logic that belongs in SysML Hand-written code where the model could say it Use assign, control flow, and nested actions in the model
Tick interval too long Missed or delayed timeouts Sleep shorter than smallest after duration
Forgetting init() Sub-parts are never constructed and no connection is wired Call init() on the top part before the loop
Stepping each machine by hand More driver code than needed, and a part added later is forgotten Call process() on the top part; it scans the whole containment tree
Deep hierarchy without purpose Hard to debug in simulation Flatten; use hierarchical states only when they clarify behavior
Implicit events instead of attributes SysML v2 guards on data, not on an event queue Read ports in do-actions; guard on attributes
Overriding a method named after an action An action is a type of its own, not a method of the part — the override hooks into nothing Put platform code in an overridden process(), or drive it from an attribute the model sets

Quick Checklist#

Before generating and running your model:

  • All model files in one directory; generate the primary file
  • Every state machine belongs to a part and is written as a usage (state sm { … })
  • entry / exit / do reference existing actions unless you really want to declare a new one
  • System part composes and connects sub-parts
  • Events defined as enums (or rely on the default SM event enum); port payloads typed
  • Default state set (entry; then …)
  • Behavior modeled in action bodies; hardware hooks only where needed
  • Runtime (framework.h / framework.py) beside the generated file
  • Driver calls init() once and process() on the top part each cycle
  • Tick interval shorter than the shortest timeout
  • Model generates without errors; warnings read, not ignored

This guide will evolve as the SysML v2 backend gains features. Feedback and example models are welcome.