first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
0
simulators/bots/__init__.py
Normal file
0
simulators/bots/__init__.py
Normal file
52
simulators/bots/ats.py
Normal file
52
simulators/bots/ats.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class AtsBot(BaseBot):
|
||||
"""Automatic Transfer Switch — monitors which utility feed is active and logs transfers."""
|
||||
|
||||
interval = 30
|
||||
|
||||
def __init__(self, site_id: str, ats_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.ats_id = ats_id
|
||||
self._active_feed = "utility-a"
|
||||
self._transfer_count = 0
|
||||
self._last_transfer = None # ISO string or None
|
||||
self._transfer_ms = None # last transfer time in ms
|
||||
self._transferring = False
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/power/ats/{self.ats_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
if name == "ATS_TRANSFER":
|
||||
# Simulate a transfer to generator / utility-b
|
||||
self._active_feed = "generator"
|
||||
self._transfer_count += 1
|
||||
self._transfer_ms = round(random.uniform(80, 200)) # typical ATS 80–200 ms
|
||||
self._last_transfer = datetime.now(timezone.utc).isoformat()
|
||||
self._transferring = True
|
||||
elif name in (None, "RESET"):
|
||||
self._active_feed = "utility-a"
|
||||
self._transferring = False
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
# Utility voltage sensing (slight variation on active feed)
|
||||
ua_v = 415.0 + random.gauss(0, 1.0) if self._active_feed == "utility-a" else 0.0
|
||||
ub_v = 415.0 + random.gauss(0, 1.0) if self._active_feed == "utility-b" else 415.0 + random.gauss(0, 0.5)
|
||||
gen_v = 415.0 + random.gauss(0, 1.5) if self._active_feed == "generator" else 0.0
|
||||
|
||||
return {
|
||||
"state": "transferring" if self._transferring else "stable",
|
||||
"active_feed": self._active_feed,
|
||||
"transfer_count": self._transfer_count,
|
||||
"last_transfer_at": self._last_transfer,
|
||||
"last_transfer_ms": self._transfer_ms,
|
||||
"utility_a_v": round(ua_v, 1),
|
||||
"utility_b_v": round(ub_v, 1),
|
||||
"generator_v": round(gen_v, 1),
|
||||
}
|
||||
38
simulators/bots/base.py
Normal file
38
simulators/bots/base.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiomqtt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseBot:
|
||||
interval: int = 30 # seconds between publishes
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._scenario: str | None = None
|
||||
self._scenario_step: int = 0
|
||||
|
||||
def get_topic(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
self._scenario = name
|
||||
self._scenario_step = 0
|
||||
logger.info(f"{self.__class__.__name__} scenario → {name or 'NORMAL'}")
|
||||
|
||||
async def run(self, client: aiomqtt.Client) -> None:
|
||||
while True:
|
||||
try:
|
||||
payload = self.get_payload()
|
||||
payload["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
await client.publish(self.get_topic(), json.dumps(payload), qos=0)
|
||||
self._scenario_step += 1
|
||||
except Exception as e:
|
||||
logger.error(f"{self.__class__.__name__} publish error: {e}")
|
||||
await asyncio.sleep(self.interval)
|
||||
93
simulators/bots/chiller.py
Normal file
93
simulators/bots/chiller.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import random
|
||||
import math
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class ChillerBot(BaseBot):
|
||||
"""Water-cooled chiller plant — supplies chilled water to CRAC/CRAH units."""
|
||||
|
||||
interval = 60
|
||||
|
||||
RATED_COOLING_KW = 300.0 # total cooling capacity
|
||||
DESIGN_FLOW_GPM = 600.0 # nominal chilled water flow
|
||||
CHW_SETPOINT = 7.0 # chilled water supply setpoint (°C)
|
||||
CW_RETURN_DESIGN = 35.0 # condenser water return design temp (°C)
|
||||
|
||||
def __init__(self, site_id: str, chiller_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.chiller_id = chiller_id
|
||||
self._fault = False
|
||||
self._run_hours = 8_000 + random.randint(0, 2_000)
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/cooling/chiller/{self.chiller_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._fault = (name == "CHILLER_FAULT")
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
hour_inc = self.interval / 3_600
|
||||
self._run_hours += hour_inc
|
||||
|
||||
if self._fault:
|
||||
return {
|
||||
"state": "fault",
|
||||
"chw_supply_c": None,
|
||||
"chw_return_c": None,
|
||||
"chw_delta_c": None,
|
||||
"flow_gpm": 0,
|
||||
"cooling_load_kw": 0,
|
||||
"cooling_load_pct": 0,
|
||||
"cop": 0,
|
||||
"compressor_load_pct": 0,
|
||||
"condenser_pressure_bar": None,
|
||||
"evaporator_pressure_bar": None,
|
||||
"cw_supply_c": None,
|
||||
"cw_return_c": None,
|
||||
"run_hours": round(self._run_hours, 1),
|
||||
}
|
||||
|
||||
# Normal operation
|
||||
load_pct = 55.0 + random.gauss(0, 5.0)
|
||||
load_pct = max(20.0, min(100.0, load_pct))
|
||||
load_kw = self.RATED_COOLING_KW * load_pct / 100.0
|
||||
|
||||
# CHW temperatures — supply held near setpoint, return rises with load
|
||||
chw_supply = self.CHW_SETPOINT + random.gauss(0, 0.15)
|
||||
chw_delta = 5.0 + (load_pct / 100.0) * 3.0 + random.gauss(0, 0.2)
|
||||
chw_return = chw_supply + chw_delta
|
||||
|
||||
# Condenser water
|
||||
cw_return = self.CW_RETURN_DESIGN - 3.0 + (load_pct / 100.0) * 4.0 + random.gauss(0, 0.5)
|
||||
cw_supply = cw_return - 5.0 + random.gauss(0, 0.3)
|
||||
|
||||
# Flow — slight variation from design
|
||||
flow_gpm = self.DESIGN_FLOW_GPM * (0.92 + random.gauss(0, 0.01))
|
||||
|
||||
# Refrigerant pressures (R134a-like)
|
||||
evap_p = 3.0 + (chw_supply / 10.0) + random.gauss(0, 0.05)
|
||||
cond_p = 12.0 + (cw_return / 10.0) + random.gauss(0, 0.1)
|
||||
|
||||
# COP
|
||||
comp_load = load_pct * 0.9 + random.gauss(0, 1.0)
|
||||
comp_power = (comp_load / 100.0) * (load_kw / 4.5) # ~4.5 COP design
|
||||
cop = load_kw / comp_power if comp_power > 0 else 0.0
|
||||
|
||||
return {
|
||||
"state": "online",
|
||||
"chw_supply_c": round(chw_supply, 2),
|
||||
"chw_return_c": round(chw_return, 2),
|
||||
"chw_delta_c": round(chw_delta, 2),
|
||||
"flow_gpm": round(flow_gpm, 0),
|
||||
"cooling_load_kw": round(load_kw, 1),
|
||||
"cooling_load_pct": round(load_pct, 1),
|
||||
"cop": round(cop, 2),
|
||||
"compressor_load_pct": round(comp_load, 1),
|
||||
"condenser_pressure_bar": round(cond_p, 2),
|
||||
"evaporator_pressure_bar": round(evap_p, 2),
|
||||
"cw_supply_c": round(cw_supply, 2),
|
||||
"cw_return_c": round(cw_return, 2),
|
||||
"run_hours": round(self._run_hours, 1),
|
||||
}
|
||||
184
simulators/bots/crac.py
Normal file
184
simulators/bots/crac.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import math
|
||||
import random
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class CracBot(BaseBot):
|
||||
"""CRAC/cooling unit monitor — full sensor set."""
|
||||
|
||||
interval = 60
|
||||
|
||||
# Physical constants
|
||||
RATED_CAPACITY_KW = 80.0
|
||||
MAX_AIRFLOW_CFM = 9_000.0
|
||||
AIR_DENSITY = 1.2 # kg/m³
|
||||
AIR_CP = 1_005.0 # J/kg·K
|
||||
MAX_FAN_RPM = 3_000
|
||||
RATED_VOLTAGE = 415.0 # V, 3-phase
|
||||
|
||||
def __init__(self, site_id: str, crac_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.crac_id = crac_id
|
||||
self._faulted = False
|
||||
self._compressor_fault = False
|
||||
self._dirty_filter = False
|
||||
# Persisted state — realistic starting values
|
||||
self._comp_hours = 12_000 + random.randint(0, 4_000)
|
||||
self._fan_hours = self._comp_hours + random.randint(500, 1_500)
|
||||
self._filter_dp_base = 28.0 + random.uniform(-5, 5)
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/cooling/{self.crac_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._faulted = (name == "COOLING_FAILURE")
|
||||
self._compressor_fault = (name == "COMPRESSOR_FAULT")
|
||||
self._dirty_filter = (name == "DIRTY_FILTER")
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
# Accumulate run hours (interval seconds → fraction of an hour)
|
||||
hour_inc = self.interval / 3_600
|
||||
self._comp_hours += hour_inc
|
||||
self._fan_hours += hour_inc
|
||||
|
||||
# ── Hard fault — unit completely offline ──────────────────────
|
||||
if self._faulted:
|
||||
return {
|
||||
"state": "fault",
|
||||
"mode": "fault",
|
||||
"setpoint": 22.0,
|
||||
"supply_temp": None, "return_temp": None,
|
||||
"supply_humidity": None, "return_humidity": None,
|
||||
"airflow_cfm": 0, "filter_dp_pa": round(self._filter_dp_base, 1),
|
||||
"cooling_capacity_kw": 0, "cooling_capacity_rated_kw": self.RATED_CAPACITY_KW,
|
||||
"cooling_capacity_pct": 0, "cop": 0, "sensible_heat_ratio": 0,
|
||||
"compressor_state": 0, "compressor_load_pct": 0,
|
||||
"compressor_power_kw": 0, "compressor_run_hours": round(self._comp_hours),
|
||||
"high_pressure_bar": 0, "low_pressure_bar": 0,
|
||||
"discharge_superheat_c": 0, "liquid_subcooling_c": 0,
|
||||
"fan_pct": 0, "fan_rpm": 0, "fan_power_kw": 0,
|
||||
"fan_run_hours": round(self._fan_hours),
|
||||
"total_unit_power_kw": 0, "input_voltage_v": self.RATED_VOLTAGE,
|
||||
"input_current_a": 0, "power_factor": 0,
|
||||
}
|
||||
|
||||
# ── Normal operating base values ──────────────────────────────
|
||||
supply = 17.5 + random.gauss(0, 0.3)
|
||||
return_temp = 28.0 + random.gauss(0, 0.5)
|
||||
fan_pct = 62.0 + random.gauss(0, 3)
|
||||
|
||||
# ── Scenarios ─────────────────────────────────────────────────
|
||||
if self._scenario == "FAN_DEGRADATION":
|
||||
fan_pct = max(22.0, 62.0 - self._scenario_step * 1.6) + random.gauss(0, 1.5)
|
||||
return_temp = 28.0 + min(self._scenario_step * 0.45, 11.0) + random.gauss(0, 0.5)
|
||||
supply = 17.5 + min(self._scenario_step * 0.08, 2.0) + random.gauss(0, 0.3)
|
||||
|
||||
fan_pct = max(10.0, min(100.0, fan_pct))
|
||||
|
||||
# ── Filter differential pressure ──────────────────────────────
|
||||
if self._dirty_filter:
|
||||
filter_dp = self._filter_dp_base + min(self._scenario_step * 4.5, 110.0)
|
||||
else:
|
||||
# Slow natural fouling (resets to base on RESET via _scenario_step=0)
|
||||
filter_dp = self._filter_dp_base + self._scenario_step * 0.01
|
||||
|
||||
# Filter fouling reduces airflow
|
||||
airflow_factor = max(0.50, 1.0 - max(0.0, filter_dp - 30) / 300.0)
|
||||
airflow_cfm = self.MAX_AIRFLOW_CFM * (fan_pct / 100.0) * airflow_factor
|
||||
airflow_m3s = airflow_cfm * 0.000_471_947
|
||||
|
||||
# ── Cooling capacity ──────────────────────────────────────────
|
||||
delta_t = return_temp - supply
|
||||
cooling_kw = airflow_m3s * self.AIR_DENSITY * self.AIR_CP * delta_t / 1_000.0
|
||||
|
||||
compressor_running = True
|
||||
if self._compressor_fault:
|
||||
# Fan-only — minimal sensible cooling only
|
||||
cooling_kw = cooling_kw * 0.08
|
||||
fan_pct = 100.0 # fans go full trying to compensate
|
||||
compressor_running = False
|
||||
|
||||
cooling_kw = max(0.0, cooling_kw)
|
||||
cooling_cap_pct = min(100.0, (cooling_kw / self.RATED_CAPACITY_KW) * 100.0)
|
||||
|
||||
# ── Compressor ────────────────────────────────────────────────
|
||||
comp_load_pct = min(100.0, (cooling_kw / self.RATED_CAPACITY_KW) * 110.0) if compressor_running else 0.0
|
||||
comp_power_kw = (comp_load_pct / 100.0) * 26.0 if compressor_running else 0.0
|
||||
|
||||
# Refrigerant pressures (R410A-like behaviour)
|
||||
if compressor_running:
|
||||
high_p = 17.5 + (comp_load_pct / 100.0) * 3.5 + random.gauss(0, 0.2)
|
||||
low_p = 5.2 - (comp_load_pct / 100.0) * 0.8 + random.gauss(0, 0.1)
|
||||
superheat = 8.0 + random.gauss(0, 0.8)
|
||||
subcooling = 4.5 + random.gauss(0, 0.4)
|
||||
else:
|
||||
# Pressures equalise when compressor is off
|
||||
high_p = low_p = 8.0 + random.gauss(0, 0.1)
|
||||
superheat = subcooling = 0.0
|
||||
|
||||
# ── Fan ───────────────────────────────────────────────────────
|
||||
fan_rpm = (fan_pct / 100.0) * self.MAX_FAN_RPM
|
||||
# Fan power: approximately cubic with speed fraction
|
||||
fan_power_kw = 3.5 * (fan_pct / 100.0) ** 3
|
||||
|
||||
# ── Electrical ────────────────────────────────────────────────
|
||||
total_power_kw = comp_power_kw + fan_power_kw + 0.4 # 0.4 kW for controls
|
||||
power_factor = max(0.85, min(0.99, 0.93 + random.gauss(0, 0.01)))
|
||||
voltage = self.RATED_VOLTAGE + random.gauss(0, 2.0)
|
||||
current_a = (total_power_kw * 1_000) / (math.sqrt(3) * voltage * power_factor) if total_power_kw > 0 else 0.0
|
||||
|
||||
# ── COP & SHR ─────────────────────────────────────────────────
|
||||
cop = (cooling_kw / total_power_kw) if total_power_kw > 0 else 0.0
|
||||
shr = max(0.0, min(1.0, 0.92 + random.gauss(0, 0.01)))
|
||||
|
||||
# ── Humidity ──────────────────────────────────────────────────
|
||||
supply_humidity = max(20.0, min(80.0, 45.0 + random.gauss(0, 2.0)))
|
||||
return_humidity = max(30.0, min(80.0, 55.0 + random.gauss(0, 2.0)))
|
||||
|
||||
# ── Operating mode ────────────────────────────────────────────
|
||||
if self._compressor_fault:
|
||||
mode = "fan_only"
|
||||
elif self._scenario == "FAN_DEGRADATION":
|
||||
mode = "cooling" # still cooling, just degraded
|
||||
else:
|
||||
mode = "cooling"
|
||||
|
||||
return {
|
||||
"state": "online",
|
||||
"mode": mode,
|
||||
"setpoint": 22.0,
|
||||
# Thermal
|
||||
"supply_temp": round(supply, 2),
|
||||
"return_temp": round(return_temp, 2),
|
||||
"supply_humidity": round(supply_humidity, 1),
|
||||
"return_humidity": round(return_humidity, 1),
|
||||
"airflow_cfm": round(airflow_cfm),
|
||||
"filter_dp_pa": round(filter_dp, 1),
|
||||
# Capacity
|
||||
"cooling_capacity_kw": round(cooling_kw, 2),
|
||||
"cooling_capacity_rated_kw": self.RATED_CAPACITY_KW,
|
||||
"cooling_capacity_pct": round(cooling_cap_pct, 1),
|
||||
"cop": round(cop, 2),
|
||||
"sensible_heat_ratio": round(shr, 2),
|
||||
# Compressor
|
||||
"compressor_state": 1 if compressor_running else 0,
|
||||
"compressor_load_pct": round(comp_load_pct, 1),
|
||||
"compressor_power_kw": round(comp_power_kw, 2),
|
||||
"compressor_run_hours": round(self._comp_hours),
|
||||
"high_pressure_bar": round(max(0.0, high_p), 2),
|
||||
"low_pressure_bar": round(max(0.0, low_p), 2),
|
||||
"discharge_superheat_c": round(max(0.0, superheat), 1),
|
||||
"liquid_subcooling_c": round(max(0.0, subcooling), 1),
|
||||
# Fan
|
||||
"fan_pct": round(fan_pct, 1),
|
||||
"fan_rpm": round(fan_rpm),
|
||||
"fan_power_kw": round(fan_power_kw, 2),
|
||||
"fan_run_hours": round(self._fan_hours),
|
||||
# Electrical
|
||||
"total_unit_power_kw": round(total_power_kw, 2),
|
||||
"input_voltage_v": round(voltage, 1),
|
||||
"input_current_a": round(current_a, 1),
|
||||
"power_factor": round(power_factor, 3),
|
||||
}
|
||||
55
simulators/bots/env_sensor.py
Normal file
55
simulators/bots/env_sensor.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import math
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class EnvSensorBot(BaseBot):
|
||||
"""Temperature + humidity sensor for a single rack."""
|
||||
|
||||
interval = 30
|
||||
|
||||
def __init__(self, site_id: str, room_id: str, rack_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.room_id = room_id
|
||||
self.rack_id = rack_id
|
||||
# Racks at the far end of a row run slightly warmer
|
||||
rack_num = int(rack_id[-2:]) if rack_id[-2:].isdigit() else 1
|
||||
self._base_temp = 21.5 + (rack_num * 0.15)
|
||||
self._base_humidity = 44.0 + random.uniform(-2, 2)
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/{self.room_id}/{self.rack_id}/env"
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
hour = datetime.now(timezone.utc).hour
|
||||
# Natural day/night cycle — slightly warmer during working hours
|
||||
day_factor = 1.0 + 0.04 * math.sin(math.pi * (hour - 6) / 12)
|
||||
|
||||
temp = (
|
||||
self._base_temp * day_factor
|
||||
+ math.sin(self._scenario_step * 0.3) * 0.2
|
||||
+ random.gauss(0, 0.15)
|
||||
)
|
||||
humidity = self._base_humidity + math.sin(self._scenario_step * 0.2) * 1.5 + random.gauss(0, 0.3)
|
||||
|
||||
# Scenarios
|
||||
if self._scenario == "COOLING_FAILURE":
|
||||
# CRAC offline — racks heat up rapidly
|
||||
temp += min(self._scenario_step * 0.4, 12.0)
|
||||
elif self._scenario == "HIGH_TEMPERATURE":
|
||||
# Gradual ambient rise (e.g. summer overload) — slower, caps lower
|
||||
temp += min(self._scenario_step * 0.2, 6.0)
|
||||
elif self._scenario == "HUMIDITY_SPIKE":
|
||||
# Condensation / humidifier fault
|
||||
humidity += min(self._scenario_step * 1.5, 28.0)
|
||||
elif self._scenario == "SLOW_BURN":
|
||||
# Dirty filter starving airflow — temp and humidity both climb together
|
||||
temp += min(self._scenario_step * 0.15, 7.0)
|
||||
humidity += min(self._scenario_step * 0.8, 22.0)
|
||||
|
||||
return {
|
||||
"temperature": round(max(18.0, temp), 2),
|
||||
"humidity": round(max(30.0, min(80.0, humidity)), 1),
|
||||
}
|
||||
128
simulators/bots/generator.py
Normal file
128
simulators/bots/generator.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import random
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class GeneratorBot(BaseBot):
|
||||
"""Diesel generator — normally on standby, runs on GENERATOR_FAILURE / ATS_TRANSFER scenarios."""
|
||||
|
||||
interval = 60
|
||||
|
||||
RATED_KW = 500.0
|
||||
TANK_LITRES = 2_000.0 # full tank
|
||||
BURN_RATE_LH = 90.0 # litres/hour at full load (~45 l/h at 50% load)
|
||||
|
||||
def __init__(self, site_id: str, gen_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.gen_id = gen_id
|
||||
self._running = False
|
||||
self._testing = False
|
||||
self._fault = False
|
||||
self._fuel_l = self.TANK_LITRES * (0.75 + random.uniform(-0.05, 0.10))
|
||||
self._run_hours = 1_200 + random.randint(0, 400)
|
||||
self._load_kw = 0.0
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/generator/{self.gen_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._running = name in ("GENERATOR_FAILURE", "ATS_TRANSFER")
|
||||
self._testing = (name == "GENERATOR_TEST")
|
||||
self._fault = (name == "GENERATOR_FAULT")
|
||||
if name is None:
|
||||
self._running = False
|
||||
self._testing = False
|
||||
self._fault = False
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
hour_inc = self.interval / 3_600
|
||||
|
||||
if self._fault:
|
||||
return {
|
||||
"state": "fault",
|
||||
"fuel_pct": round(self._fuel_l / self.TANK_LITRES * 100, 1),
|
||||
"fuel_litres": round(self._fuel_l, 0),
|
||||
"fuel_rate_lph": 0.0,
|
||||
"load_kw": 0.0,
|
||||
"load_pct": 0.0,
|
||||
"run_hours": round(self._run_hours, 1),
|
||||
"voltage_v": 0.0,
|
||||
"frequency_hz": 0.0,
|
||||
"engine_rpm": 0.0,
|
||||
"oil_pressure_bar": 0.0,
|
||||
"coolant_temp_c": 0.0,
|
||||
"exhaust_temp_c": 28.0,
|
||||
"alternator_temp_c": 30.0,
|
||||
"power_factor": 0.0,
|
||||
"battery_v": 0.0,
|
||||
}
|
||||
|
||||
if self._running or self._testing:
|
||||
self._run_hours += hour_inc
|
||||
target_load = (
|
||||
self.RATED_KW * (0.55 + random.gauss(0, 0.02))
|
||||
if self._running
|
||||
else self.RATED_KW * (0.20 + random.gauss(0, 0.01))
|
||||
)
|
||||
self._load_kw = target_load
|
||||
load_frac = self._load_kw / self.RATED_KW
|
||||
# Burn fuel
|
||||
burn = self.BURN_RATE_LH * load_frac * hour_inc
|
||||
self._fuel_l = max(0.0, self._fuel_l - burn)
|
||||
|
||||
# Low fuel scenario
|
||||
if self._scenario == "GENERATOR_LOW_FUEL":
|
||||
self._fuel_l = max(0.0, self._fuel_l - 8.0)
|
||||
|
||||
state = "running" if self._running else "test"
|
||||
voltage = 415.0 + random.gauss(0, 1.5)
|
||||
frequency = 50.0 + random.gauss(0, 0.05)
|
||||
oil_p = 4.2 + random.gauss(0, 0.1)
|
||||
coolant = 78.0 + random.gauss(0, 2.0)
|
||||
battery_v = 24.3 + random.gauss(0, 0.1)
|
||||
# 1500 RPM at 50 Hz; slight droop under load
|
||||
engine_rpm = 1500.0 - (load_frac * 8.0) + random.gauss(0, 2.0)
|
||||
# Exhaust rises with load: ~180°C at idle, ~430°C at full load
|
||||
exhaust_temp = 180.0 + load_frac * 250.0 + random.gauss(0, 5.0)
|
||||
# Alternator winding temperature
|
||||
alt_temp = 45.0 + load_frac * 35.0 + random.gauss(0, 1.5)
|
||||
# Power factor typical for resistive/inductive DC loads
|
||||
pf = 0.87 + load_frac * 0.05 + random.gauss(0, 0.005)
|
||||
pf = round(min(0.99, max(0.80, pf)), 3)
|
||||
else:
|
||||
self._load_kw = 0.0
|
||||
load_frac = 0.0
|
||||
state = "standby"
|
||||
voltage = 0.0
|
||||
frequency = 0.0
|
||||
oil_p = 0.0
|
||||
coolant = 25.0 + random.gauss(0, 1.0)
|
||||
battery_v = 27.1 + random.gauss(0, 0.1) # trickle-charged standby battery
|
||||
engine_rpm = 0.0
|
||||
exhaust_temp = 28.0 + random.gauss(0, 1.0) # ambient exhaust stack temp
|
||||
alt_temp = 30.0 + random.gauss(0, 0.5)
|
||||
pf = 0.0
|
||||
|
||||
fuel_pct = min(100.0, self._fuel_l / self.TANK_LITRES * 100)
|
||||
# Actual fuel consumption rate for this interval
|
||||
fuel_rate = self.BURN_RATE_LH * load_frac if load_frac > 0 else 0.0
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"fuel_pct": round(fuel_pct, 1),
|
||||
"fuel_litres": round(self._fuel_l, 0),
|
||||
"fuel_rate_lph": round(fuel_rate, 1),
|
||||
"load_kw": round(self._load_kw, 1),
|
||||
"load_pct": round(load_frac * 100, 1),
|
||||
"run_hours": round(self._run_hours, 1),
|
||||
"voltage_v": round(voltage, 1),
|
||||
"frequency_hz": round(frequency, 2),
|
||||
"engine_rpm": round(engine_rpm, 0),
|
||||
"oil_pressure_bar": round(oil_p, 2),
|
||||
"coolant_temp_c": round(coolant, 1),
|
||||
"exhaust_temp_c": round(exhaust_temp, 1),
|
||||
"alternator_temp_c": round(alt_temp, 1),
|
||||
"power_factor": pf,
|
||||
"battery_v": round(battery_v, 2),
|
||||
}
|
||||
75
simulators/bots/network.py
Normal file
75
simulators/bots/network.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import random
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class NetworkBot(BaseBot):
|
||||
"""Network switch — publishes live port/bandwidth/CPU/health data."""
|
||||
|
||||
interval = 60
|
||||
|
||||
def __init__(self, site_id: str, switch_id: str, port_count: int = 48) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.switch_id = switch_id
|
||||
self.port_count = port_count
|
||||
self._uptime_s = random.randint(30 * 86400, 180 * 86400) # 30–180 day uptime
|
||||
self._active_ports = int(port_count * random.uniform(0.65, 0.85))
|
||||
self._down = False
|
||||
self._degraded = False
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/network/{self.switch_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._down = (name == "SWITCH_DOWN")
|
||||
self._degraded = (name == "LINK_FAULT")
|
||||
if name is None:
|
||||
self._down = False
|
||||
self._degraded = False
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
self._uptime_s += self.interval
|
||||
|
||||
if self._down:
|
||||
return {
|
||||
"state": "down",
|
||||
"uptime_s": 0,
|
||||
"port_count": self.port_count,
|
||||
"active_ports": 0,
|
||||
"bandwidth_in_mbps": 0.0,
|
||||
"bandwidth_out_mbps": 0.0,
|
||||
"cpu_pct": 0.0,
|
||||
"mem_pct": 0.0,
|
||||
"temperature_c": 0.0,
|
||||
"packet_loss_pct": 100.0,
|
||||
}
|
||||
|
||||
if self._degraded:
|
||||
active = max(1, int(self._active_ports * 0.5))
|
||||
pkt_loss = round(random.uniform(5.0, 25.0), 2)
|
||||
else:
|
||||
active = self._active_ports + random.randint(-1, 1)
|
||||
active = max(1, min(self.port_count, active))
|
||||
self._active_ports = active
|
||||
pkt_loss = round(random.uniform(0.0, 0.05), 3)
|
||||
|
||||
util = active / self.port_count
|
||||
bw_in = round(util * random.uniform(200, 800) + random.gauss(0, 10), 1)
|
||||
bw_out = round(util * random.uniform(150, 600) + random.gauss(0, 8), 1)
|
||||
cpu = round(max(1.0, min(95.0, util * 40 + random.gauss(0, 3))), 1)
|
||||
mem = round(max(10.0, min(90.0, 30 + util * 20 + random.gauss(0, 2))), 1)
|
||||
temp = round(35.0 + util * 20 + random.gauss(0, 1.5), 1)
|
||||
|
||||
return {
|
||||
"state": "degraded" if self._degraded else "up",
|
||||
"uptime_s": self._uptime_s,
|
||||
"port_count": self.port_count,
|
||||
"active_ports": active,
|
||||
"bandwidth_in_mbps": max(0.0, bw_in),
|
||||
"bandwidth_out_mbps": max(0.0, bw_out),
|
||||
"cpu_pct": cpu,
|
||||
"mem_pct": mem,
|
||||
"temperature_c": temp,
|
||||
"packet_loss_pct": pkt_loss,
|
||||
}
|
||||
41
simulators/bots/particles.py
Normal file
41
simulators/bots/particles.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import math
|
||||
import random
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
# ISO 14644-1 Class 8 limits
|
||||
ISO8_0_5UM = 3_520_000 # particles/m³ ≥0.5 µm
|
||||
ISO8_5UM = 29_300 # particles/m³ ≥5 µm
|
||||
|
||||
|
||||
class ParticleBot(BaseBot):
|
||||
"""Air quality / particle count sensor — one per room."""
|
||||
|
||||
interval = 60 # publish every 60 s
|
||||
|
||||
def __init__(self, site_id: str, room_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.room_id = room_id
|
||||
# Normal operating baseline — well below ISO 8
|
||||
self._base_0_5 = random.uniform(60_000, 120_000)
|
||||
self._base_5 = random.uniform(200, 600)
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/{self.room_id}/particles"
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
drift = math.sin(self._scenario_step * 0.05) * 0.1
|
||||
|
||||
p0_5 = self._base_0_5 * (1 + drift) + random.gauss(0, 3_000)
|
||||
p5 = self._base_5 * (1 + drift) + random.gauss(0, 20)
|
||||
|
||||
if self._scenario == "PARTICLE_SPIKE":
|
||||
spike = min(self._scenario_step * 50_000, ISO8_0_5UM * 1.5)
|
||||
p0_5 += spike
|
||||
p5 += spike * (ISO8_5UM / ISO8_0_5UM)
|
||||
|
||||
return {
|
||||
"particles_0_5um": max(0, round(p0_5)),
|
||||
"particles_5um": max(0, round(p5)),
|
||||
}
|
||||
87
simulators/bots/pdu.py
Normal file
87
simulators/bots/pdu.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import math
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from bots.base import BaseBot
|
||||
|
||||
VOLTAGE_LINE = 230.0 # line-to-neutral (V)
|
||||
|
||||
|
||||
class PduBot(BaseBot):
|
||||
"""PDU power monitor for a single rack — rack total + per-phase breakdown."""
|
||||
|
||||
interval = 60
|
||||
|
||||
def __init__(self, site_id: str, room_id: str, rack_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.room_id = room_id
|
||||
self.rack_id = rack_id
|
||||
rack_num = int(rack_id[-2:]) if rack_id[-2:].isdigit() else 1
|
||||
# Base load varies per rack (2 – 6 kW)
|
||||
self._base_load = 2.0 + (rack_num % 5) * 0.8
|
||||
# Phase split offsets — each rack has a slightly different natural imbalance
|
||||
self._phase_bias = [1.0 + random.gauss(0, 0.04) for _ in range(3)]
|
||||
self._imbalanced = False
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/{self.room_id}/{self.rack_id}/power"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._imbalanced = (name == "PHASE_IMBALANCE")
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
hour = datetime.now(timezone.utc).hour
|
||||
# Higher load during business hours (8–20)
|
||||
biz_factor = 1.0 + 0.25 * math.sin(math.pi * max(0, hour - 8) / 12) if 8 <= hour <= 20 else 0.85
|
||||
|
||||
load = self._base_load * biz_factor + random.gauss(0, 0.1)
|
||||
|
||||
if self._scenario == "POWER_SPIKE":
|
||||
load *= 1.0 + min(self._scenario_step * 0.08, 0.5)
|
||||
elif self._scenario == "RACK_OVERLOAD":
|
||||
load = 8.5 + random.gauss(0, 0.3)
|
||||
|
||||
load = round(max(0.5, load), 2)
|
||||
|
||||
# ── Per-phase breakdown ───────────────────────────────────────
|
||||
if self._imbalanced:
|
||||
# Drive phase A much higher, C drops
|
||||
bias = [
|
||||
1.0 + min(self._scenario_step * 0.06, 0.50), # phase A surges
|
||||
1.0 + random.gauss(0, 0.02), # phase B stable
|
||||
max(0.3, 1.0 - min(self._scenario_step * 0.04, 0.40)), # phase C drops
|
||||
]
|
||||
else:
|
||||
bias = [b + random.gauss(0, 0.015) for b in self._phase_bias]
|
||||
|
||||
bias_total = sum(bias)
|
||||
phase_kw = [load * (b / bias_total) for b in bias]
|
||||
phase_a_kw, phase_b_kw, phase_c_kw = phase_kw
|
||||
|
||||
# Current per phase (I = P / V, single-phase)
|
||||
pf = 0.93 + random.gauss(0, 0.01)
|
||||
def amps(kw: float) -> float:
|
||||
return kw * 1000 / (VOLTAGE_LINE * pf)
|
||||
|
||||
phase_a_a = amps(phase_a_kw)
|
||||
phase_b_a = amps(phase_b_kw)
|
||||
phase_c_a = amps(phase_c_kw)
|
||||
|
||||
# Phase imbalance % = (max - min) / avg * 100
|
||||
currents = [phase_a_a, phase_b_a, phase_c_a]
|
||||
avg_a = sum(currents) / 3
|
||||
imbalance = (max(currents) - min(currents)) / avg_a * 100 if avg_a > 0 else 0.0
|
||||
|
||||
return {
|
||||
"load_kw": load,
|
||||
"phase_a_kw": round(phase_a_kw, 2),
|
||||
"phase_b_kw": round(phase_b_kw, 2),
|
||||
"phase_c_kw": round(phase_c_kw, 2),
|
||||
"phase_a_a": round(phase_a_a, 1),
|
||||
"phase_b_a": round(phase_b_a, 1),
|
||||
"phase_c_a": round(phase_c_a, 1),
|
||||
"imbalance_pct": round(imbalance, 1),
|
||||
"power_factor": round(pf, 3),
|
||||
"voltage_v": round(VOLTAGE_LINE + random.gauss(0, 0.5), 1),
|
||||
}
|
||||
54
simulators/bots/ups.py
Normal file
54
simulators/bots/ups.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import random
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class UpsBot(BaseBot):
|
||||
"""UPS unit monitor."""
|
||||
|
||||
interval = 60
|
||||
|
||||
def __init__(self, site_id: str, ups_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.ups_id = ups_id
|
||||
self._charge = 94.0 + random.uniform(-3, 3)
|
||||
self._on_battery = False
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/power/{self.ups_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
if name in ("UPS_MAINS_FAILURE", "UPS_OVERLOAD"):
|
||||
self._on_battery = True
|
||||
elif name in (None, "RESET"):
|
||||
self._on_battery = False
|
||||
self._charge = 94.0
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
overload = (self._scenario == "UPS_OVERLOAD")
|
||||
|
||||
if self._on_battery:
|
||||
# Overload drains battery ~4x faster — carrying full site load with no mains
|
||||
drain = random.uniform(3.0, 5.0) if overload else random.uniform(0.8, 1.5)
|
||||
self._charge = max(5.0, self._charge - drain)
|
||||
state = "overload" if overload else "on_battery"
|
||||
runtime = max(1, int((self._charge / 100) * (15 if overload else 60)))
|
||||
else:
|
||||
# Trickle charge back up when on mains
|
||||
self._charge = min(100.0, self._charge + random.uniform(0, 0.2))
|
||||
state = "online"
|
||||
runtime = int((self._charge / 100) * 60)
|
||||
|
||||
if overload:
|
||||
load_pct = min(99.0, 88.0 + self._scenario_step * 1.2 + random.gauss(0, 1.5))
|
||||
else:
|
||||
load_pct = 62.0 + random.gauss(0, 2)
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"charge_pct": round(self._charge, 1),
|
||||
"load_pct": round(load_pct, 1),
|
||||
"runtime_min": runtime,
|
||||
"voltage": round(229.5 + random.gauss(0, 0.5), 1),
|
||||
}
|
||||
66
simulators/bots/vesda.py
Normal file
66
simulators/bots/vesda.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import random
|
||||
from bots.base import BaseBot
|
||||
|
||||
|
||||
# VESDA alarm level thresholds (obscuration % per metre)
|
||||
LEVELS = [
|
||||
("normal", 0.0, 0.08),
|
||||
("alert", 0.08, 0.20),
|
||||
("action", 0.20, 0.50),
|
||||
("fire", 0.50, 1.00),
|
||||
]
|
||||
|
||||
|
||||
def _level(obscuration: float) -> str:
|
||||
for name, lo, hi in LEVELS:
|
||||
if lo <= obscuration < hi:
|
||||
return name
|
||||
return "fire"
|
||||
|
||||
|
||||
class VesdaBot(BaseBot):
|
||||
"""VESDA aspirating smoke detector for a fire zone."""
|
||||
|
||||
interval = 30
|
||||
|
||||
def __init__(self, site_id: str, zone_id: str, room_id: str) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.zone_id = zone_id
|
||||
self.room_id = room_id
|
||||
self._base_obscuration = random.uniform(0.005, 0.015) # normal background
|
||||
self._alert = False
|
||||
self._fire = False
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/fire/{self.zone_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._alert = (name == "VESDA_ALERT")
|
||||
self._fire = (name == "VESDA_FIRE")
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
if self._fire:
|
||||
# Escalate rapidly to fire level
|
||||
obs = min(0.85, 0.50 + self._scenario_step * 0.03) + random.gauss(0, 0.01)
|
||||
elif self._alert:
|
||||
# Sit in alert/action band
|
||||
obs = min(0.45, 0.08 + self._scenario_step * 0.012) + random.gauss(0, 0.005)
|
||||
else:
|
||||
# Normal background with tiny random drift
|
||||
obs = self._base_obscuration + random.gauss(0, 0.002)
|
||||
obs = max(0.001, obs)
|
||||
|
||||
level = _level(obs)
|
||||
|
||||
return {
|
||||
"zone_id": self.zone_id,
|
||||
"room_id": self.room_id,
|
||||
"level": level,
|
||||
"obscuration_pct_m": round(obs * 100, 3),
|
||||
"detector_1_ok": True,
|
||||
"detector_2_ok": True,
|
||||
"power_ok": True,
|
||||
"flow_ok": not self._fire, # flow sensor trips in severe fire
|
||||
}
|
||||
41
simulators/bots/water_leak.py
Normal file
41
simulators/bots/water_leak.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from bots.base import BaseBot
|
||||
|
||||
|
||||
class WaterLeakBot(BaseBot):
|
||||
"""Water leak sensor — normally clear, triggers on LEAK_DETECTED scenario."""
|
||||
|
||||
interval = 120
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
site_id: str,
|
||||
sensor_id: str,
|
||||
floor_zone: str = "general",
|
||||
under_floor: bool = False,
|
||||
near_crac: bool = False,
|
||||
room_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.site_id = site_id
|
||||
self.sensor_id = sensor_id
|
||||
self.floor_zone = floor_zone
|
||||
self.under_floor = under_floor
|
||||
self.near_crac = near_crac
|
||||
self.room_id = room_id
|
||||
self._leaked = False
|
||||
|
||||
def get_topic(self) -> str:
|
||||
return f"bms/{self.site_id}/leak/{self.sensor_id}"
|
||||
|
||||
def set_scenario(self, name: str | None) -> None:
|
||||
super().set_scenario(name)
|
||||
self._leaked = (name == "LEAK_DETECTED")
|
||||
|
||||
def get_payload(self) -> dict:
|
||||
return {
|
||||
"state": "detected" if self._leaked else "clear",
|
||||
"floor_zone": self.floor_zone,
|
||||
"under_floor": self.under_floor,
|
||||
"near_crac": self.near_crac,
|
||||
"room_id": self.room_id,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue