Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Every one of these has already cost someone an hour:
| Poll interval stays at 30 min; 15 min was measured and is marginal | `notes/polling.md` |
| `startTime` is immutable and back-dated, so durations measure the outage | `notes/grading.md` § Does startTime drift |
| Every measured window ends at the collection horizon, not at the build clock | `notes/grading.md` § What the clock knows |
| Stored and computed in UTC, shown in Dublin time: months, day cells and gate dates are cut at Dublin midnight (as UTC instants, never Dublin-zoned datetimes), and case records stay UTC with only what is printed converted (`render._local`, `local()` in site.html) | `notes/grading.md` § Months and days are Dublin's (2026-09-24) |
| An outage still listed at the last poll is not judged on the charter, and a live fault runs to its last sighting, not to a passed estimate (planned works keep their schedule, issue #49) | `notes/grading.md` § An outage still listed; § A live fault runs to its last sighting (2026-09-24) |
| Peak customers means the most off while the outage was live | `notes/grading.md` § The peak is the highest count |
| Part-observed days keep their colour and say so in the tooltip | `notes/grading.md` § Short days say so |
Expand Down
51 changes: 35 additions & 16 deletions esb_site/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@

from __future__ import annotations

import calendar
import csv
import math
import sqlite3
from collections import defaultdict
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import NamedTuple
from zoneinfo import ZoneInfo

DATA_DIR = Path(__file__).parent / "data"
SA_POP_PATH = DATA_DIR / "sa_pop.csv"
Expand All @@ -30,6 +32,9 @@
# it. Days before it are rendered as "no data", never as "no outages".
COLLECTION_START = datetime(2026, 7, 31, 21, 2, 11, tzinfo=UTC)

# notes/grading.md § Months and days are Dublin's
DUBLIN = ZoneInfo("Europe/Dublin")

# The denominator for CML and CI. Both figures ESB publishes point at the same
# number: the Distribution System Statistics in DAPR 2024 give "c. 2.5 million
# customer meters", and the company page says "roughly 2.5 million customers
Expand Down Expand Up @@ -163,21 +168,34 @@ def fmt_utc(dt):
return dt.strftime("%Y-%m-%dT%H:%M:%SZ") if dt else ""


def local(dt):
"""An instant on the Dublin wall clock, which months and days are cut by."""
return dt.astimezone(DUBLIN)


def midnight(day):
"""The UTC instant a Dublin day begins. Every boundary is converted back
to UTC, because subtracting two datetimes that share a zone subtracts their
wall clocks and loses the hour at a clock change."""
return datetime(day.year, day.month, day.day, tzinfo=DUBLIN).astimezone(UTC)


def month_bounds(ym):
year, month = int(ym[:4]), int(ym[5:7])
lo = datetime(year, month, 1, tzinfo=UTC)
hi = datetime(year + (month == 12), month % 12 + 1, 1, tzinfo=UTC)
lo = midnight(date(year, month, 1))
hi = midnight(date(year + (month == 12), month % 12 + 1, 1))
return lo, hi


def month_list(start, end):
"""Every month from start's to end's, inclusive.
"""Every Dublin month from start's to end's, inclusive.

Walked as (year, month) rather than as datetimes: COLLECTION_START is the
first poll's exact instant, and a cursor carrying its 21:02 clock time hid
each new month until its first evening.
"""
months = []
start, end = local(start), local(end)
year, month = start.year, start.month
while (year, month) <= (end.year, end.month):
months.append(f"{year:04d}-{month:02d}")
Expand Down Expand Up @@ -920,7 +938,10 @@ def partial_days(until):
was. The colour still says what was seen; these dates let the page say the
day was short.
"""
days = {COLLECTION_START.date(), (until - timedelta(microseconds=1)).date()}
days = {
local(COLLECTION_START).date(),
local(until - timedelta(microseconds=1)).date(),
}
return sorted(d.isoformat() for d in days)


Expand Down Expand Up @@ -958,7 +979,6 @@ def county_month(outages, county, customers, ym, now, until):
lo, hi = observed_window(ym, until)
observed_minutes = max((hi - lo).total_seconds() / 60.0, 1.0)
observed_days = observed_minutes / 1440.0
month_lo, month_hi = month_bounds(ym)

fault_cm = planned_cm = 0.0
faults = planned = 0
Expand Down Expand Up @@ -1001,28 +1021,27 @@ def county_month(outages, county, customers, ym, now, until):
for seg_start, seg_end, seg_customers in o.segments:
cur, stop = max(seg_start, lo), min(seg_end, hi)
while cur < stop:
nxt = (cur + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
seg = min(stop, nxt)
day = local(cur).date()
seg = min(stop, midnight(day + timedelta(days=1)))
if o.planned:
per_day_planned.add(cur.date())
per_day_planned.add(day)
else:
per_day_fault[cur.date()] += (
per_day_fault[day] += (
seg_customers * (seg - cur).total_seconds() / 60.0
)
cur = seg

cml = fault_cm / customers
annualised = cml * MINUTES_PER_YEAR / observed_minutes
days_in_month = (month_hi - month_lo).days
year, month = int(ym[:4]), int(ym[5:7])
cells = []
for d in range(1, days_in_month + 1):
day = date(month_lo.year, month_lo.month, d)
day_lo = datetime(day.year, day.month, day.day, tzinfo=UTC)
# Never from the bounds: a Dublin March is 23 hours short and October 25 long.
for d in range(1, calendar.monthrange(year, month)[1] + 1):
day = date(year, month, d)
day_lo, day_hi = midnight(day), midnight(day + timedelta(days=1))
if day_lo >= now:
cells.append(DAY_FUTURE)
elif day_lo + timedelta(days=1) <= COLLECTION_START or day_lo >= until:
elif day_hi <= COLLECTION_START or day_lo >= until:
# Either side of the collected window is "no data". A day the
# collector never reached is not a day without outages, and
# colouring it would publish an all-clear nobody checked.
Expand Down
37 changes: 25 additions & 12 deletions esb_site/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import html
import io
from collections import defaultdict
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from pathlib import Path

import statusui
Expand Down Expand Up @@ -67,13 +67,20 @@


def _short(dt):
"""Timestamps are rendered, never computed on, so minutes are enough."""
"""Record timestamps are UTC to the minute; see _local for printing them."""
return dt.strftime("%Y-%m-%dT%H:%M") if dt else None


def _local(ts):
"""A record timestamp on the Dublin wall clock. Mirrored in site.html."""
dt = datetime.fromisoformat(ts).replace(tzinfo=UTC)
return model.local(dt).strftime("%Y-%m-%dT%H:%M")


def _when_at(ts, ref):
"""A timestamp against the outage's start day: the clock time alone when it
falls on the same day, the full day otherwise. Mirrored in site.html."""
ts, ref = _local(ts), _local(ref)
if ts[:10] == ref[:10]:
return ts[11:16]
return f"{_fmt_day(ts)}, {ts[11:16]}"
Expand Down Expand Up @@ -212,13 +219,15 @@ def entries(county, names):
# from a collector that stopped. Formatted for display here - it is
# only ever shown, and the footer says "Data to {observed}".
"observed": (
f"{statusui.fmt_date(until.date().isoformat(), now.date())},"
f" {until:%H:%M} UTC"
f"{statusui.fmt_date(model.local(until).date().isoformat(), model.local(now).date())},"
f" {model.local(until):%H:%M}"
),
# The same instant for freshness(), which dates the page against the
# reader's clock rather than the build's. STALE_AFTER travels with it,
# so a page served from cache can still go stale.
"observed_iso": f"{until:%Y-%m-%dT%H:%M:00Z}",
# the Dublin month the horizon falls in, for the "so far" wording
"observed_month": f"{model.local(until):%Y-%m}",
"stale_hours": round(STALE_AFTER.total_seconds() / 3600),
# Two dates at most, and the same for every county, so they sit here
# rather than on every month of every county's row.
Expand All @@ -237,7 +246,7 @@ def entries(county, names):
(model.national_ci(outages, until) / model.ESB_NATIONAL_CI - 1) * 100
),
},
"start": model.COLLECTION_START.strftime("%-d %B %Y"),
"start": model.local(model.COLLECTION_START).strftime("%-d %B %Y"),
"months": months,
"esb": {
"national": model.ESB_NATIONAL_CML,
Expand Down Expand Up @@ -386,7 +395,8 @@ def _case_html(k, horizon):
chain = k[8]
bits = [f"{k[3]:,} customer" + ("" if k[3] == 1 else "s") + " affected"]
if k[4]:
bits.append(f"began {_fmt_day(k[4])}, {k[4][11:16]}")
began = _local(k[4])
bits.append(f"began {_fmt_day(began)}, {began[11:16]}")
if k[4] and k[5]:
hours = (
datetime.fromisoformat(k[5]) - datetime.fromisoformat(k[4])
Expand Down Expand Up @@ -446,7 +456,7 @@ def _update_line(row, key, planned=False):
+ (" still off" if kind == "update" else "")
)
cls = ' class="key"' if key else ""
return f"<li{cls}><time>{_when(when)}</time>{' · '.join(bits)}</li>"
return f"<li{cls}><time>{_when(_local(when))}</time>{' · '.join(bits)}</li>"


def _updates_html(rows, planned=False):
Expand Down Expand Up @@ -492,7 +502,7 @@ def _daygate(months, until):
"""
gates = ((ym, model.days_gate(ym, until)) for ym in months)
return {
ym: "" if when >= model.month_bounds(ym)[1] else f"{when:%-d %B}"
ym: "" if when >= model.month_bounds(ym)[1] else f"{model.local(when):%-d %B}"
for ym, when in gates
if when is not None
}
Expand All @@ -509,7 +519,10 @@ def ungraded_reason(ym, faults, until):
# past the month's end: it can never reach five days, so promise no date
if when >= model.month_bounds(ym)[1]:
return f"Only part of {month_label(ym)} was watched, so it is not graded"
return f"{month_label(ym)} is too new to grade. Grades appear from {when:%-d %B}"
return (
f"{month_label(ym)} is too new to grade. "
f"Grades appear from {model.local(when):%-d %B}"
)
if faults < model.MIN_GRADED_FAULTS:
return f"Too few faults in {month_label(ym)} to grade fairly"
# Past both gates, nothing was judged: every fault that started in the month
Expand Down Expand Up @@ -571,9 +584,9 @@ def _month_watched(ym, until):
olo, ohi = model.observed_window(ym, until)
bits = []
if olo > lo:
bits.append(f"from {olo:%-d %b}")
bits.append(f"from {model.local(olo):%-d %b}")
if ohi < hi:
bits.append(f"to {ohi:%-d %b}")
bits.append(f"to {model.local(ohi):%-d %b}")
return " ".join(bits)


Expand Down Expand Up @@ -1035,7 +1048,7 @@ def write(site_dir, outages, sa_index, now, until):
)
area_paths.append(rel)

lastmod = now.strftime("%Y-%m-%d")
lastmod = model.local(now).strftime("%Y-%m-%d")
paths = (
[""]
+ [f"c/{slug(c)}.html" for c in sa_index.counties]
Expand Down
25 changes: 22 additions & 3 deletions esb_site/site.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,27 @@ <h3 class="natheading" id="natHeading"></h3>

function fmtCml(v) { return v >= 100 ? Math.round(v) : v.toFixed(1); }

// A record timestamp on the Dublin wall clock. Mirrored in render.py (_local).
// Guarded because an engine without zone data throws here, and UTC times are
// a better failure than an app that never renders.
var DUBLIN = null;
try {
DUBLIN = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Dublin", hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
});
} catch (e) {}
function local(ts) {
if (!DUBLIN) return ts;
var p = {};
DUBLIN.formatToParts(new Date(ts + "Z")).forEach(function (x) { p[x.type] = x.value; });
return p.year + "-" + p.month + "-" + p.day + "T" + p.hour + ":" + p.minute;
}

// a timestamp against the outage's start day: the clock time alone when it
// falls on the same day, the full day otherwise. Mirrored in render.py.
function whenAt(ts, ref) {
ts = local(ts); ref = local(ref);
if (ts.slice(0, 10) === ref.slice(0, 10)) return ts.slice(11, 16);
return fmtDay(ts) + ", " + ts.slice(11, 16);
}
Expand Down Expand Up @@ -328,7 +346,7 @@ <h3 class="natheading" id="natHeading"></h3>
var n = D.national[curMonth] || [0, 0, 0, 0, 0, null, null, 0];
var within = n[5];
// "so far" only while the viewed month is still collecting
var partial = D.observed_iso.slice(0, 7) === curMonth;
var partial = D.observed_month === curMonth;
var ungraded = ungradedNote(rows, curMonth);
document.getElementById("natline").innerHTML =
"<strong>" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ":</strong> " +
Expand Down Expand Up @@ -419,7 +437,8 @@ <h3 class="natheading" id="natHeading"></h3>
var planned = k[2], ups = k[9], chain = k[8];
var dur = k[5] && k[4] ? (new Date(k[5] + "Z") - new Date(k[4] + "Z")) / 3600000 : null;
var bits = [plural(k[3], "customer") + " affected"];
if (k[4]) bits.push("began " + fmtDay(k[4]) + ", " + k[4].slice(11, 16));
var began = k[4] && local(k[4]);
if (began) bits.push("began " + fmtDay(began) + ", " + began.slice(11, 16));
if (dur != null) bits = bits.concat(endBits(k, dur));
// in the chip rather than trailing the timings: it is the row's most human
// fact and it was in its least-read position
Expand Down Expand Up @@ -451,7 +470,7 @@ <h3 class="natheading" id="natHeading"></h3>
var label = (planned && PLANNED_ROW_LABEL[r[0]]) || ROW_LABEL[r[0]];
if (label) bits.push("<b>" + label + "</b>");
if (r[2] != null) bits.push(num(r[2]) + " customers" + (r[0] === "update" ? " still off" : ""));
return '<li class="' + (key ? "key" : "") + '"><time>' + when(r[1]) + "</time>" + bits.join(" · ") + "</li>";
return '<li class="' + (key ? "key" : "") + '"><time>' + when(local(r[1])) + "</time>" + bits.join(" · ") + "</li>";
}

function updateList(ups, planned) {
Expand Down
52 changes: 52 additions & 0 deletions notes/grading.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,55 @@ installed package. **To change the shared UI now:** edit in `../statusui`, test
then `../statusui/rollout.sh` bumps the pin in all three sites, runs each site's tests and
opens the PRs. An unpushed statusui change can be tried here with
`uv run --with-editable ../statusui python -m esb_site ...`.

## Months and days are Dublin's; the arithmetic stays UTC (2026-09-24)

Until this date every month, day cell, gate date and printed time on the site
was UTC, and none of it said so. ESB publishes Dublin wall-clock times, so from
April to October every time a reader saw was an hour behind ESB's own: an
outage ESB lists as starting at 15:15 read "began 14:15". The buckets had the
same hour's error. A fault at 00:30 on 1 September, Irish time, counted in
August, coloured the 31 August cell, and sat on August's page. lifts found and
fixed the same thing on 2026-08-18 (`lifts/notes/site.md` § Displayed instants
are Dublin wall-clock, and so are the day buckets); uisce still cuts at UTC.

The rule is the owner's: **store and compute in UTC, display in local time.**

- **Boundaries** are Dublin midnights converted back to UTC instants
(`model.midnight`). They are never left as Dublin-zoned datetimes, because
Python subtracts two datetimes that share a zone by their wall clocks, which
loses the hour at a clock change. Every window, overlap test and
customer-minute sum still runs on UTC.
- **Day cells** are counted with `calendar.monthrange`, never from the bounds:
a Dublin March is 23 hours short and October 25 hours long.
- **Case records stay UTC.** This is where esb differs from lifts, which ships
Dublin wall-clock strings and precomputes every duration because subtracting
offset-free wall-clock strings loses the October hour. Here the durations,
the estimate deltas and the horizon comparison keep subtracting UTC strings,
unchanged. Only what is printed turns Dublin: `render._local` and its
mirror `local()` in site.html, which uses `Intl.DateTimeFormat` with
`timeZone: "Europe/Dublin"`. The page shows Dublin whatever the reader's
own zone is, and the payload does not grow.
- **`observed`**, the "Data to ..." hover title, is Dublin wall-clock and no
longer says "UTC". `observed_month` is new, and gives the Dublin month for
the "so far" wording. `observed_iso` stays a UTC instant for `freshness()`.
`generated` keeps statusui's shared UTC stamp, and the CSV keeps its `*_utc`
columns.

Accepted edge: in the hour the clocks go back in October, 01:00 to 02:00
happens twice, and the printed times carry no offset. An outage from 01:50
summer time to 01:10 winter time reads "began 01:50 · restored 01:10 (20 min)".
The span is right, because it is taken in UTC, but the clock times read
backwards. It is one hour a year. Labelling offsets on every row to cover it
would cost more than the confusion it saves.

Checked in Chromium with the browser set to New York: all 856 Dublin and Cork
records render the same summary line and timeline times from the JS as from
render.py.

On the corpus to 24 September no grade letter moves. Day cells shift in 13
county-months. Dublin's monthly customer minutes lost moves by 0.1 in both
months. One Monaghan fault that began just before midnight on 31 July, Irish
time, moves from July into August (Monaghan August 99.5% to 98.9% restored in
4 h), which leaves July empty: under Dublin months it holds under two observed hours, from 21:02 to 23:00 UTC. National September
customer minutes lost goes from 11.9 to 12.0.
4 changes: 2 additions & 2 deletions tests/test_site_county.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ def test_a_month_watched_end_to_end_carries_no_caveat(self):
def test_a_part_watched_month_says_which_part(self):
"""Collection began on 31 July and the horizon stops mid-September, so
those two rows are built from less time than the months beside them. A
row of zeros for three hours of July reads as a quiet month otherwise."""
row of zeros for two hours of July reads as a quiet month otherwise."""
july = re.search(r'<th scope="row">July 2026(.*?)</th>', self.page).group(1)
self.assertIn("from 31 Jul", july)
sept = re.search(r'<th scope="row">September 2026(.*?)</th>', self.page).group(1)
Expand Down Expand Up @@ -392,7 +392,7 @@ def test_the_reason_is_on_the_page_not_only_in_a_hover(self):
self.assertIn("September 2026 is too new to grade", self.text_of(page))

def test_a_month_that_can_never_reach_five_days_promises_no_date(self):
"""Collection opened at 21:02 on 31 July, so July holds three hours and
"""Collection opened at 21:02 on 31 July, so July holds two hours and
the month is over. "Grades appear from 5 August" would be a lie."""
self.observe(detail("1"), datetime(2026, 8, 10, 10, 0, tzinfo=UTC))
self.poll(datetime(2026, 9, 10, 0, 0, tzinfo=UTC), n_listed=1)
Expand Down
Loading
Loading