This commit is contained in:
Jonas Bengtsson
2026-10-04 20:59:22 +02:00
parent f82e8d4be9
commit 68d3c42d79
12 changed files with 394 additions and 73 deletions
+45 -4
View File
@@ -25,10 +25,14 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from ..modules import VirtualNode
from .engine import ScenarioError
if TYPE_CHECKING:
from .engine import ScenarioEngine
@dataclass
class ChannelState:
@@ -40,6 +44,7 @@ class ChannelState:
io_code: str # e.g. "DI0.0"
name: str | None # configured application name, if any
value: object # bool for di/do, int/float for ai/ao
range: tuple[int, int] | None = None # (min, max) raw counts — analog only
@dataclass
@@ -62,6 +67,35 @@ class PlantState:
"""{io_code: value} for all channels of one kind."""
return {c.io_code: c.value for c in self.channels if c.kind == kind}
def channel(self, key: str) -> ChannelState:
"""Full ChannelState (name, range, ...) by I/O code or name.
Matches the I/O code first ("AI0.0"), then the configured
application name ("speed_setpoint", FR-10.2.11). KeyError
when neither matches — including a `None` name (unconfigured
channels match by code only).
"""
for ch in self.channels:
if ch.io_code == key:
return ch
for ch in self.channels:
if ch.name == key:
return ch
raise KeyError(key)
def channel_range(self, key: str) -> tuple[int, int]:
"""Display range (min, max) of an analog channel, by I/O code
or name (FR-10.2.14).
Always defined for AI/AO — the range store is dense — so
env models can unpack it without None-handling; digital
channels carry no range and raise KeyError.
"""
rng = self.channel(key).range
if rng is None:
raise KeyError(key)
return rng
class PlantEnv:
"""gym-style plant environment (FR-8.2.14-17).
@@ -75,9 +109,14 @@ class PlantEnv:
TICK_MS = 10.0
def __init__(self, node: VirtualNode | None = None, *,
engine=None) -> None:
engine: ScenarioEngine | None = None) -> None:
if node is None:
if engine is None:
raise ScenarioError("PlantEnv needs a node or an engine")
self.node: VirtualNode = engine.node
else:
self.node = node
self.engine = engine
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)
@@ -114,6 +153,8 @@ class PlantEnv:
io_code=code,
name=node.channel_names.get(code),
value=value,
range=node.channel_ranges[code]
if kind in ("ai", "ao") else None,
))
aliases = ({name: self._alias_value(name)
for name in (self._aliases or {})}
@@ -198,14 +239,14 @@ class PlantEnv:
if kind == "di":
ds.set_digital_input_channel(loc.bit + 1, bool(value))
elif kind == "ai":
raw = int(float(value) * scale + offset) & 0xFFFF
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
ds.set_input_word(loc.word, raw)
elif kind == "do":
result = ds.write_bits(loc.bit, [bool(value)])
if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}")
else: # ao
raw = int(float(value) * scale + offset) & 0xFFFF
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
result = ds.write_words(loc.word, [raw])
if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}")