hvac-agents / backend /api /o16_controller.py
subhan07's picture
HVAC FastAPI backend and O1-O20 agents (no frontend)
95c00ee verified
Raw History Blame Contribute Delete
7.03 kB
"""O16 Variable Head Pressure Control — Water-Cooled Condensers — domain controller."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from backend.agents.runtime.command import get_command
from backend.services import o16_service
from backend.services.canonical_telemetry_service import latest_points
router = APIRouter(prefix="/api/agents/variable-speed/o16", tags=["O16 Water-Cooled Head Pressure"])
class TelemetryIngest(BaseModel):
building_id: Optional[str] = None
points: List[Dict[str, Any]]
class CommandBody(BaseModel):
command_id: Optional[str] = None
confirm: bool = False
class ConfigBody(BaseModel):
enabled: Optional[bool] = None
control_mode: Optional[str] = None
control_strategy: Optional[str] = None
shared_pump: Optional[bool] = None
target_head_pressure: Optional[float] = None
target_condensing_temp_c: Optional[float] = None
min_head_pressure: Optional[float] = None
max_head_pressure: Optional[float] = None
min_condensing_temp_c: Optional[float] = None
max_condensing_temp_c: Optional[float] = None
min_pump_speed_pct: Optional[float] = None
max_pump_speed_pct: Optional[float] = None
min_cw_flow: Optional[float] = None
max_cw_flow: Optional[float] = None
min_valve_pct: Optional[float] = None
max_valve_pct: Optional[float] = None
pump_trim_pct: Optional[float] = None
valve_trim_pct: Optional[float] = None
hp_deadband: Optional[float] = None
max_pump_step_pct: Optional[float] = None
high_load_pct: Optional[float] = None
isolate_valve_pct: Optional[float] = None
verify_tolerance: Optional[float] = None
refrigerant: Optional[str] = None
building_id: Optional[str] = None
class SafeModeBody(BaseModel):
reason: Optional[str] = None
def _http(exc: Exception, status: int = 409) -> HTTPException:
if isinstance(exc, KeyError):
return HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "Command not found."})
if isinstance(exc, PermissionError):
return HTTPException(status_code=409, detail={"code": str(exc), "message": str(exc)})
return HTTPException(status_code=status, detail={"code": "DISPATCH_BLOCKED", "message": str(exc)})
@router.get("/dashboard")
async def dashboard():
return o16_service.dashboard()
@router.get("/state")
async def state():
return o16_service.evaluate_o16(persist=False)
@router.get("/telemetry")
async def telemetry(building_id: Optional[str] = None):
sampled = o16_service.sample_o16(building_id)
return {
"opportunity": "O16",
"points": sampled.get("_points") or latest_points(building_id, limit=200),
"sampled": {k: sampled[k] for k in sampled if not str(k).startswith("_") and "__" not in str(k)},
"source": sampled.get("source"),
"quality": sampled.get("quality"),
}
@router.post("/telemetry")
async def post_telemetry(body: TelemetryIngest):
n = o16_service.ingest_points(body.points, body.building_id)
return {"ingested": n, "state": o16_service.evaluate_o16(persist=True, building_id=body.building_id)}
@router.get("/recommendation")
async def recommendation():
s = o16_service.evaluate_o16(persist=False)
return {
"recommendation": s.get("recommendation"),
"recommendation_state": s.get("recommendation_state"),
"current": s.get("current_value"),
"recommended": s.get("optimized_value"),
"unit": s.get("unit"),
"reason": s.get("reason"),
"confidence": s.get("confidence"),
"safety": s.get("safety_status"),
"why": s.get("why"),
"current_state": s.get("current_state"),
"optimized_state": s.get("optimized_state"),
}
@router.post("/optimize")
async def optimize():
return o16_service.optimize()
@router.post("/commands")
async def post_command(body: CommandBody):
return o16_service.create_command(body.model_dump())
@router.get("/commands")
async def commands():
return {"commands": o16_service.command_list()}
@router.get("/commands/{command_id}")
async def command_one(command_id: str):
row = get_command(command_id)
if not row or row.get("opportunity") != "O16":
raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "Command not found."})
return row
@router.post("/commands/{command_id}/approve")
async def approve(command_id: str):
try:
return o16_service.approve_command(command_id)
except Exception as exc:
raise _http(exc)
@router.post("/commands/{command_id}/apply")
async def apply(command_id: str, body: Optional[CommandBody] = None):
try:
return o16_service.apply_command(command_id, confirm=bool(body and body.confirm))
except Exception as exc:
raise _http(exc)
@router.post("/commands/{command_id}/verify")
async def verify(command_id: str):
try:
return o16_service.verify(command_id)
except Exception as exc:
raise _http(exc)
@router.post("/commands/{command_id}/rollback")
async def rollback(command_id: str):
try:
return o16_service.rollback(command_id)
except Exception as exc:
raise _http(exc)
@router.get("/history")
async def history(hours: int = Query(24, ge=1, le=720), format: Optional[str] = None):
data = o16_service.history(hours)
if (format or "").lower() == "csv":
cols = ["timestamp", "head_pressure", "condensing_temperature", "cw_supply", "cw_return", "cw_flow", "pump_speed", "pump_power", "load", "quality", "source"]
lines = [",".join(cols)]
for p in data.get("points") or []:
lines.append(",".join("" if p.get(c) is None else str(p.get(c)) for c in cols))
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/csv")
return data
@router.get("/health")
async def health():
return o16_service.health()
@router.get("/equipment")
async def equipment():
return {"equipment": o16_service.list_equipment()}
@router.get("/safety")
async def safety():
return o16_service.safety_view()
@router.get("/runs")
async def runs():
return {"runs": o16_service.runs()}
@router.get("/audit")
async def audit():
return {"events": o16_service.audit_events()}
@router.get("/config")
async def config():
return o16_service.get_config()
@router.post("/config")
async def post_config(body: ConfigBody):
return o16_service.save_config(body.model_dump(exclude_none=True))
@router.post("/safe-mode")
async def safe_mode(body: Optional[SafeModeBody] = None):
return o16_service.enter_safe_mode(body.reason if body else None)