diff --git a/src/netcheck/speed.py b/src/netcheck/speed.py new file mode 100644 index 0000000..39f3608 --- /dev/null +++ b/src/netcheck/speed.py @@ -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[^",]*)"?') + + +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) diff --git a/tests/test_speed_math.py b/tests/test_speed_math.py new file mode 100644 index 0000000..6b63ac6 --- /dev/null +++ b/tests/test_speed_math.py @@ -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