Claude commited on
Commit
fd0bd3b
Β·
0 Parent(s):

Initial implementation: NEM Battery SCADA Data Explorer

Browse files

Full-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 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>&nbsp;</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
+ &nbsp;|&nbsp;
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
+ &nbsp;|&nbsp;
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