-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path1_Setup.py
More file actions
498 lines (416 loc) · 16.3 KB
/
1_Setup.py
File metadata and controls
498 lines (416 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
import logging
import os
import tempfile
# Configure logging FIRST, before importing other modules that use loggers
from logger import setup_logging
# App log level from environment variable (default: INFO)
# Set LOG_LEVEL=DEBUG for verbose output (only affects app.* loggers)
_log_level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
_log_level = getattr(logging, _log_level_name, logging.INFO)
setup_logging(app_level=_log_level)
import streamlit as st
if st.secrets.get("GUROBI_LIC"):
# Define a fixed path for the license file in the system's temp directory
temp_dir = tempfile.gettempdir()
license_path = os.path.join(temp_dir, "gurobi.lic")
# Write the license file only if it doesn't already exist
if not os.path.exists(license_path):
with open(license_path, "w") as f:
f.write(st.secrets["GUROBI_LIC"])
# Set the environment variable to point to the license file
os.environ["GRB_LICENSE_FILE"] = license_path
import random
from datetime import datetime
from typing import Any
import pandas as pd
from constants import (
DEFAULT_IS_DOUBLES,
DEFAULT_NUM_COURTS,
DEFAULT_WEIGHTS,
PAGE_SESSION,
PLAYERS_PER_COURT_DOUBLES,
PLAYERS_PER_COURT_SINGLES,
TTT_DEFAULT_MU,
TTT_DEFAULT_SIGMA,
)
from database import PlayerDB, SessionDB
from session_logic import ClubNightSession, SessionManager, Player
from app_types import Gender
import session_service
import player_service
from player_service import (
create_registry_dataframe,
create_session_setup_dataframe,
dataframe_to_players,
)
# Setup Constants
DEFAULT_PLAYERS_TABLE = {
f"P{i}": Player(name=f"P{i}", gender=Gender.MALE, mu=TTT_DEFAULT_MU)
for i in range(1, 11)
}
# Random words for default session names
RANDOM_WORDS = [
"Phoenix",
"Dragon",
"Tiger",
"Eagle",
"Falcon",
"Hawk",
"Wolf",
"Lion",
"Thunder",
"Lightning",
"Storm",
"Blaze",
"Frost",
"Shadow",
"Star",
"Moon",
"Solar",
"Cosmic",
"Nova",
"Meteor",
"Comet",
"Galaxy",
"Nebula",
"Aurora",
"Titan",
"Atlas",
"Zeus",
"Apollo",
"Orion",
"Neptune",
"Mercury",
"Venus",
]
def generate_session_name() -> str:
"""Generates a unique session name using a random word and timestamp."""
word = random.choice(RANDOM_WORDS)
timestamp = datetime.now().strftime("%m%d-%H%M")
return f"{word}-{timestamp}"
# create_editor_dataframe is imported from player_registry
def validate_session_setup(
player_ids: list, num_courts: int
) -> tuple[bool, str | None]:
"""Validates player list and court count. Returns (is_valid, error_message)."""
if len(player_ids) != len(set(player_ids)):
return False, "Error: Duplicate names found in the player list."
if not player_ids:
return False, "Please add players to the list before starting."
# Allow starting even if there are more courts than players; extra courts will sit idle
return True, None
def start_session(
player_table: dict[str, Player],
num_courts: int,
weights: dict[str, Any],
session_name: str,
is_doubles: bool,
is_recorded: bool = True,
) -> None:
"""Creates and starts a new badminton session.
Raises:
Stops execution via st.stop() if database session cannot be created.
"""
try:
session = session_service.create_new_session(
player_table=player_table,
num_courts=num_courts,
weights=weights,
session_name=session_name,
is_doubles=is_doubles,
is_recorded=is_recorded,
)
except Exception as e:
st.error(f"Could not create session in database: {e}")
st.stop()
st.session_state.session = session
st.session_state.current_session_name = session_name
st.switch_page(f"pages/{PAGE_SESSION}")
st.set_page_config(layout="wide", page_title="Badminton Setup")
st.title("🏸 Badminton Club Rotation")
# --- Session Selection Logic ---
existing_sessions = SessionManager.list_sessions()
if existing_sessions:
st.subheader("Active Sessions")
for session_name in existing_sessions:
with st.container(border=True):
col1, col2, col3 = st.columns([3, 1, 1])
with col1:
st.markdown(f"### {session_name}")
with col2:
if st.button("▶️ Resume", key=f"resume_{session_name}", width="stretch"):
session = SessionManager.load(session_name)
if session:
st.session_state.session = session
st.session_state.current_session_name = session_name
st.switch_page(f"pages/{PAGE_SESSION}")
else:
st.error(f"Failed to load session '{session_name}'")
with col3:
if st.button("🗑️ Delete", key=f"delete_{session_name}", width="stretch"):
SessionManager.clear(session_name)
st.rerun()
st.divider()
# --- Main Setup UI ---
st.header("Session Setup")
# Initialize master registry if not exists
# Initialize master registry if not exists or requested to update
should_refresh_players = st.session_state.get("player_table_updated", False)
if "master_registry" not in st.session_state or should_refresh_players:
try:
st.session_state.master_registry = PlayerDB.get_all_players()
except Exception as e:
st.warning(f"Could not connect to Supabase: {e}")
if "master_registry" not in st.session_state:
st.session_state.master_registry = {}
# Sync session_player_selection if needed (re-sync from player_table when returning from session)
# Ensure persistent state exists
if "session_player_selection" not in st.session_state:
st.session_state.session_player_selection = []
# Handle restoration from session termination
if should_refresh_players and "player_table" in st.session_state:
# Filter to ensure we only select players currently in the registry
valid_keys = [
k
for k in st.session_state.player_table.keys()
if k in st.session_state.master_registry
]
st.session_state.session_player_selection = valid_keys
# Now satisfied, delete the flag
if "player_table_updated" in st.session_state:
del st.session_state["player_table_updated"]
tab1, tab2 = st.tabs(["👥 Session Players", "🗃️ Member Registry"])
with tab2:
st.subheader("Manage Member Registry")
st.info("This is your master database. Add new members or update ratings here.")
# Create DF for registry (clean view, core registry fields only)
registry_df = create_registry_dataframe(st.session_state.master_registry)
# Column configuration for registry
reg_column_config = {
"Gender": st.column_config.SelectboxColumn(
"Gender", options=["M", "F"], default="M", required=True
),
"Prior Mu": st.column_config.NumberColumn(
"Prior Mu",
help="Initial skill estimate (18=weak, 25=avg, 32=strong). Edit this!",
default=TTT_DEFAULT_MU,
min_value=10.0,
max_value=40.0,
step=1.0,
format="%.1f",
required=True,
),
"Mu": st.column_config.NumberColumn(
"Mu",
help="Current skill (computed by TTT from match history)",
format="%.1f",
),
"Sigma": st.column_config.NumberColumn(
"Sigma",
help="Uncertainty (computed by TTT)",
format="%.2f",
),
"Rating": st.column_config.NumberColumn(
"Rating",
help="Conservative skill rating (mu - 3*sigma)",
format="%.1f",
),
"database_id": None, # Hide from user - internal tracking only
}
registry_df = registry_df.sort_values("Rating", ascending=False).reset_index(drop=True)
registry_df["#"] = range(1, len(registry_df) + 1)
edited_reg_df = st.data_editor(
registry_df,
column_config=reg_column_config,
disabled=[
"#",
"Mu",
"Sigma",
"Rating",
"database_id",
], # Only Prior Mu is editable (besides Name/Gender)
hide_index=True,
num_rows="dynamic",
width="stretch",
key="registry_editor",
)
if st.button("💾 Save Registry to Cloud", type="secondary"):
# Process edited DataFrame into Player objects
new_registry = dataframe_to_players(edited_reg_df)
try:
player_service.sync_registry_to_database(
old_registry=st.session_state.master_registry,
new_registry=new_registry,
)
st.session_state.master_registry = new_registry
st.success("Registry saved to Supabase!")
st.rerun()
except Exception as e:
st.error(f"Failed to save registry: {e}")
with tab1:
st.subheader("Select Session Players")
all_member_names = sorted(list(st.session_state.master_registry.keys()))
# Use multiselect to pick from registry with stable key-based state
selected_names = st.multiselect(
"Who is playing in this session?",
options=all_member_names,
key="session_player_selection",
help="Start typing to search for existing members",
)
st.divider()
if selected_names:
st.markdown(f"**{len(selected_names)} players selected**")
# Build the session player table from registry
session_players = {
name: st.session_state.master_registry[name] for name in selected_names
}
# If Doubles, allow setting temporary team names for this session
if st.session_state.get("is_doubles_persistent", DEFAULT_IS_DOUBLES):
st.write("### (Optional) Pair Fixed Teams")
st.caption(
"Players with matching team names will always partner together. "
"Use comma-separated names for multiple memberships (e.g., 'TeamA, TeamB')."
)
# Simple editor for team names only for selected players
player_ranks = range(1, len(session_players) + 1)
temp_team_df = pd.DataFrame(
{
"Player": [p.name for p in session_players.values()],
"Team Name": [""] * len(session_players),
}
)
edited_teams = st.data_editor(
temp_team_df,
hide_index=True,
width="stretch",
key="session_team_editor",
)
# Update team names in our temporary session object
for _, row in edited_teams.iterrows():
if row["Player"] in session_players:
session_players[row["Player"]].team_name = row["Team Name"]
# Store in session state for the 'Start' button below
st.session_state.player_table = session_players
else:
st.warning("Select at least one player to start.")
st.session_state.player_table = {}
st.divider()
# Initialize the editor's DataFrame if it doesn't exist
if "editor_df" not in st.session_state:
# Need to get is_doubles early, use default if not set
current_is_doubles = st.session_state.get(
"is_doubles_persistent", DEFAULT_IS_DOUBLES
)
st.session_state.editor_df = create_session_setup_dataframe(
st.session_state.player_table, current_is_doubles
)
# Note: Confirm button is removed in favor of Tab management
# --- Session Start Logic ---
st.subheader("2. Start New Session")
session_name = st.text_input(
"Session Name",
placeholder="e.g., Monday Night, Weekend Game",
key="new_session_name",
)
with st.sidebar:
st.header("Optimizer Weights")
st.info("Adjust the importance of different factors for creating matches.")
if "weights" not in st.session_state:
st.session_state.weights = DEFAULT_WEIGHTS.copy()
# Initialize individual weight keys if they don't exist
if "skill_weight" not in st.session_state:
st.session_state.skill_weight = st.session_state.weights["skill"]
if "power_weight" not in st.session_state:
st.session_state.power_weight = st.session_state.weights["power"]
if "pairing_weight" not in st.session_state:
st.session_state.pairing_weight = st.session_state.weights["pairing"]
# Let the widgets manage their own state via keys
st.number_input("Skill Balance", min_value=0, step=1, key="skill_weight")
st.number_input("Power Balance", min_value=0, step=1, key="power_weight")
st.number_input("Pairing History", min_value=0, step=1, key="pairing_weight")
# Update the central weights dictionary from the widget states
st.session_state.weights["skill"] = st.session_state.skill_weight
st.session_state.weights["power"] = st.session_state.power_weight
st.session_state.weights["pairing"] = st.session_state.pairing_weight
# Initialize persistent game mode (is_doubles boolean)
if "is_doubles_persistent" not in st.session_state:
st.session_state.is_doubles_persistent = DEFAULT_IS_DOUBLES
is_doubles = st.toggle(
"Doubles Mode",
value=st.session_state.is_doubles_persistent,
help="Enable for 4-player doubles, disable for 2-player singles",
)
# Detect game mode change and refresh editor
if st.session_state.is_doubles_persistent != is_doubles:
st.session_state.is_doubles_persistent = is_doubles
# Refresh the editor dataframe to add/remove Team Name column
st.session_state.editor_df = create_session_setup_dataframe(
st.session_state.player_table, is_doubles
)
st.rerun()
# Initialize persistent number of courts (survives session resets)
if "num_courts_persistent" not in st.session_state:
st.session_state.num_courts_persistent = DEFAULT_NUM_COURTS
# Initialize the widget key if it doesn't exist or restore from persistent value
if "num_courts_input" not in st.session_state:
st.session_state.num_courts_input = st.session_state.num_courts_persistent
num_courts = st.number_input(
"Number of Courts Available", min_value=1, step=1, key="num_courts_input"
)
# Keep the persistent value in sync with the widget
st.session_state.num_courts_persistent = num_courts
# Initialize is_recorded persistent state
if "is_recorded_persistent" not in st.session_state:
st.session_state.is_recorded_persistent = True
is_recorded = st.checkbox(
"Record to Dataset",
value=st.session_state.is_recorded_persistent,
help="When enabled, session matches are saved to the database for rating calculations",
)
st.session_state.is_recorded_persistent = is_recorded
if st.button("🚀 Start New Session", type="primary"):
# Validate player_table
if "player_table" not in st.session_state or not st.session_state.player_table:
st.error("Player list is empty. Please add players before starting.")
st.stop()
player_table = st.session_state.player_table
# Generate default session name if none provided
final_session_name = (
session_name.strip()
if session_name and session_name.strip()
else generate_session_name()
)
# Check if session name already exists
if final_session_name in SessionManager.list_sessions():
st.error(
f"A session named '{final_session_name}' already exists. Please choose a different name or delete the existing session."
)
st.stop()
player_ids = list(player_table.keys())
# Validate first
is_valid, error_message = validate_session_setup(player_ids, num_courts)
# Provide a heads-up if there are more courts than can be filled
players_per_court = (
PLAYERS_PER_COURT_DOUBLES if is_doubles else PLAYERS_PER_COURT_SINGLES
)
game_mode_str = "Doubles" if is_doubles else "Singles"
total_players = len(player_ids)
possible_courts = total_players // players_per_court
if num_courts > possible_courts:
st.info(
f"Only {possible_courts} court(s) can be filled with {total_players} players in {game_mode_str} mode. "
"Extra courts will remain idle until more players join."
)
if is_valid:
# If validation passes, start the session
start_session(
player_table,
num_courts,
st.session_state.weights,
final_session_name,
is_doubles,
is_recorded,
)
else:
# If validation fails, show error
st.error(error_message)