From ea30c7d838581a71e54ee5120c6f90377a798974 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 16:38:20 -0400 Subject: [PATCH] 05/23 Phase 20 --- .claude/settings.local.json | 3 +- CLAUDE.md | 16 +++ core/wheeling.py | 49 +++++++ data/lottosight.db | Bin 557056 -> 557056 bytes tests/test_wheeling.py | 98 ++++++++++++++ ui/predictor_ui.py | 262 +++++++++++++++++++++++++++++++++++- 6 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 core/wheeling.py create mode 100644 tests/test_wheeling.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4a34f60..437f766 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(python -m pytest tests/ -q)", "Bash(python -c ' *)", "Bash(python -m pytest tests/test_backup.py -v)", - "Bash(python -m pytest --tb=short -q)" + "Bash(python -m pytest --tb=short -q)", + "Bash(python -m pytest tests/test_wheeling.py -v)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index f7c24da..dafd74f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -341,6 +341,22 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 20 — Number Wheeling System +- [x] Write `core/wheeling.py` + - [x] `wheel_count(numbers, k) -> int` — C(n, k) preview, deduplicates input + - [x] `wheel_full(numbers, k) -> list[list[int]]` — all combinations, each sorted ascending + - [x] Raises `ValueError` for invalid k, k > pool size, or count > `MAX_TICKETS` (200) +- [x] Update `ui/predictor_ui.py` + - [x] Add "Wheel" as 4th tab in ttk.Notebook + - [x] Game dropdown (synced on refresh), Numbers entry, Pick spinbox (defaults to game's main_count) + - [x] Live preview label: "Will generate X tickets (C(n,k))" updates as user types + - [x] Range validation against game's main_max before generating + - [x] Results treeview (#, Numbers) + BallsBar detail strip on row select + - [x] Save to DB (strategy="Wheel") + Copy + Clear buttons +- [x] Write `tests/test_wheeling.py` — 18 tests (379/379 total passing) + +--- + ### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup - [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed - [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing diff --git a/core/wheeling.py b/core/wheeling.py new file mode 100644 index 0000000..37a9365 --- /dev/null +++ b/core/wheeling.py @@ -0,0 +1,49 @@ +""" +core/wheeling.py +---------------- +Full-cover number wheeling. + +wheel_count(numbers, k) — C(n, k) ticket count preview +wheel_full(numbers, k) — all C(n, k) sorted combinations + +Raises ValueError for invalid inputs or if result exceeds MAX_TICKETS. +""" + +from itertools import combinations +from math import comb + +MAX_TICKETS = 200 + + +def wheel_count(numbers: list | set, k: int) -> int: + """Return how many tickets a full wheel would produce (C(n, k)).""" + n = len(set(numbers)) + if k < 1 or k > n: + return 0 + return comb(n, k) + + +def wheel_full(numbers: list | set, k: int) -> list[list[int]]: + """ + Generate all C(n, k) combinations from *numbers*, each sorted ascending. + Duplicates in input are removed before wheeling. + Raises ValueError if k is out of range or count > MAX_TICKETS. + """ + pool = sorted(set(numbers)) + n = len(pool) + + if k < 1: + raise ValueError("Pick count must be at least 1.") + if k > n: + raise ValueError( + f"Pick count ({k}) exceeds the number pool size ({n})." + ) + + count = comb(n, k) + if count > MAX_TICKETS: + raise ValueError( + f"Wheel would produce {count:,} tickets (max {MAX_TICKETS:,}). " + f"Reduce your pool or pick count." + ) + + return [sorted(combo) for combo in combinations(pool, k)] diff --git a/data/lottosight.db b/data/lottosight.db index 9a7da8730982e9aae072eccbb9c5cc61a6a08d82..67113b2f9c12ef71937b1b249c012b04bf0a1c1f 100644 GIT binary patch delta 177 zcmZo@P-%$BfOP~ZnElcB+6gAOG@BLgdABP&D0;?m^g)Z$`e{mF)1O2RNv z&J$2^1HDPz@=##|OZMqdVMASzAKfwq35B{m~Bquf{by delta 101 zcmZo@P-Kj diff --git a/tests/test_wheeling.py b/tests/test_wheeling.py new file mode 100644 index 0000000..675ef87 --- /dev/null +++ b/tests/test_wheeling.py @@ -0,0 +1,98 @@ +""" +tests/test_wheeling.py +----------------------- +Tests for core/wheeling.py — full-cover wheel generation. +""" + +import pytest +from core.wheeling import wheel_full, wheel_count, MAX_TICKETS + + +# ── wheel_count ─────────────────────────────────────────────────────────────── + +def test_wheel_count_basic(): + assert wheel_count([1, 2, 3, 4, 5, 6], 5) == 6 # C(6,5) + +def test_wheel_count_exact_pick(): + assert wheel_count([1, 2, 3, 4, 5], 5) == 1 # C(5,5) + +def test_wheel_count_larger(): + assert wheel_count(list(range(1, 10)), 5) == 126 # C(9,5) + +def test_wheel_count_k_zero_returns_zero(): + assert wheel_count([1, 2, 3, 4, 5], 0) == 0 + +def test_wheel_count_k_exceeds_n_returns_zero(): + assert wheel_count([1, 2, 3], 5) == 0 + +def test_wheel_count_deduplicates_input(): + assert wheel_count([1, 1, 2, 3, 4, 5], 5) == 1 # C(5,5) after dedup + + +# ── wheel_full — valid cases ────────────────────────────────────────────────── + +def test_wheel_full_ticket_count(): + tickets = wheel_full([1, 2, 3, 4, 5, 6], 5) + assert len(tickets) == 6 + +def test_wheel_full_single_ticket(): + tickets = wheel_full([5, 14, 22, 36, 69], 5) + assert len(tickets) == 1 + assert tickets[0] == [5, 14, 22, 36, 69] + +def test_wheel_full_each_ticket_sorted(): + tickets = wheel_full([10, 3, 7, 1, 5, 2], 4) + for t in tickets: + assert t == sorted(t) + +def test_wheel_full_no_duplicate_tickets(): + tickets = wheel_full(list(range(1, 9)), 5) # C(8,5) = 56 + as_tuples = [tuple(t) for t in tickets] + assert len(as_tuples) == len(set(as_tuples)) + +def test_wheel_full_all_numbers_in_pool(): + pool = [5, 14, 22, 36, 55, 69] + tickets = wheel_full(pool, 4) + for t in tickets: + for n in t: + assert n in pool + +def test_wheel_full_deduplicates_input(): + # [1,1,2,3,4,5] → pool [1,2,3,4,5] → C(5,5) = 1 + tickets = wheel_full([1, 1, 2, 3, 4, 5], 5) + assert len(tickets) == 1 + +def test_wheel_full_at_cap(monkeypatch): + import core.wheeling as wm + monkeypatch.setattr(wm, "MAX_TICKETS", 56) + # C(8,5) = 56 exactly at cap — should pass + tickets = wm.wheel_full(list(range(1, 9)), 5) + assert len(tickets) == 56 + +def test_wheel_full_returns_list_of_lists(): + tickets = wheel_full([1, 2, 3, 4, 5, 6], 5) + assert isinstance(tickets, list) + for t in tickets: + assert isinstance(t, list) + + +# ── wheel_full — error cases ────────────────────────────────────────────────── + +def test_wheel_full_k_zero_raises(): + with pytest.raises(ValueError, match="at least 1"): + wheel_full([1, 2, 3, 4, 5], 0) + +def test_wheel_full_k_exceeds_n_raises(): + with pytest.raises(ValueError, match="exceeds"): + wheel_full([1, 2, 3], 5) + +def test_wheel_full_exceeds_cap_raises(): + # C(10,5) = 252 > MAX_TICKETS (200) + with pytest.raises(ValueError, match="252"): + wheel_full(list(range(1, 11)), 5) + +def test_wheel_full_error_message_includes_count(): + with pytest.raises(ValueError) as exc_info: + wheel_full(list(range(1, 11)), 5) + assert "252" in str(exc_info.value) + assert str(MAX_TICKETS) in str(exc_info.value) diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py index 3effb69..3220ad0 100644 --- a/ui/predictor_ui.py +++ b/ui/predictor_ui.py @@ -1,11 +1,12 @@ """ ui/predictor_ui.py ------------------ -Prediction generator screen — two tabs inside a ttk.Notebook. +Prediction generator screen — four tabs inside a ttk.Notebook. - Generate tab — pick strategy + game + count → generate tickets → save to DB - Saved tab — browse all saved predictions, see match count vs last real - draw, delete individual rows or clear all + Generate tab — pick strategy + game + count → generate tickets → save to DB + Saved tab — browse all saved predictions, see match count vs last draw + Check Ticket tab — compare a user ticket against all historical draws + Wheel tab — full-cover wheeling: pick N numbers → all C(N,k) tickets """ import os @@ -22,6 +23,7 @@ from core.predictor import ( hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, quick_pick, ) from core.checker import check_ticket, parse_numbers +from core.wheeling import wheel_full, wheel_count, MAX_TICKETS from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir from ui.widgets import BallsBar @@ -48,6 +50,17 @@ _DESCRIPTIONS = { _TICKET_COUNTS = [str(n) for n in range(1, 11)] +def _parse_raw_numbers(raw: str) -> list[int]: + """Parse space- or comma-separated integers from a string, ignoring non-numeric tokens.""" + result = [] + for tok in raw.replace(",", " ").split(): + try: + result.append(int(tok)) + except ValueError: + pass + return result + + def _count_matches(pred_numbers: str, last_draw) -> str: """Compare prediction numbers to last draw, return 'X/5' string.""" if last_draw is None: @@ -67,6 +80,7 @@ class PredictorScreen(ttk.Frame): self._game_id: int | None = None self._tickets: list[dict] = [] self._strategy_name: str = "Hot Numbers" + self._wheel_tickets: list[list[int]] = [] self._build_ui() # ── UI construction ─────────────────────────────────────────────────────── @@ -78,13 +92,16 @@ class PredictorScreen(ttk.Frame): gen_frame = ttk.Frame(nb) saved_frame = ttk.Frame(nb) check_frame = ttk.Frame(nb) + wheel_frame = ttk.Frame(nb) nb.add(gen_frame, text="Generate") nb.add(saved_frame, text="Saved") nb.add(check_frame, text="Check Ticket") + nb.add(wheel_frame, text="Wheel") self._build_generate_tab(gen_frame) self._build_saved_tab(saved_frame) self._build_check_tab(check_frame) + self._build_wheel_tab(wheel_frame) # ── Generate tab ────────────────────────────────────────────────────────── @@ -248,6 +265,7 @@ class PredictorScreen(ttk.Frame): def refresh(self): self._load_games() self._load_check_games() + self._load_wheel_games() self._refresh_saved() def _load_games(self): @@ -705,6 +723,242 @@ class PredictorScreen(ttk.Frame): w.destroy() self._chk_summary_var.set("") + # ── Wheel tab ───────────────────────────────────────────────────────────── + + def _build_wheel_tab(self, parent): + bar = ttk.Frame(parent, padding=(6, 8, 6, 4)) + bar.pack(fill="x") + + ttk.Label(bar, text="Game:").pack(side="left") + self._whl_game_var = tk.StringVar() + self._whl_game_cb = ttk.Combobox( + bar, textvariable=self._whl_game_var, state="readonly", width=15 + ) + self._whl_game_cb.pack(side="left", padx=(4, 14)) + self._whl_game_cb.bind("<>", lambda _: self._on_wheel_game_change()) + + ttk.Label(bar, text="Numbers:").pack(side="left") + self._whl_nums_var = tk.StringVar() + whl_entry = ttk.Entry(bar, textvariable=self._whl_nums_var, width=28) + whl_entry.pack(side="left", padx=(4, 4)) + whl_entry.bind("", lambda e: self._run_wheel()) + self._whl_nums_var.trace_add("write", lambda *_: self._update_wheel_preview()) + + ttk.Label(bar, text="Pick:").pack(side="left") + self._whl_pick_var = tk.IntVar(value=5) + self._whl_pick_sb = ttk.Spinbox( + bar, from_=1, to=10, textvariable=self._whl_pick_var, width=4, + command=self._update_wheel_preview, + ) + self._whl_pick_sb.pack(side="left", padx=(4, 14)) + self._whl_pick_var.trace_add("write", lambda *_: self._update_wheel_preview()) + + self._whl_btn = ttk.Button(bar, text="Generate Wheel", command=self._run_wheel) + self._whl_btn.pack(side="left", padx=(0, 4)) + ttk.Button(bar, text="Clear", command=self._clear_wheel).pack(side="left") + + # Preview + hint + hint = ttk.Frame(parent, padding=(6, 0, 6, 4)) + hint.pack(fill="x") + self._whl_preview_var = tk.StringVar( + value=f"Enter more than pick count numbers. Max {MAX_TICKETS} tickets." + ) + ttk.Label(hint, textvariable=self._whl_preview_var, + foreground="#888888", font=("TkDefaultFont", 8)).pack(anchor="w") + + # Results treeview + tree_frame = ttk.Frame(parent) + tree_frame.pack(fill="both", expand=True, padx=6, pady=(0, 4)) + + w_cols = ("#", "numbers") + self._whl_tree = ttk.Treeview( + tree_frame, columns=w_cols, show="headings", selectmode="browse" + ) + self._whl_tree.heading("#", text="#") + self._whl_tree.heading("numbers", text="Numbers") + self._whl_tree.column("#", width=50, anchor="center", stretch=False) + self._whl_tree.column("numbers", width=300, anchor="w") + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._whl_tree.yview) + self._whl_tree.configure(yscrollcommand=vsb.set) + self._whl_tree.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + self._whl_tree.bind("<>", self._on_wheel_row_select) + + # Ball detail strip + ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6) + self._whl_detail = ttk.Frame(parent, padding=(8, 3)) + self._whl_detail.pack(fill="x") + + # Bottom bar + bottom = ttk.Frame(parent, padding=(6, 4)) + bottom.pack(fill="x") + + self._whl_status_var = tk.StringVar() + ttk.Label(bottom, textvariable=self._whl_status_var, + foreground="#27ae60").pack(side="left", fill="x", expand=True) + + self._whl_save_btn = ttk.Button( + bottom, text="Save to DB", command=self._save_wheel, state="disabled" + ) + self._whl_save_btn.pack(side="right") + self._whl_copy_btn = ttk.Button( + bottom, text="Copy", command=self._copy_wheel, state="disabled" + ) + self._whl_copy_btn.pack(side="right", padx=(0, 4)) + + def _load_wheel_games(self): + games = get_all_games(active_only=True) + names = [g["name"] for g in games] + self._whl_game_cb["values"] = names + if not self._whl_game_var.get() or self._whl_game_var.get() not in names: + if names: + self._whl_game_var.set(names[0]) + self._on_wheel_game_change() + + def _on_wheel_game_change(self): + game = get_game_by_name(self._whl_game_var.get()) + if game: + self._whl_pick_var.set(game["main_count"]) + self._clear_wheel() + self._update_wheel_preview() + + def _update_wheel_preview(self): + raw = self._whl_nums_var.get().strip() + nums = _parse_raw_numbers(raw) + try: + k = int(self._whl_pick_var.get()) + except (ValueError, tk.TclError): + self._whl_preview_var.set("Invalid pick count.") + return + + if not nums: + self._whl_preview_var.set( + f"Enter more than pick count numbers. Max {MAX_TICKETS} tickets." + ) + return + + n = len(set(nums)) + if k < 1 or k > n: + self._whl_preview_var.set(f"Pick count must be between 1 and {n}.") + return + + from math import comb + count = comb(n, k) + if count > MAX_TICKETS: + self._whl_preview_var.set( + f"Would generate {count:,} tickets — exceeds limit of {MAX_TICKETS}. " + f"Reduce pool or pick count." + ) + else: + self._whl_preview_var.set( + f"Will generate {count} ticket{'s' if count != 1 else ''} (C({n},{k}))." + ) + + def _run_wheel(self): + self._whl_status_var.set("") + raw = self._whl_nums_var.get().strip() + nums = _parse_raw_numbers(raw) + if not nums: + self._whl_status_var.set("Enter numbers first.") + return + + game = get_game_by_name(self._whl_game_var.get()) + if game is None: + self._whl_status_var.set("Select a game first.") + return + + try: + k = int(self._whl_pick_var.get()) + except (ValueError, tk.TclError): + self._whl_status_var.set("Invalid pick count.") + return + + # Validate number ranges + bad = [n for n in nums if not (1 <= n <= game["main_max"])] + if bad: + self._whl_status_var.set( + f"Numbers out of range 1–{game['main_max']}: {sorted(set(bad))}" + ) + return + + try: + tickets = wheel_full(nums, k) + except ValueError as e: + self._whl_status_var.set(str(e)) + return + + self._wheel_tickets = tickets + self._whl_tree.delete(*self._whl_tree.get_children()) + for w in self._whl_detail.winfo_children(): + w.destroy() + + for i, combo in enumerate(tickets, 1): + nums_str = " ".join(f"{n:02d}" for n in combo) + self._whl_tree.insert("", "end", values=(i, nums_str)) + + self._whl_save_btn.config(state="normal") + self._whl_copy_btn.config(state="normal") + n_pool = len(set(nums)) + self._whl_status_var.set( + f"{len(tickets)} ticket{'s' if len(tickets) != 1 else ''} — " + f"full cover of {n_pool} numbers pick {k}." + ) + logger.info("[WHEEL] Generated %d tickets from %d-number pool", len(tickets), n_pool) + + def _on_wheel_row_select(self, _event=None): + for w in self._whl_detail.winfo_children(): + w.destroy() + sel = self._whl_tree.selection() + if not sel: + return + vals = self._whl_tree.item(sel[0], "values") + try: + nums = [int(x) for x in str(vals[1]).split() if x.isdigit()] + except Exception: + return + if nums: + BallsBar(self._whl_detail, numbers=nums).pack(anchor="w") + + def _save_wheel(self): + if not self._wheel_tickets: + return + game = get_game_by_name(self._whl_game_var.get()) + if game is None: + return + for combo in self._wheel_tickets: + insert_prediction(game["id"], "Wheel", combo, bonus=None) + saved = len(self._wheel_tickets) + self._whl_status_var.set(f"Saved {saved} ticket{'s' if saved != 1 else ''} to DB.") + self._whl_save_btn.config(state="disabled") + self._refresh_saved() + logger.info("[WHEEL] Saved %d wheel tickets to DB", saved) + + def _copy_wheel(self): + if not self._wheel_tickets: + return + lines = [ + f"Ticket {i}: {' '.join(f'{n:02d}' for n in combo)}" + for i, combo in enumerate(self._wheel_tickets, 1) + ] + self.clipboard_clear() + self.clipboard_append("\n".join(lines)) + self._whl_status_var.set("Copied to clipboard.") + + def _clear_wheel(self): + self._wheel_tickets = [] + if hasattr(self, "_whl_tree"): + self._whl_tree.delete(*self._whl_tree.get_children()) + if hasattr(self, "_whl_detail"): + for w in self._whl_detail.winfo_children(): + w.destroy() + if hasattr(self, "_whl_save_btn"): + self._whl_save_btn.config(state="disabled") + if hasattr(self, "_whl_copy_btn"): + self._whl_copy_btn.config(state="disabled") + if hasattr(self, "_whl_status_var"): + self._whl_status_var.set("") + def _load_check_games(self): games = get_all_games(active_only=True) names = [g["name"] for g in games]