"""Tests for the eval metrics module.""" from __future__ import annotations import numpy as np import pytest class TestKinematics: def test_compute_speed_constant_velocity(self): """Constant-velocity trajectory has constant speed.""" from tools.eval.metrics import compute_speed # 10 points, moving 10 px in 100 ms each step → speed = 0.1 px/ms xs = np.arange(0, 100, 10, dtype=float) ys = np.zeros(10, dtype=float) ts = np.arange(0, 1000, 100, dtype=float) v = compute_speed(xs, ys, ts) # All speeds should be ≈ 0.1 px/ms assert v.shape == (9,) # n-1 differences np.testing.assert_allclose(v, 0.1, rtol=1e-4) def test_compute_speed_handles_zero_dt(self): """Adjacent points with same timestamp must not produce NaN/inf.""" from tools.eval.metrics import compute_speed xs = np.array([0.0, 10.0, 20.0]) ys = np.array([0.0, 0.0, 0.0]) ts = np.array([0.0, 0.0, 100.0]) # zero dt between [0] and [1] v = compute_speed(xs, ys, ts) assert np.isfinite(v).all() def test_compute_acceleration(self): """Linearly increasing speed → constant acceleration.""" from tools.eval.metrics import compute_acceleration # speeds: 0.1, 0.2, 0.3, 0.4 over dt = 100 ms each → a = 0.001 px/ms² speeds = np.array([0.1, 0.2, 0.3, 0.4]) ts = np.array([100.0, 200.0, 300.0, 400.0]) a = compute_acceleration(speeds, ts) np.testing.assert_allclose(a, 0.001, rtol=1e-4) def test_compute_jerk(self): from tools.eval.metrics import compute_jerk # accelerations: 0.001, 0.002, 0.003 over dt = 100 ms → j = 0.00001 accels = np.array([0.001, 0.002, 0.003]) ts = np.array([200.0, 300.0, 400.0]) j = compute_jerk(accels, ts) np.testing.assert_allclose(j, 1e-5, rtol=1e-4) class TestStatsSummary: def test_compute_stats_returns_expected_keys(self): from tools.eval.metrics import compute_stats x = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) s = compute_stats(x) assert "mean" in s assert "std" in s assert "cv" in s assert "p25" in s assert "p50" in s assert "p75" in s assert "p95" in s def test_cv_for_constant_is_zero(self): from tools.eval.metrics import compute_stats x = np.full(10, 3.0) s = compute_stats(x) assert s["cv"] == 0.0 class TestFftSpectrum: def test_finds_dominant_frequency(self): """A pure 8 Hz signal should have its peak near 8 Hz.""" from tools.eval.metrics import fft_spectrum # Sample at 100 Hz for 1 second sample_rate_hz = 100.0 ts_ms = np.arange(0, 1000, 1000 / sample_rate_hz) signal = np.sin(2 * np.pi * 8 * ts_ms / 1000) # 8 Hz sine freqs, mags = fft_spectrum(signal, sample_rate_hz) peak_freq = freqs[np.argmax(mags)] assert abs(peak_freq - 8.0) < 1.0 # within 1 Hz def test_returns_only_positive_frequencies(self): from tools.eval.metrics import fft_spectrum signal = np.random.randn(64) freqs, mags = fft_spectrum(signal, 50.0) assert (freqs >= 0).all() assert len(freqs) == len(mags) class TestKlDivergence: def test_identical_distributions_zero_kl(self): """KL(p, p) ≈ 0.""" from tools.eval.metrics import kl_divergence_histograms rng = np.random.default_rng(42) x = rng.normal(0, 1, 5000) y = rng.normal(0, 1, 5000) kl = kl_divergence_histograms(x, y, bins=50) assert kl < 0.05 def test_different_distributions_positive_kl(self): """Different means → positive KL.""" from tools.eval.metrics import kl_divergence_histograms rng = np.random.default_rng(42) x = rng.normal(0, 1, 5000) y = rng.normal(3, 1, 5000) kl = kl_divergence_histograms(x, y, bins=50) assert kl > 0.5 def test_handles_disjoint_supports(self): """No NaN even when histograms have non-overlapping bins.""" from tools.eval.metrics import kl_divergence_histograms x = np.array([1.0, 1.1, 1.2, 1.3, 1.4]) y = np.array([10.0, 10.1, 10.2, 10.3, 10.4]) kl = kl_divergence_histograms(x, y, bins=10) assert np.isfinite(kl) class TestReportGeneration: def test_generates_report_md(self, tmp_path): """Smoke test: build_report writes an MD file with all expected sections.""" from tools.eval.report import build_report # Synthetic generated traces (3 traces, 50 points each) rng = np.random.default_rng(0) gen_traces = [] for _ in range(3): xs = np.cumsum(rng.uniform(0, 5, 50)) ys = np.cumsum(rng.uniform(-1, 1, 50)) ts = np.cumsum(rng.uniform(5, 20, 50)) gen_traces.append({"xs": xs, "ys": ys, "ts": ts}) # Synthetic reference ref_traces = [] for _ in range(5): xs = np.cumsum(rng.uniform(0, 5, 50)) ys = np.cumsum(rng.uniform(-1, 1, 50)) ts = np.cumsum(rng.uniform(5, 20, 50)) ref_traces.append({"xs": xs, "ys": ys, "ts": ts}) out_md = tmp_path / "report.md" build_report( generated_traces=gen_traces, reference_traces=ref_traces, output_md=out_md, tag="smoke-test", model_dir="/fake/model/dir", ) assert out_md.exists() content = out_md.read_text(encoding="utf-8") assert "# Eval Report" in content assert "smoke-test" in content assert "速度" in content or "speed" in content.lower() assert "FFT" in content.upper() # PNG plots should exist next to MD plot_dir = tmp_path / "plots" assert plot_dir.exists() assert any(plot_dir.iterdir())