🚧 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
+195
View File
@@ -0,0 +1,195 @@
"""Wear counters and degradation models (spec §8.3, FR-8.3.1-5).
Plant parameters (e.g. ``pump_capacity``) age from wear instead of
staying ideal forever, exercising the PLC's compensation and
maintenance logic:
degradation:
pump_capacity: # parameter name (float 0..1)
from: 1.0 # fresh value (also the reset target)
model: linear # linear | exponential | staircase
rate: 0.0002 # per runtime hour (linear/exp)
per: runtime_hours # wear basis
on: pump_relay # alias observed for wear (any kind)
staircase: [0.95, 0.85, 0.7] # staircase model: levels per step
maintenance_at: 0.75 # parameter threshold -> diagnosis
diagnosis: # injected when threshold crossed
module: 2 # per-type DI/DO... module number
channel: 0
value: 0x0001
Counters update once per tick from the observed alias truth (runtime
hours, rising/falling edges). The engine reads parameters via
``engine.parameter(name)`` (FR-8.3.3); the maintenance alarm is the
PLC's to raise — the scenario asserts it (FR-8.3.4); the built-in
``maintenance_reset`` action clears diagnosis and restores ``from``
(FR-8.3.5).
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
@dataclass
class WearCounters:
"""Per-alias wear (FR-8.3.1)."""
runtime_hours: float = 0.0
rising_edges: int = 0
falling_edges: int = 0
def as_dict(self) -> dict:
return {
"runtime_hours": self.runtime_hours,
"rising_edges": self.rising_edges,
"falling_edges": self.falling_edges,
}
@dataclass
class DegradationModel:
"""One aging parameter (FR-8.3.2-5)."""
name: str
parameter: str # parameter key, e.g. 'pump_capacity'
observes: str # alias name the wear counts come from
model: str = "linear" # linear | exponential | staircase
start: float = 1.0 # fresh value; maintenance_reset target
rate: float = 0.0 # per hour (linear/exponential) or per cycle
basis: str = "runtime_hours" # runtime_hours | cycles
staircase: list[float] = field(default_factory=list)
maintenance_at: float | None = None
diagnosis: dict | None = None
# runtime state
value: float = 1.0
cycles: int = 0
diagnosed: bool = False
def reset(self) -> None:
"""FR-8.3.5: restore to `start`; diagnosis cleared by the engine."""
self.value = self.start
self.cycles = 0
def update(self, dt_hours: float, wear: WearCounters) -> None:
"""Advance the parameter one tick (FR-8.3.2)."""
if self.basis == "cycles":
if self.model == "staircase":
level = min(self.cycles, len(self.staircase) - 1)
self.value = self.staircase[level]
elif self.model == "linear":
self.value = self.start - self.rate * self.cycles
elif self.model == "exponential":
self.value = self.start * (self.rate ** self.cycles)
else:
if self.model == "staircase":
# `rate` = levels per runtime hour; rate<=0 = one level/hour
per_level_hours = (1.0 / self.rate) if self.rate > 0 else 1.0
level = min(int(wear.runtime_hours / per_level_hours),
len(self.staircase) - 1)
self.value = self.staircase[level]
elif self.model == "exponential":
self.value = self.start * (1.0 - self.rate) ** wear.runtime_hours
else: # linear
self.value = self.start - self.rate * wear.runtime_hours
self.value = max(0.0, min(1.0, self.value))
class DegradationManager:
"""All aging parameters of one continuous run (spec §8.3)."""
def __init__(self, engine, spec: dict | None) -> None:
self.engine = engine
self.models: dict[str, DegradationModel] = {}
self.wear: dict[str, WearCounters] = {}
self._last_truth: dict[str, bool] = {}
for param_name, cfg in (spec or {}).items():
if not isinstance(cfg, dict):
raise ValueError(f"degradation {param_name!r} must be a mapping")
# NB: the key is 'observes', NOT 'on' — YAML 1.1 parses the
# bare key `on` as boolean True (a classic trap)
observes = str(cfg.get("observes", ""))
if observes not in engine._aliases:
raise ValueError(
f"degradation {param_name!r}: unknown alias {observes!r}")
model = DegradationModel(
name=param_name,
parameter=str(cfg.get("parameter", param_name)),
observes=observes,
model=str(cfg.get("model", "linear")),
start=float(cfg.get("from", 1.0)),
rate=float(cfg.get("rate", 0.0)),
basis=str(cfg.get("per", "runtime_hours")),
staircase=[float(v) for v in cfg.get("staircase", [])],
maintenance_at=(float(cfg["maintenance_at"])
if "maintenance_at" in cfg else None),
diagnosis=cfg.get("diagnosis"),
)
if model.model == "staircase" and not model.staircase:
raise ValueError(
f"degradation {param_name!r}: staircase model needs 'staircase'")
model.value = model.start
self.models[param_name] = model
self.wear.setdefault(observes, WearCounters())
# -- per-tick update ------------------------------------------------------
def tick(self, dt_ms: float) -> None:
"""Update wear counters and parameters for one tick (FR-8.2.12 step 4)."""
dt_hours = dt_ms / 3_600_000.0
for alias_name, wear in self.wear.items():
truth = bool(self.engine.alias_value(alias_name))
before = self._last_truth.get(alias_name, False)
if truth:
wear.runtime_hours += dt_hours
if truth and not before:
wear.rising_edges += 1
# count a cycle per completed run: rising edge closes it
for model in self.models.values():
if model.observes == alias_name and model.basis == "cycles":
model.cycles += 1
if (not truth) and before:
wear.falling_edges += 1
self._last_truth[alias_name] = truth
for model in self.models.values():
model.update(dt_hours, self.wear[model.observes])
if (model.maintenance_at is not None and not model.diagnosed
and model.value <= model.maintenance_at):
model.diagnosed = True
self._inject_diagnosis(model)
def _inject_diagnosis(self, model: DegradationModel) -> None:
"""FR-8.3.4: threshold crossed -> module diagnosis injected."""
diag = model.diagnosis or {}
module = int(diag.get("module", 0))
channel = int(diag.get("channel", 0))
value = int(diag.get("value", 0x0001))
registers = getattr(self.engine, "registers", None)
if registers is not None:
registers.set_module_diagnosis(module, channel, value)
# -- engine interface (FR-8.3.3) -------------------------------------------
def parameter(self, name: str) -> float:
"""Parameter value for plant physics (pump models etc.)."""
model = self.models.get(name)
if model is None:
raise KeyError(f"unknown parameter {name!r}")
return model.value
def maintenance_reset(self) -> None:
"""FR-8.3.5 action: restore parameters, clear diagnosis."""
for model in self.models.values():
model.reset()
model.diagnosed = False
registers = getattr(self.engine, "registers", None)
if registers is not None:
registers.set_module_diagnosis(0, 0, 0)
def snapshot(self) -> dict:
"""Wear + parameter values for report snapshots (FR-8.4.3)."""
return {
"parameters": {n: m.value for n, m in self.models.items()},
"wear": {a: w.as_dict() for a, w in self.wear.items()},
}