add gitea
Some checks failed
Build and Deploy / build-and-push (push) Failing after 31s
Build and Deploy / optional-deploy (push) Has been skipped

This commit is contained in:
pauli 2026-05-21 00:10:05 +02:00
parent b35141d1c7
commit 8a110661bb
7 changed files with 199 additions and 59 deletions

View File

@ -14,4 +14,4 @@ COPY . /app
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["streamlit", "run", "app.py", "--server.port", "${PORT}", "--server.address", "0.0.0.0"] 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', '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 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': { '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()

View File

@ -1,4 +1,3 @@
version: '3.8'
services: services:
app: app:
build: . build: .

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 streamlit
pandas pandas
numpy 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}"