Spaces:
Running
Running
File size: 12,041 Bytes
fd0bd3b 2f1129f fd0bd3b 19b8b03 e6a4103 2420f34 fd0bd3b 19b8b03 fd0bd3b e6a4103 2f1129f e6a4103 c6457f4 e6a4103 2f1129f ead45e6 fd0bd3b 2f1129f fd0bd3b e6a4103 2f1129f e6a4103 2f1129f fd0bd3b ead45e6 0d542b0 fd0bd3b 2f1129f fd0bd3b 2f1129f c6457f4 2420f34 19b8b03 fd0bd3b 2f1129f fd0bd3b e6a4103 fd0bd3b e6a4103 fd0bd3b c6457f4 6250d00 e6a4103 fd0bd3b 3f12710 e6a4103 c6457f4 fd0bd3b c6457f4 fd0bd3b 2f1129f fd0bd3b e6a4103 fd0bd3b e6a4103 fd0bd3b c6457f4 6250d00 e6a4103 fd0bd3b e6a4103 fd0bd3b c6457f4 fd0bd3b 2f1129f fd0bd3b e6a4103 fd0bd3b e6a4103 fd0bd3b c6457f4 6250d00 e6a4103 fd0bd3b e6a4103 fd0bd3b c6457f4 fd0bd3b 2f1129f fd0bd3b 2f1129f fd0bd3b 2f1129f fd0bd3b 2f1129f fd0bd3b e6a4103 2f1129f e6a4103 fd0bd3b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 | """
REST API endpoints for BESS SCADA data and dispatch energy data.
"""
import json
import logging
import time
from datetime import date, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from app.config import (
ANALYTICS_TOKEN,
DATA_START_DATE,
DISPATCH_START_DATE,
FPPMW_CUTOVER_DATE,
MAX_DAYS_PER_REQUEST,
)
from app.services.aemo_fetcher import AEMOFetchError, FetchResult, fetch_csv_for_date
from app.services.analytics import get_stats, get_timing_estimate, log_request
from app.services.dispatch_fetcher import DispatchFetchError, fetch_dispatch_csv_for_date
from app.services.dispatch_processor import (
DispatchProcessingError,
compute_dispatch_summary,
filter_and_process_dispatch,
to_csv_bytes as dispatch_to_csv_bytes,
to_json_records as dispatch_to_json_records,
to_parquet_bytes as dispatch_to_parquet_bytes,
)
from app.services.gen_info_fetcher import fetch_bess_list
from app.services.data_processor import (
DataProcessingError,
compute_summary,
filter_and_process,
to_csv_bytes,
to_json_records,
to_parquet_bytes,
)
router = APIRouter(prefix="/api")
DATA_DIR = Path(__file__).parent.parent / "data"
def _get_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _parse_date(date_str: str) -> date:
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid date format. Use YYYY-MM-DD."
)
def _source_type(target_date: date) -> str:
"""'current' for SCADA dates from the Current directory, 'archive' otherwise."""
return "current" if target_date >= FPPMW_CUTOVER_DATE else "archive"
# ββ BESS list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/bess")
async def get_bess_list():
"""
Return in-service battery storage units grouped by state.
Response shape:
{
"states": { "NSW": [...], "VIC": [...], ... },
"source": "live" | "mirror" | "snapshot",
"fetched_at": ISO-8601 timestamp,
"warnings": list of strings (may be empty)
}
"""
result = await fetch_bess_list()
return {
"states": result.states,
"source": result.source,
"fetched_at": result.fetched_at.isoformat(timespec="seconds") + "Z",
"warnings": result.warnings,
}
# ββ Quality flags βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/quality-flags")
def get_quality_flags():
"""Return MW_QUALITY_FLAG descriptions."""
flags_file = DATA_DIR / "quality_flags.json"
return json.loads(flags_file.read_text())
# ββ SCADA data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def _fetch_day(target_date: date, duid: str) -> FetchResult:
return await fetch_csv_for_date(target_date, duid)
@router.get("/data")
async def get_data(
request: Request,
duid: str = Query(..., description="BESS DUID identifier"),
date: str = Query(..., description="Date in YYYY-MM-DD format"),
):
"""Fetch and return filtered 4-second SCADA data as JSON."""
target_date = _parse_date(date)
ip = _get_ip(request)
src = _source_type(target_date)
t0 = time.monotonic()
try:
result = await _fetch_day(target_date, duid)
df = filter_and_process(result.csv_chunks, duid, target_date)
duration_ms = int((time.monotonic() - t0) * 1000)
summary = compute_summary(df)
records = to_json_records(df)
log_request(ip, duid, date, "view", duration_ms=duration_ms, source_type=src)
resp = {
"duid": duid,
"date": date,
"total_rows": len(df),
"displayed_rows": len(records),
"summary": summary,
"data": records,
}
if result.warnings:
resp["warnings"] = result.warnings
return resp
except AEMOFetchError as e:
raise HTTPException(status_code=503, detail=str(e))
except DataProcessingError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/download/csv")
async def download_csv(
request: Request,
duid: str = Query(...),
date: str = Query(...),
):
"""Download full filtered SCADA data as CSV."""
target_date = _parse_date(date)
ip = _get_ip(request)
src = _source_type(target_date)
t0 = time.monotonic()
try:
result = await _fetch_day(target_date, duid)
df = filter_and_process(result.csv_chunks, duid, target_date)
duration_ms = int((time.monotonic() - t0) * 1000)
output = to_csv_bytes(df)
log_request(ip, duid, date, "download_csv", duration_ms=duration_ms, source_type=src)
filename = f"BESS_SCADA_{duid}_{date}.csv"
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if result.warnings:
headers["X-Data-Warnings"] = "; ".join(result.warnings)
return Response(content=output, media_type="text/csv", headers=headers)
except AEMOFetchError as e:
raise HTTPException(status_code=503, detail=str(e))
except DataProcessingError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/download/parquet")
async def download_parquet(
request: Request,
duid: str = Query(...),
date: str = Query(...),
):
"""Download full filtered SCADA data as Parquet."""
target_date = _parse_date(date)
ip = _get_ip(request)
src = _source_type(target_date)
t0 = time.monotonic()
try:
result = await _fetch_day(target_date, duid)
df = filter_and_process(result.csv_chunks, duid, target_date)
duration_ms = int((time.monotonic() - t0) * 1000)
output = to_parquet_bytes(df)
log_request(ip, duid, date, "download_parquet", duration_ms=duration_ms, source_type=src)
filename = f"BESS_SCADA_{duid}_{date}.parquet"
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if result.warnings:
headers["X-Data-Warnings"] = "; ".join(result.warnings)
return Response(content=output, media_type="application/octet-stream", headers=headers)
except AEMOFetchError as e:
raise HTTPException(status_code=503, detail=str(e))
except DataProcessingError as e:
raise HTTPException(status_code=404, detail=str(e))
# ββ Dispatch / energy data ββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/energy-data")
async def get_energy_data(
request: Request,
duid: str = Query(..., description="BESS DUID identifier"),
date: str = Query(..., description="Date in YYYY-MM-DD format"),
):
"""Fetch and return filtered 5-minute dispatch energy data as JSON."""
target_date = _parse_date(date)
ip = _get_ip(request)
t0 = time.monotonic()
try:
csv_bytes, src = await fetch_dispatch_csv_for_date(target_date)
df = filter_and_process_dispatch(csv_bytes, duid, target_date)
duration_ms = int((time.monotonic() - t0) * 1000)
summary = compute_dispatch_summary(df)
records = dispatch_to_json_records(df)
log_request(ip, duid, date, "view", duration_ms=duration_ms, source_type=src)
return {
"duid": duid,
"date": date,
"total_rows": len(df),
"summary": summary,
"data": records,
}
except DispatchFetchError as e:
raise HTTPException(status_code=503, detail=str(e))
except DispatchProcessingError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/download/energy-csv")
async def download_energy_csv(
request: Request,
duid: str = Query(...),
date: str = Query(...),
):
"""Download full filtered dispatch energy data as CSV."""
target_date = _parse_date(date)
ip = _get_ip(request)
t0 = time.monotonic()
try:
csv_bytes, src = await fetch_dispatch_csv_for_date(target_date)
df = filter_and_process_dispatch(csv_bytes, duid, target_date)
duration_ms = int((time.monotonic() - t0) * 1000)
output = dispatch_to_csv_bytes(df)
log_request(ip, duid, date, "download_energy_csv",
duration_ms=duration_ms, source_type=src)
filename = f"BESS_Energy_{duid}_{date}.csv"
return Response(
content=output,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except DispatchFetchError as e:
raise HTTPException(status_code=503, detail=str(e))
except DispatchProcessingError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/download/energy-parquet")
async def download_energy_parquet(
request: Request,
duid: str = Query(...),
date: str = Query(...),
):
"""Download full filtered dispatch energy data as Parquet."""
target_date = _parse_date(date)
ip = _get_ip(request)
t0 = time.monotonic()
try:
csv_bytes, src = await fetch_dispatch_csv_for_date(target_date)
df = filter_and_process_dispatch(csv_bytes, duid, target_date)
duration_ms = int((time.monotonic() - t0) * 1000)
output = dispatch_to_parquet_bytes(df)
log_request(ip, duid, date, "download_energy_parquet",
duration_ms=duration_ms, source_type=src)
filename = f"BESS_Energy_{duid}_{date}.parquet"
return Response(
content=output,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except DispatchFetchError as e:
raise HTTPException(status_code=503, detail=str(e))
except DispatchProcessingError as e:
raise HTTPException(status_code=404, detail=str(e))
# ββ Admin βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/analytics")
def analytics(token: str = Query(...)):
"""Admin-only analytics endpoint."""
if token != ANALYTICS_TOKEN:
raise HTTPException(status_code=403, detail="Invalid token.")
return get_stats()
# ββ App info ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/info")
def info():
"""Return app metadata and timing estimates for the frontend."""
return {
"data_start_date": DATA_START_DATE.isoformat(),
"dispatch_start_date": DISPATCH_START_DATE.isoformat(),
"cutover_date": FPPMW_CUTOVER_DATE.isoformat(),
"max_days_per_request": MAX_DAYS_PER_REQUEST,
"estimates": {
"current": get_timing_estimate("current"),
"archive": get_timing_estimate("archive"),
"dispatch_current": get_timing_estimate("dispatch_current"),
"dispatch_archive": get_timing_estimate("dispatch_archive"),
},
}
|