Simulation
Deterministic control over a scenario-backed simulated target: step it cycle by cycle, run it in
real time, pause it, and reset it back to its declared start state. Requires feature flag
Simulation enabled — off by default.
Scenario configured (see
Configuration), and startup fails outright if a
Scenario is ever declared on a target that is not Mode: Simulated — so there is no
configuration under which sim/* can touch a real PLC. That is a guardrail, not a licence to put
this behind a public endpoint.A target only has these routes at all once it has a Scenario and ScenarioHostedService has
loaded it at startup. A plcId that is not configured at all, and a plcId that is configured
but has no Scenario, are different problems and are reported differently — see
Error Codes below.
Get State
GET /api/plcs/{plcId}/sim/statePolicy: LifecycleAccess (role = admin, scope ads:lifecycle)
curl http://localhost:5000/api/plcs/demo/sim/state{
"data": {
"state": "Stopped",
"cycleCount": 0,
"elapsedMs": 0,
"scanMs": 100,
"error": null
},
"error": null
}state is one of Stopped (seeded but not ticking — the initial state), Running (the
real-time pump is ticking), Paused (stopped, can be resumed or stepped) or Faulted (a cycle
threw; terminal until reset). error carries the failure message once Faulted, surfaced here
rather than only logged — a scenario author, or an agent correcting one, needs it without shelling
into the host’s logs. cycleCount and elapsedMs count from load or the last sim/reset, not
from process start.
Step
POST /api/plcs/{plcId}/sim/step?cycles=200Policy: LifecycleAccess
Runs exactly cycles scan cycles inline and returns once they are all done — the deterministic
entry point a CI assertion drives the machine with. cycles must be between 1 and 100000
(see MaxStepCycles below); an out-of-range value is rejected before anything
runs.
curl -X POST "http://localhost:5000/api/plcs/demo/sim/step?cycles=20"{
"data": { "state": "Stopped", "cycleCount": 20, "elapsedMs": 2000, "scanMs": 100, "error": null },
"error": null
}Stepping a simulation that is currently Running is rejected — pause it first. A pumped
simulation’s cycle boundaries are wall-clock timed, so interleaving a step with the pump cannot
produce a trace anything could reproduce.
Run
POST /api/plcs/{plcId}/sim/runPolicy: LifecycleAccess
Starts the real-time pump: one scan cycle every scanMs (the scenario’s declared scan period),
on its own background loop. Idempotent — calling run on an already-Running simulation is a
no-op, not an error.
curl -X POST http://localhost:5000/api/plcs/demo/sim/runPause
POST /api/plcs/{plcId}/sim/pausePolicy: LifecycleAccess
Stops the real-time pump, leaving cycleCount, elapsedMs and every symbol’s current value
exactly where they were. A paused simulation can be resumed with sim/run or driven deterministically
with sim/step.
curl -X POST http://localhost:5000/api/plcs/demo/sim/pauseReset
POST /api/plcs/{plcId}/sim/resetPolicy: LifecycleAccess
Stops the pump if it is running, re-seeds every symbol from machine.yaml’s declared start state,
and clears any fault — the only way back to a usable target once state is Faulted. Also the
way to replay a scenario that has settled and stopped moving (see
Scenario Authoring below).
curl -X POST http://localhost:5000/api/plcs/demo/sim/resetsim/reset re-seeds only the scenario’s half of the target’s symbols, not the
InitialValues a PlcTargets entry may also declare — the two are independent seed sources
layered on top of each other at startup (see
Configuration), and only the scenario half
is re-applied on reset.stRev.* revision counters are never reset. A reset silently rewrites every symbol
in the store back to its seed value without notifying any subscriber (seeding never fires
subscription callbacks, by design), so if the counters reset to zero too, the very next bump
could republish a value some subscriber had already observed before the reset — indistinguishable
from no change at all. They keep counting from wherever they were instead, so every value a
subscriber has ever seen for the life of the host stays behind the next one.Error Codes
In addition to the common error codes:
| Code | HTTP Status | Description |
|---|---|---|
SCENARIO_NOT_CONFIGURED | 404 | The target exists and is Simulated, but has no Scenario configured — distinct from PLC_NOT_FOUND, which means the plcId itself is not configured at all |
SIMULATION_CONFLICT | 409 | The request conflicts with the simulation’s current state in a way the caller can resolve: stepping while Running (pause first), or driving a Faulted simulation (reset first) |
A scan-cycle failure that is not one of those two conflicts — a symbol write failing partway
through a flush, for instance — is a server error, not a conflict: it answers 500
INTERNAL_ERROR like any other unexpected failure, and separately faults the simulation (visible
via sim/state).
MaxStepCycles
cycles on sim/step is bounded at 100,000 per request. At a 100 ms scan that is close to
three simulated hours — far more than any test needs in one call — while protecting the host from
a single request that never returns. Request more cycles across multiple calls if you genuinely
need them; each call is still bounded individually.
Rate Limiting
sim/* carries no per-endpoint rate-limit policy, unlike every other write-ish slice in this
API: the feature flag is off by default, the surface only exists for targets with a Scenario,
and being driven hard by a CI harness stepping thousands of cycles is the point, not a risk. The
one thing worth guarding against a single request for — an enormous cycles value — is
MaxStepCycles’s job, not a per-endpoint policy’s.
RateLimiting:GlobalPerUser
limiter ahead of every request regardless of endpoint — 200 requests/second by default, and
RateLimiting:Enabled defaults to true — and sim/* is not exempt from it, only from the
per-endpoint policies every other write-ish slice carries. A harness polling sim/state in a
tight loop, or issuing many small sim/step calls instead of fewer larger ones, will hit 429
RATE_LIMITED from that global budget well before MaxStepCycles becomes relevant. See
Rate Limiting for the full configuration and
how to raise or disable it.Scenario Authoring
A scenario is a directory containing one file, machine.yaml, referenced from a Simulated
target’s Scenario key:
{
"PlcTargets": {
"demo": {
"Mode": "Simulated",
"Scenario": "scenarios/demo"
}
}
}Scenario resolves relative to the application’s content root; absolute paths work too. See
Configuration for the full PlcTargets
reference, including ScenarioAutoStart.
The runtime owns every contract mechanic — an author never writes one. Revision counters
(stRev.*), the header counts, and every symbol’s PLC type (BOOL, INT, UINT, UDINT,
REAL, LREAL, STRING(n)) are derived and enforced by
Dahlke.Hmi.Simulation
from what machine.yaml declares. If a scenario ever needed to mention stRev or a raw PLC type
name, that would be a bug in the runtime, not something an author should have to work around.
Here is the shipped worked example, src/Adsify.Api/scenarios/demo/machine.yaml, in full:
# A worked GVL_HMI v1 scenario. Two axes and a robot joint, so the HMI's Motion page has
# machine-level axes and a multi-joint part to group. Every behaviour here is declared — nothing
# is implicit.
#
# This settles after roughly 3 seconds and then holds perfectly still: P1 has no scripting, so
# nothing here is scripted to move again once every axis reaches its declared targetPos. That is
# expected, not a stuck simulation — POST /api/plcs/demo/sim/reset replays the same ramp from the
# declared start positions.
contract: gvl-hmi/1
root: GVL_HMI
scanMs: 100
parts:
- Indexer
- Robot
machine:
state: 1 # NAMUR wire table: 0 Running, 1 Idle, 2 Fault, 3 Maintenance
mode: 2
speed: 0.0
speedSetpoint: 120.0
activeRecipeId: "R-1004"
activeRecipeVersion: 3
axes:
# Long stroke, so the ramp is visible on a trend for several seconds.
- id: X1
component: M1-SD
unit: mm
limits: { min: 0.0, max: 500.0, vmax: 250.0, amax: 1000.0 }
start: { position: 120.0, targetPos: 460.0, enabled: true, homed: true }
# Short stroke and a low vmax, so the two axes settle at obviously different times.
- id: Y1
component: M2-SD
unit: mm
limits: { min: 0.0, max: 200.0, vmax: 60.0, amax: 400.0 }
start: { position: 40.0, targetPos: 175.0, enabled: true, homed: true }
# Joint convention <partId>.<jointId> (§4.4) — the HMI groups this under the Robot part rather
# than showing it as a machine-level axis.
- id: Robot.A1
unit: deg
limits: { min: -180.0, max: 180.0, vmax: 90.0, amax: 300.0 }
start: { position: 0.0, targetPos: 120.0, enabled: true, homed: true }What each key means
| Key | Meaning |
|---|---|
contract | Must be exactly gvl-hmi/1 — the only version this runtime implements. |
root | The symbol root the HMI is configured to read, e.g. GVL_HMI (a dedicated global variable list) or MAIN.stHmi (a member of an existing program instance). |
scanMs | The simulated scan period, 10–1000 ms. 100 (the default) is inside the contract’s recommended 100–200 ms axis push cadence. |
maxAxes | aAxes[] allocation. Defaults to 16; only needs raising if a scenario declares more axes than that. |
parts | Machine part names, up to 8 (MAX_PART_STATES). Sizes stCounts.nParts. |
machine | Seed state for the machine-level area — NAMUR state (0 Running, 1 Idle, 2 Fault, 3 Maintenance; defaults to Idle, never Running, so a freshly-loaded scenario does not claim to already be running), mode, speed, speedSetpoint, activeRecipeId, activeRecipeVersion. |
axes | One entry per axis. id is the contract’s sId (max 15 characters — STRING(15)); the joint convention <partId>.<jointId> (e.g. Robot.A1) groups an axis under a part instead of showing it at machine level. component cross-references a component id (P3+; harmless to omit in P1). unit is sUnit (max 7 characters). limits are the axis’s static engineering limits — vmax is also the runtime’s clamp on any commanded velocity. start is the seed state: position, an optional targetPos (defaults to position — an axis with no declared target holds still rather than ramping toward zero), enabled, homed. |
The schema behind all of this — machine.schema.json, embedded in
Dahlke.Hmi.Simulation and exposed via ScenarioLoader.SchemaJson — is the single source of
validation truth. A malformed or out-of-range machine.yaml is rejected at load with a list of
JSON Pointer-located errors (e.g. /axes/0/limits/vmax: 0 should be greater than 0) rather than a stack trace, and rejects the same way whether the
mistake is a bad indent, an axis id over 15 characters, or more axes declared than maxAxes
allows.
Pointing a target at a scenario
- Create a directory (anywhere on disk, or under the application) containing a
machine.yaml. - Set
Mode: SimulatedandScenario: <path to that directory>on the target inPlcTargets. - Restart the host.
ScenarioHostedServiceloads and validates every configured scenario at startup — a scenario that fails to load fails the whole application to start, the same way aPlcTargetstypo would, rather than leaving the HMI talking to a target whose symbols do not exist. - Drive it with
sim/step/sim/run, or read it through the ordinary Variables and Notifications endpoints exactly as you would a real PLC — that is the entire point: nothing downstream of adsify’s own backend needs to know the target is simulated.