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"),
        },
    }