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
125 lines
3.6 KiB
Python
125 lines
3.6 KiB
Python
"""Monitoring period bookkeeping per spec §5.2, §8.6, §9.8.
|
|
|
|
A monitoring period is a span during which Fenris monitoring is enabled.
|
|
Powered-off time stays inside a period; deliberately disabled time does not.
|
|
|
|
Key contracts:
|
|
- Run finding no open period opens one at the run moment, never backdated (§9.8)
|
|
- Wall-clock outside periods excluded from numerator and denominator (§5.2)
|
|
- End causes: user_disabled, migrated, unknown_gap
|
|
"""
|
|
import sqlite3
|
|
from datetime import datetime
|
|
|
|
|
|
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
|
"""Ensure a monitoring period is open. If none exists, open one at run_time.
|
|
|
|
Spec §9.8: A collection run finding no open monitoring period opens one
|
|
at the run moment, never backdated.
|
|
"""
|
|
if get_open_period(conn) is not None:
|
|
return # Already open — no-op
|
|
|
|
ts = run_time.isoformat()
|
|
conn.execute(
|
|
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
|
|
(ts,),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def close_period(
|
|
conn: sqlite3.Connection,
|
|
closed_at: datetime,
|
|
end_cause: str,
|
|
) -> None:
|
|
"""Close the current open monitoring period.
|
|
|
|
Spec §8.6: Pause with an open period closes it user_disabled.
|
|
If no period is open, this is a no-op (pause otherwise).
|
|
"""
|
|
open_period = get_open_period(conn)
|
|
if open_period is None:
|
|
return # No-op
|
|
|
|
ts = closed_at.isoformat()
|
|
conn.execute(
|
|
"UPDATE monitoring_periods SET ended_at = ?, end_cause = ? WHERE id = ?",
|
|
(ts, end_cause, open_period["id"]),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def get_open_period(conn: sqlite3.Connection) -> dict | None:
|
|
"""Return the currently open monitoring period, or None."""
|
|
cursor = conn.execute(
|
|
"SELECT id, started_at, ended_at, end_cause "
|
|
"FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
|
|
)
|
|
row = cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return {
|
|
"id": row[0],
|
|
"started_at": row[1],
|
|
"ended_at": row[2],
|
|
"end_cause": row[3],
|
|
}
|
|
|
|
|
|
def is_inside_period(conn: sqlite3.Connection, ts: datetime) -> bool:
|
|
"""Check if a timestamp falls inside any monitoring period.
|
|
|
|
Spec §5.2: Wall-clock outside periods is excluded from numerator/denominator.
|
|
"""
|
|
ts_str = ts.isoformat()
|
|
cursor = conn.execute(
|
|
"SELECT 1 FROM monitoring_periods "
|
|
"WHERE started_at <= ? AND (ended_at IS NULL OR ended_at > ?) "
|
|
"LIMIT 1",
|
|
(ts_str, ts_str),
|
|
)
|
|
return cursor.fetchone() is not None
|
|
|
|
|
|
def wall_clock_in_periods(
|
|
conn: sqlite3.Connection,
|
|
start: datetime,
|
|
end: datetime,
|
|
) -> int:
|
|
"""Compute total wall-clock seconds between start and end that fall inside
|
|
any monitoring period.
|
|
|
|
Used for denominator computation (§5.2).
|
|
"""
|
|
start_str = start.isoformat()
|
|
end_str = end.isoformat()
|
|
|
|
cursor = conn.execute(
|
|
"SELECT started_at, ended_at FROM monitoring_periods "
|
|
"WHERE ended_at IS NULL OR ended_at > ? "
|
|
"ORDER BY started_at",
|
|
(start_str,),
|
|
)
|
|
|
|
total = 0
|
|
for row in cursor.fetchall():
|
|
period_start = row[0]
|
|
period_end = row[1] # None if open
|
|
|
|
# Clip period to [start, end]
|
|
effective_start = max(period_start, start_str)
|
|
if period_end is not None:
|
|
effective_end = min(period_end, end_str)
|
|
else:
|
|
effective_end = end_str
|
|
|
|
if effective_start < effective_end:
|
|
# Parse for arithmetic
|
|
s = datetime.fromisoformat(effective_start)
|
|
e = datetime.fromisoformat(effective_end)
|
|
total += int((e - s).total_seconds())
|
|
|
|
return total
|