diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 987da7c..504eba1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(python -m pytest tests/ -v --tb=short)", "Bash(python -m pytest tests/ -q --tb=short)", "Bash(python -m PyInstaller lottosight.spec --clean)", - "Bash(python -c \"from ui.dashboard import DashboardScreen\")" + "Bash(python -c \"from ui.dashboard import DashboardScreen\")", + "Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)", + "Bash(python -m pytest tests/ -q)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 7fd2478..23c028f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,18 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 10 — Complete Analysis Screen (7 Charts) +- [x] Extend `ui/analysis.py` with 4 new chart tabs (was 3, now 7) + - [x] Pairs — horizontal bar chart, top-20 most common number pairs + - [x] Odd/Even — bar chart of draw-split distribution (e.g. 3O/2E = 38%) + - [x] Sum Range — histogram of total draw-sum distribution + - [x] Deltas — bar chart of gaps between consecutive numbers within draws +- [x] Add `_draw_pairs`, `_draw_odd_even`, `_draw_sum_range`, `_draw_deltas` pure functions +- [x] `tests/test_analysis_charts.py` — 23 tests using Agg backend (no display needed) +- [x] 200/200 total tests passing + +--- + ### ✅ Phase 9 — Dashboard Screen - [x] Write `ui/dashboard.py` - [x] Last Draw Results — card per active game (date, numbers, bonus, multiplier) diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py new file mode 100644 index 0000000..ba8e80a --- /dev/null +++ b/tests/test_analysis_charts.py @@ -0,0 +1,201 @@ +""" +tests/test_analysis_charts.py +------------------------------ +Tests for the pure chart-drawing functions in ui/analysis.py. +Uses matplotlib's Agg (non-interactive) backend so no display is required. +Each test verifies that the function populates the axes correctly +and doesn't raise on empty or populated data. +""" + +import matplotlib +matplotlib.use("Agg") +from matplotlib.figure import Figure + +import pytest +from db.models import get_game_by_name, insert_draw +from ui.analysis import ( + _empty, + _draw_frequency, + _draw_heatmap, + _draw_gap, + _draw_pairs, + _draw_odd_even, + _draw_sum_range, + _draw_deltas, +) + +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]), +] + + +def _ax(): + """Return a fresh Axes on a headless figure.""" + fig = Figure() + return fig.add_subplot(111) + + +@pytest.fixture +def pb(tmp_db): + game = get_game_by_name("Powerball") + for date, nums in DRAWS: + insert_draw(game["id"], date, nums, bonus=7, source="test") + return game + + +# ── _empty ──────────────────────────────────────────────────────────────────── + +def test_empty_disables_axis(tmp_db): + ax = _ax() + _empty(ax, "test message") + assert not ax.get_visible() or not ax.axison + + +# ── _draw_frequency ─────────────────────────────────────────────────────────── + +def test_draw_frequency_creates_bars(pb): + ax = _ax() + _draw_frequency(ax, pb["id"]) + assert len(ax.patches) > 0 + + +def test_draw_frequency_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_frequency(ax, pb["id"]) # should show _empty placeholder, not raise + + +def test_draw_frequency_last_n(pb): + ax = _ax() + _draw_frequency(ax, pb["id"], last_n=1) + assert len(ax.patches) > 0 + + +def test_draw_frequency_title_includes_last_n(pb): + ax = _ax() + _draw_frequency(ax, pb["id"], last_n=50) + assert "50" in ax.get_title() + + +# ── _draw_heatmap ───────────────────────────────────────────────────────────── + +def test_draw_heatmap_creates_image(pb): + ax = _ax() + _draw_heatmap(ax, pb["id"]) + assert len(ax.images) > 0 + + +def test_draw_heatmap_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_heatmap(ax, pb["id"]) + + +# ── _draw_gap ───────────────────────────────────────────────────────────────── + +def test_draw_gap_creates_bars(pb): + ax = _ax() + _draw_gap(ax, pb["id"]) + assert len(ax.patches) > 0 + + +def test_draw_gap_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_gap(ax, pb["id"]) + + +# ── _draw_pairs ─────────────────────────────────────────────────────────────── + +def test_draw_pairs_creates_bars(pb): + ax = _ax() + _draw_pairs(ax, pb["id"]) + assert len(ax.patches) > 0 + + +def test_draw_pairs_respects_top_n(pb): + ax = _ax() + _draw_pairs(ax, pb["id"], top_n=5) + assert len(ax.patches) <= 5 + + +def test_draw_pairs_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_pairs(ax, pb["id"]) + + +def test_draw_pairs_title_set(pb): + ax = _ax() + _draw_pairs(ax, pb["id"], top_n=10) + assert "Pair" in ax.get_title() or "pair" in ax.get_title().lower() + + +# ── _draw_odd_even ──────────────────────────────────────────────────────────── + +def test_draw_odd_even_creates_bars(pb): + ax = _ax() + _draw_odd_even(ax, pb["id"]) + assert len(ax.patches) > 0 + + +def test_draw_odd_even_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_odd_even(ax, pb["id"]) + + +def test_draw_odd_even_title_set(pb): + ax = _ax() + _draw_odd_even(ax, pb["id"]) + assert "Odd" in ax.get_title() or "Even" in ax.get_title() + + +# ── _draw_sum_range ─────────────────────────────────────────────────────────── + +def test_draw_sum_range_creates_patches(pb): + ax = _ax() + _draw_sum_range(ax, pb["id"]) + assert len(ax.patches) > 0 + + +def test_draw_sum_range_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_sum_range(ax, pb["id"]) + + +def test_draw_sum_range_title_set(pb): + ax = _ax() + _draw_sum_range(ax, pb["id"]) + assert "Sum" in ax.get_title() + + +# ── _draw_deltas ────────────────────────────────────────────────────────────── + +def test_draw_deltas_creates_bars(pb): + ax = _ax() + _draw_deltas(ax, pb["id"]) + assert len(ax.patches) > 0 + + +def test_draw_deltas_empty_db_no_error(tmp_db): + pb = get_game_by_name("Powerball") + ax = _ax() + _draw_deltas(ax, pb["id"]) + + +def test_draw_deltas_title_set(pb): + ax = _ax() + _draw_deltas(ax, pb["id"]) + assert "Delta" in ax.get_title() + + +def test_draw_deltas_x_axis_are_positive(pb): + ax = _ax() + _draw_deltas(ax, pb["id"]) + # All delta values between consecutive sorted numbers are positive + x_vals = [p.get_x() for p in ax.patches] + assert all(x >= 0 for x in x_vals) diff --git a/ui/analysis.py b/ui/analysis.py index 67d7469..d9e2a70 100644 --- a/ui/analysis.py +++ b/ui/analysis.py @@ -1,15 +1,20 @@ """ ui/analysis.py -------------- -Analysis screen — three Matplotlib charts embedded in a ttk.Notebook. +Analysis screen — seven 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 + • Pairs — top-20 most common number pairs + • Odd/Even — distribution of odd vs even count per draw + • Sum Range — histogram of draw-sum distribution + • Deltas — distribution of gaps between consecutive numbers in a draw """ import os import tkinter as tk from tkinter import ttk, filedialog, messagebox +from collections import Counter import numpy as np import matplotlib @@ -18,7 +23,10 @@ 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 +from core.analyzer import ( + frequency_analysis, gap_analysis, positional_frequency, + pair_analysis, odd_even_ratio, sum_range_analysis, delta_analysis, +) from core.exporter import export_frequency_excel, ensure_exports_dir _LAST_N_OPTIONS = { @@ -64,13 +72,17 @@ class AnalysisScreen(ttk.Frame): ttk.Button(bar, text="↻ Refresh", command=self.refresh).pack(side="right") ttk.Button(bar, text="Export Frequency", command=self._export_frequency).pack(side="right", padx=(0, 4)) - # Notebook with three chart tabs + # Notebook — all seven 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") + 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") + self._pair_fig, self._pair_canvas = self._make_tab(nb, "Pairs") + self._oe_fig, self._oe_canvas = self._make_tab(nb, "Odd/Even") + self._sum_fig, self._sum_canvas = self._make_tab(nb, "Sum Range") + self._delta_fig, self._delta_canvas = self._make_tab(nb, "Deltas") def _make_tab(self, notebook, title): frame = ttk.Frame(notebook) @@ -116,6 +128,10 @@ class AnalysisScreen(ttk.Frame): self._redraw_frequency() self._redraw_heatmap() self._redraw_gap() + self._redraw_pairs() + self._redraw_odd_even() + self._redraw_sum_range() + self._redraw_deltas() def _redraw_frequency(self): self._freq_fig.clear() @@ -144,6 +160,42 @@ class AnalysisScreen(ttk.Frame): _draw_gap(ax, self._game_id) self._gap_canvas.draw() + def _redraw_pairs(self): + self._pair_fig.clear() + ax = self._pair_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_pairs(ax, self._game_id) + self._pair_canvas.draw() + + def _redraw_odd_even(self): + self._oe_fig.clear() + ax = self._oe_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_odd_even(ax, self._game_id) + self._oe_canvas.draw() + + def _redraw_sum_range(self): + self._sum_fig.clear() + ax = self._sum_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_sum_range(ax, self._game_id) + self._sum_canvas.draw() + + def _redraw_deltas(self): + self._delta_fig.clear() + ax = self._delta_fig.add_subplot(111) + if self._game_id is None: + _empty(ax, "Select a game above") + else: + _draw_deltas(ax, self._game_id) + self._delta_canvas.draw() + def _export_frequency(self): if self._game_id is None: messagebox.showinfo("Export", "Select a game first.") @@ -183,7 +235,6 @@ def _draw_frequency(ax, game_id, last_n=None): 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 @@ -215,7 +266,6 @@ def _draw_heatmap(ax, game_id): 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(): @@ -248,7 +298,6 @@ def _draw_gap(ax, game_id): 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 @@ -264,3 +313,106 @@ def _draw_gap(ax, game_id): ax.legend(fontsize=9) ax.tick_params(axis="x", labelsize=7) ax.grid(axis="y", alpha=0.35) + + +def _draw_pairs(ax, game_id, top_n=20): + pairs = pair_analysis(game_id, top_n=top_n) + if not pairs: + _empty(ax) + return + + # Sort by count descending, take top_n + sorted_pairs = sorted(pairs.items(), key=lambda x: x[1], reverse=True)[:top_n] + labels = [f"{a}-{b}" for (a, b), _ in sorted_pairs] + counts = [c for _, c in sorted_pairs] + + y_pos = range(len(labels)) + bars = ax.barh(list(y_pos), counts, color="#2471a3", edgecolor="none", height=0.7) + ax.set_yticks(list(y_pos)) + ax.set_yticklabels(labels, fontsize=8) + ax.invert_yaxis() + + # Annotate count on each bar + for bar, count in zip(bars, counts): + ax.text(bar.get_width() + 0.1, bar.get_y() + bar.get_height() / 2, + str(count), va="center", fontsize=7, color="#333333") + + ax.set_title(f"Top {len(sorted_pairs)} Most Common Pairs", fontsize=11) + ax.set_xlabel("Times appeared together", fontsize=9) + ax.grid(axis="x", alpha=0.35) + + +def _draw_odd_even(ax, game_id): + data = odd_even_ratio(game_id) + if not data: + _empty(ax) + return + + # Distribution: how often each (odd, even) split occurs + split_counts: Counter = Counter() + main_count = data[0]["odd"] + data[0]["even"] + for row in data: + split_counts[(row["odd"], row["even"])] += 1 + + # Sort by odd count for a natural x-axis: 0 odd … main_count odd + splits = sorted(split_counts.keys(), key=lambda x: x[0]) + labels = [f"{o}O/{e}E" for o, e in splits] + counts = [split_counts[s] for s in splits] + pcts = [100 * c / len(data) for c in counts] + + bars = ax.bar(labels, pcts, color="#1e8449", edgecolor="none", width=0.6) + for bar, pct in zip(bars, pcts): + ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5, + f"{pct:.1f}%", ha="center", va="bottom", fontsize=8) + + ax.set_title("Odd / Even Split Distribution", fontsize=11) + ax.set_xlabel("Odd / Even balance", fontsize=9) + ax.set_ylabel("% of draws", fontsize=9) + ax.grid(axis="y", alpha=0.35) + + +def _draw_sum_range(ax, game_id): + data = sum_range_analysis(game_id) + if not data: + _empty(ax) + return + + sums = [row["sum"] for row in data] + avg = sum(sums) / len(sums) + + ax.hist(sums, bins=30, color="#8e44ad", edgecolor="white", linewidth=0.4) + ax.axvline(avg, color="#e74c3c", linewidth=1.5, linestyle="--", + label=f"Avg {avg:.1f}") + + ax.set_title("Draw Sum Distribution", fontsize=11) + ax.set_xlabel("Sum of main numbers", fontsize=9) + ax.set_ylabel("Number of draws", fontsize=9) + ax.legend(fontsize=9) + ax.grid(axis="y", alpha=0.35) + + +def _draw_deltas(ax, game_id): + data = delta_analysis(game_id) + if not data: + _empty(ax) + return + + # Flatten all deltas across all draws into one distribution + all_deltas: Counter = Counter() + for row in data: + for d in row["deltas"]: + all_deltas[d] += 1 + + vals = sorted(all_deltas.keys()) + counts = [all_deltas[v] for v in vals] + avg = sum(v * c for v, c in all_deltas.items()) / sum(all_deltas.values()) + + ax.bar(vals, counts, color="#d35400", edgecolor="none", width=0.8) + ax.axvline(avg, color="#2c3e50", linewidth=1.5, linestyle="--", + label=f"Avg {avg:.1f}") + + ax.set_title("Delta Pattern Distribution", fontsize=11) + ax.set_xlabel("Gap between consecutive numbers in a draw", fontsize=9) + ax.set_ylabel("Frequency", fontsize=9) + ax.legend(fontsize=9) + ax.grid(axis="y", alpha=0.35)