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

7
simulators/Dockerfile Normal file
View 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"]

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

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

61
simulators/config.py Normal file
View 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
View 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
View 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())

View file

@ -0,0 +1,2 @@
aiomqtt==2.3.0
asyncpg==0.30.0

View file

View 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'}"
)

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