05/23 Phase 4
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(python -m pytest tests/test_fetcher.py -v)",
|
"Bash(python -m pytest tests/test_fetcher.py -v)",
|
||||||
"Bash(python -m pytest -v)",
|
"Bash(python -m pytest -v)",
|
||||||
"Bash(python -m pytest tests/test_history.py -v)"
|
"Bash(python -m pytest tests/test_history.py -v)",
|
||||||
|
"Bash(python -m pytest tests/test_analyzer.py -v)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,21 +257,22 @@ All actions are logged to console and optionally to a log file:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔲 Phase 4 — Analysis Engine + Charts
|
### ✅ Phase 4 — Analysis Engine + Charts
|
||||||
- [ ] Write `core/analyzer.py`
|
- [x] Write `core/analyzer.py`
|
||||||
- [ ] `frequency_analysis(game_id, last_n)`
|
- [x] `frequency_analysis(game_id, last_n)`
|
||||||
- [ ] `gap_analysis(game_id)`
|
- [x] `gap_analysis(game_id)`
|
||||||
- [ ] `positional_frequency(game_id)`
|
- [x] `positional_frequency(game_id)`
|
||||||
- [ ] `pair_analysis(game_id)`
|
- [x] `pair_analysis(game_id)`
|
||||||
- [ ] `odd_even_ratio(game_id)`
|
- [x] `odd_even_ratio(game_id)`
|
||||||
- [ ] `sum_range_analysis(game_id)`
|
- [x] `sum_range_analysis(game_id)`
|
||||||
- [ ] `delta_analysis(game_id)`
|
- [x] `delta_analysis(game_id)`
|
||||||
- [ ] Write `ui/analysis.py`
|
- [x] Write `ui/analysis.py`
|
||||||
- [ ] Frequency bar chart (Matplotlib embedded)
|
- [x] Frequency bar chart (Matplotlib + FigureCanvasTkAgg)
|
||||||
- [ ] Heatmap of number frequency
|
- [x] Positional frequency heatmap (imshow, YlOrRd colormap)
|
||||||
- [ ] Gap chart
|
- [x] Gap chart (color-coded: blue=recent, red=due)
|
||||||
- [ ] Toggle between games
|
- [x] Game dropdown + frequency window selector
|
||||||
- [ ] Test all analysis functions with real data
|
- [x] NavigationToolbar on each tab for zoom/pan
|
||||||
|
- [x] 26 tests for all 7 analysis functions (106/106 total passing)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
core/analyzer.py
|
||||||
|
----------------
|
||||||
|
All statistical analysis functions for LottoSight.
|
||||||
|
Each function takes game_id and returns structured Python data
|
||||||
|
(dicts / lists) — no UI concerns here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
from db.models import get_all_draws_numbers, get_game_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def frequency_analysis(game_id, last_n=None):
|
||||||
|
"""
|
||||||
|
Count appearances of each main ball.
|
||||||
|
last_n: restrict to the most recent N draws (None = all).
|
||||||
|
Returns {number: count} sorted high → low.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id) # ASC order
|
||||||
|
if last_n and last_n > 0:
|
||||||
|
draws = draws[-last_n:]
|
||||||
|
|
||||||
|
counter = Counter()
|
||||||
|
for draw in draws:
|
||||||
|
counter.update(draw["numbers"])
|
||||||
|
|
||||||
|
return dict(sorted(counter.items(), key=lambda kv: kv[1], reverse=True))
|
||||||
|
|
||||||
|
|
||||||
|
def gap_analysis(game_id):
|
||||||
|
"""
|
||||||
|
Draws elapsed since each number last appeared.
|
||||||
|
gap=0 → appeared in the most recent draw.
|
||||||
|
gap=N → last appeared N draws before the current one.
|
||||||
|
Numbers never drawn receive gap = total draw count.
|
||||||
|
Returns {number: gap} for every number in the pool.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id) # ASC order
|
||||||
|
if not draws:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
game = get_game_by_id(game_id)
|
||||||
|
total = len(draws)
|
||||||
|
|
||||||
|
last_seen = {}
|
||||||
|
for idx, draw in enumerate(draws):
|
||||||
|
for n in draw["numbers"]:
|
||||||
|
last_seen[n] = idx # keep updating → final value = most recent draw index
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for n in range(1, game["main_max"] + 1):
|
||||||
|
if n in last_seen:
|
||||||
|
result[n] = (total - 1) - last_seen[n]
|
||||||
|
else:
|
||||||
|
result[n] = total
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def positional_frequency(game_id):
|
||||||
|
"""
|
||||||
|
How often each number appears at each draw position (1-indexed).
|
||||||
|
Returns {pos: {number: count}} for positions 1 .. main_count.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id)
|
||||||
|
game = get_game_by_id(game_id)
|
||||||
|
main_count = game["main_count"]
|
||||||
|
|
||||||
|
result = {pos: Counter() for pos in range(1, main_count + 1)}
|
||||||
|
for draw in draws:
|
||||||
|
for pos, num in enumerate(draw["numbers"][:main_count], start=1):
|
||||||
|
result[pos][num] += 1
|
||||||
|
|
||||||
|
return {pos: dict(c) for pos, c in result.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def pair_analysis(game_id, top_n=50):
|
||||||
|
"""
|
||||||
|
Most frequent pairs of numbers appearing together in the same draw.
|
||||||
|
Returns {(n1, n2): count} for the top_n pairs (n1 < n2), high → low.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id)
|
||||||
|
counter = Counter()
|
||||||
|
for draw in draws:
|
||||||
|
for pair in combinations(sorted(draw["numbers"]), 2):
|
||||||
|
counter[pair] += 1
|
||||||
|
|
||||||
|
return dict(counter.most_common(top_n))
|
||||||
|
|
||||||
|
|
||||||
|
def odd_even_ratio(game_id):
|
||||||
|
"""
|
||||||
|
Odd vs even ball count per draw.
|
||||||
|
Returns [{"date": str, "odd": int, "even": int}, ...] in chronological order.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id)
|
||||||
|
result = []
|
||||||
|
for draw in draws:
|
||||||
|
odd = sum(1 for n in draw["numbers"] if n % 2 != 0)
|
||||||
|
even = len(draw["numbers"]) - odd
|
||||||
|
result.append({"date": draw["draw_date"], "odd": odd, "even": even})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def sum_range_analysis(game_id):
|
||||||
|
"""
|
||||||
|
Sum of main balls per draw.
|
||||||
|
Returns [{"date": str, "sum": int}, ...] in chronological order.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id)
|
||||||
|
return [{"date": d["draw_date"], "sum": sum(d["numbers"])} for d in draws]
|
||||||
|
|
||||||
|
|
||||||
|
def delta_analysis(game_id):
|
||||||
|
"""
|
||||||
|
Differences between consecutive sorted numbers within each draw.
|
||||||
|
Returns [{"date": str, "deltas": [int, ...]}, ...] in chronological order.
|
||||||
|
"""
|
||||||
|
draws = get_all_draws_numbers(game_id)
|
||||||
|
result = []
|
||||||
|
for draw in draws:
|
||||||
|
s = sorted(draw["numbers"])
|
||||||
|
deltas = [s[i + 1] - s[i] for i in range(len(s) - 1)]
|
||||||
|
result.append({"date": draw["draw_date"], "deltas": deltas})
|
||||||
|
return result
|
||||||
Binary file not shown.
@@ -17,6 +17,7 @@ from db.database import init_db
|
|||||||
from core.fetcher import fetch_all
|
from core.fetcher import fetch_all
|
||||||
from ui.statusbar import StatusBar
|
from ui.statusbar import StatusBar
|
||||||
from ui.history import HistoryScreen
|
from ui.history import HistoryScreen
|
||||||
|
from ui.analysis import AnalysisScreen
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -98,6 +99,8 @@ class LottoSightApp(tk.Tk):
|
|||||||
def _make_screen(self, name: str) -> tk.Widget:
|
def _make_screen(self, name: str) -> tk.Widget:
|
||||||
if name == "History":
|
if name == "History":
|
||||||
return HistoryScreen(self._content)
|
return HistoryScreen(self._content)
|
||||||
|
if name == "Analysis":
|
||||||
|
return AnalysisScreen(self._content)
|
||||||
# Placeholder for screens added in later phases
|
# Placeholder for screens added in later phases
|
||||||
placeholder = ttk.Label(
|
placeholder = ttk.Label(
|
||||||
self._content, text=f"{name} — coming soon",
|
self._content, text=f"{name} — coming soon",
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
tests/test_analyzer.py
|
||||||
|
-----------------------
|
||||||
|
Unit tests for core/analyzer.py.
|
||||||
|
Uses three deterministic draws so every assertion is hand-verifiable.
|
||||||
|
|
||||||
|
Draw data (all Powerball, game_id from tmp_db):
|
||||||
|
Draw 1 (oldest) 2024-01-01: [ 1, 13, 36, 61, 69]
|
||||||
|
Draw 2 2024-01-03: [ 1, 2, 13, 45, 69]
|
||||||
|
Draw 3 (newest) 2024-01-05: [ 2, 13, 22, 36, 55]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from db.models import get_game_by_name, insert_draw
|
||||||
|
from core.analyzer import (
|
||||||
|
delta_analysis,
|
||||||
|
frequency_analysis,
|
||||||
|
gap_analysis,
|
||||||
|
odd_even_ratio,
|
||||||
|
pair_analysis,
|
||||||
|
positional_frequency,
|
||||||
|
sum_range_analysis,
|
||||||
|
)
|
||||||
|
|
||||||
|
DRAWS = [
|
||||||
|
("2024-01-01", [1, 13, 36, 61, 69]),
|
||||||
|
("2024-01-03", [1, 2, 13, 45, 69]),
|
||||||
|
("2024-01-05", [2, 13, 22, 36, 55]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pb_game(tmp_db):
|
||||||
|
"""Return Powerball game row and insert the three test draws."""
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
for date, nums in DRAWS:
|
||||||
|
insert_draw(game["id"], date, nums, bonus=7, source="test")
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
# ── frequency_analysis ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_frequency_counts_all_draws(pb_game):
|
||||||
|
data = frequency_analysis(pb_game["id"])
|
||||||
|
assert data[13] == 3 # in all 3 draws
|
||||||
|
assert data[1] == 2
|
||||||
|
assert data[69] == 2
|
||||||
|
assert data[36] == 2
|
||||||
|
assert data[2] == 2
|
||||||
|
assert data[61] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_frequency_sorted_high_to_low(pb_game):
|
||||||
|
data = frequency_analysis(pb_game["id"])
|
||||||
|
counts = list(data.values())
|
||||||
|
assert counts == sorted(counts, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frequency_last_n_restricts_draws(pb_game):
|
||||||
|
# last_n=1 → only draw 3: [2, 13, 22, 36, 55]
|
||||||
|
data = frequency_analysis(pb_game["id"], last_n=1)
|
||||||
|
for n in (2, 13, 22, 36, 55):
|
||||||
|
assert data[n] == 1
|
||||||
|
assert 1 not in data
|
||||||
|
assert 69 not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_frequency_last_n_two_draws(pb_game):
|
||||||
|
# last_n=2 → draws 2 and 3: [1,2,13,45,69] + [2,13,22,36,55]
|
||||||
|
data = frequency_analysis(pb_game["id"], last_n=2)
|
||||||
|
assert data[2] == 2
|
||||||
|
assert data[13] == 2
|
||||||
|
assert data[1] == 1
|
||||||
|
assert 61 not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_frequency_empty_db_returns_empty(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert frequency_analysis(game["id"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── gap_analysis ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_gap_zero_for_numbers_in_last_draw(pb_game):
|
||||||
|
gaps = gap_analysis(pb_game["id"])
|
||||||
|
# Draw 3 = [2, 13, 22, 36, 55] — all have gap 0
|
||||||
|
for n in (2, 13, 22, 36, 55):
|
||||||
|
assert gaps[n] == 0, f"Expected gap 0 for {n}, got {gaps[n]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_one_for_draw_before_last(pb_game):
|
||||||
|
gaps = gap_analysis(pb_game["id"])
|
||||||
|
# 1 and 69 were last in draw 2 (one before latest)
|
||||||
|
assert gaps[1] == 1
|
||||||
|
assert gaps[69] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_two_for_number_two_draws_back(pb_game):
|
||||||
|
gaps = gap_analysis(pb_game["id"])
|
||||||
|
# 61 only in draw 1 (two draws before latest)
|
||||||
|
assert gaps[61] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_total_for_never_appeared(pb_game):
|
||||||
|
gaps = gap_analysis(pb_game["id"])
|
||||||
|
# Number 3 never appeared → gap = total draws = 3
|
||||||
|
assert gaps[3] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_covers_full_pool(pb_game):
|
||||||
|
gaps = gap_analysis(pb_game["id"])
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert len(gaps) == game["main_max"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_empty_db_returns_empty(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert gap_analysis(game["id"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── positional_frequency ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_positional_has_correct_positions(pb_game):
|
||||||
|
pf = positional_frequency(pb_game["id"])
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert set(pf.keys()) == set(range(1, game["main_count"] + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_positional_counts_correct_values(pb_game):
|
||||||
|
pf = positional_frequency(pb_game["id"])
|
||||||
|
# Position 1: draw1=1, draw2=1, draw3=2 → {1: 2, 2: 1}
|
||||||
|
assert pf[1][1] == 2
|
||||||
|
assert pf[1][2] == 1
|
||||||
|
# Position 2: draw1=13, draw2=2, draw3=13 → {13: 2, 2: 1}
|
||||||
|
assert pf[2][13] == 2
|
||||||
|
assert pf[2][2] == 1
|
||||||
|
# Position 5: draw1=69, draw2=69, draw3=55 → {69: 2, 55: 1}
|
||||||
|
assert pf[5][69] == 2
|
||||||
|
assert pf[5][55] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_positional_empty_db_returns_empty_counters(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
pf = positional_frequency(game["id"])
|
||||||
|
assert all(len(v) == 0 for v in pf.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ── pair_analysis ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_pair_counts_known_pairs(pb_game):
|
||||||
|
pairs = pair_analysis(pb_game["id"])
|
||||||
|
# (1,13) appears in draw1 and draw2
|
||||||
|
assert pairs[(1, 13)] == 2
|
||||||
|
# (1,69) appears in draw1 and draw2
|
||||||
|
assert pairs[(1, 69)] == 2
|
||||||
|
# (13,69) appears in draw1 and draw2
|
||||||
|
assert pairs[(13, 69)] == 2
|
||||||
|
# (2,13) appears in draw2 and draw3
|
||||||
|
assert pairs[(2, 13)] == 2
|
||||||
|
# (13,36) appears in draw1 and draw3
|
||||||
|
assert pairs[(13, 36)] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_keys_are_sorted_tuples(pb_game):
|
||||||
|
pairs = pair_analysis(pb_game["id"])
|
||||||
|
for n1, n2 in pairs.keys():
|
||||||
|
assert n1 < n2
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_respects_top_n(pb_game):
|
||||||
|
pairs = pair_analysis(pb_game["id"], top_n=3)
|
||||||
|
assert len(pairs) <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_empty_db_returns_empty(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert pair_analysis(game["id"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── odd_even_ratio ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_odd_even_counts_correct(pb_game):
|
||||||
|
ratios = odd_even_ratio(pb_game["id"])
|
||||||
|
assert len(ratios) == 3
|
||||||
|
# Draw 1 [1,13,36,61,69]: odd=4, even=1
|
||||||
|
r1 = next(r for r in ratios if r["date"] == "2024-01-01")
|
||||||
|
assert r1["odd"] == 4
|
||||||
|
assert r1["even"] == 1
|
||||||
|
# Draw 3 [2,13,22,36,55]: odd=2, even=3
|
||||||
|
r3 = next(r for r in ratios if r["date"] == "2024-01-05")
|
||||||
|
assert r3["odd"] == 2
|
||||||
|
assert r3["even"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_odd_even_sums_to_main_count(pb_game):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
ratios = odd_even_ratio(pb_game["id"])
|
||||||
|
for r in ratios:
|
||||||
|
assert r["odd"] + r["even"] == game["main_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_odd_even_empty_db(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert odd_even_ratio(game["id"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── sum_range_analysis ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sum_values_correct(pb_game):
|
||||||
|
sums = sum_range_analysis(pb_game["id"])
|
||||||
|
assert len(sums) == 3
|
||||||
|
s1 = next(s for s in sums if s["date"] == "2024-01-01")
|
||||||
|
assert s1["sum"] == 1 + 13 + 36 + 61 + 69 # 180
|
||||||
|
s2 = next(s for s in sums if s["date"] == "2024-01-03")
|
||||||
|
assert s2["sum"] == 1 + 2 + 13 + 45 + 69 # 130
|
||||||
|
s3 = next(s for s in sums if s["date"] == "2024-01-05")
|
||||||
|
assert s3["sum"] == 2 + 13 + 22 + 36 + 55 # 128
|
||||||
|
|
||||||
|
|
||||||
|
def test_sum_empty_db(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert sum_range_analysis(game["id"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── delta_analysis ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_delta_values_correct(pb_game):
|
||||||
|
deltas = delta_analysis(pb_game["id"])
|
||||||
|
assert len(deltas) == 3
|
||||||
|
d1 = next(d for d in deltas if d["date"] == "2024-01-01")
|
||||||
|
# sorted [1,13,36,61,69] → diffs [12, 23, 25, 8]
|
||||||
|
assert d1["deltas"] == [12, 23, 25, 8]
|
||||||
|
d3 = next(d for d in deltas if d["date"] == "2024-01-05")
|
||||||
|
# sorted [2,13,22,36,55] → diffs [11, 9, 14, 19]
|
||||||
|
assert d3["deltas"] == [11, 9, 14, 19]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delta_length_matches_main_count_minus_one(pb_game):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
deltas = delta_analysis(pb_game["id"])
|
||||||
|
for d in deltas:
|
||||||
|
assert len(d["deltas"]) == game["main_count"] - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delta_empty_db(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert delta_analysis(game["id"]) == []
|
||||||
+244
@@ -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)
|
||||||
Reference in New Issue
Block a user