Spaces:
Running
Add learned wait-time estimates and stretch chart to fill frame
Browse filesTiming estimates (backend):
- config.py: add FPPMW_CUTOVER_DATE, TIMING_MIN_SAMPLES,
TIMING_DEFAULT_CURRENT_SEC / ARCHIVE_SEC as named constants
- analytics.py: migrate schema to add duration_ms + source_type columns;
extend log_request() to accept them; add get_timing_estimate() which
returns p75 of the last 100 successful view durations per source_type,
falling back to hardcoded defaults until >= TIMING_MIN_SAMPLES records exist
- api.py: time fetch + process pipeline in all three data handlers;
classify each request as 'current' (>= 2026-01-11) or 'archive';
pass duration_ms + source_type to log_request; expose get_timing_estimate
results and cutover_date in /api/info
Dynamic loading message (frontend):
- app.js: store cutover_date and estimates from /api/info on boot;
add formatEstimate() helper that converts seconds to human-readable bands
and appends sample count when the estimate is learned (not default);
replace hardcoded loading strings with the dynamic estimate message
Chart sizing:
- style.css: widen main max-width 1200px β 1600px so the chart fills
more of the viewport on wide screens; change chart height to
clamp(400px, 50vh, 650px) for proportional scaling across screen sizes
- app.js: add autosize: true to Plotly layout so the chart fills its
container on first render without waiting for a resize event
https://claude.ai/code/session_01TrCrzZmWbQscJiAQaW7Rg8
- app/config.py +12 -0
- app/routers/api.py +30 -5
- app/services/analytics.py +84 -14
- app/static/css/style.css +2 -2
- app/static/js/app.js +36 -10
|
@@ -8,6 +8,12 @@ AEMO_ARCHIVE_URL = "https://nemweb.com.au/Reports/Archive/FPPDAILY/"
|
|
| 8 |
# Data availability: first FPPMW SCADA bundle is PUBLIC_NEXT_DAY_FPPMW_20250228.zip
|
| 9 |
DATA_START_DATE = date(2025, 2, 28)
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# Request limits
|
| 12 |
MAX_DAYS_PER_REQUEST = 1
|
| 13 |
|
|
@@ -19,6 +25,12 @@ AEMO_READ_TIMEOUT = 60
|
|
| 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 (unit identifier used only for filtering,
|
| 23 |
# not included in output)
|
| 24 |
REQUIRED_COLUMNS = [
|
|
|
|
| 8 |
# Data availability: first FPPMW SCADA bundle is PUBLIC_NEXT_DAY_FPPMW_20250228.zip
|
| 9 |
DATA_START_DATE = date(2025, 2, 28)
|
| 10 |
|
| 11 |
+
# From this date onward, data is served as individual daily ZIPs from the
|
| 12 |
+
# Current directory (faster). Before this date, large archive bundles are
|
| 13 |
+
# used (slower). This cutover is also sent to the frontend so both sides
|
| 14 |
+
# classify requests consistently.
|
| 15 |
+
FPPMW_CUTOVER_DATE = date(2026, 1, 11)
|
| 16 |
+
|
| 17 |
# Request limits
|
| 18 |
MAX_DAYS_PER_REQUEST = 1
|
| 19 |
|
|
|
|
| 25 |
ANALYTICS_TOKEN = os.environ.get("ANALYTICS_TOKEN", "changeme")
|
| 26 |
ANALYTICS_DB_PATH = "/tmp/analytics.db"
|
| 27 |
|
| 28 |
+
# Timing estimates: minimum successful samples before learned estimate is used
|
| 29 |
+
# instead of the hardcoded default.
|
| 30 |
+
TIMING_MIN_SAMPLES = 5
|
| 31 |
+
TIMING_DEFAULT_CURRENT_SEC = 120 # ~2 min default for current-dir files
|
| 32 |
+
TIMING_DEFAULT_ARCHIVE_SEC = 480 # ~8 min default for archive bundles
|
| 33 |
+
|
| 34 |
# Columns to keep from the raw CSV (unit identifier used only for filtering,
|
| 35 |
# not included in output)
|
| 36 |
REQUIRED_COLUMNS = [
|
|
@@ -3,6 +3,7 @@ REST API endpoints for BESS SCADA data.
|
|
| 3 |
"""
|
| 4 |
import json
|
| 5 |
import logging
|
|
|
|
| 6 |
from datetime import date, datetime
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
@@ -11,9 +12,14 @@ logger = logging.getLogger(__name__)
|
|
| 11 |
from fastapi import APIRouter, HTTPException, Query, Request
|
| 12 |
from fastapi.responses import Response
|
| 13 |
|
| 14 |
-
from app.config import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from app.services.aemo_fetcher import AEMOFetchError, fetch_csv_for_date
|
| 16 |
-
from app.services.analytics import get_stats, log_request
|
| 17 |
from app.services.gen_info_fetcher import fetch_bess_list
|
| 18 |
from app.services.data_processor import (
|
| 19 |
DataProcessingError,
|
|
@@ -43,6 +49,11 @@ def _parse_date(date_str: str) -> date:
|
|
| 43 |
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
@router.get("/bess")
|
| 47 |
async def get_bess_list():
|
| 48 |
"""Return in-service battery storage units grouped by state, sourced from the
|
|
@@ -81,13 +92,16 @@ async def get_data(
|
|
| 81 |
"""
|
| 82 |
target_date = _parse_date(date)
|
| 83 |
ip = _get_ip(request)
|
|
|
|
| 84 |
|
|
|
|
| 85 |
try:
|
| 86 |
csv_bytes = await _fetch_day_csv(target_date, duid)
|
| 87 |
df = filter_and_process(csv_bytes, duid, target_date)
|
|
|
|
| 88 |
summary = compute_summary(df)
|
| 89 |
records = to_json_records(df)
|
| 90 |
-
log_request(ip, duid, date, "view")
|
| 91 |
return {
|
| 92 |
"duid": duid,
|
| 93 |
"date": date,
|
|
@@ -111,12 +125,15 @@ async def download_csv(
|
|
| 111 |
"""Download full filtered data as CSV."""
|
| 112 |
target_date = _parse_date(date)
|
| 113 |
ip = _get_ip(request)
|
|
|
|
| 114 |
|
|
|
|
| 115 |
try:
|
| 116 |
csv_bytes = await _fetch_day_csv(target_date, duid)
|
| 117 |
df = filter_and_process(csv_bytes, duid, target_date)
|
|
|
|
| 118 |
output = to_csv_bytes(df)
|
| 119 |
-
log_request(ip, duid, date, "download_csv")
|
| 120 |
filename = f"BESS_SCADA_{duid}_{date}.csv"
|
| 121 |
return Response(
|
| 122 |
content=output,
|
|
@@ -138,12 +155,15 @@ async def download_parquet(
|
|
| 138 |
"""Download full filtered data as Parquet."""
|
| 139 |
target_date = _parse_date(date)
|
| 140 |
ip = _get_ip(request)
|
|
|
|
| 141 |
|
|
|
|
| 142 |
try:
|
| 143 |
csv_bytes = await _fetch_day_csv(target_date, duid)
|
| 144 |
df = filter_and_process(csv_bytes, duid, target_date)
|
|
|
|
| 145 |
output = to_parquet_bytes(df)
|
| 146 |
-
log_request(ip, duid, date, "download_parquet")
|
| 147 |
filename = f"BESS_SCADA_{duid}_{date}.parquet"
|
| 148 |
return Response(
|
| 149 |
content=output,
|
|
@@ -169,5 +189,10 @@ def info():
|
|
| 169 |
"""Return app metadata for the frontend."""
|
| 170 |
return {
|
| 171 |
"data_start_date": DATA_START_DATE.isoformat(),
|
|
|
|
| 172 |
"max_days_per_request": MAX_DAYS_PER_REQUEST,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
}
|
|
|
|
| 3 |
"""
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
+
import time
|
| 7 |
from datetime import date, datetime
|
| 8 |
from pathlib import Path
|
| 9 |
|
|
|
|
| 12 |
from fastapi import APIRouter, HTTPException, Query, Request
|
| 13 |
from fastapi.responses import Response
|
| 14 |
|
| 15 |
+
from app.config import (
|
| 16 |
+
ANALYTICS_TOKEN,
|
| 17 |
+
DATA_START_DATE,
|
| 18 |
+
FPPMW_CUTOVER_DATE,
|
| 19 |
+
MAX_DAYS_PER_REQUEST,
|
| 20 |
+
)
|
| 21 |
from app.services.aemo_fetcher import AEMOFetchError, fetch_csv_for_date
|
| 22 |
+
from app.services.analytics import get_stats, get_timing_estimate, log_request
|
| 23 |
from app.services.gen_info_fetcher import fetch_bess_list
|
| 24 |
from app.services.data_processor import (
|
| 25 |
DataProcessingError,
|
|
|
|
| 49 |
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
|
| 50 |
|
| 51 |
|
| 52 |
+
def _source_type(target_date: date) -> str:
|
| 53 |
+
"""'current' for dates served from the Current directory, 'archive' otherwise."""
|
| 54 |
+
return "current" if target_date >= FPPMW_CUTOVER_DATE else "archive"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
@router.get("/bess")
|
| 58 |
async def get_bess_list():
|
| 59 |
"""Return in-service battery storage units grouped by state, sourced from the
|
|
|
|
| 92 |
"""
|
| 93 |
target_date = _parse_date(date)
|
| 94 |
ip = _get_ip(request)
|
| 95 |
+
src = _source_type(target_date)
|
| 96 |
|
| 97 |
+
t0 = time.monotonic()
|
| 98 |
try:
|
| 99 |
csv_bytes = await _fetch_day_csv(target_date, duid)
|
| 100 |
df = filter_and_process(csv_bytes, duid, target_date)
|
| 101 |
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 102 |
summary = compute_summary(df)
|
| 103 |
records = to_json_records(df)
|
| 104 |
+
log_request(ip, duid, date, "view", duration_ms=duration_ms, source_type=src)
|
| 105 |
return {
|
| 106 |
"duid": duid,
|
| 107 |
"date": date,
|
|
|
|
| 125 |
"""Download full filtered data as CSV."""
|
| 126 |
target_date = _parse_date(date)
|
| 127 |
ip = _get_ip(request)
|
| 128 |
+
src = _source_type(target_date)
|
| 129 |
|
| 130 |
+
t0 = time.monotonic()
|
| 131 |
try:
|
| 132 |
csv_bytes = await _fetch_day_csv(target_date, duid)
|
| 133 |
df = filter_and_process(csv_bytes, duid, target_date)
|
| 134 |
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 135 |
output = to_csv_bytes(df)
|
| 136 |
+
log_request(ip, duid, date, "download_csv", duration_ms=duration_ms, source_type=src)
|
| 137 |
filename = f"BESS_SCADA_{duid}_{date}.csv"
|
| 138 |
return Response(
|
| 139 |
content=output,
|
|
|
|
| 155 |
"""Download full filtered data as Parquet."""
|
| 156 |
target_date = _parse_date(date)
|
| 157 |
ip = _get_ip(request)
|
| 158 |
+
src = _source_type(target_date)
|
| 159 |
|
| 160 |
+
t0 = time.monotonic()
|
| 161 |
try:
|
| 162 |
csv_bytes = await _fetch_day_csv(target_date, duid)
|
| 163 |
df = filter_and_process(csv_bytes, duid, target_date)
|
| 164 |
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 165 |
output = to_parquet_bytes(df)
|
| 166 |
+
log_request(ip, duid, date, "download_parquet", duration_ms=duration_ms, source_type=src)
|
| 167 |
filename = f"BESS_SCADA_{duid}_{date}.parquet"
|
| 168 |
return Response(
|
| 169 |
content=output,
|
|
|
|
| 189 |
"""Return app metadata for the frontend."""
|
| 190 |
return {
|
| 191 |
"data_start_date": DATA_START_DATE.isoformat(),
|
| 192 |
+
"cutover_date": FPPMW_CUTOVER_DATE.isoformat(),
|
| 193 |
"max_days_per_request": MAX_DAYS_PER_REQUEST,
|
| 194 |
+
"estimates": {
|
| 195 |
+
"current": get_timing_estimate("current"),
|
| 196 |
+
"archive": get_timing_estimate("archive"),
|
| 197 |
+
},
|
| 198 |
}
|
|
@@ -1,11 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
Lightweight SQLite-based analytics logger.
|
| 3 |
-
Tracks request counts, IPs,
|
| 4 |
"""
|
| 5 |
import sqlite3
|
| 6 |
from datetime import datetime
|
| 7 |
|
| 8 |
-
from app.config import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
def _get_conn() -> sqlite3.Connection:
|
|
@@ -15,34 +20,96 @@ def _get_conn() -> sqlite3.Connection:
|
|
| 15 |
|
| 16 |
|
| 17 |
def init_db() -> None:
|
| 18 |
-
"""Create the analytics table
|
| 19 |
with _get_conn() as conn:
|
| 20 |
conn.execute("""
|
| 21 |
CREATE TABLE IF NOT EXISTS requests (
|
| 22 |
-
id
|
| 23 |
-
timestamp
|
| 24 |
-
ip
|
| 25 |
-
duid
|
| 26 |
-
date
|
| 27 |
-
action
|
|
|
|
|
|
|
| 28 |
)
|
| 29 |
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
conn.commit()
|
| 31 |
|
| 32 |
|
| 33 |
-
def log_request(
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
try:
|
| 36 |
with _get_conn() as conn:
|
| 37 |
conn.execute(
|
| 38 |
-
"
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -58,7 +125,10 @@ def get_stats() -> dict:
|
|
| 58 |
"SELECT ip, COUNT(*) as cnt FROM requests GROUP BY ip ORDER BY cnt DESC LIMIT 50"
|
| 59 |
).fetchall()
|
| 60 |
recent = conn.execute(
|
| 61 |
-
"
|
|
|
|
|
|
|
|
|
|
| 62 |
).fetchall()
|
| 63 |
|
| 64 |
return {
|
|
|
|
| 1 |
"""
|
| 2 |
Lightweight SQLite-based analytics logger.
|
| 3 |
+
Tracks request counts, IPs, selected parameters, and end-to-end durations.
|
| 4 |
"""
|
| 5 |
import sqlite3
|
| 6 |
from datetime import datetime
|
| 7 |
|
| 8 |
+
from app.config import (
|
| 9 |
+
ANALYTICS_DB_PATH,
|
| 10 |
+
TIMING_DEFAULT_ARCHIVE_SEC,
|
| 11 |
+
TIMING_DEFAULT_CURRENT_SEC,
|
| 12 |
+
TIMING_MIN_SAMPLES,
|
| 13 |
+
)
|
| 14 |
|
| 15 |
|
| 16 |
def _get_conn() -> sqlite3.Connection:
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def init_db() -> None:
|
| 23 |
+
"""Create the analytics table and migrate any missing columns."""
|
| 24 |
with _get_conn() as conn:
|
| 25 |
conn.execute("""
|
| 26 |
CREATE TABLE IF NOT EXISTS requests (
|
| 27 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 28 |
+
timestamp TEXT NOT NULL,
|
| 29 |
+
ip TEXT,
|
| 30 |
+
duid TEXT,
|
| 31 |
+
date TEXT,
|
| 32 |
+
action TEXT,
|
| 33 |
+
duration_ms INTEGER,
|
| 34 |
+
source_type TEXT
|
| 35 |
)
|
| 36 |
""")
|
| 37 |
+
# Migrate pre-existing tables that lack the new columns.
|
| 38 |
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(requests)")}
|
| 39 |
+
if "duration_ms" not in existing:
|
| 40 |
+
conn.execute("ALTER TABLE requests ADD COLUMN duration_ms INTEGER")
|
| 41 |
+
if "source_type" not in existing:
|
| 42 |
+
conn.execute("ALTER TABLE requests ADD COLUMN source_type TEXT")
|
| 43 |
conn.commit()
|
| 44 |
|
| 45 |
|
| 46 |
+
def log_request(
|
| 47 |
+
ip: str,
|
| 48 |
+
duid: str,
|
| 49 |
+
date: str,
|
| 50 |
+
action: str,
|
| 51 |
+
*,
|
| 52 |
+
duration_ms: int | None = None,
|
| 53 |
+
source_type: str | None = None,
|
| 54 |
+
) -> None:
|
| 55 |
+
"""Log a single request, optionally with timing and data-source type."""
|
| 56 |
try:
|
| 57 |
with _get_conn() as conn:
|
| 58 |
conn.execute(
|
| 59 |
+
"""
|
| 60 |
+
INSERT INTO requests
|
| 61 |
+
(timestamp, ip, duid, date, action, duration_ms, source_type)
|
| 62 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 63 |
+
""",
|
| 64 |
+
(datetime.utcnow().isoformat(), ip, duid, date, action,
|
| 65 |
+
duration_ms, source_type),
|
| 66 |
)
|
| 67 |
conn.commit()
|
| 68 |
except Exception:
|
| 69 |
pass # Never let analytics failures break the main flow
|
| 70 |
|
| 71 |
|
| 72 |
+
def get_timing_estimate(source_type: str) -> dict:
|
| 73 |
+
"""
|
| 74 |
+
Return a p75 wait-time estimate in seconds for the given source_type
|
| 75 |
+
('current' or 'archive'), derived from the last 100 successful view
|
| 76 |
+
requests that recorded a duration.
|
| 77 |
+
|
| 78 |
+
Falls back to a hardcoded default when fewer than TIMING_MIN_SAMPLES
|
| 79 |
+
records are available.
|
| 80 |
+
"""
|
| 81 |
+
default_sec = (
|
| 82 |
+
TIMING_DEFAULT_CURRENT_SEC
|
| 83 |
+
if source_type == "current"
|
| 84 |
+
else TIMING_DEFAULT_ARCHIVE_SEC
|
| 85 |
+
)
|
| 86 |
+
try:
|
| 87 |
+
with _get_conn() as conn:
|
| 88 |
+
rows = conn.execute(
|
| 89 |
+
"""
|
| 90 |
+
SELECT duration_ms FROM requests
|
| 91 |
+
WHERE source_type = ?
|
| 92 |
+
AND duration_ms IS NOT NULL
|
| 93 |
+
AND action = 'view'
|
| 94 |
+
ORDER BY id DESC
|
| 95 |
+
LIMIT 100
|
| 96 |
+
""",
|
| 97 |
+
(source_type,),
|
| 98 |
+
).fetchall()
|
| 99 |
+
|
| 100 |
+
durations = sorted(row[0] for row in rows)
|
| 101 |
+
n = len(durations)
|
| 102 |
+
if n < TIMING_MIN_SAMPLES:
|
| 103 |
+
return {"seconds": default_sec, "sample_count": n, "is_default": True}
|
| 104 |
+
|
| 105 |
+
p75_idx = int(0.75 * (n - 1))
|
| 106 |
+
p75_sec = round(durations[p75_idx] / 1000)
|
| 107 |
+
return {"seconds": p75_sec, "sample_count": n, "is_default": False}
|
| 108 |
+
|
| 109 |
+
except Exception:
|
| 110 |
+
return {"seconds": default_sec, "sample_count": 0, "is_default": True}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def get_stats() -> dict:
|
| 114 |
"""Return summary analytics."""
|
| 115 |
try:
|
|
|
|
| 125 |
"SELECT ip, COUNT(*) as cnt FROM requests GROUP BY ip ORDER BY cnt DESC LIMIT 50"
|
| 126 |
).fetchall()
|
| 127 |
recent = conn.execute(
|
| 128 |
+
"""
|
| 129 |
+
SELECT timestamp, ip, duid, date, action, duration_ms, source_type
|
| 130 |
+
FROM requests ORDER BY id DESC LIMIT 100
|
| 131 |
+
"""
|
| 132 |
).fetchall()
|
| 133 |
|
| 134 |
return {
|
|
@@ -47,7 +47,7 @@ header p {
|
|
| 47 |
}
|
| 48 |
|
| 49 |
main {
|
| 50 |
-
max-width:
|
| 51 |
margin: 0 auto;
|
| 52 |
padding: 2rem 1.5rem;
|
| 53 |
display: flex;
|
|
@@ -233,7 +233,7 @@ select:disabled, input:disabled {
|
|
| 233 |
/* ββ Chart ββ */
|
| 234 |
#chart-container {
|
| 235 |
width: 100%;
|
| 236 |
-
height: 400px;
|
| 237 |
}
|
| 238 |
|
| 239 |
/* ββ Download bar ββ */
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
main {
|
| 50 |
+
max-width: 1600px;
|
| 51 |
margin: 0 auto;
|
| 52 |
padding: 2rem 1.5rem;
|
| 53 |
display: flex;
|
|
|
|
| 233 |
/* ββ Chart ββ */
|
| 234 |
#chart-container {
|
| 235 |
width: 100%;
|
| 236 |
+
height: clamp(400px, 50vh, 650px);
|
| 237 |
}
|
| 238 |
|
| 239 |
/* ββ Download bar ββ */
|
|
@@ -10,6 +10,11 @@ let qualityFlags = {};
|
|
| 10 |
let currentData = null;
|
| 11 |
let currentDuid = null;
|
| 12 |
let currentDate = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
/* ββ DOM refs ββ */
|
| 15 |
const loadingMsg = document.getElementById('loading-msg');
|
|
@@ -36,10 +41,12 @@ async function init() {
|
|
| 36 |
inpDate.value = yesterday.toISOString().slice(0, 10);
|
| 37 |
inpDate.max = yesterday.toISOString().slice(0, 10);
|
| 38 |
|
| 39 |
-
// Fetch app info for min date
|
| 40 |
try {
|
| 41 |
const info = await fetch(`${API}/api/info`).then(r => r.json());
|
| 42 |
inpDate.min = info.data_start_date;
|
|
|
|
|
|
|
| 43 |
} catch (_) {}
|
| 44 |
|
| 45 |
// Load BESS list and quality flags in parallel
|
|
@@ -92,6 +99,27 @@ function onBessChange() {
|
|
| 92 |
btnLoad.disabled = !hasBess;
|
| 93 |
}
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
/* ββ Load data ββ */
|
| 96 |
function setFormLocked(locked) {
|
| 97 |
selState.disabled = locked;
|
|
@@ -109,15 +137,12 @@ async function onLoad() {
|
|
| 109 |
hideError();
|
| 110 |
hideResults();
|
| 111 |
|
| 112 |
-
//
|
| 113 |
-
//
|
| 114 |
-
|
| 115 |
-
const
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
} else {
|
| 119 |
-
loadingMsg.textContent = 'Fetching data from AEMO NEMWEBβ¦ older dates are stored in large archive files and may take 5β10 minutes or more. Please wait.';
|
| 120 |
-
}
|
| 121 |
|
| 122 |
showLoading(true);
|
| 123 |
setFormLocked(true);
|
|
@@ -217,6 +242,7 @@ function renderChart(data) {
|
|
| 217 |
});
|
| 218 |
|
| 219 |
const layout = {
|
|
|
|
| 220 |
paper_bgcolor: 'transparent',
|
| 221 |
plot_bgcolor: 'transparent',
|
| 222 |
font: { color: '#e2e8f0', size: 11 },
|
|
|
|
| 10 |
let currentData = null;
|
| 11 |
let currentDuid = null;
|
| 12 |
let currentDate = null;
|
| 13 |
+
let cutoverDate = '2026-01-11'; // updated from /api/info on boot
|
| 14 |
+
let appEstimates = { // updated from /api/info on boot
|
| 15 |
+
current: { seconds: 120, sample_count: 0, is_default: true },
|
| 16 |
+
archive: { seconds: 480, sample_count: 0, is_default: true },
|
| 17 |
+
};
|
| 18 |
|
| 19 |
/* ββ DOM refs ββ */
|
| 20 |
const loadingMsg = document.getElementById('loading-msg');
|
|
|
|
| 41 |
inpDate.value = yesterday.toISOString().slice(0, 10);
|
| 42 |
inpDate.max = yesterday.toISOString().slice(0, 10);
|
| 43 |
|
| 44 |
+
// Fetch app info for min date, cutover date, and timing estimates
|
| 45 |
try {
|
| 46 |
const info = await fetch(`${API}/api/info`).then(r => r.json());
|
| 47 |
inpDate.min = info.data_start_date;
|
| 48 |
+
if (info.cutover_date) cutoverDate = info.cutover_date;
|
| 49 |
+
if (info.estimates) appEstimates = info.estimates;
|
| 50 |
} catch (_) {}
|
| 51 |
|
| 52 |
// Load BESS list and quality flags in parallel
|
|
|
|
| 99 |
btnLoad.disabled = !hasBess;
|
| 100 |
}
|
| 101 |
|
| 102 |
+
/* ββ Timing estimate helpers ββ */
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* Format a timing estimate object into a human-readable string fragment.
|
| 106 |
+
* est = { seconds, sample_count, is_default }
|
| 107 |
+
*/
|
| 108 |
+
function formatEstimate(est) {
|
| 109 |
+
const sec = est.seconds || 120;
|
| 110 |
+
const suffix = est.is_default ? '' : ` (based on ${est.sample_count} recent request${est.sample_count === 1 ? '' : 's'})`;
|
| 111 |
+
let duration;
|
| 112 |
+
if (sec < 60) duration = 'less than a minute';
|
| 113 |
+
else if (sec < 100) duration = 'about 1 minute';
|
| 114 |
+
else if (sec < 160) duration = 'about 2 minutes';
|
| 115 |
+
else if (sec < 220) duration = 'about 3 minutes';
|
| 116 |
+
else if (sec < 310) duration = 'about 4β5 minutes';
|
| 117 |
+
else if (sec < 420) duration = 'about 6β7 minutes';
|
| 118 |
+
else if (sec < 570) duration = 'about 8β10 minutes';
|
| 119 |
+
else duration = '10 minutes or more';
|
| 120 |
+
return `this typically takes ${duration}${suffix}`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
/* ββ Load data ββ */
|
| 124 |
function setFormLocked(locked) {
|
| 125 |
selState.disabled = locked;
|
|
|
|
| 137 |
hideError();
|
| 138 |
hideResults();
|
| 139 |
|
| 140 |
+
// Show a wait-time estimate learned from previous successful requests.
|
| 141 |
+
// The cutover date and p75 estimates are provided by /api/info on boot.
|
| 142 |
+
const srcType = date >= cutoverDate ? 'current' : 'archive';
|
| 143 |
+
const est = appEstimates[srcType] || appEstimates.current;
|
| 144 |
+
loadingMsg.textContent =
|
| 145 |
+
`Fetching data from AEMO NEMWEB\u2026 ${formatEstimate(est)}. Please wait.`;
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
showLoading(true);
|
| 148 |
setFormLocked(true);
|
|
|
|
| 242 |
});
|
| 243 |
|
| 244 |
const layout = {
|
| 245 |
+
autosize: true,
|
| 246 |
paper_bgcolor: 'transparent',
|
| 247 |
plot_bgcolor: 'transparent',
|
| 248 |
font: { color: '#e2e8f0', size: 11 },
|