"""Cửa vào engine app: ``recommend_v2`` + dataclass kết quả + validate. 31/07/2026 (BRIEF việc D): mọi đường cũ — ``recommend()`` 3 bi, oracle grid search, hybrid net top-K, zone V1 (raster/``dt``/thang nới), fallback "ít tệ nhất", 2-ply — XOÁ khỏi ``main`` sau khi gate G2 chứng minh V2 không đổi hành vi của chúng (60/60 ván hybrid k80 khớp từng dòng CSV 28/07). Bản đầy đủ sống ở nhánh ``v1-full``; mọi đối chứng sau này chạy ở đó. Thiết kế đường còn lại: ``Documents/PoolCoach_App_ZonePlanner_Design.md`` §8 (poolcoach-docs). KHÔNG print — CLI/BE tự trình bày. """ from __future__ import annotations import time from dataclasses import dataclass, field from typing import Any, Optional from .geometry import feasible_pockets_full, pocket_name from .rules import judge_shot, target_ball from .simulate import simulate_shot, simulate_shot_multi from .trajectory import extract_trajectories # ------------------------------------------------------------- full rack @dataclass class ShotFull: """1 cú full-rack đã rank + chấm luật. Hỗ trợ shot["key"] như Shot.""" rank: int pocket: int phi: float v0: float side: float vert: float target: str # bi mục tiêu (bi nhỏ nhất) lúc đánh next: Optional[str] # bi kế tiếp sau cú | None nếu hết bàn win: bool # bi 9 vào lỗ hợp lệ foul: bool # luôn False trong shots của V2 (lọc (i)) scratch: bool potted: list = field(default_factory=list) # id bi vào lỗ trong cú first_contact: Optional[str] = None # bi cue chạm đầu tiên | None q: float = 0.0 ev: float = 0.0 balls_final: Any = None # {id: xy | None} — nút "Đánh cú này" FE system: Any = None trajectories: Optional[dict] = None #: Khoá đã dùng để xếp hạng cú này — V2 luôn là ``"roll"``. Giữ dạng cờ #: chứ không bỏ hẳn: response tự khai tiêu chí của nó, và bản kế tiếp #: (safety/kick) sẽ cần phân biệt. rank_by: Optional[str] = None #: Zone V2 (31/07/2026, design §8.2): quãng đường bi cái lăn SAU va chạm #: (mét, arc length thật — đường có băng dài hơn đường thẳng) — khoá xếp #: hạng số 1, FE hiện thay cho EV/Q. roll_len: Optional[float] = None #: Zone V2: khoảng cách đáp ``d(cue_final, bi kế)`` (mét) — vế phá hoà #: ``|d − 1.0|``. None ở cú cuối ván (không có bi kế — "không đo được", #: đừng đọc thành 0 mét). d_land: Optional[float] = None def __getitem__(self, key): return getattr(self, key) @dataclass class RecommendFullResult: engine: str target: str = "" shots: list = field(default_factory=list) # [] nếu không có cú hợp lệ ăn bi n_results: int = 0 n_legal_pot: int = 0 # số cú hợp lệ ăn được target (25/07: không còn # tính cú chỉ làm rơi bi 9 — xem §2.1) n_win: int = 0 # trong đó số cú thắng (bi 9 vào lỗ) elapsed_s: float = 0.0 pockets: list = field(default_factory=list) # [(idx, phi, cut, blocked)] fallback: Any = None # ShotFull | None — CHỈ set khi shots rỗng (§2.2) OVERLAP_EPS = 1e-6 # m — hai bi TỰA vào nhau sau sim cách nhau ĐÚNG 2R; # sai số float một chiều là raise. Nới đúng bằng dung # sai float, không hơn: 1 µm không che nổi cú chồng bi # thật (input rác lệch cỡ mm trở lên). def validate_full(env_h, balls): """Validate bàn N bi (§5): cue + ≥1 bi 1..9, trong bàn, không chồng. Message tiếng Việt cùng style validate v1 (BE trả 422). BẤT BIẾN: mọi trạng thái ``simulate_shot_multi`` sinh ra PHẢI qua được hàm này. Vỡ bất biến đó thì mọi vòng lặp bơm ``balls_final`` ngược vào ``recommend_v2`` chết giữa ván — ``gen_demo_boards.py`` và ``autoPlay()`` của FE đều là vòng lặp đó. Biên vị trí đọc từ ``env_h._bounds`` (xem ``envs.position_env.table_bounds``) — KHÔNG tính lại ở đây, và KHÔNG phải hằng số riêng của module này. """ import numpy as np from poolcoach_rl.envs.position_env import BALL_R # Biên vị trí = ENVELOPE VẬT LÝ dẫn từ hình học của chính bàn đang mô # phỏng: hộp bao của (mặt bàn ∪ mọi đường tròn lỗ). Bi chưa bị ăn không ra # ngoài hộp đó được — muốn ra xa hơn phải đi xuyên một đường tròn lỗ, mà đi # qua đường tròn lỗ là bị ăn. # # Vì sao KHÔNG nới hằng số thêm lần nữa: bản 27/07 nới đúng BALL_R với lập # luận "dư ~5× so với mức đo được (28.6 mm vs 5.9 mm)", rồi 28/07 gặp # trạng thái vượt 29.7 mm. Chọn headroom theo mẫu ĐÃ THẤY là đúng cái cách # làm vừa hụt một lần; mẫu quan sát không bao giờ chứng minh được nó đã # chạm đáy. Hộp bao hình học thì chứng minh được, nên không có lần thứ ba. # # Đánh đổi CHẤP NHẬN CÓ Ý THỨC: hộp bao nhận cả điểm ngoài băng mà xa mọi # lỗ (ví dụ x = −0.05, y = 0.60 — bên trái băng, chẳng gần lỗ nào), dù về # vật lý bi không tới đó được. Chấp nhận vì (i) đây là hộp CHỨNG MINH ĐƯỢC # là chứa trọn mọi vị trí nghỉ hợp lệ, và (ii) biên chặt cho input do NGƯỜI # đặt KHÔNG biến mất — nó nằm ở FE: `app/static/app.js` dòng 331-332 kẹp # toạ độ kéo-thả về [R, w−R] × [R, l−R]. Đó chính là "tách hai đường": FE # chặn chỗ input do người sinh ra, BE chấp nhận đúng tập trạng thái mà sim # của CHÍNH NÓ sinh ra được. Kiểm tra không bị xoá đi cho tiện — nó chuyển # chỗ. Muốn chặt hơn ở BE thì phải mô hình hoá vùng hàm lỗ. if "cue" not in balls: raise ValueError("Thiếu bi cái (cue)") ids = [bid for bid in balls if bid != "cue"] if not ids: raise ValueError("Bàn không có bi mục tiêu (cần ≥1 bi 1..9)") for bid in ids: if bid not in {str(i) for i in range(1, 10)}: raise ValueError(f"Id bi không hợp lệ: {bid!r} (chỉ cue và 1..9)") w, l = env_h.w, env_h.l lo_x, hi_x, lo_y, hi_y = env_h._bounds for bid, xy in balls.items(): # NaN/inf phải bị chặn TRƯỚC, bằng message RIÊNG: mọi so sánh với NaN # đều False nên nó rơi thẳng vào nhánh dưới và báo "ngoài bàn" — đúng # kiểu message dẫn người debug đi sai hướng. if not (np.isfinite(xy[0]) and np.isfinite(xy[1])): raise ValueError(f"Vị trí {bid} {tuple(xy)} không phải số hữu hạn " f"(NaN hoặc inf)") if not (lo_x <= xy[0] <= hi_x and lo_y <= xy[1] <= hi_y): raise ValueError(f"Vị trí {bid} {tuple(xy)} ngoài bàn " f"(bàn {w:.3f} × {l:.3f} m)") bids = sorted(balls, key=lambda b: (b != "cue", b)) for i, a in enumerate(bids): for b in bids[i + 1:]: if (float(np.linalg.norm(balls[a] - balls[b])) < 2 * BALL_R - OVERLAP_EPS): raise ValueError(f"{a} và {b} chồng lên nhau (< 2R)") TRAJ_MIN_DISP = 0.002 # m — bi dịch chuyển ít hơn coi như đứng yên, bỏ khỏi # trajectories (§2e; chỉ áp cho đường full rack) # ------------------------------------------------ zone V2 (31/07/2026, §8) ZONE_RANK_ROLL = "roll" # xếp bằng độ dài đường lăn sau va chạm (V2, §8.2) def recommend_v2(balls, *, env_h=None, alternatives=3, n_render=1) -> RecommendFullResult: """ZonePlanner V2 — đường chọn cú DUY NHẤT của app từ 31/07 (design §8). Lịch sử tên hàm: sinh sáng 31/07 làm cửa vào RIÊNG nằm cạnh ``recommend_full`` (oracle/hybrid/zone-V1) để gate G2 còn chứng minh được "thêm V2 không đổi hành vi engine cũ" — đã chứng minh xong (60/60 ván khớp) rồi việc D mới xoá các đường kia; chúng sống ở nhánh ``v1-full``. Kết quả khai ``engine="zone"``: với người dùng, ``zone`` là TÊN của đường ZonePlanner, còn phiên bản tiêu chí nằm ở ``rank_by`` (``"roll"``). Trả ``RecommendFullResult``: - ``shots``: top ``1 + alternatives`` từ ``PlanV2Result.pool``, xếp bằng ĐÚNG ``rank_key_v2`` mà ``plan_shot_v2`` đã dùng (``sorted`` ổn định ⇒ phần tử cực tiểu đầu tiên là chính ``plan.shot`` — cùng bất biến V1). - hết đường: ``shots = []``, ``fallback = None`` (design §8.3 — không thang nới, không cú "ít tệ nhất"; hết đường là thông tin cho người tập, message do tầng app soạn). - ``n_legal_pot`` = số ứng viên qua ĐỦ 5 lọc cứng trên mọi lỗ đã thử — giữ NGHĨA "cú hợp lệ trong không gian tìm kiếm" mà FE đang hiện, với "hợp lệ" nay là hợp lệ THEO TIÊU CHÍ V2. """ import numpy as np from .zone_v2 import plan_shot_v2, rank_key_v2 if env_h is None: from poolcoach_rl.envs import PositionPlayEnv env_h = PositionPlayEnv() balls = {bid: np.asarray(xy, dtype=np.float64) for bid, xy in balls.items()} validate_full(env_h, balls) target = target_ball(balls) pockets = feasible_pockets_full(env_h, balls, target) t0 = time.time() plan = plan_shot_v2(env_h, balls, target, pockets=pockets) elapsed = time.time() - t0 ranked = sorted(plan.pool, key=rank_key_v2())[:1 + max(alternatives, 0)] shots = [] for i, c in enumerate(ranked): f = c.facts shot = ShotFull(rank=i + 1, pocket=f["pocket"], phi=f["phi"], v0=f["v0"], side=f["side"], vert=f["vert"], target=target, next=f["next"], win=f["win"], foul=f["foul"], scratch=f["scratch"], potted=f["potted"], first_contact=f["first_contact"], q=f["q"], ev=f["ev"], balls_final=f["balls_final"], rank_by=ZONE_RANK_ROLL, roll_len=float(c.roll_len), d_land=(None if c.d_land is None else float(c.d_land))) if i < n_render: rr = simulate_shot_multi(env_h, balls, shot.phi, shot.v0, shot.side, shot.vert, render=True) if rr is not None: shot.system = rr["system"] shot.trajectories = extract_trajectories( rr["system"], min_disp=TRAJ_MIN_DISP) shot.balls_final = rr["balls_final"] shots.append(shot) return RecommendFullResult( engine="zone", target=target, shots=shots, n_results=plan.n_cand, n_legal_pot=sum(p.n_pass for p in plan.pockets), n_win=sum(1 for c in plan.pool if c.facts["win"]), elapsed_s=elapsed, pockets=pockets, fallback=None) def describe_full(env_h, shot) -> str: """Diễn giải tiếng người cho cú V2 (nhận ShotFull hoặc dict cùng shape — cả hai đều hỗ trợ ``shot["key"]``). Từ 31/07 mọi cú tới đây đều đã qua lọc (i) của V2 — không foul, không miss — nên các nhánh diễn giải cú phạm luật của bản cũ (fallback "ít tệ nhất") đã xoá theo ``pick_fallback``; xem ``v1-full`` nếu cần lại. """ target = shot["target"] pocket = pocket_name(env_h, shot["pocket"]) potted = shot["potted"] lines = [f"ĐÁNH BI {target} VÀO LỖ {pocket.upper()}"] force = ("nhẹ" if shot["v0"] < 1.5 else "vừa" if shot["v0"] < 2.8 else "mạnh") lines.append(f" Hướng : phi = {shot['phi']:.1f}° (ngắm ghost-ball)") lines.append(f" Lực : {shot['v0']:.2f} m/s ({force}, thang 0.5-4.0)") spin = [] if shot["vert"] >= 0.15: spin.append(f"đánh tới (follow, b=+{shot['vert']:.1f})") elif shot["vert"] <= -0.15: spin.append(f"kéo về (draw, b={shot['vert']:.1f})") if abs(shot["side"]) >= 0.15: spin.append(f"xoáy ngang (a={shot['side']:+.1f})") lines.append(f" Xoáy : {', '.join(spin) if spin else 'không (đánh tâm bi)'}") if shot["win"]: lines.append(" ⚑ Bi 9 vào lỗ trong cú này — THẮNG nếu đúng " "như mô phỏng") extras = [b for b in potted if b != target and b != "9"] if extras: lines.append(f" Bonus : bi {', '.join(extras)} cũng vào lỗ") if target in potted and shot["next"] is not None: cf = (shot["balls_final"] or {}).get("cue") # đọc trường optional không nổ trên dict shape cũ (cùng lý do # app/main._field). _opt = (shot.get if isinstance(shot, dict) else lambda k: getattr(shot, k, None)) if cf is not None: # khoá xếp hạng V2 là ĐƯỜNG LĂN + d_đáp — nói đúng hai số đó, # không nói Q/EV (BRIEF việc B.3). d_txt = ("" if _opt("d_land") is None else f", cách bi {shot['next']} {_opt('d_land'):.2f} m") roll = _opt("roll_len") roll_txt = "" if roll is None else f"lăn {roll:.2f} m sau chạm, " lines.append(f" Điều bi : bi cái {roll_txt}dừng " f"({cf[0]:.2f}, {cf[1]:.2f})" f"{d_txt} — cú tiếp → bi {shot['next']}") return "\n".join(lines) def warmup(env_h) -> float: """Chạy 1 cú giả kích JIT Numba (~40s lần đầu). Trả elapsed (giây). BE gọi trong background thread lúc startup.""" import numpy as np t0 = time.time() simulate_shot(env_h, np.array([env_h.w * 0.5, env_h.l * 0.25]), np.array([env_h.w * 0.5, env_h.l * 0.5]), None, 90.0, 1.5, 0.0, 0.0) return time.time() - t0