diff --git a/CLAUDE.md b/CLAUDE.md index 9ad568a..faca2f7 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 | +| A month listed before any of its data exists says **no data yet**, never zeros: decided once by `model.month_watched` and shipped as `D.nodata`, so the county page and the app cannot disagree; nothing is counted or filed in it. It stays listed, because the month list follows the calendar | `notes/design-alignment.md` § A month the data has not reached says "no data yet" (2026-09-24) | | 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 | diff --git a/esb_site/__main__.py b/esb_site/__main__.py index 4974c89..3bc07cd 100644 --- a/esb_site/__main__.py +++ b/esb_site/__main__.py @@ -14,6 +14,12 @@ DEFAULT_OUT = "out/site" +def parse_now(value): + """UTC when it carries no offset, converted when it does.""" + when = datetime.fromisoformat(value) + return when.replace(tzinfo=UTC) if when.tzinfo is None else when.astimezone(UTC) + + def main(argv=None) -> int: parser = argparse.ArgumentParser( prog="esb_site", description="Build the static ESB outage status site." @@ -25,7 +31,7 @@ def main(argv=None) -> int: parser.add_argument( "--now", default=None, - help="override the build clock, as an ISO UTC timestamp (for reproducible builds)", + help="override the build clock, as an ISO timestamp, UTC unless it carries an offset", ) args = parser.parse_args(argv) @@ -38,11 +44,7 @@ def main(argv=None) -> int: ) return 1 - now = ( - datetime.fromisoformat(args.now).replace(tzinfo=UTC) - if args.now - else datetime.now(UTC) - ) + now = parse_now(args.now) if args.now else datetime.now(UTC) sa_index = model.SmallAreaIndex.load() outages, unplaced, until = model.load_outages(db_path, sa_index, now) diff --git a/esb_site/model.py b/esb_site/model.py index fb91c1e..38e339d 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -924,7 +924,13 @@ def load_outages(db_path, sa_index, now): segments=segments, ) ) - return label_repeats(merge_events(outages)), unplaced, until + # The record starts at the first poll: an event over before it + # overlaps no observed window, so nothing the site derives may count + # it, a repeat chain included. Dropped by the end the merge settles on + # (a sibling lingering a poll past a confirmed restore is the feed + # catching up) and before the chains are labelled. + events = [o for o in merge_events(outages) if o.end > COLLECTION_START] + return label_repeats(events), unplaced, until finally: conn.close() @@ -956,6 +962,21 @@ def observed_window(ym, until): return max(lo, COLLECTION_START), min(hi, until) +def overlaps(o, lo, hi): + """Whether outage `o` falls in the window [lo, hi). An end published past + the horizon passes a bare overlap test against the inverted window of a + month the data has not reached, so an empty window takes nothing.""" + return hi > lo and o.end > lo and o.start < hi + + +def month_watched(ym, until): + """Whether the collected data reaches into month `ym` at all. A build just + after the 1st, or one while the collector is down, lists a month before + any data for it exists, and its window is then empty or inverted.""" + lo, hi = observed_window(ym, until) + return hi > lo + + def days_gate(ym, until): """When month `ym` reaches MIN_GRADED_DAYS, or None once it has. @@ -994,7 +1015,7 @@ def county_month(outages, county, customers, ym, now, until): for o in outages: if o.county != county or not o.start or not o.end: continue - if o.end <= lo or o.start >= hi: + if not overlaps(o, lo, hi): continue cm = o.customer_minutes(lo, hi) if o.planned: diff --git a/esb_site/render.py b/esb_site/render.py index 7a1581d..90197de 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -16,6 +16,7 @@ from collections import defaultdict from datetime import UTC, datetime, timedelta from pathlib import Path +from urllib.parse import quote import statusui @@ -123,13 +124,9 @@ def build(outages, sa_index, now, until): """ months = model.month_list(model.COLLECTION_START, now) - # A county's record starts at the first poll: an outage restored before - # it overlaps no observed window, so the page never lists it and nothing - # derived from the county's list may count it either. by_county = defaultdict(list) for o in outages: - if o.end > model.COLLECTION_START: - by_county[o.county].append(o) + by_county[o.county].append(o) stats, national = {}, {} for county in sa_index.counties: @@ -156,7 +153,7 @@ def build(outages, sa_index, now, until): for ym in months: lo, hi = model.observed_window(ym, until) - live = [o for o in outages if o.start and o.end and o.end > lo and o.start < hi] + live = [o for o in outages if o.start and o.end and model.overlaps(o, lo, hi)] faults = [o for o in live if not o.planned] # Same gate as county_month: an outage still out has no restoration to # judge, and its elapsed time would score as a fast one. @@ -226,8 +223,10 @@ def entries(county, names): # 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}", + # the Dublin month the data last reaches, for the "so far" wording; + # a horizon on the stroke of midnight watched none of the new month + "observed_month": f"{model.local(until - timedelta(microseconds=1)):%Y-%m}", + "nodata": [ym for ym in months if not model.month_watched(ym, until)], "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. @@ -305,7 +304,7 @@ def shard(outages, months, until): for o in sorted(outages, key=lambda o: o.start, reverse=True): record = None for ym, lo, hi in windows: - if o.end > lo and o.start < hi: + if model.overlaps(o, lo, hi): record = case_record(o) if record is None else record by_month[ym].append(record) return by_month @@ -452,7 +451,7 @@ def _update_line(row, key, planned=False): bits.append(f"{label}") if customers is not None: bits.append( - f"{customers:,} customers" + f"{customers:,} customer{'' if customers == 1 else 's'}" + (" still off" if kind == "update" else "") ) cls = ' class="key"' if key else "" @@ -500,7 +499,9 @@ def _daygate(months, until): Absent means graded on days; "" means a month that can never reach five. """ - gates = ((ym, model.days_gate(ym, until)) for ym in months) + gates = ( + (ym, model.days_gate(ym, until)) for ym in months if model.month_watched(ym, until) + ) return { ym: "" if when >= model.month_bounds(ym)[1] else f"{model.local(when):%-d %B}" for ym, when in gates @@ -514,6 +515,8 @@ def ungraded_reason(ym, faults, until): Three gates withhold it and naming the wrong one sends a reader after outages that are not the reason. Mirrored in site.html (ungradedReason). """ + if not model.month_watched(ym, until): + return f"There is no data yet for {month_label(ym)}" when = model.days_gate(ym, until) if when is not None: # past the month's end: it can never reach five days, so promise no date @@ -580,6 +583,8 @@ def _month_watched(ym, until): months are short, and a row of zeros for three hours of July reads as a quiet month rather than an absent collector. """ + if not model.month_watched(ym, until): + return "no data yet" lo, hi = model.month_bounds(ym) olo, ohi = model.observed_window(ym, until) bits = [] @@ -620,6 +625,13 @@ def _county_months_html(county, data, months, until): for ym in reversed(months): m = data["stats"][county][ym] watched = _month_watched(ym, until) + if not model.month_watched(ym, until): + rows.append( + f'