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 '