Skip to content

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.

Development and CI only. These endpoints drive machine state, and are deliberately not exposed on a shared network by default. They are also structurally unable to reach real hardware: every route requires the target to have a 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/state

Policy: 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=200

Policy: 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/run

Policy: 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/run

Pause

POST /api/plcs/{plcId}/sim/pause

Policy: 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/pause

Reset

POST /api/plcs/{plcId}/sim/reset

Policy: 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/reset
sim/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.
The nine 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:

CodeHTTP StatusDescription
SCENARIO_NOT_CONFIGURED404The 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_CONFLICT409The 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.

The global per-user limiter still applies. adsify installs one 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

KeyMeaning
contractMust be exactly gvl-hmi/1 — the only version this runtime implements.
rootThe 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).
scanMsThe simulated scan period, 101000 ms. 100 (the default) is inside the contract’s recommended 100–200 ms axis push cadence.
maxAxesaAxes[] allocation. Defaults to 16; only needs raising if a scenario declares more axes than that.
partsMachine part names, up to 8 (MAX_PART_STATES). Sizes stCounts.nParts.
machineSeed 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.
axesOne 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

  1. Create a directory (anywhere on disk, or under the application) containing a machine.yaml.
  2. Set Mode: Simulated and Scenario: <path to that directory> on the target in PlcTargets.
  3. Restart the host. ScenarioHostedService loads and validates every configured scenario at startup — a scenario that fails to load fails the whole application to start, the same way a PlcTargets typo would, rather than leaving the HMI talking to a target whose symbols do not exist.
  4. 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.