419 lines
14 KiB
Python
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)
|