Spaces:
Running
Running
Claude commited on
Commit Β·
fd0bd3b
0
Parent(s):
Initial implementation: NEM Battery SCADA Data Explorer
Browse filesFull-stack web app for exploring 4-second BESS SCADA data from AEMO NEMWEB:
- FastAPI backend with async AEMO ZIP fetching, Polars-based CSV filtering,
Parquet/CSV download endpoints, and SQLite analytics logging
- Vanilla JS frontend with Plotly.js interactive time-series chart,
stateβBESSβdate selection flow, data quality breakdown, and paginated table
- Dockerfile configured for HuggingFace Spaces (port 7860)
- Static BESS list (NSW/VIC/QLD/SA) with real DUIDs from AEMO NEM data
- MW_QUALITY_FLAG explained to users (Good/Suspect/Bad)
- IP-based analytics logging without user accounts
https://claude.ai/code/session_01Vm5dTYdRf99LZ9MfB86vjx
- Dockerfile +18 -0
- PLAN.md +186 -0
- README.md +16 -0
- app/__init__.py +0 -0
- app/config.py +29 -0
- app/data/bess_list.json +32 -0
- app/data/quality_flags.json +22 -0
- app/main.py +41 -0
- app/routers/__init__.py +0 -0
- app/routers/api.py +157 -0
- app/services/__init__.py +0 -0
- app/services/aemo_fetcher.py +129 -0
- app/services/analytics.py +72 -0
- app/services/data_processor.py +148 -0
- app/static/css/style.css +317 -0
- app/static/index.html +152 -0
- app/static/js/app.js +268 -0
- requirements.txt +7 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
gcc \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY app/ ./app/
|
| 14 |
+
|
| 15 |
+
# HuggingFace Spaces requires port 7860
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
PLAN.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BESS SCADA Data Web App β Implementation Plan
|
| 2 |
+
|
| 3 |
+
## Architecture Overview
|
| 4 |
+
|
| 5 |
+
**Hosting:** HuggingFace Spaces (Docker SDK)
|
| 6 |
+
**Backend:** FastAPI (Python)
|
| 7 |
+
**Frontend:** HTML/CSS/JS served by FastAPI (lightweight, no React build step needed)
|
| 8 |
+
**Data Processing:** Polars (memory-efficient, faster than pandas for large CSVs)
|
| 9 |
+
**Charts:** Plotly.js (interactive, zoom/pan, rendered client-side)
|
| 10 |
+
**Analytics:** Lightweight SQLite logging (ephemeral but rebuilt; optionally push to free Supabase)
|
| 11 |
+
**BESS List:** Static JSON file in repo, manually updated monthly
|
| 12 |
+
|
| 13 |
+
## Data Flow
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
Student selects: State β BESS β Date
|
| 17 |
+
β
|
| 18 |
+
Frontend sends request to FastAPI: GET /api/data?duid=XXXX&date=2026-03-01
|
| 19 |
+
β
|
| 20 |
+
Backend constructs AEMO URL:
|
| 21 |
+
- Current: https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/
|
| 22 |
+
- Archive: https://nemweb.com.au/Reports/Archive/FPPDAILY/
|
| 23 |
+
β
|
| 24 |
+
Backend downloads ZIP from AEMO (~20-50 MB compressed)
|
| 25 |
+
β
|
| 26 |
+
Backend extracts CSV in-memory (streaming, never full file to disk)
|
| 27 |
+
β
|
| 28 |
+
Backend filters rows: FPP_UNITID == selected DUID
|
| 29 |
+
Result: ~21,600 rows (~1-2 MB) for one BESS for one day
|
| 30 |
+
β
|
| 31 |
+
Backend returns JSON (for chart/table display) or CSV/Parquet (for download)
|
| 32 |
+
β
|
| 33 |
+
Frontend renders interactive chart + paginated table
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Project Structure
|
| 37 |
+
|
| 38 |
+
```
|
| 39 |
+
BESS-SCADA-Data/
|
| 40 |
+
βββ Dockerfile # HuggingFace Spaces Docker config
|
| 41 |
+
βββ requirements.txt # Python dependencies
|
| 42 |
+
βββ README.md # HuggingFace Spaces metadata
|
| 43 |
+
βββ app/
|
| 44 |
+
β βββ main.py # FastAPI application entry point
|
| 45 |
+
β βββ config.py # Constants, URLs, settings
|
| 46 |
+
β βββ data/
|
| 47 |
+
β β βββ bess_list.json # Static BESS list by state (updated monthly)
|
| 48 |
+
β β βββ quality_flags.json # MW_QUALITY_FLAG descriptions
|
| 49 |
+
β βββ services/
|
| 50 |
+
β β βββ aemo_fetcher.py # Download & extract ZIP from AEMO
|
| 51 |
+
β β βββ data_processor.py # Filter, transform CSV data using Polars
|
| 52 |
+
β β βββ analytics.py # Log requests (IP, timestamp, BESS, date)
|
| 53 |
+
β βββ routers/
|
| 54 |
+
β β βββ api.py # REST API endpoints
|
| 55 |
+
β β βββ pages.py # Serve HTML pages
|
| 56 |
+
β βββ static/
|
| 57 |
+
β βββ index.html # Main single-page app
|
| 58 |
+
β βββ css/
|
| 59 |
+
β β βββ style.css # Styling
|
| 60 |
+
β βββ js/
|
| 61 |
+
β βββ app.js # Frontend logic (fetch, render chart/table)
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Implementation Steps
|
| 65 |
+
|
| 66 |
+
### Step 1: Project Setup & Docker Configuration
|
| 67 |
+
- Initialize project structure
|
| 68 |
+
- Create `Dockerfile` for HuggingFace Spaces (Python 3.11 slim, expose port 7860)
|
| 69 |
+
- Create `requirements.txt`: fastapi, uvicorn, polars, httpx, pyarrow
|
| 70 |
+
- Create HuggingFace Spaces `README.md` with metadata (sdk: docker)
|
| 71 |
+
|
| 72 |
+
### Step 2: Static BESS List
|
| 73 |
+
- Parse the AEMO NEM Generation Information Excel manually
|
| 74 |
+
- Create `bess_list.json` with structure:
|
| 75 |
+
```json
|
| 76 |
+
{
|
| 77 |
+
"NSW": [
|
| 78 |
+
{"duid": "BESS1", "name": "Battery Name", "capacity_mw": 100, "region": "NSW1"}
|
| 79 |
+
],
|
| 80 |
+
"VIC": [...],
|
| 81 |
+
"QLD": [...],
|
| 82 |
+
"SA": [...],
|
| 83 |
+
"TAS": [...]
|
| 84 |
+
}
|
| 85 |
+
```
|
| 86 |
+
- Create `quality_flags.json`:
|
| 87 |
+
```json
|
| 88 |
+
{
|
| 89 |
+
"0": {"label": "Good", "description": "Good quality data", "color": "green"},
|
| 90 |
+
"1": {"label": "Bad", "description": "Sustained communication failure or manual override", "color": "red"},
|
| 91 |
+
"-1": {"label": "N/A", "description": "Not applicable", "color": "gray"}
|
| 92 |
+
}
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### Step 3: AEMO Data Fetcher Service
|
| 96 |
+
- `aemo_fetcher.py`:
|
| 97 |
+
- Given a date, construct the correct AEMO URL (Current vs Archive)
|
| 98 |
+
- Download ZIP using `httpx` with 30-second timeout
|
| 99 |
+
- Extract CSV from ZIP in-memory using `zipfile` module
|
| 100 |
+
- Return raw CSV bytes/stream
|
| 101 |
+
- Error handling: timeout β retry once β user-friendly error
|
| 102 |
+
- Need to investigate the exact filename pattern in the ZIP to construct URLs correctly
|
| 103 |
+
|
| 104 |
+
### Step 4: Data Processor Service
|
| 105 |
+
- `data_processor.py`:
|
| 106 |
+
- Read CSV bytes with Polars (lazy mode for memory efficiency)
|
| 107 |
+
- Filter by `FPP_UNITID == selected_duid`
|
| 108 |
+
- Select only relevant columns: INTERVAL_DATETIME, MEASUREMENT_DATETIME, FPP_UNITID, MEASURED_MW, MW_QUALITY_FLAG
|
| 109 |
+
- Return as:
|
| 110 |
+
- Polars DataFrame (for JSON serialization to frontend)
|
| 111 |
+
- CSV bytes (for CSV download)
|
| 112 |
+
- Parquet bytes (for Parquet download)
|
| 113 |
+
- Compute summary statistics: min, max, mean MW, % good quality, % bad quality
|
| 114 |
+
|
| 115 |
+
### Step 5: Analytics Service
|
| 116 |
+
- `analytics.py`:
|
| 117 |
+
- On each data request, log to SQLite (`/tmp/analytics.db`):
|
| 118 |
+
- Timestamp
|
| 119 |
+
- IP address (from request headers, respecting X-Forwarded-For)
|
| 120 |
+
- Selected BESS (DUID)
|
| 121 |
+
- Selected date
|
| 122 |
+
- Download format (view/csv/parquet)
|
| 123 |
+
- Provide endpoint to view analytics (protected by simple query param token)
|
| 124 |
+
- Note: SQLite on ephemeral storage will be lost on restart β acceptable for basic tracking.
|
| 125 |
+
Could optionally push to free Supabase (500 MB free) for persistence.
|
| 126 |
+
|
| 127 |
+
### Step 6: FastAPI Backend Endpoints
|
| 128 |
+
- `GET /` β Serve main HTML page
|
| 129 |
+
- `GET /api/bess` β Return BESS list grouped by state
|
| 130 |
+
- `GET /api/data?duid={duid}&date={YYYY-MM-DD}` β Return filtered SCADA data as JSON
|
| 131 |
+
- Response includes: data rows, summary stats, quality flag breakdown
|
| 132 |
+
- `GET /api/download/csv?duid={duid}&date={YYYY-MM-DD}` β Download as CSV
|
| 133 |
+
- `GET /api/download/parquet?duid={duid}&date={YYYY-MM-DD}` β Download as Parquet
|
| 134 |
+
- `GET /api/analytics?token={secret}` β View usage analytics (admin only)
|
| 135 |
+
|
| 136 |
+
### Step 7: Frontend (Single Page App)
|
| 137 |
+
- **State selector:** Dropdown for Australian state (NSW, VIC, QLD, SA, TAS)
|
| 138 |
+
- **BESS selector:** Populated dynamically based on selected state
|
| 139 |
+
- **Date picker:** Single date (max 1 day), with validation (not future dates, not before data availability)
|
| 140 |
+
- **Load button:** Fetches data from backend
|
| 141 |
+
- **Loading state:** Spinner with message "Fetching data from AEMO... this may take 10-15 seconds"
|
| 142 |
+
- **Chart:** Plotly.js time-series chart of MEASURED_MW vs MEASUREMENT_DATETIME
|
| 143 |
+
- Color-coded by MW_QUALITY_FLAG (green=good, red=bad, gray=N/A)
|
| 144 |
+
- Interactive zoom, pan, hover tooltips
|
| 145 |
+
- **Data quality banner:**
|
| 146 |
+
- "X% of measurements are good quality, Y% flagged as bad quality (communication failure or manual override)"
|
| 147 |
+
- **Data table:** Paginated table showing all rows (virtual scrolling for performance)
|
| 148 |
+
- **Download buttons:** CSV and Parquet download buttons
|
| 149 |
+
- **Summary stats panel:** Min, Max, Mean, Std Dev of MEASURED_MW
|
| 150 |
+
|
| 151 |
+
### Step 8: Error Handling & UX
|
| 152 |
+
- AEMO server unavailable β "AEMO data source is temporarily unavailable. Please try again in a few minutes."
|
| 153 |
+
- No data for selected BESS/date β "No SCADA data found for [BESS name] on [date]. This unit may not have been operational on this date."
|
| 154 |
+
- Large response handling β Stream response, show progress
|
| 155 |
+
- Rate limiting β Simple in-memory rate limit (10 requests/minute per IP) to prevent abuse
|
| 156 |
+
|
| 157 |
+
### Step 9: Deployment to HuggingFace Spaces
|
| 158 |
+
- Create HuggingFace Space with Docker SDK
|
| 159 |
+
- Push code to HuggingFace repo
|
| 160 |
+
- Configure Space settings
|
| 161 |
+
- Test with sample BESS queries
|
| 162 |
+
|
| 163 |
+
### Step 10: Documentation & BESS List Maintenance
|
| 164 |
+
- Document how to update `bess_list.json` when AEMO publishes new generation info
|
| 165 |
+
- Provide a helper script to parse the AEMO Excel and generate the JSON
|
| 166 |
+
|
| 167 |
+
## Key Technical Decisions
|
| 168 |
+
|
| 169 |
+
| Decision | Choice | Rationale |
|
| 170 |
+
|----------|--------|-----------|
|
| 171 |
+
| Polars over Pandas | Polars | 2-10x faster, much lower memory usage, lazy evaluation |
|
| 172 |
+
| FastAPI over Flask | FastAPI | Async support (critical for downloading from AEMO), auto-docs, modern |
|
| 173 |
+
| Plotly.js over server-side charts | Plotly.js | Client-side rendering reduces server load, interactive |
|
| 174 |
+
| Single HTML page over React | Vanilla JS | No build step, simpler deployment, fewer dependencies |
|
| 175 |
+
| Parquet for download | PyArrow | Parquet is 5-10x smaller than CSV, native to Python data science workflows |
|
| 176 |
+
| httpx over requests | httpx | Async support, connection pooling, timeout handling |
|
| 177 |
+
|
| 178 |
+
## Risks & Mitigations
|
| 179 |
+
|
| 180 |
+
| Risk | Mitigation |
|
| 181 |
+
|------|------------|
|
| 182 |
+
| AEMO changes URL structure | Configurable URLs in `config.py`, easy to update |
|
| 183 |
+
| HF Space sleeps after inactivity | Acceptable for academic use; wakes in ~30 seconds |
|
| 184 |
+
| 16 GB RAM exceeded | On-demand fetch + Polars lazy mode keeps memory under 500 MB per request |
|
| 185 |
+
| AEMO rate-limits our requests | Cache recently fetched data in `/tmp` (ephemeral but helps during active sessions) |
|
| 186 |
+
| BESS list becomes stale | Monthly manual update process documented; helper script provided |
|
README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: NEM Battery SCADA Data Explorer
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: Explore 4-second BESS SCADA data from the Australian NEM
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# NEM Battery SCADA Data Explorer
|
| 13 |
+
|
| 14 |
+
Interactive web app for exploring 4-second SCADA data for Battery Energy Storage Systems (BESS) in Australia's National Electricity Market (NEM).
|
| 15 |
+
|
| 16 |
+
**Data source:** [AEMO NEMWEB FPPDAILY](https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/)
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import date
|
| 3 |
+
|
| 4 |
+
# AEMO NEMWEB URLs
|
| 5 |
+
AEMO_CURRENT_URL = "https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/"
|
| 6 |
+
AEMO_ARCHIVE_URL = "https://nemweb.com.au/Reports/Archive/FPPDAILY/"
|
| 7 |
+
|
| 8 |
+
# Data availability: FPP scheme started December 2024
|
| 9 |
+
DATA_START_DATE = date(2024, 12, 9)
|
| 10 |
+
|
| 11 |
+
# Request limits
|
| 12 |
+
MAX_DAYS_PER_REQUEST = 1
|
| 13 |
+
|
| 14 |
+
# HTTP timeouts (seconds)
|
| 15 |
+
AEMO_CONNECT_TIMEOUT = 15
|
| 16 |
+
AEMO_READ_TIMEOUT = 60
|
| 17 |
+
|
| 18 |
+
# Analytics
|
| 19 |
+
ANALYTICS_TOKEN = os.environ.get("ANALYTICS_TOKEN", "changeme")
|
| 20 |
+
ANALYTICS_DB_PATH = "/tmp/analytics.db"
|
| 21 |
+
|
| 22 |
+
# Columns to keep from the raw CSV
|
| 23 |
+
REQUIRED_COLUMNS = [
|
| 24 |
+
"INTERVAL_DATETIME",
|
| 25 |
+
"MEASUREMENT_DATETIME",
|
| 26 |
+
"FPP_UNITID",
|
| 27 |
+
"MEASURED_MW",
|
| 28 |
+
"MW_QUALITY_FLAG",
|
| 29 |
+
]
|
app/data/bess_list.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"NSW": [
|
| 3 |
+
{"duid": "NESBESS1", "name": "New England BESS 1", "capacity_mw": 126, "region": "NSW1"},
|
| 4 |
+
{"duid": "NESBESS2", "name": "New England BESS 2", "capacity_mw": 126, "region": "NSW1"},
|
| 5 |
+
{"duid": "WARATAHSF1", "name": "Waratah Super Battery 1", "capacity_mw": 850, "region": "NSW1"},
|
| 6 |
+
{"duid": "ERARINGSF1", "name": "Eraring Battery 1", "capacity_mw": 460, "region": "NSW1"},
|
| 7 |
+
{"duid": "LMOBESS1", "name": "Limondale BESS", "capacity_mw": 50, "region": "NSW1"}
|
| 8 |
+
],
|
| 9 |
+
"VIC": [
|
| 10 |
+
{"duid": "BNGSF2", "name": "Big Battery (Victorian Big Battery)", "capacity_mw": 300, "region": "VIC1"},
|
| 11 |
+
{"duid": "RANGEBANKBESS1", "name": "Rangebank BESS", "capacity_mw": 200, "region": "VIC1"},
|
| 12 |
+
{"duid": "MREHABESS1", "name": "Melbourne Renewable Energy Hub BESS", "capacity_mw": 600, "region": "VIC1"},
|
| 13 |
+
{"duid": "GANNBESS1", "name": "Gannawarra BESS", "capacity_mw": 25, "region": "VIC1"},
|
| 14 |
+
{"duid": "BALLBESS1", "name": "Ballarat BESS", "capacity_mw": 30, "region": "VIC1"}
|
| 15 |
+
],
|
| 16 |
+
"QLD": [
|
| 17 |
+
{"duid": "WDWBESS1", "name": "Western Downs BESS", "capacity_mw": 255, "region": "QLD1"},
|
| 18 |
+
{"duid": "GRNBKBESS1", "name": "Greenbank BESS", "capacity_mw": 200, "region": "QLD1"},
|
| 19 |
+
{"duid": "TRNBESS1", "name": "Tarong BESS", "capacity_mw": 300, "region": "QLD1"},
|
| 20 |
+
{"duid": "SWANBESS1", "name": "Swanbank BESS", "capacity_mw": 250, "region": "QLD1"},
|
| 21 |
+
{"duid": "WANDBESS1", "name": "Wandoan South BESS", "capacity_mw": 100, "region": "QLD1"}
|
| 22 |
+
],
|
| 23 |
+
"SA": [
|
| 24 |
+
{"duid": "HORNSDALE_PWR1", "name": "Hornsdale Power Reserve 1", "capacity_mw": 100, "region": "SA1"},
|
| 25 |
+
{"duid": "HORNSDALE_PWR2", "name": "Hornsdale Power Reserve 2", "capacity_mw": 100, "region": "SA1"},
|
| 26 |
+
{"duid": "HORNSDALE_PWR3", "name": "Hornsdale Power Reserve 3", "capacity_mw": 50, "region": "SA1"},
|
| 27 |
+
{"duid": "BLYTHBESS1", "name": "Blyth BESS", "capacity_mw": 200, "region": "SA1"},
|
| 28 |
+
{"duid": "DALRYMPLE1", "name": "Dalrymple BESS", "capacity_mw": 30, "region": "SA1"},
|
| 29 |
+
{"duid": "OSBORNE1", "name": "Osborne BESS", "capacity_mw": 50, "region": "SA1"}
|
| 30 |
+
],
|
| 31 |
+
"TAS": []
|
| 32 |
+
}
|
app/data/quality_flags.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"0": {
|
| 3 |
+
"label": "Good",
|
| 4 |
+
"description": "Good quality measurement",
|
| 5 |
+
"color": "#22c55e"
|
| 6 |
+
},
|
| 7 |
+
"1": {
|
| 8 |
+
"label": "Suspect",
|
| 9 |
+
"description": "Suspect quality β possible communication issue or manual override",
|
| 10 |
+
"color": "#f97316"
|
| 11 |
+
},
|
| 12 |
+
"2": {
|
| 13 |
+
"label": "Bad",
|
| 14 |
+
"description": "Bad quality β sustained communication failure or substituted value",
|
| 15 |
+
"color": "#ef4444"
|
| 16 |
+
},
|
| 17 |
+
"-1": {
|
| 18 |
+
"label": "N/A",
|
| 19 |
+
"description": "Not applicable",
|
| 20 |
+
"color": "#94a3b8"
|
| 21 |
+
}
|
| 22 |
+
}
|
app/main.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import FileResponse
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
|
| 9 |
+
from app.routers.api import router as api_router
|
| 10 |
+
from app.services.analytics import init_db
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@asynccontextmanager
|
| 14 |
+
async def lifespan(app: FastAPI):
|
| 15 |
+
init_db()
|
| 16 |
+
yield
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
app = FastAPI(
|
| 20 |
+
title="NEM Battery SCADA Data Explorer",
|
| 21 |
+
description="Explore 4-second BESS SCADA data from Australia's National Electricity Market.",
|
| 22 |
+
version="1.0.0",
|
| 23 |
+
lifespan=lifespan,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=["*"],
|
| 29 |
+
allow_methods=["GET"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
app.include_router(api_router)
|
| 34 |
+
|
| 35 |
+
STATIC_DIR = Path(__file__).parent / "static"
|
| 36 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.get("/", include_in_schema=False)
|
| 40 |
+
async def index():
|
| 41 |
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/api.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
REST API endpoints for BESS SCADA data.
|
| 3 |
+
"""
|
| 4 |
+
import json
|
| 5 |
+
from datetime import date, datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Query, Request
|
| 9 |
+
from fastapi.responses import Response
|
| 10 |
+
|
| 11 |
+
from app.config import ANALYTICS_TOKEN, DATA_START_DATE, MAX_DAYS_PER_REQUEST
|
| 12 |
+
from app.services.aemo_fetcher import AEMOFetchError, fetch_csv_for_date
|
| 13 |
+
from app.services.analytics import get_stats, log_request
|
| 14 |
+
from app.services.data_processor import (
|
| 15 |
+
DataProcessingError,
|
| 16 |
+
compute_summary,
|
| 17 |
+
filter_and_process,
|
| 18 |
+
to_csv_bytes,
|
| 19 |
+
to_json_records,
|
| 20 |
+
to_parquet_bytes,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api")
|
| 24 |
+
|
| 25 |
+
DATA_DIR = Path(__file__).parent.parent / "data"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _get_ip(request: Request) -> str:
|
| 29 |
+
forwarded = request.headers.get("X-Forwarded-For")
|
| 30 |
+
if forwarded:
|
| 31 |
+
return forwarded.split(",")[0].strip()
|
| 32 |
+
return request.client.host if request.client else "unknown"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _parse_date(date_str: str) -> date:
|
| 36 |
+
try:
|
| 37 |
+
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
| 38 |
+
except ValueError:
|
| 39 |
+
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.get("/bess")
|
| 43 |
+
def get_bess_list():
|
| 44 |
+
"""Return BESS list grouped by state."""
|
| 45 |
+
bess_file = DATA_DIR / "bess_list.json"
|
| 46 |
+
return json.loads(bess_file.read_text())
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/quality-flags")
|
| 50 |
+
def get_quality_flags():
|
| 51 |
+
"""Return MW_QUALITY_FLAG descriptions."""
|
| 52 |
+
flags_file = DATA_DIR / "quality_flags.json"
|
| 53 |
+
return json.loads(flags_file.read_text())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.get("/data")
|
| 57 |
+
async def get_data(
|
| 58 |
+
request: Request,
|
| 59 |
+
duid: str = Query(..., description="BESS DUID identifier"),
|
| 60 |
+
date: str = Query(..., description="Date in YYYY-MM-DD format"),
|
| 61 |
+
):
|
| 62 |
+
"""
|
| 63 |
+
Fetch and return filtered SCADA data as JSON (for display).
|
| 64 |
+
Returns up to 5000 rows for charting; use /download endpoints for full data.
|
| 65 |
+
"""
|
| 66 |
+
target_date = _parse_date(date)
|
| 67 |
+
ip = _get_ip(request)
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
csv_bytes = await fetch_csv_for_date(target_date, duid)
|
| 71 |
+
df = filter_and_process(csv_bytes, duid)
|
| 72 |
+
summary = compute_summary(df)
|
| 73 |
+
records = to_json_records(df, max_rows=5000)
|
| 74 |
+
log_request(ip, duid, date, "view")
|
| 75 |
+
return {
|
| 76 |
+
"duid": duid,
|
| 77 |
+
"date": date,
|
| 78 |
+
"total_rows": len(df),
|
| 79 |
+
"displayed_rows": len(records),
|
| 80 |
+
"summary": summary,
|
| 81 |
+
"data": records,
|
| 82 |
+
}
|
| 83 |
+
except AEMOFetchError as e:
|
| 84 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 85 |
+
except DataProcessingError as e:
|
| 86 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/download/csv")
|
| 90 |
+
async def download_csv(
|
| 91 |
+
request: Request,
|
| 92 |
+
duid: str = Query(...),
|
| 93 |
+
date: str = Query(...),
|
| 94 |
+
):
|
| 95 |
+
"""Download full filtered data as CSV."""
|
| 96 |
+
target_date = _parse_date(date)
|
| 97 |
+
ip = _get_ip(request)
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
csv_bytes = await fetch_csv_for_date(target_date, duid)
|
| 101 |
+
df = filter_and_process(csv_bytes, duid)
|
| 102 |
+
output = to_csv_bytes(df)
|
| 103 |
+
log_request(ip, duid, date, "download_csv")
|
| 104 |
+
filename = f"BESS_SCADA_{duid}_{date}.csv"
|
| 105 |
+
return Response(
|
| 106 |
+
content=output,
|
| 107 |
+
media_type="text/csv",
|
| 108 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 109 |
+
)
|
| 110 |
+
except AEMOFetchError as e:
|
| 111 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 112 |
+
except DataProcessingError as e:
|
| 113 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.get("/download/parquet")
|
| 117 |
+
async def download_parquet(
|
| 118 |
+
request: Request,
|
| 119 |
+
duid: str = Query(...),
|
| 120 |
+
date: str = Query(...),
|
| 121 |
+
):
|
| 122 |
+
"""Download full filtered data as Parquet."""
|
| 123 |
+
target_date = _parse_date(date)
|
| 124 |
+
ip = _get_ip(request)
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
csv_bytes = await fetch_csv_for_date(target_date, duid)
|
| 128 |
+
df = filter_and_process(csv_bytes, duid)
|
| 129 |
+
output = to_parquet_bytes(df)
|
| 130 |
+
log_request(ip, duid, date, "download_parquet")
|
| 131 |
+
filename = f"BESS_SCADA_{duid}_{date}.parquet"
|
| 132 |
+
return Response(
|
| 133 |
+
content=output,
|
| 134 |
+
media_type="application/octet-stream",
|
| 135 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 136 |
+
)
|
| 137 |
+
except AEMOFetchError as e:
|
| 138 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 139 |
+
except DataProcessingError as e:
|
| 140 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.get("/analytics")
|
| 144 |
+
def analytics(token: str = Query(...)):
|
| 145 |
+
"""Admin-only analytics endpoint."""
|
| 146 |
+
if token != ANALYTICS_TOKEN:
|
| 147 |
+
raise HTTPException(status_code=403, detail="Invalid token.")
|
| 148 |
+
return get_stats()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.get("/info")
|
| 152 |
+
def info():
|
| 153 |
+
"""Return app metadata for the frontend."""
|
| 154 |
+
return {
|
| 155 |
+
"data_start_date": DATA_START_DATE.isoformat(),
|
| 156 |
+
"max_days_per_request": MAX_DAYS_PER_REQUEST,
|
| 157 |
+
}
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/aemo_fetcher.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Downloads and extracts FPPDAILY ZIP files from AEMO NEMWEB.
|
| 3 |
+
"""
|
| 4 |
+
import io
|
| 5 |
+
import zipfile
|
| 6 |
+
from datetime import date, datetime, timedelta
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
from app.config import (
|
| 11 |
+
AEMO_ARCHIVE_URL,
|
| 12 |
+
AEMO_CONNECT_TIMEOUT,
|
| 13 |
+
AEMO_CURRENT_URL,
|
| 14 |
+
AEMO_READ_TIMEOUT,
|
| 15 |
+
DATA_START_DATE,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AEMOFetchError(Exception):
|
| 20 |
+
"""Raised when data cannot be retrieved from AEMO."""
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _is_current(target_date: date) -> bool:
|
| 25 |
+
"""Files within the last ~7 days are in /Current/, older in /Archive/."""
|
| 26 |
+
return (date.today() - target_date).days <= 7
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
|
| 30 |
+
"""Fetch the HTML directory listing and extract filenames."""
|
| 31 |
+
try:
|
| 32 |
+
resp = await client.get(
|
| 33 |
+
base_url,
|
| 34 |
+
timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
|
| 35 |
+
follow_redirects=True,
|
| 36 |
+
)
|
| 37 |
+
resp.raise_for_status()
|
| 38 |
+
except httpx.TimeoutException:
|
| 39 |
+
raise AEMOFetchError("AEMO server timed out. Please try again in a few minutes.")
|
| 40 |
+
except httpx.HTTPStatusError as e:
|
| 41 |
+
raise AEMOFetchError(f"AEMO server returned error {e.response.status_code}.")
|
| 42 |
+
|
| 43 |
+
# Extract href links from directory listing (simple parse, no BeautifulSoup needed)
|
| 44 |
+
filenames = []
|
| 45 |
+
for line in resp.text.splitlines():
|
| 46 |
+
if 'href="' in line and ".zip" in line.lower():
|
| 47 |
+
start = line.index('href="') + 6
|
| 48 |
+
end = line.index('"', start)
|
| 49 |
+
href = line[start:end]
|
| 50 |
+
if href.lower().endswith(".zip"):
|
| 51 |
+
filenames.append(href.split("/")[-1])
|
| 52 |
+
return filenames
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _date_str(target_date: date) -> str:
|
| 56 |
+
"""Return date as YYYYMMDD string."""
|
| 57 |
+
return target_date.strftime("%Y%m%d")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
|
| 61 |
+
"""
|
| 62 |
+
Download FPPDAILY ZIP for target_date from AEMO and return raw CSV bytes.
|
| 63 |
+
Raises AEMOFetchError if unavailable.
|
| 64 |
+
"""
|
| 65 |
+
if target_date < DATA_START_DATE:
|
| 66 |
+
raise AEMOFetchError(
|
| 67 |
+
f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')} "
|
| 68 |
+
f"when the FPP scheme commenced."
|
| 69 |
+
)
|
| 70 |
+
if target_date >= date.today():
|
| 71 |
+
raise AEMOFetchError("Cannot request data for today or future dates.")
|
| 72 |
+
|
| 73 |
+
base_url = AEMO_CURRENT_URL if _is_current(target_date) else AEMO_ARCHIVE_URL
|
| 74 |
+
date_str = _date_str(target_date)
|
| 75 |
+
|
| 76 |
+
async with httpx.AsyncClient() as client:
|
| 77 |
+
# List the directory to find the correct filename
|
| 78 |
+
try:
|
| 79 |
+
filenames = await _list_directory(base_url, client)
|
| 80 |
+
except AEMOFetchError:
|
| 81 |
+
# Fallback: try archive if current failed
|
| 82 |
+
if base_url == AEMO_CURRENT_URL:
|
| 83 |
+
filenames = await _list_directory(AEMO_ARCHIVE_URL, client)
|
| 84 |
+
base_url = AEMO_ARCHIVE_URL
|
| 85 |
+
else:
|
| 86 |
+
raise
|
| 87 |
+
|
| 88 |
+
# Find a file matching the date
|
| 89 |
+
matching = [f for f in filenames if date_str in f]
|
| 90 |
+
if not matching:
|
| 91 |
+
raise AEMOFetchError(
|
| 92 |
+
f"No FPPDAILY data found for {target_date.strftime('%d %B %Y')}. "
|
| 93 |
+
f"The file may not yet be published or the date may not have data."
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Use the first matching file
|
| 97 |
+
filename = matching[0]
|
| 98 |
+
zip_url = base_url + filename
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
resp = await client.get(
|
| 102 |
+
zip_url,
|
| 103 |
+
timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
|
| 104 |
+
follow_redirects=True,
|
| 105 |
+
)
|
| 106 |
+
resp.raise_for_status()
|
| 107 |
+
except httpx.TimeoutException:
|
| 108 |
+
raise AEMOFetchError(
|
| 109 |
+
"AEMO server timed out while downloading the data file. "
|
| 110 |
+
"The file may be very large. Please try again."
|
| 111 |
+
)
|
| 112 |
+
except httpx.HTTPStatusError as e:
|
| 113 |
+
raise AEMOFetchError(
|
| 114 |
+
f"Could not download data file (HTTP {e.response.status_code})."
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Extract CSV from ZIP (may contain multiple files; pick the relevant one)
|
| 118 |
+
try:
|
| 119 |
+
zip_bytes = io.BytesIO(resp.content)
|
| 120 |
+
with zipfile.ZipFile(zip_bytes) as zf:
|
| 121 |
+
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
|
| 122 |
+
if not csv_names:
|
| 123 |
+
raise AEMOFetchError("ZIP file contained no CSV files.")
|
| 124 |
+
# Pick the first (usually only one) CSV
|
| 125 |
+
csv_bytes = zf.read(csv_names[0])
|
| 126 |
+
except zipfile.BadZipFile:
|
| 127 |
+
raise AEMOFetchError("Downloaded file appears to be corrupt. Please try again.")
|
| 128 |
+
|
| 129 |
+
return csv_bytes
|
app/services/analytics.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lightweight SQLite-based analytics logger.
|
| 3 |
+
Tracks request counts, IPs, and selected parameters.
|
| 4 |
+
"""
|
| 5 |
+
import sqlite3
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
from app.config import ANALYTICS_DB_PATH
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _get_conn() -> sqlite3.Connection:
|
| 12 |
+
conn = sqlite3.connect(ANALYTICS_DB_PATH)
|
| 13 |
+
conn.row_factory = sqlite3.Row
|
| 14 |
+
return conn
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def init_db() -> None:
|
| 18 |
+
"""Create the analytics table if it doesn't exist."""
|
| 19 |
+
with _get_conn() as conn:
|
| 20 |
+
conn.execute("""
|
| 21 |
+
CREATE TABLE IF NOT EXISTS requests (
|
| 22 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 23 |
+
timestamp TEXT NOT NULL,
|
| 24 |
+
ip TEXT,
|
| 25 |
+
duid TEXT,
|
| 26 |
+
date TEXT,
|
| 27 |
+
action TEXT
|
| 28 |
+
)
|
| 29 |
+
""")
|
| 30 |
+
conn.commit()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def log_request(ip: str, duid: str, date: str, action: str) -> None:
|
| 34 |
+
"""Log a single request."""
|
| 35 |
+
try:
|
| 36 |
+
with _get_conn() as conn:
|
| 37 |
+
conn.execute(
|
| 38 |
+
"INSERT INTO requests (timestamp, ip, duid, date, action) VALUES (?, ?, ?, ?, ?)",
|
| 39 |
+
(datetime.utcnow().isoformat(), ip, duid, date, action),
|
| 40 |
+
)
|
| 41 |
+
conn.commit()
|
| 42 |
+
except Exception:
|
| 43 |
+
pass # Never let analytics failures break the main flow
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_stats() -> dict:
|
| 47 |
+
"""Return summary analytics."""
|
| 48 |
+
try:
|
| 49 |
+
with _get_conn() as conn:
|
| 50 |
+
total = conn.execute("SELECT COUNT(*) FROM requests").fetchone()[0]
|
| 51 |
+
by_action = conn.execute(
|
| 52 |
+
"SELECT action, COUNT(*) as cnt FROM requests GROUP BY action ORDER BY cnt DESC"
|
| 53 |
+
).fetchall()
|
| 54 |
+
by_duid = conn.execute(
|
| 55 |
+
"SELECT duid, COUNT(*) as cnt FROM requests GROUP BY duid ORDER BY cnt DESC LIMIT 20"
|
| 56 |
+
).fetchall()
|
| 57 |
+
by_ip = conn.execute(
|
| 58 |
+
"SELECT ip, COUNT(*) as cnt FROM requests GROUP BY ip ORDER BY cnt DESC LIMIT 50"
|
| 59 |
+
).fetchall()
|
| 60 |
+
recent = conn.execute(
|
| 61 |
+
"SELECT timestamp, ip, duid, date, action FROM requests ORDER BY id DESC LIMIT 100"
|
| 62 |
+
).fetchall()
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"total_requests": total,
|
| 66 |
+
"by_action": [dict(r) for r in by_action],
|
| 67 |
+
"by_duid": [dict(r) for r in by_duid],
|
| 68 |
+
"by_ip": [dict(r) for r in by_ip],
|
| 69 |
+
"recent": [dict(r) for r in recent],
|
| 70 |
+
}
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return {"error": str(e)}
|
app/services/data_processor.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Filters and transforms raw AEMO FPPDAILY CSV bytes using Polars.
|
| 3 |
+
"""
|
| 4 |
+
import io
|
| 5 |
+
|
| 6 |
+
import polars as pl
|
| 7 |
+
import pyarrow.parquet as pq
|
| 8 |
+
import pyarrow as pa
|
| 9 |
+
|
| 10 |
+
from app.config import REQUIRED_COLUMNS
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DataProcessingError(Exception):
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
| 18 |
+
"""
|
| 19 |
+
AEMO CSV files have a non-standard header format:
|
| 20 |
+
- First row: "C,..." (comment/metadata)
|
| 21 |
+
- Second row: "I,..." (table name/column header info)
|
| 22 |
+
- Data rows: "D,..." (data)
|
| 23 |
+
We need to skip the metadata rows and parse only data rows.
|
| 24 |
+
"""
|
| 25 |
+
text = csv_bytes.decode("utf-8", errors="replace")
|
| 26 |
+
lines = text.splitlines()
|
| 27 |
+
|
| 28 |
+
header = None
|
| 29 |
+
data_lines = []
|
| 30 |
+
|
| 31 |
+
for line in lines:
|
| 32 |
+
if not line.strip():
|
| 33 |
+
continue
|
| 34 |
+
if line.startswith("I,"):
|
| 35 |
+
# Column header row β strip the leading "I," and use remaining fields
|
| 36 |
+
parts = line.split(",")
|
| 37 |
+
# Format: I, TABLE_NAME, VERSION, col1, col2, ...
|
| 38 |
+
# The actual column names start at index 3 (after I, table, version)
|
| 39 |
+
if len(parts) > 3:
|
| 40 |
+
header = parts[3:]
|
| 41 |
+
elif line.startswith("D,"):
|
| 42 |
+
# Data row β strip leading "D," and the table/version fields
|
| 43 |
+
parts = line.split(",")
|
| 44 |
+
if len(parts) > 3:
|
| 45 |
+
data_lines.append(parts[3:])
|
| 46 |
+
|
| 47 |
+
if header is None:
|
| 48 |
+
raise DataProcessingError("Could not find column header row in CSV file.")
|
| 49 |
+
if not data_lines:
|
| 50 |
+
raise DataProcessingError("CSV file contained no data rows.")
|
| 51 |
+
|
| 52 |
+
# Build a simple CSV string for Polars to parse
|
| 53 |
+
# Pad/trim rows to match header length
|
| 54 |
+
n_cols = len(header)
|
| 55 |
+
padded = []
|
| 56 |
+
for row in data_lines:
|
| 57 |
+
if len(row) >= n_cols:
|
| 58 |
+
padded.append(row[:n_cols])
|
| 59 |
+
else:
|
| 60 |
+
padded.append(row + [""] * (n_cols - len(row)))
|
| 61 |
+
|
| 62 |
+
csv_content = ",".join(header) + "\n" + "\n".join(",".join(r) for r in padded)
|
| 63 |
+
df = pl.read_csv(io.StringIO(csv_content), infer_schema_length=1000)
|
| 64 |
+
return df
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def filter_and_process(csv_bytes: bytes, duid: str) -> pl.DataFrame:
|
| 68 |
+
"""
|
| 69 |
+
Parse raw CSV bytes, filter to the requested DUID, return cleaned DataFrame.
|
| 70 |
+
"""
|
| 71 |
+
df = _parse_aemo_csv(csv_bytes)
|
| 72 |
+
|
| 73 |
+
# Check required columns exist
|
| 74 |
+
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
| 75 |
+
if missing:
|
| 76 |
+
raise DataProcessingError(
|
| 77 |
+
f"CSV missing expected columns: {missing}. "
|
| 78 |
+
f"AEMO may have changed the file format."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Filter to selected DUID
|
| 82 |
+
df = df.filter(pl.col("FPP_UNITID") == duid).select(REQUIRED_COLUMNS)
|
| 83 |
+
|
| 84 |
+
if df.is_empty():
|
| 85 |
+
raise DataProcessingError(
|
| 86 |
+
f"No data found for DUID '{duid}' on this date. "
|
| 87 |
+
f"This unit may not have been operational or eligible for FPP on this date."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Cast types
|
| 91 |
+
df = df.with_columns([
|
| 92 |
+
pl.col("INTERVAL_DATETIME").str.to_datetime(format="%Y/%m/%d %H:%M:%S", strict=False),
|
| 93 |
+
pl.col("MEASUREMENT_DATETIME").str.to_datetime(format="%Y/%m/%d %H:%M:%S", strict=False),
|
| 94 |
+
pl.col("MEASURED_MW").cast(pl.Float64, strict=False),
|
| 95 |
+
pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False),
|
| 96 |
+
]).sort("MEASUREMENT_DATETIME")
|
| 97 |
+
|
| 98 |
+
return df
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def compute_summary(df: pl.DataFrame) -> dict:
|
| 102 |
+
"""Compute summary statistics for the filtered data."""
|
| 103 |
+
mw = df["MEASURED_MW"].drop_nulls()
|
| 104 |
+
flags = df["MW_QUALITY_FLAG"].value_counts().sort("MW_QUALITY_FLAG")
|
| 105 |
+
total = len(df)
|
| 106 |
+
|
| 107 |
+
flag_breakdown = {}
|
| 108 |
+
for row in flags.iter_rows(named=True):
|
| 109 |
+
flag = str(row["MW_QUALITY_FLAG"])
|
| 110 |
+
count = row["count"]
|
| 111 |
+
flag_breakdown[flag] = {
|
| 112 |
+
"count": count,
|
| 113 |
+
"pct": round(100 * count / total, 1) if total > 0 else 0,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"total_rows": total,
|
| 118 |
+
"min_mw": round(float(mw.min()), 3) if len(mw) > 0 else None,
|
| 119 |
+
"max_mw": round(float(mw.max()), 3) if len(mw) > 0 else None,
|
| 120 |
+
"mean_mw": round(float(mw.mean()), 3) if len(mw) > 0 else None,
|
| 121 |
+
"std_mw": round(float(mw.std()), 3) if len(mw) > 0 else None,
|
| 122 |
+
"flag_breakdown": flag_breakdown,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def to_csv_bytes(df: pl.DataFrame) -> bytes:
|
| 127 |
+
"""Serialize DataFrame to CSV bytes."""
|
| 128 |
+
return df.write_csv().encode("utf-8")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def to_parquet_bytes(df: pl.DataFrame) -> bytes:
|
| 132 |
+
"""Serialize DataFrame to Parquet bytes."""
|
| 133 |
+
buf = io.BytesIO()
|
| 134 |
+
df.write_parquet(buf)
|
| 135 |
+
return buf.getvalue()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def to_json_records(df: pl.DataFrame, max_rows: int = 5000) -> list[dict]:
|
| 139 |
+
"""
|
| 140 |
+
Return data as a list of dicts for JSON response.
|
| 141 |
+
Truncates to max_rows for display (full data available via download).
|
| 142 |
+
Datetimes serialized as ISO strings.
|
| 143 |
+
"""
|
| 144 |
+
display_df = df.head(max_rows).with_columns([
|
| 145 |
+
pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 146 |
+
pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 147 |
+
])
|
| 148 |
+
return display_df.to_dicts()
|
app/static/css/style.css
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ Reset & base ββ */
|
| 2 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 3 |
+
|
| 4 |
+
:root {
|
| 5 |
+
--bg: #0f172a;
|
| 6 |
+
--surface: #1e293b;
|
| 7 |
+
--surface2: #273549;
|
| 8 |
+
--border: #334155;
|
| 9 |
+
--text: #e2e8f0;
|
| 10 |
+
--text-muted: #94a3b8;
|
| 11 |
+
--accent: #38bdf8;
|
| 12 |
+
--accent-hover: #7dd3fc;
|
| 13 |
+
--good: #22c55e;
|
| 14 |
+
--suspect: #f97316;
|
| 15 |
+
--bad: #ef4444;
|
| 16 |
+
--na: #94a3b8;
|
| 17 |
+
--radius: 8px;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
body {
|
| 21 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 22 |
+
background: var(--bg);
|
| 23 |
+
color: var(--text);
|
| 24 |
+
min-height: 100vh;
|
| 25 |
+
line-height: 1.5;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* ββ Layout ββ */
|
| 29 |
+
header {
|
| 30 |
+
background: var(--surface);
|
| 31 |
+
border-bottom: 1px solid var(--border);
|
| 32 |
+
padding: 1rem 2rem;
|
| 33 |
+
display: flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
gap: 1rem;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
header h1 {
|
| 39 |
+
font-size: 1.25rem;
|
| 40 |
+
font-weight: 700;
|
| 41 |
+
color: var(--accent);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
header p {
|
| 45 |
+
font-size: 0.8rem;
|
| 46 |
+
color: var(--text-muted);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
main {
|
| 50 |
+
max-width: 1200px;
|
| 51 |
+
margin: 0 auto;
|
| 52 |
+
padding: 2rem 1.5rem;
|
| 53 |
+
display: flex;
|
| 54 |
+
flex-direction: column;
|
| 55 |
+
gap: 1.5rem;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ββ Control panel ββ */
|
| 59 |
+
.panel {
|
| 60 |
+
background: var(--surface);
|
| 61 |
+
border: 1px solid var(--border);
|
| 62 |
+
border-radius: var(--radius);
|
| 63 |
+
padding: 1.5rem;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.panel h2 {
|
| 67 |
+
font-size: 0.9rem;
|
| 68 |
+
font-weight: 600;
|
| 69 |
+
text-transform: uppercase;
|
| 70 |
+
letter-spacing: 0.05em;
|
| 71 |
+
color: var(--text-muted);
|
| 72 |
+
margin-bottom: 1rem;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.controls {
|
| 76 |
+
display: grid;
|
| 77 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 78 |
+
gap: 1rem;
|
| 79 |
+
align-items: end;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.form-group {
|
| 83 |
+
display: flex;
|
| 84 |
+
flex-direction: column;
|
| 85 |
+
gap: 0.35rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
label {
|
| 89 |
+
font-size: 0.8rem;
|
| 90 |
+
font-weight: 500;
|
| 91 |
+
color: var(--text-muted);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
select, input[type="date"] {
|
| 95 |
+
background: var(--surface2);
|
| 96 |
+
border: 1px solid var(--border);
|
| 97 |
+
border-radius: var(--radius);
|
| 98 |
+
color: var(--text);
|
| 99 |
+
padding: 0.55rem 0.75rem;
|
| 100 |
+
font-size: 0.9rem;
|
| 101 |
+
width: 100%;
|
| 102 |
+
appearance: none;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
transition: border-color 0.15s;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
select:focus, input[type="date"]:focus {
|
| 108 |
+
outline: none;
|
| 109 |
+
border-color: var(--accent);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
select:disabled, input:disabled {
|
| 113 |
+
opacity: 0.5;
|
| 114 |
+
cursor: not-allowed;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* ββ Buttons ββ */
|
| 118 |
+
.btn {
|
| 119 |
+
display: inline-flex;
|
| 120 |
+
align-items: center;
|
| 121 |
+
gap: 0.4rem;
|
| 122 |
+
padding: 0.55rem 1.25rem;
|
| 123 |
+
border-radius: var(--radius);
|
| 124 |
+
font-size: 0.9rem;
|
| 125 |
+
font-weight: 600;
|
| 126 |
+
cursor: pointer;
|
| 127 |
+
border: none;
|
| 128 |
+
transition: background 0.15s, opacity 0.15s;
|
| 129 |
+
text-decoration: none;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.btn-primary {
|
| 133 |
+
background: var(--accent);
|
| 134 |
+
color: #0f172a;
|
| 135 |
+
}
|
| 136 |
+
.btn-primary:hover { background: var(--accent-hover); }
|
| 137 |
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 138 |
+
|
| 139 |
+
.btn-outline {
|
| 140 |
+
background: transparent;
|
| 141 |
+
border: 1px solid var(--border);
|
| 142 |
+
color: var(--text);
|
| 143 |
+
}
|
| 144 |
+
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
| 145 |
+
|
| 146 |
+
/* ββ Status & alerts ββ */
|
| 147 |
+
.alert {
|
| 148 |
+
padding: 0.85rem 1.1rem;
|
| 149 |
+
border-radius: var(--radius);
|
| 150 |
+
font-size: 0.875rem;
|
| 151 |
+
display: flex;
|
| 152 |
+
gap: 0.6rem;
|
| 153 |
+
align-items: flex-start;
|
| 154 |
+
}
|
| 155 |
+
.alert-error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.4); color: #fca5a5; }
|
| 156 |
+
.alert-info { background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.3); color: var(--accent); }
|
| 157 |
+
|
| 158 |
+
.hidden { display: none !important; }
|
| 159 |
+
|
| 160 |
+
/* ββ Loading spinner ββ */
|
| 161 |
+
.spinner-wrap {
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-direction: column;
|
| 164 |
+
align-items: center;
|
| 165 |
+
gap: 1rem;
|
| 166 |
+
padding: 3rem;
|
| 167 |
+
color: var(--text-muted);
|
| 168 |
+
font-size: 0.875rem;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.spinner {
|
| 172 |
+
width: 36px; height: 36px;
|
| 173 |
+
border: 3px solid var(--border);
|
| 174 |
+
border-top-color: var(--accent);
|
| 175 |
+
border-radius: 50%;
|
| 176 |
+
animation: spin 0.8s linear infinite;
|
| 177 |
+
}
|
| 178 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 179 |
+
|
| 180 |
+
/* ββ Summary cards ββ */
|
| 181 |
+
.stats-grid {
|
| 182 |
+
display: grid;
|
| 183 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 184 |
+
gap: 1rem;
|
| 185 |
+
margin-bottom: 1.25rem;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.stat-card {
|
| 189 |
+
background: var(--surface2);
|
| 190 |
+
border: 1px solid var(--border);
|
| 191 |
+
border-radius: var(--radius);
|
| 192 |
+
padding: 1rem;
|
| 193 |
+
text-align: center;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.stat-value {
|
| 197 |
+
font-size: 1.4rem;
|
| 198 |
+
font-weight: 700;
|
| 199 |
+
color: var(--accent);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.stat-label {
|
| 203 |
+
font-size: 0.72rem;
|
| 204 |
+
color: var(--text-muted);
|
| 205 |
+
text-transform: uppercase;
|
| 206 |
+
letter-spacing: 0.04em;
|
| 207 |
+
margin-top: 0.2rem;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/* ββ Quality badge strip ββ */
|
| 211 |
+
.quality-strip {
|
| 212 |
+
display: flex;
|
| 213 |
+
flex-wrap: wrap;
|
| 214 |
+
gap: 0.6rem;
|
| 215 |
+
margin-bottom: 1.25rem;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.q-badge {
|
| 219 |
+
display: inline-flex;
|
| 220 |
+
align-items: center;
|
| 221 |
+
gap: 0.4rem;
|
| 222 |
+
padding: 0.35rem 0.7rem;
|
| 223 |
+
border-radius: 999px;
|
| 224 |
+
font-size: 0.8rem;
|
| 225 |
+
font-weight: 500;
|
| 226 |
+
}
|
| 227 |
+
.q-badge .dot {
|
| 228 |
+
width: 8px; height: 8px;
|
| 229 |
+
border-radius: 50%;
|
| 230 |
+
flex-shrink: 0;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* ββ Chart ββ */
|
| 234 |
+
#chart-container {
|
| 235 |
+
width: 100%;
|
| 236 |
+
height: 400px;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/* ββ Download bar ββ */
|
| 240 |
+
.download-bar {
|
| 241 |
+
display: flex;
|
| 242 |
+
gap: 0.75rem;
|
| 243 |
+
align-items: center;
|
| 244 |
+
flex-wrap: wrap;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.download-bar span {
|
| 248 |
+
font-size: 0.8rem;
|
| 249 |
+
color: var(--text-muted);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/* ββ Table ββ */
|
| 253 |
+
.table-wrap {
|
| 254 |
+
overflow-x: auto;
|
| 255 |
+
max-height: 400px;
|
| 256 |
+
border: 1px solid var(--border);
|
| 257 |
+
border-radius: var(--radius);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
table {
|
| 261 |
+
width: 100%;
|
| 262 |
+
border-collapse: collapse;
|
| 263 |
+
font-size: 0.82rem;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
thead {
|
| 267 |
+
position: sticky;
|
| 268 |
+
top: 0;
|
| 269 |
+
background: var(--surface2);
|
| 270 |
+
z-index: 1;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
th {
|
| 274 |
+
padding: 0.6rem 0.8rem;
|
| 275 |
+
text-align: left;
|
| 276 |
+
font-weight: 600;
|
| 277 |
+
color: var(--text-muted);
|
| 278 |
+
border-bottom: 1px solid var(--border);
|
| 279 |
+
white-space: nowrap;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
td {
|
| 283 |
+
padding: 0.45rem 0.8rem;
|
| 284 |
+
border-bottom: 1px solid rgba(51,65,85,0.5);
|
| 285 |
+
white-space: nowrap;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
tr:last-child td { border-bottom: none; }
|
| 289 |
+
tr:hover td { background: rgba(255,255,255,0.03); }
|
| 290 |
+
|
| 291 |
+
.flag-good { color: var(--good); }
|
| 292 |
+
.flag-suspect { color: var(--suspect); }
|
| 293 |
+
.flag-bad { color: var(--bad); }
|
| 294 |
+
.flag-na { color: var(--na); }
|
| 295 |
+
|
| 296 |
+
.table-note {
|
| 297 |
+
font-size: 0.75rem;
|
| 298 |
+
color: var(--text-muted);
|
| 299 |
+
margin-top: 0.5rem;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/* ββ Footer ββ */
|
| 303 |
+
footer {
|
| 304 |
+
text-align: center;
|
| 305 |
+
padding: 2rem;
|
| 306 |
+
font-size: 0.78rem;
|
| 307 |
+
color: var(--text-muted);
|
| 308 |
+
border-top: 1px solid var(--border);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
footer a { color: var(--accent); text-decoration: none; }
|
| 312 |
+
footer a:hover { text-decoration: underline; }
|
| 313 |
+
|
| 314 |
+
@media (max-width: 600px) {
|
| 315 |
+
header { flex-direction: column; align-items: flex-start; }
|
| 316 |
+
.controls { grid-template-columns: 1fr; }
|
| 317 |
+
}
|
app/static/index.html
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>NEM Battery SCADA Data Explorer</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
| 9 |
+
<link rel="stylesheet" href="/static/css/style.css" />
|
| 10 |
+
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js" defer></script>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<!-- ββ Header ββ -->
|
| 15 |
+
<header>
|
| 16 |
+
<div>
|
| 17 |
+
<h1>π NEM Battery SCADA Data Explorer</h1>
|
| 18 |
+
<p>4-second SCADA data for Battery Energy Storage Systems in Australia's National Electricity Market</p>
|
| 19 |
+
</div>
|
| 20 |
+
</header>
|
| 21 |
+
|
| 22 |
+
<main>
|
| 23 |
+
|
| 24 |
+
<!-- ββ Control panel ββ -->
|
| 25 |
+
<section class="panel">
|
| 26 |
+
<h2>Select Data</h2>
|
| 27 |
+
<div class="controls">
|
| 28 |
+
|
| 29 |
+
<div class="form-group">
|
| 30 |
+
<label for="sel-state">State / Region</label>
|
| 31 |
+
<select id="sel-state" disabled>
|
| 32 |
+
<option value="">β Loadingβ¦ β</option>
|
| 33 |
+
</select>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div class="form-group">
|
| 37 |
+
<label for="sel-bess">Battery (BESS)</label>
|
| 38 |
+
<select id="sel-bess" disabled>
|
| 39 |
+
<option value="">β Select a state first β</option>
|
| 40 |
+
</select>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="form-group">
|
| 44 |
+
<label for="inp-date">Date <span style="color:var(--text-muted);font-weight:400">(max 1 day)</span></label>
|
| 45 |
+
<input type="date" id="inp-date" />
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div class="form-group">
|
| 49 |
+
<label> </label>
|
| 50 |
+
<button class="btn btn-primary" id="btn-load" disabled>Load Data</button>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
</div>
|
| 54 |
+
</section>
|
| 55 |
+
|
| 56 |
+
<!-- ββ Error ββ -->
|
| 57 |
+
<div id="error-box" class="alert alert-error hidden" role="alert">
|
| 58 |
+
β οΈ <span id="error-msg"></span>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- ββ Loading ββ -->
|
| 62 |
+
<div id="loading-box" class="hidden">
|
| 63 |
+
<div class="spinner-wrap">
|
| 64 |
+
<div class="spinner"></div>
|
| 65 |
+
<p>Fetching data from AEMO NEMWEBβ¦ this may take 10β20 seconds for large files.</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<!-- ββ Results ββ -->
|
| 70 |
+
<div id="results-box" class="hidden">
|
| 71 |
+
|
| 72 |
+
<!-- Summary stats -->
|
| 73 |
+
<section class="panel">
|
| 74 |
+
<h2>Summary Statistics</h2>
|
| 75 |
+
<div id="stats-grid" class="stats-grid"></div>
|
| 76 |
+
|
| 77 |
+
<h2 style="margin-bottom:0.6rem">Data Quality</h2>
|
| 78 |
+
<div id="quality-strip" class="quality-strip"></div>
|
| 79 |
+
<p style="font-size:0.78rem;color:var(--text-muted)">
|
| 80 |
+
Quality flags are sourced directly from the AEMO FPP dataset.
|
| 81 |
+
<strong style="color:var(--good)">Good (0)</strong> = reliable measurement;
|
| 82 |
+
<strong style="color:var(--suspect)">Suspect (1)</strong> = possible communication issue;
|
| 83 |
+
<strong style="color:var(--bad)">Bad (2)</strong> = sustained failure or substituted value.
|
| 84 |
+
</p>
|
| 85 |
+
</section>
|
| 86 |
+
|
| 87 |
+
<!-- Chart -->
|
| 88 |
+
<section class="panel">
|
| 89 |
+
<h2>Power Output Over Time</h2>
|
| 90 |
+
<div id="chart-container"></div>
|
| 91 |
+
</section>
|
| 92 |
+
|
| 93 |
+
<!-- Download + table -->
|
| 94 |
+
<section class="panel">
|
| 95 |
+
<h2>Download Full Dataset</h2>
|
| 96 |
+
<div class="download-bar" style="margin-bottom:1.25rem">
|
| 97 |
+
<span>Download all rows for the selected BESS and date:</span>
|
| 98 |
+
<a id="btn-csv" class="btn btn-outline" href="#" download>β¬ CSV</a>
|
| 99 |
+
<a id="btn-parquet" class="btn btn-outline" href="#" download>β¬ Parquet</a>
|
| 100 |
+
</div>
|
| 101 |
+
<p style="font-size:0.78rem;color:var(--text-muted);margin-bottom:1.25rem">
|
| 102 |
+
<strong>Parquet</strong> is recommended for Python users β it is 5β10Γ smaller than CSV and
|
| 103 |
+
loads natively with <code>pandas.read_parquet()</code> or <code>polars.read_parquet()</code>.
|
| 104 |
+
</p>
|
| 105 |
+
|
| 106 |
+
<h2 style="margin-bottom:0.6rem">Data Preview</h2>
|
| 107 |
+
<div class="table-wrap">
|
| 108 |
+
<table>
|
| 109 |
+
<thead>
|
| 110 |
+
<tr>
|
| 111 |
+
<th>Measurement Time</th>
|
| 112 |
+
<th>Market Interval</th>
|
| 113 |
+
<th>Measured MW</th>
|
| 114 |
+
<th>Quality Flag</th>
|
| 115 |
+
</tr>
|
| 116 |
+
</thead>
|
| 117 |
+
<tbody id="table-body"></tbody>
|
| 118 |
+
</table>
|
| 119 |
+
</div>
|
| 120 |
+
<p id="table-note" class="table-note hidden"></p>
|
| 121 |
+
</section>
|
| 122 |
+
|
| 123 |
+
</div><!-- /results -->
|
| 124 |
+
|
| 125 |
+
<!-- ββ Info ββ -->
|
| 126 |
+
<section class="panel">
|
| 127 |
+
<h2>About This App</h2>
|
| 128 |
+
<p style="font-size:0.875rem;color:var(--text-muted);line-height:1.7">
|
| 129 |
+
This tool provides access to 4-second SCADA data published by the
|
| 130 |
+
<a href="https://www.aemo.com.au" target="_blank" rel="noopener">Australian Energy Market Operator (AEMO)</a>
|
| 131 |
+
via the <a href="https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/" target="_blank" rel="noopener">NEMWEB FPPDAILY</a>
|
| 132 |
+
report. Data is available from <strong>9 December 2024</strong> when the
|
| 133 |
+
<a href="https://aemo.com.au/initiatives/major-programs/frequency-performance-payments-project" target="_blank" rel="noopener">Frequency Performance Payments (FPP)</a>
|
| 134 |
+
scheme commenced non-financial operation.<br/><br/>
|
| 135 |
+
Data is fetched live from AEMO on each request β no data is stored on this server.
|
| 136 |
+
Maximum request window: <strong>1 day</strong> per query.
|
| 137 |
+
</p>
|
| 138 |
+
</section>
|
| 139 |
+
|
| 140 |
+
</main>
|
| 141 |
+
|
| 142 |
+
<footer>
|
| 143 |
+
Data source: <a href="https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/" target="_blank">AEMO NEMWEB FPPDAILY</a>
|
| 144 |
+
|
|
| 145 |
+
BESS list: <a href="https://www.aemo.com.au/energy-systems/electricity/national-electricity-market-nem/planning_and_forecasting/generation-information" target="_blank">AEMO NEM Generation Information</a>
|
| 146 |
+
|
|
| 147 |
+
Not affiliated with AEMO.
|
| 148 |
+
</footer>
|
| 149 |
+
|
| 150 |
+
<script src="/static/js/app.js"></script>
|
| 151 |
+
</body>
|
| 152 |
+
</html>
|
app/static/js/app.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
NEM BESS SCADA Explorer β frontend logic
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
const API = ''; // same origin
|
| 6 |
+
|
| 7 |
+
/* ββ State ββ */
|
| 8 |
+
let bessList = {};
|
| 9 |
+
let qualityFlags = {};
|
| 10 |
+
let currentData = null;
|
| 11 |
+
let currentDuid = null;
|
| 12 |
+
let currentDate = null;
|
| 13 |
+
|
| 14 |
+
/* ββ DOM refs ββ */
|
| 15 |
+
const selState = document.getElementById('sel-state');
|
| 16 |
+
const selBess = document.getElementById('sel-bess');
|
| 17 |
+
const inpDate = document.getElementById('inp-date');
|
| 18 |
+
const btnLoad = document.getElementById('btn-load');
|
| 19 |
+
const errorBox = document.getElementById('error-box');
|
| 20 |
+
const errorMsg = document.getElementById('error-msg');
|
| 21 |
+
const loadingBox = document.getElementById('loading-box');
|
| 22 |
+
const resultsBox = document.getElementById('results-box');
|
| 23 |
+
const statsGrid = document.getElementById('stats-grid');
|
| 24 |
+
const qualStrip = document.getElementById('quality-strip');
|
| 25 |
+
const tableBody = document.getElementById('table-body');
|
| 26 |
+
const tableNote = document.getElementById('table-note');
|
| 27 |
+
const btnCsv = document.getElementById('btn-csv');
|
| 28 |
+
const btnParquet = document.getElementById('btn-parquet');
|
| 29 |
+
|
| 30 |
+
/* ββ Boot ββ */
|
| 31 |
+
async function init() {
|
| 32 |
+
// Set default date to yesterday
|
| 33 |
+
const yesterday = new Date();
|
| 34 |
+
yesterday.setDate(yesterday.getDate() - 1);
|
| 35 |
+
inpDate.value = yesterday.toISOString().slice(0, 10);
|
| 36 |
+
inpDate.max = yesterday.toISOString().slice(0, 10);
|
| 37 |
+
|
| 38 |
+
// Fetch app info for min date
|
| 39 |
+
try {
|
| 40 |
+
const info = await fetch(`${API}/api/info`).then(r => r.json());
|
| 41 |
+
inpDate.min = info.data_start_date;
|
| 42 |
+
} catch (_) {}
|
| 43 |
+
|
| 44 |
+
// Load BESS list and quality flags in parallel
|
| 45 |
+
try {
|
| 46 |
+
[bessList, qualityFlags] = await Promise.all([
|
| 47 |
+
fetch(`${API}/api/bess`).then(r => r.json()),
|
| 48 |
+
fetch(`${API}/api/quality-flags`).then(r => r.json()),
|
| 49 |
+
]);
|
| 50 |
+
} catch (e) {
|
| 51 |
+
showError('Failed to load BESS list. Please refresh the page.');
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Populate state selector
|
| 56 |
+
Object.keys(bessList).sort().forEach(state => {
|
| 57 |
+
const opt = document.createElement('option');
|
| 58 |
+
opt.value = state;
|
| 59 |
+
opt.textContent = state;
|
| 60 |
+
selState.appendChild(opt);
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
selState.disabled = false;
|
| 64 |
+
selState.addEventListener('change', onStateChange);
|
| 65 |
+
btnLoad.addEventListener('click', onLoad);
|
| 66 |
+
selBess.addEventListener('change', () => { btnLoad.disabled = !selBess.value; });
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function onStateChange() {
|
| 70 |
+
const state = selState.value;
|
| 71 |
+
selBess.innerHTML = '<option value="">β Select BESS β</option>';
|
| 72 |
+
selBess.disabled = !state;
|
| 73 |
+
btnLoad.disabled = true;
|
| 74 |
+
|
| 75 |
+
if (!state || !bessList[state]) return;
|
| 76 |
+
|
| 77 |
+
bessList[state].forEach(b => {
|
| 78 |
+
const opt = document.createElement('option');
|
| 79 |
+
opt.value = b.duid;
|
| 80 |
+
opt.textContent = `${b.name} (${b.capacity_mw} MW)`;
|
| 81 |
+
selBess.appendChild(opt);
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* ββ Load data ββ */
|
| 86 |
+
async function onLoad() {
|
| 87 |
+
const duid = selBess.value;
|
| 88 |
+
const date = inpDate.value;
|
| 89 |
+
|
| 90 |
+
if (!duid || !date) return;
|
| 91 |
+
|
| 92 |
+
hideError();
|
| 93 |
+
hideResults();
|
| 94 |
+
showLoading(true);
|
| 95 |
+
btnLoad.disabled = true;
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const resp = await fetch(`${API}/api/data?duid=${encodeURIComponent(duid)}&date=${date}`);
|
| 99 |
+
if (!resp.ok) {
|
| 100 |
+
const err = await resp.json().catch(() => ({ detail: 'Unknown error' }));
|
| 101 |
+
throw new Error(err.detail || `Server error ${resp.status}`);
|
| 102 |
+
}
|
| 103 |
+
const payload = await resp.json();
|
| 104 |
+
currentData = payload;
|
| 105 |
+
currentDuid = duid;
|
| 106 |
+
currentDate = date;
|
| 107 |
+
renderResults(payload, duid, date);
|
| 108 |
+
} catch (e) {
|
| 109 |
+
showError(e.message);
|
| 110 |
+
} finally {
|
| 111 |
+
showLoading(false);
|
| 112 |
+
btnLoad.disabled = false;
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* ββ Render ββ */
|
| 117 |
+
function renderResults(payload, duid, date) {
|
| 118 |
+
const { summary, data } = payload;
|
| 119 |
+
|
| 120 |
+
renderStats(summary, payload.total_rows);
|
| 121 |
+
renderQualityStrip(summary.flag_breakdown);
|
| 122 |
+
renderChart(data);
|
| 123 |
+
renderTable(data, payload.total_rows);
|
| 124 |
+
|
| 125 |
+
const base = `${API}/api/download`;
|
| 126 |
+
const params = `duid=${encodeURIComponent(duid)}&date=${date}`;
|
| 127 |
+
btnCsv.href = `${base}/csv?${params}`;
|
| 128 |
+
btnParquet.href = `${base}/parquet?${params}`;
|
| 129 |
+
|
| 130 |
+
resultsBox.classList.remove('hidden');
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function renderStats(summary, totalRows) {
|
| 134 |
+
statsGrid.innerHTML = '';
|
| 135 |
+
const stats = [
|
| 136 |
+
{ label: 'Total Readings', value: totalRows.toLocaleString() },
|
| 137 |
+
{ label: 'Min MW', value: summary.min_mw != null ? summary.min_mw.toFixed(2) : 'β' },
|
| 138 |
+
{ label: 'Max MW', value: summary.max_mw != null ? summary.max_mw.toFixed(2) : 'β' },
|
| 139 |
+
{ label: 'Mean MW', value: summary.mean_mw != null ? summary.mean_mw.toFixed(2) : 'β' },
|
| 140 |
+
{ label: 'Std Dev', value: summary.std_mw != null ? summary.std_mw.toFixed(2) : 'β' },
|
| 141 |
+
];
|
| 142 |
+
stats.forEach(s => {
|
| 143 |
+
statsGrid.insertAdjacentHTML('beforeend', `
|
| 144 |
+
<div class="stat-card">
|
| 145 |
+
<div class="stat-value">${s.value}</div>
|
| 146 |
+
<div class="stat-label">${s.label}</div>
|
| 147 |
+
</div>`);
|
| 148 |
+
});
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function renderQualityStrip(breakdown) {
|
| 152 |
+
qualStrip.innerHTML = '';
|
| 153 |
+
const order = ['0', '1', '2', '-1'];
|
| 154 |
+
order.forEach(flag => {
|
| 155 |
+
const info = qualityFlags[flag] || { label: `Flag ${flag}`, description: '', color: '#94a3b8' };
|
| 156 |
+
const entry = breakdown[flag];
|
| 157 |
+
if (!entry) return;
|
| 158 |
+
qualStrip.insertAdjacentHTML('beforeend', `
|
| 159 |
+
<div class="q-badge" title="${info.description}">
|
| 160 |
+
<span class="dot" style="background:${info.color}"></span>
|
| 161 |
+
<strong>${info.label}</strong>: ${entry.count.toLocaleString()} readings (${entry.pct}%)
|
| 162 |
+
</div>`);
|
| 163 |
+
});
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function renderChart(data) {
|
| 167 |
+
if (!data || data.length === 0) return;
|
| 168 |
+
|
| 169 |
+
// Group by quality flag for separate traces (so we can colour-code points)
|
| 170 |
+
const flagGroups = {};
|
| 171 |
+
data.forEach(row => {
|
| 172 |
+
const flag = String(row.MW_QUALITY_FLAG ?? '-1');
|
| 173 |
+
if (!flagGroups[flag]) flagGroups[flag] = { x: [], y: [] };
|
| 174 |
+
flagGroups[flag].x.push(row.MEASUREMENT_DATETIME);
|
| 175 |
+
flagGroups[flag].y.push(row.MEASURED_MW);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
const traces = Object.entries(flagGroups).map(([flag, pts]) => {
|
| 179 |
+
const info = qualityFlags[flag] || { label: `Flag ${flag}`, color: '#94a3b8' };
|
| 180 |
+
return {
|
| 181 |
+
x: pts.x,
|
| 182 |
+
y: pts.y,
|
| 183 |
+
mode: 'lines+markers',
|
| 184 |
+
marker: { size: 2, color: info.color },
|
| 185 |
+
line: { width: 1, color: info.color },
|
| 186 |
+
name: info.label,
|
| 187 |
+
type: 'scattergl',
|
| 188 |
+
};
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
const layout = {
|
| 192 |
+
paper_bgcolor: 'transparent',
|
| 193 |
+
plot_bgcolor: 'transparent',
|
| 194 |
+
font: { color: '#e2e8f0', size: 11 },
|
| 195 |
+
xaxis: {
|
| 196 |
+
title: 'Time',
|
| 197 |
+
gridcolor: '#334155',
|
| 198 |
+
linecolor: '#334155',
|
| 199 |
+
tickfont: { color: '#94a3b8' },
|
| 200 |
+
},
|
| 201 |
+
yaxis: {
|
| 202 |
+
title: 'Power (MW)',
|
| 203 |
+
gridcolor: '#334155',
|
| 204 |
+
linecolor: '#334155',
|
| 205 |
+
tickfont: { color: '#94a3b8' },
|
| 206 |
+
zeroline: true,
|
| 207 |
+
zerolinecolor: '#475569',
|
| 208 |
+
},
|
| 209 |
+
legend: { orientation: 'h', y: -0.2 },
|
| 210 |
+
margin: { t: 20, r: 20, b: 60, l: 60 },
|
| 211 |
+
hovermode: 'x unified',
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
Plotly.react('chart-container', traces, layout, {
|
| 215 |
+
responsive: true,
|
| 216 |
+
displayModeBar: true,
|
| 217 |
+
modeBarButtonsToRemove: ['select2d', 'lasso2d'],
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function flagClass(flag) {
|
| 222 |
+
switch (String(flag)) {
|
| 223 |
+
case '0': return 'flag-good';
|
| 224 |
+
case '1': return 'flag-suspect';
|
| 225 |
+
case '2': return 'flag-bad';
|
| 226 |
+
default: return 'flag-na';
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function flagLabel(flag) {
|
| 231 |
+
return (qualityFlags[String(flag)] || { label: flag }).label;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function renderTable(data, totalRows) {
|
| 235 |
+
tableBody.innerHTML = '';
|
| 236 |
+
data.forEach(row => {
|
| 237 |
+
const cls = flagClass(row.MW_QUALITY_FLAG);
|
| 238 |
+
tableBody.insertAdjacentHTML('beforeend', `
|
| 239 |
+
<tr>
|
| 240 |
+
<td>${row.MEASUREMENT_DATETIME ?? ''}</td>
|
| 241 |
+
<td>${row.INTERVAL_DATETIME ?? ''}</td>
|
| 242 |
+
<td>${row.MEASURED_MW != null ? row.MEASURED_MW.toFixed(4) : 'β'}</td>
|
| 243 |
+
<td class="${cls}">${flagLabel(row.MW_QUALITY_FLAG)}</td>
|
| 244 |
+
</tr>`);
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
if (totalRows > data.length) {
|
| 248 |
+
tableNote.textContent =
|
| 249 |
+
`Showing ${data.length.toLocaleString()} of ${totalRows.toLocaleString()} rows. Download the full dataset using the buttons above.`;
|
| 250 |
+
tableNote.classList.remove('hidden');
|
| 251 |
+
} else {
|
| 252 |
+
tableNote.classList.add('hidden');
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
/* ββ UI helpers ββ */
|
| 257 |
+
function showError(msg) {
|
| 258 |
+
errorMsg.textContent = msg;
|
| 259 |
+
errorBox.classList.remove('hidden');
|
| 260 |
+
}
|
| 261 |
+
function hideError() { errorBox.classList.add('hidden'); }
|
| 262 |
+
function showLoading(show) {
|
| 263 |
+
loadingBox.classList.toggle('hidden', !show);
|
| 264 |
+
}
|
| 265 |
+
function hideResults() { resultsBox.classList.add('hidden'); }
|
| 266 |
+
|
| 267 |
+
/* ββ Start ββ */
|
| 268 |
+
init();
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.5
|
| 2 |
+
uvicorn[standard]==0.32.1
|
| 3 |
+
polars==1.15.0
|
| 4 |
+
httpx==0.27.2
|
| 5 |
+
pyarrow==18.1.0
|
| 6 |
+
python-multipart==0.0.12
|
| 7 |
+
aiofiles==24.1.0
|