masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
14.7 kB
"""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}{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