"""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