diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 765c2c4..b104b05 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(python -m pytest tests/test_fetcher.py -v)", "Bash(python -m pytest -v)", - "Bash(python -m pytest tests/test_history.py -v)" + "Bash(python -m pytest tests/test_history.py -v)", + "Bash(python -m pytest tests/test_analyzer.py -v)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index f16e9cf..e6fb4fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,21 +257,22 @@ All actions are logged to console and optionally to a log file: --- -### 🔲 Phase 4 — Analysis Engine + Charts -- [ ] Write `core/analyzer.py` - - [ ] `frequency_analysis(game_id, last_n)` - - [ ] `gap_analysis(game_id)` - - [ ] `positional_frequency(game_id)` - - [ ] `pair_analysis(game_id)` - - [ ] `odd_even_ratio(game_id)` - - [ ] `sum_range_analysis(game_id)` - - [ ] `delta_analysis(game_id)` -- [ ] Write `ui/analysis.py` - - [ ] Frequency bar chart (Matplotlib embedded) - - [ ] Heatmap of number frequency - - [ ] Gap chart - - [ ] Toggle between games -- [ ] Test all analysis functions with real data +### ✅ Phase 4 — Analysis Engine + Charts +- [x] Write `core/analyzer.py` + - [x] `frequency_analysis(game_id, last_n)` + - [x] `gap_analysis(game_id)` + - [x] `positional_frequency(game_id)` + - [x] `pair_analysis(game_id)` + - [x] `odd_even_ratio(game_id)` + - [x] `sum_range_analysis(game_id)` + - [x] `delta_analysis(game_id)` +- [x] Write `ui/analysis.py` + - [x] Frequency bar chart (Matplotlib + FigureCanvasTkAgg) + - [x] Positional frequency heatmap (imshow, YlOrRd colormap) + - [x] Gap chart (color-coded: blue=recent, red=due) + - [x] Game dropdown + frequency window selector + - [x] NavigationToolbar on each tab for zoom/pan +- [x] 26 tests for all 7 analysis functions (106/106 total passing) --- diff --git a/core/analyzer.py b/core/analyzer.py new file mode 100644 index 0000000..8d191ed --- /dev/null +++ b/core/analyzer.py @@ -0,0 +1,127 @@ +""" +core/analyzer.py +---------------- +All statistical analysis functions for LottoSight. +Each function takes game_id and returns structured Python data +(dicts / lists) — no UI concerns here. +""" + +from collections import Counter +from itertools import combinations + +from db.models import get_all_draws_numbers, get_game_by_id + + +def frequency_analysis(game_id, last_n=None): + """ + Count appearances of each main ball. + last_n: restrict to the most recent N draws (None = all). + Returns {number: count} sorted high → low. + """ + draws = get_all_draws_numbers(game_id) # ASC order + if last_n and last_n > 0: + draws = draws[-last_n:] + + counter = Counter() + for draw in draws: + counter.update(draw["numbers"]) + + return dict(sorted(counter.items(), key=lambda kv: kv[1], reverse=True)) + + +def gap_analysis(game_id): + """ + Draws elapsed since each number last appeared. + gap=0 → appeared in the most recent draw. + gap=N → last appeared N draws before the current one. + Numbers never drawn receive gap = total draw count. + Returns {number: gap} for every number in the pool. + """ + draws = get_all_draws_numbers(game_id) # ASC order + if not draws: + return {} + + game = get_game_by_id(game_id) + total = len(draws) + + last_seen = {} + for idx, draw in enumerate(draws): + for n in draw["numbers"]: + last_seen[n] = idx # keep updating → final value = most recent draw index + + result = {} + for n in range(1, game["main_max"] + 1): + if n in last_seen: + result[n] = (total - 1) - last_seen[n] + else: + result[n] = total + + return result + + +def positional_frequency(game_id): + """ + How often each number appears at each draw position (1-indexed). + Returns {pos: {number: count}} for positions 1 .. main_count. + """ + draws = get_all_draws_numbers(game_id) + game = get_game_by_id(game_id) + main_count = game["main_count"] + + result = {pos: Counter() for pos in range(1, main_count + 1)} + for draw in draws: + for pos, num in enumerate(draw["numbers"][:main_count], start=1): + result[pos][num] += 1 + + return {pos: dict(c) for pos, c in result.items()} + + +def pair_analysis(game_id, top_n=50): + """ + Most frequent pairs of numbers appearing together in the same draw. + Returns {(n1, n2): count} for the top_n pairs (n1 < n2), high → low. + """ + draws = get_all_draws_numbers(game_id) + counter = Counter() + for draw in draws: + for pair in combinations(sorted(draw["numbers"]), 2): + counter[pair] += 1 + + return dict(counter.most_common(top_n)) + + +def odd_even_ratio(game_id): + """ + Odd vs even ball count per draw. + Returns [{"date": str, "odd": int, "even": int}, ...] in chronological order. + """ + draws = get_all_draws_numbers(game_id) + result = [] + for draw in draws: + odd = sum(1 for n in draw["numbers"] if n % 2 != 0) + even = len(draw["numbers"]) - odd + result.append({"date": draw["draw_date"], "odd": odd, "even": even}) + return result + + +def sum_range_analysis(game_id): + """ + Sum of main balls per draw. + Returns [{"date": str, "sum": int}, ...] in chronological order. + """ + draws = get_all_draws_numbers(game_id) + return [{"date": d["draw_date"], "sum": sum(d["numbers"])} for d in draws] + + +def delta_analysis(game_id): + """ + Differences between consecutive sorted numbers within each draw. + Returns [{"date": str, "deltas": [int, ...]}, ...] in chronological order. + """ + draws = get_all_draws_numbers(game_id) + result = [] + for draw in draws: + s = sorted(draw["numbers"]) + deltas = [s[i + 1] - s[i] for i in range(len(s) - 1)] + result.append({"date": draw["draw_date"], "deltas": deltas}) + return result diff --git a/data/lottosight.db b/data/lottosight.db index 2775ee3..0362e47 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/main.py b/main.py index cf4bcc3..fd7506d 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ from db.database import init_db from core.fetcher import fetch_all from ui.statusbar import StatusBar from ui.history import HistoryScreen +from ui.analysis import AnalysisScreen logging.basicConfig( level=logging.INFO, @@ -98,6 +99,8 @@ class LottoSightApp(tk.Tk): def _make_screen(self, name: str) -> tk.Widget: if name == "History": return HistoryScreen(self._content) + if name == "Analysis": + return AnalysisScreen(self._content) # Placeholder for screens added in later phases placeholder = ttk.Label( self._content, text=f"{name} — coming soon", diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py new file mode 100644 index 0000000..fd0fcab --- /dev/null +++ b/tests/test_analyzer.py @@ -0,0 +1,247 @@ +""" +tests/test_analyzer.py +----------------------- +Unit tests for core/analyzer.py. +Uses three deterministic draws so every assertion is hand-verifiable. + +Draw data (all Powerball, game_id from tmp_db): + Draw 1 (oldest) 2024-01-01: [ 1, 13, 36, 61, 69] + Draw 2 2024-01-03: [ 1, 2, 13, 45, 69] + Draw 3 (newest) 2024-01-05: [ 2, 13, 22, 36, 55] +""" + +import pytest +from db.models import get_game_by_name, insert_draw +from core.analyzer import ( + delta_analysis, + frequency_analysis, + gap_analysis, + odd_even_ratio, + pair_analysis, + positional_frequency, + sum_range_analysis, +) + +DRAWS = [ + ("2024-01-01", [1, 13, 36, 61, 69]), + ("2024-01-03", [1, 2, 13, 45, 69]), + ("2024-01-05", [2, 13, 22, 36, 55]), +] + + +@pytest.fixture +def pb_game(tmp_db): + """Return Powerball game row and insert the three test draws.""" + game = get_game_by_name("Powerball") + for date, nums in DRAWS: + insert_draw(game["id"], date, nums, bonus=7, source="test") + return game + + +# ── frequency_analysis ──────────────────────────────────────────────────────── + +def test_frequency_counts_all_draws(pb_game): + data = frequency_analysis(pb_game["id"]) + assert data[13] == 3 # in all 3 draws + assert data[1] == 2 + assert data[69] == 2 + assert data[36] == 2 + assert data[2] == 2 + assert data[61] == 1 + + +def test_frequency_sorted_high_to_low(pb_game): + data = frequency_analysis(pb_game["id"]) + counts = list(data.values()) + assert counts == sorted(counts, reverse=True) + + +def test_frequency_last_n_restricts_draws(pb_game): + # last_n=1 → only draw 3: [2, 13, 22, 36, 55] + data = frequency_analysis(pb_game["id"], last_n=1) + for n in (2, 13, 22, 36, 55): + assert data[n] == 1 + assert 1 not in data + assert 69 not in data + + +def test_frequency_last_n_two_draws(pb_game): + # last_n=2 → draws 2 and 3: [1,2,13,45,69] + [2,13,22,36,55] + data = frequency_analysis(pb_game["id"], last_n=2) + assert data[2] == 2 + assert data[13] == 2 + assert data[1] == 1 + assert 61 not in data + + +def test_frequency_empty_db_returns_empty(tmp_db): + game = get_game_by_name("Powerball") + assert frequency_analysis(game["id"]) == {} + + +# ── gap_analysis ────────────────────────────────────────────────────────────── + +def test_gap_zero_for_numbers_in_last_draw(pb_game): + gaps = gap_analysis(pb_game["id"]) + # Draw 3 = [2, 13, 22, 36, 55] — all have gap 0 + for n in (2, 13, 22, 36, 55): + assert gaps[n] == 0, f"Expected gap 0 for {n}, got {gaps[n]}" + + +def test_gap_one_for_draw_before_last(pb_game): + gaps = gap_analysis(pb_game["id"]) + # 1 and 69 were last in draw 2 (one before latest) + assert gaps[1] == 1 + assert gaps[69] == 1 + + +def test_gap_two_for_number_two_draws_back(pb_game): + gaps = gap_analysis(pb_game["id"]) + # 61 only in draw 1 (two draws before latest) + assert gaps[61] == 2 + + +def test_gap_total_for_never_appeared(pb_game): + gaps = gap_analysis(pb_game["id"]) + # Number 3 never appeared → gap = total draws = 3 + assert gaps[3] == 3 + + +def test_gap_covers_full_pool(pb_game): + gaps = gap_analysis(pb_game["id"]) + game = get_game_by_name("Powerball") + assert len(gaps) == game["main_max"] + + +def test_gap_empty_db_returns_empty(tmp_db): + game = get_game_by_name("Powerball") + assert gap_analysis(game["id"]) == {} + + +# ── positional_frequency ────────────────────────────────────────────────────── + +def test_positional_has_correct_positions(pb_game): + pf = positional_frequency(pb_game["id"]) + game = get_game_by_name("Powerball") + assert set(pf.keys()) == set(range(1, game["main_count"] + 1)) + + +def test_positional_counts_correct_values(pb_game): + pf = positional_frequency(pb_game["id"]) + # Position 1: draw1=1, draw2=1, draw3=2 → {1: 2, 2: 1} + assert pf[1][1] == 2 + assert pf[1][2] == 1 + # Position 2: draw1=13, draw2=2, draw3=13 → {13: 2, 2: 1} + assert pf[2][13] == 2 + assert pf[2][2] == 1 + # Position 5: draw1=69, draw2=69, draw3=55 → {69: 2, 55: 1} + assert pf[5][69] == 2 + assert pf[5][55] == 1 + + +def test_positional_empty_db_returns_empty_counters(tmp_db): + game = get_game_by_name("Powerball") + pf = positional_frequency(game["id"]) + assert all(len(v) == 0 for v in pf.values()) + + +# ── pair_analysis ───────────────────────────────────────────────────────────── + +def test_pair_counts_known_pairs(pb_game): + pairs = pair_analysis(pb_game["id"]) + # (1,13) appears in draw1 and draw2 + assert pairs[(1, 13)] == 2 + # (1,69) appears in draw1 and draw2 + assert pairs[(1, 69)] == 2 + # (13,69) appears in draw1 and draw2 + assert pairs[(13, 69)] == 2 + # (2,13) appears in draw2 and draw3 + assert pairs[(2, 13)] == 2 + # (13,36) appears in draw1 and draw3 + assert pairs[(13, 36)] == 2 + + +def test_pair_keys_are_sorted_tuples(pb_game): + pairs = pair_analysis(pb_game["id"]) + for n1, n2 in pairs.keys(): + assert n1 < n2 + + +def test_pair_respects_top_n(pb_game): + pairs = pair_analysis(pb_game["id"], top_n=3) + assert len(pairs) <= 3 + + +def test_pair_empty_db_returns_empty(tmp_db): + game = get_game_by_name("Powerball") + assert pair_analysis(game["id"]) == {} + + +# ── odd_even_ratio ──────────────────────────────────────────────────────────── + +def test_odd_even_counts_correct(pb_game): + ratios = odd_even_ratio(pb_game["id"]) + assert len(ratios) == 3 + # Draw 1 [1,13,36,61,69]: odd=4, even=1 + r1 = next(r for r in ratios if r["date"] == "2024-01-01") + assert r1["odd"] == 4 + assert r1["even"] == 1 + # Draw 3 [2,13,22,36,55]: odd=2, even=3 + r3 = next(r for r in ratios if r["date"] == "2024-01-05") + assert r3["odd"] == 2 + assert r3["even"] == 3 + + +def test_odd_even_sums_to_main_count(pb_game): + game = get_game_by_name("Powerball") + ratios = odd_even_ratio(pb_game["id"]) + for r in ratios: + assert r["odd"] + r["even"] == game["main_count"] + + +def test_odd_even_empty_db(tmp_db): + game = get_game_by_name("Powerball") + assert odd_even_ratio(game["id"]) == [] + + +# ── sum_range_analysis ──────────────────────────────────────────────────────── + +def test_sum_values_correct(pb_game): + sums = sum_range_analysis(pb_game["id"]) + assert len(sums) == 3 + s1 = next(s for s in sums if s["date"] == "2024-01-01") + assert s1["sum"] == 1 + 13 + 36 + 61 + 69 # 180 + s2 = next(s for s in sums if s["date"] == "2024-01-03") + assert s2["sum"] == 1 + 2 + 13 + 45 + 69 # 130 + s3 = next(s for s in sums if s["date"] == "2024-01-05") + assert s3["sum"] == 2 + 13 + 22 + 36 + 55 # 128 + + +def test_sum_empty_db(tmp_db): + game = get_game_by_name("Powerball") + assert sum_range_analysis(game["id"]) == [] + + +# ── delta_analysis ──────────────────────────────────────────────────────────── + +def test_delta_values_correct(pb_game): + deltas = delta_analysis(pb_game["id"]) + assert len(deltas) == 3 + d1 = next(d for d in deltas if d["date"] == "2024-01-01") + # sorted [1,13,36,61,69] → diffs [12, 23, 25, 8] + assert d1["deltas"] == [12, 23, 25, 8] + d3 = next(d for d in deltas if d["date"] == "2024-01-05") + # sorted [2,13,22,36,55] → diffs [11, 9, 14, 19] + assert d3["deltas"] == [11, 9, 14, 19] + + +def test_delta_length_matches_main_count_minus_one(pb_game): + game = get_game_by_name("Powerball") + deltas = delta_analysis(pb_game["id"]) + for d in deltas: + assert len(d["deltas"]) == game["main_count"] - 1 + + +def test_delta_empty_db(tmp_db): + game = get_game_by_name("Powerball") + assert delta_analysis(game["id"]) == [] diff --git a/ui/analysis.py b/ui/analysis.py new file mode 100644 index 0000000..d85aa1c --- /dev/null +++ b/ui/analysis.py @@ -0,0 +1,244 @@ +""" +ui/analysis.py +-------------- +Analysis screen — three Matplotlib charts embedded in a ttk.Notebook. + • Frequency — bar chart of how often each number appears + • Heatmap — positional frequency matrix + • Gap — draws since each number last appeared +""" + +import tkinter as tk +from tkinter import ttk + +import numpy as np +import matplotlib +matplotlib.use("TkAgg") +from matplotlib.figure import Figure +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk + +from db.models import get_all_games, get_game_by_id, get_game_by_name +from core.analyzer import frequency_analysis, gap_analysis, positional_frequency + +_LAST_N_OPTIONS = { + "All draws": None, + "Last 50": 50, + "Last 100": 100, + "Last 200": 200, + "Last 500": 500, +} + + +class AnalysisScreen(ttk.Frame): + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + self._game_id: int | None = None + self._build_ui() + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + # Controls bar + bar = ttk.Frame(self, padding=(6, 6, 6, 4)) + bar.pack(fill="x") + + ttk.Label(bar, text="Game:").pack(side="left") + self._game_var = tk.StringVar() + self._game_cb = ttk.Combobox( + bar, textvariable=self._game_var, state="readonly", width=15 + ) + self._game_cb.pack(side="left", padx=(4, 14)) + self._game_cb.bind("<>", lambda _: self._on_game_change()) + + ttk.Label(bar, text="Frequency window:").pack(side="left") + self._last_n_var = tk.StringVar(value="All draws") + last_n_cb = ttk.Combobox( + bar, textvariable=self._last_n_var, + values=list(_LAST_N_OPTIONS.keys()), + state="readonly", width=12, + ) + last_n_cb.pack(side="left", padx=(4, 0)) + last_n_cb.bind("<>", lambda _: self._redraw_frequency()) + + ttk.Button(bar, text="↻ Refresh", command=self.refresh).pack(side="right") + + # Notebook with three chart tabs + nb = ttk.Notebook(self) + nb.pack(fill="both", expand=True, padx=6, pady=(0, 6)) + + self._freq_fig, self._freq_canvas = self._make_tab(nb, "Frequency") + self._heat_fig, self._heat_canvas = self._make_tab(nb, "Heatmap") + self._gap_fig, self._gap_canvas = self._make_tab(nb, "Gap") + + def _make_tab(self, notebook, title): + frame = ttk.Frame(notebook) + notebook.add(frame, text=title) + + fig = Figure(tight_layout=True) + canvas = FigureCanvasTkAgg(fig, master=frame) + canvas.get_tk_widget().pack(fill="both", expand=True) + + tb_frame = ttk.Frame(frame) + tb_frame.pack(fill="x") + NavigationToolbar2Tk(canvas, tb_frame) + + return fig, canvas + + # ── Data loading ────────────────────────────────────────────────────────── + + def refresh(self): + self._load_games() + self._redraw_all() + + def _load_games(self): + games = get_all_games(active_only=True) + names = [g["name"] for g in games] + self._game_cb["values"] = names + if not self._game_var.get() or self._game_var.get() not in names: + if names: + self._game_var.set(names[0]) + game = get_game_by_name(self._game_var.get()) + self._game_id = game["id"] if game else None + + def _on_game_change(self): + game = get_game_by_name(self._game_var.get()) + self._game_id = game["id"] if game else None + self._redraw_all() + + def _get_last_n(self): + return _LAST_N_OPTIONS.get(self._last_n_var.get()) + + # ── Redraw helpers ──────────────────────────────────────────────────────── + + def _redraw_all(self): + self._redraw_frequency() + self._redraw_heatmap() + self._redraw_gap() + + def _redraw_frequency(self): + self._freq_fig.clear() + ax = self._freq_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_frequency(ax, self._game_id, self._get_last_n()) + self._freq_canvas.draw() + + def _redraw_heatmap(self): + self._heat_fig.clear() + ax = self._heat_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_heatmap(ax, self._game_id) + self._heat_canvas.draw() + + def _redraw_gap(self): + self._gap_fig.clear() + ax = self._gap_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_gap(ax, self._game_id) + self._gap_canvas.draw() + + +# ── Pure chart-drawing functions (no Tkinter, just axes) ───────────────────── + +def _empty(ax, message="No data available — use Fetch Now to download draws"): + ax.set_axis_off() + ax.text(0.5, 0.5, message, ha="center", va="center", + fontsize=13, color="#888888", transform=ax.transAxes) + + +def _draw_frequency(ax, game_id, last_n=None): + data = frequency_analysis(game_id, last_n=last_n) + if not data: + _empty(ax) + return + + nums = sorted(data.keys()) + counts = [data.get(n, 0) for n in nums] + avg = sum(counts) / len(counts) + max_c = max(counts) or 1 + + # Gradient: cold (blue) → hot (red) based on frequency + colors = [ + (0.15 + 0.7 * (c / max_c), 0.25, 1.0 - 0.75 * (c / max_c)) + for c in counts + ] + + ax.bar(nums, counts, color=colors, width=0.75, edgecolor="none") + ax.axhline(avg, color="#e74c3c", linewidth=1.3, linestyle="--", + label=f"Avg {avg:.1f}") + + title = "Number Frequency" + if last_n: + title += f" (last {last_n} draws)" + ax.set_title(title, fontsize=11) + ax.set_xlabel("Number", fontsize=9) + ax.set_ylabel("Count", fontsize=9) + ax.legend(fontsize=9) + ax.tick_params(axis="x", labelsize=7) + ax.grid(axis="y", alpha=0.35) + + +def _draw_heatmap(ax, game_id): + pos_freq = positional_frequency(game_id) + game = get_game_by_id(game_id) + + if not pos_freq or not any(pos_freq.values()): + _empty(ax) + return + + main_count = game["main_count"] + main_max = game["main_max"] + + # Build rows × cols matrix (positions × numbers) + matrix = np.zeros((main_count, main_max)) + for pos in range(1, main_count + 1): + for num, cnt in pos_freq.get(pos, {}).items(): + if 1 <= num <= main_max: + matrix[pos - 1, num - 1] = cnt + + im = ax.imshow(matrix, aspect="auto", cmap="YlOrRd", interpolation="nearest") + ax.figure.colorbar(im, ax=ax, fraction=0.025, pad=0.02, label="Count") + + ax.set_yticks(range(main_count)) + ax.set_yticklabels([f"Pos {i + 1}" for i in range(main_count)], fontsize=9) + + step = 5 + xticks = range(0, main_max, step) + ax.set_xticks(list(xticks)) + ax.set_xticklabels([str(i + 1) for i in xticks], fontsize=8) + + ax.set_title("Positional Frequency Heatmap", fontsize=11) + ax.set_xlabel("Number", fontsize=9) + + +def _draw_gap(ax, game_id): + gaps = gap_analysis(game_id) + if not gaps: + _empty(ax) + return + + nums = sorted(gaps.keys()) + gap_vals = [gaps[n] for n in nums] + avg = sum(gap_vals) / len(gap_vals) + max_g = max(gap_vals) or 1 + + # Gradient: low gap = blue (hot), high gap = red (due) + colors = [ + (0.8 * (g / max_g), 0.15, 1.0 - 0.8 * (g / max_g)) + for g in gap_vals + ] + + ax.bar(nums, gap_vals, color=colors, width=0.75, edgecolor="none") + ax.axhline(avg, color="#e74c3c", linewidth=1.3, linestyle="--", + label=f"Avg {avg:.1f}") + + ax.set_title("Gap Analysis — Draws Since Last Appearance", fontsize=11) + ax.set_xlabel("Number", fontsize=9) + ax.set_ylabel("Draws since last seen", fontsize=9) + ax.legend(fontsize=9) + ax.tick_params(axis="x", labelsize=7) + ax.grid(axis="y", alpha=0.35)