Files
lottosight/ui/analysis.py
T
2026-05-23 12:25:23 -04:00

419 lines
14 KiB
Python

"""
ui/analysis.py
--------------
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
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,
pair_analysis, odd_even_ratio, sum_range_analysis, delta_analysis,
)
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 — 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)
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()
self._redraw_pairs()
self._redraw_odd_even()
self._redraw_sum_range()
self._redraw_deltas()
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 _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.")
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
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"]
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
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)
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)