Beledevere's picture
Fresh deployment without parquet
eafee5b
Raw History Blame Contribute Delete
15.4 kB
"""
GridSense AI - FastAPI Backend
Serves forecasts via REST API for SLDC integration.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from runtime_config import chdir_project_root, configure_runtime
configure_runtime()
chdir_project_root()
from dotenv import load_dotenv
load_dotenv()
import pickle
import pandas as pd
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import List, Optional
import io
from data.scada_generator import PLANTS
from pipeline.baseline_forecaster import load_pipeline, forecast_baseline
from pipeline.residual_adjuster import apply_residual_layer
from pipeline.physics_constraints import apply_physics_constraints
from pipeline.uncertainty import apply_confidence_bands
from pipeline.explainability import SUPPORTED_LANGUAGES, generate_explanations
from pipeline.data_quality import check_data_quality, generate_quality_report
from pipeline.cerc_compliance import compute_cerc_metrics, generate_cerc_report
from pipeline.operator_override import OverrideRule, get_override_manager
app = FastAPI(title="GridSense AI Forecast API", version="0.1.0")
# Lazy-load model on first request
_pipeline = None
def get_pipeline():
global _pipeline
if _pipeline is None:
_pipeline = load_pipeline()
return _pipeline
class ForecastRequest(BaseModel):
plant_id: str
context_hours: int = 168 # 7 days history
prediction_hours: int = 24
forecast_timestamp: Optional[str] = None
language: str = "en"
class ForecastResponse(BaseModel):
plant_id: str
timestamp: str
forecast_MW: float
confidence_lower: float
confidence_upper: float
explanation: str
was_clamped: bool
clamp_reason: Optional[str] = None
drivers: dict
class ClusterForecastRequest(BaseModel):
plant_ids: List[str]
context_hours: int = 168
prediction_hours: int = 24
forecast_timestamp: Optional[str] = None
language: str = "en"
cluster_name: str = "Karnataka Renewable Cluster"
class ClusterForecastResponse(BaseModel):
cluster_name: str
timestamp: str
aggregated_forecast_MW: float
confidence_lower: float
confidence_upper: float
plant_breakdown: List[dict]
key_drivers: str
@app.get("/health")
def health():
return {"status": "ok", "model": "amazon/chronos-bolt-small", "version": "0.1.0"}
def load_inputs():
scada = pd.read_csv("data/synthetic_scada/synthetic_scada.csv", parse_dates=["timestamp"])
weather = pd.read_parquet("data/weather/weather_all.parquet")
scada["timestamp"] = pd.to_datetime(scada["timestamp"]).dt.tz_localize(None)
weather["timestamp"] = pd.to_datetime(weather["timestamp"]).dt.tz_localize(None)
return scada, weather
def validate_language(language: str) -> str:
value = (language or "en").strip().lower()
if value not in SUPPORTED_LANGUAGES:
raise HTTPException(status_code=422, detail=f"Unsupported language '{language}'. Use one of {sorted(SUPPORTED_LANGUAGES)}.")
return value
def build_forecast_frame(
pipeline,
scada: pd.DataFrame,
weather: pd.DataFrame,
plant_id: str,
context_hours: int,
prediction_hours: int,
forecast_timestamp: Optional[str] = None,
language: str = "en",
):
meta_map = {p["plant_id"]: p for p in PLANTS}
if plant_id not in meta_map:
raise HTTPException(status_code=404, detail=f"Unknown plant_id '{plant_id}'.")
if prediction_hours <= 0 or prediction_hours > 48:
raise HTTPException(status_code=422, detail="prediction_hours must be between 1 and 48.")
if context_hours < prediction_hours * 2:
raise HTTPException(status_code=422, detail="context_hours must be at least 2x prediction_hours.")
ctx = scada[scada["plant_id"] == plant_id].copy().sort_values("timestamp")
if ctx.empty:
raise HTTPException(status_code=404, detail=f"No SCADA data found for '{plant_id}'.")
if forecast_timestamp:
ts = pd.to_datetime(forecast_timestamp)
ctx = ctx[ctx["timestamp"] <= ts]
else:
ts = ctx["timestamp"].max()
ctx = ctx.tail(context_hours)
if len(ctx) < prediction_hours * 2:
raise HTTPException(status_code=422, detail=f"Insufficient context for '{plant_id}'. Need at least {prediction_hours * 2} rows.")
pred_df = forecast_baseline(pipeline, ctx, prediction_length=prediction_hours)
pred_df["plant_id"] = plant_id
future_weather = weather[
(weather["plant_id"] == plant_id) &
(weather["timestamp"] > ts) &
(weather["timestamp"] <= ts + pd.Timedelta(hours=prediction_hours))
].copy()
pred_df = apply_residual_layer(pred_df, future_weather, PLANTS)
pred_df = apply_physics_constraints(pred_df, future_weather, PLANTS)
pred_df = apply_confidence_bands(pred_df, PLANTS)
pred_df = generate_explanations(pred_df, language=language)
pred_df = get_override_manager().apply_overrides(pred_df)
pred_df["forecast_MW"] = pred_df["final_forecast_MW"].astype(float).round(3)
pred_df["plant_type"] = meta_map[plant_id]["type"]
pred_df["requested_language"] = language
return pred_df
@app.post("/forecast", response_model=List[ForecastResponse])
def forecast(req: ForecastRequest):
pipeline = get_pipeline()
scada, weather = load_inputs()
language = validate_language(req.language)
pred_df = build_forecast_frame(
pipeline,
scada,
weather,
req.plant_id,
req.context_hours,
req.prediction_hours,
forecast_timestamp=req.forecast_timestamp,
language=language,
)
results = []
for _, row in pred_df.iterrows():
results.append(ForecastResponse(
plant_id=row["plant_id"],
timestamp=str(row["timestamp"]),
forecast_MW=round(float(row["forecast_MW"]), 3),
confidence_lower=round(float(row["confidence_lower"]), 3),
confidence_upper=round(float(row["confidence_upper"]), 3),
explanation=row.get("explanation", ""),
was_clamped=bool(row.get("was_clamped", False)),
clamp_reason=row.get("clamp_reason", None),
drivers={
"cloud_fraction": round(float(row.get("cloud_fraction", 0)), 3),
"wind_speed_10m": None if pd.isna(row.get("wind_speed_10m")) else round(float(row.get("wind_speed_10m", 0)), 2),
"residual_MW": round(float(row.get("residual_MW", 0)), 3),
"wind_based_MW": None if pd.isna(row.get("wind_based_MW")) else round(float(row.get("wind_based_MW", 0)), 3),
}
))
return results
@app.post("/forecast/bulk")
def forecast_bulk(file: UploadFile = File(...)):
"""Upload CSV and return batch forecasts as CSV."""
if not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="Only CSV files are supported.")
contents = file.file.read()
if len(contents) > 2_000_000:
raise HTTPException(status_code=413, detail="Bulk CSV is too large. Keep uploads under 2 MB.")
try:
req_df = pd.read_csv(io.StringIO(contents.decode("utf-8")))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not parse CSV: {exc}") from exc
if "plant_id" not in req_df.columns:
raise HTTPException(status_code=400, detail="CSV must include a plant_id column.")
pipeline = get_pipeline()
scada, weather = load_inputs()
rows = []
for idx, record in req_df.fillna("").iterrows():
plant_id = str(record.get("plant_id", "")).strip()
if not plant_id:
continue
prediction_hours = int(record.get("prediction_hours") or 24)
context_hours = int(record.get("context_hours") or 168)
raw_forecast_timestamp = record.get("forecast_timestamp")
if raw_forecast_timestamp in ("", None) or pd.isna(raw_forecast_timestamp):
forecast_timestamp = None
else:
forecast_timestamp = str(raw_forecast_timestamp).strip() or None
language = validate_language(str(record.get("language") or "en"))
pred_df = build_forecast_frame(
pipeline,
scada,
weather,
plant_id,
context_hours,
prediction_hours,
forecast_timestamp=forecast_timestamp,
language=language,
)
pred_df = pred_df.copy()
pred_df["request_index"] = idx
pred_df["requested_timestamp"] = forecast_timestamp or ""
pred_df["requested_prediction_hours"] = prediction_hours
pred_df["requested_context_hours"] = context_hours
rows.append(
pred_df[
[
"request_index",
"plant_id",
"timestamp",
"forecast_MW",
"confidence_lower",
"confidence_upper",
"requested_prediction_hours",
"requested_context_hours",
"requested_timestamp",
"requested_language",
"explanation",
"was_clamped",
"clamp_reason",
]
]
)
if not rows:
raise HTTPException(status_code=400, detail="No valid rows found in bulk CSV.")
out_df = pd.concat(rows, ignore_index=True)
csv_bytes = out_df.to_csv(index=False).encode("utf-8")
headers = {"Content-Disposition": "attachment; filename=bulk_forecast_results.csv"}
return StreamingResponse(io.BytesIO(csv_bytes), media_type="text/csv", headers=headers)
@app.post("/forecast/cluster", response_model=List[ClusterForecastResponse])
def forecast_cluster(req: ClusterForecastRequest):
"""
Aggregate forecasts across multiple plants (cluster/region level).
Sums individual plant forecasts and combines confidence bands.
"""
pipeline = get_pipeline()
scada, weather = load_inputs()
language = validate_language(req.language)
all_plant_forecasts = []
for pid in req.plant_ids:
pred_df = build_forecast_frame(
pipeline,
scada,
weather,
pid,
req.context_hours,
req.prediction_hours,
forecast_timestamp=req.forecast_timestamp,
language=language,
)
all_plant_forecasts.append(pred_df)
if not all_plant_forecasts:
return []
combined = pd.concat(all_plant_forecasts, ignore_index=True)
merged = (
combined.groupby("timestamp", as_index=False)
.agg(
aggregated_forecast_MW=("forecast_MW", "sum"),
confidence_lower=("confidence_lower", "sum"),
confidence_upper=("confidence_upper", "sum"),
physics_events=("was_clamped", "sum"),
)
.sort_values("timestamp")
)
results = []
for _, row in merged.iterrows():
breakdown = []
for df in all_plant_forecasts:
sub = df[df["timestamp"] == row["timestamp"]]
if not sub.empty:
s = sub.iloc[0]
breakdown.append({
"plant_id": s["plant_id"],
"forecast_MW": round(float(s["forecast_MW"]), 1),
"explanation": s.get("explanation", "")[:60]
})
results.append(ClusterForecastResponse(
cluster_name=req.cluster_name,
timestamp=str(row["timestamp"]),
aggregated_forecast_MW=round(float(row["aggregated_forecast_MW"]), 1),
confidence_lower=round(float(row["confidence_lower"]), 1),
confidence_upper=round(float(row["confidence_upper"]), 1),
plant_breakdown=breakdown,
key_drivers=f"{len(breakdown)} plants aggregated; {int(row['physics_events'])} physics events"
))
return results
# ── Override Endpoints ──────────────────────────────────────
class OverrideRequest(BaseModel):
plant_id: str
start_time: str
end_time: str
override_type: str # zero, cap, scale, absolute
value: Optional[float] = None
reason: str
created_by: str = "operator"
@app.post("/override")
def create_override(req: OverrideRequest):
"""SLDC operator creates an override (maintenance, curtailment, etc.)."""
mgr = get_override_manager()
rule = OverrideRule(
plant_id=req.plant_id,
start_time=req.start_time,
end_time=req.end_time,
override_type=req.override_type,
value=req.value,
reason=req.reason,
created_by=req.created_by,
)
mgr.add_rule(rule)
return {"status": "created", "rule": rule.to_dict()}
@app.get("/overrides")
def list_overrides(plant_id: Optional[str] = None):
"""List active operator overrides."""
mgr = get_override_manager()
return {"overrides": mgr.list_rules(plant_id)}
@app.delete("/override/clear")
def clear_expired_overrides():
"""Remove expired override rules."""
mgr = get_override_manager()
mgr.clear_expired()
return {"status": "cleared"}
# ── Data Quality Endpoint ───────────────────────────────────
@app.get("/data-quality")
def data_quality():
"""Run data quality checks on SCADA input and return issues."""
scada = pd.read_csv("data/synthetic_scada/synthetic_scada.csv", parse_dates=["timestamp"])
clean, issues = check_data_quality(scada, PLANTS)
report_path = generate_quality_report(issues)
return {
"total_issues": len(issues),
"critical_count": sum(1 for i in issues if i["severity"] == "critical"),
"warning_count": sum(1 for i in issues if i["severity"] == "warning"),
"issues": issues,
"report_file": report_path,
}
# ── CERC Compliance Endpoint ────────────────────────────────
@app.get("/compliance")
def compliance():
"""Benchmark forecast accuracy against CERC regulatory limits."""
forecasts = pd.read_parquet("models/baseline/baseline_forecasts.parquet")
actuals = pd.read_parquet("models/baseline/baseline_actuals.parquet")
weather = pd.read_parquet("data/weather/weather_all.parquet")
for df in (forecasts, actuals, weather):
df["timestamp"] = pd.to_datetime(df["timestamp"]).dt.tz_localize(None)
postprocessed = []
for pid in forecasts["plant_id"].unique():
sub = forecasts[forecasts["plant_id"] == pid].copy()
future_weather = weather[weather["plant_id"] == pid].copy()
sub = apply_residual_layer(sub, future_weather, PLANTS)
sub = apply_physics_constraints(sub, future_weather, PLANTS)
sub = apply_confidence_bands(sub, PLANTS)
sub["forecast_MW"] = sub["final_forecast_MW"]
postprocessed.append(sub[["plant_id", "timestamp", "forecast_MW"]])
final_forecasts = pd.concat(postprocessed, ignore_index=True)
metrics = compute_cerc_metrics(final_forecasts, actuals, PLANTS)
report = generate_cerc_report(metrics)
return report
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)