first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View file

52
simulators/bots/ats.py Normal file
View 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 80200 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
View 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)

View 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
View 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),
}

View 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),
}

View 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),
}

View 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) # 30180 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,
}

View 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
View 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 (820)
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
View 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
View 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
}

View 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,
}