🚧 Implement continous simulation
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user