05/23 Phase 10

This commit is contained in:
2026-05-23 12:25:23 -04:00
parent 3ac30787d5
commit f8c11b8979
4 changed files with 377 additions and 10 deletions
+3 -1
View File
@@ -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)"
]
}
}
+12
View File
@@ -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)
+201
View File
@@ -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)
+158 -6
View File
@@ -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._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)