Simulation
Every target can run against an in-memory value store instead of hardware — per target, or forced
for the whole fleet. No TwinCAT installation is required in simulation. For the test harness that
builds on this (TestPlc, hand-written doubles), see Testing.
AddTwinCatAdsSimulation — all targets forced to simulation
// All targets are in-memory; no TwinCAT installation required.
builder.Services.AddTwinCatAdsSimulation(builder.Configuration);
Per-target simulation — mixed fleets
builder.Services.AddTwinCatAds(o =>
{
o.Targets["real-plc"] = new PlcTargetOptions
{
AmsNetId = "192.168.1.10.1.1",
Mode = ConnectionMode.Real,
};
o.Targets["sim-plc"] = new PlcTargetOptions
{
DisplayName = "Simulated PLC",
Mode = ConnectionMode.Simulated,
InitialValues = { ["GVL.Temp"] = 21.5f },
};
});
Seeding initial values
InitialValues are applied at connection creation. Writes fire subscriptions on changed values;
SetInitialValues seeds the store silently without triggering callbacks.
Seed a struct-shaped symbol in code by seeding a member tree (or an instance of the type itself); it then reads back as a .NET type — see Reading a PLC struct into a .NET type:
sim.SetInitialValues(new Dictionary<string, object?>
{
["MAIN.Motor"] = new Dictionary<string, object?> { ["Speed"] = 1500, ["Running"] = true },
});
MotorState motor = await sim.ReadValueAsync<MotorState>("MAIN.Motor");
JSON InitialValues cannot seed a struct — a config entry seeds one scalar symbol, so a
struct-shaped symbol has to be seeded in code.
In code-first configuration values keep their CLR types and are seeded verbatim. JSON
configuration is string-typed, so a bare scalar entry is seeded as a string — a metadata read
reports it as STRING where a real PLC would report DINT. Declare the PLC type to get a
faithful stand-in:
"PlcTargets": {
"sim-plc": {
"Mode": "Simulated",
"InitialValues": {
"MAIN.Speed": { "value": 1500, "type": "DINT" },
"MAIN.Setpoint": { "value": 21.5, "type": "LREAL" },
"MAIN.Running": { "value": true, "type": "BOOL" },
"MAIN.Cycle": { "type": "TIME" }, // no value → the type's default
"MAIN.Station": "Demo Station" // bare scalar → seeded as STRING
}
}
}
| Symbol | ReadValueWithMetadataAsync |
|---|---|
MAIN.Speed |
1500 / DINT |
MAIN.Setpoint |
21.5 / LREAL |
MAIN.Running |
true / BOOL |
MAIN.Station |
"Demo Station" / STRING |
type is any IEC 61131-3 elementary type name (BOOL, BYTE, WORD, DWORD, LWORD, SINT,
INT, DINT, LINT, USINT, UINT, UDINT, ULINT, REAL, LREAL, TIME, DT, STRING,
WSTRING), matched case-insensitively with Beckhoff aliases resolved. The type is never inferred
from the value's content, so a STRING symbol holding "1500" stays a string. An unknown type,
an unconvertible value, or a value with no type fails options validation at startup with every
bad entry listed at once.
Test-code direct access to SimulatedAdsConnection
if (pool.TryGetSimulatedConnection("plc1", out var sim))
sim.SetInitialValues(new Dictionary<string, object?> { ["GVL.A"] = 99 });
For a whole started fleet rather than one connection — seeded targets, write assertions, a pool
ready to inject — use the Dahlke.TwinCAT.Ads.Testing package instead: see
Testing.
What simulation covers elsewhere
- Subscriptions fire on changed writes, exactly as Subscriptions describes.
- Raw channels have their own simulated store with byte-level seeding — see Raw ADS channels.
- Struct binding in simulation matches by name, hardware maps by declaration order — the one gap simulation cannot close; see the note under struct reads.