🚧 WIP
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user