05/23 Phase 4

This commit is contained in:
2026-05-23 11:25:56 -04:00
parent 5dcf93a85f
commit 0931cdcd8e
7 changed files with 639 additions and 16 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"allow": [ "allow": [
"Bash(python -m pytest tests/test_fetcher.py -v)", "Bash(python -m pytest tests/test_fetcher.py -v)",
"Bash(python -m pytest -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)"
] ]
} }
} }
+16 -15
View File
@@ -257,21 +257,22 @@ All actions are logged to console and optionally to a log file:
--- ---
### 🔲 Phase 4 — Analysis Engine + Charts ### Phase 4 — Analysis Engine + Charts
- [ ] Write `core/analyzer.py` - [x] Write `core/analyzer.py`
- [ ] `frequency_analysis(game_id, last_n)` - [x] `frequency_analysis(game_id, last_n)`
- [ ] `gap_analysis(game_id)` - [x] `gap_analysis(game_id)`
- [ ] `positional_frequency(game_id)` - [x] `positional_frequency(game_id)`
- [ ] `pair_analysis(game_id)` - [x] `pair_analysis(game_id)`
- [ ] `odd_even_ratio(game_id)` - [x] `odd_even_ratio(game_id)`
- [ ] `sum_range_analysis(game_id)` - [x] `sum_range_analysis(game_id)`
- [ ] `delta_analysis(game_id)` - [x] `delta_analysis(game_id)`
- [ ] Write `ui/analysis.py` - [x] Write `ui/analysis.py`
- [ ] Frequency bar chart (Matplotlib embedded) - [x] Frequency bar chart (Matplotlib + FigureCanvasTkAgg)
- [ ] Heatmap of number frequency - [x] Positional frequency heatmap (imshow, YlOrRd colormap)
- [ ] Gap chart - [x] Gap chart (color-coded: blue=recent, red=due)
- [ ] Toggle between games - [x] Game dropdown + frequency window selector
- [ ] Test all analysis functions with real data - [x] NavigationToolbar on each tab for zoom/pan
- [x] 26 tests for all 7 analysis functions (106/106 total passing)
--- ---
+127
View File
@@ -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
Binary file not shown.
+3
View File
@@ -17,6 +17,7 @@ from db.database import init_db
from core.fetcher import fetch_all from core.fetcher import fetch_all
from ui.statusbar import StatusBar from ui.statusbar import StatusBar
from ui.history import HistoryScreen from ui.history import HistoryScreen
from ui.analysis import AnalysisScreen
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -98,6 +99,8 @@ class LottoSightApp(tk.Tk):
def _make_screen(self, name: str) -> tk.Widget: def _make_screen(self, name: str) -> tk.Widget:
if name == "History": if name == "History":
return HistoryScreen(self._content) return HistoryScreen(self._content)
if name == "Analysis":
return AnalysisScreen(self._content)
# Placeholder for screens added in later phases # Placeholder for screens added in later phases
placeholder = ttk.Label( placeholder = ttk.Label(
self._content, text=f"{name} — coming soon", self._content, text=f"{name} — coming soon",
+247
View File
@@ -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"]) == []
+244
View File
@@ -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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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)