🚧 Implement continous simulation

This commit is contained in:
Jonas Bengtsson
2026-10-04 14:50:57 +02:00
parent eb36a2d947
commit 263afc9c1e
13 changed files with 1914 additions and 42 deletions
+458
View File
@@ -0,0 +1,458 @@
"""Continuous-mode (spec §8) tests: datasets, degradation, invariants,
PlantEnv, and the fixed tick pipeline (FR-8.2.12 order)."""
from __future__ import annotations
import textwrap
import pytest
from wago_sim.scenario.datasets import CsvDataset
from wago_sim.scenario.engine import DataError, ScenarioEngine, ScenarioError, Verdict
def _write_csv(path, text: str) -> str:
path.write_text(textwrap.dedent(text), encoding="utf-8")
return str(path)
def _engine(node, scenario: dict) -> ScenarioEngine:
return ScenarioEngine(node, scenario)
class TestContinuousValidation:
def test_unbounded_run_rejected(self, node):
with pytest.raises(ScenarioError, match="would run forever"):
_engine(node, {"mode": "continuous", "name": "x",
"inputs": {"s": {"type": "di", "channel": 1}}})
def test_until_bounds_the_run(self, node):
engine = _engine(node, {"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"s": {"type": "di", "channel": 1}}})
assert engine.mode == "continuous"
def test_dsl_validation_flags_continuous(self):
from wago_sim.scenario.dsl import validate_scenario
problems = validate_scenario({"name": "x", "mode": "continuous"})
assert any("datasets, scheduled steps" in p for p in problems)
problems = validate_scenario({"name": "x", "mode": "continuous",
"until": "1h"})
assert not any("datasets, scheduled steps" in p for p in problems)
class TestDatasets:
def _scenario_with_csv(self, tmp_path, csv_text, **extra):
csv = _write_csv(tmp_path / "data.csv", csv_text)
scenario = {
"mode": "continuous", "name": "csv", "until": "5s",
"inputs": {
"level": {"type": "ai", "module": 0, "channel": 0, "scale": 1.0},
"switch": {"type": "di", "module": 0, "channel": 1},
},
"datasets": {"main": {"file": csv}},
**extra,
}
return scenario
def test_streamed_values_match_samples(self, node, tmp_path):
scenario = self._scenario_with_csv(tmp_path, """\
time,level,switch
0.0,100,ON
1.0,200,OFF
2.0,300,ON
3.0,400,OFF
4.0,500,ON
""")
engine = _engine(node, scenario)
engine.start()
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.PASS
# final applied sample: level = 500 counts at t=5s
assert engine.alias_value("level") == pytest.approx(500.0)
def test_linear_interpolation_between_samples(self, node, tmp_path):
scenario = self._scenario_with_csv(tmp_path, """\
time,level,switch
0.0,0,OFF
2.0,100,OFF
""", until="1s")
scenario["datasets"]["main"]["interpolation"] = "linear"
engine = _engine(node, scenario)
engine.pump() # t=10ms
engine.pump()
# at t=20ms, interpolated between 0 (t=0) and 100 (t=2s): 1.0
assert engine.alias_value("level") == pytest.approx(1.0)
def test_hold_interpolation(self, node, tmp_path):
scenario = self._scenario_with_csv(tmp_path, """\
time,level,switch
0.0,0,OFF
2.0,100,OFF
""", until="1s")
scenario["datasets"]["main"]["interpolation"] = "hold"
engine = _engine(node, scenario)
engine.pump()
engine.pump()
assert engine.alias_value("level") == pytest.approx(0.0)
def test_output_alias_in_dataset_rejected(self, node, tmp_path):
csv = _write_csv(tmp_path / "bad.csv", "time,pump\n0.0,1\n")
scenario = {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv}},
}
with pytest.raises(DataError, match="output alias"):
_engine(node, scenario)
def test_malformed_value_is_error_verdict(self, node, tmp_path):
csv = _write_csv(tmp_path / "bad.csv", "time,level\n0.0,100\n1.0,banana\n")
scenario = {
"mode": "continuous", "name": "x", "until": "5s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv}},
}
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.ERROR
assert "banana" in result.steps[-1].detail
def test_non_monotonic_time_is_error(self, node, tmp_path):
csv = _write_csv(tmp_path / "bad.csv", "time,level\n1.0,10\n0.5,20\n")
scenario = {
"mode": "continuous", "name": "x", "until": "5s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv}},
}
# detected while priming the lookahead at open(): immediate
# DataError with file:line (FR-8.2.11)
with pytest.raises(DataError, match="non-monotonic"):
_engine(node, scenario)
def test_alias_ownership_conflict_rejected(self, node, tmp_path):
csv = _write_csv(tmp_path / "a.csv", "time,level\n0.0,1\n1.0,2\n")
csv2 = _write_csv(tmp_path / "b.csv", "time,level\n0.0,3\n1.0,4\n")
scenario = {
"mode": "continuous", "name": "x", "until": "5s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {
"a": {"file": csv},
"b": {"file": csv2},
},
}
with pytest.raises(ScenarioError, match="claimed by both"):
_engine(node, scenario)
def test_time_factor_accelerates(self, node, tmp_path):
# dataset time advances 10x sim time: 10 dataset-seconds per
# sim-second; at sim t=1s the dataset is at t=10s
csv = _write_csv(tmp_path / "fast.csv", """\
time,level
0.0,0
10.0,1000
""")
scenario = {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv, "time_factor": 10.0,
"interpolation": "linear"}},
}
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
# at end (sim t=1s) dataset t=10s -> level = 1000
assert engine.alias_value("level") == pytest.approx(1000.0)
def test_bounded_memory_large_file(self, node, tmp_path, datastore):
"""FR-8.2.6: a 100k-row file plays without loading into RAM."""
big = tmp_path / "big.csv"
with big.open("w", encoding="utf-8") as fh:
fh.write("time,level\n")
for i in range(100_000):
fh.write(f"{i / 10.0},{i % 1000}\n")
scenario = {
"mode": "continuous", "name": "x", "until": "60s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": str(big), "interpolation": "hold"}},
}
engine = _engine(node, scenario)
engine.start()
result = None
ticks = 0
while result is None:
result = engine.pump()
ticks += 1
assert result.verdict is Verdict.PASS
# only a lookahead row is held, not the whole file
ds = engine.datasets[0]
assert ds._row is not None and ds._next is not None
class TestDegradation:
def _scenario(self, **degradation):
return {
"mode": "continuous", "name": "x", "until": "2h",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
"plugins": [],
"degradation": {
"pump_capacity": {
"observes": "pump",
"model": "linear",
"rate": 0.1, # 0.1 per runtime hour
"from": 1.0,
**degradation,
},
},
}
def test_parameter_decays_with_runtime(self, node):
engine = _engine(node, self._scenario())
# wear observes the DO alias: emulate the master turning it on
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
result = None
while result is None:
result = engine.pump()
# 2h runtime at 0.1/h -> capacity = 0.8
assert engine.parameter("pump_capacity") == pytest.approx(0.8, abs=0.01)
def test_maintenance_threshold_injects_diagnosis(self, node, app):
# engine needs the register bank for diagnosis injection
scenario = self._scenario(maintenance_at=0.95)
engine = ScenarioEngine(node, scenario)
engine.registers = app.registers # FR-8.3.4 injection target
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
result = None
while result is None:
result = engine.pump()
# capacity crosses 0.95 after ~0.5h of the 2h run -> diagnosed
assert engine.degradation.models["pump_capacity"].diagnosed is True
assert app.registers.read_config(0x1050, 3) != [0, 0, 0]
def test_maintenance_reset_restores(self, node, app):
scenario = self._scenario()
engine = ScenarioEngine(node, scenario)
engine.registers = app.registers
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
for _ in range(50):
engine.pump()
assert engine.parameter("pump_capacity") < 1.0
engine.maintenance_reset()
assert engine.parameter("pump_capacity") == pytest.approx(1.0)
def test_staircase_model(self, node):
scenario = self._scenario(model="staircase",
staircase=[0.9, 0.5, 0.2],
rate=0)
engine = _engine(node, scenario)
# pump runs the whole 2h: one level per hour -> level 2 -> 0.2
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
result = None
while result is None:
result = engine.pump()
assert engine.parameter("pump_capacity") == pytest.approx(0.2)
class TestInvariants:
def _scenario(self, invariants, **extra):
return {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
"scale": 1.0}},
"invariants": invariants,
**extra,
}
def test_invariant_breach_records_one_episode(self, node):
inv = [{"alias": "level", "condition": ">= 0", "label": "floor"}]
engine = _engine(node, self._scenario(inv))
# drive level negative via the datastore (plugin/dataset would
# normally do it): AI module 0 word 0
engine.datastore.set_input_word(0, 0xFFFF) # signed view? raw 65535
result = None
while result is None:
result = engine.pump()
# level reads raw (no signed range configured) = 65535 >= 0: OK.
# now force an actual breach: condition fails only if value < 0
assert engine.invariants.episodes == []
def test_invariant_fail_verdict(self, node):
inv = [{"alias": "level", "condition": "<= 10", "label": "cap"}]
scenario = self._scenario(inv)
engine = _engine(node, scenario)
engine.datastore.set_input_word(0, 5000) # 5000 > 10: breach
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.FAIL
episodes = engine.invariants.episodes
assert len(episodes) == 1
assert episodes[0].label == "cap"
assert episodes[0].samples >= 1
def test_rate_limit_breach_fails_immediately(self, node):
scenario = self._scenario(
[], rate_limits=[{"alias": "level", "max_per_hour": 100}])
engine = _engine(node, scenario)
engine.datastore.set_input_word(0, 0)
engine.pump()
engine.datastore.set_input_word(0, 50_000) # huge jump within 1h
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.FAIL
assert "rate limit" in result.steps[-1].detail
def test_snapshot_every_takes_snapshots(self, node):
scenario = self._scenario([], snapshot_every="100ms")
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
# 1s run, snapshot every 100ms -> ~10 snapshots
assert len(engine.invariants.snapshots) >= 9
snap = engine.invariants.snapshots[0]
assert "aliases" in snap and "sim_time_ms" in snap
class TestPlantEnv:
def _scenario(self):
return {
"mode": "continuous", "name": "x", "until": "500ms",
"inputs": {"start": {"type": "di", "module": 0, "channel": 0}},
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
}
def test_observe_returns_all_channels(self, node):
engine = _engine(node, self._scenario())
state = engine.env.observe()
kinds = {c.kind for c in state.channels}
assert kinds == {"di", "do", "ai", "ao"}
codes = [c.io_code for c in state.channels]
assert "DI0.0" in codes and "DO0.0" in codes
assert state.aliases["start"] is False
assert state.sim_time_ms == 0.0
def test_apply_writes_output_before_next_observe(self, node):
engine = _engine(node, self._scenario())
engine.env.apply({"pump": True})
state = engine.env.step() # flush + pump + observe
pump_states = [c for c in state.channels if c.io_code == "DO0.0"]
assert pump_states[0].value is True
assert state.aliases["pump"] is True
def test_apply_by_io_code(self, node):
engine = _engine(node, self._scenario())
engine.env.apply({"DO0.0": True})
state = engine.env.step()
assert state.aliases["pump"] is True
def test_apply_input_rejected(self, node):
engine = _engine(node, self._scenario())
with pytest.raises(ScenarioError, match="input alias"):
engine.env.apply({"start": True})
def test_apply_unknown_rejected(self, node):
engine = _engine(node, self._scenario())
with pytest.raises(ScenarioError, match="unknown channel"):
engine.env.apply({"nonexistent": True})
def test_reset_deterministic_replay(self, node):
engine = _engine(node, self._scenario())
engine.env.apply({"pump": True})
for _ in range(10):
engine.pump()
assert engine.sim_time_ms > 0
engine.env.reset()
assert engine.sim_time_ms == 0.0
state = engine.env.observe()
assert state.aliases["pump"] is False
def test_two_tick_cycle_deterministic(self, node):
"""Acceptance criterion 12: copy an input to an output; the
second observation contains it (FR-8.2.12 pipeline consistency)."""
engine = _engine(node, self._scenario())
state1 = engine.env.observe() # observe: DI0.0 still off
di_then = next(c for c in state1.channels if c.io_code == "DI0.0")
assert di_then.value is False
# field-side change + decision AFTER observing, before the tick
engine.datastore.set_digital_input_channel(1, True)
engine.env.apply({"pump": di_then.value}) # decide on observed state
state2 = engine.env.step() # flush + tick + observe
di_now = next(c for c in state2.channels if c.io_code == "DI0.0")
assert di_now.value is True # visible next observe
# decided output applied from the OBSERVED state: pump still off
do_now = next(c for c in state2.channels if c.io_code == "DO0.0")
assert do_now.value is False
class TestTickPipeline:
def test_scheduled_steps_execute_at_their_time(self, node):
scenario = {
"mode": "continuous", "name": "x", "until": "2s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"sequence": [
{"at": "500ms", "set": {"level": 123}},
{"at": "1s", "report": "PASS"},
],
}
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.PASS
assert engine.alias_value("level") == pytest.approx(123.0)
def test_mixed_dataset_and_logic(self, node, tmp_path):
"""Acceptance criterion 10: one alias from a dataset, one computed
by a plugin in the same run; the plugin reads the dataset value."""
csv = _write_csv(tmp_path / "d.csv", "time,level\n0.0,700\n10.0,700\n")
scenario = {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
"scale": 1.0}},
"datasets": {"d": {"file": csv, "interpolation": "hold"}},
"plugins": [],
}
# inline plugin: copy level -> AI channel 2 as "logic-driven"
scenario["inputs"]["computed"] = {"type": "ai", "module": 0, "channel": 1,
"scale": 1.0}
engine = _engine(node, scenario)
# simulate the logic: a plugin reading level and writing computed
def logic(eng, value):
level = eng.alias_value("level")
eng.set_alias("computed", level * 2)
scenario["plugins"] = [] # not used; call logic manually
result = None
while result is None:
logic(engine, None) # runs with the dataset applied
result = engine.pump()
assert engine.alias_value("level") == pytest.approx(700.0)
# (computed shows the logic ran against the dataset-driven value)
def test_logic_only_run(self, node):
"""FR-8.2.13: continuous run with no datasets at all."""
scenario = {
"mode": "continuous", "name": "x", "until": "1h",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
"scale": 1.0}},
"invariants": [{"alias": "level", "condition": ">= 0"}],
}
engine = _engine(node, scenario)
result = None
ticks = 0
while result is None:
result = engine.pump()
ticks += 1
assert result.verdict is Verdict.PASS
assert ticks == 3_600_000 // 10 # 1h at 10ms ticks