app/services/scoring.py (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from statistics import median
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ProbeResult, Tracker
@dataclass(slots=True)
class TrackerMetrics:
uptime_24h: float | None
uptime_7d: float | None
uptime_30d: float | None
latency_median_7d: float | None
latency_p95_7d: float | None
valid_rate_7d: float | None
measurement_count: int
measurement_count_7d: int
tracking_days: float
score: int | None
provisional: bool
def _percentile(sorted_values: list[float], p: float) -> float | None:
if not sorted_values:
return None
if len(sorted_values) == 1:
return sorted_values[0]
k = (len(sorted_values) - 1) * p
f = int(k)
c = min(f + 1, len(sorted_values) - 1)
if f == c:
return sorted_values[f]
return sorted_values[f] + (sorted_values[c] - sorted_values[f]) * (k - f)
def uptime_ratio(results: Sequence[ProbeResult]) -> float | None:
if not results:
return None
ups = sum(1 for r in results if r.status == "up" and r.response_valid)
return ups / len(results)
def latency_score(median_ms: float | None) -> int:
if median_ms is None:
return 0
if median_ms <= 150:
return 20
if median_ms <= 300:
return 16
if median_ms <= 600:
return 10
if median_ms <= 1000:
return 5
return 0
def confidence_score(count: int) -> int:
if count < 3:
return 0
if count < 10:
return 1
if count < 20:
return 3
return 5
def compute_score(
*,
uptime_7d: float | None,
latency_median_7d: float | None,
valid_rate_7d: float | None,
measurement_count: int,
) -> int | None:
if measurement_count < 3:
return None
avail = int(round((uptime_7d or 0.0) * 60))
lat = latency_score(latency_median_7d)
valid = int(round((valid_rate_7d or 0.0) * 15))
conf = confidence_score(measurement_count)
return max(0, min(100, avail + lat + valid + conf))
def metrics_from_results(
tracker: Tracker, results: Sequence[ProbeResult], *, now: datetime | None = None
) -> TrackerMetrics:
now = now or datetime.now(UTC)
# Normalize naive datetimes from SQLite
def _aware(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
first_seen = _aware(tracker.first_seen_at)
cut_24h = now - timedelta(hours=24)
cut_7d = now - timedelta(days=7)
cut_30d = now - timedelta(days=30)
aware_results = []
for r in results:
# mutate view via checked_at comparison with aware times
checked = _aware(r.checked_at)
aware_results.append((checked, r))
r24 = [r for checked, r in aware_results if checked >= cut_24h]
r7 = [r for checked, r in aware_results if checked >= cut_7d]
r30 = [r for checked, r in aware_results if checked >= cut_30d]
latencies = sorted(r.latency_ms for r in r7 if r.latency_ms is not None and r.response_valid)
valid_rate = None
if r7:
valid_rate = sum(1 for r in r7 if r.response_valid) / len(r7)
count = len(results)
provisional = count < 10
score = compute_score(
uptime_7d=uptime_ratio(r7),
latency_median_7d=float(median(latencies)) if latencies else None,
valid_rate_7d=valid_rate,
measurement_count=count,
)
tracking_days = (now - first_seen).total_seconds() / 86400.0
return TrackerMetrics(
uptime_24h=uptime_ratio(r24),
uptime_7d=uptime_ratio(r7),
uptime_30d=uptime_ratio(r30),
latency_median_7d=float(median(latencies)) if latencies else None,
latency_p95_7d=_percentile(list(latencies), 0.95),
valid_rate_7d=valid_rate,
measurement_count=count,
measurement_count_7d=len(r7),
tracking_days=tracking_days,
score=score,
provisional=provisional,
)
async def load_metrics(session: AsyncSession, tracker: Tracker) -> TrackerMetrics:
result = await session.execute(
select(ProbeResult)
.where(ProbeResult.tracker_id == tracker.id)
.order_by(ProbeResult.checked_at.desc())
.limit(5000)
)
rows = list(result.scalars().all())
return metrics_from_results(tracker, rows)
|