Claude commited on
Commit
e6a4103
Β·
1 Parent(s): 7986823

Add learned wait-time estimates and stretch chart to fill frame

Browse files

Timing 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 CHANGED
@@ -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 = [
app/routers/api.py CHANGED
@@ -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 ANALYTICS_TOKEN, DATA_START_DATE, MAX_DAYS_PER_REQUEST
 
 
 
 
 
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
  }
app/services/analytics.py CHANGED
@@ -1,11 +1,16 @@
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:
@@ -15,34 +20,96 @@ def _get_conn() -> sqlite3.Connection:
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:
@@ -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
- "SELECT timestamp, ip, duid, date, action FROM requests ORDER BY id DESC LIMIT 100"
 
 
 
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 {
app/static/css/style.css CHANGED
@@ -47,7 +47,7 @@ header p {
47
  }
48
 
49
  main {
50
- max-width: 1200px;
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 ── */
app/static/js/app.js CHANGED
@@ -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
- // Give a realistic wait-time estimate based on how the NEMWEB archive is structured.
113
- // Files from 11 Jan 2026 onward are served as individual daily ZIPs (~2–3 min).
114
- // Older dates are packed in large monthly/quarterly archives (~5–10 min or more).
115
- const RECENT_CUTOFF = '2026-01-11';
116
- if (date >= RECENT_CUTOFF) {
117
- loadingMsg.textContent = 'Fetching data from AEMO NEMWEB… this typically takes 2–3 minutes. Please wait.';
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 },