from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, Query from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from core.database import get_session router = APIRouter() # Topology — mirrors simulator config ROOMS = { "sg-01": [ {"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)]}, ] } ATS_UNITS = {"sg-01": ["ats-01"]} GENERATORS = {"sg-01": ["gen-01"]} ACTIVE_FEED_MAP = {0.0: "utility-a", 1.0: "utility-b", 2.0: "generator"} # Singapore commercial electricity tariff (SGD / kWh, approximate) TARIFF_SGD_KWH = 0.298 @router.get("/rack-breakdown") async def rack_power_breakdown( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Latest kW reading per rack, grouped by room.""" result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) rack_id, room_id, value AS power_kw FROM readings WHERE site_id = :site_id AND sensor_type = 'power_kw' AND rack_id IS NOT NULL AND recorded_at > NOW() - INTERVAL '10 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) rows = result.mappings().all() rack_map: dict[str, dict] = {r["rack_id"]: dict(r) for r in rows} rooms = [] for room in ROOMS.get(site_id, []): racks = [] for rack_id in room["racks"]: reading = rack_map.get(rack_id) racks.append({ "rack_id": rack_id, "power_kw": round(float(reading["power_kw"]), 2) if reading else None, }) rooms.append({"room_id": room["room_id"], "racks": racks}) return rooms @router.get("/room-history") async def room_power_history( site_id: str = Query(...), hours: int = Query(6, ge=1, le=24), session: AsyncSession = Depends(get_session), ): """Total power per room bucketed by 5 minutes — for a multi-line trend chart.""" from_time = datetime.now(timezone.utc) - timedelta(hours=hours) try: result = await session.execute(text(""" SELECT bucket, room_id, ROUND(SUM(avg_per_rack)::numeric, 1) AS total_kw FROM ( SELECT time_bucket('5 minutes', recorded_at) AS bucket, sensor_id, room_id, AVG(value) AS avg_per_rack FROM readings WHERE site_id = :site_id AND sensor_type = 'power_kw' AND room_id IS NOT NULL AND recorded_at > :from_time GROUP BY bucket, sensor_id, room_id ) per_rack GROUP BY bucket, room_id ORDER BY bucket ASC """), {"site_id": site_id, "from_time": from_time}) except Exception: result = await session.execute(text(""" SELECT bucket, room_id, ROUND(SUM(avg_per_rack)::numeric, 1) AS total_kw FROM ( SELECT date_trunc('minute', recorded_at) AS bucket, sensor_id, room_id, AVG(value) AS avg_per_rack FROM readings WHERE site_id = :site_id AND sensor_type = 'power_kw' AND room_id IS NOT NULL AND recorded_at > :from_time GROUP BY bucket, sensor_id, room_id ) per_rack GROUP BY bucket, room_id ORDER BY bucket ASC """), {"site_id": site_id, "from_time": from_time}) return [dict(r) for r in result.mappings().all()] @router.get("/ups") async def ups_status( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Latest UPS readings.""" result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, sensor_type, value FROM readings WHERE site_id = :site_id AND sensor_type IN ('ups_charge', 'ups_load', 'ups_runtime', 'ups_state', 'ups_voltage') AND recorded_at > NOW() - INTERVAL '10 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) rows = result.mappings().all() # sensor_id format: sg-01/power/ups-01/charge_pct ups_data: dict[str, dict] = {} for row in rows: parts = row["sensor_id"].split("/") if len(parts) < 3: continue ups_id = parts[2] if ups_id not in ups_data: ups_data[ups_id] = {"ups_id": ups_id} key_map = { "ups_charge": "charge_pct", "ups_load": "load_pct", "ups_runtime": "runtime_min", "ups_state": "_state_raw", "ups_voltage": "voltage_v", } field = key_map.get(row["sensor_type"]) if field: ups_data[ups_id][field] = round(float(row["value"]), 1) STATE_MAP = {0.0: "online", 1.0: "battery", 2.0: "overload"} result_list = [] for ups_id, d in sorted(ups_data.items()): # Use stored state if available; fall back to charge heuristic only if state never arrived state_raw = d.get("_state_raw") if state_raw is not None: state = STATE_MAP.get(round(state_raw), "online") else: charge = d.get("charge_pct") state = "battery" if (charge is not None and charge < 20.0) else "online" result_list.append({ "ups_id": ups_id, "state": state, "charge_pct": d.get("charge_pct"), "load_pct": d.get("load_pct"), "runtime_min": d.get("runtime_min"), "voltage_v": d.get("voltage_v"), }) return result_list @router.get("/ups/history") async def ups_history( site_id: str = Query(...), ups_id: str = Query(...), hours: int = Query(6, ge=1, le=24), session: AsyncSession = Depends(get_session), ): """5-minute bucketed trend for a single UPS: charge, load, runtime, voltage.""" from_time = datetime.now(timezone.utc) - timedelta(hours=hours) types_sql = "'ups_charge', 'ups_load', 'ups_runtime', 'ups_voltage'" try: result = await session.execute(text(f""" SELECT time_bucket('5 minutes', recorded_at) AS bucket, sensor_type, ROUND(AVG(value)::numeric, 2) AS avg_val FROM readings WHERE site_id = :site_id AND sensor_id LIKE :pattern AND sensor_type IN ({types_sql}) AND recorded_at > :from_time GROUP BY bucket, sensor_type ORDER BY bucket ASC """), {"site_id": site_id, "pattern": f"{site_id}/power/{ups_id}/%", "from_time": from_time}) except Exception: result = await session.execute(text(f""" SELECT date_trunc('minute', recorded_at) AS bucket, sensor_type, ROUND(AVG(value)::numeric, 2) AS avg_val FROM readings WHERE site_id = :site_id AND sensor_id LIKE :pattern AND sensor_type IN ({types_sql}) AND recorded_at > :from_time GROUP BY bucket, sensor_type ORDER BY bucket ASC """), {"site_id": site_id, "pattern": f"{site_id}/power/{ups_id}/%", "from_time": from_time}) KEY_MAP = { "ups_charge": "charge_pct", "ups_load": "load_pct", "ups_runtime": "runtime_min", "ups_voltage": "voltage_v", } buckets: dict[str, dict] = {} for row in result.mappings().all(): b = row["bucket"].isoformat() buckets.setdefault(b, {"bucket": b}) field = KEY_MAP.get(row["sensor_type"]) if field: buckets[b][field] = float(row["avg_val"]) return list(buckets.values()) @router.get("/ats") async def ats_status( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Latest ATS transfer switch readings.""" result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, sensor_type, value FROM readings WHERE site_id = :site_id AND sensor_type IN ('ats_active', 'ats_state', 'ats_xfer_count', 'ats_xfer_ms', 'ats_ua_v', 'ats_ub_v', 'ats_gen_v') AND recorded_at > NOW() - INTERVAL '2 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) ats_data: dict[str, dict] = {} for row in result.mappings().all(): parts = row["sensor_id"].split("/") # sensor_id: {site}/power/ats/{ats_id}/{key} → parts[3] if len(parts) < 4: continue ats_id = parts[3] if ats_id not in ats_data: ats_data[ats_id] = {"ats_id": ats_id} v = float(row["value"]) s_type = row["sensor_type"] if s_type == "ats_active": ats_data[ats_id]["active_feed"] = ACTIVE_FEED_MAP.get(round(v), "utility-a") elif s_type == "ats_state": ats_data[ats_id]["state"] = "transferring" if v > 0.5 else "stable" elif s_type == "ats_xfer_count": ats_data[ats_id]["transfer_count"] = int(v) elif s_type == "ats_xfer_ms": ats_data[ats_id]["last_transfer_ms"] = round(v, 0) if v > 0 else None elif s_type == "ats_ua_v": ats_data[ats_id]["utility_a_v"] = round(v, 1) elif s_type == "ats_ub_v": ats_data[ats_id]["utility_b_v"] = round(v, 1) elif s_type == "ats_gen_v": ats_data[ats_id]["generator_v"] = round(v, 1) out = [] for ats_id in ATS_UNITS.get(site_id, []): d = ats_data.get(ats_id, {"ats_id": ats_id}) d.setdefault("state", "stable") d.setdefault("active_feed", "utility-a") d.setdefault("transfer_count", 0) d.setdefault("last_transfer_ms", None) out.append(d) return out @router.get("/phase") async def pdu_phase_breakdown( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Per-phase kW, amps, and imbalance % for every rack PDU.""" result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) rack_id, room_id, sensor_type, value FROM readings WHERE site_id = :site_id AND sensor_type IN ('pdu_phase_a_kw', 'pdu_phase_b_kw', 'pdu_phase_c_kw', 'pdu_phase_a_a', 'pdu_phase_b_a', 'pdu_phase_c_a', 'pdu_imbalance') AND rack_id IS NOT NULL AND recorded_at > NOW() - INTERVAL '10 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) FIELD_MAP = { "pdu_phase_a_kw": "phase_a_kw", "pdu_phase_b_kw": "phase_b_kw", "pdu_phase_c_kw": "phase_c_kw", "pdu_phase_a_a": "phase_a_a", "pdu_phase_b_a": "phase_b_a", "pdu_phase_c_a": "phase_c_a", "pdu_imbalance": "imbalance_pct", } rack_map: dict[tuple, float] = {} rack_rooms: dict[str, str] = {} for row in result.mappings().all(): rack_id = row["rack_id"] room_id = row["room_id"] s_type = row["sensor_type"] if rack_id: rack_map[(rack_id, s_type)] = round(float(row["value"]), 2) if room_id: rack_rooms[rack_id] = room_id rooms = [] for room in ROOMS.get(site_id, []): racks = [] for rack_id in room["racks"]: entry: dict = {"rack_id": rack_id, "room_id": room["room_id"]} for s_type, field in FIELD_MAP.items(): entry[field] = rack_map.get((rack_id, s_type)) racks.append(entry) rooms.append({"room_id": room["room_id"], "racks": racks}) return rooms @router.get("/redundancy") async def power_redundancy( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Compute power redundancy level: 2N, N+1, or N.""" # Count UPS units online ups_result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, value FROM readings WHERE site_id = :site_id AND sensor_type = 'ups_charge' AND recorded_at > NOW() - INTERVAL '10 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) ups_rows = ups_result.mappings().all() ups_online = len([r for r in ups_rows if float(r["value"]) > 10.0]) ups_total = len(ups_rows) # ATS active feed ats_result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, value FROM readings WHERE site_id = :site_id AND sensor_type = 'ats_active' AND recorded_at > NOW() - INTERVAL '2 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) ats_rows = ats_result.mappings().all() ats_active_feed = None if ats_rows: ats_active_feed = ACTIVE_FEED_MAP.get(round(float(ats_rows[0]["value"])), "utility-a") # Generator available (not fault) gen_result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, value FROM readings WHERE site_id = :site_id AND sensor_type = 'gen_state' AND recorded_at > NOW() - INTERVAL '5 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) gen_rows = gen_result.mappings().all() gen_available = len([r for r in gen_rows if float(r["value"]) >= 0.0]) > 0 # Derive level if ups_total >= 2 and ups_online >= 2 and gen_available: level = "2N" elif ups_online >= 1 and gen_available: level = "N+1" else: level = "N" return { "site_id": site_id, "level": level, "ups_total": ups_total, "ups_online": ups_online, "generator_ok": gen_available, "ats_active_feed": ats_active_feed, "notes": ( "Dual UPS + generator = 2N" if level == "2N" else "Single path active — reduced redundancy" if level == "N" else "N+1 — one redundant path available" ), } @router.get("/utility") async def utility_power( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Current total IT load and estimated monthly energy cost.""" # Latest total IT load kw_result = await session.execute(text(""" SELECT ROUND(SUM(value)::numeric, 2) AS total_kw FROM ( SELECT DISTINCT ON (sensor_id) sensor_id, value FROM readings WHERE site_id = :site_id AND sensor_type = 'power_kw' AND recorded_at > NOW() - INTERVAL '10 minutes' ORDER BY sensor_id, recorded_at DESC ) latest """), {"site_id": site_id}) kw_row = kw_result.mappings().first() total_kw = float(kw_row["total_kw"] or 0) if kw_row else 0.0 # Estimated month-to-date kWh (from readings since start of month) from_month = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) kwh_result = await session.execute(text(""" SELECT ROUND((SUM(value) * 5.0 / 60.0)::numeric, 1) AS kwh_mtd FROM ( SELECT DISTINCT ON (sensor_id, date_trunc('minute', recorded_at)) sensor_id, value FROM readings WHERE site_id = :site_id AND sensor_type = 'power_kw' AND recorded_at > :from_month ORDER BY sensor_id, date_trunc('minute', recorded_at), recorded_at DESC ) bucketed """), {"site_id": site_id, "from_month": from_month}) kwh_row = kwh_result.mappings().first() kwh_mtd = float(kwh_row["kwh_mtd"] or 0) if kwh_row else 0.0 cost_mtd = round(kwh_mtd * TARIFF_SGD_KWH, 2) # Annualised from month-to-date pace now = datetime.now(timezone.utc) day_of_month = now.day days_in_month = 30 if day_of_month > 0: kwh_annual_est = round(kwh_mtd / day_of_month * 365, 0) cost_annual_est = round(kwh_annual_est * TARIFF_SGD_KWH, 2) else: kwh_annual_est = 0.0 cost_annual_est = 0.0 return { "site_id": site_id, "total_kw": total_kw, "tariff_sgd_kwh": TARIFF_SGD_KWH, "kwh_month_to_date": kwh_mtd, "cost_sgd_mtd": cost_mtd, "kwh_annual_est": kwh_annual_est, "cost_sgd_annual_est": cost_annual_est, "currency": "SGD", }