first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
7
simulators/Dockerfile
Normal file
7
simulators/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN chmod +x entrypoint.sh
|
||||
CMD ["./entrypoint.sh"]
|
||||
84
simulators/backfill_new_racks.py
Normal file
84
simulators/backfill_new_racks.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
One-shot backfill: inserts 30 minutes of historical readings for all new racks.
|
||||
Only inserts for rack_ids that have zero existing readings — safe to run multiple times.
|
||||
"""
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://dcim:dcim_pass@localhost:5432/dcim")
|
||||
SEED_MINUTES = 30
|
||||
INTERVAL_MINS = 5
|
||||
SITE_ID = "sg-01"
|
||||
|
||||
ROOMS = [
|
||||
{"room_id": "hall-a", "racks": [f"SG1A01.{i:02d}" for i in range(1, 21)] + [f"SG1A02.{i:02d}" for i in range(1, 21)]},
|
||||
{"room_id": "hall-b", "racks": [f"SG1B01.{i:02d}" for i in range(1, 21)] + [f"SG1B02.{i:02d}" for i in range(1, 21)]},
|
||||
]
|
||||
|
||||
|
||||
async def backfill() -> None:
|
||||
url = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
|
||||
print("Backfill: connecting to database...")
|
||||
conn = await asyncpg.connect(url)
|
||||
|
||||
try:
|
||||
rows: list[tuple] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for room in ROOMS:
|
||||
room_id = room["room_id"]
|
||||
for rack_id in room["racks"]:
|
||||
# Skip if this rack already has readings
|
||||
count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM readings WHERE site_id = $1 AND rack_id = $2",
|
||||
SITE_ID, rack_id,
|
||||
)
|
||||
if count > 0:
|
||||
print(f" Skipping {rack_id} ({count} rows exist)")
|
||||
continue
|
||||
|
||||
num = int(rack_id[-2:])
|
||||
base_temp = 21.5 + num * 0.15
|
||||
base_load = 2.0 + (num % 5) * 0.8
|
||||
base_id = f"{SITE_ID}/{room_id}/{rack_id}"
|
||||
|
||||
for minutes_ago in range(SEED_MINUTES, -1, -INTERVAL_MINS):
|
||||
t = now - timedelta(minutes=minutes_ago)
|
||||
hour = t.hour
|
||||
day_factor = 1.0 + 0.04 * math.sin(math.pi * (hour - 6) / 12)
|
||||
biz_factor = (1.0 + 0.25 * math.sin(math.pi * max(0, hour - 8) / 12)
|
||||
if 8 <= hour <= 20 else 0.85)
|
||||
|
||||
temp = base_temp * day_factor + random.gauss(0, 0.2)
|
||||
humidity = 44.0 + random.gauss(0, 1.0)
|
||||
load = base_load * biz_factor + random.gauss(0, 0.1)
|
||||
|
||||
rows += [
|
||||
(t, f"{base_id}/temperature", "temperature", SITE_ID, room_id, rack_id, round(temp, 2), "°C"),
|
||||
(t, f"{base_id}/humidity", "humidity", SITE_ID, room_id, rack_id, round(humidity, 1), "%"),
|
||||
(t, f"{base_id}/power_kw", "power_kw", SITE_ID, room_id, rack_id, round(max(0.5, load), 2), "kW"),
|
||||
]
|
||||
print(f" Queued {rack_id}")
|
||||
|
||||
if not rows:
|
||||
print("Backfill: nothing to insert — all racks already have data.")
|
||||
return
|
||||
|
||||
await conn.executemany("""
|
||||
INSERT INTO readings
|
||||
(recorded_at, sensor_id, sensor_type, site_id, room_id, rack_id, value, unit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""", rows)
|
||||
print(f"Backfill: inserted {len(rows)} readings across {len(rows) // (SEED_MINUTES // INTERVAL_MINS + 1) // 3} racks. Done.")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(backfill())
|
||||
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,
|
||||
}
|
||||
61
simulators/config.py
Normal file
61
simulators/config.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import os
|
||||
|
||||
MQTT_HOST = os.getenv("MQTT_HOST", "localhost")
|
||||
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||
|
||||
# The simulated data center topology
|
||||
TOPOLOGY = {
|
||||
"site_id": "sg-01",
|
||||
"rooms": [
|
||||
{
|
||||
"room_id": "hall-a",
|
||||
"racks": [f"SG1A01.{i:02d}" for i in range(1, 21)] + [f"SG1A02.{i:02d}" for i in range(1, 21)],
|
||||
"crac_id": "crac-01",
|
||||
},
|
||||
{
|
||||
"room_id": "hall-b",
|
||||
"racks": [f"SG1B01.{i:02d}" for i in range(1, 21)] + [f"SG1B02.{i:02d}" for i in range(1, 21)],
|
||||
"crac_id": "crac-02",
|
||||
},
|
||||
],
|
||||
"ups_units": ["ups-01", "ups-02"],
|
||||
"generators": ["gen-01"],
|
||||
"ats_units": ["ats-01"],
|
||||
"chillers": ["chiller-01"],
|
||||
"vesda_zones": [
|
||||
{"zone_id": "vesda-hall-a", "room_id": "hall-a"},
|
||||
{"zone_id": "vesda-hall-b", "room_id": "hall-b"},
|
||||
],
|
||||
"leak_sensors": [
|
||||
{
|
||||
"sensor_id": "leak-01",
|
||||
"floor_zone": "crac-zone-a",
|
||||
"under_floor": True,
|
||||
"near_crac": True,
|
||||
"room_id": "hall-a",
|
||||
},
|
||||
{
|
||||
"sensor_id": "leak-02",
|
||||
"floor_zone": "server-row-b1",
|
||||
"under_floor": True,
|
||||
"near_crac": False,
|
||||
"room_id": "hall-b",
|
||||
},
|
||||
{
|
||||
"sensor_id": "leak-03",
|
||||
"floor_zone": "ups-room",
|
||||
"under_floor": False,
|
||||
"near_crac": False,
|
||||
"room_id": None,
|
||||
},
|
||||
],
|
||||
"switches": [
|
||||
{"switch_id": "sw-core-01", "name": "Core Switch — Hall A", "model": "Cisco Catalyst C9300-48P", "room_id": "hall-a", "rack_id": "SG1A01.01", "port_count": 48, "role": "core"},
|
||||
{"switch_id": "sw-core-02", "name": "Core Switch — Hall B", "model": "Arista 7050CX3-32S", "room_id": "hall-b", "rack_id": "SG1B01.01", "port_count": 32, "role": "core"},
|
||||
{"switch_id": "sw-edge-01", "name": "Edge / Uplink Switch", "model": "Juniper EX4300-48T", "room_id": "hall-a", "rack_id": "SG1A01.05", "port_count": 48, "role": "edge"},
|
||||
],
|
||||
"particle_sensors": [
|
||||
{"room_id": "hall-a"},
|
||||
{"room_id": "hall-b"},
|
||||
],
|
||||
}
|
||||
5
simulators/entrypoint.sh
Executable file
5
simulators/entrypoint.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
echo "Running seeder..."
|
||||
python seed.py
|
||||
echo "Starting simulator bots..."
|
||||
exec python main.py
|
||||
182
simulators/main.py
Normal file
182
simulators/main.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from config import MQTT_HOST, MQTT_PORT, TOPOLOGY
|
||||
from scenarios.compound import CompoundOrchestrator, COMPOUND_SCENARIOS
|
||||
from bots.env_sensor import EnvSensorBot
|
||||
from bots.pdu import PduBot
|
||||
from bots.ups import UpsBot
|
||||
from bots.crac import CracBot
|
||||
from bots.water_leak import WaterLeakBot
|
||||
from bots.generator import GeneratorBot
|
||||
from bots.ats import AtsBot
|
||||
from bots.chiller import ChillerBot
|
||||
from bots.vesda import VesdaBot
|
||||
from bots.network import NetworkBot
|
||||
from bots.particles import ParticleBot
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_bots() -> tuple[list, dict]:
|
||||
"""Instantiate all bots. Returns (list, key→bot dict for scenario targeting).
|
||||
|
||||
Keys include individual device IDs AND room IDs (mapped to all bots in that room).
|
||||
"""
|
||||
bots = []
|
||||
by_key: dict[str, object | list] = {}
|
||||
site_id = TOPOLOGY["site_id"]
|
||||
|
||||
for room in TOPOLOGY["rooms"]:
|
||||
room_id = room["room_id"]
|
||||
room_bots: list = []
|
||||
|
||||
for rack_id in room["racks"]:
|
||||
env = EnvSensorBot(site_id, room_id, rack_id)
|
||||
pdu = PduBot(site_id, room_id, rack_id)
|
||||
bots += [env, pdu]
|
||||
by_key[rack_id] = env
|
||||
by_key[f"{rack_id}/pdu"] = pdu
|
||||
room_bots += [env, pdu]
|
||||
|
||||
crac = CracBot(site_id, room["crac_id"])
|
||||
bots.append(crac)
|
||||
by_key[room["crac_id"]] = crac
|
||||
room_bots.append(crac)
|
||||
|
||||
# Room-level targeting: hall-a targets ALL bots; /env and /pdu target subsets
|
||||
by_key[room_id] = room_bots
|
||||
by_key[f"{room_id}/env"] = [b for b in room_bots if isinstance(b, EnvSensorBot)]
|
||||
by_key[f"{room_id}/pdu"] = [b for b in room_bots if isinstance(b, PduBot)]
|
||||
|
||||
for ups_id in TOPOLOGY["ups_units"]:
|
||||
ups = UpsBot(site_id, ups_id)
|
||||
bots.append(ups)
|
||||
by_key[ups_id] = ups
|
||||
|
||||
for gen_id in TOPOLOGY.get("generators", []):
|
||||
gen = GeneratorBot(site_id, gen_id)
|
||||
bots.append(gen)
|
||||
by_key[gen_id] = gen
|
||||
|
||||
for ats_id in TOPOLOGY.get("ats_units", []):
|
||||
ats = AtsBot(site_id, ats_id)
|
||||
bots.append(ats)
|
||||
by_key[ats_id] = ats
|
||||
|
||||
for chiller_id in TOPOLOGY.get("chillers", []):
|
||||
chiller = ChillerBot(site_id, chiller_id)
|
||||
bots.append(chiller)
|
||||
by_key[chiller_id] = chiller
|
||||
|
||||
for zone in TOPOLOGY.get("vesda_zones", []):
|
||||
vesda = VesdaBot(site_id, zone["zone_id"], zone["room_id"])
|
||||
bots.append(vesda)
|
||||
by_key[zone["zone_id"]] = vesda
|
||||
|
||||
for leak_cfg in TOPOLOGY.get("leak_sensors", []):
|
||||
# Support both old string format and new dict format
|
||||
if isinstance(leak_cfg, str):
|
||||
leak = WaterLeakBot(site_id, leak_cfg)
|
||||
by_key[leak_cfg] = leak
|
||||
else:
|
||||
leak = WaterLeakBot(
|
||||
site_id,
|
||||
leak_cfg["sensor_id"],
|
||||
floor_zone=leak_cfg.get("floor_zone", "general"),
|
||||
under_floor=leak_cfg.get("under_floor", False),
|
||||
near_crac=leak_cfg.get("near_crac", False),
|
||||
room_id=leak_cfg.get("room_id"),
|
||||
)
|
||||
by_key[leak_cfg["sensor_id"]] = leak
|
||||
bots.append(leak)
|
||||
|
||||
for sw_cfg in TOPOLOGY.get("switches", []):
|
||||
sw = NetworkBot(site_id, sw_cfg["switch_id"], port_count=sw_cfg.get("port_count", 48))
|
||||
bots.append(sw)
|
||||
by_key[sw_cfg["switch_id"]] = sw
|
||||
|
||||
for ps_cfg in TOPOLOGY.get("particle_sensors", []):
|
||||
ps = ParticleBot(site_id, ps_cfg["room_id"])
|
||||
bots.append(ps)
|
||||
by_key[f"particles-{ps_cfg['room_id']}"] = ps
|
||||
|
||||
return bots, by_key
|
||||
|
||||
|
||||
async def listen_scenarios(
|
||||
client: aiomqtt.Client,
|
||||
by_key: dict,
|
||||
orchestrator: CompoundOrchestrator,
|
||||
) -> None:
|
||||
"""Listen for scenario control messages and apply them to bots."""
|
||||
await client.subscribe("bms/control/scenario")
|
||||
async for message in client.messages:
|
||||
if "control/scenario" not in str(message.topic):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(message.payload.decode())
|
||||
scenario = data.get("scenario")
|
||||
target = data.get("target") # rack_id, crac_id, ups_id — or None for all
|
||||
|
||||
# Compound scenarios are handled by the orchestrator
|
||||
if scenario in COMPOUND_SCENARIOS:
|
||||
orchestrator.trigger(scenario)
|
||||
continue
|
||||
|
||||
if scenario == "RESET":
|
||||
orchestrator.reset()
|
||||
continue
|
||||
|
||||
if target:
|
||||
entry = by_key.get(target)
|
||||
if entry is None:
|
||||
logger.warning(f"Unknown target '{target}'")
|
||||
elif isinstance(entry, list):
|
||||
for bot in entry:
|
||||
bot.set_scenario(scenario)
|
||||
logger.info(f"Scenario '{scenario}' applied to {len(entry)} bots in {target}")
|
||||
else:
|
||||
entry.set_scenario(scenario)
|
||||
logger.info(f"Scenario '{scenario}' applied to {target}")
|
||||
else:
|
||||
# Collect unique bot instances (by_key also contains room-level lists)
|
||||
all_bots: set = set()
|
||||
for v in by_key.values():
|
||||
if isinstance(v, list):
|
||||
all_bots.update(v)
|
||||
else:
|
||||
all_bots.add(v)
|
||||
for bot in all_bots:
|
||||
bot.set_scenario(scenario)
|
||||
logger.info(f"Scenario '{scenario}' applied to {len(all_bots)} bots")
|
||||
except Exception as e:
|
||||
logger.error(f"Scenario message error: {e}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
bots, by_key = build_bots()
|
||||
orchestrator = CompoundOrchestrator(by_key)
|
||||
logger.info(f"Built {len(bots)} simulator bots for site {TOPOLOGY['site_id']}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with aiomqtt.Client(MQTT_HOST, port=MQTT_PORT) as client:
|
||||
logger.info(f"Connected to MQTT at {MQTT_HOST}:{MQTT_PORT}")
|
||||
tasks = [asyncio.create_task(bot.run(client)) for bot in bots]
|
||||
tasks.append(asyncio.create_task(listen_scenarios(client, by_key, orchestrator)))
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as e:
|
||||
logger.error(f"Connection lost: {e} — reconnecting in 5s")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
2
simulators/requirements.txt
Normal file
2
simulators/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
aiomqtt==2.3.0
|
||||
asyncpg==0.30.0
|
||||
0
simulators/scenarios/__init__.py
Normal file
0
simulators/scenarios/__init__.py
Normal file
163
simulators/scenarios/compound.py
Normal file
163
simulators/scenarios/compound.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
Compound (multi-bot, time-sequenced) scenarios.
|
||||
|
||||
Each step is a tuple: (delay_seconds, target_key, scenario_name_or_None)
|
||||
|
||||
Delays are wall-clock seconds from when trigger() is called.
|
||||
Fine-grained room sub-keys (hall-a/env, hall-a/pdu, etc.) are registered
|
||||
by build_bots() in main.py so individual device classes can be targeted
|
||||
without disturbing other bots in the same room.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Scenario 1: Hot Night ────────────────────────────────────────────────────
|
||||
# CRAC-01 compressor trips silently. The backup unit overworks itself.
|
||||
# Rack temps climb, servers spin fans harder, power draw rises, smoke follows.
|
||||
HOT_NIGHT = [
|
||||
# t=0 CRAC-01 compressor trips — cooling capacity gone
|
||||
( 0, "crac-01", "COOLING_FAILURE"),
|
||||
# t=2m Rack inlet temps start climbing
|
||||
( 120, "hall-a/env", "COOLING_FAILURE"),
|
||||
# t=5m Servers spin fans faster → PDU draw rises across the room
|
||||
( 300, "hall-a/pdu", "POWER_SPIKE"),
|
||||
# t=8m CRAC-02 overworked — fan bearing begins degrading
|
||||
( 480, "crac-02", "FAN_DEGRADATION"),
|
||||
# t=10m Hot dust particles rising — VESDA alert fires
|
||||
( 600, "vesda-hall-a", "VESDA_ALERT"),
|
||||
]
|
||||
|
||||
|
||||
# ── Scenario 2: Generator Test Gone Wrong ────────────────────────────────────
|
||||
# Planned ATS transfer to generator. Generator was low on fuel (not checked).
|
||||
# It runs for 10 minutes then faults. UPS must carry the full site load with
|
||||
# no mains and no generator to recharge it.
|
||||
GENERATOR_TEST_GONE_WRONG = [
|
||||
# t=0 Utility fails — ATS transfers to generator
|
||||
( 0, "ats-01", "ATS_TRANSFER"),
|
||||
( 0, "gen-01", "GENERATOR_FAILURE"),
|
||||
# t=10m Fuel critically low — wasn't topped up before the test
|
||||
( 600, "gen-01", "GENERATOR_LOW_FUEL"),
|
||||
# t=15m Generator faults — output collapses
|
||||
( 900, "gen-01", "GENERATOR_FAULT"),
|
||||
# t=16m ATS loses its only live feed — UPS is now the last line of defence
|
||||
( 960, "ats-01", None),
|
||||
( 960, "ups-01", "UPS_MAINS_FAILURE"),
|
||||
( 960, "ups-02", "UPS_MAINS_FAILURE"),
|
||||
]
|
||||
|
||||
|
||||
# ── Scenario 3: Slow Burn ────────────────────────────────────────────────────
|
||||
# A dirty filter nobody noticed. Airflow degrades for 30 simulated minutes.
|
||||
# Temps creep, humidity climbs, VESDA alerts, then CRAC trips on protection.
|
||||
SLOW_BURN = [
|
||||
# t=0 Dirty filter — airflow begins throttling
|
||||
( 0, "crac-01", "DIRTY_FILTER"),
|
||||
# t=10m Rack temps and humidity start climbing together
|
||||
( 600, "hall-a/env", "SLOW_BURN"),
|
||||
# t=25m Particulates agitated by heat — VESDA alert fires
|
||||
( 1500, "vesda-hall-a", "VESDA_ALERT"),
|
||||
# t=30m CRAC-01 protection trips on high return-air temperature
|
||||
( 1800, "crac-01", "COOLING_FAILURE"),
|
||||
]
|
||||
|
||||
|
||||
# ── Scenario 4: Last Resort ──────────────────────────────────────────────────
|
||||
# Utility fails. Generator starts. Generator fails after 2 minutes.
|
||||
# UPS absorbs full load with no recharge path. Load spikes on inrush.
|
||||
# UPS components overheat. VESDA escalates to fire.
|
||||
LAST_RESORT = [
|
||||
# t=0 Utility power lost — ATS transfers to generator
|
||||
( 0, "ats-01", "ATS_TRANSFER"),
|
||||
( 0, "gen-01", "GENERATOR_FAILURE"),
|
||||
# t=2m Generator fails to sustain load — faults out
|
||||
( 120, "gen-01", "GENERATOR_FAULT"),
|
||||
# t=2m30s ATS loses generator feed — UPS is the only thing left
|
||||
( 150, "ats-01", None),
|
||||
( 155, "ups-01", "UPS_OVERLOAD"),
|
||||
( 155, "ups-02", "UPS_OVERLOAD"),
|
||||
# t=3m PDU inrush spike as UPS absorbs full site load
|
||||
( 180, "hall-a/pdu", "POWER_SPIKE"),
|
||||
( 180, "hall-b/pdu", "POWER_SPIKE"),
|
||||
# t=6m UPS rectifiers heating — VESDA detects particles
|
||||
( 360, "vesda-hall-a", "VESDA_ALERT"),
|
||||
( 380, "vesda-hall-b", "VESDA_ALERT"),
|
||||
# t=9m Thermal runaway — VESDA escalates to fire
|
||||
( 540, "vesda-hall-a", "VESDA_FIRE"),
|
||||
( 570, "vesda-hall-b", "VESDA_FIRE"),
|
||||
]
|
||||
|
||||
|
||||
COMPOUND_SCENARIOS: dict[str, list[tuple]] = {
|
||||
"HOT_NIGHT": HOT_NIGHT,
|
||||
"GENERATOR_TEST_GONE_WRONG": GENERATOR_TEST_GONE_WRONG,
|
||||
"SLOW_BURN": SLOW_BURN,
|
||||
"LAST_RESORT": LAST_RESORT,
|
||||
}
|
||||
|
||||
|
||||
class CompoundOrchestrator:
|
||||
"""
|
||||
Fires multi-bot, time-sequenced scenarios.
|
||||
|
||||
Steps are scheduled from the moment trigger() is called. Any currently
|
||||
running scenario is cancelled before the new one starts.
|
||||
"""
|
||||
|
||||
def __init__(self, by_key: dict) -> None:
|
||||
self._by_key = by_key
|
||||
self._active: asyncio.Task | None = None
|
||||
|
||||
def trigger(self, name: str) -> None:
|
||||
steps = COMPOUND_SCENARIOS.get(name)
|
||||
if steps is None:
|
||||
logger.warning(f"[Compound] Unknown scenario: '{name}'")
|
||||
return
|
||||
self._cancel_active()
|
||||
self._active = asyncio.create_task(self._run(name, steps))
|
||||
|
||||
def reset(self) -> None:
|
||||
self._cancel_active()
|
||||
seen: set = set()
|
||||
for v in self._by_key.values():
|
||||
targets = v if isinstance(v, list) else [v]
|
||||
for bot in targets:
|
||||
if id(bot) not in seen:
|
||||
bot.set_scenario(None)
|
||||
seen.add(id(bot))
|
||||
|
||||
def _cancel_active(self) -> None:
|
||||
if self._active and not self._active.done():
|
||||
self._active.cancel()
|
||||
self._active = None
|
||||
|
||||
async def _run(self, name: str, steps: list[tuple]) -> None:
|
||||
logger.info(f"[Compound] '{name}' started — {len(steps)} steps")
|
||||
sorted_steps = sorted(steps, key=lambda s: s[0])
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
try:
|
||||
for delay, target_key, scenario in sorted_steps:
|
||||
elapsed = asyncio.get_event_loop().time() - t0
|
||||
wait = delay - elapsed
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
self._apply(target_key, scenario, delay)
|
||||
logger.info(f"[Compound] '{name}' complete")
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"[Compound] '{name}' cancelled")
|
||||
|
||||
def _apply(self, target_key: str, scenario: str | None, delay: int) -> None:
|
||||
entry = self._by_key.get(target_key)
|
||||
if entry is None:
|
||||
logger.warning(f"[Compound] Unknown target '{target_key}' — skipping")
|
||||
return
|
||||
targets = entry if isinstance(entry, list) else [entry]
|
||||
for bot in targets:
|
||||
bot.set_scenario(scenario)
|
||||
logger.info(
|
||||
f"[Compound] t+{delay}s {target_key} ({len(targets)} bots)"
|
||||
f" → {scenario or 'RESET'}"
|
||||
)
|
||||
88
simulators/scenarios/runner.py
Normal file
88
simulators/scenarios/runner.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""
|
||||
DemoBMS Scenario Runner
|
||||
Triggers demo scenarios by publishing a control message to the MQTT broker.
|
||||
|
||||
Usage:
|
||||
python scenarios/runner.py --scenario COOLING_FAILURE --target crac-01
|
||||
python scenarios/runner.py --scenario FAN_DEGRADATION --target crac-02
|
||||
python scenarios/runner.py --scenario HIGH_TEMPERATURE --target hall-a
|
||||
python scenarios/runner.py --scenario HUMIDITY_SPIKE --target hall-b
|
||||
python scenarios/runner.py --scenario UPS_MAINS_FAILURE --target ups-01
|
||||
python scenarios/runner.py --scenario POWER_SPIKE --target hall-a
|
||||
python scenarios/runner.py --scenario RACK_OVERLOAD --target rack-A05/pdu
|
||||
python scenarios/runner.py --scenario LEAK_DETECTED --target leak-01
|
||||
python scenarios/runner.py --scenario RESET
|
||||
python scenarios/runner.py --scenario RESET --target ups-01
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import argparse
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
import aiomqtt
|
||||
from config import MQTT_HOST, MQTT_PORT
|
||||
|
||||
SCENARIOS = {
|
||||
# Cooling
|
||||
"COOLING_FAILURE": "CRAC unit goes offline — rack temperatures rise rapidly (target: crac-01 / crac-02)",
|
||||
"FAN_DEGRADATION": "CRAC fan bearing wear — fan speed drops, ΔT rises over ~25 min (target: crac-01 / crac-02)",
|
||||
"COMPRESSOR_FAULT": "Compressor trips — unit drops to fan-only, cooling capacity collapses to ~8% (target: crac-01 / crac-02)",
|
||||
"DIRTY_FILTER": "Filter fouling — ΔP rises, airflow and capacity degrade over time (target: crac-01 / crac-02)",
|
||||
"HIGH_TEMPERATURE": "Gradual ambient heat rise, e.g. summer overload — slower than COOLING_FAILURE (target: hall-a / hall-b)",
|
||||
"HUMIDITY_SPIKE": "Humidity climbs — condensation / humidifier fault risk (target: hall-a / hall-b)",
|
||||
"CHILLER_FAULT": "Chiller plant trips — chilled water supply lost (target: chiller-01)",
|
||||
# Power
|
||||
"UPS_MAINS_FAILURE": "Mains power lost — UPS switches to battery and drains (target: ups-01 / ups-02)",
|
||||
"POWER_SPIKE": "PDU load surges across a room by up to 50% (target: hall-a / hall-b / rack-XXX/pdu)",
|
||||
"RACK_OVERLOAD": "Single rack redlines at ~85-95% of rated 10 kW capacity (target: rack-XXX/pdu)",
|
||||
"PHASE_IMBALANCE": "PDU phase A overloads, phase C drops — imbalance flag triggers (target: rack-XXX/pdu)",
|
||||
"ATS_TRANSFER": "Utility feed lost — ATS transfers load to generator (target: ats-01)",
|
||||
"GENERATOR_FAILURE": "Generator starts and runs under load following utility failure (target: gen-01)",
|
||||
"GENERATOR_LOW_FUEL": "Generator fuel level drains to critical low (target: gen-01)",
|
||||
"GENERATOR_FAULT": "Generator fails to start — fault state, no output (target: gen-01)",
|
||||
# Environmental / Life Safety
|
||||
"LEAK_DETECTED": "Water leak sensor triggers a critical alarm (target: leak-01 / leak-02 / leak-03)",
|
||||
"VESDA_ALERT": "Smoke obscuration rises into Alert/Action band (target: vesda-hall-a / vesda-hall-b)",
|
||||
"VESDA_FIRE": "Smoke obscuration escalates to Fire level (target: vesda-hall-a / vesda-hall-b)",
|
||||
# Control
|
||||
"RESET": "Return all bots (or a specific target) to normal operation",
|
||||
# ── Compound (multi-bot, time-sequenced) ─────────────────────────────────
|
||||
"HOT_NIGHT": "CRAC-01 trips → temps rise → power spikes → CRAC-02 degrades → VESDA alert [~10 min]",
|
||||
"GENERATOR_TEST_GONE_WRONG": "ATS transfers → generator runs low on fuel → faults → UPS takes over [~16 min]",
|
||||
"SLOW_BURN": "Dirty filter → creeping temp+humidity → VESDA alert → CRAC protection trips [~30 min]",
|
||||
"LAST_RESORT": "Utility fails → generator starts → faults at 2 min → UPS overloads → VESDA fire [~9 min]",
|
||||
}
|
||||
|
||||
|
||||
async def trigger(scenario: str, target: str | None) -> None:
|
||||
async with aiomqtt.Client(MQTT_HOST, port=MQTT_PORT) as client:
|
||||
payload = json.dumps({"scenario": scenario, "target": target})
|
||||
await client.publish("bms/control/scenario", payload, qos=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="DemoBMS Scenario Runner")
|
||||
parser.add_argument("--scenario", choices=list(SCENARIOS.keys()), required=False)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
help="Device ID to target (e.g. rack-A05/pdu, crac-01, ups-01, hall-a, leak-01). Omit for all.",
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="List all available scenarios and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list or not args.scenario:
|
||||
print("\nAvailable scenarios:\n")
|
||||
for name, desc in SCENARIOS.items():
|
||||
print(f" {name:<22} {desc}")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n Scenario : {args.scenario}")
|
||||
print(f" Target : {args.target or 'ALL bots'}")
|
||||
print(f" Effect : {SCENARIOS[args.scenario]}\n")
|
||||
|
||||
asyncio.run(trigger(args.scenario, args.target))
|
||||
print("Done. Watch the dashboard for changes.")
|
||||
104
simulators/seed.py
Normal file
104
simulators/seed.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""
|
||||
Historical data seeder — runs once at startup.
|
||||
Generates SEED_MINUTES of backdated readings so the dashboard
|
||||
has chart history from the moment the app first loads.
|
||||
"""
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://dcim:dcim_pass@localhost:5432/dcim")
|
||||
SEED_MINUTES = int(os.getenv("SEED_MINUTES", "30"))
|
||||
SITE_ID = "sg-01"
|
||||
INTERVAL_MINS = 5 # one data point every 5 minutes
|
||||
|
||||
ROOMS = [
|
||||
{"room_id": "hall-a", "racks": [f"SG1A01.{i:02d}" for i in range(1, 21)] + [f"SG1A02.{i:02d}" for i in range(1, 21)]},
|
||||
{"room_id": "hall-b", "racks": [f"SG1B01.{i:02d}" for i in range(1, 21)] + [f"SG1B02.{i:02d}" for i in range(1, 21)]},
|
||||
]
|
||||
CRAC_UNITS = [("hall-a", "crac-01"), ("hall-b", "crac-02")]
|
||||
UPS_UNITS = ["ups-01", "ups-02"]
|
||||
LEAK_SENSORS = ["leak-01"]
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
# Strip +asyncpg prefix if present (asyncpg needs plain postgresql://)
|
||||
url = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
|
||||
|
||||
print(f"Seeder: connecting to database...")
|
||||
conn = await asyncpg.connect(url)
|
||||
|
||||
try:
|
||||
count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM readings WHERE site_id = $1", SITE_ID
|
||||
)
|
||||
if count > 0:
|
||||
print(f"Seeder: {count} rows already exist — skipping.")
|
||||
return
|
||||
|
||||
print(f"Seeder: generating {SEED_MINUTES} minutes of history...")
|
||||
rows: list[tuple] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for minutes_ago in range(SEED_MINUTES, -1, -INTERVAL_MINS):
|
||||
t = now - timedelta(minutes=minutes_ago)
|
||||
hour = t.hour
|
||||
day_factor = 1.0 + 0.04 * math.sin(math.pi * (hour - 6) / 12)
|
||||
biz_factor = (1.0 + 0.25 * math.sin(math.pi * max(0, hour - 8) / 12)
|
||||
if 8 <= hour <= 20 else 0.85)
|
||||
|
||||
for room in ROOMS:
|
||||
room_id = room["room_id"]
|
||||
for rack_id in room["racks"]:
|
||||
num = int(rack_id[-2:])
|
||||
base_temp = 21.5 + num * 0.15
|
||||
base_load = 2.0 + (num % 5) * 0.8
|
||||
base_id = f"{SITE_ID}/{room_id}/{rack_id}"
|
||||
|
||||
temp = base_temp * day_factor + random.gauss(0, 0.2)
|
||||
humidity = 44.0 + random.gauss(0, 1.0)
|
||||
load = base_load * biz_factor + random.gauss(0, 0.1)
|
||||
|
||||
rows += [
|
||||
(t, f"{base_id}/temperature", "temperature", SITE_ID, room_id, rack_id, round(temp, 2), "°C"),
|
||||
(t, f"{base_id}/humidity", "humidity", SITE_ID, room_id, rack_id, round(humidity, 1), "%"),
|
||||
(t, f"{base_id}/power_kw", "power_kw", SITE_ID, room_id, rack_id, round(max(0.5, load), 2), "kW"),
|
||||
]
|
||||
|
||||
for _, crac_id in CRAC_UNITS:
|
||||
base = f"{SITE_ID}/cooling/{crac_id}"
|
||||
rows += [
|
||||
(t, f"{base}/supply_temp", "cooling_supply", SITE_ID, None, None, round(17.5 + random.gauss(0, 0.2), 2), "°C"),
|
||||
(t, f"{base}/return_temp", "cooling_return", SITE_ID, None, None, round(28.0 + random.gauss(0, 0.3), 2), "°C"),
|
||||
(t, f"{base}/fan_pct", "cooling_fan", SITE_ID, None, None, round(62.0 + random.gauss(0, 2.0), 1), "%"),
|
||||
]
|
||||
|
||||
for ups_id in UPS_UNITS:
|
||||
base = f"{SITE_ID}/power/{ups_id}"
|
||||
rows += [
|
||||
(t, f"{base}/charge_pct", "ups_charge", SITE_ID, None, None, round(94.0 + random.gauss(0, 0.5), 1), "%"),
|
||||
(t, f"{base}/load_pct", "ups_load", SITE_ID, None, None, round(63.0 + random.gauss(0, 1.0), 1), "%"),
|
||||
(t, f"{base}/runtime_min", "ups_runtime", SITE_ID, None, None, round(57.0 + random.gauss(0, 1.0), 0), "min"),
|
||||
]
|
||||
|
||||
for leak_id in LEAK_SENSORS:
|
||||
rows.append((t, f"{SITE_ID}/leak/{leak_id}", "leak", SITE_ID, None, None, 0.0, ""))
|
||||
|
||||
await conn.executemany("""
|
||||
INSERT INTO readings
|
||||
(recorded_at, sensor_id, sensor_type, site_id, room_id, rack_id, value, unit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""", rows)
|
||||
|
||||
print(f"Seeder: inserted {len(rows)} readings. Done.")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
Loading…
Add table
Add a link
Reference in a new issue