From 1cebdde5fef7ae8387c4d5b4eaad4b7a5f94f9eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:58:56 +0000 Subject: [PATCH 1/2] Cut months and days at Dublin midnight and show Dublin time Every month, day cell, gate date and printed time was UTC and unlabelled, so through the summer each time a reader saw was an hour behind ESB's own, and a fault at 00:30 on the 1st counted in the previous month. Boundaries are now Dublin midnights held as UTC instants, so every window and customer-minute sum still runs on UTC; day cells come from calendar.monthrange. Case records stay UTC, so durations and estimate deltas keep subtracting UTC, and only printed times turn Dublin, through render._local and its site.html mirror on Intl with timeZone Europe/Dublin. The observed hover title is Dublin; observed_month is new for the "so far" wording. No grade letter moves on the corpus to 24 September. Checked in Chromium set to New York: 856 records render identically from JS and Python. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- CLAUDE.md | 1 + esb_site/model.py | 53 +++++++++++------ esb_site/render.py | 36 ++++++++---- esb_site/site.html | 20 ++++++- notes/grading.md | 45 +++++++++++++++ tests/test_site_model.py | 111 +++++++++++++++++++++++++----------- tests/test_site_national.py | 2 +- 7 files changed, 203 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9a23ce2..892a799 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/esb_site/model.py b/esb_site/model.py index c9a7fc5..ca0db3e 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -12,6 +12,7 @@ from __future__ import annotations +import calendar import csv import math import sqlite3 @@ -19,6 +20,7 @@ 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" @@ -30,6 +32,11 @@ # it. Days before it are rendered as "no data", never as "no outages". COLLECTION_START = datetime(2026, 7, 31, 21, 2, 11, tzinfo=UTC) +# Everything is stored and computed in UTC. Months and days are Dublin's, +# because ESB's own times are and so is every reader: cut at UTC midnight, a +# fault at 00:30 on 1 September counted in August for half the year. +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 @@ -163,21 +170,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}") @@ -920,7 +940,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) @@ -958,7 +981,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 @@ -1001,28 +1023,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. diff --git a/esb_site/render.py b/esb_site/render.py index ce84ee8..51cd2ad 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -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 @@ -67,13 +67,21 @@ def _short(dt): - """Timestamps are rendered, never computed on, so minutes are enough.""" + """A record's timestamps are UTC to the minute. Durations are subtracted + from them, so they stay UTC and turn Dublin only as they are printed.""" 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]}" @@ -212,13 +220,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. @@ -237,7 +247,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, @@ -386,7 +396,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]) @@ -446,7 +457,7 @@ def _update_line(row, key, planned=False): + (" still off" if kind == "update" else "") ) cls = ' class="key"' if key else "" - return f"{' · '.join(bits)}" + return f"{' · '.join(bits)}" def _updates_html(rows, planned=False): @@ -492,7 +503,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 } @@ -509,7 +520,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 @@ -571,9 +585,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) diff --git a/esb_site/site.html b/esb_site/site.html index eb279f7..3a7c281 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -156,9 +156,23 @@

function fmtCml(v) { return v >= 100 ? Math.round(v) : v.toFixed(1); } +// A record timestamp, UTC to the minute, on the Dublin wall clock. Durations +// are subtracted from the UTC strings; only what is printed turns Dublin. +// Mirrored in render.py (_local). +var 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" +}); +function local(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); } @@ -328,7 +342,7 @@

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 = "" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ": " + @@ -419,7 +433,7 @@

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)); + if (k[4]) bits.push("began " + fmtDay(local(k[4])) + ", " + local(k[4]).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 @@ -451,7 +465,7 @@

var label = (planned && PLANNED_ROW_LABEL[r[0]]) || ROW_LABEL[r[0]]; if (label) bits.push("" + label + ""); if (r[2] != null) bits.push(num(r[2]) + " customers" + (r[0] === "update" ? " still off" : "")); - return '
  • " + bits.join(" · ") + "
  • "; + return '
  • " + bits.join(" · ") + "
  • "; } function updateList(ups, planned) { diff --git a/notes/grading.md b/notes/grading.md index bca0c0b..8b5a5ad 100644 --- a/notes/grading.md +++ b/notes/grading.md @@ -785,3 +785,48 @@ 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. + +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's three observed hours empty. National September +customer minutes lost goes from 11.9 to 12.0. diff --git a/tests/test_site_model.py b/tests/test_site_model.py index c061c8a..5f59b25 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -694,6 +694,16 @@ def test_cells_cover_the_whole_month_and_mark_the_unobserved(self): # NOW is the 20th, so the 21st onward is still to come. self.assertEqual(set(s["cells"][20:]), {str(model.DAY_FUTURE)}) + def test_a_month_with_a_clock_change_still_has_a_cell_per_day(self): + # Dublin's March is 23 hours short and October 25 long; the cells + # count calendar days, not the hours between the bounds. + outages, _, index = self.load() + for ym, days in (("2027-03", 31), ("2026-10", 31), ("2027-02", 28)): + s = model.county_month( + outages, "Dublin", index.customers["Dublin"], ym, NOW, self.until + ) + self.assertEqual(len(s["cells"]), days, ym) + def test_days_before_collection_started_are_not_days_without_outages(self): outages, _, index = self.load() s = model.county_month( @@ -800,10 +810,11 @@ def test_days_past_the_horizon_are_not_days_without_outages(self): s = model.county_month( outages, "Dublin", index.customers["Dublin"], "2026-08", NOW, self.until ) - # Collection stopped on the 12th and NOW is the 20th: the 13th to the - # 19th are unwatched, and the 20th onward is still to come. - self.assertEqual(set(s["cells"][12:19]), {str(model.DAY_NO_DATA)}) - self.assertEqual(set(s["cells"][19:]), {str(model.DAY_FUTURE)}) + # Collection stopped on the 12th and NOW is 01:00 on the 20th in + # Dublin: the 13th to the 20th are unwatched, and the 21st onward is + # still to come. + self.assertEqual(set(s["cells"][12:20]), {str(model.DAY_NO_DATA)}) + self.assertEqual(set(s["cells"][20:]), {str(model.DAY_FUTURE)}) def test_the_measured_window_stops_at_the_horizon(self): """Time the collector was down is not time this site watched.""" @@ -813,7 +824,8 @@ def test_the_measured_window_stops_at_the_horizon(self): s = model.county_month( outages, "Dublin", index.customers["Dublin"], "2026-08", NOW, self.until ) - self.assertAlmostEqual(s["observed_days"], 11.25, places=2) + # Dublin's August began at 23:00 UTC on 31 July: 11 days and 7 hours + self.assertAlmostEqual(s["observed_days"], 11 + 7 / 24, places=4) class TestOngoingOutages(SiteModelCase): @@ -933,30 +945,30 @@ class TestShardMonths(SiteModelCase): """The list under a month and the tiles above it count the same outages.""" def test_an_outage_crossing_midnight_on_the_last_is_listed_in_both(self): - # 00:00 Dublin on 1 August is 23:00 UTC on 31 July, so this one is - # counted in both months. Filed by its start month alone it went - # missing from August's list while August's fault tile still counted - # it, and a reader could count the rows and come up one short. + # 23:30 to 01:00 across Dublin's midnight into September, so this one + # is counted in both months. Filed by its start month alone it went + # missing from September's list while September's fault tile still + # counted it, and a reader could count the rows and come up one short. self.observe( - detail("1", startTime="01/08/2026 00:00"), - datetime(2026, 7, 31, 23, 30, tzinfo=UTC), + detail("1", startTime="31/08/2026 23:30"), + datetime(2026, 8, 31, 23, 0, tzinfo=UTC), ) self.observe( detail( "1", - startTime="01/08/2026 00:00", + startTime="31/08/2026 23:30", outageType="Restored", - restoreTime="01/08/2026 12:00", + restoreTime="01/09/2026 01:00", ), - datetime(2026, 8, 1, 11, 30, tzinfo=UTC), + datetime(2026, 9, 1, 0, 30, tzinfo=UTC), ) - self.poll(datetime(2026, 8, 1, 11, 30, tzinfo=UTC), n_listed=1) + self.poll(datetime(2026, 9, 1, 0, 30, tzinfo=UTC), n_listed=1) outages, _, index = self.load() - months = ["2026-07", "2026-08"] + months = ["2026-08", "2026-09"] by_month = render.shard(outages, months, self.until) - self.assertEqual(len(by_month["2026-07"]), 1) self.assertEqual(len(by_month["2026-08"]), 1) + self.assertEqual(len(by_month["2026-09"]), 1) for ym in months: counted = model.county_month( @@ -1015,14 +1027,19 @@ def test_the_first_and_last_days_of_collection_are_short(self): self.load() self.assertEqual( model.partial_days(self.until), - [model.COLLECTION_START.date().isoformat(), "2026-08-12"], + [model.local(model.COLLECTION_START).date().isoformat(), "2026-08-12"], ) def test_a_horizon_on_the_stroke_of_midnight_leaves_a_whole_day(self): """[lo, hi) - a window ending at 00:00 covers the previous day fully.""" - until = datetime(2026, 8, 13, 0, 0, tzinfo=UTC) + until = datetime(2026, 8, 12, 23, 0, tzinfo=UTC) # Dublin's midnight self.assertEqual(model.partial_days(until)[-1], "2026-08-12") + def test_the_last_day_is_dublins(self): + # 23:30 UTC on the 12th is half past midnight on the 13th in Dublin + until = datetime(2026, 8, 12, 23, 30, tzinfo=UTC) + self.assertEqual(model.partial_days(until)[-1], "2026-08-13") + class TestMonthList(unittest.TestCase): """A month reaches the strip on its first day, not on its first evening.""" @@ -1039,6 +1056,13 @@ def test_the_new_month_is_listed_from_its_first_instant(self): ["2026-07", "2026-08", "2026-09"], ) + def test_the_month_turns_over_at_dublins_midnight(self): + # 23:30 UTC on 31 August is already September in Dublin + self.assertEqual( + model.month_list(model.COLLECTION_START, datetime(2026, 8, 31, 23, 30, tzinfo=UTC))[-1], + "2026-09", + ) + def test_the_first_build_of_the_month_already_has_it(self): self.assertEqual( model.month_list(model.COLLECTION_START, self.FIRST_BUILD_OF_SEPTEMBER)[-1], @@ -1262,7 +1286,8 @@ class TestCaseCopy(unittest.TestCase): Records are hand-made in case_record's shape: [id, location, planned, customers, start, end, endSrc, reason, chain, - updates, est, ongoing]. + updates, est, ongoing]. Their times are UTC and the row prints Dublin's, + an hour ahead through the summer. """ # The horizon every row here is read against, well past each record's end @@ -1289,8 +1314,8 @@ def record(**over): def test_a_confirmed_restore_says_how_long_and_how_it_landed(self): html = self.html(self.record()) self.assertIn( - "17 customers affected · began Mon 24 Aug, 10:46 · " - "restored 14:32 (3 h 46 min) · 28 min earlier than ESB estimated", + "17 customers affected · began Mon 24 Aug, 11:46 · " + "restored 15:32 (3 h 46 min) · 28 min earlier than ESB estimated", html, ) # the duration belongs to the phrase naming the end it measures @@ -1298,18 +1323,35 @@ def test_a_confirmed_restore_says_how_long_and_how_it_landed(self): def test_a_restore_past_the_estimate_says_later(self): html = self.html(self.record(est="2026-08-24T13:00")) - self.assertIn("restored 14:32 (3 h 46 min) · 1 h 32 min later than ESB estimated", html) + self.assertIn("restored 15:32 (3 h 46 min) · 1 h 32 min later than ESB estimated", html) def test_an_estimate_all_but_met_is_not_worth_a_clause(self): # Inside five minutes either way, "3 min earlier" is noise dressed as # a finding. 5% of restored faults land there. html = self.html(self.record(est="2026-08-24T14:35")) - self.assertIn("restored 14:32 (3 h 46 min)", html) + self.assertIn("restored 15:32 (3 h 46 min)", html) self.assertNotIn("than ESB estimated", html) def test_an_end_on_a_later_day_names_the_day(self): html = self.html(self.record(end="2026-08-25T01:10", est=None)) - self.assertIn("restored Tue 25 Aug, 01:10", html) + self.assertIn("restored Tue 25 Aug, 02:10", html) + + def test_the_day_is_dublins_not_utcs(self): + # 23:30 UTC is 00:30 the next morning in Dublin in August. + html = self.html(self.record(end="2026-08-24T23:30", est=None)) + self.assertIn("restored Tue 25 Aug, 00:30", html) + + def test_winter_times_are_utc_already(self): + html = self.html(self.record(start="2026-12-01T10:46", end="2026-12-01T14:32", + est=None)) + self.assertIn("began Tue 1 Dec, 10:46 · restored 14:32 (3 h 46 min)", html) + + def test_a_span_across_the_clock_change_is_the_real_one(self): + # Clocks go back at 02:00 on 25 October: 00:30 to 03:30 on the wall is + # four hours, and the span says so because it is taken in UTC. + html = self.html(self.record(start="2026-10-24T23:30", end="2026-10-25T03:30", + est=None)) + self.assertIn("began Sun 25 Oct, 00:30 · restored 03:30 (4 h)", html) def test_an_unconfirmed_fault_end_says_what_is_missing(self): # "not confirmed" left a reader guessing whether the estimate or the @@ -1318,7 +1360,7 @@ def test_an_unconfirmed_fault_end_says_what_is_missing(self): self.record(end="2026-08-24T15:00", end_src="estimated", est=None) ) self.assertIn( - "expected back by 15:00 (about 4 h) · no restore time published", html + "expected back by 16:00 (about 4 h) · no restore time published", html ) self.assertNotIn("not confirmed", html) @@ -1366,7 +1408,7 @@ def test_planned_works_read_as_a_schedule_not_a_failed_promise(self): self.record(planned=1, end="2026-08-24T15:00", end_src="estimated", est=None) ) - self.assertIn("scheduled until 15:00 (4 h 14 min)", html) + self.assertIn("scheduled until 16:00 (4 h 14 min)", html) self.assertNotIn("not confirmed", html) # Still listed at the last poll: the delisted wording read as an ending. @@ -1384,15 +1426,15 @@ def test_a_live_fault_with_an_estimate_ahead_says_when(self): self.record(start="2026-09-05T03:25", end="2026-09-05T05:00", end_src="listed", est="2026-09-05T07:30", ongoing=1) ) - self.assertIn("still out when last checked · expected back by 07:30", html) + self.assertIn("still out when last checked · expected back by 08:30", html) def test_an_estimate_between_the_sighting_and_the_horizon_has_passed(self): - # Last seen 04:30, estimate 04:45, data to 05:00: the time has passed. + # Last seen 04:30 UTC, estimate 04:45, data to 05:00: the time has passed. html = self.html( self.record(start="2026-09-05T03:25", end="2026-09-05T04:30", end_src="listed", est="2026-09-05T04:45", ongoing=1) ) - self.assertIn("past ESB's estimate of 04:45", html) + self.assertIn("past ESB's estimate of 05:45", html) self.assertNotIn("expected back", html) def test_a_live_fault_past_its_estimate_says_so(self): @@ -1402,7 +1444,7 @@ def test_a_live_fault_past_its_estimate_says_so(self): end_src="estimated", est="2026-09-05T00:15", ongoing=1) ) self.assertIn( - "still out when last checked · past ESB's estimate of Sat 5 Sep, 00:15", + "still out when last checked · past ESB's estimate of Sat 5 Sep, 01:15", html, ) self.assertNotIn("expected back", html) @@ -1414,7 +1456,7 @@ def test_live_planned_works_keep_their_schedule(self): end_src="listed", est="2026-09-09T17:00", ongoing=1) ) self.assertIn( - "scheduled until Wed 9 Sep, 17:00 (7 days) · still listed when last checked", + "scheduled until Wed 9 Sep, 18:00 (7 days) · still listed when last checked", html, ) self.assertNotIn("listed for", html) @@ -1469,11 +1511,12 @@ class TestTheFiveDayGate(unittest.TestCase): def test_it_measures_calendar_coverage_not_days_with_faults(self): """Nothing in it counts an outage. A quiet county and a battered one clear it on the same date.""" + # Dublin's midnight on the 6th, which is 23:00 UTC on the 5th self.assertIsNotNone( - model.days_gate("2026-09", datetime(2026, 9, 5, 23, 59, tzinfo=UTC)) + model.days_gate("2026-09", datetime(2026, 9, 5, 22, 59, tzinfo=UTC)) ) self.assertIsNone( - model.days_gate("2026-09", datetime(2026, 9, 6, 0, 0, tzinfo=UTC)) + model.days_gate("2026-09", datetime(2026, 9, 5, 23, 0, tzinfo=UTC)) ) def test_it_opens_five_days_after_the_window_not_the_month(self): diff --git a/tests/test_site_national.py b/tests/test_site_national.py index ac9bcee..61e72a4 100644 --- a/tests/test_site_national.py +++ b/tests/test_site_national.py @@ -253,7 +253,7 @@ def test_the_payload_carries_no_per_outage_records(self): self.assertEqual( set(self.data), { - "generated", "observed", "observed_iso", "stale_hours", + "generated", "observed", "observed_iso", "observed_month", "stale_hours", "partial", "daygate", "compare", "start", "months", "esb", "counties", "customers", "stats", "national", From 3a7aa66e6bdcf754e4f891a7dd8f953847f10dda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 09:17:04 +0000 Subject: [PATCH 2/2] Pin each Dublin conversion with a test, and harden the page formatter Review of this PR showed the per-day key, the timeline row, the caveat dates, observed and observed_month could each revert to UTC with every test still passing; each now has a test that fails when it does, and the JS mirror's call sites are held by a text test since CI runs no JS. Also: - a page on an engine without zone data falls back to UTC times instead of failing to render at all - the sitemap lastmod is Dublin's date - the start is converted once per row in caseHtml - comments no longer restate the rule the note settles - July under Dublin months holds under two hours, not three - the note records the October repeated hour as an accepted edge Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/model.py | 4 +--- esb_site/render.py | 5 ++-- esb_site/site.html | 21 ++++++++++------- notes/grading.md | 9 +++++++- tests/test_site_county.py | 4 ++-- tests/test_site_model.py | 48 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 73 insertions(+), 18 deletions(-) diff --git a/esb_site/model.py b/esb_site/model.py index ca0db3e..fb91c1e 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -32,9 +32,7 @@ # it. Days before it are rendered as "no data", never as "no outages". COLLECTION_START = datetime(2026, 7, 31, 21, 2, 11, tzinfo=UTC) -# Everything is stored and computed in UTC. Months and days are Dublin's, -# because ESB's own times are and so is every reader: cut at UTC midnight, a -# fault at 00:30 on 1 September counted in August for half the year. +# 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 diff --git a/esb_site/render.py b/esb_site/render.py index 51cd2ad..7a1581d 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -67,8 +67,7 @@ def _short(dt): - """A record's timestamps are UTC to the minute. Durations are subtracted - from them, so they stay UTC and turn Dublin only as they are printed.""" + """Record timestamps are UTC to the minute; see _local for printing them.""" return dt.strftime("%Y-%m-%dT%H:%M") if dt else None @@ -1049,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] diff --git a/esb_site/site.html b/esb_site/site.html index 3a7c281..1d98393 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -156,14 +156,18 @@

    function fmtCml(v) { return v >= 100 ? Math.round(v) : v.toFixed(1); } -// A record timestamp, UTC to the minute, on the Dublin wall clock. Durations -// are subtracted from the UTC strings; only what is printed turns Dublin. -// Mirrored in render.py (_local). -var 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" -}); +// 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; @@ -433,7 +437,8 @@

    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(local(k[4])) + ", " + local(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 diff --git a/notes/grading.md b/notes/grading.md index 8b5a5ad..b7313e2 100644 --- a/notes/grading.md +++ b/notes/grading.md @@ -820,6 +820,13 @@ The rule is the owner's: **store and compute in UTC, display in local time.** `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. @@ -828,5 +835,5 @@ 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's three observed hours empty. National September +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. diff --git a/tests/test_site_county.py b/tests/test_site_county.py index f0f56c1..bd169c1 100644 --- a/tests/test_site_county.py +++ b/tests/test_site_county.py @@ -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'July 2026(.*?)', self.page).group(1) self.assertIn("from 31 Jul", july) sept = re.search(r'September 2026(.*?)', self.page).group(1) @@ -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) diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 5f59b25..500157e 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1528,7 +1528,53 @@ def test_it_opens_five_days_after_the_window_not_the_month(self): ) def test_a_month_caught_too_late_never_opens(self): - """July holds three hours and is over. The caller has to check the date + """July holds two hours and is over. The caller has to check the date against the month, or it promises a grade that can never arrive.""" when = model.days_gate("2026-07", datetime(2026, 9, 1, tzinfo=UTC)) self.assertGreater(when, model.month_bounds("2026-07")[1]) + + +class TestDublinDisplay(SiteModelCase): + """Stored in UTC, cut and printed on Dublin's clock.""" + + def test_a_fault_just_after_dublins_midnight_colours_that_day(self): + # 23:10-23:50 UTC on the 12th is 00:10-00:50 on the 13th in Dublin + self.observe( + detail("1", numCustAffected=200000, outageType="Restored", + startTime="13/08/2026 00:10", restoreTime="13/08/2026 00:50"), + datetime(2026, 8, 12, 23, 30, tzinfo=UTC), + ) + self.poll(datetime(2026, 8, 14, 6, tzinfo=UTC)) + outages, _, index = self.load() + cells = model.county_month( + outages, "Dublin", index.customers["Dublin"], "2026-08", NOW, self.until + )["cells"] + self.assertEqual(cells[11], "0") + self.assertNotEqual(cells[12], "0") + + def test_a_timeline_row_prints_dublins_time(self): + line = render._update_line(["update", "2026-08-24T14:15", 40], False) + self.assertIn("", line) + + def test_the_caveat_names_dublins_day(self): + until = datetime(2026, 9, 9, 23, 30, tzinfo=UTC) + self.assertEqual(render._month_watched("2026-09", until), "to 10 Sep") + + def test_the_horizon_is_shown_and_filed_in_dublin(self): + until = datetime(2026, 8, 31, 23, 30, tzinfo=UTC) + data = render.build([], model.SmallAreaIndex.load(), until, until)[0] + self.assertEqual(data["observed_month"], "2026-09") + self.assertEqual(data["observed"], "Tue 1 Sep, 00:30") + + def test_the_app_prints_through_the_same_conversion(self): + # the JS mirror has no runner in CI, so its call sites are held here + page = (Path(model.__file__).parent / "site.html").read_text() + + def body(name): + return page.split(f"function {name}(", 1)[1].split("\nfunction ", 1)[0] + + self.assertIn("ts = local(ts); ref = local(ref);", body("whenAt")) + self.assertIn("local(k[4])", body("caseHtml")) + self.assertIn("when(local(r[1]))", body("updateLine")) + self.assertIn('timeZone: "Europe/Dublin"', page) + self.assertIn("D.observed_month === curMonth", page)