poolcoach / tests /test_broadcast_analytics.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
24.9 kB
"""Unit test tầng giải tích broadcast (bàn giao 24, Bước 1.3) — thuần numpy,
KHÔNG cv2/torch: chỉ đụng ``analyze_track`` + các hàm thuần của
``poolcoach_cv.broadcast`` (phần ``analyze_clip`` cần YOLO/cv2 nghiệm thu
tay ở gate G-24.3 end-to-end, cùng lý do G3 của scan).
Hai nguồn dữ liệu:
- Track TỔNG HỢP tự dựng: biết trước V0/φ/va chạm nên assert được số đúng,
và tiêm frame trùng kiểu 25fps-upconvert để khoá luật dedup (HANDOFF 23
Bất ngờ 1 — dip giả về 0 mỗi 6 bước nếu quên).
- Track THẬT rút gọn từ shot_01 P0 (fixtures/bb9_shot01_track_cut.csv —
61 dòng CSV, KHÔNG commit frame): còn đủ still → đánh (~2.6 m/s, hướng
~200°) → chạm bi lúc ~2.0s → lăn. Số kỳ vọng đọc từ chính track đã được
mắt kiểm ở bàn giao 23 (overlay shot_01, Danh xác nhận).
"""
from __future__ import annotations
import csv
import math
from pathlib import Path
import pytest
from poolcoach_cv import broadcast as bc
FIXTURE = Path(__file__).parent / "fixtures" / "bb9_shot01_track_cut.csv"
DT = 1 / 30.0
def make_rows(legs, *, still_s=0.5, dup_every=None, start=(0.6, 0.5)):
"""Dựng track tổng hợp: đứng yên ``still_s`` rồi đi từng ``leg``
(heading_deg, speed_mps, dur_s). ``dup_every``: chèn frame TRÙNG kiểu
25fps-upconvert — lặp vị trí + img_diff ~0, PTS vẫn tiến đều (đúng bẫy
thật: PTS frame trùng hợp lệ, chỉ img_diff lộ)."""
rows = []
t = 0.0
x, y = start
moving = False
def push(px, py, diff):
nonlocal t
rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t, 6),
"covered": 1, "table_x_m": px, "table_y_m": py,
"img_diff": diff})
t += DT
for _ in range(int(round(still_s / DT))):
push(x, y, -1.0 if not rows else 0.2)
hd = 0.0
sp = 0.0
for hd, sp, dur in legs:
moving = True
for _ in range(int(round(dur / DT))):
x += sp * DT * math.cos(math.radians(hd))
y += sp * DT * math.sin(math.radians(hd))
push(x, y, 0.8)
# lăn chậm dần 2 m/s² (vật lý thật — measure_p0 đo 0.1–1.5) rồi mới
# dừng: dừng ĐỘT NGỘT là một cú full-hit và tầng speed-drop SẼ bắt nó
while moving and sp > 0.05:
sp = max(sp - 2.0 * DT, 0.0)
x += sp * DT * math.cos(math.radians(hd))
y += sp * DT * math.sin(math.radians(hd))
push(x, y, 0.6)
for _ in range(6):
push(x, y, 0.2 if moving else 0.15)
if dup_every:
out, k = [], 0
for r in rows:
out.append(dict(r))
k += 1
if k % dup_every == 0:
out.append({**r, "img_diff": 0.01})
for i, r in enumerate(out): # PTS đều lại như stream CFR thật
r["frame_file"] = f"{i:05d}"
r["t_s"] = round(i * DT, 6)
rows = out
return rows
# ------------------------------------------------------------- tổng hợp
def test_kink_v0_phi_va_dedup_frame_trung():
"""Cú thẳng 2 m/s hướng +y, gãy 60° sang 150°: V0 ≈ 2, φ ≈ 90, đúng
MỘT va chạm tại chỗ gãy — và kết quả Y HỆT khi track bị chèn frame
trùng chu kỳ 6 (dedup phải nuốt trọn, không lệch số)."""
legs = [(90.0, 2.0, 0.4), (150.0, 1.6, 0.4)]
clean = bc.analyze_track(make_rows(legs))
duped = bc.analyze_track(make_rows(legs, dup_every=6))
for out in (clean, duped):
m = out["metrics"]
assert m["v0_mps"] == pytest.approx(2.0, abs=0.15)
assert m["phi_deg"] == pytest.approx(90.0, abs=5.0)
assert m["n_collisions"] == 1
c = out["collisions"][0]
assert "kink" in c["kind"]
# gãy tại (0.6, 0.5 + 2.0×0.4) — cho phép lệch 1 bước frame
assert c["x_m"] == pytest.approx(0.6, abs=0.08)
assert c["y_m"] == pytest.approx(1.3, abs=0.08)
assert c["t_s"] == pytest.approx(0.5 + 0.4, abs=0.1)
assert clean["metrics"]["n_dup_frames"] == 0
assert duped["metrics"]["n_dup_frames"] > 0
# số giải tích không được lệch giữa hai biến thể
assert duped["metrics"]["v0_mps"] == pytest.approx(
clean["metrics"]["v0_mps"], abs=0.05)
assert duped["metrics"]["phi_deg"] == pytest.approx(
clean["metrics"]["phi_deg"], abs=2.0)
def test_speed_drop_khong_doi_huong_van_ra_va_cham():
"""Full-hit: đi thẳng +x 2.5 m/s rồi tụt còn 0.9 m/s (drop 64%) mà KHÔNG
đổi hướng — tầng 2 (speed-drop) phải bắt được va chạm này."""
out = bc.analyze_track(make_rows([(0.0, 2.5, 0.3), (0.0, 0.9, 0.3)]))
assert out["metrics"]["n_collisions"] == 1
c = out["collisions"][0]
assert "speed_drop" in c["kind"]
assert c["x_m"] == pytest.approx(0.6 + 2.5 * 0.3, abs=0.1)
assert c["t_s"] == pytest.approx(0.8, abs=0.1)
def test_kink_gia_tai_motion_onset_bi_guard_2r_loai():
"""Tái hiện kink giả của cú 11 (HANDOFF 24 Bất ngờ 8, Cowork chốt guard
phương án (a) BRIEF 25): bước chuyển động ĐẦU TIÊN bi mới nhích ~14mm
(cỡ sàn nhiễu dọc hướng chạy ±13mm) theo hướng rác, rồi mới chạy thật
~1.05 m/s. Không guard thì Δθ ~180° bắn va chạm giả NGAY motion_start,
cắt cụt cửa sổ V0 → V0 rác 0.5x, φ null. Có guard 2R: không va chạm,
V0 ≈ 1.05, φ ≈ 90°."""
rows = []
t = [0.0]
def push(x, y):
rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
"covered": 1, "table_x_m": x, "table_y_m": y,
"img_diff": 0.5 if len(rows) else -1.0})
t[0] += DT
x, y = 0.6, 0.5
for _ in range(15): # đứng yên 0.5s
push(x, y)
y -= 0.014 # bước nhiễu: 14mm heading 270°
push(x, y)
sp = 1.05
for _ in range(15): # chạy thật hướng +y
y += sp * DT
push(x, y)
while sp > 0.05: # lăn chậm dần như make_rows
sp = max(sp - 2.0 * DT, 0.0)
y += sp * DT
push(x, y)
for _ in range(6):
push(x, y)
out = bc.analyze_track(rows)
m = out["metrics"]
assert m["n_collisions"] == 0, out["collisions"]
assert m["v0_mps"] == pytest.approx(1.05, abs=0.1)
assert m["phi_deg"] == pytest.approx(90.0, abs=8.0)
assert not any("V0" in w for w in out["warnings"])
def test_speed_drop_duoi_san_1ms_khong_con_va_cham_gia():
"""Sàn speed-drop 0.5 → 1.0 m/s (Cowork chốt BRIEF 25): cú lăn chậm
0.9 m/s tụt 55% giữa đường — kiểu 2 va chạm giả t=4.0/4.8 của cú 11 —
không được ra va chạm nữa. Hệ quả khai trong bảng hằng: va chạm nhẹ
dưới 1 m/s bỏ sót có chủ đích."""
out = bc.analyze_track(make_rows([(0.0, 0.9, 0.4), (0.0, 0.4, 0.4)]))
assert out["metrics"]["n_collisions"] == 0, out["collisions"]
def test_khong_co_cu_danh_khong_va_cham_va_canh_bao():
rows = make_rows([], still_s=1.0)
out = bc.analyze_track(rows)
m = out["metrics"]
assert m["v0_mps"] is None and m["phi_deg"] is None
assert m["motion_start_s"] is None
assert m["n_collisions"] == 0
assert any("bắt đầu chạy" in w for w in out["warnings"])
def test_contact_cushion_khi_va_cham_sat_mep_bang():
"""Nhánh 1/3 (BRIEF 25 bước 2): bi chạy tới sát băng dài trên rồi bật
ngược — điểm gãy cách mép ≤ 1.5R → contact "cushion"."""
out = bc.analyze_track(
make_rows([(90.0, 2.0, 0.45), (270.0, 1.5, 0.4)], start=(0.5, 1.6)))
assert out["metrics"]["n_collisions"] == 1
c = out["collisions"][0]
assert c["y_m"] > bc.TABLE_L_M - 2 * bc.CONTACT_CUSHION_M
assert c["contact"] == "cushion"
def test_contact_ball_khi_gan_bi_tinh_truoc_cu():
"""Nhánh 2/3: speed-drop đúng chỗ có bi tĩnh (median detection không-cue
cửa sổ TRƯỚC motion_start) → contact "ball". Detection sau motion_start
cố tình đặt lệch chỗ khác — không được tính vào bi tĩnh."""
rows = make_rows([(90.0, 2.5, 0.4), (90.0, 1.2, 0.4)])
others = [{"t_s": i * DT, "x_m": 0.601, "y_m": 1.499} for i in range(12)]
others += [{"t_s": 2.0, "x_m": 0.2, "y_m": 0.2}] * 5 # sau motion — bỏ
out = bc.analyze_track(rows, others=others)
assert out["metrics"]["n_collisions"] == 1
c = out["collisions"][0]
assert "speed_drop" in c["kind"]
assert c["contact"] == "ball"
def test_contact_cushion_neo_kink_lech_van_ra_cushion():
"""Cơ chế phương án (a) BRIEF 26 bước 1.1 (từ HANDOFF 25 Bất ngờ 1):
neo kink tại điểm gãy heading nằm 1.56R khỏi mép (> ngưỡng 1.5R) nhưng
điểm track NGAY SAU neo chạm tới 1.0R → MIN trên ±2 điểm quanh neo phải
ra "cushion"; đo tại neo như cũ thì "unknown".
LƯU Ý (đo được ở rerun BG26 — HANDOFF 26): track THẬT của cú 11 KHÔNG
có điểm sát mép hơn neo (chạm rơi giữa hai frame) nên ca thật vẫn
unknown sau fix — test này khoá CƠ CHẾ cửa sổ, không phải khoá kết quả
cú 11 thật; ngưỡng cho ca thật chờ Cowork chốt."""
R = bc.BALL_R_M
rows = []
t = [0.0]
def push(x, y):
rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
"covered": 1, "table_x_m": x, "table_y_m": y,
"img_diff": 0.8 if len(rows) else -1.0})
t[0] += DT
x = 0.5
for _ in range(15): # đứng yên 0.5s
push(x, 1.56 * R + 8 * 0.05)
for k in range(8, 0, -1): # lao xuống băng ngắn dưới ~1.5 m/s
push(x, 1.56 * R + k * 0.05)
push(x, 1.56 * R) # NEO: gãy heading tại đây, 1.56R
push(x + 0.05, 1.00 * R) # bước kế mới là điểm sát mép 1.0R
push(x + 0.10, 1.08 * R) # bật ra xiên dọc băng
sp = 1.4
hd = 35.0
while sp > 0.05: # lăn chậm dần rồi dừng
x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
push(x2 + sp * DT * math.cos(math.radians(hd)),
y2 + sp * DT * math.sin(math.radians(hd)))
sp = max(sp - 2.0 * DT, 0.0)
for _ in range(6):
push(rows[-1]["table_x_m"], rows[-1]["table_y_m"])
out = bc.analyze_track(rows)
assert out["metrics"]["n_collisions"] >= 1
c = out["collisions"][0]
assert "kink" in c["kind"]
# neo NẰM NGOÀI ngưỡng cushion — chứng tỏ MIN cửa sổ mới là thứ cứu ca này
assert min(c["y_m"], bc.TABLE_L_M - c["y_m"]) > bc.CONTACT_CUSHION_M
assert c["contact"] == "cushion"
# hệ quả đúng của fix (kỳ vọng BRIEF 26 bước 1.4): side đọc được ở băng
assert "side" in (out["spin_class"] or "")
def _bounce_rows(*, v_in, v_out, d_vertex_m, hd_in=270.0, hd_out=120.0,
x0=0.5):
"""Track bật băng ngắn dưới với đỉnh (vertex) CHÍNH LÀ điểm gần mép nhất
— mô phỏng ca "chạm rơi giữa hai frame" của cú 11/cú 12-t2.6 (HANDOFF 26
Bất ngờ 1): không có điểm track nào sát mép hơn neo, chỉ ngưỡng động
(b′) mới với tới. Vào thẳng đứng xuống ``hd_in`` tốc độ ``v_in`` tới
đỉnh cách mép ``d_vertex_m``, bật ra ``hd_out`` tốc độ ``v_out``."""
rows = []
t = [0.0]
def push(x, y):
rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
"covered": 1, "table_x_m": x, "table_y_m": y,
"img_diff": 0.8 if len(rows) else -1.0})
t[0] += DT
n_in = 14
for _ in range(15): # đứng yên 0.5s
push(x0, d_vertex_m + n_in * v_in * DT)
for k in range(n_in - 1, 0, -1): # lao xuống mép
push(x0, d_vertex_m + k * v_in * DT)
push(x0, d_vertex_m) # ĐỈNH = neo kink = điểm gần nhất
sp = v_out
while sp > 0.05: # bật ra rồi lăn chậm dần
x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
push(x2 + sp * DT * math.cos(math.radians(hd_out)),
y2 + sp * DT * math.sin(math.radians(hd_out)))
sp = max(sp - 2.0 * DT, 0.0)
for _ in range(6):
push(rows[-1]["table_x_m"], rows[-1]["table_y_m"])
return rows
def test_contact_cushion_nguong_dong_cu11_cham_giua_frame():
"""(b′) BG26b ca cú 11: đỉnh track 1.56R khỏi mép, KHÔNG điểm nào sát
hơn, chord vào ~0.9 m/s → ngưỡng động 1.5R + 0.9·dt/2 ≈ 2.0R phủ được →
cushion (ngưỡng tĩnh 1.5R thì unknown — chính là điểm DỪNG HANDOFF 26).
Side đọc được ở băng và bị ÉP low (chord vào < 1 m/s, fix 1.2)."""
R = bc.BALL_R_M
out = bc.analyze_track(_bounce_rows(v_in=0.9, v_out=0.8,
d_vertex_m=1.56 * R))
assert out["metrics"]["n_collisions"] >= 1
c = out["collisions"][0]
assert "kink" in c["kind"]
assert min(c["y_m"], bc.TABLE_L_M - c["y_m"]) == pytest.approx(
1.56 * R, abs=5e-4) # JSON làm tròn 4dp
assert min(c["y_m"], bc.TABLE_L_M - c["y_m"]) > bc.CONTACT_CUSHION_M
assert c["contact"] == "cushion"
assert "side" in (out["spin_class"] or "")
assert out["spin_confidence"] == "low"
assert any("ép low" in e["text"] for e in out["spin_evidence"])
def test_contact_cushion_nguong_dong_cu12_t26_nhanh():
"""(b′) ca cú 12-t2.6: đỉnh 1.53R, chord vào ~2 m/s → ngưỡng động
≈ 2.7R → cushion; tốc độ ≥ 1 m/s nên KHÔNG ép low."""
R = bc.BALL_R_M
out = bc.analyze_track(_bounce_rows(v_in=2.0, v_out=1.6,
d_vertex_m=1.53 * R))
assert out["metrics"]["n_collisions"] >= 1
c = out["collisions"][0]
assert "kink" in c["kind"]
assert c["contact"] == "cushion"
assert not any("ép low" in e["text"] for e in out["spin_evidence"])
def test_contact_kink_giua_ban_v_cao_khong_lat_thanh_cushion():
"""(b′) ca đối chứng: gãy khúc GIỮA BÀN (d_rail ~5R) ở tốc độ cao 3 m/s
— slack động ~3.25R vẫn không với tới → unknown; có bi tĩnh tại chỗ →
ball. Khoá caveat "cú nhanh sát băng" không tràn thành cào ngưỡng."""
R = bc.BALL_R_M
rows = []
t = [0.0]
def push(x, y):
rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
"covered": 1, "table_x_m": x, "table_y_m": y,
"img_diff": 0.8 if len(rows) else -1.0})
t[0] += DT
x, y = 5 * R, 0.4 # chạy dọc cách băng trái đúng 5R
for _ in range(15):
push(x, y)
for _ in range(6): # chạy +y 3 m/s
y += 3.0 * DT
push(x, y)
# gãy 50° tại đây (90° → 40°) — mọi điểm quanh neo đều cách mép ≥ 5R
sp = 2.5
while sp > 0.05:
x += sp * DT * math.cos(math.radians(40.0))
y += sp * DT * math.sin(math.radians(40.0))
push(x, y)
sp = max(sp - 2.0 * DT, 0.0)
for _ in range(6):
push(x, y)
out = bc.analyze_track(rows)
assert out["metrics"]["n_collisions"] >= 1
c = out["collisions"][0]
assert "kink" in c["kind"]
assert c["contact"] == "unknown", c
others = [{"t_s": i * DT, "x_m": c["x_m"] + 0.02, "y_m": c["y_m"] + 0.03}
for i in range(12)]
out2 = bc.analyze_track(rows, others=others)
assert out2["collisions"][0]["contact"] == "ball"
def test_contact_speed_drop_giu_cach_tinh_tai_neo():
"""BRIEF 26 bước 1.1 vế sau: speed_drop GIỮ cách đo cũ (tại neo). Cú chạm
dày vào bi tĩnh ở ~1.8R cạnh băng dài trái, chạy chéo RA XA băng nên điểm
track 2 bước TRƯỚC neo còn ở ~1.4R (≤ 1.5R): nếu cửa sổ MIN ±2 bị áp
nhầm cho speed_drop thì ra "cushion" — đúng phải là "ball"."""
R = bc.BALL_R_M
rows = []
t = [0.0]
def push(x, y, diff=0.8):
rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
"covered": 1, "table_x_m": x, "table_y_m": y,
"img_diff": diff})
t[0] += DT
x0, y0 = 1.0 * R, 0.30 # xuất phát sát băng dài trái
for _ in range(15):
push(x0, y0, diff=-1.0 if not rows else 0.2)
# 4 bước nhanh chéo ra xa băng: +6mm x, +50mm y mỗi bước (~1.51 m/s).
# Neo speed-drop = điểm cuối chuỗi nhanh (1.0R + 4×6mm ≈ 1.84R); điểm
# 2 bước trước neo ở 1.0R + 2×6mm ≈ 1.42R ≤ 1.5R — chính là bẫy.
for k in range(1, 5):
push(x0 + k * 0.006, y0 + k * 0.050)
x_anchor = x0 + 4 * 0.006
assert x0 + 2 * 0.006 <= bc.CONTACT_CUSHION_M < x_anchor
# sau va chạm: cùng hướng, tốc độ tụt còn ~0.5 m/s (drop ~67%)
sp = 0.5
hd = math.degrees(math.atan2(0.050, 0.006))
for _ in range(6):
x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
push(x2 + sp * DT * math.cos(math.radians(hd)),
y2 + sp * DT * math.sin(math.radians(hd)))
while sp > 0.05:
sp = max(sp - 2.0 * DT, 0.0)
x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
push(x2 + sp * DT * math.cos(math.radians(hd)),
y2 + sp * DT * math.sin(math.radians(hd)))
for _ in range(6):
push(rows[-1]["table_x_m"], rows[-1]["table_y_m"])
others = [{"t_s": i * DT, "x_m": x_anchor + 0.02, "y_m": rows[18]["table_y_m"] + 0.03}
for i in range(12)]
out = bc.analyze_track(rows, others=others)
assert out["metrics"]["n_collisions"] >= 1
c = out["collisions"][0]
assert c["kind"] == "speed_drop", c
assert c["x_m"] > bc.CONTACT_CUSHION_M # neo ngoài ngưỡng cushion
assert c["contact"] == "ball"
def test_contact_unknown_giua_ban_khong_bi_tinh():
"""Nhánh 3/3: gãy khúc giữa bàn, không có detection bi nào quanh đó
(others rỗng/None) → contact "unknown" — kể cả đường CSV cũ không truyền
others vẫn chạy nguyên."""
legs = [(90.0, 2.0, 0.4), (150.0, 1.6, 0.4)]
for others in (None, []):
out = bc.analyze_track(make_rows(legs), others=others)
assert out["metrics"]["n_collisions"] == 1
assert out["collisions"][0]["contact"] == "unknown"
def test_coverage_thap_ra_canh_bao_khong_phai_loi():
"""Mất track (người che) là THÔNG TIN chứ không phải bug (BRIEF #5):
coverage < 80% → cảnh báo hiện, số vẫn trả."""
rows = make_rows([(90.0, 2.0, 0.4)])
for r in rows[: int(0.35 * len(rows))]:
r["covered"] = 0
out = bc.analyze_track(rows)
assert out["metrics"]["coverage"] < 0.80
assert any("phủ" in w for w in out["warnings"])
def test_track_rong_nem_valueerror():
with pytest.raises(ValueError):
bc.analyze_track([])
# ------------------------------------------------- baseline spin (BG25)
STATIC_15 = [{"t_s": i * DT, "x_m": 0.601, "y_m": 1.499} for i in range(12)]
def test_spin_follow_cu_cham_day_tien_tiep():
"""Follow, cú chạm dày (speed_drop, không kink): sau chạm bi tĩnh cue
tiếp tục TIẾN dọc hướng cũ → follow, confidence medium."""
rows = make_rows([(90.0, 2.5, 0.4), (90.0, 1.2, 0.4)])
out = bc.analyze_track(rows, others=STATIC_15)
assert out["collisions"][0]["contact"] == "ball"
assert out["spin_class"] == "follow"
assert out["spin_confidence"] == "medium"
assert any(e["axis"] == "vertical" and "follow" in e["text"]
for e in out["spin_evidence"])
def test_spin_draw_duong_ve_sau_cham():
"""Draw: sau chạm bi đường đi quay ngược ~180° (tangent cú cắt tối đa
90° — chỉ backspin kéo về được) → draw."""
rows = make_rows([(90.0, 2.5, 0.4), (270.0, 1.0, 0.5)])
out = bc.analyze_track(rows, others=STATIC_15)
assert out["collisions"][0]["contact"] == "ball"
assert out["spin_class"] == "draw"
assert out["spin_confidence"] == "medium"
def test_spin_side_lech_phan_xa_guong():
"""Side: bật băng dài phải, hướng ra lệch +25° so với phản xạ gương —
tiếp tuyến sau bật bị đẩy theo chiều ωz > 0 → side-R, confidence low
(25° < 30°). Va chạm đầu là băng → trục dọc KHÔNG đọc."""
rows = make_rows([(30.0, 2.0, 16 * DT), (175.0, 1.2, 0.35)],
start=(0.31, 0.8))
out = bc.analyze_track(rows)
assert out["metrics"]["n_collisions"] == 1
assert out["collisions"][0]["contact"] == "cushion"
assert out["spin_class"] == "side-R"
assert out["spin_confidence"] == "low"
assert any(e["axis"] == "vertical" and "không phải bi" in e["text"]
for e in out["spin_evidence"])
lat = [e for e in out["spin_evidence"] if e["axis"] == "lateral"]
assert lat and lat[0]["deg"] == pytest.approx(25.0, abs=6.0)
def test_spin_ep_low_khi_toc_do_quanh_va_cham_duoi_san():
"""BG26 bước 1.2 (Cowork chốt từ câu hỏi HANDOFF 25 #4): CÙNG hình học
bật băng lệch gương +35° (≥ 30° → medium theo luật cũ), chỉ khác tốc độ
tới va chạm — 2.0 m/s giữ medium, 0.9 m/s (< sàn SPEED_DROP_MIN_MPS)
bị ÉP low kèm evidence nói rõ, vì vùng chậm là vùng nhiễu dọc hướng
chạy chi phối (bài học cú 11)."""
fast = bc.analyze_track(
make_rows([(30.0, 2.0, 16 * DT), (185.0, 1.6, 0.35)],
start=(0.31, 0.8)))
assert fast["spin_class"] == "side-R"
assert fast["spin_confidence"] == "medium"
assert not any("ép low" in e["text"] for e in fast["spin_evidence"])
slow = bc.analyze_track(
make_rows([(30.0, 0.9, 36 * DT), (185.0, 0.8, 0.35)],
start=(0.31, 0.8)))
assert slow["metrics"]["n_collisions"] >= 1
assert slow["collisions"][0]["contact"] == "cushion"
assert slow["spin_class"] == "side-R"
assert slow["spin_confidence"] == "low"
assert any("ép low" in e["text"] for e in slow["spin_evidence"])
def test_spin_null_cu_khong_va_cham():
"""Cú lăn không chạm gì: spin unidentifiable VỀ NGUYÊN TẮC (§3.2) —
null + lý do, không đoán bừa (2/10 cú P0 thuộc loại này)."""
out = bc.analyze_track(make_rows([(90.0, 1.8, 0.5)]))
assert out["metrics"]["n_collisions"] == 0
assert out["spin_class"] is None
assert out["spin_confidence"] is None
assert any("không có va chạm" in e["text"] for e in out["spin_evidence"])
# ------------------------------------------------------------ track thật
def _fixture_rows():
with open(FIXTURE, encoding="utf-8") as f:
return list(csv.DictReader(f))
def test_shot01_that_v0_phi_va_cham():
"""Track thật shot_01 (P0, mắt đã kiểm): đánh lúc ~0.77s (t0 fixture
3173.0, motion 3173.77), V0 quãng 2–3.6 m/s hướng ~200°, chạm bi mục
tiêu lúc ~1.0s sau đó (t≈3174.0) rồi lăn chậm hướng ~134°."""
out = bc.analyze_track(_fixture_rows())
m = out["metrics"]
assert m["motion_start_s"] == pytest.approx(0.77, abs=0.1)
assert 1.5 <= m["v0_mps"] <= 4.5
assert 185 <= m["phi_deg"] <= 220
assert m["n_collisions"] >= 1
c = out["collisions"][0]
assert c["t_s"] - 3173.0 == pytest.approx(1.0, abs=0.15)
# fixture chỉ có track cue (không detection bi khác) → contact "unknown"
assert c["contact"] == "unknown"
# sau va chạm KHÔNG được mọc thêm va chạm giả giữa đoạn lăn chậm
# (nhiễu dọc hướng chạy ±13mm làm tốc độ per-step lắc mạnh — HANDOFF 23)
assert m["n_collisions"] <= 2
# stream 25fps upconvert: fixture 61 frame phải dính ~1/6 frame trùng
assert m["n_dup_frames"] >= 5
assert out["warnings"] == [] # cú đẹp: không có gì để cảnh báo
def test_shot01_track_tra_toa_do_tho_khong_smooth():
"""Track trả ra = đúng điểm covered đã dedup, toạ độ THÔ từ CSV —
smooth chỉ để vẽ là việc của FE (BRIEF bước 3.2)."""
rows = _fixture_rows()
out = bc.analyze_track(rows)
raw = {(round(float(r["table_x_m"]), 4), round(float(r["table_y_m"]), 4))
for r in rows if r["covered"] == "1"}
for p in out["track"]:
assert (p["x_m"], p["y_m"]) in raw