""" 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 from core.odds import game_odds _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("<>", 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("<>", 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") self._make_odds_tab(nb) def _make_odds_tab(self, notebook): frame = ttk.Frame(notebook) notebook.add(frame, text="Odds") ttk.Label( frame, text="Mathematical odds based on the game's pool size. " "Not all tiers pay prizes in every game.", foreground="#666666", padding=(8, 6, 8, 2), ).pack(anchor="w") cols = ("tier", "odds", "probability") tv = ttk.Treeview(frame, columns=cols, show="headings", selectmode="none") tv.heading("tier", text="Prize Tier") tv.heading("odds", text="Odds (1 in X)") tv.heading("probability", text="Probability") tv.column("tier", width=210, anchor="w") tv.column("odds", width=210, anchor="e") tv.column("probability", width=160, anchor="e") vsb = ttk.Scrollbar(frame, orient="vertical", command=tv.yview) tv.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") tv.pack(fill="both", expand=True, padx=8, pady=(0, 8)) self._odds_tv = tv 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() self._redraw_odds() 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 _redraw_odds(self): for row in self._odds_tv.get_children(): self._odds_tv.delete(row) if self._game_id is None: return for r in game_odds(self._game_id): p = r["probability"] if p >= 0.001: pct = f"{p * 100:.4f}%" elif p >= 0.000001: pct = f"{p * 100:.6f}%" else: pct = f"{p:.2e}" self._odds_tv.insert("", "end", values=(r["tier"], r["odds_str"], pct)) 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)