🚧 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
+216
View File
@@ -0,0 +1,216 @@
"""PlantEnv: gymnasium-style environment over the simulated plant (spec §8, FR-8.2.14-16).
The environment is the decision-source interface to the distributed
I/O: per deterministic engine tick —
state = env.observe() # WAGO input image in engineering units
decision = policy(state) # caller's plant model / agent (no policy here)
env.apply(actions) # decided outputs -> output image via
# the datastore's INTERNAL write paths
engine.pump() # engine advances exactly one tick
The engine always owns the tick clock (FR-4.3.9 deterministic sim
time); the env never reads wall-clock. `apply()` writes through the
datastore's Modbus-equivalent dispatch so mirrors, statistics, and
watchdog behavior stay consistent (FR-9.2.6 semantics).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .engine import ScenarioError
@dataclass
class ChannelState:
"""One channel of the observed plant state (engineering units)."""
kind: str # di | do | ai | ao
module: int # per-type module number (FR-4.3.11 v1.5)
channel: int # 0-based channel on the module
io_code: str # e.g. "DI0.0"
name: str | None # configured application name, if any
value: object # bool for di/do, float for ai/ao
@dataclass
class PlantState:
"""Full observation: every channel + aliases + parameters + clock."""
sim_time_ms: float
channels: list[ChannelState] = field(default_factory=list)
aliases: dict[str, object] = field(default_factory=dict)
parameters: dict[str, float] = field(default_factory=dict)
class PlantEnv:
"""gym-style observe/apply environment (FR-8.2.14-16).
Constructed by the continuous engine; the engine stays the clock
owner — the env's `step()` convenience drives `engine.pump()` for
exactly one tick (server mode may tick the engine externally and
only use observe/apply).
"""
def __init__(self, engine) -> None:
self.engine = engine
self._actions: dict[str, object] = {}
# -- observation (FR-8.2.14) -----------------------------------------------
def observe(self) -> PlantState:
"""Snapshot of the generic inputs (WAGO input image + aliases).
DI/AI channels hold the simulated field state; DO/AO hold the
PLC-written outputs as currently in the images — all values in
engineering units (alias scale/offset applied where defined).
"""
node = self.engine.node
ds = self.engine.datastore
channels: list[ChannelState] = []
for kind, pool in (("di", node.di), ("do", node.do),
("ai", node.ai), ("ao", node.ao)):
for i, loc in enumerate(pool, start=1):
if kind == "di":
value = ds.digital_input_channel(i)
elif kind == "do":
value = ds.digital_output_channel(i)
elif kind == "ai":
value = ds.get_input_word(loc.word)
else:
value = ds.get_output_word(loc.word)
code = node.io_code_at_slot(kind, loc.module_index, loc.channel - 1)
channels.append(ChannelState(
kind=kind,
module=node.kind_index(loc.module_index)[kind],
channel=loc.channel - 1,
io_code=code,
name=node.channel_names.get(code),
value=value,
))
aliases = {name: self.engine.alias_value(name)
for name in self.engine._aliases}
parameters: dict[str, float] = {}
degradation = getattr(self.engine, "degradation", None)
if degradation is not None:
parameters = {n: m.value for n, m in degradation.models.items()}
return PlantState(
sim_time_ms=self.engine.sim_time_ms,
channels=channels,
aliases=aliases,
parameters=parameters,
)
# -- actions (FR-8.2.14) -----------------------------------------------------
def apply(self, actions: dict[str, object]) -> None:
"""Queue decided outputs; they land when the engine ticks.
Keys: I/O code ("DO0.0") or alias name ("pump_relay"). Writing
a DI/AI through the env is an error — inputs are the field side
(datasets/scenario steps simulate them). Keys are validated
EAGERLY so a bad decision surfaces immediately, not at flush.
"""
if not isinstance(actions, dict):
raise ScenarioError("apply() actions must be a mapping")
validated: dict[str, object] = {}
for key, value in actions.items():
alias = self.engine._aliases.get(key) or self._alias_by_code(key)
if alias is None:
raise ScenarioError(f"apply(): unknown channel {key!r}")
if alias.kind not in ("do", "ao"):
raise ScenarioError(
f"apply(): {key!r} is an input alias ({alias.kind}); "
"only outputs (do/ao) are decided")
validated[str(key)] = value
self._actions.update(validated)
def flush(self) -> None:
"""Write queued actions into the output image (engine-tick step).
Called by the engine after the observe/decide window and before
the pipeline advances — so the next observe() sees them
(FR-8.2.12 atomic-env-tick paragraph).
"""
for key, value in self._actions.items():
self._write_one(key, value)
self._actions = {}
def _write_one(self, key: str, value: object) -> None:
# resolve by alias name first, then by I/O code
alias = self.engine._aliases.get(key)
if alias is None:
alias = self._alias_by_code(key)
if alias is None:
raise ScenarioError(f"apply(): unknown channel {key!r}")
if alias.kind not in ("do", "ao"):
raise ScenarioError(
f"apply(): {key!r} is an input alias ({alias.kind}); "
"only outputs (do/ao) are decided")
loc = self.engine._alias_location(alias)
ds = self.engine.datastore
if alias.kind == "do":
result = ds.write_bits(loc.bit, [bool(value)])
else:
numeric = float(value) # type: ignore[arg-type]
raw = int(numeric * alias.scale + alias.offset) & 0xFFFF
result = ds.write_words(loc.word, [raw])
if isinstance(result, Exception):
raise ScenarioError(f"apply() write to {key!r} rejected: {result}")
def _alias_by_code(self, code: str):
"""Find an alias whose I/O code matches (per-type codes)."""
code_up = str(code).strip().upper()
node = self.engine.node
for name, alias in self.engine._aliases.items():
if alias.kind not in ("do", "ao"):
continue
try:
loc = self.engine._alias_location(alias)
except Exception:
continue
if node.io_code_at_slot(alias.kind, loc.module_index,
loc.channel - 1) == code_up:
return alias
return None
# -- clock (FR-8.2.16) ---------------------------------------------------------
@property
def tick_ms(self) -> float:
return float(self.engine.TICK_MS)
@property
def sim_time_ms(self) -> float:
return self.engine.sim_time_ms
# -- episode control (FR-8.2.16) ------------------------------------------------
def reset(self, config: dict | None = None) -> PlantState:
"""Fresh episode: zero images, reset wear + parameters + clock
(deterministic — identical runs replay identically, NFR-8.7.3)."""
self.engine.reset_episode()
return self.observe()
def step(self, actions: dict[str, object] | None = None):
"""One full env tick: apply -> engine pump -> observe (FR-8.2.14).
Convenience for headless agents; server mode ticks the engine
on its own scheduler and uses observe/apply separately.
"""
if actions:
self.apply(actions)
self.engine.pump()
return self.observe()
# -- §8.3/§8.4 machinery (FR-8.2.16) ----------------------------------------------
@property
def parameters(self) -> dict[str, float]:
deg = getattr(self.engine, "degradation", None)
return {n: m.value for n, m in deg.models.items()} if deg else {}
@property
def invariants(self):
return getattr(self.engine, "invariants", None)