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,147 @@
|
||||
"""Monitoring period tests.
|
||||
|
||||
From spec §5.2, §8.6, §9.8:
|
||||
- Run finding no open period opens one at the run moment, never backdated
|
||||
- Wall-clock outside periods excluded from numerator/denominator
|
||||
- Powered-off time stays inside a period; disabled time does not
|
||||
- End causes: user_disabled, migrated, unknown_gap
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.monitoring_periods import (
|
||||
ensure_period_open,
|
||||
close_period,
|
||||
get_open_period,
|
||||
is_inside_period,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_conn(tmp_path: Path):
|
||||
"""Initialize an observation store and return a connection."""
|
||||
db_path = tmp_path / "test.db"
|
||||
conn = init_store(db_path)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestEnsurePeriodOpen:
|
||||
"""Spec §9.8: Run finding no open period opens one at the run moment."""
|
||||
|
||||
def test_opens_period_when_none_exists(self, store_conn):
|
||||
"""First collection run opens a period at the run moment."""
|
||||
run_time = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
ensure_period_open(store_conn, run_time)
|
||||
|
||||
period = get_open_period(store_conn)
|
||||
assert period is not None
|
||||
assert period["started_at"] == "2026-09-01T12:00:00+00:00"
|
||||
assert period["ended_at"] is None
|
||||
assert period["end_cause"] is None
|
||||
|
||||
def test_no_opener_when_already_open(self, store_conn):
|
||||
"""If a period is already open, no new period is created."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(store_conn, t1)
|
||||
ensure_period_open(store_conn, t2)
|
||||
|
||||
period = get_open_period(store_conn)
|
||||
assert period is not None
|
||||
assert period["started_at"] == "2026-09-01T12:00:00+00:00"
|
||||
|
||||
def test_never_backdated(self, store_conn):
|
||||
"""Period starts at the run moment, not the beginning of the hour."""
|
||||
run_time = datetime(2026, 9, 1, 12, 3, 45, tzinfo=timezone.utc)
|
||||
ensure_period_open(store_conn, run_time)
|
||||
|
||||
period = get_open_period(store_conn)
|
||||
assert period["started_at"] == "2026-09-01T12:03:45+00:00"
|
||||
|
||||
def test_new_period_after_close(self, store_conn):
|
||||
"""After closing a period, next run opens a new one."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||
t3 = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(store_conn, t1)
|
||||
close_period(store_conn, t2, "user_disabled")
|
||||
ensure_period_open(store_conn, t3)
|
||||
|
||||
period = get_open_period(store_conn)
|
||||
assert period is not None
|
||||
assert period["started_at"] == "2026-09-01T14:00:00+00:00"
|
||||
|
||||
|
||||
class TestClosePeriod:
|
||||
"""Spec §8.6: Pause closes with user_disabled."""
|
||||
|
||||
def test_close_with_user_disabled(self, store_conn):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(store_conn, t1)
|
||||
close_period(store_conn, t2, "user_disabled")
|
||||
|
||||
period = get_open_period(store_conn)
|
||||
assert period is None
|
||||
|
||||
cursor = store_conn.execute(
|
||||
"SELECT ended_at, end_cause FROM monitoring_periods WHERE id = 1"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
assert row[0] == "2026-09-01T13:00:00+00:00"
|
||||
assert row[1] == "user_disabled"
|
||||
|
||||
def test_close_nonexistent_is_noop(self, store_conn):
|
||||
"""Closing when no period is open is a no-op (spec §8.6 pause otherwise)."""
|
||||
t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
close_period(store_conn, t, "user_disabled")
|
||||
|
||||
cursor = store_conn.execute("SELECT COUNT(*) FROM monitoring_periods")
|
||||
assert cursor.fetchone()[0] == 0
|
||||
|
||||
|
||||
class TestIsInsidePeriod:
|
||||
"""Spec §5.2: Wall-clock outside periods excluded from numerator/denominator."""
|
||||
|
||||
def test_inside_open_period(self, store_conn):
|
||||
t_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t_inside = datetime(2026, 9, 1, 12, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(store_conn, t_start)
|
||||
assert is_inside_period(store_conn, t_inside) is True
|
||||
|
||||
def test_outside_closed_period(self, store_conn):
|
||||
t_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||
t_outside = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(store_conn, t_start)
|
||||
close_period(store_conn, t_end, "user_disabled")
|
||||
assert is_inside_period(store_conn, t_outside) is False
|
||||
|
||||
def test_outside_no_periods(self, store_conn):
|
||||
t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
assert is_inside_period(store_conn, t) is False
|
||||
|
||||
def test_inside_second_period(self, store_conn):
|
||||
"""Two periods with a gap; time in second period is inside."""
|
||||
t1_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t1_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||
t2_start = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
t_inside = datetime(2026, 9, 1, 14, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(store_conn, t1_start)
|
||||
close_period(store_conn, t1_end, "user_disabled")
|
||||
ensure_period_open(store_conn, t2_start)
|
||||
assert is_inside_period(store_conn, t_inside) is True
|
||||
Reference in New Issue
Block a user