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