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 usage — state 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; } |
for … in |
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 port → in 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 structXDefwithoperator()in C++, a class with__call__in Python — and the part holds an instance of it. DefiningX()in a subclass therefore hooks into nothing; it just adds an unrelated method. For platform code, put the call into an overriddenprocess(), or drive it from a part attribute that the model sets withassign.
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.sysmlRunning 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,acceptandtransition, plusflowandbindwhere 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 = recordScripted 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 callprocess(), send on a port, or step a state machine from one.
Incremental modeling pattern#
- Model control logic, calculations, and port handling directly in SysML actions.
- Run the simulation and verify state transitions, guards, and attribute updates.
- 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 svgrenders 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/doreference 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 andprocess()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.