feat(hour/day derivation): hour classification, monitoring periods, day aggregates, pruning
Hour classification (PR-4): - Powered-off: POH delta < 90% of wall-clock span - Active: DUW delta >= 256 MiB - Idle: powered on + sampled + below active threshold - Unknown: unsampled without POH evidence - Four splits sum to exactly wall_clock_seconds - Disabled time is never an hour state Monitoring periods (FL-8): - ensure_period_open: opens period at run moment if none exists - close_period: closes with end cause - is_inside_period: checks timestamp against period bounds - Never backdated; wall-clock outside periods excluded from denominator Day aggregates (ST-4, PR-5): - Derived monotonically from hour rows - UTC-bounded; no 23/25-hour days - Coverage: known seconds / period wall-clock - Gap hours inside periods contribute unknown seconds - Hours outside periods excluded entirely - No absent hour interpolated/estimated/fabricated (FL-3) Raw sample pruning (ST-5): - Prunes samples older than 14 days - Hour observations and day aggregates retained indefinitely Closes #22
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""Day aggregate derivation per spec §5.4, §3.3.
|
||||
|
||||
One row per UTC day, derived monotonically from hour rows — the grain at
|
||||
which usage-habit evidence is judged. No absent hour is ever interpolated,
|
||||
estimated, or fabricated (§5.3, FL-3).
|
||||
|
||||
Coverage: the share of wall-clock seconds inside monitoring periods whose
|
||||
usage-habit classification is known rather than unknown (§5.3).
|
||||
"""
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayAggregate:
|
||||
"""One UTC day's aggregated stats."""
|
||||
day: str # ISO 8601 UTC date, e.g. "2026-09-01"
|
||||
seconds_active: int
|
||||
seconds_idle: int
|
||||
seconds_powered_off: int
|
||||
seconds_unknown: int
|
||||
bytes_written_delta: int
|
||||
bytes_read_delta: int
|
||||
sample_count: int
|
||||
coverage: float
|
||||
|
||||
|
||||
def _period_wall_clock_for_day(conn: sqlite3.Connection, day: str) -> int:
|
||||
"""Total wall-clock seconds inside monitoring periods for a UTC day.
|
||||
|
||||
Clamps each period to the day boundary [dayT00:00, dayT24:00).
|
||||
"""
|
||||
day_start = datetime.fromisoformat(f"{day}T00:00:00+00:00")
|
||||
day_end = day_start + timedelta(days=1)
|
||||
day_start_str = day_start.isoformat()
|
||||
day_end_str = day_end.isoformat()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT started_at, ended_at FROM monitoring_periods "
|
||||
"WHERE (ended_at IS NULL OR ended_at > ?) AND started_at < ? "
|
||||
"ORDER BY started_at",
|
||||
(day_start_str, day_end_str),
|
||||
)
|
||||
|
||||
total = 0
|
||||
for row in cursor.fetchall():
|
||||
period_start = row[0]
|
||||
period_end = row[1]
|
||||
|
||||
effective_start = max(period_start, day_start_str)
|
||||
if period_end is not None:
|
||||
effective_end = min(period_end, day_end_str)
|
||||
else:
|
||||
effective_end = day_end_str
|
||||
|
||||
if effective_start < effective_end:
|
||||
s = datetime.fromisoformat(effective_start)
|
||||
e = datetime.fromisoformat(effective_end)
|
||||
total += int((e - s).total_seconds())
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _hour_overlaps_period(conn: sqlite3.Connection, hour_iso: str) -> bool:
|
||||
"""Check if an hour's wall-clock span overlaps any monitoring period."""
|
||||
hour_start = datetime.fromisoformat(hour_iso)
|
||||
hour_end = hour_start + timedelta(hours=1)
|
||||
hs = hour_start.isoformat()
|
||||
he = hour_end.isoformat()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT 1 FROM monitoring_periods "
|
||||
"WHERE started_at < ? AND (ended_at IS NULL OR ended_at > ?) "
|
||||
"LIMIT 1",
|
||||
(he, hs),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def derive_day(conn: sqlite3.Connection, day: str) -> DayAggregate | None:
|
||||
"""Derive a single day aggregate from its hour rows + monitoring periods.
|
||||
|
||||
Only hours overlapping a monitoring period contribute to the aggregate.
|
||||
Gap hours inside periods contribute unknown seconds. Hours outside all
|
||||
monitoring periods are excluded entirely (§5.2).
|
||||
|
||||
Returns None if no hours exist for the day.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"SELECT hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||
" bytes_written_delta, bytes_read_delta, sample_count "
|
||||
"FROM hour_observations "
|
||||
"WHERE hour LIKE ? "
|
||||
"ORDER BY hour",
|
||||
(day + "T%",),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
total_active = 0
|
||||
total_idle = 0
|
||||
total_powered_off = 0
|
||||
total_unknown_from_hours = 0
|
||||
total_bw = 0
|
||||
total_br = 0
|
||||
total_samples = 0
|
||||
total_hour_wall_clock = 0
|
||||
|
||||
for row in rows:
|
||||
# Only count hours overlapping a monitoring period
|
||||
if not _hour_overlaps_period(conn, row[0]):
|
||||
continue
|
||||
|
||||
total_active += row[1]
|
||||
total_idle += row[2]
|
||||
total_powered_off += row[3]
|
||||
total_unknown_from_hours += row[4]
|
||||
total_bw += row[5]
|
||||
total_br += row[6]
|
||||
total_samples += row[7]
|
||||
total_hour_wall_clock += row[1] + row[2] + row[3] + row[4]
|
||||
|
||||
# Wall-clock seconds inside monitoring periods for this day
|
||||
period_wc = _period_wall_clock_for_day(conn, day)
|
||||
|
||||
# Gap seconds = period wall-clock - sum of existing hour wall-clock
|
||||
gap_seconds = max(0, period_wc - total_hour_wall_clock)
|
||||
|
||||
total_unknown = total_unknown_from_hours + gap_seconds
|
||||
|
||||
# Coverage: known seconds / period wall-clock (§5.2, §5.3)
|
||||
known_seconds = total_active + total_idle + total_powered_off
|
||||
coverage = known_seconds / period_wc if period_wc > 0 else 0.0
|
||||
|
||||
return DayAggregate(
|
||||
day=day,
|
||||
seconds_active=total_active,
|
||||
seconds_idle=total_idle,
|
||||
seconds_powered_off=total_powered_off,
|
||||
seconds_unknown=total_unknown,
|
||||
bytes_written_delta=total_bw,
|
||||
bytes_read_delta=total_br,
|
||||
sample_count=total_samples,
|
||||
coverage=coverage,
|
||||
)
|
||||
|
||||
|
||||
def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
|
||||
"""Derive day aggregates for all days that have hour rows.
|
||||
|
||||
Returns days sorted by date.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
|
||||
)
|
||||
days = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
results = []
|
||||
for day in days:
|
||||
agg = derive_day(conn, day)
|
||||
if agg is not None:
|
||||
results.append(agg)
|
||||
return results
|
||||
Reference in New Issue
Block a user