File size: 8,429 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# -*- coding: utf-8 -*-
"""Khoá harness eval synthetic (BG26 bước 3): GT mapping dấu trục (đúng
chiều probe a>0 → side-L), khoảng cách góc vòng tròn, null tính riêng không
pha vào accuracy, và baseline_predict chạy trọn trên một cú bẩn tự dựng."""

from __future__ import annotations

import math
import sys
from pathlib import Path

import numpy as np
import pytest

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "broadcast"))

import eval_baseline_synth as ev  # noqa: E402
import gen_synth_shots as gs  # noqa: E402


def _shot(**kw):
    base = {"label_v0": 2.0, "label_phi": 90.0, "label_a": 0.0,
            "label_b": 0.0, "v0_ball": 2.6, "phi_ball": 90.0,
            "identifiable": 1, "fps": 30, "upconvert": 0, "scratch": 0,
            "shot_idx": 0}
    base.update(kw)
    return base


def test_gt_mapping_dau_truc():
    """a>0 = đánh mép TRÁI → side-L (probe settle-chord BG26b, khớp docstring
    _read_spin); b>0 = follow. Ngưỡng nhỏ B_STUN_MAX/A_SIDE_MIN → stun/
    neutral."""
    assert ev.gt_axes(_shot(label_b=0.3, label_a=0.3)) == ("follow", "side-L")
    assert ev.gt_axes(_shot(label_b=-0.3, label_a=-0.3)) == ("draw", "side-R")
    assert ev.gt_axes(_shot(label_b=0.05, label_a=0.05)) == ("stun", None)
    assert ev.gt_axes(_shot(label_b=ev.B_STUN_MAX + 1e-6,
                            label_a=-ev.A_SIDE_MIN - 1e-6)) \
        == ("follow", "side-R")


def test_khoang_cach_goc_vong_tron():
    assert ev.circ_diff_deg(359.0, 1.0) == pytest.approx(2.0)
    assert ev.circ_diff_deg(1.0, 359.0) == pytest.approx(2.0)
    assert ev.circ_diff_deg(180.0, 0.0) == pytest.approx(180.0)
    assert ev.circ_diff_deg(90.0, 90.0) == 0.0


def test_summarize_null_tinh_rieng():
    """2 cú đọc được (1 đúng 1 sai) + 2 cú null → acc = 1/2 (KHÔNG phải
    1/4), null_rate = 1/2 — null không đổ vào sai (BRIEF bước 3.2)."""
    recs = []
    for i, (pred_side, gt_side) in enumerate([
            ("side-L", "side-L"), ("side-R", "side-L"),
            ("", "side-L"), ("", "side-L")]):
        recs.append({"shot_idx": i, "label_v0": 2.0, "label_phi": 0.0,
                     "label_a": 0.3, "label_b": 0.0, "v0_ball": 2.6,
                     "phi_ball": 0.0, "identifiable": 1, "fps": 30,
                     "upconvert": 0, "scratch": 0, "gt_vert": "stun",
                     "gt_side": gt_side, "pred_v0": None,
                     "pred_v0_raw": None, "pred_phi": None,
                     "pred_vert": "", "pred_side": pred_side,
                     "pred_conf": "", "n_collisions": 1, "v0_relerr": None,
                     "v0_relerr_raw": None, "dphi": None})
    s = ev.summarize(recs)
    assert s["side_acc"] == pytest.approx(0.5)
    assert s["side_null_rate"] == pytest.approx(0.5)
    assert s["n_side_scored"] == 2
    assert s["v0_null_rate"] == 1.0


def _rec(i, ident=1, gt_side="side-L", gt_vert="follow",
         pred_side="side-L", pred_vert="follow", dphi=1.0, v0r=0.05):
    return {"shot_idx": i, "identifiable": ident, "gt_side": gt_side,
            "gt_vert": gt_vert, "pred_side": pred_side,
            "pred_vert": pred_vert, "dphi": dphi, "v0_relerr_raw": v0r}


def test_write_gate_csv_tap_con_va_bar(tmp_path):
    """Bảng gate BG27: tập con side/vert lấy đúng shot_idx baseline đọc
    được; bar tuyệt đối chấm trên net; số baseline TÍNH LẠI từ CSV; lệch
    số mốc tập con → DỪNG (SystemExit)."""
    import csv as _csv
    base_csv = tmp_path / "base.csv"
    fields = ["shot_idx", "identifiable", "gt_side", "gt_vert",
              "pred_side", "pred_vert", "dphi", "v0_relerr_raw"]
    base_rows = [
        # cú 0: baseline đọc side ĐÚNG + vert SAI
        {"shot_idx": 0, "identifiable": 1, "gt_side": "side-L",
         "gt_vert": "follow", "pred_side": "side-L", "pred_vert": "stun",
         "dphi": 50.0, "v0_relerr_raw": 0.5},
        # cú 1: baseline null cả hai trục (không vào tập con)
        {"shot_idx": 1, "identifiable": 1, "gt_side": "side-R",
         "gt_vert": "draw", "pred_side": "", "pred_vert": "",
         "dphi": 80.0, "v0_relerr_raw": 0.7},
        # cú 2: non-identifiable — không được vào tập con dù có pred
        {"shot_idx": 2, "identifiable": 0, "gt_side": "side-L",
         "gt_vert": "stun", "pred_side": "side-L", "pred_vert": "stun",
         "dphi": "", "v0_relerr_raw": ""},
    ]
    with open(base_csv, "w", newline="", encoding="utf-8") as f:
        w = _csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        w.writerows(base_rows)

    # net: cú 0 side đúng vert đúng; cú 1 side sai vert đúng; cú 2 bỏ qua
    recs = [_rec(0), _rec(1, gt_side="side-R", gt_vert="draw",
                          pred_side="side-L", pred_vert="draw",
                          dphi=3.0, v0r=0.15),
            _rec(2, ident=0, dphi=None, v0r=None)]
    rows = ev.write_gate_csv(recs, base_csv, tmp_path / "gate.csv",
                             n_side=1, n_vert=1)
    g = {r["metric"]: r for r in rows}
    # tập con side/vert = đúng 1 cú (shot 0); net đúng cả hai trên cú đó
    assert g["side_acc"]["net_on_subset"] == 1.0
    assert g["vert_acc"]["net_on_subset"] == 1.0
    assert g["vert_acc"]["baseline"] == 0.0        # baseline sai vert cú 0
    # bar tuyệt đối: side toàn identifiable = 1/2; vert = 2/2
    assert g["side_acc"]["net_abs"] == pytest.approx(0.5)
    assert g["vert_acc"]["net_abs"] == pytest.approx(1.0)
    assert g["side_acc"]["dat_abs"] == 0
    assert g["vert_acc"]["dat_abs"] == 1
    # median net trên all: dphi (1+3)/2 = 2 → ĐẠT bar ≤2; baseline 65 → thắng
    assert g["dphi_med_deg"]["net_abs"] == pytest.approx(2.0)
    assert g["dphi_med_deg"]["dat_abs"] == 1 and \
        g["dphi_med_deg"]["dat_beat"] == 1
    # lệch số mốc tập con → DỪNG
    with pytest.raises(SystemExit):
        ev.write_gate_csv(recs, base_csv, tmp_path / "gate2.csv",
                          n_side=630, n_vert=186)
    assert (tmp_path / "gate.csv").exists()


def test_lat_target_matched():
    """Filter lát target-matched (BRIEF 28 bước 1): chạm đầu = min(t_first_bb,
    t_first_cush) và cú thuộc lát khi ≥ 0.3s; không chạm (cả hai NaN) thì
    KHÔNG thuộc lát — nó không có chạm đầu."""
    nan = float("nan")
    assert ev.t_first_contact_s(_shot(t_first_bb=0.5, t_first_cush=0.8)) == 0.5
    assert math.isnan(ev.t_first_contact_s(_shot(t_first_bb=nan,
                                                 t_first_cush=nan)))
    # cả hai chạm: lấy MIN — bb 0.25 kéo cú ra khỏi lát dù cush 0.6
    assert not ev.is_target_matched(_shot(t_first_bb=0.25, t_first_cush=0.6))
    assert ev.is_target_matched(_shot(t_first_bb=0.5, t_first_cush=0.8))
    # chỉ một loại chạm cũng tính
    assert ev.is_target_matched(_shot(t_first_bb=nan, t_first_cush=0.31))
    assert not ev.is_target_matched(_shot(t_first_bb=0.1, t_first_cush=nan))
    # biên: đúng 0.3 thuộc lát (>=); không chạm → ngoài lát
    assert ev.is_target_matched(_shot(t_first_bb=0.3, t_first_cush=nan))
    assert not ev.is_target_matched(_shot(t_first_bb=nan, t_first_cush=nan))


def test_baseline_predict_tren_cu_ban_tu_dung(monkeypatch):
    """Integration nhỏ: cú thẳng 2 m/s hướng +y làm bẩn bằng chính
    corrupt_shot → baseline đọc v0_raw ≈ 2, phi ≈ 90, quy đổi v0 = raw/K_V;
    không va chạm → spin null cả hai trục."""
    monkeypatch.setattr(gs, "GAP_RATE_PER_BALL", 0.0)
    rng = np.random.default_rng(42)
    t_sim = np.arange(0.0, 1.2, 0.01)
    sp = np.maximum(2.0 - 0.35 * t_sim, 0.0)          # giảm tốc lăn nhẹ
    y = 0.4 + np.cumsum(sp) * 0.01
    xy_sim = np.stack([np.full_like(t_sim, 0.6), y], axis=1)[:, None, :]
    out = gs.corrupt_shot(rng, t_sim, xy_sim, np.array([np.inf]),
                          50, False, 0.8, 0.0)
    shot = _shot(n_frames=len(out["t"]), n_balls=1)
    shot.update({"t": out["t"], "xy": out["xy"], "covered": out["covered"],
                 "img_diff": out["img_diff"]})
    p = ev.baseline_predict(shot)
    assert p["v0_raw"] == pytest.approx(2.0, abs=0.25)
    assert p["v0"] == pytest.approx(p["v0_raw"] / ev.K_V)
    assert p["phi"] == pytest.approx(90.0, abs=5.0)
    assert p["vert"] is None and p["side"] is None