1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -0,0 +1,88 @@
|
||||
"""Five-minute extraction completeness, distinct from continuous observed coverage."""
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import math
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .config import utc_text
|
||||
|
||||
BUCKET_MS = 300_000
|
||||
MAX_SOURCE_AGE_MS = 90_000
|
||||
|
||||
|
||||
def milliseconds(value):
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("Observation timestamp has no timezone")
|
||||
return round(parsed.timestamp() * 1000)
|
||||
|
||||
|
||||
def _finite(value):
|
||||
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value)
|
||||
|
||||
|
||||
def sample_coverage(sample):
|
||||
observed = milliseconds(sample["observed_at"])
|
||||
if "history_interval" in sample:
|
||||
# Cloud history has measured interval energy, but no cumulative meter or relay history.
|
||||
return (sample["id"], sample["source_id"], observed, observed, 1, observed, 0, observed, 0)
|
||||
readings = sample.get("readings", {})
|
||||
result = [sample["id"], sample["source_id"], observed]
|
||||
for source, fields in [
|
||||
("powerflow", ["production_ac_w", "consumption_w", "grid_import_w", "grid_export_w"]),
|
||||
("meter", ["grid_import_total_wh", "grid_export_total_wh"]),
|
||||
("boiler", []),
|
||||
]:
|
||||
try:
|
||||
stamp = milliseconds(sample["source_timestamps"][source])
|
||||
valid = source not in sample.get("errors", {}) and abs(stamp - observed) <= MAX_SOURCE_AGE_MS
|
||||
except (KeyError, ValueError, TypeError, AttributeError):
|
||||
stamp, valid = observed, False
|
||||
valid = valid and all(_finite(readings.get(field)) for field in fields)
|
||||
if source == "boiler":
|
||||
valid = valid and isinstance(readings.get("boiler_enabled"), bool)
|
||||
result.extend([stamp if valid else observed, int(valid)])
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def daily_coverage(rows, timezone_name, days, now=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
zone = ZoneInfo(timezone_name)
|
||||
today = now.astimezone(zone).date()
|
||||
first = today - timedelta(days=days)
|
||||
upper = round(now.timestamp() * 1000)
|
||||
slots_by_time = {}
|
||||
observations = Counter()
|
||||
for row in rows:
|
||||
observed = row[0]
|
||||
if observed > upper + 1000:
|
||||
continue
|
||||
date = datetime.fromtimestamp(observed / 1000, zone).date().isoformat()
|
||||
observations[date] += 1
|
||||
bucket = observed // BUCKET_MS * BUCKET_MS
|
||||
mask = sum(1 << index for index in range(3) if row[2 + index * 2])
|
||||
slots_by_time[bucket] = slots_by_time.get(bucket, 0) | mask
|
||||
result = []
|
||||
for offset in range(days + 1):
|
||||
date = today - timedelta(days=offset)
|
||||
start = datetime.combine(date, datetime.min.time(), zone)
|
||||
end = datetime.combine(date + timedelta(days=1), datetime.min.time(), zone)
|
||||
start_ms = round(start.timestamp() * 1000)
|
||||
expected = round((end.timestamp() - start.timestamp()) * 1000) // BUCKET_MS
|
||||
elapsed = expected if date < today else max(0, min(expected, (upper - start_ms) // BUCKET_MS))
|
||||
slots = []
|
||||
for index in range(expected):
|
||||
slots.append(slots_by_time.get(start_ms + index * BUCKET_MS, 0) if index < elapsed else -1)
|
||||
complete = sum(mask == 7 for mask in slots)
|
||||
count = observations[date.isoformat()]
|
||||
status = "in_progress" if date == today else "complete" if complete == expected else "partial" if count else "missing"
|
||||
result.append({
|
||||
"date": date.isoformat(), "day_start": utc_text(start), "expected": expected, "elapsed": elapsed,
|
||||
"complete": complete, "missing": elapsed - complete, "samples": count, "status": status,
|
||||
"energy_complete": sum(mask >= 0 and bool(mask & 1) for mask in slots),
|
||||
"meter_complete": sum(mask >= 0 and bool(mask & 2) for mask in slots),
|
||||
"boiler_complete": sum(mask >= 0 and bool(mask & 4) for mask in slots),
|
||||
"slots": slots,
|
||||
})
|
||||
return result
|
||||
Reference in new issue
Block a user