Compare commits
2 Commits
add2e6aa46
...
8a110661bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a110661bb | |||
| b35141d1c7 |
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Copy this file to .env and fill in secrets. Do NOT commit .env
|
||||||
|
# Example env file for local deployment
|
||||||
|
PORT=8501
|
||||||
|
IMAGE_NAME=wm2026_simulator:latest
|
||||||
|
# Set a password used by your deployment or reverse proxy
|
||||||
|
STREAMLIT_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Optional: remote deploy settings (used by CI if provided as secrets)
|
||||||
|
DEPLOY_HOST=example.com
|
||||||
|
DEPLOY_USER=deploy
|
||||||
|
DEPLOY_PASSWORD=supersecret
|
||||||
55
.github/workflows/deploy.yml
vendored
Normal file
55
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:latest
|
||||||
|
|
||||||
|
optional-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-and-push
|
||||||
|
if: ${{ secrets.DEPLOY_HOST && secrets.DEPLOY_USER && secrets.DEPLOY_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install sshpass
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y sshpass
|
||||||
|
|
||||||
|
- name: Copy files and restart remote compose
|
||||||
|
env:
|
||||||
|
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
PASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||||
|
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:latest
|
||||||
|
run: |
|
||||||
|
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $USER@$HOST "mkdir -p ~/wm2026_deploy && exit"
|
||||||
|
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no docker-compose.yml $USER@$HOST:~/wm2026_deploy/
|
||||||
|
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $USER@$HOST "cd ~/wm2026_deploy && docker pull $IMAGE || true && docker-compose up -d --build"
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
*.pyc
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
/.pytest_cache
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PORT=8501
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
CMD streamlit run app.py --server.port $PORT --server.address 0.0.0.0
|
||||||
Binary file not shown.
150
app.py
150
app.py
@ -128,6 +128,27 @@ TRANSLATIONS = {
|
|||||||
'es': 'Elige un rival',
|
'es': 'Elige un rival',
|
||||||
'ru': 'Выберите соперника',
|
'ru': 'Выберите соперника',
|
||||||
},
|
},
|
||||||
|
'select_focus_team_label': {
|
||||||
|
'de': 'Land für KO-Analyse wählen',
|
||||||
|
'en': 'Choose country for KO analysis',
|
||||||
|
'fr': 'Choisir le pays pour l’analyse KO',
|
||||||
|
'es': 'Elige país para análisis KO',
|
||||||
|
'ru': 'Выберите страну для анализа плей-офф',
|
||||||
|
},
|
||||||
|
'group_place_phrase': {
|
||||||
|
'de': 'Gruppenplatz',
|
||||||
|
'en': 'group place',
|
||||||
|
'fr': 'place en groupe',
|
||||||
|
'es': 'puesto de grupo',
|
||||||
|
'ru': 'место в группе',
|
||||||
|
},
|
||||||
|
'path_phrase': {
|
||||||
|
'de': 'Pfad',
|
||||||
|
'en': 'path',
|
||||||
|
'fr': 'parcours',
|
||||||
|
'es': 'ruta',
|
||||||
|
'ru': 'путь',
|
||||||
|
},
|
||||||
'germany_vs': {
|
'germany_vs': {
|
||||||
'de': '**Deutschland vs. {opponent}**',
|
'de': '**Deutschland vs. {opponent}**',
|
||||||
'en': '**Germany vs. {opponent}**',
|
'en': '**Germany vs. {opponent}**',
|
||||||
@ -637,7 +658,7 @@ def simulate_group(group_teams, rng, strengths):
|
|||||||
return teams_sorted, {'pts': pts, 'gd': gd, 'gf': gf, 'cards': cards, 'matches': matches}, match_records
|
return teams_sorted, {'pts': pts, 'gd': gd, 'gf': gf, 'cards': cards, 'matches': matches}, match_records
|
||||||
|
|
||||||
|
|
||||||
def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_rankings=None):
|
def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_rankings=None, focal_team='Germany'):
|
||||||
if team_rankings is None:
|
if team_rankings is None:
|
||||||
team_rankings = strengths
|
team_rankings = strengths
|
||||||
|
|
||||||
@ -793,23 +814,23 @@ def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_ranki
|
|||||||
|
|
||||||
rounds = {}
|
rounds = {}
|
||||||
for a, b in r32_matches:
|
for a, b in r32_matches:
|
||||||
if a == 'Germany' or b == 'Germany':
|
if a == focal_team or b == focal_team:
|
||||||
opp = b if a == 'Germany' else a
|
opp = b if a == focal_team else a
|
||||||
rounds['R32'] = opp
|
rounds['R32'] = opp
|
||||||
for a, b in r16_matches:
|
for a, b in r16_matches:
|
||||||
if a == 'Germany' or b == 'Germany':
|
if a == focal_team or b == focal_team:
|
||||||
opp = b if a == 'Germany' else a
|
opp = b if a == focal_team else a
|
||||||
rounds['R16'] = opp
|
rounds['R16'] = opp
|
||||||
for a, b in qf_matches:
|
for a, b in qf_matches:
|
||||||
if a == 'Germany' or b == 'Germany':
|
if a == focal_team or b == focal_team:
|
||||||
opp = b if a == 'Germany' else a
|
opp = b if a == focal_team else a
|
||||||
rounds['QF'] = opp
|
rounds['QF'] = opp
|
||||||
for a, b in sf_matches:
|
for a, b in sf_matches:
|
||||||
if a == 'Germany' or b == 'Germany':
|
if a == focal_team or b == focal_team:
|
||||||
opp = b if a == 'Germany' else a
|
opp = b if a == focal_team else a
|
||||||
rounds['SF'] = opp
|
rounds['SF'] = opp
|
||||||
if 'Germany' in final_teams:
|
if focal_team in final_teams:
|
||||||
opp = final_teams[1] if final_teams[0] == 'Germany' else final_teams[0] if final_teams[1] == 'Germany' else None
|
opp = final_teams[1] if final_teams[0] == focal_team else final_teams[0] if final_teams[1] == focal_team else None
|
||||||
rounds['Final'] = opp
|
rounds['Final'] = opp
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -843,7 +864,7 @@ def parse_group_place(route):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, strengths=None):
|
def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, strengths=None, focal_team='Germany'):
|
||||||
rng = np.random.default_rng(seed)
|
rng = np.random.default_rng(seed)
|
||||||
counters = {r: Counter() for r in ['R32', 'R16', 'QF', 'SF', 'Final']}
|
counters = {r: Counter() for r in ['R32', 'R16', 'QF', 'SF', 'Final']}
|
||||||
teams = [t for team_list in groups.values() for t in team_list]
|
teams = [t for team_list in groups.values() for t in team_list]
|
||||||
@ -866,17 +887,17 @@ def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _ in range(n):
|
for _ in range(n):
|
||||||
result = run_single_tournament(groups, rng, strengths, annex_mapping=annex_mapping, team_rankings=team_rankings)
|
result = run_single_tournament(groups, rng, strengths, annex_mapping=annex_mapping, team_rankings=team_rankings, focal_team=focal_team)
|
||||||
for r, opp in result['rounds'].items():
|
for r, opp in result['rounds'].items():
|
||||||
if opp is not None:
|
if opp is not None:
|
||||||
counters[r][opp] += 1
|
counters[r][opp] += 1
|
||||||
meeting_stats['Germany'][opp][r] += 1
|
meeting_stats[focal_team][opp][r] += 1
|
||||||
meeting_stats[opp]['Germany'][r] += 1
|
meeting_stats[opp][focal_team][r] += 1
|
||||||
if result['positions']['Germany'] == 1 and result['positions'][opp] == 1:
|
if result['positions'][focal_team] == 1 and result['positions'][opp] == 1:
|
||||||
both_group_winner_meetings[opp] += 1
|
both_group_winner_meetings[opp] += 1
|
||||||
germany_route = summarize_meeting_route(result['team_info']['Germany'], r)
|
focal_route = summarize_meeting_route(result['team_info'][focal_team], r)
|
||||||
opp_route = summarize_meeting_route(result['team_info'][opp], r)
|
opp_route = summarize_meeting_route(result['team_info'][opp], r)
|
||||||
meeting_paths[opp][r][(germany_route, opp_route)] += 1
|
meeting_paths[opp][r][(focal_route, opp_route)] += 1
|
||||||
for team, pos in result['positions'].items():
|
for team, pos in result['positions'].items():
|
||||||
team_stats[team]['group_positions'][pos] += 1
|
team_stats[team]['group_positions'][pos] += 1
|
||||||
for team, record in result['group_records'].items():
|
for team, record in result['group_records'].items():
|
||||||
@ -961,11 +982,21 @@ def main():
|
|||||||
list(LANGUAGES.keys()),
|
list(LANGUAGES.keys()),
|
||||||
format_func=lambda code: LANGUAGES[code],
|
format_func=lambda code: LANGUAGES[code],
|
||||||
index=0,
|
index=0,
|
||||||
|
key='lang_select',
|
||||||
)
|
)
|
||||||
st.title(translate('title', lang))
|
st.title(translate('title', lang))
|
||||||
|
|
||||||
teams = load_teams()
|
teams = load_teams()
|
||||||
strengths = compute_team_strengths(TEAM_STAGE_ODDS)
|
strengths = compute_team_strengths(TEAM_STAGE_ODDS)
|
||||||
|
# choose focal team for KO analysis (defaults to Germany if present)
|
||||||
|
focus_default = 'Germany' if 'Germany' in teams else teams[0]
|
||||||
|
focus_team = st.sidebar.selectbox(
|
||||||
|
translate('select_focus_team_label', lang),
|
||||||
|
teams,
|
||||||
|
index=teams.index(focus_default),
|
||||||
|
format_func=lambda t: display_team_name(t, lang),
|
||||||
|
key=f'focus_team_{lang}',
|
||||||
|
)
|
||||||
|
|
||||||
st.sidebar.header(translate('sidebar_header', lang))
|
st.sidebar.header(translate('sidebar_header', lang))
|
||||||
n = st.sidebar.slider(translate('sidebar_n', lang), min_value=100, max_value=20000, value=2000, step=100)
|
n = st.sidebar.slider(translate('sidebar_n', lang), min_value=100, max_value=20000, value=2000, step=100)
|
||||||
@ -999,6 +1030,7 @@ def main():
|
|||||||
annex_mapping=None,
|
annex_mapping=None,
|
||||||
team_rankings=strengths,
|
team_rankings=strengths,
|
||||||
strengths=strengths,
|
strengths=strengths,
|
||||||
|
focal_team=focus_team,
|
||||||
)
|
)
|
||||||
|
|
||||||
if st.session_state.simulation_results is not None:
|
if st.session_state.simulation_results is not None:
|
||||||
@ -1006,25 +1038,26 @@ def main():
|
|||||||
st.success(translate('done_message', lang))
|
st.success(translate('done_message', lang))
|
||||||
|
|
||||||
counters, team_stats, meeting_stats, meeting_paths, both_group_winner_meetings = st.session_state.simulation_results
|
counters, team_stats, meeting_stats, meeting_paths, both_group_winner_meetings = st.session_state.simulation_results
|
||||||
germany = team_stats['Germany']
|
focal_stats = team_stats[focus_team]
|
||||||
tab1, tab2 = st.tabs([translate('tab_opponent_details', lang), translate('tab_results', lang)])
|
tab1, tab2 = st.tabs([translate('tab_opponent_details', lang), translate('tab_results', lang)])
|
||||||
|
|
||||||
with tab1:
|
with tab1:
|
||||||
st.subheader(translate('opponent_details_header', lang))
|
st.subheader(translate('opponent_details_header', lang))
|
||||||
possible_opponents = sorted(
|
possible_opponents = sorted(
|
||||||
[t for t in teams if t != 'Germany'],
|
[t for t in teams if t != focus_team],
|
||||||
key=lambda x: sum(meeting_stats['Germany'][x].values()),
|
key=lambda x: sum(meeting_stats[focus_team][x].values()),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
opponent_options = ['Germany'] + [t for t in sorted(teams) if t != 'Germany']
|
opponent_options = [focus_team] + [t for t in sorted(teams) if t != focus_team]
|
||||||
opponent = st.selectbox(
|
opponent = st.selectbox(
|
||||||
translate('select_opponent', lang),
|
translate('select_opponent', lang),
|
||||||
opponent_options,
|
opponent_options,
|
||||||
format_func=lambda t: display_team_name(t, lang),
|
format_func=lambda t: display_team_name(t, lang),
|
||||||
|
key=f'opponent_select_{lang}',
|
||||||
)
|
)
|
||||||
|
|
||||||
if opponent:
|
if opponent:
|
||||||
meet = meeting_stats['Germany'][opponent]
|
meet = meeting_stats[focus_team][opponent]
|
||||||
total_meet = sum(meet.values())
|
total_meet = sum(meet.values())
|
||||||
st.markdown(translate('germany_vs', lang, opponent=display_team_name(opponent, lang)))
|
st.markdown(translate('germany_vs', lang, opponent=display_team_name(opponent, lang)))
|
||||||
if total_meet == 0:
|
if total_meet == 0:
|
||||||
@ -1061,20 +1094,20 @@ def main():
|
|||||||
if meet[r] == 0:
|
if meet[r] == 0:
|
||||||
continue
|
continue
|
||||||
for (germany_route, opp_route), path_count in meeting_paths[opponent][r].most_common():
|
for (germany_route, opp_route), path_count in meeting_paths[opponent][r].most_common():
|
||||||
germany_winner = is_group_winner_route(germany_route)
|
focal_winner = is_group_winner_route(germany_route)
|
||||||
opp_winner = is_group_winner_route(opp_route)
|
opp_winner = is_group_winner_route(opp_route)
|
||||||
germany_place = parse_group_place(germany_route)
|
focal_place = parse_group_place(germany_route)
|
||||||
opp_place = parse_group_place(opp_route)
|
opp_place = parse_group_place(opp_route)
|
||||||
placement_rows.append({
|
placement_rows.append({
|
||||||
'Round': r,
|
'Round': r,
|
||||||
'Germany group place': germany_place,
|
'Focus group place': focal_place,
|
||||||
f'{opponent} group place': opp_place,
|
'Opponent group place': opp_place,
|
||||||
'Both group winners': germany_winner and opp_winner,
|
'Both group winners': focal_winner and opp_winner,
|
||||||
'Count': path_count,
|
'Count': path_count,
|
||||||
'Share of round': f'{path_count / meet[r]:.1%}',
|
'Share of round': f'{path_count / meet[r]:.1%}',
|
||||||
'Share of sims': f'{path_count / n:.1%}',
|
'Share of sims': f'{path_count / n:.1%}',
|
||||||
'Germany path': ' → '.join(germany_route),
|
'Focus path': ' → '.join(germany_route),
|
||||||
f'{opponent} path': ' → '.join(opp_route),
|
'Opponent path': ' → '.join(opp_route),
|
||||||
})
|
})
|
||||||
|
|
||||||
if placement_rows:
|
if placement_rows:
|
||||||
@ -1086,14 +1119,14 @@ def main():
|
|||||||
[
|
[
|
||||||
'Stage',
|
'Stage',
|
||||||
'Round',
|
'Round',
|
||||||
'Germany group place',
|
'Focus group place',
|
||||||
f'{opponent} group place',
|
'Opponent group place',
|
||||||
'Both group winners',
|
'Both group winners',
|
||||||
'Count',
|
'Count',
|
||||||
'Share of round',
|
'Share of round',
|
||||||
'Share of sims',
|
'Share of sims',
|
||||||
'Germany path',
|
'Focus path',
|
||||||
f'{opponent} path',
|
'Opponent path',
|
||||||
'Round order',
|
'Round order',
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@ -1102,7 +1135,12 @@ def main():
|
|||||||
ascending=[False, True, False],
|
ascending=[False, True, False],
|
||||||
)
|
)
|
||||||
placement_df = placement_df.drop(columns=['Round order'])
|
placement_df = placement_df.drop(columns=['Round order'])
|
||||||
placement_df = placement_df.rename(columns=COLUMN_LABELS[lang])
|
# prepare dynamic labels for the focus team
|
||||||
|
col_map = COLUMN_LABELS.get(lang, {}).copy()
|
||||||
|
# dynamic focus labels
|
||||||
|
col_map['Focus group place'] = f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}"
|
||||||
|
col_map['Focus path'] = f"{display_team_name(focus_team, lang)} {translate('path_phrase', lang)}"
|
||||||
|
placement_df = placement_df.rename(columns=col_map)
|
||||||
st.markdown(translate('flows_first_both_winners', lang))
|
st.markdown(translate('flows_first_both_winners', lang))
|
||||||
def highlight_row(row):
|
def highlight_row(row):
|
||||||
return [
|
return [
|
||||||
@ -1112,32 +1150,32 @@ def main():
|
|||||||
st.dataframe(placement_df.style.apply(highlight_row, axis=1))
|
st.dataframe(placement_df.style.apply(highlight_row, axis=1))
|
||||||
|
|
||||||
grouping = pd.DataFrame(placement_rows).groupby(
|
grouping = pd.DataFrame(placement_rows).groupby(
|
||||||
['Germany group place', f'{opponent} group place']
|
['Focus group place', 'Opponent group place']
|
||||||
)['Count'].sum().reset_index()
|
)['Count'].sum().reset_index()
|
||||||
grouping['Probability of meeting'] = grouping['Count'] / total_meet
|
grouping['Probability of meeting'] = grouping['Count'] / total_meet
|
||||||
grouping = grouping.rename(
|
grouping = grouping.rename(
|
||||||
columns={
|
columns={
|
||||||
'Germany group place': label('Germany group place', lang),
|
'Focus group place': f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}",
|
||||||
f'{opponent} group place': label(f'{opponent} group place', lang),
|
'Opponent group place': label('Opponent group place', lang),
|
||||||
'Count': label('Count', lang),
|
'Count': label('Count', lang),
|
||||||
'Probability of meeting': label('Probability of meeting', lang),
|
'Probability of meeting': label('Probability of meeting', lang),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
st.subheader(translate('meeting_by_group_place', lang))
|
st.subheader(translate('meeting_by_group_place', lang))
|
||||||
grouping = grouping.sort_values(
|
grouping = grouping.sort_values(
|
||||||
[label('Germany group place', lang), label(f'{opponent} group place', lang)]
|
[f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}", label('Opponent group place', lang)]
|
||||||
)
|
)
|
||||||
st.dataframe(grouping)
|
st.dataframe(grouping)
|
||||||
|
|
||||||
st.subheader(translate('stage_meeting_phase', lang))
|
st.subheader(translate('stage_meeting_phase', lang))
|
||||||
st.markdown(
|
st.markdown(
|
||||||
format_stage_line(
|
format_stage_line(
|
||||||
display_team_name('Germany', lang),
|
display_team_name(focus_team, lang),
|
||||||
f'{team_stats['Germany']['R16'] / n:.1%}',
|
f"{team_stats[focus_team]['R16'] / n:.1%}",
|
||||||
f'{team_stats['Germany']['QF'] / n:.1%}',
|
f"{team_stats[focus_team]['QF'] / n:.1%}",
|
||||||
f'{team_stats['Germany']['SF'] / n:.1%}',
|
f"{team_stats[focus_team]['SF'] / n:.1%}",
|
||||||
f'{team_stats['Germany']['Final'] / n:.1%}',
|
f"{team_stats[focus_team]['Final'] / n:.1%}",
|
||||||
f'{team_stats['Germany']['Winner'] / n:.1%}',
|
f"{team_stats[focus_team]['Winner'] / n:.1%}",
|
||||||
lang=lang,
|
lang=lang,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1145,11 +1183,11 @@ def main():
|
|||||||
st.markdown(
|
st.markdown(
|
||||||
format_stage_line(
|
format_stage_line(
|
||||||
display_team_name(opponent, lang),
|
display_team_name(opponent, lang),
|
||||||
f'{opp_stats['R16'] / n:.1%}',
|
f"{opp_stats['R16'] / n:.1%}",
|
||||||
f'{opp_stats['QF'] / n:.1%}',
|
f"{opp_stats['QF'] / n:.1%}",
|
||||||
f'{opp_stats['SF'] / n:.1%}',
|
f"{opp_stats['SF'] / n:.1%}",
|
||||||
f'{opp_stats['Final'] / n:.1%}',
|
f"{opp_stats['Final'] / n:.1%}",
|
||||||
f'{opp_stats['Winner'] / n:.1%}',
|
f"{opp_stats['Winner'] / n:.1%}",
|
||||||
lang=lang,
|
lang=lang,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1162,13 +1200,13 @@ def main():
|
|||||||
translate(
|
translate(
|
||||||
'germany_reaches',
|
'germany_reaches',
|
||||||
lang,
|
lang,
|
||||||
team=display_team_name('Germany', lang),
|
team=display_team_name(focus_team, lang),
|
||||||
stage=stage_label(r, lang),
|
stage=stage_label(r, lang),
|
||||||
value=f"{germany[r] / n:.1%}",
|
value=f"{team_stats[focus_team][r] / n:.1%}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
st.subheader(translate('possible_opponents', lang, team=display_team_name('Germany', lang)))
|
st.subheader(translate('possible_opponents', lang, team=display_team_name(focus_team, lang)))
|
||||||
for r in ['R32', 'R16', 'QF', 'SF', 'Final']:
|
for r in ['R32', 'R16', 'QF', 'SF', 'Final']:
|
||||||
cnt = counters[r]
|
cnt = counters[r]
|
||||||
total = sum(cnt.values())
|
total = sum(cnt.values())
|
||||||
@ -1215,11 +1253,12 @@ def main():
|
|||||||
summary_df = pd.DataFrame(team_rows).sort_values(label('R16', lang), ascending=False)
|
summary_df = pd.DataFrame(team_rows).sort_values(label('R16', lang), ascending=False)
|
||||||
st.dataframe(summary_df)
|
st.dataframe(summary_df)
|
||||||
|
|
||||||
team_options = ['Germany'] + [t for t in sorted(teams) if t != 'Germany']
|
team_options = [focus_team] + [t for t in sorted(teams) if t != focus_team]
|
||||||
selected_team = st.selectbox(
|
selected_team = st.selectbox(
|
||||||
translate('select_team', lang),
|
translate('select_team', lang),
|
||||||
team_options,
|
team_options,
|
||||||
format_func=lambda t: display_team_name(t, lang),
|
format_func=lambda t: display_team_name(t, lang),
|
||||||
|
key=f'team_select_{lang}',
|
||||||
)
|
)
|
||||||
if selected_team:
|
if selected_team:
|
||||||
selected = team_stats[selected_team]
|
selected = team_stats[selected_team]
|
||||||
@ -1264,6 +1303,3 @@ def main():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: ${IMAGE_NAME:-wm2026_simulator:latest}
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8501}:8501"
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-8501}
|
||||||
|
- STREAMLIT_PASSWORD=${STREAMLIT_PASSWORD}
|
||||||
|
restart: unless-stopped
|
||||||
73
gitea-deploy.sh
Executable file
73
gitea-deploy.sh
Executable file
@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Gitea / Git deployment runner script for Proxmox LXC container with Docker.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./gitea-deploy.sh
|
||||||
|
#
|
||||||
|
# Optional environment variables:
|
||||||
|
# REPO_DIR Path to the repository root (default: script directory)
|
||||||
|
# DEPLOY_BRANCH Git branch to deploy (default: main)
|
||||||
|
# COMPOSE_FILE Docker Compose file path (default: docker-compose.yml)
|
||||||
|
# DOCKER_CMD Docker command to use (auto-detected)
|
||||||
|
# GIT_CLEAN If set to 1, clean untracked files before deploy
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_DIR="${REPO_DIR:-$SCRIPT_DIR}"
|
||||||
|
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
GIT_CLEAN="${GIT_CLEAN:-0}"
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE_CMD="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE_CMD="docker-compose"
|
||||||
|
else
|
||||||
|
echo "ERROR: docker compose command not found. Install Docker Compose or use Docker v20+ with built-in compose."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
echo "Deploying $REPO_DIR"
|
||||||
|
echo "Branch: $DEPLOY_BRANCH"
|
||||||
|
echo "Compose file: $COMPOSE_FILE"
|
||||||
|
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: git is not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
echo "ERROR: Compose file '$COMPOSE_FILE' not found in $REPO_DIR."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure repository is on the correct branch and in sync with remote.
|
||||||
|
|
||||||
|
git fetch --all --prune
|
||||||
|
|
||||||
|
git checkout "$DEPLOY_BRANCH"
|
||||||
|
git reset --hard "origin/$DEPLOY_BRANCH"
|
||||||
|
|
||||||
|
if [ "$GIT_CLEAN" = "1" ]; then
|
||||||
|
echo "Cleaning untracked files..."
|
||||||
|
git clean -fdx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and deploy with Docker Compose.
|
||||||
|
|
||||||
|
echo "Stopping existing containers and removing orphans..."
|
||||||
|
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" down --remove-orphans
|
||||||
|
|
||||||
|
echo "Building image..."
|
||||||
|
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||||
|
|
||||||
|
echo "Starting containers..."
|
||||||
|
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
echo "Deployment finished. Current service status:"
|
||||||
|
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
|
exit 0
|
||||||
@ -1,3 +1,4 @@
|
|||||||
streamlit
|
streamlit
|
||||||
pandas
|
pandas
|
||||||
numpy
|
numpy
|
||||||
|
pypdf
|
||||||
|
|||||||
31
tests/test_i18n.py
Normal file
31
tests/test_i18n.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
from app import translate, display_team_name, COLUMN_LABELS, TRANSLATIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_team_name_localization():
|
||||||
|
assert display_team_name('Germany', 'de') == 'Deutschland'
|
||||||
|
assert display_team_name('Germany', 'en') == 'Germany'
|
||||||
|
assert display_team_name('Spain', 'de') == 'Spain'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_focus_label_creation():
|
||||||
|
focus = 'Spain'
|
||||||
|
lang = 'en'
|
||||||
|
col_map = COLUMN_LABELS.get(lang, {}).copy()
|
||||||
|
col_map['Focus group place'] = f"{display_team_name(focus, lang)} {translate('group_place_phrase', lang)}"
|
||||||
|
col_map['Focus path'] = f"{display_team_name(focus, lang)} {translate('path_phrase', lang)}"
|
||||||
|
assert col_map['Focus group place'] == 'Spain group place'
|
||||||
|
assert col_map['Focus path'] == 'Spain path'
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_untranslated_deutsch_in_non_de():
|
||||||
|
# ensure no non-de translation contains the German word 'Deutschland'
|
||||||
|
bad = []
|
||||||
|
for key, d in TRANSLATIONS.items():
|
||||||
|
for lang, text in d.items():
|
||||||
|
if lang != 'de' and 'Deutschland' in text:
|
||||||
|
bad.append((key, lang, text))
|
||||||
|
assert not bad, f"Found German 'Deutschland' in non-de translations: {bad}"
|
||||||
Loading…
x
Reference in New Issue
Block a user