mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 21:32:04 +03:00
speed: throughput math, cfL4 header parsing, bufferbloat delta
This commit is contained in:
parent
8d38788671
commit
5eec7aacab
2 changed files with 144 additions and 0 deletions
58
src/netcheck/speed.py
Normal file
58
src/netcheck/speed.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from netcheck.models import CfL4Stats
|
||||
from netcheck.stats import percentile
|
||||
|
||||
_CFL4_RE = re.compile(r'cfL4\s*;\s*desc\s*=\s*"?\??(?P<query>[^",]*)"?')
|
||||
|
||||
|
||||
def mbps(bytes_transferred: int, seconds: float) -> float:
|
||||
if seconds <= 0 or bytes_transferred <= 0:
|
||||
return 0.0
|
||||
return (bytes_transferred * 8) / seconds / 1_000_000
|
||||
|
||||
|
||||
def throughput_from_samples(samples: list[tuple[int, float]], p: float = 90.0) -> float:
|
||||
rates = [mbps(size, duration) for size, duration in samples]
|
||||
rates = [rate for rate in rates if rate > 0]
|
||||
if not rates:
|
||||
return 0.0
|
||||
return round(percentile(rates, p), 3)
|
||||
|
||||
|
||||
def _as_int(value: str | None) -> int | None:
|
||||
try:
|
||||
return int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _us_to_ms(value: str | None) -> float | None:
|
||||
number = _as_int(value)
|
||||
return None if number is None else round(number / 1000.0, 3)
|
||||
|
||||
|
||||
def parse_server_timing_cfl4(header: str) -> CfL4Stats | None:
|
||||
match = _CFL4_RE.search(header or "")
|
||||
if not match:
|
||||
return None
|
||||
fields = {k: v[0] for k, v in parse_qs(match.group("query"), keep_blank_values=True).items()}
|
||||
return CfL4Stats(
|
||||
rtt_ms=_us_to_ms(fields.get("rtt")),
|
||||
min_rtt_ms=_us_to_ms(fields.get("min_rtt")),
|
||||
rtt_var_ms=_us_to_ms(fields.get("rtt_var")),
|
||||
delivery_rate_bps=_as_int(fields.get("delivery_rate")),
|
||||
cwnd=_as_int(fields.get("cwnd")),
|
||||
unsent_bytes=_as_int(fields.get("unsent_bytes")),
|
||||
recv_bytes=_as_int(fields.get("recv_bytes")),
|
||||
)
|
||||
|
||||
|
||||
def bufferbloat_delta(idle_rtt_ms: float | None, loaded_rtts_ms: list[float]) -> float | None:
|
||||
if idle_rtt_ms is None or not loaded_rtts_ms:
|
||||
return None
|
||||
loaded = percentile(sorted(loaded_rtts_ms), 95.0)
|
||||
return round(max(0.0, loaded - idle_rtt_ms), 3)
|
||||
86
tests/test_speed_math.py
Normal file
86
tests/test_speed_math.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from netcheck.speed import bufferbloat_delta, mbps, parse_server_timing_cfl4, throughput_from_samples
|
||||
|
||||
|
||||
def test_mbps_converts_bytes_and_seconds():
|
||||
assert mbps(1_000_000, 1.0) == pytest.approx(8.0)
|
||||
assert mbps(12_500_000, 1.0) == pytest.approx(100.0)
|
||||
assert mbps(25_000_000, 2.0) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_mbps_of_a_zero_or_negative_interval_is_zero_not_infinity():
|
||||
# Zero-duration timing math is exactly where inf leaks into the JSON report.
|
||||
assert mbps(1_000_000, 0.0) == 0.0
|
||||
assert mbps(1_000_000, -1.0) == 0.0
|
||||
|
||||
|
||||
def test_mbps_of_no_bytes_is_zero():
|
||||
assert mbps(0, 1.5) == 0.0
|
||||
|
||||
|
||||
def test_throughput_uses_the_ninetieth_percentile_of_the_samples():
|
||||
samples = [(1_000_000, 1.0), (1_000_000, 0.5), (1_000_000, 0.4), (1_000_000, 0.25)]
|
||||
# Per-sample Mbps: 8, 16, 20, 32 -> p90 interpolates to 28.4
|
||||
assert throughput_from_samples(samples) == pytest.approx(28.4)
|
||||
|
||||
|
||||
def test_throughput_of_a_single_sample_is_that_sample():
|
||||
assert throughput_from_samples([(12_500_000, 1.0)]) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_throughput_of_no_samples_is_zero():
|
||||
assert throughput_from_samples([]) == 0.0
|
||||
|
||||
|
||||
def test_cfl4_header_is_parsed_into_typed_stats():
|
||||
header = (
|
||||
'cfL4;desc="?proto=tcp&rtt=12345&min_rtt=11000&rtt_var=1500&sent=100&recv=200'
|
||||
'&lost=0&retrans=0&sent_bytes=1000&recv_bytes=1048576&delivery_rate=35000000'
|
||||
'&cwnd=42&unsent_bytes=0&cid=abcdef&ts=1&x=0"'
|
||||
)
|
||||
stats = parse_server_timing_cfl4(header)
|
||||
assert stats is not None
|
||||
assert stats.rtt_ms == pytest.approx(12.345)
|
||||
assert stats.min_rtt_ms == pytest.approx(11.0)
|
||||
assert stats.rtt_var_ms == pytest.approx(1.5)
|
||||
assert stats.delivery_rate_bps == 35000000
|
||||
assert stats.cwnd == 42
|
||||
assert stats.unsent_bytes == 0
|
||||
assert stats.recv_bytes == 1048576
|
||||
|
||||
|
||||
def test_cfl4_parsing_picks_its_entry_out_of_a_multi_metric_header():
|
||||
header = 'cfRequestDuration;dur=42.1, cfL4;desc="?proto=tcp&rtt=9000&cwnd=10", cfCacheStatus;desc="HIT"'
|
||||
stats = parse_server_timing_cfl4(header)
|
||||
assert stats is not None
|
||||
assert stats.rtt_ms == pytest.approx(9.0)
|
||||
assert stats.cwnd == 10
|
||||
assert stats.delivery_rate_bps is None
|
||||
|
||||
|
||||
def test_cfl4_parsing_of_a_header_without_the_entry_is_none():
|
||||
assert parse_server_timing_cfl4("cfCacheStatus;desc=HIT") is None
|
||||
assert parse_server_timing_cfl4("") is None
|
||||
|
||||
|
||||
def test_cfl4_parsing_survives_unexpected_values():
|
||||
stats = parse_server_timing_cfl4('cfL4;desc="?proto=tcp&rtt=notanumber&cwnd=7"')
|
||||
assert stats is not None
|
||||
assert stats.rtt_ms is None
|
||||
assert stats.cwnd == 7
|
||||
|
||||
|
||||
def test_bufferbloat_delta_is_the_rise_over_the_idle_baseline():
|
||||
assert bufferbloat_delta(12.0, [15.0, 60.0, 200.0, 210.0]) == pytest.approx(196.5)
|
||||
|
||||
|
||||
def test_bufferbloat_delta_never_goes_negative():
|
||||
assert bufferbloat_delta(50.0, [10.0, 12.0, 11.0]) == 0.0
|
||||
|
||||
|
||||
def test_bufferbloat_delta_needs_both_a_baseline_and_samples():
|
||||
assert bufferbloat_delta(None, [10.0]) is None
|
||||
assert bufferbloat_delta(12.0, []) is None
|
||||
Loading…
Add table
Reference in a new issue