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
235 lines
9.2 KiB
Python
235 lines
9.2 KiB
Python
"""Day aggregate tests.
|
|
|
|
From spec §5.4, §3.3, ST-4, ST-5:
|
|
- One row per UTC day, derived monotonically from hour rows
|
|
- No 23/25-hour days (UTC-bounded, DST never applies)
|
|
- Coverage: share of wall-clock seconds inside monitoring periods
|
|
whose classification is known
|
|
- No absent hour ever interpolated/estimated/fabricated (FL-3)
|
|
"""
|
|
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
|
|
from fenris.day_aggregate import derive_day, derive_all_days, DayAggregate
|
|
from fenris.hour_classify import HourSplit, ACTIVE_THRESHOLD_BYTES
|
|
|
|
|
|
@pytest.fixture
|
|
def store_conn(tmp_path: Path):
|
|
db_path = tmp_path / "test.db"
|
|
conn = init_store(db_path)
|
|
yield conn
|
|
conn.close()
|
|
|
|
|
|
def _insert_hour(conn, hour_iso, split, bytes_written_delta=0, bytes_read_delta=0,
|
|
sample_count=1, coverage=1.0):
|
|
conn.execute(
|
|
"INSERT INTO hour_observations "
|
|
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
|
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
hour_iso,
|
|
split.seconds_active,
|
|
split.seconds_idle,
|
|
split.seconds_powered_off,
|
|
split.seconds_unknown,
|
|
bytes_written_delta,
|
|
bytes_read_delta,
|
|
sample_count,
|
|
coverage,
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
class TestDeriveDay:
|
|
"""Spec §5.4: One row per UTC day, derived monotonically from hour rows."""
|
|
|
|
def test_single_hour_day(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)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
hour = "2026-09-01T12:00:00+00:00"
|
|
split = HourSplit(seconds_active=3600, seconds_idle=0, seconds_powered_off=0, seconds_unknown=0)
|
|
_insert_hour(store_conn, hour, split, bytes_written_delta=1024*1024*100)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.day == "2026-09-01"
|
|
assert day.seconds_active == 3600
|
|
assert day.seconds_idle == 0
|
|
assert day.seconds_powered_off == 0
|
|
assert day.bytes_written_delta == 1024*1024*100
|
|
assert day.sample_count == 1
|
|
|
|
def test_multiple_hours(self, store_conn):
|
|
t_start = datetime(2026, 9, 1, 10, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
hours = [
|
|
("2026-09-01T10:00:00+00:00", HourSplit(1800, 1800, 0, 0), 50*1024*1024),
|
|
("2026-09-01T11:00:00+00:00", HourSplit(3600, 0, 0, 0), 200*1024*1024),
|
|
("2026-09-01T12:00:00+00:00", HourSplit(0, 0, 3600, 0), 0),
|
|
]
|
|
for h, s, bw in hours:
|
|
_insert_hour(store_conn, h, s, bytes_written_delta=bw)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.seconds_active == 1800 + 3600 + 0
|
|
assert day.seconds_idle == 1800 + 0 + 0
|
|
assert day.seconds_powered_off == 0 + 0 + 3600
|
|
assert day.bytes_written_delta == 50*1024*1024 + 200*1024*1024 + 0
|
|
assert day.sample_count == 3
|
|
|
|
def test_no_hours_returns_none(self, store_conn):
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is None
|
|
|
|
|
|
class TestUtcBounded:
|
|
"""Spec §3.3, ST-4: Hours and days are UTC-bounded. No 23/25-hour days."""
|
|
|
|
def test_day_keys_are_utc_date_strings(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)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
hour = "2026-09-01T12:00:00+00:00"
|
|
split = HourSplit(0, 0, 3600, 0)
|
|
_insert_hour(store_conn, hour, split)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.day == "2026-09-01"
|
|
|
|
def test_cross_midnight_hours_produce_two_days(self, store_conn):
|
|
# Period spans both days
|
|
t_start = datetime(2026, 9, 1, 23, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 2, 1, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
hours = [
|
|
("2026-09-01T23:00:00+00:00", HourSplit(3600, 0, 0, 0), 100),
|
|
("2026-09-02T00:00:00+00:00", HourSplit(0, 0, 3600, 0), 0),
|
|
]
|
|
for h, s, bw in hours:
|
|
_insert_hour(store_conn, h, s, bytes_written_delta=bw)
|
|
|
|
days = derive_all_days(store_conn)
|
|
day_keys = [d.day for d in days]
|
|
assert "2026-09-01" in day_keys
|
|
assert "2026-09-02" in day_keys
|
|
assert len(days) == 2
|
|
|
|
|
|
class TestCoverage:
|
|
"""Spec §5.2, §5.3, PR-5: Coverage is known share of wall-clock seconds
|
|
inside monitoring periods."""
|
|
|
|
def test_full_coverage_all_known(self, store_conn):
|
|
"""All hours in period classified -> coverage = 1.0."""
|
|
t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 1, 3, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
hours = [
|
|
("2026-09-01T00:00:00+00:00", HourSplit(3600, 0, 0, 0), 100),
|
|
("2026-09-01T01:00:00+00:00", HourSplit(0, 3600, 0, 0), 50),
|
|
("2026-09-01T02:00:00+00:00", HourSplit(0, 0, 3600, 0), 0),
|
|
]
|
|
for h, s, bw in hours:
|
|
_insert_hour(store_conn, h, s, bytes_written_delta=bw)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.coverage == pytest.approx(1.0)
|
|
|
|
def test_gap_reduces_coverage(self, store_conn):
|
|
"""Missing hour -> unknown seconds reduce coverage."""
|
|
t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 1, 3, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
_insert_hour(store_conn, "2026-09-01T00:00:00+00:00",
|
|
HourSplit(3600, 0, 0, 0), bytes_written_delta=100)
|
|
_insert_hour(store_conn, "2026-09-01T02:00:00+00:00",
|
|
HourSplit(0, 3600, 0, 0), bytes_written_delta=50)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.coverage == pytest.approx(7200 / 10800)
|
|
assert day.seconds_unknown == 3600
|
|
|
|
def test_outside_period_excluded(self, store_conn):
|
|
"""Hours outside any monitoring period excluded from denominator."""
|
|
t_start = datetime(2026, 9, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 1, 2, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
# Hour 01 is inside the period
|
|
_insert_hour(store_conn, "2026-09-01T01:00:00+00:00",
|
|
HourSplit(3600, 0, 0, 0), bytes_written_delta=100)
|
|
# Hour 02 is outside (period ends at 02:00)
|
|
_insert_hour(store_conn, "2026-09-01T02:00:00+00:00",
|
|
HourSplit(0, 3600, 0, 0), bytes_written_delta=50)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.coverage == pytest.approx(1.0)
|
|
|
|
def test_unknown_in_period_reduces_coverage(self, store_conn):
|
|
"""Unknown seconds inside period count in denominator but not numerator."""
|
|
t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
_insert_hour(store_conn, "2026-09-01T00:00:00+00:00",
|
|
HourSplit(1800, 0, 0, 1800), bytes_written_delta=100)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.coverage == pytest.approx(0.5)
|
|
assert day.seconds_unknown == 1800
|
|
|
|
|
|
class TestNoFabrication:
|
|
"""Spec §5.3, FL-3: No absent hour is ever interpolated/estimated/fabricated."""
|
|
|
|
def test_missing_hours_stay_unknown(self, store_conn):
|
|
"""Gap hours are never filled in — they remain as unknown seconds."""
|
|
t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
t_end = datetime(2026, 9, 1, 3, 0, 0, tzinfo=timezone.utc)
|
|
ensure_period_open(store_conn, t_start)
|
|
close_period(store_conn, t_end, "user_disabled")
|
|
|
|
# Only hour 02 — hours 00 and 01 are gaps
|
|
_insert_hour(store_conn, "2026-09-01T02:00:00+00:00",
|
|
HourSplit(3600, 0, 0, 0), bytes_written_delta=100)
|
|
|
|
day = derive_day(store_conn, "2026-09-01")
|
|
assert day is not None
|
|
assert day.seconds_unknown == 2 * 3600
|
|
assert day.seconds_active == 3600
|
|
assert day.sample_count == 1
|