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
+42 -22
View File
@@ -13,50 +13,70 @@ or headless for model development:
--env envs.pump_station_env:PumpStationEnv
Physics (deliberately simple, for demonstration): one tank.
- inflow is constant (or a small sinusoidal demand profile)
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON
- the level integrates the balance; AI0.0 reports it as 0.1 mm/count
- a low-level switch DI0.1 opens when the tank drains below 500 mm
- optional is_done: stop after a sim hour (default: run forever)
- inflow is constant
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON;
the speed fraction is AO0.0 normalized over its display range
(FR-10.2.14)
- the level integrates the balance, clamped at the brim; AI0.0
reports the level scaled over the channel's display range
(default 0-4095 counts = 100 %)
- a high-level switch DI0.1 closes when the level rises above 2.7 m
- optional is_done: stop after a given sim time (default: run forever)
"""
from __future__ import annotations
from wago_sim.scenario.plantenv import PlantEnv, PlantState
TANK_AREA = 2.0 # m^2
TANK_AREA = 5.0 # m^2
FLOW_MAX = 30.0 # m^3/h the pump moves at full speed
INFLOW = 12.0 # m^3/h constant inflow
LEVEL_MAX = 9000.0 # mm — tank brim
SWITCH_AT = 500.0 # mm — low-level switch threshold
LEVEL_SENSOR_MAX = 5.0 # m — tank level meter full-scale
LEVEL_BRIM = 2.9 # m — tank brim (physical clamp)
LEVEL_HIGH = 2.7 # m — high-level switch threshold
class PumpStationEnv(PlantEnv):
"""Closed-loop tank + pump physics over the pump_node layout."""
TICK_MS = 9.0 # overrides PlantEnv TICK_MS
def __init__(self, *args, stop_after_ms: float | None = None, **kw):
super().__init__(*args, **kw)
self._level_mm = 3000.0 # initial tank level
self._level_m = 1.2 # initial tank level
self.stop_after_ms = stop_after_ms
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
dt_h = dt_s / 3600.0
# what the PLC currently commands
pump_on = bool(state.by_code("DO0.0"))
# optional speed setpoint from AO0.0 (counts -> 0..1 fraction)
speed_raw = float(state.by_code("AO0.0") or 0)
speed = min(1.0, max(0.0, speed_raw / 4095.0))
# what the PLC currently commands (master side: DO/AO)
pump_running = bool(state.by_code("DO0.0"))
# speed setpoint from AO0.0, normalized over its display range
ao_lo, ao_hi = state.channel_range("AO0.0")
ao_value = state.by_code("AO0.0")
speed_raw = float(ao_value) if isinstance(ao_value, (int, float)) else 0.0
try:
speed = min(1.0, max(0.0, (speed_raw - ao_lo) / (ao_hi - ao_lo)))
except ZeroDivisionError:
speed = 0.0
# physics: water balance with pump wear (parameters attachable)
capacity = state.parameters.get("pump_capacity", 1.0)
outflow = FLOW_MAX * capacity * speed if pump_on else 0.0
d_level_mm = (INFLOW - outflow) / TANK_AREA * 1000.0 * dt_h
self._level_mm = min(LEVEL_MAX, max(0.0, self._level_mm + d_level_mm))
# the field the PLC reads back
level_counts = int(self._level_mm / 0.1) # AI0.0: 0.1 mm/count
low_switch = self._level_mm < SWITCH_AT # DI0.1
outflow = FLOW_MAX * capacity * speed if pump_running else 0.0
d_level_m = (INFLOW - outflow) / TANK_AREA * dt_h
self._level_m = min(LEVEL_BRIM, max(0.0, self._level_m + d_level_m))
# the field the PLC reads back: level over the AI display range
# (dense store, FR-10.2.14 — defaults to 0-4095 counts = 100 %)
ai_lo, ai_hi = state.channel_range("AI0.0")
level_raw = int((self._level_m / LEVEL_SENSOR_MAX) * (ai_hi - ai_lo)
+ ai_lo)
level_raw = min(ai_hi, max(ai_lo, level_raw))
high_switch = self._level_m > LEVEL_HIGH # DI0.1
return {
"AI0.0": level_counts,
"DI0.1": low_switch,
"AI0.0": level_raw,
"DI0.1": high_switch,
}
def is_done(self, state: PlantState) -> bool: