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
+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)