🚧 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
+252 -128
View File
@@ -1,24 +1,32 @@
"""PlantEnv: gymnasium-style environment over the simulated plant (spec §8, FR-8.2.14-16).
"""PlantEnv: gymnasium-style plant environment (spec §8, FR-8.2.14-17).
The environment is the decision-source interface to the distributed
I/O: per deterministic engine tick —
Standalone-first: requires only a virtual node + datastore — no
scenario. The environment is the interface for continuous plant
simulation; per deterministic tick (runner-owned clock, never
wall-clock arithmetic):
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
state = env.observe() # ALL modules: DI/DO/AI/AO channels,
# engineering units, names, parameters
values = env.model(state, dt) # plant physics (subclass hook)
env.apply(values) # new FIELD state -> DI/AI inputs;
# DO/AO via env.force() (master-side)
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).
`model()` is the override point for plant physics: read what the PLC
commanded (DO/AO), compute what the field now shows (DI/AI), return
it. `is_done(state)` gives an optional end criterion; the default is
False — environments run until manually stopped (SIGINT/SIGTERM).
Attachments: degradation (§8.3), invariants (§8.4) and aliases
(scenario-continuous mode) hook onto the same tick when the env is
constructed on an engine.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from ..modules import VirtualNode
from .engine import ScenarioError
@@ -31,43 +39,60 @@ class ChannelState:
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
value: object # bool for di/do, int/float for ai/ao
@dataclass
class PlantState:
"""Full observation: every channel + aliases + parameters + clock."""
"""Full observation: every channel of every module + attachments."""
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)
def by_code(self, io_code: str) -> object:
"""Value of one channel by I/O code, or KeyError."""
for ch in self.channels:
if ch.io_code == io_code:
return ch.value
raise KeyError(io_code)
def by_kind(self, kind: str) -> dict[str, object]:
"""{io_code: value} for all channels of one kind."""
return {c.io_code: c.value for c in self.channels if c.kind == kind}
class PlantEnv:
"""gym-style observe/apply environment (FR-8.2.14-16).
"""gym-style plant environment (FR-8.2.14-17).
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).
Constructed on a VirtualNode (standalone) or on an engine (the
scenario-continuous layer). The runner owns the tick clock; `tick()`
advances sim-time deterministically by TICK_MS per call so both
paced (serve) and unpaced (sim) runners behave identically.
"""
def __init__(self, engine) -> None:
TICK_MS = 10.0
def __init__(self, node: VirtualNode | None = None, *,
engine=None) -> None:
self.engine = engine
self._actions: dict[str, object] = {}
self.node: VirtualNode = node if node is not None else engine.node
self.datastore = self.node.datastore
self.sim_time_ms = 0.0
# attachments (optional; the scenario layer wires these)
self.degradation = getattr(engine, "degradation", None)
self.invariants = getattr(engine, "invariants", None)
self._aliases = getattr(engine, "_aliases", None) if engine else None
self._alias_value: Callable[[str], object] | None = (
engine.alias_value if engine is not None else None)
# -- 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
"""Snapshot ALL modules (DI/DO/AI/AO) in engineering units."""
node = self.node
ds = self.datastore
channels: list[ChannelState] = []
for kind, pool in (("di", node.di), ("do", node.do),
("ai", node.ai), ("ao", node.ao)):
@@ -80,7 +105,8 @@ class PlantEnv:
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)
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],
@@ -89,128 +115,226 @@ class PlantEnv:
name=node.channel_names.get(code),
value=value,
))
aliases = {name: self.engine.alias_value(name)
for name in self.engine._aliases}
aliases = ({name: self._alias_value(name)
for name in (self._aliases or {})}
if self._alias_value else {})
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()}
if self.degradation is not None:
parameters = {n: m.value
for n, m in self.degradation.models.items()}
return PlantState(
sim_time_ms=self.engine.sim_time_ms,
sim_time_ms=self.sim_time_ms,
channels=channels,
aliases=aliases,
parameters=parameters,
)
# -- actions (FR-8.2.14) -----------------------------------------------------
# -- plant model hook (FR-8.2.16) -------------------------------------------
def apply(self, actions: dict[str, object]) -> None:
"""Queue decided outputs; they land when the engine ticks.
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
"""Plant physics: react to PLC commands, compute the new field.
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.
Override in a subclass (FR-8.2.17). Receives the full observed
state and the tick delta in seconds; returns {I/O code or alias:
new value} for the channels it drives — DI/AI normally (the
simulated sensors); DO/AO entries are treated as forcing and go
through the master-side write paths. Returning {} (the default)
makes this a pure observation environment.
"""
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)
return {}
def flush(self) -> None:
"""Write queued actions into the output image (engine-tick step).
def is_done(self, state: PlantState) -> bool:
"""Optional end criterion; default runs until manually stopped."""
return False
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).
# -- actions (FR-8.2.15) ------------------------------------------------------
def apply(self, values: dict[str, object], *, force: bool = False) -> None:
"""Write new channel values into the process images.
DI/AI keys (the field) write directly — this is the primary
direction. DO/AO keys (PLC outputs) require force=True and go
through the datastore's internal Modbus dispatch so mirrors,
statistics, and watchdog stay consistent (FR-9.2.6) — for
headless runs and fault setup only.
Keys: I/O code ("AI0.0") or alias name. Unknown keys raise
immediately (FR-8.2.16).
"""
for key, value in self._actions.items():
self._write_one(key, value)
self._actions = {}
if not isinstance(values, dict):
raise ScenarioError("apply() values must be a mapping")
for key, value in values.items():
self._write_one(key, value, force=force)
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"):
def force(self, values: dict[str, object]) -> None:
"""Master-side forcing of DO/AO (FR-8.2.15 explicit form)."""
self.apply(values, force=True)
def _resolve(self, key: str):
"""-> (kind, ChannelLocation, alias_or_None) for a code or alias."""
# alias name first (scenario layer)
if self._aliases and key in self._aliases and self.engine is not None:
alias = self._aliases[key]
return alias.kind, self.engine._alias_location(alias), alias
# any channel by I/O code (per-type codes, FR-4.3.11 v1.5)
code_up = str(key).strip().upper()
node = self.node
for kind, pool in (("di", node.di), ("do", node.do),
("ai", node.ai), ("ao", node.ao)):
for loc in pool:
if node.io_code_at_slot(
kind, loc.module_index, loc.channel - 1) == code_up:
return kind, loc, None
raise ScenarioError(f"apply(): unknown channel {key!r}")
def _write_one(self, key: str, value: object, *, force: bool) -> None:
kind, loc, alias = self._resolve(key)
ds = self.datastore
if kind in ("do", "ao") and not force:
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":
f"apply(): {key!r} is an output ({kind.upper()}); use "
"force=True / env.force() for master-side forcing")
scale = getattr(alias, "scale", 1.0) or 1.0
offset = getattr(alias, "offset", 0.0) or 0.0
if kind == "di":
ds.set_digital_input_channel(loc.bit + 1, bool(value))
elif kind == "ai":
raw = int(float(value) * scale + offset) & 0xFFFF
ds.set_input_word(loc.word, raw)
elif 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
if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}")
else: # ao
raw = int(float(value) * scale + offset) & 0xFFFF
result = ds.write_words(loc.word, [raw])
if isinstance(result, Exception):
raise ScenarioError(f"apply() write to {key!r} rejected: {result}")
if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {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
# -- one full tick (FR-8.2.14 cycle) ---------------------------------------------
def tick(self, dt_ms: float | None = None) -> PlantState:
"""One environment tick: model() -> apply(field) -> advance.
Deterministic: sim-time advances by TICK_MS (or dt_ms) per call
— never from wall-clock. Attachments advance with the tick:
degradation (§8.3) then invariants (§8.4), FR-8.2.12 order.
"""
dt = dt_ms if dt_ms is not None else self.TICK_MS
state = self.observe()
if self.is_done(state):
return state
values = self.model(state, dt / 1000.0)
if values:
self.apply(values)
self.sim_time_ms += dt
if self.degradation is not None:
self.degradation.tick(dt)
if self.invariants is not None:
self.invariants.tick()
return self.observe()
# -- episode control (FR-8.2.16) ------------------------------------------------
def reset(self) -> PlantState:
"""Fresh episode: zero images, reset wear + parameters + clock
(deterministic — identical runs replay identically, NFR-8.7.3)."""
self.datastore.reset_images()
self.sim_time_ms = 0.0
if self.degradation is not None:
for model in self.degradation.models.values():
model.reset()
model.diagnosed = False
self.degradation._last_truth = {}
if self.invariants is not None:
self.invariants.episodes = []
for inv in self.invariants.invariants:
inv.active = None
for rl in self.invariants.rate_limits:
rl.samples = []
rl.breached = False
rl.peak_rate = 0.0
self.invariants.snapshots = []
self.invariants._last_snapshot_ms = 0.0
return self.observe()
# -- 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) ----------------------------------------------
return self.TICK_MS
@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 {}
if self.degradation is None:
return {}
return {n: m.value for n, m in self.degradation.models.items()}
@property
def invariants(self):
return getattr(self.engine, "invariants", None)
def load_env(ref: str, node: VirtualNode | None = None, *,
engine=None) -> PlantEnv:
"""Load an environment from a module reference (FR-8.2.17).
Accepts ``pkg.mod.ClassName``, ``pkg.mod.factory_fn`` (returns a
PlantEnv), or a bare ``pkg.mod`` whose module defines either a
class named ``Env`` or a function ``make_env(...)``.
"""
import importlib
parts = ref.split(":")
attr_path = None
mod = None
leaf = None
if len(parts) == 2:
# explicit module:attr form — ref is the WHOLE module path
attr_path = parts[1] or None
mod_name = parts[0]
else:
# no colon: try the whole ref as a module first, then as
# pkg.mod containing an attr (e.g. ...:Class omitted)
try:
mod = importlib.import_module(ref)
mod_name = ref
except ImportError:
mod_name, _, leaf = ref.rpartition(".")
if not mod_name or ("." not in mod_name and attr_path is None
and leaf is None):
raise ScenarioError(f"--env {ref!r}: reference must include a module path")
if mod is None:
try:
mod = importlib.import_module(mod_name)
except ImportError as exc:
raise ScenarioError(
f"--env {ref!r}: cannot import {mod_name!r}: {exc}")
obj = None
if attr_path is not None:
# explicit module:attr — must exist; never fall back silently
if not hasattr(mod, attr_path):
raise ScenarioError(
f"--env {ref!r}: no {attr_path!r} in {mod_name!r}")
obj = getattr(mod, attr_path)
elif leaf and hasattr(mod, leaf):
obj = getattr(mod, leaf)
elif hasattr(mod, "Env"):
obj = getattr(mod, "Env")
elif hasattr(mod, "make_env"):
obj = getattr(mod, "make_env")
else:
raise ScenarioError(
f"--env {ref!r}: no environment found in {mod_name!r} "
"(expected a class, Env, or make_env())")
env: PlantEnv
if isinstance(obj, type) and issubclass(obj, PlantEnv):
env = obj(node=node, engine=engine)
elif callable(obj):
made = obj(node=node, engine=engine) if node is not None \
else obj(engine=engine)
if not isinstance(made, PlantEnv):
raise ScenarioError(f"--env {ref!r}: did not return a PlantEnv")
env = made
else:
raise ScenarioError(
f"--env {ref!r}: not a PlantEnv subclass or factory")
return env