Show trustworthy local-day activity totals (#90)
Add local-day activity summaries derived from UTC hour observations using the system timezone, with durable storage in a new local_days table. The collector derives local-day read/write totals after UTC aggregation; the TUI displays them with timezone, completeness state, and coverage. Schema: bump SCHEMA_VERSION to 3, add local_days table (migration 2→3 is pure addition, idempotent, preserves newer-schema refusal).
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""Local-day activity derivation from UTC hour observations.
|
||||
|
||||
Computes durable local-day read/write summaries using the actual local
|
||||
midnight boundaries, retaining UTC hour/day aggregates for endurance
|
||||
projections. The collector owns this derivation, preserving the
|
||||
existing read-only TUI boundary (ADR 0010).
|
||||
|
||||
Key contracts:
|
||||
- An interval wholly attributable to a local day contributes its volume once
|
||||
- Midnight-spanning intervals are retained once as shared/unallocated evidence
|
||||
- UTC hour/day aggregates are never modified or deleted
|
||||
- Migration cannot manufacture local precision from historical UTC data
|
||||
"""
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalDaySummary:
|
||||
"""One local day's aggregated read/write volumes."""
|
||||
local_date: str # e.g. "2026-09-01"
|
||||
tz_name: str # e.g. "Asia/Kolkata"
|
||||
tz_offset: str # e.g. "+05:30"
|
||||
utc_start: str # ISO 8601 UTC: the local midnight that starts this day
|
||||
utc_end: str # ISO 8601 UTC: the local midnight that ends this day
|
||||
bytes_written: int
|
||||
bytes_read: int
|
||||
coverage: float # known / (known + unknown) in UTC hours
|
||||
sample_count: int
|
||||
complete: bool # day's UTC range fully covered by hour observations
|
||||
|
||||
|
||||
def _local_midnight_utc(dt: datetime, tz_name: str) -> datetime:
|
||||
"""Compute the UTC time of the local midnight that contains *dt*.
|
||||
|
||||
Returns the most recent local midnight in UTC. For example, if *dt*
|
||||
is 2026-09-01T17:35:00+05:30 (i.e. 12:05 UTC) this returns
|
||||
2026-08-31T18:30:00+00:00 (2026-09-01 00:00 in +05:30).
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = dt.astimezone(local_tz)
|
||||
local_midnight = local_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return local_midnight.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def derive_local_day_summary(
|
||||
conn: sqlite3.Connection,
|
||||
tz_name: str,
|
||||
clock_now: datetime,
|
||||
) -> LocalDaySummary | None:
|
||||
"""Derive a local-day summary from UTC hour observations.
|
||||
|
||||
Computes the UTC boundaries of the current local day, queries the
|
||||
UTC hours overlapping that range, and aggregates read/write volumes.
|
||||
Midnight-spanning hours are retained once as shared evidence.
|
||||
|
||||
Returns None if no hours exist for the local day.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
from .tz_util import get_tz_offset_str
|
||||
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = clock_now.astimezone(local_tz)
|
||||
local_date = local_dt.strftime("%Y-%m-%d")
|
||||
tz_offset_str = get_tz_offset_str(clock_now, tz_name)
|
||||
|
||||
# UTC boundaries of this local day
|
||||
utc_start = _local_midnight_utc(clock_now, tz_name)
|
||||
next_local = local_dt + timedelta(days=1)
|
||||
utc_end = next_local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||
|
||||
utc_start_iso = utc_start.isoformat()
|
||||
utc_end_iso = utc_end.isoformat()
|
||||
|
||||
# Query UTC hours overlapping the local day
|
||||
cursor = conn.execute(
|
||||
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count, "
|
||||
" active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||||
"FROM hour_observations "
|
||||
"WHERE hour >= ? AND hour < ? "
|
||||
"ORDER BY hour",
|
||||
(utc_start_iso, utc_end_iso),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Also check for a midnight-spanning hour before utc_start
|
||||
# (UTC hour that starts before utc_start but ends after it)
|
||||
# The relevant hour is the one whose floor-hour contains utc_start
|
||||
midnight_hour = utc_start.replace(minute=0, second=0, microsecond=0)
|
||||
midnight_hour_iso = midnight_hour.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||
prev_row = conn.execute(
|
||||
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
|
||||
"FROM hour_observations WHERE hour = ?",
|
||||
(midnight_hour_iso,),
|
||||
).fetchone()
|
||||
|
||||
total_bw = 0
|
||||
total_br = 0
|
||||
total_samples = 0
|
||||
known_seconds = 0
|
||||
unknown_seconds = 0
|
||||
hour_count = 0
|
||||
|
||||
for row in rows:
|
||||
total_bw += row[1] or 0
|
||||
total_br += row[2] or 0
|
||||
total_samples += row[3] or 0
|
||||
known_seconds += (row[4] or 0) + (row[5] or 0) + (row[6] or 0)
|
||||
unknown_seconds += row[7] or 0
|
||||
hour_count += 1
|
||||
|
||||
# Handle midnight-spanning hour (starts before local midnight, ends after)
|
||||
if prev_row is not None:
|
||||
# This hour straddles the local midnight boundary
|
||||
total_bw += prev_row[1] or 0
|
||||
total_br += prev_row[2] or 0
|
||||
total_samples += prev_row[3] or 0
|
||||
hour_count += 1
|
||||
|
||||
total_evidenced = known_seconds + unknown_seconds
|
||||
# Coverage is known seconds as a share of the full local day,
|
||||
# not just the hours present — gaps reduce coverage
|
||||
day_seconds = int((utc_end - utc_start).total_seconds())
|
||||
coverage = known_seconds / day_seconds if day_seconds > 0 else 0.0
|
||||
|
||||
# Day is complete only if known (usable) seconds cover the full local day.
|
||||
# Unknown seconds represent gaps without usable observation evidence.
|
||||
complete = known_seconds >= day_seconds and hour_count > 0
|
||||
|
||||
# Return None if no hours exist for this local day
|
||||
if hour_count == 0:
|
||||
return None
|
||||
|
||||
return LocalDaySummary(
|
||||
local_date=local_date,
|
||||
tz_name=tz_name,
|
||||
tz_offset=tz_offset_str,
|
||||
utc_start=utc_start_iso,
|
||||
utc_end=utc_end_iso,
|
||||
bytes_written=total_bw,
|
||||
bytes_read=total_br,
|
||||
coverage=coverage,
|
||||
sample_count=total_samples,
|
||||
complete=complete,
|
||||
)
|
||||
|
||||
|
||||
def persist_local_day(
|
||||
conn: sqlite3.Connection,
|
||||
summary: LocalDaySummary,
|
||||
) -> bool:
|
||||
"""Upsert a local-day summary. Returns True if newly created."""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM local_days WHERE local_date = ? AND tz_name = ?",
|
||||
(summary.local_date, summary.tz_name),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(summary.local_date, summary.tz_name, summary.tz_offset,
|
||||
summary.utc_start, summary.utc_end,
|
||||
summary.bytes_written, summary.bytes_read,
|
||||
summary.coverage, summary.sample_count, summary.complete),
|
||||
)
|
||||
created = True
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE local_days "
|
||||
"SET tz_offset = ?, utc_start = ?, utc_end = ?, "
|
||||
" bytes_written = ?, bytes_read = ?, "
|
||||
" coverage = ?, sample_count = ?, complete = ? "
|
||||
"WHERE id = ?",
|
||||
(summary.tz_offset, summary.utc_start, summary.utc_end,
|
||||
summary.bytes_written, summary.bytes_read,
|
||||
summary.coverage, summary.sample_count, summary.complete,
|
||||
existing[0]),
|
||||
)
|
||||
created = False
|
||||
# Caller manages transactions (collector commits once after all derivation)
|
||||
return created
|
||||
|
||||
|
||||
def query_local_day_summary(
|
||||
conn: sqlite3.Connection,
|
||||
local_date: str,
|
||||
) -> dict | None:
|
||||
"""Query a stored local-day summary by date.
|
||||
|
||||
Returns a dict with the summary fields, or None if no entry exists.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete "
|
||||
"FROM local_days WHERE local_date = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(local_date,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"local_date": row[0],
|
||||
"tz_name": row[1],
|
||||
"tz_offset": row[2],
|
||||
"utc_start": row[3],
|
||||
"utc_end": row[4],
|
||||
"bytes_written": row[5],
|
||||
"bytes_read": row[6],
|
||||
"coverage": row[7],
|
||||
"sample_count": row[8],
|
||||
"complete": row[9],
|
||||
}
|
||||
|
||||
|
||||
def query_current_local_day(
|
||||
conn: sqlite3.Connection,
|
||||
clock_now: datetime,
|
||||
tz_name: str,
|
||||
) -> dict | None:
|
||||
"""Query the local-day summary for the current local date."""
|
||||
from zoneinfo import ZoneInfo
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = clock_now.astimezone(local_tz)
|
||||
local_date = local_dt.strftime("%Y-%m-%d")
|
||||
return query_local_day_summary(conn, local_date)
|
||||
Reference in New Issue
Block a user