Spaces:
Sleeping
Sleeping
| """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 | |