Files
Fenris/src/fenris/hour_classify.py
T
xavierk 7d219c4697 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
2026-09-01 22:58:03 +05:30

95 lines
3.1 KiB
Python

"""Hour classification per spec §5.1.
Each UTC hour is classified by named constants, in this order of evidence:
- Powered-off: power-on-hours delta < 90% of wall-clock span
- Active: DUW delta >= 256 MiB in the hour
- Idle: powered on, sampled, below active threshold
- Unknown: everything else (unsampled without POH evidence)
Four splits sum to exactly wall_clock_seconds. Disabled time is never an
hour state — it is wall-clock outside monitoring periods (§5.2).
"""
from dataclasses import dataclass
# Spec §5.1: Active hour threshold — 256 MiB DUW delta
ACTIVE_THRESHOLD_BYTES = 256 * 1024 * 1024 # 256 MiB
# Spec §5.1: Powered-off threshold — 90% of wall-clock span
POWERED_OFF_THRESHOLD_PERCENT = 0.90
@dataclass(frozen=True)
class HourSplit:
"""Usage-habit split for one UTC hour. Fields sum to wall_clock_seconds."""
seconds_active: int
seconds_idle: int
seconds_powered_off: int
seconds_unknown: int
def classify_hour(
wall_clock_seconds: int,
poh_delta: int,
duw_delta: int,
dur_delta: int,
sampled_seconds: int | None = None,
) -> HourSplit:
"""Classify a UTC hour into the four usage-habit states.
Args:
wall_clock_seconds: Total seconds in this hour boundary (3600 for a
full hour, less for partial-hours at period edges).
poh_delta: Power-on-hours delta since previous sample (in seconds).
duw_delta: Data-units-written delta since previous sample (in bytes).
dur_delta: Data-units-read delta since previous sample (in bytes).
sampled_seconds: Seconds within this hour covered by a sample.
None or 0 means no sample fell in this hour.
Returns:
HourSplit whose four fields sum to wall_clock_seconds.
"""
if sampled_seconds is None:
sampled_seconds = 0
# Clamp sampled_seconds to wall_clock_seconds
sampled_seconds = min(sampled_seconds, wall_clock_seconds)
# --- Decision order per spec §5.1 ---
# 1. Powered-off: POH delta < 90% of wall-clock span
powered_off_threshold = wall_clock_seconds * POWERED_OFF_THRESHOLD_PERCENT
if poh_delta < powered_off_threshold:
return HourSplit(
seconds_active=0,
seconds_idle=0,
seconds_powered_off=wall_clock_seconds,
seconds_unknown=0,
)
# 2. Active: DUW delta >= 256 MiB
if duw_delta >= ACTIVE_THRESHOLD_BYTES:
return HourSplit(
seconds_active=wall_clock_seconds,
seconds_idle=0,
seconds_powered_off=0,
seconds_unknown=0,
)
# 3. Idle: powered on, sampled, below active threshold
# Unsampled portion within the hour is unknown
if sampled_seconds > 0:
return HourSplit(
seconds_active=0,
seconds_idle=sampled_seconds,
seconds_powered_off=0,
seconds_unknown=wall_clock_seconds - sampled_seconds,
)
# 4. Unknown: unsampled without POH evidence
return HourSplit(
seconds_active=0,
seconds_idle=0,
seconds_powered_off=0,
seconds_unknown=wall_clock_seconds,
)