Files
lottosight/ui/analysis.py
T
2026-05-23 11:58:51 -04:00

267 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
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
from core.exporter import export_frequency_excel, ensure_exports_dir
_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")
ttk.Button(bar, text="Export Frequency", command=self._export_frequency).pack(side="right", padx=(0, 4))
# 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()
def _export_frequency(self):
if self._game_id is None:
messagebox.showinfo("Export", "Select a game first.")
return
ensure_exports_dir()
fp = filedialog.asksaveasfilename(
title="Export frequency analysis",
defaultextension=".xlsx",
filetypes=[("Excel workbook", "*.xlsx")],
initialfile=f"frequency_{__import__('datetime').datetime.now().strftime('%Y%m%d')}.xlsx",
)
if not fp:
return
try:
last_n = _LAST_N_OPTIONS.get(self._last_n_var.get())
export_frequency_excel(fp, self._game_id, last_n=last_n)
except Exception as e:
messagebox.showerror("Export failed", str(e))
# ── 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)