525 lines
21 KiB
Python
525 lines
21 KiB
Python
"""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
|
|
from wago_sim.scenario.plantenv import PlantEnv
|
|
|
|
|
|
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:
|
|
"""PlantEnv v2 (FR-8.2.14-17): standalone-first, apply -> DI/AI."""
|
|
|
|
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):
|
|
# standalone construction: no engine needed
|
|
env = PlantEnv(node=node)
|
|
state = 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.sim_time_ms == 0.0
|
|
|
|
def test_apply_writes_input_side(self, node):
|
|
env = PlantEnv(node=node)
|
|
env.apply({"DI0.0": True, "AI0.0": 1234})
|
|
state = env.observe()
|
|
assert state.by_code("DI0.0") is True
|
|
assert state.by_code("AI0.0") == 1234
|
|
|
|
def test_apply_output_requires_force(self, node):
|
|
env = PlantEnv(node=node)
|
|
with pytest.raises(ScenarioError, match="force"):
|
|
env.apply({"DO0.0": True})
|
|
# explicit forcing works and lands in the output image
|
|
env.force({"DO0.0": True, "AO0.0": 999})
|
|
state = env.observe()
|
|
assert state.by_code("DO0.0") is True
|
|
assert state.by_code("AO0.0") == 999
|
|
|
|
def test_apply_unknown_rejected(self, node):
|
|
env = PlantEnv(node=node)
|
|
with pytest.raises(ScenarioError, match="unknown channel"):
|
|
env.apply({"nonexistent": True})
|
|
|
|
def test_model_hook_drives_field(self, node):
|
|
"""The intended loop: model() reads PLC outputs, returns field."""
|
|
|
|
class Tank(PlantEnv):
|
|
def model(self, state, dt_s):
|
|
pump_on = bool(state.by_code("DO0.0"))
|
|
level = float(state.by_code("AI0.0"))
|
|
return {"AI0.0": level + (5.0 if pump_on else 1.0)}
|
|
|
|
env = Tank(node=node)
|
|
env.force({"DO0.0": True}) # the "PLC" turns the pump on
|
|
state = env.tick()
|
|
assert state.by_code("AI0.0") == 5.0 # field advanced via model
|
|
state = env.tick()
|
|
assert state.by_code("AI0.0") == 10.0
|
|
assert state.sim_time_ms == 20.0
|
|
|
|
def test_is_done_stops_ticks(self, node):
|
|
class DoneAfterTwo(PlantEnv):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, **kw)
|
|
self.n = 0
|
|
|
|
def is_done(self, state):
|
|
return self.n >= 2
|
|
|
|
def model(self, state, dt_s):
|
|
self.n += 1
|
|
return {}
|
|
|
|
env = DoneAfterTwo(node=node)
|
|
env.tick()
|
|
env.tick()
|
|
state = env.tick() # done: no further advance
|
|
assert env.n == 2
|
|
assert state.sim_time_ms == 20.0
|
|
|
|
def test_reset_deterministic_replay(self, node):
|
|
env = PlantEnv(node=node)
|
|
env.apply({"DI0.0": True, "AI0.0": 42})
|
|
env.tick()
|
|
assert env.sim_time_ms > 0
|
|
state = env.reset()
|
|
assert env.sim_time_ms == 0.0
|
|
assert state.by_code("DI0.0") is False
|
|
assert state.by_code("AI0.0") == 0
|
|
|
|
def test_engine_attached_env_uses_aliases(self, node):
|
|
"""Scenario layer: alias names + alias scale/offset still work."""
|
|
engine = _engine(node, self._scenario())
|
|
env = engine.env
|
|
state = env.observe()
|
|
assert state.aliases["start"] is False
|
|
# alias write to a do output requires force (PLC-side semantics)
|
|
with pytest.raises(ScenarioError, match="force"):
|
|
env.apply({"pump": True})
|
|
env.force({"pump": True})
|
|
assert env.observe().aliases["pump"] is True
|
|
|
|
def test_pump_station_example_env(self, node):
|
|
"""The shipped example env loads and runs closed-loop physics."""
|
|
from envs.pump_station_env import PumpStationEnv
|
|
env = PumpStationEnv(node=node)
|
|
# PLC turns the pump on at full speed
|
|
env.force({"DO0.0": True, "AO0.0": 4095})
|
|
env.tick() # physics applies the level
|
|
level0 = env.observe().by_code("AI0.0")
|
|
assert level0 > 0 # tank starts at 3000 mm
|
|
# big dt (10 sim-minutes): the pump visibly drains the tank
|
|
env.tick(dt_ms=600_000)
|
|
level1 = env.observe().by_code("AI0.0")
|
|
assert level1 < level0 # pump drains the tank
|
|
# pump OFF: inflow fills the tank instead
|
|
env.force({"DO0.0": False})
|
|
env.tick(dt_ms=600_000)
|
|
level2 = env.observe().by_code("AI0.0")
|
|
assert level2 > level1
|
|
# and it runs forever by default
|
|
assert env.is_done(env.observe()) is False
|
|
|
|
def test_load_env_class_and_factory(self, node):
|
|
from wago_sim.scenario.plantenv import load_env
|
|
env = load_env("envs.pump_station_env:PumpStationEnv", node=node)
|
|
assert isinstance(env, PlantEnv)
|
|
env = load_env("envs.pump_station_env:make_env", node=node)
|
|
assert isinstance(env, PlantEnv)
|
|
# bare module picks up Env
|
|
env = load_env("envs.pump_station_env", node=node)
|
|
assert isinstance(env, PlantEnv)
|
|
with pytest.raises(ScenarioError):
|
|
load_env("envs.pump_station_env:Nope", node=node)
|
|
with pytest.raises(ScenarioError):
|
|
load_env("envs.missing_module", node=node)
|
|
|
|
|
|
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 |