From 53b33a83256b12737d1756d19cdbd70495ed29a4 Mon Sep 17 00:00:00 2001 From: dhamsey3 <73405591+dhamsey3@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:46:09 +0200 Subject: [PATCH] feat: public status page on GitHub Pages, and a cost alert `python -m voltcast status` reads the live record into status.json and renders one self-contained page: whether tomorrow's forecast is out, the latest scored day against the cleared price, the next forecast, rolling error per model, what's deployed, and the AWS bill. It uses inline CSS and SVG, a table behind every chart, and dark mode. Only live forecasts appear. A scheduled workflow rebuilds it after the deadline check and after scoring, using a read-only role that sees only published results. The bill comes from a new account-wide AWS Budget, voltcast-monthly. Its API is free, unlike Cost Explorer's. The budget also emails on-call when actual or forecast spend passes 5 USD, so the cost line in the promise table is now enforced. ADR 6 records the choices. --- .github/workflows/status.yml | 68 ++++ .gitignore | 3 +- README.md | 25 +- docs/adr/0006-status-page.md | 45 +++ infrastructure/budget.tf | 25 ++ infrastructure/outputs.tf | 5 + infrastructure/variables.tf | 6 + src/voltcast/__main__.py | 17 + src/voltcast/page.py | 701 +++++++++++++++++++++++++++++++++++ src/voltcast/status.py | 161 ++++++++ tests/test_status.py | 177 +++++++++ 11 files changed, 1225 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/status.yml create mode 100644 docs/adr/0006-status-page.md create mode 100644 infrastructure/budget.tf create mode 100644 src/voltcast/page.py create mode 100644 src/voltcast/status.py create mode 100644 tests/test_status.py diff --git a/.github/workflows/status.yml b/.github/workflows/status.yml new file mode 100644 index 0000000..218792d --- /dev/null +++ b/.github/workflows/status.yml @@ -0,0 +1,68 @@ +name: status + +# Rebuilds the public status page on GitHub Pages from the live records in S3 +# and the monthly AWS Budget. It runs after the 10:15 deadline check and after +# the 14:30 scoring run (Berlin; the cron times are UTC and land after both in +# summer and winter time), and whenever the page code changes on main. +# GitHub pauses scheduled workflows after 60 days without commits; a manual +# run (workflow_dispatch) starts them again. + +on: + schedule: + - cron: "30 9 * * *" + - cron: "15 14 * * *" + push: + branches: [main] + paths: + - src/voltcast/status.py + - src/voltcast/page.py + - .github/workflows/status.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: status-page + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-dev.txt + - run: pip install -r requirements-dev.txt -e . + - uses: aws-actions/configure-aws-credentials@v4 + with: + # Read-only and trusted from main only (aws-oidc-bootstrap, role-voltcast.tf). + role-to-assume: ${{ vars.AWS_STATUS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Build the page + run: | + # The data bucket is named voltcast-data- (infrastructure/storage.tf). + account=$(aws sts get-caller-identity --query Account --output text) + python -m voltcast --store "s3://voltcast-data-$account" status --out site --budget voltcast-monthly + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5d0152b..aaa7e46 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,10 @@ __pycache__/ .ruff_cache/ .DS_Store -# local pipeline state and Lambda build output +# local pipeline state, Lambda build output and status page builds data/ build/ +site/ # Terraform infrastructure/.terraform/ diff --git a/README.md b/README.md index 63d7e31..6484dad 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ forecast before the auction closes at 12:00. That afternoon it grades itself against the cleared prices. It runs unattended on AWS, raises an alert when it misses a promise, and keeps the evidence behind every number it publishes. +**[Live status page](https://dhamsey3.github.io/voltcast/)**: the latest forecast +against the cleared price, the running error, what's deployed and the AWS bill. +It's rebuilt twice a day from the service's own records, and the data behind it +is at [`status.json`](https://dhamsey3.github.io/voltcast/status.json). + ## Why this problem The day-ahead auction sets the price of every 15-minute slot of the next day. @@ -28,7 +33,7 @@ notebook. | Every forecast is scored | same afternoon | `run-failed` alarm if scoring gives up | | Bad upstream data is never used | always | [data contract](docs/adr/0005-data-contract.md) stops the run | | No hindsight forecasts | always | live forecasts after the 12:00 gate closure are refused | -| Cost | about 2 USD/month | AWS bill (estimate until the first full month) | +| Cost | about 1 USD/month, alert above 5 | AWS Budget `voltcast-monthly` emails on-call on actual or forecast spend | ## Architecture @@ -86,9 +91,11 @@ src/voltcast/ models.py champion/challenger models pipeline.py ingest, forecast, score, check, backfill, report handler.py Lambda entry point - __main__.py CLI for local runs and backfills -tests/ 41 tests, incl. both clock-change days and a real SMARD chunk -infrastructure/ Terraform: S3+KMS, Lambda, Step Functions, Scheduler, alarms + status.py the status page's data: live record, promise, bill + page.py the status page itself: one self-contained HTML file + __main__.py CLI for local runs, backfills and the status page +tests/ 56 tests, incl. both clock-change days and a real SMARD chunk +infrastructure/ Terraform: S3+KMS, Lambda, Step Functions, Scheduler, alarms, budget docs/ architecture.drawio the diagram above (draw.io source) adr/ why things are the way they are @@ -104,6 +111,7 @@ make test # unit tests make backfill # replay every day since 2025-10-01 from real SMARD data (~30 s) make report # champion vs challenger .venv/bin/python -m voltcast forecast # forecast the next open auction +.venv/bin/python -m voltcast status # build the status page into ./site from live results ``` State goes to `./data` (gitignored), laid out exactly as in the S3 bucket. @@ -140,16 +148,19 @@ Berlin. ## Operating it +- [Status page](https://dhamsey3.github.io/voltcast/): rebuilt by the + [status workflow](.github/workflows/status.yml) after the 10:15 deadline check and after + scoring, with a read-only role that sees only published results ([ADR 6](docs/adr/0006-status-page.md)) - [Runbook](docs/runbook.md): what each alarm means and what to do - [Incidents](docs/incidents/README.md): every missed promise, written up -- [Decisions](docs/adr/): ADRs 1 to 5 +- [Decisions](docs/adr/): ADRs 1 to 6 ## Roadmap 1. **Live baseline service.** Live since 2026-10-14 in eu-central-1; the first live forecast is for 16 Oct 2026. 2. **Deploy from CI, public status page.** GitHub Actions deploys through OIDC - ([Deploy](#deploy)). Still to come: a public page showing the latest forecast against the - cleared price, rolling error and model version, plus a monthly report with the real AWS bill. + ([Deploy](#deploy)), and the [status page](https://dhamsey3.github.io/voltcast/) shows the + latest forecast against the cleared price, the running error, the deployed model and the AWS bill. 3. **First learned model.** Features from ENTSO-E (load, wind and solar forecasts) and DWD weather. Trained on SageMaker and registered in the Model Registry with lineage. The approval gate: beat the champion over 30 days in shadow. diff --git a/docs/adr/0006-status-page.md b/docs/adr/0006-status-page.md new file mode 100644 index 0000000..53c8919 --- /dev/null +++ b/docs/adr/0006-status-page.md @@ -0,0 +1,45 @@ +# 6. A static status page on GitHub Pages; the bill from AWS Budgets + +Date: 2026-10-16 · Status: accepted + +## Context + +The service's record lives in a private S3 bucket. Anyone judging it, whether +a reader of the README or a hiring manager, should be able to see the latest +forecast against the cleared price, the running error, what is deployed, and +what it costs, without AWS access. The page has to cost close to nothing: +the whole service runs at about 1 USD a month. + +## Decision + +- **A static page, rebuilt twice a day** by a scheduled GitHub Actions + workflow, after the 10:15 deadline check and after the 14:30 scoring run. + `python -m voltcast status` reads the store into `status.json` and renders + one self-contained `index.html` from it: inline CSS and SVG, no external + assets, every value in a table as well as a chart. +- **GitHub Pages hosts it.** The repo is public, so hosting and the + workflow's minutes are free. Nothing new is exposed in AWS: no public + bucket and no CDN. +- **A read-only OIDC role**, trusted from `main` only, reads just what the page + shows: published forecasts, scores and cleared prices, decrypted through S3. + It can't read raw snapshots or the challengers' unpublished forecasts, and + it can't write anything. +- **The bill comes from an AWS Budget**, not Cost Explorer. Cost Explorer + charges 0.01 USD per request, so two builds a day would add 0.30 to 0.60 USD + a month, a third or more of the bill. The Budgets API is free, and the same + budget emails on-call when actual or forecast spend passes 5 USD, which + makes the cost line in the README's promise table enforced rather than + hoped for. + +## Consequences + +- The page can be up to a few hours stale, and GitHub's schedule can run late + under load. The service's own promises are enforced by CloudWatch alarms, not + by this page. +- The budget is account-wide. That is accurate while this account runs only + voltcast. Tracking voltcast alone needs the `project` tag activated as a + cost allocation tag, which only applies from activation onward. +- GitHub pauses scheduled workflows after 60 days without commits. A manual + run starts them again. +- Only live forecasts appear. The backfill replay stays in `voltcast report` + and the README, so the live record is never mixed with hindsight. diff --git a/infrastructure/budget.tf b/infrastructure/budget.tf new file mode 100644 index 0000000..0769224 --- /dev/null +++ b/infrastructure/budget.tf @@ -0,0 +1,25 @@ +# The cost promise as an alert: AWS Budgets emails on-call when this month's +# actual spend passes the limit, or when AWS forecasts that it will. It is +# account-wide because this account runs only voltcast (the project tag isn't +# activated for cost allocation). The status page reads the same budget for +# its bill section; the Budgets API is free, unlike Cost Explorer's. + +resource "aws_budgets_budget" "monthly" { + name = "${var.project}-monthly" + budget_type = "COST" + limit_amount = format("%.2f", var.monthly_budget_usd) + limit_unit = "USD" + time_unit = "MONTHLY" + + dynamic "notification" { + # Only whether an address is set is revealed here, never the address. + for_each = nonsensitive(var.alert_email == "") ? {} : { ACTUAL = 100, FORECASTED = 100 } + content { + notification_type = notification.key + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + subscriber_email_addresses = [var.alert_email] + } + } +} diff --git a/infrastructure/outputs.tf b/infrastructure/outputs.tf index e01b457..9fb1958 100644 --- a/infrastructure/outputs.tf +++ b/infrastructure/outputs.tf @@ -15,3 +15,8 @@ output "lambda_function" { output "alerts_topic_arn" { value = aws_sns_topic.alerts.arn } + +output "budget_name" { + description = "Read by the status page for its bill section" + value = aws_budgets_budget.monthly.name +} diff --git a/infrastructure/variables.tf b/infrastructure/variables.tf index 8038014..1a65597 100644 --- a/infrastructure/variables.tf +++ b/infrastructure/variables.tf @@ -24,6 +24,12 @@ variable "alert_email" { sensitive = true } +variable "monthly_budget_usd" { + description = "Monthly AWS spend that alerts on-call, on actual or forecast spend (budget.tf)" + type = number + default = 5 +} + # Promotion is a reviewed change to these two values - see docs/adr/0003. variable "champion" { description = "Model whose forecast is published" diff --git a/src/voltcast/__main__.py b/src/voltcast/__main__.py index f4c4184..672f849 100644 --- a/src/voltcast/__main__.py +++ b/src/voltcast/__main__.py @@ -4,6 +4,7 @@ python -m voltcast forecast --date 2026-10-16 python -m voltcast backfill --start 2026-09-01 --end 2026-10-14 python -m voltcast report --days 30 + python -m voltcast status --out site --budget voltcast-monthly ``--store`` takes a local folder (default ./data) or s3://bucket. """ @@ -15,10 +16,13 @@ import logging import os from datetime import UTC, date, datetime, timedelta +from pathlib import Path from voltcast.calendar import berlin_today, next_auction_day +from voltcast.page import write_site from voltcast.pipeline import Settings, backfill, check_published, forecast, ingest, report, score from voltcast.smard import SmardClient +from voltcast.status import collect, read_budget from voltcast.store import open_store @@ -41,6 +45,9 @@ def main(argv: list[str] | None = None) -> None: p.add_argument("--end", type=date.fromisoformat, required=True) p = sub.add_parser("report", help="mean error per model") p.add_argument("--days", type=int, default=30) + p = sub.add_parser("status", help="build the public status page from live results") + p.add_argument("--out", type=Path, default=Path("site")) + p.add_argument("--budget", default="", help="AWS Budget to read the bill from (needs AWS credentials)") args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") @@ -67,6 +74,16 @@ def main(argv: list[str] | None = None) -> None: result = backfill(store, SmardClient(), args.start, args.end, settings) case "report": result = report(store, today - timedelta(days=args.days), today + timedelta(days=1)) + case "status": + budget = None + if args.budget: + import boto3 + + account = boto3.client("sts").get_caller_identity()["Account"] + budget = read_budget(boto3.client("budgets", region_name="us-east-1"), account, args.budget, now) + status = collect(store, now, budget) + files = write_site(status, args.out) + result = {"wrote": [str(f) for f in files], "promise": status["promise"]["state"], "cost": bool(budget)} print(json.dumps(result, indent=2)) diff --git a/src/voltcast/page.py b/src/voltcast/page.py new file mode 100644 index 0000000..b787169 --- /dev/null +++ b/src/voltcast/page.py @@ -0,0 +1,701 @@ +"""Render the status dict (``voltcast.status``) as one self-contained HTML page. + +No external assets: inline CSS, inline SVG plots, and a short script that only +adds hover and keyboard read-outs. Every value a read-out shows is also in a +table, so the page reads fine without JavaScript. + +The plots stretch to their box (``preserveAspectRatio="none"``) with +non-scaling strokes, and all text sits in HTML around them, so labels stay +legible at phone width instead of shrinking with the SVG. +""" + +from __future__ import annotations + +import html +import json +import math +from datetime import date +from pathlib import Path + +from voltcast.calendar import BERLIN, parse_iso +from voltcast.models import MODELS + +REPO_URL = "https://github.com/dhamsey3/voltcast" + +# Colour follows the entity across the whole page: the cleared price is always +# slot 1 and each model keeps its slot whatever its role. Give a new model the +# next free slot here; never renumber, or readers' colour memory breaks. +SLOTS = {"actual": 1, "naive_1d": 2, "seasonal_naive_7d": 3} +MAX_SLOT = 8 + + +def _slot(key: str) -> int: + if key in SLOTS: + return SLOTS[key] + extra = [name for name in MODELS if name not in SLOTS] + return min(max(SLOTS.values()) + 1 + (extra.index(key) if key in extra else len(extra)), MAX_SLOT) + + +# --- formatting ----------------------------------------------------------- + + +def _e(text: object) -> str: + return html.escape(str(text), quote=True) + + +def _num(value: float | None, digits: int = 2) -> str: + return "–" if value is None else f"{value:,.{digits}f}".replace("-", "−") + + +def _money(value: float | None) -> str: + return "–" if value is None else f"${value:,.2f}" + + +def _day(iso_day: str, *, weekday: bool = True) -> str: + d = date.fromisoformat(iso_day) + return f"{d:%a} {d.day} {d:%b %Y}" if weekday else f"{d.day} {d:%b %Y}" + + +def _short_day(iso_day: str) -> str: + d = date.fromisoformat(iso_day) + return f"{d:%a} {d.day} {d:%b}" + + +def _when(ts: str) -> str: + local = parse_iso(ts).astimezone(BERLIN) + return f"{local.day} {local:%b}, {local:%H:%M %Z}" + + +def _clock(ts: str) -> str: + return f"{parse_iso(ts).astimezone(BERLIN):%H:%M}" + + +def _month(ym: str) -> str: + return f"{date.fromisoformat(ym + '-01'):%B %Y}" + + +# --- charts --------------------------------------------------------------- + + +def _nice_ticks(lo: float, hi: float, max_ticks: int = 7) -> list[float]: + """Round-numbered ticks covering lo..hi: the finest 1/2/2.5/5 step that + needs no more than ``max_ticks``, so the plot wastes little range.""" + if math.isclose(lo, hi): + lo, hi = lo - 1, hi + 1 + magnitude = 10 ** math.floor(math.log10((hi - lo) / max_ticks)) + for step in (m * magnitude * scale for scale in (1, 10, 100) for m in (1, 2, 2.5, 5)): + start, end = math.floor(lo / step) * step, math.ceil(hi / step) * step + if round((end - start) / step) + 1 <= max_ticks: + return [round(start + i * step, 6) for i in range(round((end - start) / step) + 1)] + raise AssertionError("unreachable: a 100x step always fits") + + +def _chart( + chart_id: str, + *, + title: str, + note: str, + x_labels: list[str], + x_ticks: list[tuple[int, str]], + x_header: str, + series: list[dict], + step: bool, + from_zero: bool = False, + unit: str = "EUR/MWh", +) -> str: + """A line chart: ``step`` holds each value across its slot (quarter-hour + prices); otherwise values are points at slot centres (daily errors).""" + n = len(x_labels) + values = [v for s in series for v in s["values"] if v is not None] + lo, hi = min(values), max(values) + ticks = _nice_ticks(min(lo, 0) if from_zero else lo, hi) + t0, t1 = ticks[0], ticks[-1] + + def y(v: float) -> float: + return round(1000 - (v - t0) / (t1 - t0) * 1000, 2) + + def x(i: int) -> float: + return i if step else i + 0.5 + + def top(v: float) -> str: + return f"{y(v) / 10:.2f}%" + + def left(i: float) -> str: + return f"{i / n * 100:.3f}%" + + svg = [] + for t in ticks: + cls = "axis" if t == t0 else ("zero" if t == 0 else "grid") + svg.append(f'') + for s in series: + path, pen_down = [], False + for i, v in enumerate(s["values"]): + if v is None: + pen_down = False + continue + if step: + path.append(f"{'V' if pen_down else f'M{i}'} {y(v)} H{i + 1}") + else: + path.append(f"{'L' if pen_down else 'M'}{x(i)} {y(v)}") + pen_down = True + svg.append(f'') + if not step: + for i, v in enumerate(s["values"]): + if v is not None: + dot = f"M{x(i)} {y(v)}h0" + svg.append(f'') + + y_labels = "".join(f'{_num(t, 0)}' for t in ticks) + x_labels_html = "".join( + f'{_e(label)}' for i, label in x_ticks + ) + + # Direct labels at the line ends, unless two would collide: then the + # legend and the read-out carry identity instead of nudged labels. + ends, taken = [], [] + for s in series: + last = next((v for v in reversed(s["values"]) if v is not None), None) + if last is None or any(abs(y(last) - other) < 90 for other in taken): + continue + taken.append(y(last)) + ends.append(f'{_e(s["short"])} {_num(last)}') + + legend = "" + if len(series) > 1: + legend = '
    ' + "".join( + f'
  • {_e(s["name"])}
  • ' for s in series + ) + legend += "
" + + head = "".join(f"{_e(s['name'])}" for s in series) + rows = "".join( + f"{_e(label)}" + "".join(f"{_num(s['values'][i])}" for s in series) + "" + for i, label in enumerate(x_labels) + ) + data = {"x": x_labels, "unit": unit, "series": [{k: s[k] for k in ("name", "slot", "values")} for s in series]} + + return f""" +
+

{_e(title)}

{note}

+ {legend} +
+ +
+
+ Show the numbers +
{head}{rows}
{_e(x_header)}
+
+ +
""" + + +def _price_chart(chart_id: str, title: str, note: str, slots: list[dict], series: list[dict]) -> str: + times = [parse_iso(s["start"]).astimezone(BERLIN) for s in slots] + labels = [f"{t:%H:%M}" for t in times] + ticks = [(i, f"{t:%H:%M}") for i, t in enumerate(times) if t.minute == 0 and t.hour % 6 == 0] + ticks.append((len(times), "24:00")) + return _chart( + chart_id, + title=title, + note=note, + x_labels=labels, + x_ticks=ticks, + x_header="Berlin time", + series=series, + step=True, + ) + + +# --- page sections -------------------------------------------------------- + +ICONS = { + "good": '', + "critical": '', + "pending": '', +} + + +def _status(kind: str, label: str) -> str: + icon = f'' + return f'{icon}{_e(label)}' + + +def _tile(label: str, value: str, sub: str, extra: str = "") -> str: + return ( + f'

{label}

{value}

' + f'

{sub}

{extra}
' + ) + + +def _tiles(status: dict) -> str: + promise = status["promise"] + day = _short_day(promise["delivery_date"]) + if promise["state"] == "published": + first = _tile( + f"Forecast for {day}", + _status("good", "Published"), + f"at {_when(promise['published_at'])}, before the auction", + ) + elif promise["state"] == "due": + first = _tile( + f"Forecast for {day}", _status("pending", "Due"), f"by {_clock(promise['deadline'])} Berlin today" + ) + else: + first = _tile( + f"Forecast for {day}", + _status("critical", "Missing"), + f"deadline was {_clock(promise['deadline'])}; on-call has been alerted", + ) + + scored = status["scored"] + if scored and scored["model"] in scored["scores"]: + error = scored["scores"][scored["model"]] + second = _tile( + f"Error on {_short_day(scored['delivery_date'])}", + f'{_num(error["mae"])} EUR/MWh', + f"mean absolute error of the published forecast ({_e(scored['model'])})", + ) + else: + second = _tile("Latest error", "–", "no live day has been scored yet") + + record = status["rolling"]["all_live_days"].get(status["champion"] or "", {}) + if record: + month = status["rolling"]["last_30_days"].get(status["champion"], {}) + third = _tile( + "Live record", + f"{record['days']} {'day' if record['days'] == 1 else 'days'}", + f"since {_day(status['live_since'], weekday=False)} · 30-day MAE {_num(month.get('mae'))}", + ) + else: + third = _tile("Live record", "0 days", "the first live forecast hasn't been scored yet") + + cost = status["cost"] + if cost: + share = min(cost["month_to_date"] / cost["limit"], 1) if cost["limit"] else 0 + meter = ( + f'
' + f'
' + ) + forecast = f" · forecast {_money(cost['forecast'])}" if cost["forecast"] is not None else "" + fourth = _tile( + f"AWS bill, {_month(cost['month']).split()[0]}", + _money(cost["month_to_date"]), + f"of a {_money(cost['limit'])} monthly budget{forecast}", + meter, + ) + else: + fourth = _tile("AWS bill", "–", "budget figures appear after AWS's next daily billing update") + + return f'
{first}{second}{third}{fourth}
' + + +def _scores_table(scores: dict[str, dict]) -> str: + rows = "".join( + f'{_e(model)}' + f"{_e(s['role'])}{_num(s['mae'])}{_num(s['rmse'])}{_num(s['bias'])}" + for model, s in scores.items() + ) + return ( + '
' + f"{rows}
ModelRoleMAERMSEBias
" + ) + + +def _scored_section(status: dict) -> str: + scored = status["scored"] + if not scored: + return ( + '

How the last forecast did

No live day has been scored yet.

' + ) + model = scored["model"] + series = [ + { + "name": "Cleared price", + "short": "Cleared", + "slot": 1, + "values": [s["actual"] for s in scored["slots"]], + } + ] + if model: + series.append( + { + "name": f"Forecast ({model})", + "short": "Forecast", + "slot": _slot(model), + "values": [s["forecast"] for s in scored["slots"]], + } + ) + day = _day(scored["delivery_date"]) + chart = _price_chart( + "scored", + f"{day}: published forecast against the cleared price", + "Quarter-hour prices in EUR/MWh, Berlin time. Challengers ran in shadow; their errors are in the table.", + scored["slots"], + series, + ) + return ( + f"

How the last forecast did

{chart}" + f'

Errors on {_e(day)} (EUR/MWh)

{_scores_table(scored["scores"])}
' + ) + + +def _upcoming_section(status: dict) -> str: + upcoming = status["upcoming"] + if not upcoming: + return "" + series = [ + { + "name": f"Forecast ({upcoming['model']})", + "short": "Forecast", + "slot": _slot(upcoming["model"]), + "values": [s["price"] for s in upcoming["slots"]], + } + ] + day = _day(upcoming["delivery_date"]) + chart = _price_chart( + "upcoming", + f"{day}: forecast from {upcoming['model']}", + f"Published {_e(_when(upcoming['as_of']))}. Scored against the cleared prices the afternoon before delivery.", + upcoming["slots"], + series, + ) + return f"

The next forecast

{chart}
" + + +def _record_section(status: dict) -> str: + rolling = status["rolling"] + if not rolling["all_live_days"]: + return "" + names = {"last_7_days": "Last 7 days", "last_30_days": "Last 30 days", "all_live_days": "Since going live"} + + def days(by_model: dict) -> dict: + return {model: r["days"] for model, r in by_model.items()} + + # A window that covers the same days as the next longer one only repeats + # it (early on, all three do), so show the longer one alone. + windows = list(rolling.items()) + shown = [(w, m) for i, (w, m) in enumerate(windows) if i == len(windows) - 1 or days(m) != days(windows[i + 1][1])] + rows = [] + for window, by_model in shown: + for i, (model, r) in enumerate(by_model.items()): + first = f'{names[window]}' if i == 0 else "" + rows.append( + f'{first}{_e(model)}' + f"{r['days']}{_num(r['mae'])}{_num(r['rmse'])}{_num(r['bias'])}" + ) + table = ( + '
' + f"{''.join(rows)}
WindowModelDaysMAERMSEBias
" + ) + + trend = status["trend"] + chart = "" + if len(trend) >= 2: + models = sorted({m for t in trend for m in t["mae"]}, key=_slot) + chart = _chart( + "trend", + title=f"Daily mean absolute error, last {len(trend)} live days", + note="EUR/MWh; lower is better. Every model is scored every day, published or not.", + x_labels=[_short_day(t["delivery_date"]) for t in trend], + x_ticks=[ + (i, _short_day(t["delivery_date"])) + for i, t in enumerate(trend) + if i in (0, len(trend) - 1) or (len(trend) > 6 and i == len(trend) // 2) + ], + x_header="Delivery day", + series=[ + {"name": m, "short": m, "slot": _slot(m), "values": [t["mae"].get(m) for t in trend]} for m in models + ], + step=False, + from_zero=True, + ) + note = ( + '

Averages of daily errors over live forecasts only. The year-long replay that set the bar' + f' is in the README.

' + ) + return f"

Track record

{chart}{table}{note}
" + + +def _model_section(status: dict) -> str: + model = status["model"] + if not model: + return "" + challengers = sorted(m for m in (status["scored"] or {}).get("scores", {}) if m != model["model"]) + desc = MODELS[model["model"]].description if model["model"] in MODELS else "" + inputs = "".join( + f'
  • {_e(i["key"])} sha256 {_e(i["sha256"][:12])}…
  • ' + for i in model["inputs"] + ) + code = _e(model["code_version"]) + chall = ", ".join(f"{_e(c)}" for c in challengers) or "none" + return f""" +

    What's running

    +
    +
    Published model
    +
    {_e(model["model"])} v{_e(model["model_version"])} · {_e(desc)}
    +
    In shadow
    {chall}
    +
    Code
    {code}
    +
    Latest forecast made
    {_e(_when(model["as_of"]))} for {_e(_day(model["delivery_date"]))}
    +
    +
    Inputs it read ({len(model["inputs"])})
      {inputs}
    +
    """ + + +def _cost_section(status: dict) -> str: + cost = status["cost"] + if not cost: + return "" + rows = "".join( + f'{_month(m["month"])}{" (so far)" if m["month"] == cost["month"] else ""}' + f"{_money(m['actual'])}" + for m in reversed(cost["months"]) + ) + return f""" +

    What it costs

    +
    +{rows}
    MonthAWS bill (USD)
    +

    The whole AWS account, which runs only voltcast, against a {_money(cost["limit"])} monthly budget. +The {_e(cost["budget"])} budget in AWS Budgets also emails on-call if actual or forecast spend +passes the limit. Figures update once a day.

    +
    """ + + +def write_site(status: dict, out: Path) -> list[Path]: + """The deployable site: the page plus the data it was built from.""" + out.mkdir(parents=True, exist_ok=True) + files = {out / "index.html": render(status), out / "status.json": json.dumps(status, indent=1) + "\n"} + for path, text in files.items(): + path.write_text(text, encoding="utf-8") + return list(files) + + +def render(status: dict) -> str: + body = "".join( + [ + _tiles(status), + _scored_section(status), + _upcoming_section(status), + _record_section(status), + _model_section(status), + _cost_section(status), + ] + ) + return f""" + + + + +voltcast status + + + + +
    +
    +

    voltcast · service status

    +

    Tomorrow's German power prices, forecast and graded every day

    +

    Each morning voltcast publishes a price for every quarter-hour of tomorrow's {_e(status["region"])} + day-ahead auction, before bidding closes at 12:00. That afternoon it scores itself against the prices that cleared. + This page is rebuilt from the service's own records twice a day.

    +

    Updated {_e(_when(status["generated_at"]))} · status.json · + Source and design

    +
    +{body} + +
    + + + +""" + + +CSS = """ +:root { + color-scheme: light; + --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #6f6d68; + --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,0.10); --link: #1c5cab; + --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; + --s5: #e87ba4; --s6: #008300; --s7: #6250d6; --s8: #e34948; + --good: #0ca30c; --critical: #d03b3b; --pending: #898781; --meter: #2a78d6; --meter-track: #cde2fb; +} +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #a3a29b; + --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --link: #86b6ef; + --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; + --s5: #d55181; --s6: #008300; --s7: #9085e9; --s8: #e66767; + --meter: #3987e5; --meter-track: #184f95; + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--page); color: var(--ink); + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; } +main { max-width: 960px; margin: 0 auto; padding: 32px 16px 48px; } +a { color: var(--link); } +code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; } +h1 { font-size: clamp(1.6rem, 4vw, 2.2rem); line-height: 1.2; margin: 4px 0 12px; letter-spacing: -0.01em; } +h2 { font-size: 1.25rem; margin: 0 0 16px; } +h3 { font-size: 1rem; margin: 0; } +.eyebrow { margin: 0; color: var(--ink-2); font-size: 0.875rem; letter-spacing: 0.02em; } +.lede { max-width: 68ch; color: var(--ink-2); margin: 0 0 8px; } +.meta, .note, .empty { color: var(--ink-2); font-size: 0.875rem; } +.note { margin: 4px 0 0; } +section { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + padding: 20px; margin-top: 16px; } +.tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + background: none; border: 0; padding: 0; } +.tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; } +.tile .label { margin: 0; color: var(--ink-2); font-size: 0.875rem; } +.tile .value { margin: 4px 0 2px; font-size: 1.6rem; font-weight: 600; line-height: 1.25; } +.tile .unit { font-size: 0.875rem; font-weight: 400; color: var(--ink-2); } +.tile .sub { margin: 0; color: var(--ink-2); font-size: 0.8125rem; } +.status { display: inline-flex; align-items: center; gap: 8px; } +.status svg { width: 22px; height: 22px; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.status circle { stroke: none; } +.status-good circle { fill: var(--good); } .status-critical circle { fill: var(--critical); } +.status-pending circle { fill: none; stroke: var(--pending); stroke-width: 1.4; } +.status-good path, .status-critical path { stroke: #fff; } .status-pending path { stroke: var(--pending); } +.meter { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--meter-track); overflow: hidden; } +.meter span { display: block; height: 100%; min-width: 3px; border-radius: 3px; background: var(--meter); } +.chart { margin: 0; } +.chart figcaption { margin-bottom: 8px; } +.legend { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0 0 8px; padding: 0; + font-size: 0.875rem; color: var(--ink-2); } +.legend li { display: inline-flex; align-items: center; gap: 6px; } +.key { display: inline-block; width: 14px; height: 2px; border-radius: 1px; vertical-align: middle; margin-right: 6px; } +.legend .key { margin: 0; } +.frame { padding: 8px 88px 30px 44px; } +.plot { position: relative; height: 220px; outline: none; } +.plot:focus-visible { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--link); border-radius: 2px; } +.plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.plot line, .plot path { vector-effect: non-scaling-stroke; fill: none; } +.plot .grid { stroke: var(--grid); stroke-width: 1; } +.plot .axis, .plot .zero { stroke: var(--axis); stroke-width: 1; } +.plot .line { stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } +.plot .ring { stroke: var(--surface); stroke-width: 12; stroke-linecap: round; } +.plot .dot { stroke-width: 8; stroke-linecap: round; } +.ylab, .xlab, .end { position: absolute; font-size: 0.75rem; color: var(--muted); white-space: nowrap; + font-variant-numeric: tabular-nums; } +.ylab { right: 100%; margin-right: 8px; transform: translateY(-50%); } +.xlab { top: 100%; margin-top: 8px; transform: translateX(-50%); } +.end { left: 100%; margin-left: 8px; transform: translateY(-50%); color: var(--ink-2); } +.crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--axis); pointer-events: none; } +.tip { position: absolute; top: 0; z-index: 2; min-width: 150px; padding: 8px 10px; border-radius: 8px; + background: var(--surface); border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.12); + font-size: 0.8125rem; pointer-events: none; } +.tip-head { color: var(--ink-2); margin-bottom: 4px; } +.tip-row { display: flex; align-items: center; gap: 8px; } +.tip-row strong { font-variant-numeric: tabular-nums; } +.tip-name { color: var(--ink-2); } +.s1 { stroke: var(--s1); } .s2 { stroke: var(--s2); } .s3 { stroke: var(--s3); } .s4 { stroke: var(--s4); } +.s5 { stroke: var(--s5); } .s6 { stroke: var(--s6); } .s7 { stroke: var(--s7); } .s8 { stroke: var(--s8); } +.key.s1 { background: var(--s1); } .key.s2 { background: var(--s2); } .key.s3 { background: var(--s3); } +.key.s4 { background: var(--s4); } .key.s5 { background: var(--s5); } .key.s6 { background: var(--s6); } +.key.s7 { background: var(--s7); } .key.s8 { background: var(--s8); } +.table-view { margin-top: 8px; font-size: 0.875rem; } +.table-view summary, .lineage summary { cursor: pointer; color: var(--link); } +.table-view .scroll { max-height: 320px; overflow: auto; margin-top: 8px; } +.scroll { overflow-x: auto; } +.table-title { margin: 20px 0 8px; } +table { border-collapse: collapse; width: 100%; font-size: 0.875rem; font-variant-numeric: tabular-nums; } +th, td { padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--grid); white-space: nowrap; } +thead th { color: var(--ink-2); font-weight: 500; position: sticky; top: 0; background: var(--surface); } +tbody th { font-weight: 400; text-align: left; } +th:first-child { text-align: left; } +.text { text-align: left; } +.record tbody th { vertical-align: top; } +.facts { display: grid; gap: 12px; margin: 0; } +.facts div { display: grid; grid-template-columns: minmax(120px, 180px) 1fr; gap: 12px; } +.facts dt { color: var(--ink-2); } +.facts dd { margin: 0; } +.lineage { margin-top: 12px; font-size: 0.875rem; } +.lineage ul { padding-left: 18px; } +.hash { color: var(--muted); } +footer { margin-top: 24px; color: var(--ink-2); font-size: 0.8125rem; } +footer p { margin: 4px 0; } +@media (max-width: 560px) { + section { padding: 16px; } + .frame { padding-right: 72px; } + .facts div { grid-template-columns: 1fr; gap: 0; } +} +@media (forced-colors: active) { + .plot .line, .plot .dot { stroke: CanvasText; } +} +""" + +SCRIPT = """ +(() => { + const fmt = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + document.querySelectorAll("figure.chart").forEach((fig) => { + const data = JSON.parse(fig.querySelector(".chart-data").textContent); + const plot = fig.querySelector(".plot"); + const line = fig.querySelector(".crosshair"); + const tip = fig.querySelector(".tip"); + const n = data.x.length; + let current = -1; + const pos = (i) => ((i + 0.5) / n) * 100; + + function show(i) { + current = Math.max(0, Math.min(n - 1, i)); + const x = pos(current); + line.hidden = false; + tip.hidden = false; + line.style.left = x + "%"; + const head = document.createElement("div"); + head.className = "tip-head"; + head.textContent = data.x[current]; + const rows = data.series.map((s) => { + const row = document.createElement("div"); + row.className = "tip-row"; + const key = document.createElement("span"); + key.className = "key s" + s.slot; + const value = document.createElement("strong"); + const v = s.values[current]; + value.textContent = v == null ? "–" : fmt.format(v).replace("-", "\\u2212") + " " + data.unit; + const name = document.createElement("span"); + name.className = "tip-name"; + name.textContent = s.name; + row.append(key, value, name); + return row; + }); + tip.replaceChildren(head, ...rows); + const flip = x > 55; + tip.style.left = flip ? "" : "calc(" + x + "% + 12px)"; + tip.style.right = flip ? "calc(" + (100 - x) + "% + 12px)" : ""; + } + function hide() { + line.hidden = true; + tip.hidden = true; + } + plot.addEventListener("pointermove", (e) => { + const r = plot.getBoundingClientRect(); + show(Math.floor(((e.clientX - r.left) / r.width) * n)); + }); + plot.addEventListener("pointerleave", () => { + if (document.activeElement !== plot) hide(); + }); + plot.addEventListener("focus", () => show(current < 0 ? n - 1 : current)); + plot.addEventListener("blur", hide); + plot.addEventListener("keydown", (e) => { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + show(current + (e.key === "ArrowLeft" ? -1 : 1)); + } else if (e.key === "Escape") { + hide(); + } + }); + }); +})(); +""" diff --git a/src/voltcast/status.py b/src/voltcast/status.py new file mode 100644 index 0000000..5b14a0d --- /dev/null +++ b/src/voltcast/status.py @@ -0,0 +1,161 @@ +"""The public status page's data: what was promised, what happened, what it cost. + +``collect`` reads the store (and the AWS Budget, when given) into one plain +dict. It is published as status.json and rendered by ``voltcast.page``, so the +page can be rebuilt from any copy of the data. Only live forecasts count here; +the backfill replay stays in ``voltcast report``. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, date, datetime, time, timedelta +from typing import Any + +from voltcast.calendar import BERLIN, berlin_today, iso +from voltcast.pipeline import SOURCE, UNIT +from voltcast.store import Store, keys + +REGION = "DE-LU" +# The deadline check runs at 10:15 Berlin (check_schedule in infrastructure/variables.tf). +DEADLINE = time(10, 15) +WINDOWS = {"last_7_days": 7, "last_30_days": 30, "all_live_days": None} +TREND_DAYS = 30 + + +def _mean(values: list[float]) -> float: + return round(sum(values) / len(values), 2) + + +def _live_scores(store: Store) -> dict[str, dict[str, dict]]: + """{delivery day: {model: score}} for every live forecast that has been scored.""" + scores: dict[str, dict[str, dict]] = defaultdict(dict) + for key in store.list_keys("scores/"): + result = store.get_json(key) + if result and result["mode"] == "live": + scores[result["delivery_date"]][result["model"]] = result + return dict(sorted(scores.items())) + + +def _scored_day(store: Store, day: str, scores: dict[str, dict]) -> dict: + actual = store.get_json(keys.actuals(REGION, day)) + published = store.get_json(keys.published(day)) + forecast = {s["start"]: s["price"] for s in published["slots"]} if published else {} + return { + "delivery_date": day, + "model": published["model"] if published else None, + "slots": [ + {"start": s["start"], "actual": s["price"], "forecast": forecast.get(s["start"])} for s in actual["slots"] + ], + "scores": {model: {k: s[k] for k in ("mae", "rmse", "bias", "role")} for model, s in sorted(scores.items())}, + } + + +def _forecast_view(doc: dict, *, with_slots: bool = True) -> dict: + view = {k: doc[k] for k in ("delivery_date", "model", "model_version", "code_version", "as_of", "inputs")} + return view | {"slots": doc["slots"]} if with_slots else view + + +def _promise(store: Store, now: datetime) -> dict: + """Is the next delivery day's forecast out, still due today, or late?""" + day = berlin_today(now) + timedelta(days=1) + deadline = datetime.combine(day - timedelta(days=1), DEADLINE, BERLIN) + published = store.get_json(keys.published(day.isoformat())) + if published: + state = "published" + elif now < deadline: + state = "due" + else: + state = "missing" + return { + "delivery_date": day.isoformat(), + "deadline": iso(deadline), + "state": state, + "published_at": published["as_of"] if published else None, + } + + +def _rolling(scores: dict[str, dict[str, dict]], today: date) -> dict[str, dict[str, dict]]: + """Mean daily error per model over each window, live days only.""" + out = {} + for name, days in WINDOWS.items(): + start = today - timedelta(days=days) if days else date.min + rows: dict[str, list[dict]] = defaultdict(list) + for day, by_model in scores.items(): + if date.fromisoformat(day) > start: + for model, result in by_model.items(): + rows[model].append(result) + out[name] = { + model: { + "days": len(results), + "mae": _mean([r["mae"] for r in results]), + "rmse": _mean([r["rmse"] for r in results]), + "bias": _mean([r["bias"] for r in results]), + } + for model, results in sorted(rows.items()) + } + return out + + +def collect(store: Store, now: datetime, budget: dict | None = None) -> dict[str, Any]: + today = berlin_today(now) + scores = _live_scores(store) + last_scored = next(reversed(scores), None) + + latest = store.get_json(keys.LATEST) + upcoming = latest if latest and (last_scored is None or latest["delivery_date"] > last_scored) else None + newest = upcoming or (store.get_json(keys.published(last_scored)) if last_scored else latest) + + return { + "generated_at": iso(now), + "region": REGION, + "unit": UNIT, + "source": SOURCE, + "champion": newest["model"] if newest else None, + "live_since": next(iter(scores), None), + "promise": _promise(store, now), + "scored": _scored_day(store, last_scored, scores[last_scored]) if last_scored else None, + "upcoming": _forecast_view(upcoming) if upcoming else None, + "model": _forecast_view(newest, with_slots=False) if newest else None, + "rolling": _rolling(scores, today), + "trend": [ + {"delivery_date": day, "mae": {model: s["mae"] for model, s in by_model.items()}} + for day, by_model in list(scores.items())[-TREND_DAYS:] + ], + "cost": budget, + } + + +def read_budget(client: Any, account_id: str, name: str, now: datetime) -> dict | None: + """Month-to-date spend, the month's forecast and past months from an AWS Budget. + + The Budgets API itself is free, unlike Cost Explorer's per-request charge. + Returns None when the budget doesn't exist (yet). + """ + try: + budget = client.describe_budget(AccountId=account_id, BudgetName=name)["Budget"] + except client.exceptions.NotFoundException: + return None + spend = budget.get("CalculatedSpend", {}) + forecast = spend.get("ForecastedSpend") + + first = budget["TimePeriod"]["Start"].astimezone(UTC).date().replace(day=1) + history = client.describe_budget_performance_history( + AccountId=account_id, + BudgetName=name, + TimePeriod={"Start": datetime.combine(first, time(0), UTC), "End": now}, + )["BudgetPerformanceHistory"] + months = [ + {"month": period["TimePeriod"]["Start"].astimezone(UTC).strftime("%Y-%m"), "actual": float(amount["Amount"])} + for period in history.get("BudgetedAndActualAmountsList", []) + if (amount := period.get("ActualAmount")) + ] + return { + "budget": name, + "limit": float(budget["BudgetLimit"]["Amount"]), + "unit": budget["BudgetLimit"]["Unit"], + "month": now.astimezone(UTC).strftime("%Y-%m"), + "month_to_date": float(spend.get("ActualSpend", {}).get("Amount", 0)), + "forecast": float(forecast["Amount"]) if forecast else None, + "months": sorted(months, key=lambda m: m["month"]), + } diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..9446d6a --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,177 @@ +import json +import re +from datetime import UTC, date, datetime, time, timedelta + +import pytest + +from conftest import FakeSmard +from voltcast.calendar import BERLIN +from voltcast.page import _nice_ticks, render, write_site +from voltcast.pipeline import backfill, forecast, ingest, score +from voltcast.status import collect, read_budget + +TODAY = date(2026, 10, 14) +LIVE_DAYS = [date(2026, 10, 12), date(2026, 10, 13), TODAY] + + +def at(day: date, hour: int, minute: int = 0) -> datetime: + return datetime.combine(day, time(hour, minute), BERLIN).astimezone(UTC) + + +@pytest.fixture +def live_store(store, settings): + """Three live days forecast and scored, and tomorrow's forecast published.""" + smard = FakeSmard(TODAY - timedelta(days=21), TODAY + timedelta(days=1), published_until=TODAY) + ingest(store, smard.client(), TODAY - timedelta(days=21), TODAY, fetched_on=TODAY, settings=settings) + for day in [*LIVE_DAYS, TODAY + timedelta(days=1)]: + forecast(store, day, settings, now=at(day - timedelta(days=1), 9)) + for day in LIVE_DAYS: + score(store, day, settings, now=at(day, 14, 30)) + return store + + +def test_collect_shows_the_latest_scored_day_and_the_next_forecast(live_store): + status = collect(live_store, at(TODAY, 15)) + assert status["scored"]["delivery_date"] == "2026-10-14" + assert status["scored"]["model"] == "naive_1d" + assert len(status["scored"]["slots"]) == 96 + assert set(status["scored"]["scores"]) == {"naive_1d", "seasonal_naive_7d"} + assert status["upcoming"]["delivery_date"] == "2026-10-15" + assert status["model"]["code_version"] == "test" and "slots" not in status["model"] + assert status["live_since"] == "2026-10-12" + assert [t["delivery_date"] for t in status["trend"]] == ["2026-10-12", "2026-10-13", "2026-10-14"] + + +def test_rolling_error_averages_live_days_per_window(live_store): + status = collect(live_store, at(TODAY, 15)) + week = status["rolling"]["last_7_days"]["naive_1d"] + daily = [t["mae"]["naive_1d"] for t in status["trend"]] + assert week["days"] == 3 + assert week["mae"] == round(sum(daily) / 3, 2) + assert status["rolling"]["all_live_days"]["seasonal_naive_7d"]["days"] == 3 + + +@pytest.mark.parametrize( + ("now", "state"), + [(at(TODAY, 8), "due"), (at(TODAY, 10, 16), "missing"), (at(TODAY - timedelta(days=1), 10), "published")], +) +def test_promise_tracks_the_deadline(store, settings, now, state): + smard = FakeSmard(TODAY - timedelta(days=14), TODAY, published_until=TODAY - timedelta(days=1)) + ingest(store, smard.client(), TODAY - timedelta(days=14), TODAY, fetched_on=TODAY, settings=settings) + forecast(store, TODAY, settings, now=at(TODAY - timedelta(days=1), 9)) + promise = collect(store, now)["promise"] + assert promise["state"] == state + assert promise["delivery_date"] == (now.astimezone(BERLIN).date() + timedelta(days=1)).isoformat() + + +def test_replayed_days_stay_off_the_page(store, settings): + smard = FakeSmard(date(2026, 9, 20), TODAY, published_until=TODAY) + backfill(store, smard.client(), date(2026, 10, 1), date(2026, 10, 10), settings) + status = collect(store, at(TODAY, 15)) + assert status["scored"] is None + assert status["trend"] == [] + assert status["rolling"]["all_live_days"] == {} + + +def test_page_renders_charts_tables_and_escapes_text(live_store): + status = collect(live_store, at(TODAY, 15)) + status["model"]["inputs"][0]["key"] = "" + page = render(status) + assert page.count('
    alert(1)" not in page + assert "<script>alert(1)</script>" in page + # One table row per quarter-hour behind the price charts, so values never depend on hover. + assert len(re.findall(r"\d\d:\d\d", page)) == 2 * 96 + for blob in re.findall(r'', page, re.S): + assert len(json.loads(blob)["x"]) in (3, 96) + assert not re.search(r'(src|href)="https?://(?!github\.com/dhamsey3/voltcast)', page) + + +def test_page_renders_before_any_live_data(store): + page = render(collect(store, at(TODAY, 8))) + assert "No live day has been scored yet." in page + assert '