""" Scenario control API — proxies trigger/reset commands to the MQTT broker so the frontend can fire simulator scenarios over HTTP. """ import json import asyncio from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import Optional import aiomqtt from core.config import settings router = APIRouter() # ── Scenario catalogue ─────────────────────────────────────────────────────── # Mirrors the definitions in simulators/scenarios/runner.py and compound.py. # Kept here so the frontend has a single typed source of truth. SCENARIOS = [ # ── Compound (multi-bot, time-sequenced) ───────────────────────────────── { "name": "HOT_NIGHT", "label": "Hot Night", "description": "CRAC-01 compressor trips silently. The backup unit overworks itself. Rack temps climb, power draw rises, VESDA alert fires.", "duration": "~10 min", "compound": True, "default_target": None, "targets": [], }, { "name": "GENERATOR_TEST_GONE_WRONG", "label": "Generator Test Gone Wrong", "description": "Planned ATS transfer to generator. Generator was low on fuel and faults after 15 min. UPS must carry full site load alone.", "duration": "~16 min", "compound": True, "default_target": None, "targets": [], }, { "name": "SLOW_BURN", "label": "Slow Burn", "description": "A dirty filter nobody noticed. Airflow degrades for 30 min. Temps creep, humidity climbs, VESDA alerts, then CRAC trips on thermal protection.", "duration": "~30 min", "compound": True, "default_target": None, "targets": [], }, { "name": "LAST_RESORT", "label": "Last Resort", "description": "Utility fails. Generator starts then faults after 2 minutes. UPS absorbs the full load, overheats, and VESDA escalates to fire.", "duration": "~9 min", "compound": True, "default_target": None, "targets": [], }, # ── Cooling ────────────────────────────────────────────────────────────── { "name": "COOLING_FAILURE", "label": "Cooling Failure", "description": "CRAC unit goes offline — rack temperatures rise rapidly.", "duration": "ongoing", "compound": False, "default_target": "crac-01", "targets": ["crac-01", "crac-02"], }, { "name": "FAN_DEGRADATION", "label": "Fan Degradation", "description": "CRAC fan bearing wear — fan speed drops, ΔT rises over ~25 min.", "duration": "~25 min", "compound": False, "default_target": "crac-01", "targets": ["crac-01", "crac-02"], }, { "name": "COMPRESSOR_FAULT", "label": "Compressor Fault", "description": "Compressor trips — unit drops to fan-only, cooling capacity collapses to ~8%.", "duration": "ongoing", "compound": False, "default_target": "crac-01", "targets": ["crac-01", "crac-02"], }, { "name": "DIRTY_FILTER", "label": "Dirty Filter", "description": "Filter fouling — ΔP rises, airflow and capacity degrade over time.", "duration": "ongoing", "compound": False, "default_target": "crac-01", "targets": ["crac-01", "crac-02"], }, { "name": "HIGH_TEMPERATURE", "label": "High Temperature", "description": "Gradual ambient heat rise — slower than a full cooling failure.", "duration": "ongoing", "compound": False, "default_target": "hall-a", "targets": ["hall-a", "hall-b"], }, { "name": "HUMIDITY_SPIKE", "label": "Humidity Spike", "description": "Humidity climbs — condensation / humidifier fault risk.", "duration": "ongoing", "compound": False, "default_target": "hall-a", "targets": ["hall-a", "hall-b"], }, { "name": "CHILLER_FAULT", "label": "Chiller Fault", "description": "Chiller plant trips — chilled water supply lost.", "duration": "ongoing", "compound": False, "default_target": "chiller-01", "targets": ["chiller-01"], }, # ── Power ──────────────────────────────────────────────────────────────── { "name": "UPS_MAINS_FAILURE", "label": "UPS Mains Failure", "description": "Mains power lost — UPS switches to battery and drains.", "duration": "~60 min", "compound": False, "default_target": "ups-01", "targets": ["ups-01", "ups-02"], }, { "name": "POWER_SPIKE", "label": "Power Spike", "description": "PDU load surges across a room by up to 50%.", "duration": "ongoing", "compound": False, "default_target": "hall-a", "targets": ["hall-a", "hall-b"], }, { "name": "RACK_OVERLOAD", "label": "Rack Overload", "description": "Single rack redlines at ~85–95% of rated 10 kW capacity.", "duration": "ongoing", "compound": False, "default_target": "SG1A01.10", "targets": ["SG1A01.10", "SG1B01.10"], }, { "name": "PHASE_IMBALANCE", "label": "Phase Imbalance", "description": "PDU phase A overloads, phase C drops — imbalance flag triggers.", "duration": "ongoing", "compound": False, "default_target": "SG1A01.10/pdu", "targets": ["SG1A01.10/pdu", "SG1B01.10/pdu"], }, { "name": "ATS_TRANSFER", "label": "ATS Transfer", "description": "Utility feed lost — ATS transfers load to generator.", "duration": "ongoing", "compound": False, "default_target": "ats-01", "targets": ["ats-01"], }, { "name": "GENERATOR_FAILURE", "label": "Generator Running", "description": "Generator starts and runs under load following a utility failure.", "duration": "ongoing", "compound": False, "default_target": "gen-01", "targets": ["gen-01"], }, { "name": "GENERATOR_LOW_FUEL", "label": "Generator Low Fuel", "description": "Generator fuel level drains to critical low.", "duration": "ongoing", "compound": False, "default_target": "gen-01", "targets": ["gen-01"], }, { "name": "GENERATOR_FAULT", "label": "Generator Fault", "description": "Generator fails — fault state, no output.", "duration": "ongoing", "compound": False, "default_target": "gen-01", "targets": ["gen-01"], }, # ── Environmental / Life Safety ────────────────────────────────────────── { "name": "LEAK_DETECTED", "label": "Leak Detected", "description": "Water leak sensor triggers a critical alarm.", "duration": "ongoing", "compound": False, "default_target": "leak-01", "targets": ["leak-01", "leak-02", "leak-03"], }, { "name": "VESDA_ALERT", "label": "VESDA Alert", "description": "Smoke obscuration rises into the Alert/Action band.", "duration": "ongoing", "compound": False, "default_target": "vesda-hall-a", "targets": ["vesda-hall-a", "vesda-hall-b"], }, { "name": "VESDA_FIRE", "label": "VESDA Fire", "description": "Smoke obscuration escalates to Fire level.", "duration": "ongoing", "compound": False, "default_target": "vesda-hall-a", "targets": ["vesda-hall-a", "vesda-hall-b"], }, ] # ── Request / Response models ──────────────────────────────────────────────── class TriggerRequest(BaseModel): scenario: str target: Optional[str] = None # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("") async def list_scenarios(): return SCENARIOS @router.post("/trigger") async def trigger_scenario(body: TriggerRequest): payload = json.dumps({"scenario": body.scenario, "target": body.target}) try: async with aiomqtt.Client(settings.MQTT_HOST, port=settings.MQTT_PORT) as client: await client.publish("bms/control/scenario", payload, qos=1) except Exception as e: raise HTTPException(status_code=503, detail=f"MQTT unavailable: {e}") return {"ok": True, "scenario": body.scenario, "target": body.target}