Show trustworthy local-day activity totals (#90)
Add local-day activity summaries derived from UTC hour observations using the system timezone, with durable storage in a new local_days table. The collector derives local-day read/write totals after UTC aggregation; the TUI displays them with timezone, completeness state, and coverage. Schema: bump SCHEMA_VERSION to 3, add local_days table (migration 2→3 is pure addition, idempotent, preserves newer-schema refusal).
This commit is contained in:
@@ -345,6 +345,20 @@ def run_collection(
|
|||||||
from .day_aggregate import derive_all_days, persist_day_aggregate
|
from .day_aggregate import derive_all_days, persist_day_aggregate
|
||||||
for agg in derive_all_days(conn):
|
for agg in derive_all_days(conn):
|
||||||
persist_day_aggregate(conn, agg)
|
persist_day_aggregate(conn, agg)
|
||||||
|
|
||||||
|
# Derive local-day summary using system timezone (issue #90)
|
||||||
|
try:
|
||||||
|
from .tz_util import detect_system_tz
|
||||||
|
from .local_day import derive_local_day_summary, persist_local_day
|
||||||
|
tz_name = detect_system_tz()
|
||||||
|
clock_now = clock.utcnow()
|
||||||
|
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
|
||||||
|
if local_summary is not None:
|
||||||
|
persist_local_day(conn, local_summary)
|
||||||
|
except Exception:
|
||||||
|
# Local-day derivation failure must not prevent publication
|
||||||
|
pass
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Local-day activity derivation from UTC hour observations.
|
||||||
|
|
||||||
|
Computes durable local-day read/write summaries using the actual local
|
||||||
|
midnight boundaries, retaining UTC hour/day aggregates for endurance
|
||||||
|
projections. The collector owns this derivation, preserving the
|
||||||
|
existing read-only TUI boundary (ADR 0010).
|
||||||
|
|
||||||
|
Key contracts:
|
||||||
|
- An interval wholly attributable to a local day contributes its volume once
|
||||||
|
- Midnight-spanning intervals are retained once as shared/unallocated evidence
|
||||||
|
- UTC hour/day aggregates are never modified or deleted
|
||||||
|
- Migration cannot manufacture local precision from historical UTC data
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocalDaySummary:
|
||||||
|
"""One local day's aggregated read/write volumes."""
|
||||||
|
local_date: str # e.g. "2026-09-01"
|
||||||
|
tz_name: str # e.g. "Asia/Kolkata"
|
||||||
|
tz_offset: str # e.g. "+05:30"
|
||||||
|
utc_start: str # ISO 8601 UTC: the local midnight that starts this day
|
||||||
|
utc_end: str # ISO 8601 UTC: the local midnight that ends this day
|
||||||
|
bytes_written: int
|
||||||
|
bytes_read: int
|
||||||
|
coverage: float # known / (known + unknown) in UTC hours
|
||||||
|
sample_count: int
|
||||||
|
complete: bool # day's UTC range fully covered by hour observations
|
||||||
|
|
||||||
|
|
||||||
|
def _local_midnight_utc(dt: datetime, tz_name: str) -> datetime:
|
||||||
|
"""Compute the UTC time of the local midnight that contains *dt*.
|
||||||
|
|
||||||
|
Returns the most recent local midnight in UTC. For example, if *dt*
|
||||||
|
is 2026-09-01T17:35:00+05:30 (i.e. 12:05 UTC) this returns
|
||||||
|
2026-08-31T18:30:00+00:00 (2026-09-01 00:00 in +05:30).
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = dt.astimezone(local_tz)
|
||||||
|
local_midnight = local_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return local_midnight.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_local_day_summary(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tz_name: str,
|
||||||
|
clock_now: datetime,
|
||||||
|
) -> LocalDaySummary | None:
|
||||||
|
"""Derive a local-day summary from UTC hour observations.
|
||||||
|
|
||||||
|
Computes the UTC boundaries of the current local day, queries the
|
||||||
|
UTC hours overlapping that range, and aggregates read/write volumes.
|
||||||
|
Midnight-spanning hours are retained once as shared evidence.
|
||||||
|
|
||||||
|
Returns None if no hours exist for the local day.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from .tz_util import get_tz_offset_str
|
||||||
|
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = clock_now.astimezone(local_tz)
|
||||||
|
local_date = local_dt.strftime("%Y-%m-%d")
|
||||||
|
tz_offset_str = get_tz_offset_str(clock_now, tz_name)
|
||||||
|
|
||||||
|
# UTC boundaries of this local day
|
||||||
|
utc_start = _local_midnight_utc(clock_now, tz_name)
|
||||||
|
next_local = local_dt + timedelta(days=1)
|
||||||
|
utc_end = next_local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
utc_start_iso = utc_start.isoformat()
|
||||||
|
utc_end_iso = utc_end.isoformat()
|
||||||
|
|
||||||
|
# Query UTC hours overlapping the local day
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count, "
|
||||||
|
" active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||||||
|
"FROM hour_observations "
|
||||||
|
"WHERE hour >= ? AND hour < ? "
|
||||||
|
"ORDER BY hour",
|
||||||
|
(utc_start_iso, utc_end_iso),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Also check for a midnight-spanning hour before utc_start
|
||||||
|
# (UTC hour that starts before utc_start but ends after it)
|
||||||
|
# The relevant hour is the one whose floor-hour contains utc_start
|
||||||
|
midnight_hour = utc_start.replace(minute=0, second=0, microsecond=0)
|
||||||
|
midnight_hour_iso = midnight_hour.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
prev_row = conn.execute(
|
||||||
|
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour = ?",
|
||||||
|
(midnight_hour_iso,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
total_bw = 0
|
||||||
|
total_br = 0
|
||||||
|
total_samples = 0
|
||||||
|
known_seconds = 0
|
||||||
|
unknown_seconds = 0
|
||||||
|
hour_count = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
total_bw += row[1] or 0
|
||||||
|
total_br += row[2] or 0
|
||||||
|
total_samples += row[3] or 0
|
||||||
|
known_seconds += (row[4] or 0) + (row[5] or 0) + (row[6] or 0)
|
||||||
|
unknown_seconds += row[7] or 0
|
||||||
|
hour_count += 1
|
||||||
|
|
||||||
|
# Handle midnight-spanning hour (starts before local midnight, ends after)
|
||||||
|
if prev_row is not None:
|
||||||
|
# This hour straddles the local midnight boundary
|
||||||
|
total_bw += prev_row[1] or 0
|
||||||
|
total_br += prev_row[2] or 0
|
||||||
|
total_samples += prev_row[3] or 0
|
||||||
|
hour_count += 1
|
||||||
|
|
||||||
|
total_evidenced = known_seconds + unknown_seconds
|
||||||
|
# Coverage is known seconds as a share of the full local day,
|
||||||
|
# not just the hours present — gaps reduce coverage
|
||||||
|
day_seconds = int((utc_end - utc_start).total_seconds())
|
||||||
|
coverage = known_seconds / day_seconds if day_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
# Day is complete only if known (usable) seconds cover the full local day.
|
||||||
|
# Unknown seconds represent gaps without usable observation evidence.
|
||||||
|
complete = known_seconds >= day_seconds and hour_count > 0
|
||||||
|
|
||||||
|
# Return None if no hours exist for this local day
|
||||||
|
if hour_count == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return LocalDaySummary(
|
||||||
|
local_date=local_date,
|
||||||
|
tz_name=tz_name,
|
||||||
|
tz_offset=tz_offset_str,
|
||||||
|
utc_start=utc_start_iso,
|
||||||
|
utc_end=utc_end_iso,
|
||||||
|
bytes_written=total_bw,
|
||||||
|
bytes_read=total_br,
|
||||||
|
coverage=coverage,
|
||||||
|
sample_count=total_samples,
|
||||||
|
complete=complete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_local_day(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
summary: LocalDaySummary,
|
||||||
|
) -> bool:
|
||||||
|
"""Upsert a local-day summary. Returns True if newly created."""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM local_days WHERE local_date = ? AND tz_name = ?",
|
||||||
|
(summary.local_date, summary.tz_name),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(summary.local_date, summary.tz_name, summary.tz_offset,
|
||||||
|
summary.utc_start, summary.utc_end,
|
||||||
|
summary.bytes_written, summary.bytes_read,
|
||||||
|
summary.coverage, summary.sample_count, summary.complete),
|
||||||
|
)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE local_days "
|
||||||
|
"SET tz_offset = ?, utc_start = ?, utc_end = ?, "
|
||||||
|
" bytes_written = ?, bytes_read = ?, "
|
||||||
|
" coverage = ?, sample_count = ?, complete = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(summary.tz_offset, summary.utc_start, summary.utc_end,
|
||||||
|
summary.bytes_written, summary.bytes_read,
|
||||||
|
summary.coverage, summary.sample_count, summary.complete,
|
||||||
|
existing[0]),
|
||||||
|
)
|
||||||
|
created = False
|
||||||
|
# Caller manages transactions (collector commits once after all derivation)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def query_local_day_summary(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
local_date: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Query a stored local-day summary by date.
|
||||||
|
|
||||||
|
Returns a dict with the summary fields, or None if no entry exists.
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete "
|
||||||
|
"FROM local_days WHERE local_date = ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(local_date,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"local_date": row[0],
|
||||||
|
"tz_name": row[1],
|
||||||
|
"tz_offset": row[2],
|
||||||
|
"utc_start": row[3],
|
||||||
|
"utc_end": row[4],
|
||||||
|
"bytes_written": row[5],
|
||||||
|
"bytes_read": row[6],
|
||||||
|
"coverage": row[7],
|
||||||
|
"sample_count": row[8],
|
||||||
|
"complete": row[9],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def query_current_local_day(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
clock_now: datetime,
|
||||||
|
tz_name: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Query the local-day summary for the current local date."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = clock_now.astimezone(local_tz)
|
||||||
|
local_date = local_dt.strftime("%Y-%m-%d")
|
||||||
|
return query_local_day_summary(conn, local_date)
|
||||||
+46
-1
@@ -12,7 +12,7 @@ from typing import Optional
|
|||||||
|
|
||||||
|
|
||||||
# Schema version - increment on each migration
|
# Schema version - increment on each migration
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||||
@@ -193,6 +193,26 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# Local-day activity summaries derived from UTC hour observations.
|
||||||
|
# Each row retains its recorded timezone and UTC boundaries so that
|
||||||
|
# historical summaries survive a system-timezone change (ADR 0010).
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS local_days (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
local_date TEXT NOT NULL, -- e.g. "2026-09-01" in the recorded tz
|
||||||
|
tz_name TEXT NOT NULL, -- POSIX tz name, e.g. "Asia/Kolkata"
|
||||||
|
tz_offset TEXT NOT NULL, -- e.g. "+05:30"
|
||||||
|
utc_start TEXT NOT NULL, -- ISO 8601 UTC: local midnight start
|
||||||
|
utc_end TEXT NOT NULL, -- ISO 8601 UTC: local midnight end
|
||||||
|
bytes_written INTEGER DEFAULT 0,
|
||||||
|
bytes_read INTEGER DEFAULT 0,
|
||||||
|
coverage REAL DEFAULT 0.0,
|
||||||
|
sample_count INTEGER DEFAULT 0,
|
||||||
|
complete BOOLEAN DEFAULT 0,
|
||||||
|
UNIQUE(local_date, tz_name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Metadata table for store state (e.g., legacy import marker)
|
# Metadata table for store state (e.g., legacy import marker)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS store_metadata (
|
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||||
@@ -230,6 +250,31 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
|||||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
||||||
current_version = 2
|
current_version = 2
|
||||||
|
|
||||||
|
# Migration 2→3: add local_days table for local-day activity totals
|
||||||
|
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
|
||||||
|
if current_version < 3:
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
if "local_days" not in tables:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS local_days (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
local_date TEXT NOT NULL,
|
||||||
|
tz_name TEXT NOT NULL,
|
||||||
|
tz_offset TEXT NOT NULL,
|
||||||
|
utc_start TEXT NOT NULL,
|
||||||
|
utc_end TEXT NOT NULL,
|
||||||
|
bytes_written INTEGER DEFAULT 0,
|
||||||
|
bytes_read INTEGER DEFAULT 0,
|
||||||
|
coverage REAL DEFAULT 0.0,
|
||||||
|
sample_count INTEGER DEFAULT 0,
|
||||||
|
complete BOOLEAN DEFAULT 0,
|
||||||
|
UNIQUE(local_date, tz_name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
current_version = 3
|
||||||
|
|
||||||
|
|
||||||
def migrate_to_latest(store_path: Path) -> int:
|
def migrate_to_latest(store_path: Path) -> int:
|
||||||
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
||||||
|
|||||||
@@ -1045,19 +1045,35 @@ class FenrisTuiApp(App):
|
|||||||
grid-rows: auto 10 auto 3;
|
grid-rows: auto 10 auto 3;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
#main-grid.local-day {
|
||||||
|
grid-size: 2 5;
|
||||||
|
grid-rows: auto 10 auto auto 3;
|
||||||
|
}
|
||||||
#main-grid.paused {
|
#main-grid.paused {
|
||||||
grid-size: 2 5;
|
grid-size: 2 5;
|
||||||
grid-rows: auto auto 10 auto 3;
|
grid-rows: auto auto 10 auto 3;
|
||||||
}
|
}
|
||||||
|
#main-grid.paused.local-day {
|
||||||
|
grid-size: 2 6;
|
||||||
|
grid-rows: auto auto 10 auto auto 3;
|
||||||
|
}
|
||||||
#main-grid.constrained {
|
#main-grid.constrained {
|
||||||
grid-size: 1 4;
|
grid-size: 1 4;
|
||||||
grid-columns: 1fr;
|
grid-columns: 1fr;
|
||||||
grid-rows: auto auto auto auto;
|
grid-rows: auto auto auto auto;
|
||||||
}
|
}
|
||||||
|
#main-grid.constrained.local-day {
|
||||||
|
grid-size: 1 5;
|
||||||
|
grid-rows: auto auto auto auto auto;
|
||||||
|
}
|
||||||
#main-grid.constrained.paused {
|
#main-grid.constrained.paused {
|
||||||
grid-size: 1 5;
|
grid-size: 1 5;
|
||||||
grid-rows: auto auto auto auto auto;
|
grid-rows: auto auto auto auto auto;
|
||||||
}
|
}
|
||||||
|
#main-grid.constrained.paused.local-day {
|
||||||
|
grid-size: 1 6;
|
||||||
|
grid-rows: auto auto auto auto auto auto;
|
||||||
|
}
|
||||||
#main-grid.constrained #usage-history {
|
#main-grid.constrained #usage-history {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1087,14 @@ class FenrisTuiApp(App):
|
|||||||
height: auto;
|
height: auto;
|
||||||
min-height: 3;
|
min-height: 3;
|
||||||
}
|
}
|
||||||
|
#local-day {
|
||||||
|
column-span: 2;
|
||||||
|
display: none;
|
||||||
|
border: round #555555;
|
||||||
|
padding: 0 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 3;
|
||||||
|
}
|
||||||
#dashboard-scroll { height: 1fr; }
|
#dashboard-scroll { height: 1fr; }
|
||||||
#headline-band { column-span: 2; height: auto; min-height: 8; }
|
#headline-band { column-span: 2; height: auto; min-height: 8; }
|
||||||
#paused-banner {
|
#paused-banner {
|
||||||
@@ -1141,6 +1165,7 @@ class FenrisTuiApp(App):
|
|||||||
yield DailyBarGraph(id="usage-history", classes="pane")
|
yield DailyBarGraph(id="usage-history", classes="pane")
|
||||||
yield Static("", id="constrained-summary", classes="pane")
|
yield Static("", id="constrained-summary", classes="pane")
|
||||||
yield Static("", id="drive-health", classes="pane")
|
yield Static("", id="drive-health", classes="pane")
|
||||||
|
yield Static("", id="local-day", classes="pane")
|
||||||
yield Static("", id="service-strip", classes="pane")
|
yield Static("", id="service-strip", classes="pane")
|
||||||
yield Static("q Quit TUI", id="quit-rail")
|
yield Static("q Quit TUI", id="quit-rail")
|
||||||
|
|
||||||
@@ -1154,6 +1179,7 @@ class FenrisTuiApp(App):
|
|||||||
self.query_one("#usage-history").border_title = "Usage history"
|
self.query_one("#usage-history").border_title = "Usage history"
|
||||||
self.query_one("#constrained-summary").border_title = "Usage history"
|
self.query_one("#constrained-summary").border_title = "Usage history"
|
||||||
self.query_one("#drive-health").border_title = "Drive"
|
self.query_one("#drive-health").border_title = "Drive"
|
||||||
|
self.query_one("#local-day").border_title = "Local day"
|
||||||
self.query_one("#service-strip").border_title = "Monitoring and actions"
|
self.query_one("#service-strip").border_title = "Monitoring and actions"
|
||||||
self._refresh_timer = self.set_interval(
|
self._refresh_timer = self.set_interval(
|
||||||
self.refresh_interval_s, self.on_refresh_tick
|
self.refresh_interval_s, self.on_refresh_tick
|
||||||
@@ -1277,6 +1303,11 @@ class FenrisTuiApp(App):
|
|||||||
self.query_one("#constrained-summary").update(
|
self.query_one("#constrained-summary").update(
|
||||||
"[dim]Graph needs ≥80×24[/dim]\n" + message
|
"[dim]Graph needs ≥80×24[/dim]\n" + message
|
||||||
)
|
)
|
||||||
|
# Hide local-day widget when no data
|
||||||
|
local_day_widget = self.query_one("#local-day")
|
||||||
|
local_day_widget.styles.display = "none"
|
||||||
|
main_grid = self.query_one("#main-grid")
|
||||||
|
main_grid.remove_class("local-day")
|
||||||
|
|
||||||
def _render_all_regions(self, conn: sqlite3.Connection, comp: StatusComposition) -> None:
|
def _render_all_regions(self, conn: sqlite3.Connection, comp: StatusComposition) -> None:
|
||||||
"""Render all four regions from live store data."""
|
"""Render all four regions from live store data."""
|
||||||
@@ -1355,6 +1386,9 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
self.query_one("#drive-health").update(health_text)
|
self.query_one("#drive-health").update(health_text)
|
||||||
|
|
||||||
|
# --- Local-day totals (issue #90) ---
|
||||||
|
self._render_local_day(conn)
|
||||||
|
|
||||||
def _render_paused_banner(self, comp) -> None:
|
def _render_paused_banner(self, comp) -> None:
|
||||||
"""Show the high-contrast Deliberate disable block only when sanctioned.
|
"""Show the high-contrast Deliberate disable block only when sanctioned.
|
||||||
|
|
||||||
@@ -1388,6 +1422,46 @@ class FenrisTuiApp(App):
|
|||||||
main_grid.remove_class("paused")
|
main_grid.remove_class("paused")
|
||||||
main_grid.refresh(layout=True)
|
main_grid.refresh(layout=True)
|
||||||
|
|
||||||
|
def _render_local_day(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Render the local-day activity totals widget (issue #90)."""
|
||||||
|
try:
|
||||||
|
from .tz_util import detect_system_tz
|
||||||
|
from .local_day import query_current_local_day
|
||||||
|
tz_name = detect_system_tz()
|
||||||
|
local = query_current_local_day(conn, self._clock_now, tz_name)
|
||||||
|
except Exception:
|
||||||
|
local = None
|
||||||
|
|
||||||
|
widget = self.query_one("#local-day")
|
||||||
|
main_grid = self.query_one("#main-grid")
|
||||||
|
|
||||||
|
if local is None:
|
||||||
|
widget.styles.display = "none"
|
||||||
|
main_grid.remove_class("local-day")
|
||||||
|
return
|
||||||
|
|
||||||
|
main_grid.add_class("local-day")
|
||||||
|
widget.styles.display = "block"
|
||||||
|
|
||||||
|
bw = local["bytes_written"]
|
||||||
|
br = local["bytes_read"]
|
||||||
|
partial = "" if local["complete"] else " · totals so far"
|
||||||
|
tz_display = "%s %s" % (local["tz_name"], local["tz_offset"])
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"[bold]%s[/bold] %s%s\n"
|
||||||
|
" W %.3f GB · R %.3f GB · %.0f%% coverage"
|
||||||
|
% (
|
||||||
|
local["local_date"],
|
||||||
|
tz_display,
|
||||||
|
partial,
|
||||||
|
bw / 1e9,
|
||||||
|
br / 1e9,
|
||||||
|
local["coverage"] * 100,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
widget.update(text)
|
||||||
|
|
||||||
def _on_graph_drill(self, day: str) -> None:
|
def _on_graph_drill(self, day: str) -> None:
|
||||||
"""Load hourly data when the graph enters drill-down mode."""
|
"""Load hourly data when the graph enters drill-down mode."""
|
||||||
graph = self.query_one("#usage-history")
|
graph = self.query_one("#usage-history")
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""System timezone detection for local-day activity totals.
|
||||||
|
|
||||||
|
Provides timezone detection (TZ env, /etc/localtime) and offset
|
||||||
|
computation used by local-day derivation. All functions are
|
||||||
|
stateless and safe to call from the collector and TUI reader.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def detect_system_tz() -> str:
|
||||||
|
"""Detect the system timezone name.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``TZ`` environment variable (may be empty or ":UTC")
|
||||||
|
2. Symlink target of ``/etc/localtime``
|
||||||
|
3. Falls back to ``"UTC"``
|
||||||
|
|
||||||
|
Returns a POSIX timezone name like ``"Asia/Kolkata"`` or ``"UTC"``.
|
||||||
|
"""
|
||||||
|
tz = os.environ.get("TZ", "").strip()
|
||||||
|
if tz:
|
||||||
|
return tz.lstrip(":")
|
||||||
|
|
||||||
|
localtime = Path("/etc/localtime")
|
||||||
|
if localtime.is_symlink():
|
||||||
|
target = os.readlink(str(localtime))
|
||||||
|
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
|
||||||
|
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
|
||||||
|
if target.startswith(prefix):
|
||||||
|
return target[len(prefix):]
|
||||||
|
return target
|
||||||
|
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def get_tz_offset_str(dt: datetime, tz_name: str) -> str:
|
||||||
|
"""UTC offset as ``+HH:MM`` or ``-HH:MM`` string for *dt* in *tz_name*."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = dt.astimezone(local_tz)
|
||||||
|
offset = local_dt.utcoffset()
|
||||||
|
total_seconds = int(offset.total_seconds())
|
||||||
|
sign = "+" if total_seconds >= 0 else "-"
|
||||||
|
total_seconds = abs(total_seconds)
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
return "%s%02d:%02d" % (sign, hours, minutes)
|
||||||
@@ -154,7 +154,7 @@ class TestSchemaMigration:
|
|||||||
|
|
||||||
# Migrate
|
# Migrate
|
||||||
steps = migrate_to_latest(db)
|
steps = migrate_to_latest(db)
|
||||||
assert steps == 1
|
assert steps == 2 # v1→v2→v3
|
||||||
|
|
||||||
# Verify data preserved
|
# Verify data preserved
|
||||||
conn = sqlite3.connect(str(db))
|
conn = sqlite3.connect(str(db))
|
||||||
|
|||||||
@@ -244,10 +244,11 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
|||||||
"controller_segments",
|
"controller_segments",
|
||||||
"endurance_baseline",
|
"endurance_baseline",
|
||||||
}
|
}
|
||||||
|
|
||||||
# sqlite_sequence is a system table created by AUTOINCREMENT
|
# sqlite_sequence is a system table created by AUTOINCREMENT
|
||||||
expected_tables.add("sqlite_sequence")
|
expected_tables.add("sqlite_sequence")
|
||||||
expected_tables.add("store_metadata")
|
expected_tables.add("store_metadata")
|
||||||
|
expected_tables.add("local_days")
|
||||||
assert expected_tables == tables
|
assert expected_tables == tables
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Local-day activity derivation tests (issue #90, ADR 0010).
|
||||||
|
|
||||||
|
Verifies that local-day summaries are correctly derived from UTC hour
|
||||||
|
observations, that the schema migration creates the local_days table,
|
||||||
|
that timezone boundaries are preserved across system-timezone changes,
|
||||||
|
and that midnight-spanning intervals are retained as shared evidence
|
||||||
|
without double counting.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: collector.run_collection() → local_day derivation
|
||||||
|
- read side: local_day.query_current_local_day(), query_local_day_summary()
|
||||||
|
- persistence: store.init_store() → local_days table, migration 2→3
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store, SCHEMA_VERSION
|
||||||
|
from fenris.local_day import (
|
||||||
|
derive_local_day_summary,
|
||||||
|
persist_local_day,
|
||||||
|
query_local_day_summary,
|
||||||
|
query_current_local_day,
|
||||||
|
LocalDaySummary,
|
||||||
|
)
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155",
|
||||||
|
"build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw,
|
||||||
|
"data_units_read": dur, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_tree(tmp_path: Path) -> Path:
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text(
|
||||||
|
"nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n"
|
||||||
|
)
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||||
|
active=3600, idle=0, powered_off=0, unknown=0):
|
||||||
|
"""Insert a UTC hour observation."""
|
||||||
|
known = active + idle + powered_off
|
||||||
|
coverage = known / 3600.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, active, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema migration: local_days table is created
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSchemaMigration:
|
||||||
|
"""Store version 3 includes the local_days table."""
|
||||||
|
|
||||||
|
def test_fresh_store_has_local_days(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "local_days" in tables
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_migration_from_v2_creates_local_days(self, tmp_path):
|
||||||
|
db = tmp_path / "obs.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA user_version=2")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "local_days" in tables
|
||||||
|
assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_migration_is_idempotent(self, tmp_path):
|
||||||
|
db = tmp_path / "obs.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.close()
|
||||||
|
# Reopening should not fail
|
||||||
|
conn = init_store(db)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local-day derivation from UTC hours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeriveLocalDay:
|
||||||
|
"""Derive local-day summaries from UTC hour observations."""
|
||||||
|
|
||||||
|
def test_utc_plus_zero(self, tmp_path):
|
||||||
|
"""UTC timezone: local day boundaries = UTC day boundaries."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
ensure_period_open(conn, datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.local_date == "2026-09-01"
|
||||||
|
assert summary.tz_name == "UTC"
|
||||||
|
assert summary.tz_offset == "+00:00"
|
||||||
|
assert summary.bytes_written == 300
|
||||||
|
assert summary.bytes_read == 130
|
||||||
|
assert summary.complete is False # not all 24 hours covered
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_utc_plus_five_thirty(self, tmp_path):
|
||||||
|
"""Asia/Kolkata half-hour offset: UTC boundaries are offset."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Local day 2026-09-01 in +05:30 is UTC [2026-08-31T18:30, 2026-09-01T18:30)
|
||||||
|
# Insert UTC hours that belong to this local day
|
||||||
|
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # 17:30 local
|
||||||
|
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.local_date == "2026-09-01"
|
||||||
|
assert summary.tz_name == "Asia/Kolkata"
|
||||||
|
assert summary.tz_offset == "+05:30"
|
||||||
|
assert summary.bytes_written == 300
|
||||||
|
assert summary.bytes_read == 130
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_midnight_spanning_hour_included(self, tmp_path):
|
||||||
|
"""UTC hour straddling local midnight is included in the local day."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
|
||||||
|
# UTC hour 2026-08-31T18:00 covers 18:00-19:00, straddling the midnight
|
||||||
|
_insert_hour(conn, "2026-08-31T18:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
# The midnight-spanning hour (18:00) is included
|
||||||
|
assert summary.bytes_written == 300
|
||||||
|
assert summary.bytes_read == 130
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_no_hours_returns_none(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
assert summary is None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistence and querying
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLocalDayPersistence:
|
||||||
|
"""Local-day summaries persist and can be queried."""
|
||||||
|
|
||||||
|
def test_persist_and_query(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=300, bytes_read=130,
|
||||||
|
coverage=0.5, sample_count=4, complete=False,
|
||||||
|
)
|
||||||
|
created = persist_local_day(conn, summary)
|
||||||
|
assert created is True
|
||||||
|
|
||||||
|
# Upsert again — should update, not create
|
||||||
|
created2 = persist_local_day(conn, summary)
|
||||||
|
assert created2 is False
|
||||||
|
|
||||||
|
result = query_local_day_summary(conn, "2026-09-01")
|
||||||
|
assert result is not None
|
||||||
|
assert result["bytes_written"] == 300
|
||||||
|
assert result["tz_name"] == "UTC"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_query_nonexistent_returns_none(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
assert query_local_day_summary(conn, "2026-01-01") is None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_query_current_local_day(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=500, bytes_read=200,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||||||
|
result = query_current_local_day(conn, clock, "UTC")
|
||||||
|
assert result is not None
|
||||||
|
assert result["bytes_written"] == 500
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timezone change handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTimezoneChange:
|
||||||
|
"""Historical summaries retain their recorded timezone."""
|
||||||
|
|
||||||
|
def test_old_tz_preserved_after_change(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Old summary in UTC
|
||||||
|
old = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=100, bytes_read=50,
|
||||||
|
coverage=0.5, sample_count=5, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, old)
|
||||||
|
|
||||||
|
# System timezone changes to Asia/Kolkata
|
||||||
|
# Old summary retains UTC
|
||||||
|
result = query_local_day_summary(conn, "2026-09-01")
|
||||||
|
assert result is not None
|
||||||
|
assert result["tz_name"] == "UTC"
|
||||||
|
assert result["tz_offset"] == "+00:00"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_different_tz_same_date_stored_separately(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Two summaries for same date in different timezones
|
||||||
|
for tz, offset in [("UTC", "+00:00"), ("Asia/Kolkata", "+05:30")]:
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name=tz, tz_offset=offset,
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=100, bytes_read=50,
|
||||||
|
coverage=0.5, sample_count=5, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
|
||||||
|
# Both exist independently — query by specific timezone
|
||||||
|
utc_row = conn.execute(
|
||||||
|
"SELECT tz_name, bytes_written FROM local_days "
|
||||||
|
"WHERE local_date = '2026-09-01' AND tz_name = 'UTC'"
|
||||||
|
).fetchone()
|
||||||
|
assert utc_row is not None
|
||||||
|
assert utc_row[0] == "UTC"
|
||||||
|
assert utc_row[1] == 100
|
||||||
|
|
||||||
|
in_row = conn.execute(
|
||||||
|
"SELECT tz_name, bytes_written FROM local_days "
|
||||||
|
"WHERE local_date = '2026-09-01' AND tz_name = 'Asia/Kolkata'"
|
||||||
|
).fetchone()
|
||||||
|
assert in_row is not None
|
||||||
|
assert in_row[0] == "Asia/Kolkata"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Coverage and completeness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLocalDayCompleteness:
|
||||||
|
"""Complete days vs partial current day."""
|
||||||
|
|
||||||
|
def test_incomplete_when_missing_hours(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
_insert_hour(conn, "2026-09-01T12:00:00+00:00", bw=100, br=50)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.complete is False
|
||||||
|
assert summary.coverage < 1.0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_coverage_ratio(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
# 2 hours of known data out of 24 total UTC hours in a UTC day
|
||||||
|
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, active=3600)
|
||||||
|
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, active=3600)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
# 7200 known seconds / 86400 total = ~0.083
|
||||||
|
assert summary.coverage == pytest.approx(7200 / 86400, rel=0.01)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collector integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCollectorIntegration:
|
||||||
|
"""Collector derives local-day summaries during collection."""
|
||||||
|
|
||||||
|
def test_collection_creates_local_day(self, tmp_path, sysfs_tree):
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
# Verify the local day has the expected write/read data
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT bytes_written, bytes_read, complete FROM local_days LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == 50 * 512000 # writes
|
||||||
|
assert row[1] == 30 * 512000 # reads
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DST handling (23/25-hour days)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDSTHandling:
|
||||||
|
"""Local days with non-24-hour durations from DST transitions."""
|
||||||
|
|
||||||
|
def test_short_day_23_hours(self, tmp_path):
|
||||||
|
"""Spring forward: local day is 23 hours."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Simulate a 23-hour day in a timezone with DST
|
||||||
|
# For simplicity, just verify the summary records the correct UTC range
|
||||||
|
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
# US/Eastern springs forward on 2026-03-08
|
||||||
|
# Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d)
|
||||||
|
_insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600)
|
||||||
|
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
|
||||||
|
|
||||||
|
summary = derive_local_day_summary(conn, "US/Eastern", clock)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.local_date == "2026-03-08"
|
||||||
|
# UTC range should be approximately 23 hours
|
||||||
|
utc_start = datetime.fromisoformat(summary.utc_start)
|
||||||
|
utc_end = datetime.fromisoformat(summary.utc_end)
|
||||||
|
duration = (utc_end - utc_start).total_seconds()
|
||||||
|
assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
|
||||||
|
conn.close()
|
||||||
Reference in New Issue
Block a user