Spaces:
Sleeping
Sleeping
| """ZonePlanner V2 — thứ tự ưu tiên tiêu chí (31/07/2026, design §8, Danh chốt). | |
| Thiết kế: ``Documents/PoolCoach_App_ZonePlanner_Design.md`` **§8 là nguồn sự | |
| thật** (repo poolcoach-docs). §8 là VÒNG 2 ĐÃ KHAI: ``d_min`` 0.30 → 0.15 và | |
| dải lực ``[1.2, 3.0]`` → nấc 2–10 đổi SAU khi thấy số phase 1–2.2, cùng 4 | |
| quyết định hỏi–đáp trong phiên (tránh mọi bi · ngắn nhất trước, 1 m phá hoà · | |
| ``dt`` xoá · hết đường không fallback). KHÔNG tinh thêm tham số nào ở đây. | |
| LỊCH SỬ MODULE: V2 được dựng NẰM CẠNH V1 (``zone_planner.py``) sáng 31/07; | |
| sau khi gate G2 chứng minh việc thêm V2 không đổi hành vi engine cũ (60/60 | |
| ván hybrid k80 khớp từng dòng), việc D xoá oracle/hybrid/V1 khỏi ``main`` — | |
| các mảnh THUẦN mà V2 dùng chung với V1 được CHUYỂN NGUYÊN VĂN vào đây | |
| (``select_pockets`` + ``PocketPick`` + ``pockets_for_net``, bảng | |
| ``TECHNIQUES``, ``snap_to_grid`` + hai grid mặc định, ``best_cut_deg`` + | |
| hai hằng góc). Bản đầy đủ của V1 (raster/``dt``/thang nới/oracle/hybrid) | |
| sống ở nhánh ``v1-full`` — mọi đối chứng sau này chạy ở đó. | |
| TẬP ỨNG VIÊN — không raster, không ``dt``: bước 1 chọn lỗ NHƯ V1 × 11 kỹ | |
| thuật × **nấc lực 2–10** (``default_v0_grid()[1:]`` — 99 ô/lỗ), sim từng cú, | |
| lọc trên KẾT QUẢ sim. Lỗ thứ 2 CHỈ dùng khi lỗ thứ 1 không sinh nổi cú nào | |
| qua lọc — y nếp V1. | |
| LỌC CỨNG (AND, nhị phân, KHÔNG thang nới — rỗng nghĩa là rỗng, design §8.1; | |
| suy từ câu Danh "sẽ có những quả hết đường, đó là điều nằm trong dự tính"): | |
| (i) bi mục tiêu ăn TRỰC TIẾP vào lỗ: không foul (chạm đúng bi, không | |
| scratch), target vào lỗ, KHÔNG combo, và đường bi mục tiêu không chạm | |
| bi khác — đọc từ ``bb_events``: không event BALL_BALL nào dính target | |
| ngoài cú chạm đầu của cue. | |
| (ii) góc cắt như cũ: ``θ_pot_max = 70°`` nằm ở bước 1 (``select_pockets``); | |
| nêm ``θ_max = 45°`` kiểm TRÊN ĐIỂM ĐÁP — ``best_cut_deg`` từ | |
| ``cue_final`` vào bi kế phải ≤ 45°. Không có bi kế (cú cuối ván) thì | |
| vế nêm vô nghĩa và tự qua. | |
| (iii) MỚI: đường bi cái SAU va chạm không đụng BẤT KỲ bi nào — kể cả | |
| double-kiss bi mục tiêu; chạm băng thì được. Đọc từ ``bb_events``: | |
| không BALL_BALL nào dính ``cue`` sau cú chạm đầu. | |
| (iv) khoảng cách đáp ``d ∈ [0.15, 1.50] m`` tới bi kế. Cú cuối ván (không | |
| bi kế): BỎ (iv) — luật §8.3, đề xuất Cowork trong phiên. | |
| (v) lực nấc 2–10: so bằng GIÁ TRỊ GRID, không literal (bài học | |
| ``linspace[3] ≠ 0.2`` trong docstring ``zone_planner``). | |
| XẾP HẠNG lexicographic, không trọng số (design §8.2): | |
| 1. tổng quãng đường bi cái lăn SAU va chạm — NGẮN NHẤT. Arc length thật từ | |
| ``cue_roll_after`` (event-polyline của sim — đường có băng DÀI HƠN đường | |
| thẳng; nghiệm thu 31/07: lệch continuize 0.2–8.9 mm trên 6 cú thử). | |
| 2. hoà → ``|d_đáp − 1.0 m|`` nhỏ nhất. | |
| 3. vẫn hoà → thứ tự enumerate tất định (nếp ``n_tie_ge2``). | |
| Cú cuối ván xếp theo đúng khoá trên với vế 2 = hằng 0 (không có ``d_đáp``) — | |
| tức "đường lăn ngắn nhất", KHÔNG dùng ``ev`` (nó đi theo oracle, sắp xoá). | |
| Import nhẹ theo quy ước package: numpy/pooltool chỉ import lazy trong thân | |
| hàm; mọi hàm lọc/xếp ở đây THUẦN để test khoá được dưới stub pooltool rỗng. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| # ------------------------------------------------------------------ tham số | |
| # Danh chốt 31/07/2026 (design §8.1) — vòng 2 ĐÃ KHAI, xem docstring module. | |
| D_MIN = 0.15 # m — khoảng cách đáp tối thiểu tới bi kế (iv) | |
| D_MAX = 1.50 # m — khoảng cách đáp tối đa (iv) | |
| D_IDEAL = 1.00 # m — mốc phá hoà thứ 2 của xếp hạng (§8.2) | |
| # Hai hằng GÓC — chốt 30/07 từ kiến thức bida của Danh, TRƯỚC khi thấy số | |
| # (nguồn gốc: zone.py phase 0; chuyển nguyên văn sang đây ở việc D 31/07). | |
| THETA_MAX_DEG = 45.0 # nêm góc ăn bi kế tại điểm đáp — điều kiện (ii) | |
| THETA_POT_MAX_DEG = 70.0 # góc cắt tối đa của CÚ NÀY — bước 1 chọn lỗ | |
| # Cỡ grid ứng viên — nguồn gốc engines.py (N_V0 × N_SPIN² là không gian | |
| # oracle cũ); V2 chỉ còn dùng để dựng hai grid mặc định dưới đây. | |
| N_V0, N_SPIN = 10, 5 | |
| N_POCKETS_KEEP = 2 # bước 1.4 — "lấy 2 lỗ đầu" | |
| # ------------------------------------------- mảnh thuần chuyển từ V1 (31/07) | |
| # Chuyển NGUYÊN VĂN từ zone_planner.py / zone.py / engines.py khi xoá V1 | |
| # (việc D). Docstring gốc giữ nguyên — chúng là hồ sơ của các quyết định. | |
| #: Bộ 11 kỹ thuật cơ bản ``(side, vert)`` — design §2 bước 4. 14 ô còn lại | |
| #: của grid 25 ô là xoáy mạnh kết hợp, đúng thứ Danh muốn loại ("chọn các | |
| #: đầu cơ dễ và ít phải dùng xoáy"). Giá trị ở đây là giá trị NGƯỜI ĐỌC; giá | |
| #: trị đem đi sim là phần tử lưới được ``v2_specs`` snap về. | |
| TECHNIQUES = ( | |
| (0.0, 0.0), # tâm bi | |
| (0.0, 0.2), # cu lê nhẹ | |
| (0.0, 0.4), # cu lê mạnh | |
| (0.0, -0.2), # trô nhẹ | |
| (0.0, -0.4), # trô mạnh | |
| (0.2, 0.0), # xoáy phải | |
| (-0.2, 0.0), # xoáy trái | |
| (0.2, 0.2), # cu lê + xoáy phải | |
| (-0.2, 0.2), # cu lê + xoáy trái | |
| (0.2, -0.2), # trô + xoáy phải | |
| (-0.2, -0.2), # trô + xoáy trái | |
| ) | |
| def default_v0_grid(): | |
| """``linspace(0.5, 4.0, 10)`` — NGUYÊN grid lực của engine cũ; nấc 2–10 | |
| của nó là dải lực V2 (design §8.1 (v)).""" | |
| import numpy as np | |
| return np.linspace(0.5, 4.0, N_V0) | |
| def default_spin_grid(): | |
| """``linspace(-0.4, 0.4, 5)`` — NGUYÊN grid spin cũ.""" | |
| import numpy as np | |
| return np.linspace(-0.4, 0.4, N_SPIN) | |
| def snap_to_grid(value, grid, *, atol=1e-9): | |
| """Trả phần tử của ``grid`` khớp ``value``; raise nếu không khớp ĐÚNG một. | |
| Tồn tại vì ``linspace(-0.4, 0.4, 5)[3]`` = 0.20000000000000007 ≠ 0.2: so | |
| bằng ``==`` trên float ở đây là sai lặng lẽ, còn dùng luôn literal là sim | |
| ngoài lưới đã quét. Raise chứ không "lấy gần nhất": một kỹ thuật rơi | |
| ngoài grid nghĩa là bảng tham số và grid đã lệch nhau, mà đó là chuyện | |
| phải nổ chứ không phải chuyện làm tròn. | |
| """ | |
| import numpy as np | |
| g = np.asarray(grid, dtype=np.float64).ravel() | |
| hit = np.nonzero(np.isclose(g, value, rtol=0.0, atol=atol))[0] | |
| if hit.size != 1: | |
| raise ValueError( | |
| f"giá trị {value!r} khớp {hit.size} phần tử của lưới " | |
| f"{[float(x) for x in g]} (chờ đúng 1, atol={atol}) — bảng 11 kỹ " | |
| f"thuật và grid đã lệch nhau, đừng làm tròn cho qua") | |
| return float(g[hit[0]]) | |
| def pockets_for_net(pockets): | |
| """Lọc lỗ CHƯA BỊ CHẮN, giữ nguyên thứ tự; mọi lỗ đều blocked → trả | |
| nguyên input (bàn kẹt; để sim tự phán — ``blocked`` chỉ là heuristic | |
| "cách đường đi < 2R" và cú sượt đôi khi vẫn vào). KHÔNG bao giờ trả rỗng | |
| nếu input không rỗng. | |
| Tên giữ từ thời hybrid ("lỗ nạp cho net") vì design §2 bước 1.2 gọi đúng | |
| tên này — đổi tên là cắt liên kết với bản đặc tả mà bước 1 chép nguyên | |
| văn. ``pockets``: ``[(idx, phi, cos_cut, blocked)]`` từ | |
| ``feasible_pockets_full``.""" | |
| unblocked = [p for p in pockets if not p[3]] | |
| return unblocked if unblocked else list(pockets) | |
| class PocketPick: | |
| """Một lỗ đã qua bước 1, kèm hai đại lượng bước 1 dùng để xếp.""" | |
| idx: int # index lỗ trong ``env_h._pockets`` | |
| phi: float # độ, hệ pooltool — ngắm ghost-ball vào lỗ này | |
| cos_cut: float | |
| blocked: bool | |
| theta_deg: float # θ_cut = arccos(cos_cut) | |
| dist: float # d(T, lỗ) — khoá sắp xếp của bước 1.4 | |
| def select_pockets(pockets, target_xy, pockets_xy, *, | |
| theta_pot_max_deg=THETA_POT_MAX_DEG, | |
| n_keep=N_POCKETS_KEEP): | |
| """Bước 1 — trả ≤ ``n_keep`` lỗ, ưu tiên GẦN trước, đã loại lỗ quá mỏng. | |
| Nguyên văn luật Danh (design §2 bước 1), KHÔNG thay bằng công thức tự | |
| nghĩ: lọc ``pockets_for_net`` → loại lỗ ``θ_cut > 70°`` (loại hết thì giữ | |
| ĐÚNG MỘT lỗ mỏng nhất) → sắp theo ``d(T, lỗ)`` tăng dần → lấy 2 lỗ đầu. | |
| ``pockets_xy`` nhận tường minh chứ không nhận ``env_h`` để hàm THUẦN, | |
| test khoá được dưới stub pooltool rỗng. ``sorted`` ỔN ĐỊNH: hai lỗ cách | |
| ``T`` bằng nhau giữ nguyên thứ tự — không nguồn ngẫu nhiên nào lọt vào. | |
| """ | |
| import numpy as np | |
| pks = pockets_for_net(list(pockets)) | |
| if not pks: | |
| return [] | |
| tgt = np.asarray(target_xy, dtype=np.float64).reshape(2) | |
| pxy = np.asarray(pockets_xy, dtype=np.float64).reshape(-1, 2) | |
| def _theta(p): | |
| return math.degrees(math.acos(max(-1.0, min(1.0, float(p[2]))))) | |
| thetas = [_theta(p) for p in pks] | |
| keep = [(p, t) for p, t in zip(pks, thetas) if t <= theta_pot_max_deg] | |
| if not keep: | |
| # "loại hết thì giữ ĐÚNG MỘT lỗ có θ_cut nhỏ nhất" — bàn kẹt vẫn phải | |
| # có một lỗ để máy lọc thử, sự thật cuối cùng do sim quyết. | |
| i = min(range(len(pks)), key=lambda k: thetas[k]) | |
| keep = [(pks[i], thetas[i])] | |
| picks = [PocketPick(idx=int(p[0]), phi=float(p[1]), cos_cut=float(p[2]), | |
| blocked=bool(p[3]), theta_deg=float(t), | |
| dist=float(np.linalg.norm(pxy[int(p[0])] - tgt))) | |
| for p, t in keep] | |
| picks.sort(key=lambda pk: pk.dist) | |
| return picks[:max(int(n_keep), 0)] | |
| def best_cut_deg(pockets, next_xy, pts): | |
| """Góc ăn bi TỐT NHẤT (độ) từ mỗi điểm ``pts`` vào bi kế, trên MỌI lỗ. | |
| ``cos_cut = dot(unit(N − c), unit(q − N))`` — cùng định nghĩa với | |
| ``_position_q``; trả góc chứ không trả cos để đọc được cạnh ``θ_max``. | |
| KHÔNG lọc bi chắn: đây là câu hỏi về GÓC. Trả mảng ``(M,)``; ``nan`` ở | |
| điểm trùng vị trí bi kế. | |
| """ | |
| import numpy as np | |
| P = np.asarray(pts, dtype=np.float64).reshape(-1, 2) | |
| Q = np.asarray(pockets, dtype=np.float64).reshape(-1, 2) | |
| N = np.asarray(next_xy, dtype=np.float64).reshape(2) | |
| V = Q - N | |
| vn = np.linalg.norm(V, axis=-1) | |
| V = V[vn > 1e-9] / vn[vn > 1e-9, None] | |
| U = N - P | |
| un = np.linalg.norm(U, axis=-1) | |
| safe = un > 1e-9 | |
| out = np.full(P.shape[0], np.nan) | |
| if V.size == 0 or not safe.any(): | |
| return out | |
| cos = (U[safe] / un[safe, None]) @ V.T | |
| out[safe] = np.degrees(np.arccos(np.clip(cos.max(axis=1), -1.0, 1.0))) | |
| return out | |
| # ---------------------------------------------------------------- ứng viên | |
| def v2_specs(v0_grid=None, spin_grid=None, techniques=TECHNIQUES): | |
| """99 ứng viên ``(side, vert, v0)`` = 11 kỹ thuật × NẤC LỰC 2–10, thứ tự | |
| CỐ ĐỊNH (kỹ thuật × V0 tăng dần) — khoá phá-hoà-cuối của §8.2 đọc thứ tự | |
| này. | |
| Khác ``candidate_specs`` V1 đúng một chỗ: cắt nấc 1 (``v0_grid[0]`` = | |
| 0.5 m/s) ngay ở TẬP ứng viên — V2 không có "sim đủ 10 nấc rồi lọc lúc | |
| chấm" vì không còn cột chẩn đoán dải lực nào phải nuôi (design §8.1 (v)). | |
| Giá trị spin vẫn là PHẦN TỬ LƯỚI qua ``snap_to_grid``, không phải literal. | |
| """ | |
| v0s = default_v0_grid() if v0_grid is None else v0_grid | |
| sps = default_spin_grid() if spin_grid is None else spin_grid | |
| out = [] | |
| for side, vert in techniques: | |
| s = snap_to_grid(side, sps) | |
| v = snap_to_grid(vert, sps) | |
| out += [(s, v, float(x)) for x in list(v0s)[1:]] | |
| return out | |
| def force_ok(v0, v0_grid=None): | |
| """(v) — ``v0`` có thuộc nấc 2–10 của grid không, so bằng GIÁ TRỊ GRID. | |
| ``v2_specs`` đã enumerate đúng nấc 2–10 nên hàm này không bao giờ loại | |
| thêm gì trên đường chạy thật; nó tồn tại để điều kiện (v) KIỂM ĐƯỢC độc | |
| lập (gate G3.3: ``v0_grid[0]`` = 0.5 bị loại, ``v0_grid[1]`` = 0.8889 | |
| qua) và để ai đổi tập ứng viên sau này vẫn còn một lưới chắn. | |
| """ | |
| import numpy as np | |
| g = np.asarray(default_v0_grid() if v0_grid is None else v0_grid, | |
| dtype=np.float64).ravel() | |
| return bool(np.isclose(g[1:], float(v0), rtol=0.0, atol=1e-9).any()) | |
| # ------------------------------------------------- lọc từ events (i)/(iii) | |
| def cue_hits_after_contact(bb_events): | |
| """(iii) — có event BALL_BALL nào dính ``cue`` SAU cú chạm đầu của cue | |
| không. Double-kiss bi mục tiêu cũng là một event như thế. Chạm băng không | |
| phải BALL_BALL nên tự do.""" | |
| seen_first = False | |
| for _t, ids in bb_events or (): | |
| if "cue" in ids: | |
| if seen_first: | |
| return True | |
| seen_first = True | |
| return False | |
| def target_path_dirty(bb_events, target): | |
| """(i), vế đường đi — bi mục tiêu có dính event BALL_BALL nào NGOÀI cú | |
| chạm đầu của cue không. | |
| Cú chạm đầu của cue được miễn dù trúng ai: trúng target là chính cú đánh, | |
| trúng bi khác là foul và (i) vế foul đã giết ở chỗ khác — kể nó vào đây | |
| chỉ làm một ứng viên chết hai lần với hai nhãn.""" | |
| seen_first_cue = False | |
| for _t, ids in bb_events or (): | |
| if "cue" in ids and not seen_first_cue: | |
| seen_first_cue = True | |
| continue | |
| if target in ids: | |
| return True | |
| return False | |
| # --------------------------------------------------------------- xếp hạng | |
| def rank_key_v2(d_ideal=D_IDEAL): | |
| """Khoá lexicographic §8.2, sort TĂNG DẦN (``min``/``sorted`` thuận): | |
| đường lăn ngắn → ``|d_đáp − 1 m|`` nhỏ → thứ tự enumerate. | |
| ``d_land is None`` (cú cuối ván, không có bi kế) thế bằng hằng 0.0 — mọi | |
| ứng viên của cùng thế bàn đó cùng hằng, vế 2 tự trung hoà và khoá còn lại | |
| đúng "đường lăn ngắn nhất" như §8.3 chốt. Trả hàm để ``d_ideal`` không | |
| thành hằng số ẩn trong thân sort (cùng nếp ``rank_key`` V1).""" | |
| def key(c): | |
| d = 0.0 if c.d_land is None else abs(c.d_land - d_ideal) | |
| return (c.roll_len, d, c.idx) | |
| return key | |
| class CandidateV2: | |
| """Một ô ``(side, vert, v0)`` đã sim + qua máy lọc V2.""" | |
| side: float | |
| vert: float | |
| v0: float | |
| pocket: int | |
| phi: float | |
| idx: int # thứ tự enumerate trong specs — phá hoà cuối | |
| facts: dict # simulate_shot_multi ∪ judge_shot ∪ tham số cú | |
| ok: bool = False # qua ĐỦ 5 lọc cứng | |
| fails: tuple = () # nhãn lọc đã giết ("i"|"ii"|"iii"|"iv"|"v") | |
| roll_len: Any = None # float — quãng đường bi cái sau va chạm (m) | |
| d_land: Any = None # float | None — |cue_final − bi kế| (m) | |
| cut_next_deg: Any = None # float | None — góc cắt tốt nhất từ điểm đáp | |
| def spin(self) -> float: | |
| return abs(self.side) + abs(self.vert) | |
| class CandidateDebugV2: | |
| """Mask ĐỦ 5 tiêu chí cho MỘT ô ứng viên — chỉ sinh khi | |
| ``plan_shot_v2(debug_candidates=True)`` (04/08/2026, đo phân rã 48%). | |
| Khác ``CandidateV2.fails`` đúng một chỗ: ``fails`` short-circuit (ii)/(iv) | |
| khi (i)/(iii) đã giết — đúng cho đường chạy thật; ``mask`` chấm ĐỦ CẢ 5 để | |
| one-left-out per criterion tính offline được. Với ứng viên (i)/(iii) bẩn, | |
| (ii)/(iv) trong mask vẫn đo từ vị trí bi kế TRƯỚC cú — cùng một công thức | |
| với ``_apply_filters`` (một code path duy nhất, bẫy 27/07), chấp nhận xấp | |
| xỉ vì bi kế có thể đã bị đụng; khai trong HANDOFF của lần đo. | |
| ``mask`` giá trị ``True`` (qua) | ``False`` (trượt) | ``None`` (không đo | |
| được: cue vào lỗ → không có điểm đáp cho (ii)/(iv); sim None → cả (i)-(iv)). | |
| ``pass_all`` = cả 5 đều ``True`` — trùng ``CandidateV2.ok`` từng ứng viên | |
| (test khoá), vì (i) trong mask gộp cả vế ``roll_len is None`` như ``ok``. | |
| """ | |
| pocket: int # index lỗ trong env_h._pockets | |
| pocket_rank: int # 1 | 2 — thứ tự lỗ đã thử (bước 1) | |
| side: float # kỹ thuật (side, vert) + lực v0 = định danh ô | |
| vert: float | |
| v0: float | |
| idx: int # thứ tự enumerate trong specs | |
| sim_none: bool = False # pooltool ném exception → không có facts | |
| mask: dict = field(default_factory=dict) # {"i".."v": True|False|None} | |
| pass_all: bool = False | |
| def _debug_mask(env_h, balls, target, j, m, v0, *, d_min, d_max, | |
| theta_max_deg, v0_grid): | |
| """Chấm ĐỦ 5 tiêu chí độc lập cho một ứng viên ĐÃ SIM — thuần đọc, không | |
| ghi gì vào candidate. Từng vế chép NGUYÊN công thức ``_apply_filters`` | |
| (sửa một trong hai là lệch code path — đừng); chỉ khác là không | |
| short-circuit và không đụng ``c``.""" | |
| import numpy as np | |
| direct = (not j["foul"]) and (target in m["potted"]) \ | |
| and (list(m["potted"]) == [target]) \ | |
| and not target_path_dirty(m.get("bb_events"), target) | |
| mask = { | |
| # gộp vế roll_len như `ok` (nhãn "i" của _apply_filters): thiếu khoá | |
| # xếp hạng = không thành cú được, gốc là "không có cú chạm" | |
| "i": bool(direct and m.get("cue_roll_after") is not None), | |
| "iii": not cue_hits_after_contact(m.get("bb_events")), | |
| "v": force_ok(v0, v0_grid), | |
| } | |
| nxt = j["next"] | |
| cue_final = (m["balls_final"] or {}).get("cue") | |
| if nxt is None: | |
| mask["ii"] = True # cú cuối ván: nêm vô nghĩa, (iv) BỎ — §8.3 | |
| mask["iv"] = True | |
| elif cue_final is None: | |
| mask["ii"] = None # cue vào lỗ — không có điểm đáp, không đo được | |
| mask["iv"] = None | |
| else: | |
| nxt_xy = balls[nxt] | |
| d = float(np.linalg.norm( | |
| np.asarray(cue_final, dtype=np.float64).reshape(2) | |
| - np.asarray(nxt_xy, dtype=np.float64).reshape(2))) | |
| cut = float(best_cut_deg(env_h._pockets, nxt_xy, [cue_final])[0]) | |
| mask["ii"] = bool(cut == cut and cut <= theta_max_deg) # NaN → trượt | |
| mask["iv"] = bool(d_min <= d <= d_max) | |
| return mask | |
| MASK_KEYS = ("i", "ii", "iii", "iv", "v") | |
| class PocketV2Report: | |
| """Chẩn đoán MỘT lỗ đã thử — có cả khi lỗ không ra cú nào. ``fail_counts`` | |
| đếm TỪNG tiêu chí độc lập (một ứng viên chết nhiều lọc thì tính cho mọi | |
| lọc đã giết nó): con số phục vụ câu hỏi "vòng thắt nào thắt" của §8.4(b), | |
| không phải phép cộng chéo về tổng.""" | |
| pick: Any # PocketPick (bước 1) | |
| rank: int # 1 = lỗ gần nhất | |
| n_cand: int # số ô đã sim | |
| n_sim_none: int # sim ném exception → pooltool trả None | |
| n_pass: int # qua đủ 5 lọc | |
| fail_counts: dict = field(default_factory=dict) | |
| class PlanV2Result: | |
| """Kết quả V2 cho MỘT thế bàn. ``shot is None`` = hết đường — một câu trả | |
| lời nằm trong dự tính (design §8.1), không phải lỗi.""" | |
| shot: Any = None # CandidateV2 | None | |
| #: Ứng viên QUA LỌC trên lỗ đã chọn — caller xếp top-N bằng ĐÚNG | |
| #: ``rank_key_v2()``; ``sorted(pool, key=key)[0] is shot`` (sort ổn định, | |
| #: ``min`` trả phần tử cực tiểu đầu tiên — cùng bất biến ``PlanResult.pool`` | |
| #: của V1, cùng lý do: không nhân bản vòng lặp bước 4 lần thứ hai). | |
| pool: list = field(default_factory=list) | |
| pocket_rank: Any = None # 1 | 2 | None | |
| n_tie_top1: Any = None # số ứng viên hoà top-1 TRƯỚC khoá enumerate | |
| picks: list = field(default_factory=list) # lỗ sau bước 1 (đã cắt 2) | |
| n_cand: int = 0 # tổng ô đã sim, mọi lỗ đã thử | |
| pockets: list = field(default_factory=list) # [PocketV2Report] | |
| #: [CandidateDebugV2] — CHỈ điền khi ``debug_candidates=True`` (04/08); | |
| #: flag tắt luôn là []. Mở rộng thuần: không nhánh nào của engine đọc nó. | |
| debug_candidates: list = field(default_factory=list) | |
| def plan_shot_v2(env_h, balls, target, *, pockets=None, v0_grid=None, | |
| spin_grid=None, d_min=D_MIN, d_max=D_MAX, | |
| theta_max_deg=THETA_MAX_DEG, | |
| theta_pot_max_deg=THETA_POT_MAX_DEG, | |
| n_pockets=N_POCKETS_KEEP, key=None, | |
| debug_candidates=False): | |
| """Chạy bước 1 (chọn lỗ như V1) + máy lọc/xếp V2 trên MỘT thế bàn. | |
| KHÔNG nhận ``zm``: V2 không có bản đồ raster (design §8.2 — ``dt`` xoá). | |
| Bi kế cho (ii)(iv) và ``d_đáp`` đọc từ ``judge_shot()["next"]`` ở VỊ TRÍ | |
| TRƯỚC cú — hợp lệ vì (i)+(iii) bảo đảm không bi nào ngoài target bị đụng. | |
| ``pockets`` — kết quả ``feasible_pockets_full`` (CHƯA lọc); None → tự gọi. | |
| KHÔNG raise khi hết đường: trả ``PlanV2Result`` với ``shot = None`` và đủ | |
| ``fail_counts`` để biết vòng thắt nào thắt. | |
| ``debug_candidates`` (04/08/2026, mở rộng thuần — tiền lệ ``zm=None`` | |
| 30/07): bật thì ``PlanV2Result.debug_candidates`` mang mask ĐỦ 5 tiêu chí | |
| cho MỌI ô đã enumerate của MỌI lỗ đã thử (xem ``CandidateDebugV2``). | |
| CHỈ THÊM thông tin: không sim thêm ô nào, không đổi lọc/xếp/top-1 — | |
| lỗ 2 vẫn chỉ được thử khi lỗ 1 trắng tay, đúng nếp cũ. | |
| """ | |
| from .geometry import feasible_pockets_full | |
| from .rules import judge_shot | |
| from .simulate import simulate_shot_multi | |
| if pockets is None: | |
| pockets = feasible_pockets_full(env_h, balls, target) | |
| specs = v2_specs(v0_grid, spin_grid) | |
| key = rank_key_v2() if key is None else key | |
| picks = select_pockets(pockets, balls[target], env_h._pockets, | |
| theta_pot_max_deg=theta_pot_max_deg, | |
| n_keep=n_pockets) | |
| out = PlanV2Result(picks=picks) | |
| for rank, pk in enumerate(picks, start=1): | |
| cands, n_none = [], 0 | |
| n_fail = {"i": 0, "ii": 0, "iii": 0, "iv": 0, "v": 0} | |
| for idx, (side, vert, v0) in enumerate(specs): | |
| m = simulate_shot_multi(env_h, balls, pk.phi, v0, side, vert) | |
| out.n_cand += 1 | |
| if m is None: | |
| n_none += 1 | |
| if debug_candidates: | |
| # không facts thì (i)-(iv) không đo được; (v) thuần tham | |
| # số nên vẫn chấm — đừng bịa False cho phần còn lại | |
| out.debug_candidates.append(CandidateDebugV2( | |
| pocket=pk.idx, pocket_rank=rank, side=side, | |
| vert=vert, v0=v0, idx=idx, sim_none=True, | |
| mask={"i": None, "ii": None, "iii": None, "iv": None, | |
| "v": force_ok(v0, v0_grid)}, | |
| pass_all=False)) | |
| continue | |
| j = judge_shot(env_h, m, balls, target) | |
| facts = dict(m) | |
| facts.update(j) | |
| facts.update(pocket=pk.idx, phi=pk.phi, v0=v0, side=side, | |
| vert=vert) | |
| c = CandidateV2(side=side, vert=vert, v0=v0, pocket=pk.idx, | |
| phi=pk.phi, idx=idx, facts=facts) | |
| _apply_filters(env_h, c, balls, target, j, m, d_min=d_min, | |
| d_max=d_max, theta_max_deg=theta_max_deg, | |
| v0_grid=v0_grid) | |
| for lab in c.fails: | |
| n_fail[lab] += 1 | |
| cands.append(c) | |
| if debug_candidates: | |
| mask = _debug_mask(env_h, balls, target, j, m, v0, | |
| d_min=d_min, d_max=d_max, | |
| theta_max_deg=theta_max_deg, | |
| v0_grid=v0_grid) | |
| out.debug_candidates.append(CandidateDebugV2( | |
| pocket=pk.idx, pocket_rank=rank, side=side, vert=vert, | |
| v0=v0, idx=idx, sim_none=False, mask=mask, | |
| pass_all=all(mask[k] is True for k in MASK_KEYS))) | |
| passed = [c for c in cands if c.ok] | |
| out.pockets.append(PocketV2Report( | |
| pick=pk, rank=rank, n_cand=len(specs), n_sim_none=n_none, | |
| n_pass=len(passed), fail_counts=n_fail)) | |
| if not passed: | |
| continue # lỗ này hết đường → thử lỗ thứ 2 (nếu có) | |
| best = min(passed, key=key) | |
| out.shot = best | |
| out.pool = list(passed) | |
| out.pocket_rank = rank | |
| # hoà "thật" đếm TRƯỚC khoá enumerate — khoá đủ (có idx) thì không | |
| # bao giờ hoà, và n_tie_top1 sẽ luôn là 1, tức không đo được gì. | |
| k2_best = key(best)[:-1] | |
| out.n_tie_top1 = sum(1 for c in passed if key(c)[:-1] == k2_best) | |
| break | |
| return out | |
| def _apply_filters(env_h, c, balls, target, j, m, *, d_min, d_max, | |
| theta_max_deg, v0_grid): | |
| """Chấm 5 lọc cứng cho MỘT ứng viên — điền ``ok``/``fails`` + số đo dẫn | |
| xuất (``roll_len``/``d_land``/``cut_next_deg``) vào ``c`` tại chỗ. | |
| (ii)/(iv) chỉ chấm được trên ứng viên có (i)+(iii) sạch: lúc đó bi kế | |
| chắc chắn chưa bị đụng (vị trí trước cú vẫn là vị trí thật) và | |
| ``cue_final`` là một điểm đáp có nghĩa. Ứng viên đã foul/đụng bi thì | |
| ``d_đáp`` không định nghĩa được — đừng đọc thành một con số. Hệ quả cho | |
| ``fail_counts``: đếm (ii)/(iv) là đếm TRONG các cú pot sạch, đúng câu hỏi | |
| "vòng thắt nào thắt" của §8.4(b).""" | |
| import numpy as np | |
| fails = [] | |
| # (i) — ăn trực tiếp: không foul (đúng bi đầu, không scratch), target vào | |
| # lỗ, KHÔNG bi nào khác vào lỗ kèm, đường bi mục tiêu sạch. | |
| direct = (not j["foul"]) and (target in m["potted"]) \ | |
| and (list(m["potted"]) == [target]) \ | |
| and not target_path_dirty(m.get("bb_events"), target) | |
| if not direct: | |
| fails.append("i") | |
| # (iii) — bi cái sau va chạm không đụng bi nào (kể cả double-kiss) | |
| if cue_hits_after_contact(m.get("bb_events")): | |
| fails.append("iii") | |
| # (v) — nấc lực, so bằng giá trị grid | |
| if not force_ok(c.v0, v0_grid): | |
| fails.append("v") | |
| # (ii) nêm 45° + (iv) dải d — cần điểm đáp và bi kế. `next` của judge là | |
| # bi nhỏ nhất còn lại SAU cú; vị trí lấy TRƯỚC cú (bi kế không bị đụng | |
| # khi (i)+(iii) đứng). None = cú cuối ván: nêm vô nghĩa, (iv) BỎ (§8.3). | |
| nxt = j["next"] | |
| cue_final = (m["balls_final"] or {}).get("cue") | |
| if (nxt is not None and cue_final is not None | |
| and "i" not in fails and "iii" not in fails): | |
| nxt_xy = balls[nxt] | |
| c.d_land = float(np.linalg.norm( | |
| np.asarray(cue_final, dtype=np.float64).reshape(2) | |
| - np.asarray(nxt_xy, dtype=np.float64).reshape(2))) | |
| cut = float(best_cut_deg(env_h._pockets, nxt_xy, [cue_final])[0]) | |
| c.cut_next_deg = cut | |
| if not (cut == cut and cut <= theta_max_deg): # NaN cũng loại | |
| fails.append("ii") | |
| if not (d_min <= c.d_land <= d_max): | |
| fails.append("iv") | |
| c.roll_len = m.get("cue_roll_after") | |
| c.fails = tuple(fails) | |
| # roll_len là khoá xếp hạng số 1 — ứng viên hợp lệ mà sim không trả được | |
| # nó (không có cú chạm? không thể khi (i) đứng) thì không xếp được hạng, | |
| # coi như không qua. Ghi nhãn "i" vì gốc của nó là "không có cú chạm". | |
| c.ok = not fails and c.roll_len is not None | |
| if not fails and c.roll_len is None: | |
| c.fails = ("i",) | |