🚧 Rewrite continous simulation.

This commit is contained in:
Jonas Bengtsson
2026-10-04 15:29:57 +02:00
parent 263afc9c1e
commit 66b7e12f0f
9 changed files with 856 additions and 226 deletions
+113 -46
View File
@@ -9,6 +9,7 @@ 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:
@@ -326,6 +327,8 @@ class TestInvariants:
class TestPlantEnv:
"""PlantEnv v2 (FR-8.2.14-17): standalone-first, apply -> DI/AI."""
def _scenario(self):
return {
"mode": "continuous", "name": "x", "until": "500ms",
@@ -334,66 +337,130 @@ class TestPlantEnv:
}
def test_observe_returns_all_channels(self, node):
engine = _engine(node, self._scenario())
state = engine.env.observe()
# 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.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_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_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_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):
engine = _engine(node, self._scenario())
env = PlantEnv(node=node)
with pytest.raises(ScenarioError, match="unknown channel"):
engine.env.apply({"nonexistent": True})
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):
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
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_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)."""
def test_engine_attached_env_uses_aliases(self, node):
"""Scenario layer: alias names + alias scale/offset still work."""
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
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: