Compare commits

..

2 Commits

Author SHA1 Message Date
8a110661bb add gitea
Some checks failed
Build and Deploy / build-and-push (push) Failing after 31s
Build and Deploy / optional-deploy (push) Has been skipped
2026-05-21 00:10:05 +02:00
b35141d1c7 Add Dockerfile, compose, env example and CI deploy workflow 2026-05-20 21:47:48 +02:00
10 changed files with 298 additions and 57 deletions

11
.env.example Normal file
View 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
View 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
View File

@ -0,0 +1,7 @@
.env
__pycache__/
.venv/
*.pyc
dist/
build/
/.pytest_cache

17
Dockerfile Normal file
View 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
View File

@ -128,6 +128,27 @@ TRANSLATIONS = {
'es': 'Elige un rival',
'ru': 'Выберите соперника',
},
'select_focus_team_label': {
'de': 'Land für KO-Analyse wählen',
'en': 'Choose country for KO analysis',
'fr': 'Choisir le pays pour lanalyse 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': {
'de': '**Deutschland 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
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:
team_rankings = strengths
@ -793,23 +814,23 @@ def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_ranki
rounds = {}
for a, b in r32_matches:
if a == 'Germany' or b == 'Germany':
opp = b if a == 'Germany' else a
if a == focal_team or b == focal_team:
opp = b if a == focal_team else a
rounds['R32'] = opp
for a, b in r16_matches:
if a == 'Germany' or b == 'Germany':
opp = b if a == 'Germany' else a
if a == focal_team or b == focal_team:
opp = b if a == focal_team else a
rounds['R16'] = opp
for a, b in qf_matches:
if a == 'Germany' or b == 'Germany':
opp = b if a == 'Germany' else a
if a == focal_team or b == focal_team:
opp = b if a == focal_team else a
rounds['QF'] = opp
for a, b in sf_matches:
if a == 'Germany' or b == 'Germany':
opp = b if a == 'Germany' else a
if a == focal_team or b == focal_team:
opp = b if a == focal_team else a
rounds['SF'] = opp
if 'Germany' in final_teams:
opp = final_teams[1] if final_teams[0] == 'Germany' else final_teams[0] if final_teams[1] == 'Germany' else None
if focal_team in final_teams:
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
return {
@ -843,7 +864,7 @@ def parse_group_place(route):
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)
counters = {r: Counter() for r in ['R32', 'R16', 'QF', 'SF', 'Final']}
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):
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():
if opp is not None:
counters[r][opp] += 1
meeting_stats['Germany'][opp][r] += 1
meeting_stats[opp]['Germany'][r] += 1
if result['positions']['Germany'] == 1 and result['positions'][opp] == 1:
meeting_stats[focal_team][opp][r] += 1
meeting_stats[opp][focal_team][r] += 1
if result['positions'][focal_team] == 1 and result['positions'][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)
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():
team_stats[team]['group_positions'][pos] += 1
for team, record in result['group_records'].items():
@ -961,11 +982,21 @@ def main():
list(LANGUAGES.keys()),
format_func=lambda code: LANGUAGES[code],
index=0,
key='lang_select',
)
st.title(translate('title', lang))
teams = load_teams()
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))
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,
team_rankings=strengths,
strengths=strengths,
focal_team=focus_team,
)
if st.session_state.simulation_results is not None:
@ -1006,25 +1038,26 @@ def main():
st.success(translate('done_message', lang))
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)])
with tab1:
st.subheader(translate('opponent_details_header', lang))
possible_opponents = sorted(
[t for t in teams if t != 'Germany'],
key=lambda x: sum(meeting_stats['Germany'][x].values()),
[t for t in teams if t != focus_team],
key=lambda x: sum(meeting_stats[focus_team][x].values()),
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(
translate('select_opponent', lang),
opponent_options,
format_func=lambda t: display_team_name(t, lang),
key=f'opponent_select_{lang}',
)
if opponent:
meet = meeting_stats['Germany'][opponent]
meet = meeting_stats[focus_team][opponent]
total_meet = sum(meet.values())
st.markdown(translate('germany_vs', lang, opponent=display_team_name(opponent, lang)))
if total_meet == 0:
@ -1061,20 +1094,20 @@ def main():
if meet[r] == 0:
continue
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)
germany_place = parse_group_place(germany_route)
focal_place = parse_group_place(germany_route)
opp_place = parse_group_place(opp_route)
placement_rows.append({
'Round': r,
'Germany group place': germany_place,
f'{opponent} group place': opp_place,
'Both group winners': germany_winner and opp_winner,
'Focus group place': focal_place,
'Opponent group place': opp_place,
'Both group winners': focal_winner and opp_winner,
'Count': path_count,
'Share of round': f'{path_count / meet[r]:.1%}',
'Share of sims': f'{path_count / n:.1%}',
'Germany path': ''.join(germany_route),
f'{opponent} path': ''.join(opp_route),
'Focus path': ''.join(germany_route),
'Opponent path': ''.join(opp_route),
})
if placement_rows:
@ -1086,14 +1119,14 @@ def main():
[
'Stage',
'Round',
'Germany group place',
f'{opponent} group place',
'Focus group place',
'Opponent group place',
'Both group winners',
'Count',
'Share of round',
'Share of sims',
'Germany path',
f'{opponent} path',
'Focus path',
'Opponent path',
'Round order',
]
]
@ -1102,7 +1135,12 @@ def main():
ascending=[False, True, False],
)
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))
def highlight_row(row):
return [
@ -1112,32 +1150,32 @@ def main():
st.dataframe(placement_df.style.apply(highlight_row, axis=1))
grouping = pd.DataFrame(placement_rows).groupby(
['Germany group place', f'{opponent} group place']
['Focus group place', 'Opponent group place']
)['Count'].sum().reset_index()
grouping['Probability of meeting'] = grouping['Count'] / total_meet
grouping = grouping.rename(
columns={
'Germany group place': label('Germany group place', lang),
f'{opponent} group place': label(f'{opponent} group place', lang),
'Focus group place': f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}",
'Opponent group place': label('Opponent group place', lang),
'Count': label('Count', lang),
'Probability of meeting': label('Probability of meeting', lang),
}
)
st.subheader(translate('meeting_by_group_place', lang))
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.subheader(translate('stage_meeting_phase', lang))
st.markdown(
format_stage_line(
display_team_name('Germany', lang),
f'{team_stats['Germany']['R16'] / n:.1%}',
f'{team_stats['Germany']['QF'] / n:.1%}',
f'{team_stats['Germany']['SF'] / n:.1%}',
f'{team_stats['Germany']['Final'] / n:.1%}',
f'{team_stats['Germany']['Winner'] / n:.1%}',
display_team_name(focus_team, lang),
f"{team_stats[focus_team]['R16'] / n:.1%}",
f"{team_stats[focus_team]['QF'] / n:.1%}",
f"{team_stats[focus_team]['SF'] / n:.1%}",
f"{team_stats[focus_team]['Final'] / n:.1%}",
f"{team_stats[focus_team]['Winner'] / n:.1%}",
lang=lang,
)
)
@ -1145,11 +1183,11 @@ def main():
st.markdown(
format_stage_line(
display_team_name(opponent, lang),
f'{opp_stats['R16'] / n:.1%}',
f'{opp_stats['QF'] / n:.1%}',
f'{opp_stats['SF'] / n:.1%}',
f'{opp_stats['Final'] / n:.1%}',
f'{opp_stats['Winner'] / n:.1%}',
f"{opp_stats['R16'] / n:.1%}",
f"{opp_stats['QF'] / n:.1%}",
f"{opp_stats['SF'] / n:.1%}",
f"{opp_stats['Final'] / n:.1%}",
f"{opp_stats['Winner'] / n:.1%}",
lang=lang,
)
)
@ -1162,13 +1200,13 @@ def main():
translate(
'germany_reaches',
lang,
team=display_team_name('Germany', lang),
team=display_team_name(focus_team, 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']:
cnt = counters[r]
total = sum(cnt.values())
@ -1215,11 +1253,12 @@ def main():
summary_df = pd.DataFrame(team_rows).sort_values(label('R16', lang), ascending=False)
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(
translate('select_team', lang),
team_options,
format_func=lambda t: display_team_name(t, lang),
key=f'team_select_{lang}',
)
if selected_team:
selected = team_stats[selected_team]
@ -1264,6 +1303,3 @@ def main():
if __name__ == '__main__':
main()
if __name__ == '__main__':
main()

10
docker-compose.yml Normal file
View 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
View 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

View File

@ -1,3 +1,4 @@
streamlit
pandas
numpy
pypdf

31
tests/test_i18n.py Normal file
View 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}"