pauli 8a110661bb
Some checks failed
Build and Deploy / build-and-push (push) Failing after 31s
Build and Deploy / optional-deploy (push) Has been skipped
add gitea
2026-05-21 00:10:05 +02:00

1306 lines
60 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import streamlit as st
import pandas as pd
import numpy as np
from collections import defaultdict, Counter
import random
import urllib.parse
from pypdf import PdfReader
LANGUAGES = {
'de': 'Deutsch',
'en': 'English',
'fr': 'Français',
'es': 'Español',
'ru': 'Русский',
}
GERMANY_DISPLAY = {
'de': 'Deutschland',
'en': 'Germany',
'fr': 'Allemagne',
'es': 'Alemania',
'ru': 'Германия',
}
TRANSLATIONS = {
'title': {
'de': 'WM 2026 KO-Simulator — Wer kann Deutschland treffen?',
'en': 'World Cup 2026 KO Simulator — Who can meet Germany?',
'fr': 'Simulateur KO Coupe du Monde 2026 — Qui peut rencontrer lAllemagne ?',
'es': 'Simulador KO Mundial 2026 — ¿Quién puede encontrarse con Alemania?',
'ru': 'Симулятор плей-офф ЧМ 2026 — Кто может встретиться с Германией?',
},
'sidebar_header': {
'de': 'Einstellungen',
'en': 'Settings',
'fr': 'Paramètres',
'es': 'Configuración',
'ru': 'Настройки',
},
'sidebar_n': {
'de': 'Anzahl Simulationen',
'en': 'Number of simulations',
'fr': 'Nombre de simulations',
'es': 'Número de simulaciones',
'ru': 'Количество симуляций',
},
'sidebar_seed': {
'de': 'Zufalls-Seed (0 = variabel)',
'en': 'Random seed (0 = variable)',
'fr': 'Graine aléatoire (0 = variable)',
'es': 'Semilla aleatoria (0 = variable)',
'ru': 'Случайное зерно (0 = переменное)',
},
'sidebar_groups_description': {
'de': 'Gruppen werden in 12 Gruppen (AL) mit je 4 Teams verteilt.',
'en': 'Teams are distributed into 12 groups (AL) of 4 teams each.',
'fr': 'Les équipes sont réparties en 12 groupes (AL) de 4 équipes chacun.',
'es': 'Los equipos se distribuyen en 12 grupos (AL) de 4 equipos cada uno.',
'ru': 'Команды распределяются на 12 групп (AL) по 4 команды.',
},
'sidebar_odds_description': {
'de': 'Die Simulation verwendet Wettquoten für Achtelfinale, Viertelfinale, Halbfinale, Finale und Sieg, um Teamstärke und Matchausgänge zu gewichten.',
'en': 'The simulator uses odds for Round of 16, Quarterfinal, Semifinal, Final and Winner to weight team strength and match outcomes.',
'fr': 'Le simulateur utilise les cotes pour les 8es, quarts, demis, finale et victoire afin de pondérer la force des équipes et les résultats.',
'es': 'El simulador utiliza probabilidades para octavos, cuartos, semifinales, final y victoria para ponderar la fuerza de los equipos y los resultados.',
'ru': 'Симулятор использует котировки на 1/8, 1/4, 1/2, финал и победу для взвешивания силы команд и результатов.',
},
'groups_header': {
'de': 'Gruppen (Standardverteilung)',
'en': 'Groups (default distribution)',
'fr': 'Groupes (distribution par défaut)',
'es': 'Grupos (distribución por defecto)',
'ru': 'Группы (стандартное распределение)',
},
'round32_scheme': {
'de': '**Paarungsschema für die Runde der letzten 32**: Gruppen werden paarweise verbunden: (A↔B), (C↔D), ..., (K↔L). Für jedes Paar gibt es zwei Spiele: A1 vs B2 und B1 vs A2.',
'en': '**Round of 32 layout**: groups are paired (A↔B), (C↔D), ..., (K↔L). Each pair yields two matches: A1 vs B2 and B1 vs A2.',
'fr': '**Schéma des 32es de finale** : les groupes sont appariés (A↔B), (C↔D), ..., (K↔L). Chaque paire donne deux matches : A1 vs B2 et B1 vs A2.',
'es': '**Esquema de octavos de final**: los grupos se emparejan (A↔B), (C↔D), ..., (K↔L). Cada pareja genera dos partidos: A1 vs B2 y B1 vs A2.',
'ru': '**Сетка 1/8 финала**: группы соединяются парами (A↔B), (C↔D), ..., (K↔L). Для каждой пары два матча: A1 vs B2 и B1 vs A2.',
},
'button_start': {
'de': 'Simulation starten',
'en': 'Start simulation',
'fr': 'Lancer la simulation',
'es': 'Iniciar simulación',
'ru': 'Запустить симуляцию',
},
'start_info': {
'de': 'Simulation läuft — bitte kurz warten...',
'en': 'Simulation running — please wait...',
'fr': 'Simulation en cours — veuillez patienter...',
'es': 'Simulación en curso — espere por favor...',
'ru': 'Симуляция запущена — подождите, пожалуйста...',
},
'done_message': {
'de': 'Fertig',
'en': 'Done',
'fr': 'Terminé',
'es': 'Listo',
'ru': 'Готово',
},
'tab_opponent_details': {
'de': 'Gegnerdetails',
'en': 'Opponent details',
'fr': 'Détails adversaires',
'es': 'Detalles del rival',
'ru': 'Детали соперника',
},
'tab_results': {
'de': 'Ergebnisse',
'en': 'Results',
'fr': 'Résultats',
'es': 'Resultados',
'ru': 'Результаты',
},
'opponent_details_header': {
'de': 'Direkte KO-Paarungen mit Deutschland',
'en': 'Direct knockout pairings with Germany',
'fr': 'Rencontres directes en KO avec lAllemagne',
'es': 'Emparejamientos directos de eliminación con Alemania',
'ru': 'Прямые пары плей-офф с Германией',
},
'select_opponent': {
'de': 'Wähle einen Gegner',
'en': 'Choose an opponent',
'fr': 'Choisissez un adversaire',
'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}**',
'fr': '**Allemagne vs. {opponent}**',
'es': '**Alemania vs. {opponent}**',
'ru': '**Германия vs. {opponent}**',
},
'no_encounter': {
'de': 'In den Simulationen sind diese beiden Teams nicht aufeinander getroffen.',
'en': 'These two teams did not meet in the simulations.',
'fr': 'Ces deux équipes ne se sont pas rencontrées dans les simulations.',
'es': 'Estos dos equipos no se enfrentaron en las simulaciones.',
'ru': 'Эти две команды не встретились в симуляциях.',
},
'did_not_reach_stage': {
'de': '{team} hat das {stage} in den Simulationen nicht erreicht.',
'en': '{team} did not reach the {stage} in the simulations.',
'fr': '{team} na pas atteint le {stage} dans les simulations.',
'es': '{team} no alcanzó {stage} en las simulaciones.',
'ru': '{team} не вышла в {stage} в симуляциях.',
},
'total_encounters': {
'de': '- Insgesamt {count} Begegnungen mit Deutschland ({prob} der Simulationen)',
'en': '- Total {count} meetings with Germany ({prob} of simulations)',
'fr': '- Au total {count} rencontres avec lAllemagne ({prob} des simulations)',
'es': '- En total {count} enfrentamientos con Alemania ({prob} de las simulaciones)',
'ru': '- Всего {count} встреч с Германией ({prob} симуляций)',
},
'both_group_winners': {
'de': '- Beide Teams als Gruppensieger in **{count}** Begegnungen ({prob})',
'en': '- Both teams were group winners in **{count}** meetings ({prob})',
'fr': '- Les deux équipes étaient vainqueurs de groupe dans **{count}** rencontres ({prob})',
'es': '- Ambos equipos fueron primeros de grupo en **{count}** encuentros ({prob})',
'ru': '- Обе команды были победителями группы в **{count}** встречах ({prob})',
},
'stage_count': {
'de': '- {stage}: **{count}** Mal ({prob})',
'en': '- {stage}: **{count}** times ({prob})',
'fr': '- {stage} : **{count}** fois ({prob})',
'es': '- {stage}: **{count}** veces ({prob})',
'ru': '- {stage}: **{count}** раз ({prob})',
},
'no_both_group_winners': {
'de': '- Keine Begegnungen, in denen beide Teams Gruppensieger waren.',
'en': '- No meetings where both teams were group winners.',
'fr': '- Aucune rencontre où les deux équipes étaient vainqueurs de groupe.',
'es': '- Ningún encuentro en el que ambos equipos fueran primeros de grupo.',
'ru': '- Нет встреч, в которых обе команды были победителями групп.',
},
'possible_paths_header': {
'de': 'Mögliche Verläufe für die Paarung',
'en': 'Possible paths for the matchup',
'fr': 'Parcours possibles pour la confrontation',
'es': 'Posibles rutas para el enfrentamiento',
'ru': 'Возможные пути для пары',
},
'flows_first_both_winners': {
'de': '**Verläufe, zuerst beide Gruppensieger**',
'en': '**Paths, both group winners first**',
'fr': '**Parcours, dabord les deux vainqueurs de groupe**',
'es': '**Rutas, primero ambos primeros de grupo**',
'ru': '**Маршруты, сначала оба победителя группы**',
},
'meeting_by_group_place': {
'de': 'Treffen nach Gruppenplatzierung',
'en': 'Meetings by group placement',
'fr': 'Rencontres selon la place en groupe',
'es': 'Encuentros por posición de grupo',
'ru': 'Встречи по месту в группе',
},
'stage_meeting_phase': {
'de': 'Typische Turnierphase bei Begegnung',
'en': 'Typical tournament phase at meeting',
'fr': 'Phase de tournoi typique lors de la rencontre',
'es': 'Fase típica del torneo en el enfrentamiento',
'ru': 'Типичная стадия турнира при встрече',
},
'team_phase_line': {
'de': '{team}: Achtelfinale **{r16}**, Viertelfinale **{qf}**, Halbfinale **{sf}**, Finale **{final}**, Sieg **{winner}**',
'en': '{team}: Round of 16 **{r16}**, Quarterfinal **{qf}**, Semifinal **{sf}**, Final **{final}**, Win **{winner}**',
'fr': '{team} : 8es **{r16}**, quarts **{qf}**, demis **{sf}**, finale **{final}**, victoire **{winner}**',
'es': '{team}: Octavos **{r16}**, Cuartos **{qf}**, Semifinales **{sf}**, Final **{final}**, Victoria **{winner}**',
'ru': '{team}: 1/8 **{r16}**, 1/4 **{qf}**, 1/2 **{sf}**, финал **{final}**, победа **{winner}**',
},
'this_window_text': {
'de': 'Dieses Fenster zeigt, wie oft die jeweilige KO-Paarung zustande kommt und auf welchem Spielverlauf Deutschland und der Gegner dazu wahrscheinlich kommen.',
'en': 'This view shows how often the knockout matchup occurs and which path Germany and the opponent are likely to take.',
'fr': 'Cette vue montre la fréquence de cette confrontation en KO et le parcours probable de lAllemagne et de ladversaire.',
'es': 'Esta vista muestra con qué frecuencia ocurre el enfrentamiento eliminatorio y qué ruta es probable para Alemania y el rival.',
'ru': 'Этот вид показывает, как часто происходит пара плей-офф и какой путь, скорее всего, пройдут Германия и соперник.',
},
'please_start': {
'de': 'Bitte starte zuerst die Simulation, damit dir die Gegnerdetails angezeigt werden.',
'en': 'Please start the simulation first to see opponent details.',
'fr': 'Veuillez dabord lancer la simulation pour voir les détails des adversaires.',
'es': 'Por favor, inicia primero la simulación para ver los detalles del rival.',
'ru': 'Пожалуйста, сначала запустите симуляцию, чтобы увидеть детали соперника.',
},
'final_note': {
'de': 'Die Simulation verwendet Wettquoten, um die Teamstärke zu gewichten. So können die Gegnerverteilungen besser den Bookmaker-Erwartungen folgen und liefern zugleich Gruppenergebnisse (Siege/Unentschieden/Niederlagen) für alle Teams.',
'en': 'The simulation uses odds to weight team strength. That way opponent distributions better match bookmaker expectations and also deliver group-stage results (wins/draws/losses) for all teams.',
'fr': 'La simulation utilise les cotes pour pondérer la force des équipes. Ainsi les distributions des adversaires correspondent mieux aux attentes des bookmakers et fournissent des résultats de phase de groupes (victoires/nuls/défaites) pour toutes les équipes.',
'es': 'La simulación usa probabilidades para ponderar la fuerza de los equipos. Así, las distribuciones de rivales se ajustan mejor a las expectativas de las casas de apuestas y ofrecen resultados de la fase de grupos (victorias/empates/derrotas) para todos los equipos.',
'ru': 'Симуляция использует котировки для взвешивания силы команд. Так распределение соперников лучше соответствует ожиданиям букмекеров и дает результаты группового этапа (победы/ничьи/поражения) для всех команд.',
},
'germany_comparison_header': {
'de': 'Deutschland im Vergleich',
'en': 'Germany compared',
'fr': 'LAllemagne en comparaison',
'es': 'Alemania en comparación',
'ru': 'Германия в сравнении',
},
'germany_reaches': {
'de': '{team} erreicht das {stage} in **{value}** der Simulationen.',
'en': '{team} reaches the {stage} in **{value}** of simulations.',
'fr': '{team} atteint les {stage} dans **{value}** des simulations.',
'es': '{team} llega a {stage} en **{value}** de las simulaciones.',
'ru': '{team} выходит в {stage} в **{value}** симуляциях.',
},
'select_team': {
'de': 'Team auswählen',
'en': 'Select team',
'fr': 'Choisir une équipe',
'es': 'Selecciona un equipo',
'ru': 'Выберите команду',
},
'column_opponent': {
'de': 'Gegner',
'en': 'Opponent',
'fr': 'Adversaire',
'es': 'Rival',
'ru': 'Соперник',
},
'Probability': {
'de': 'Wahrscheinlichkeit',
'en': 'Probability',
'fr': 'Probabilité',
'es': 'Probabilidad',
'ru': 'Вероятность',
},
'possible_opponents': {
'de': 'Mögliche Gegner von {team}',
'en': 'Possible opponents for {team}',
'fr': 'Adversaires possibles pour {team}',
'es': 'Posibles rivales para {team}',
'ru': 'Возможные соперники для {team}',
},
'team_summary_header': {
'de': 'Team-Zusammenfassung nach Simulation',
'en': 'Team summary from simulation',
'fr': 'Résumé des équipes après simulation',
'es': 'Resumen del equipo tras la simulación',
'ru': 'Сводка по командам после симуляции',
},
'detailed_overview': {
'de': '{team} — Detaillierte Übersicht',
'en': '{team} — Detailed overview',
'fr': '{team} — Aperçu détaillé',
'es': '{team} — Resumen detallado',
'ru': '{team} — Подробный обзор',
},
'group_place_line': {
'de': '- Gruppenplatz 1: **{p1}**, Platz 2: **{p2}**, Platz 3: **{p3}**, Platz 4: **{p4}**',
'en': '- Group place 1: **{p1}**, place 2: **{p2}**, place 3: **{p3}**, place 4: **{p4}**',
'fr': '- Place de groupe 1 : **{p1}**, place 2 : **{p2}**, place 3 : **{p3}**, place 4 : **{p4}**',
'es': '- Puesto en grupo 1: **{p1}**, puesto 2: **{p2}**, puesto 3: **{p3}**, puesto 4: **{p4}**',
'ru': '- Место в группе 1: **{p1}**, место 2: **{p2}**, место 3: **{p3}**, место 4: **{p4}**',
},
'average_group_results': {
'de': '- Durchschnittliche Gruppenergebnisse: {wins} Siege, {draws} Unentschieden, {losses} Niederlagen',
'en': '- Average group results: {wins} wins, {draws} draws, {losses} losses',
'fr': '- Résultats moyens de groupe : {wins} victoires, {draws} nuls, {losses} défaites',
'es': '- Resultados medios de grupo: {wins} victorias, {draws} empates, {losses} derrotas',
'ru': '- Средние результаты в группе: {wins} побед, {draws} ничьих, {losses} поражений',
},
'stage_stats_line': {
'de': '- Achtelfinale: **{r16}**, Viertelfinale: **{qf}**, Halbfinale: **{sf}**, Finale: **{final}**, Sieg: **{winner}**',
'en': '- Round of 16: **{r16}**, Quarterfinal: **{qf}**, Semifinal: **{sf}**, Final: **{final}**, Win: **{winner}**',
'fr': '- 8es : **{r16}**, quarts : **{qf}**, demis : **{sf}**, finale : **{final}**, victoire : **{winner}**',
'es': '- Octavos: **{r16}**, Cuartos: **{qf}**, Semifinales: **{sf}**, Final: **{final}**, Victoria: **{winner}**',
'ru': '- 1/8: **{r16}**, 1/4: **{qf}**, 1/2: **{sf}**, финал: **{final}**, победа: **{winner}**',
},
}
ROUND_NAMES = {
'de': {'R32': 'Runde der letzten 32', 'R16': 'Achtelfinale', 'QF': 'Viertelfinale', 'SF': 'Halbfinale', 'Final': 'Finale', 'Winner': 'Sieg'},
'en': {'R32': 'Round of 32', 'R16': 'Round of 16', 'QF': 'Quarterfinal', 'SF': 'Semifinal', 'Final': 'Final', 'Winner': 'Winner'},
'fr': {'R32': '8es', 'R16': '8es', 'QF': 'Quarts', 'SF': 'Démis', 'Final': 'Finale', 'Winner': 'Victoire'},
'es': {'R32': 'Octavos', 'R16': 'Octavos', 'QF': 'Cuartos', 'SF': 'Semifinales', 'Final': 'Final', 'Winner': 'Victoria'},
'ru': {'R32': '1/8', 'R16': '1/8', 'QF': '1/4', 'SF': '1/2', 'Final': 'Финал', 'Winner': 'Победа'},
}
COLUMN_LABELS = {
'de': {
'team': 'Team',
'avg_group_wins': 'Avg. Gruppen-Siege',
'avg_group_draws': 'Avg. Gruppen-Unentschieden',
'avg_group_losses': 'Avg. Gruppen-Niederlagen',
'place_1': '1. Platz',
'place_2': '2. Platz',
'place_3': '3. Platz',
'place_4': '4. Platz',
'R16': 'Achtelfinale',
'QF': 'Viertelfinale',
'SF': 'Halbfinale',
'Final': 'Finale',
'Winner': 'Sieg',
'Stage': 'Phase',
'Round': 'Runde',
'Germany group place': 'Deutschland Gruppenplatz',
'Opponent group place': 'Gegner Gruppenplatz',
'Both group winners': 'Beide Gruppensieger',
'Count': 'Anzahl',
'Share of round': 'Anteil in der Runde',
'Share of sims': 'Anteil der Simulationen',
'Germany path': 'Deutschland Pfad',
'Opponent path': 'Gegner Pfad',
'Probability of meeting': 'Wahrscheinlichkeit des Treffens',
},
'en': {
'team': 'Team',
'avg_group_wins': 'Avg. group wins',
'avg_group_draws': 'Avg. group draws',
'avg_group_losses': 'Avg. group losses',
'place_1': 'Place 1',
'place_2': 'Place 2',
'place_3': 'Place 3',
'place_4': 'Place 4',
'R16': 'Round of 16',
'QF': 'Quarterfinal',
'SF': 'Semifinal',
'Final': 'Final',
'Winner': 'Win',
'Stage': 'Stage',
'Round': 'Round',
'Germany group place': 'Germany group place',
'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': 'Germany path',
'Opponent path': 'Opponent path',
'Probability of meeting': 'Probability of meeting',
},
'fr': {
'team': 'Équipe',
'avg_group_wins': 'Moy. victoires de groupe',
'avg_group_draws': 'Moy. nuls de groupe',
'avg_group_losses': 'Moy. défaites de groupe',
'place_1': '1ᵉʳ',
'place_2': '2ᵉ',
'place_3': '3ᵉ',
'place_4': '4ᵉ',
'R16': '8es',
'QF': 'Quarts',
'SF': 'Démis',
'Final': 'Finale',
'Winner': 'Victoire',
'Stage': 'Phase',
'Round': 'Tour',
'Germany group place': 'Place Allemagne',
'Opponent group place': 'Place adversaire',
'Both group winners': 'Deux vainqueurs de groupe',
'Count': 'Nombre',
'Share of round': 'Part de tour',
'Share of sims': 'Part des simulations',
'Germany path': 'Parcours Allemagne',
'Opponent path': 'Parcours adversaire',
'Probability of meeting': 'Probabilité de rencontre',
},
'es': {
'team': 'Equipo',
'avg_group_wins': 'Prom. victorias de grupo',
'avg_group_draws': 'Prom. empates de grupo',
'avg_group_losses': 'Prom. derrotas de grupo',
'place_1': 'Puesto 1',
'place_2': 'Puesto 2',
'place_3': 'Puesto 3',
'place_4': 'Puesto 4',
'R16': 'Octavos',
'QF': 'Cuartos',
'SF': 'Semifinales',
'Final': 'Final',
'Winner': 'Victoria',
'Stage': 'Fase',
'Round': 'Ronda',
'Germany group place': 'Puesto grupo Alemania',
'Opponent group place': 'Puesto grupo rival',
'Both group winners': 'Ambos primeros de grupo',
'Count': 'Cantidad',
'Share of round': 'Proporción de ronda',
'Share of sims': 'Proporción de simulaciones',
'Germany path': 'Ruta Alemania',
'Opponent path': 'Ruta rival',
'Probability of meeting': 'Probabilidad de encuentro',
},
'ru': {
'team': 'Команда',
'avg_group_wins': 'Ср. победы в группе',
'avg_group_draws': 'Ср. ничьи в группе',
'avg_group_losses': 'Ср. поражения в группе',
'place_1': '1-е место',
'place_2': '2-е место',
'place_3': '3-е место',
'place_4': '4-е место',
'R16': '1/8',
'QF': '1/4',
'SF': '1/2',
'Final': 'Финал',
'Winner': 'Победа',
'Stage': 'Стадия',
'Round': 'Раунд',
'Germany group place': 'Место Германии',
'Opponent group place': 'Место соперника',
'Both group winners': 'Оба победителя группы',
'Count': 'Количество',
'Share of round': 'Доля раунда',
'Share of sims': 'Доля симуляций',
'Germany path': 'Путь Германии',
'Opponent path': 'Путь соперника',
'Probability of meeting': 'Вероятность встречи',
},
}
def translate(key, lang='de', **kwargs):
value = TRANSLATIONS.get(key, {})
text = value.get(lang, value.get('en', key))
return text.format(**kwargs)
def label(key, lang='de'):
return COLUMN_LABELS.get(lang, {}).get(key, key)
def stage_label(stage, lang='de'):
return ROUND_NAMES.get(lang, ROUND_NAMES['en']).get(stage, stage)
def display_team_name(team, lang='de'):
return GERMANY_DISPLAY.get(lang, team) if team == 'Germany' else team
def format_stage_line(team, r16, qf, sf, final, winner, lang='de'):
return translate('team_phase_line', lang, team=team, r16=r16, qf=qf, sf=sf, final=final, winner=winner)
def format_reaches_line(team, stage, value, lang='de'):
return translate('germany_reaches', lang, team=team, stage=stage, value=value)
def format_possible_opponents(team, lang='de'):
return translate('possible_opponents', lang, team=team)
def load_teams(path="teams.csv"):
df = pd.read_csv(path)
teams = [urllib.parse.unquote(t) for t in df['Team'].dropna().tolist()]
return teams
def make_default_groups(teams):
# 12 groups A-L, 4 teams each
groups = {}
letters = [chr(ord('A') + i) for i in range(12)]
idx = 0
for g in letters:
groups[g] = teams[idx:idx+4]
idx += 4
return groups
TEAM_STAGE_ODDS = {
"Spain": {"win": 5.50, "final": 3.30, "semi": 2.20, "quarter": 1.70, "round16": 1.27},
"France": {"win": 5.50, "final": 3.50, "semi": 2.35, "quarter": 1.75, "round16": 1.30},
"England": {"win": 8.00, "final": 4.00, "semi": 2.70, "quarter": 1.80, "round16": 1.28},
"Brazil": {"win": 9.00, "final": 4.80, "semi": 3.00, "quarter": 1.90, "round16": 1.40},
"Argentina": {"win": 10.00, "final": 5.00, "semi": 3.10, "quarter": 2.00, "round16": 1.40},
"Portugal": {"win": 12.00, "final": 6.00, "semi": 3.40, "quarter": 2.25, "round16": 1.40},
"Germany": {"win": 15.00, "final": 6.00, "semi": 3.80, "quarter": 2.35, "round16": 1.40},
"Netherlands": {"win": 25.00, "final": 10.00, "semi": 4.80, "quarter": 2.80, "round16": 1.75},
"Norway": {"win": 30.00, "final": 13.00, "semi": 5.00, "quarter": 2.80, "round16": 1.80},
"Belgium": {"win": 40.00, "final": 15.00, "semi": 6.50, "quarter": 3.00, "round16": 1.80},
"Colombia": {"win": 40.00, "final": 15.00, "semi": 7.00, "quarter": 3.20, "round16": 1.95},
"Japan": {"win": 50.00, "final": 25.00, "semi": 10.00, "quarter": 4.50, "round16": 2.30},
"Morocco": {"win": 50.00, "final": 20.00, "semi": 9.00, "quarter": 4.50, "round16": 2.00},
"Uruguay": {"win": 60.00, "final": 20.00, "semi": 9.00, "quarter": 4.30, "round16": 2.00},
"United States": {"win": 60.00, "final": 20.00, "semi": 9.00, "quarter": 4.30, "round16": 2.10},
"Mexico": {"win": 80.00, "final": 25.00, "semi": 11.00, "quarter": 5.00, "round16": 2.10},
"Croatia": {"win": 80.00, "final": 25.00, "semi": 12.00, "quarter": 5.00, "round16": 2.30},
"Switzerland": {"win": 80.00, "final": 25.00, "semi": 12.00, "quarter": 5.00, "round16": 1.95},
"Turkey": {"win": 80.00, "final": 25.00, "semi": 12.00, "quarter": 5.00, "round16": 2.20},
"Ecuador": {"win": 100.00, "final": 35.00, "semi": 13.00, "quarter": 6.00, "round16": 2.40},
"Senegal": {"win": 100.00, "final": 40.00, "semi": 15.00, "quarter": 7.00, "round16": 3.20},
"Sweden": {"win": 100.00, "final": 40.00, "semi": 17.00, "quarter": 7.50, "round16": 3.30},
"Austria": {"win": 125.00, "final": 40.00, "semi": 18.00, "quarter": 7.00, "round16": 3.20},
"Paraguay": {"win": 150.00, "final": 50.00, "semi": 20.00, "quarter": 7.50, "round16": 3.20},
"Canada": {"win": 150.00, "final": 50.00, "semi": 17.00, "quarter": 7.00, "round16": 3.10},
"Bosnia and Herzegovina": {"win": 250.00, "final": 80.00, "semi": 25.00, "quarter": 9.00, "round16": 3.50},
"Scotland": {"win": 250.00, "final": 70.00, "semi": 25.00, "quarter": 9.00, "round16": 3.40},
"Czech Republic": {"win": 250.00, "final": 70.00, "semi": 30.00, "quarter": 9.00, "round16": 3.40},
"Ivory Coast": {"win": 300.00, "final": 80.00, "semi": 30.00, "quarter": 9.00, "round16": 3.30},
"Egypt": {"win": 300.00, "final": 80.00, "semi": 30.00, "quarter": 9.00, "round16": 3.30},
"Ghana": {"win": 400.00, "final": 100.00, "semi": 30.00, "quarter": 10.00, "round16": 4.00},
"South Korea": {"win": 400.00, "final": 100.00, "semi": 35.00, "quarter": 10.00, "round16": 4.00},
"Algeria": {"win": 400.00, "final": 100.00, "semi": 30.00, "quarter": 10.00, "round16": 4.00},
"Tunisia": {"win": 500.00, "final": 150.00, "semi": 40.00, "quarter": 12.00, "round16": 4.50},
"Australia": {"win": 500.00, "final": 150.00, "semi": 40.00, "quarter": 12.00, "round16": 4.50},
"Iran": {"win": 500.00, "final": 150.00, "semi": 40.00, "quarter": 12.00, "round16": 4.50},
"DR Congo": {"win": 750.00, "final": 200.00, "semi": 50.00, "quarter": 15.00, "round16": 6.00},
"South Africa": {"win": 1000.00, "final": 250.00, "semi": 60.00, "quarter": 15.00, "round16": 5.50},
"Qatar": {"win": 1000.00, "final": 250.00, "semi": 60.00, "quarter": 15.00, "round16": 5.50},
"Saudi Arabia": {"win": 1000.00, "final": 250.00, "semi": 60.00, "quarter": 15.00, "round16": 5.50},
"Panama": {"win": 1500.00, "final": 300.00, "semi": 70.00, "quarter": 20.00, "round16": 8.00},
"New Zealand": {"win": 1500.00, "final": 300.00, "semi": 70.00, "quarter": 20.00, "round16": 7.00},
"Iraq": {"win": 1500.00, "final": 300.00, "semi": 70.00, "quarter": 20.00, "round16": 9.00},
"Cape Verde": {"win": 2000.00, "final": 400.00, "semi": 100.00, "quarter": 40.00, "round16": 15.00},
"Curacao": {"win": 2000.00, "final": 400.00, "semi": 100.00, "quarter": 40.00, "round16": 17.00},
"Uzbekistan": {"win": 2000.00, "final": 400.00, "semi": 100.00, "quarter": 40.00, "round16": 15.00},
"Jordan": {"win": 2500.00, "final": 500.00, "semi": 150.00, "quarter": 50.00, "round16": 15.00},
"Haiti": {"win": 3000.00, "final": 750.00, "semi": 350.00, "quarter": 100.00, "round16": 25.00},
}
def compute_team_strengths(stage_odds):
strengths = {}
for team, odds in stage_odds.items():
p = {
'win': 1.0 / odds['win'],
'final': 1.0 / odds['final'],
'semi': 1.0 / odds['semi'],
'quarter': 1.0 / odds['quarter'],
'round16': 1.0 / odds['round16'],
}
strengths[team] = (
p['win'] * 0.30
+ p['final'] * 0.25
+ p['semi'] * 0.20
+ p['quarter'] * 0.15
+ p['round16'] * 0.10
)
mean_strength = np.mean(list(strengths.values()))
if mean_strength <= 0:
mean_strength = 1.0
for team in strengths:
strengths[team] /= mean_strength
return strengths
def simulate_group(group_teams, rng, strengths):
# round-robin for 4 teams: each pair plays once
pts = {t: 0 for t in group_teams}
gd = {t: 0 for t in group_teams}
gf = {t: 0 for t in group_teams}
cards = {t: 0 for t in group_teams} # conduct penalty points (negative)
match_records = {t: {'wins': 0, 'draws': 0, 'losses': 0} for t in group_teams}
matches = []
for i in range(len(group_teams)):
for j in range(i + 1, len(group_teams)):
a = group_teams[i]
b = group_teams[j]
pa = strengths.get(a, 1.0)
pb = strengths.get(b, 1.0)
total = pa + pb
a_lambda = 1.3 * (pa / total) + 0.6
b_lambda = 1.3 * (pb / total) + 0.6
sa = int(rng.poisson(a_lambda))
sb = int(rng.poisson(b_lambda))
sa = min(max(sa, 0), 6)
sb = min(max(sb, 0), 6)
# simulate simple card events
ya = int(rng.integers(0, 3))
yb = int(rng.integers(0, 3))
ra = int(rng.random() < 0.02)
rb = int(rng.random() < 0.02)
def penalty(y, r):
pen = -1 * y
if r:
pen += -4
return pen
gf[a] += sa
gf[b] += sb
gd[a] += sa - sb
gd[b] += sb - sa
if sa > sb:
pts[a] += 3
match_records[a]['wins'] += 1
match_records[b]['losses'] += 1
elif sb > sa:
pts[b] += 3
match_records[b]['wins'] += 1
match_records[a]['losses'] += 1
else:
pts[a] += 1
pts[b] += 1
match_records[a]['draws'] += 1
match_records[b]['draws'] += 1
cards[a] += penalty(ya, ra)
cards[b] += penalty(yb, rb)
matches.append((a, b, sa, sb, ya, ra, yb, rb))
teams_sorted = list(group_teams)
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, focal_team='Germany'):
if team_rankings is None:
team_rankings = strengths
group_winners = {}
group_runners = {}
group_thirds = {}
group_stats = {}
group_records = {}
team_positions = {}
all_teams = [t for team_list in groups.values() for t in team_list]
team_stage_reached = {t: {'R16': False, 'QF': False, 'SF': False, 'Final': False, 'Winner': False} for t in all_teams}
for g, teams in groups.items():
_, stats, match_records = simulate_group(teams, rng, strengths)
order = rank_group(teams, stats, team_rankings=team_rankings)
group_winners[g] = order[0]
group_runners[g] = order[1]
group_thirds[g] = order[2]
group_stats[g] = stats
group_records.update(match_records)
for idx, team in enumerate(order, start=1):
team_positions[team] = idx
team_groups = {team: g for g, team_list in groups.items() for team in team_list}
team_info = {
team: {
'group': team_groups[team],
'position': team_positions[team],
'record': group_records[team],
'route': [],
'stage_reached': {'R32': False, 'QF': False, 'SF': False, 'Final': False},
}
for team in all_teams
}
third_list = list(group_thirds.items())
def third_key(item):
g, t = item
s = group_stats[g]
return (s['pts'][t], s['gd'][t], s['gf'][t], s['cards'][t], team_rankings.get(t, 0))
third_list.sort(key=third_key, reverse=True)
best3 = third_list[:8]
best3_teams = {g: t for g, t in best3}
r32 = {1: (group_winners['A'], group_runners['B'])}
r32[2] = (group_winners['E'], None)
r32[3] = (group_winners['F'], group_runners['C'])
r32[4] = (group_winners['C'], group_winners['F'])
r32[5] = (group_winners['I'], None)
r32[6] = (group_runners['E'], group_runners['I'])
r32[7] = (group_winners['A'], None)
r32[8] = (group_winners['L'], None)
r32[9] = (group_winners['D'], None)
r32[10] = (group_winners['G'], None)
r32[11] = (group_runners['K'], group_runners['L'])
r32[12] = (group_winners['H'], group_runners['J'])
r32[13] = (group_winners['B'], None)
r32[14] = (group_winners['J'], group_runners['H'])
r32[15] = (group_winners['K'], None)
r32[16] = (group_runners['D'], group_runners['G'])
if annex_mapping is None:
annex_mapping = {}
key = frozenset([g for g, _ in best3])
ordered = annex_mapping.get(key, sorted([g for g, _ in best3]))
match_place_indices = {2: 0, 5: 1, 7: 2, 8: 3, 9: 4, 10: 5, 13: 6, 15: 7}
for matchno, idx in match_place_indices.items():
grp_letter = ordered[idx]
team = best3_teams.get(grp_letter)
a, b = r32[matchno]
r32[matchno] = (a, team)
r32_matches = [r32[i] for i in range(1, 17)]
def play_knockout_round(matches):
winners = []
for a, b in matches:
if a is None or b is None:
winners.append(a or b)
continue
pa = strengths.get(a, 1.0)
pb = strengths.get(b, 1.0)
prob_a = pa / (pa + pb)
winners.append(a if rng.random() < prob_a else b)
return winners
r16_teams = play_knockout_round(r32_matches)
r16_matches = [(r16_teams[i], r16_teams[i+1]) for i in range(0, len(r16_teams), 2)]
qf_teams = play_knockout_round(r16_matches)
qf_matches = [(qf_teams[i], qf_teams[i+1]) for i in range(0, len(qf_teams), 2)]
sf_teams = play_knockout_round(qf_matches)
sf_matches = [(sf_teams[i], sf_teams[i+1]) for i in range(0, len(sf_teams), 2)]
final_teams = play_knockout_round(sf_matches)
final_match = (final_teams[0], final_teams[1]) if len(final_teams) == 2 else (None, None)
champion = play_knockout_round([final_match])[0] if final_match[0] is not None and final_match[1] is not None else final_match[0]
for team in set(group_winners.values()).union(group_runners.values()).union(best3_teams.values()):
if team is not None:
team_stage_reached[team]['R16'] = False
for team in r16_teams:
if team is not None:
team_stage_reached[team]['R16'] = True
for a, b in r32_matches:
if a is not None and b is not None:
desc = f'R32: {a} ({team_info[a]["group"]}{team_info[a]["position"]}) vs {b} ({team_info[b]["group"]}{team_info[b]["position"]})'
team_info[a]['route'].append(('R32', desc))
team_info[b]['route'].append(('R32', desc))
team_info[a]['stage_reached']['R32'] = True
team_info[b]['stage_reached']['R32'] = True
for a, b in r16_matches:
if a is not None and b is not None:
desc = f'R16: {a} vs {b}'
team_info[a]['route'].append(('R16', desc))
team_info[b]['route'].append(('R16', desc))
team_info[a]['stage_reached']['R16'] = True
team_info[b]['stage_reached']['R16'] = True
for a, b in qf_matches:
if a is not None and b is not None:
desc = f'QF: {a} vs {b}'
team_info[a]['route'].append(('QF', desc))
team_info[b]['route'].append(('QF', desc))
team_info[a]['stage_reached']['QF'] = True
team_info[b]['stage_reached']['QF'] = True
for a, b in sf_matches:
if a is not None and b is not None:
desc = f'SF: {a} vs {b}'
team_info[a]['route'].append(('SF', desc))
team_info[b]['route'].append(('SF', desc))
team_info[a]['stage_reached']['SF'] = True
team_info[b]['stage_reached']['SF'] = True
if final_match[0] is not None and final_match[1] is not None:
a, b = final_match
desc = f'Final: {a} vs {b}'
team_info[a]['route'].append(('Final', desc))
team_info[b]['route'].append(('Final', desc))
team_info[a]['stage_reached']['Final'] = True
team_info[b]['stage_reached']['Final'] = True
for team in qf_teams:
if team is not None:
team_stage_reached[team]['QF'] = True
for team in sf_teams:
if team is not None:
team_stage_reached[team]['SF'] = True
for team in final_teams:
if team is not None:
team_stage_reached[team]['Final'] = True
if champion is not None:
team_stage_reached[champion]['Winner'] = True
rounds = {}
for a, b in r32_matches:
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 == 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 == 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 == focal_team or b == focal_team:
opp = b if a == focal_team else a
rounds['SF'] = opp
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 {
'rounds': rounds,
'group_records': group_records,
'positions': team_positions,
'stages': team_stage_reached,
'champion': champion,
'team_info': team_info,
}
def summarize_meeting_route(info, meeting_round):
lines = [f'Gruppe {info["group"]} Platz {info["position"]} ({info["record"]["wins"]}S/{info["record"]["draws"]}U/{info["record"]["losses"]}N)']
for stage, desc in info['route']:
lines.append(desc)
if stage == meeting_round:
break
return tuple(lines)
def is_group_winner_route(route):
return any('Platz 1' in line for line in route[:1])
def parse_group_place(route):
first_line = route[0] if route else ''
for num in ['1', '2', '3', '4']:
if f'Platz {num}' in first_line:
return int(num)
return 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]
meeting_stats = {t: defaultdict(Counter) for t in teams}
meeting_paths = {t: {r: Counter() for r in ['R32', 'R16', 'QF', 'SF', 'Final']} for t in teams}
both_group_winner_meetings = {t: 0 for t in teams}
team_stats = {
t: {
'group_positions': Counter(),
'group_wins': 0,
'group_draws': 0,
'group_losses': 0,
'R16': 0,
'QF': 0,
'SF': 0,
'Final': 0,
'Winner': 0,
}
for t in teams
}
for _ in range(n):
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[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
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][(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():
team_stats[team]['group_wins'] += record['wins']
team_stats[team]['group_draws'] += record['draws']
team_stats[team]['group_losses'] += record['losses']
for team, stage in result['stages'].items():
if stage['R16']:
team_stats[team]['R16'] += 1
if stage['QF']:
team_stats[team]['QF'] += 1
if stage['SF']:
team_stats[team]['SF'] += 1
if stage['Final']:
team_stats[team]['Final'] += 1
if stage['Winner']:
team_stats[team]['Winner'] += 1
return counters, team_stats, meeting_stats, meeting_paths, both_group_winner_meetings
def parse_annex_c(pdf_path='FWC26_regulations_EN.pdf'):
# parse Annex C combinations into mapping: frozenset(groups)->ordered list
reader = PdfReader(pdf_path)
full = '\n'.join([p.extract_text() or '' for p in reader.pages])
lines = full.splitlines()
comb_lines = [l.strip() for l in lines if l.strip() and l.strip().split()[0].isdigit() and '3' in l]
mapping = {}
for l in comb_lines:
parts = l.split()
# first token may be index
toks = [p for p in parts if p.startswith('3')]
groups = [t[1:] for t in toks]
mapping[frozenset(groups)] = groups
return mapping
def rank_group(group_teams, stats, team_rankings=None):
# Apply FIFA tie-breakers for ordering teams in a group
pts = stats['pts']; gd = stats['gd']; gf = stats['gf']; cards = stats.get('cards', {})
def cmp_key(team):
# We implement steps: head-to-head (simplified: not fully recomputed here),
# but to be faithful we must implement head-to-head among tied teams.
return (pts[team], gd[team], gf[team], cards.get(team,0), team_rankings.get(team,0) if team_rankings else 0)
# For correctness we perform iterative head-to-head resolution for tied sets
teams = list(group_teams)
# sort by overall points/gd/gf first as baseline
teams.sort(key=lambda t: (pts[t], gd[t], gf[t]), reverse=True)
# handle ties by applying head-to-head between tied teams
i = 0
final = []
while i < len(teams):
j = i+1
tied = [teams[i]]
while j < len(teams) and pts[teams[j]]==pts[teams[i]] and gd[teams[j]]==gd[teams[i]] and gf[teams[j]]==gf[teams[i]]:
tied.append(teams[j]); j+=1
if len(tied)>1:
# compute head-to-head among tied teams
h_pts = {t:0 for t in tied}
h_gd = {t:0 for t in tied}
h_gf = {t:0 for t in tied}
for a,b,sa,sb, *rest in stats['matches']:
if a in tied and b in tied:
h_gf[a]+=sa; h_gf[b]+=sb
h_gd[a]+=sa-sb; h_gd[b]+=sb-sa
if sa>sb: h_pts[a]+=3
elif sb>sa: h_pts[b]+=3
else: h_pts[a]+=1; h_pts[b]+=1
# sort tied by head-to-head criteria
tied.sort(key=lambda t: (h_pts[t], h_gd[t], h_gf[t], gd[t], gf[t], cards.get(t,0), team_rankings.get(t,0) if team_rankings else 0), reverse=True)
final.extend(tied)
i = j
return final
def main():
lang = st.sidebar.selectbox(
'Language / Sprache / Langue / Idioma / Язык',
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)
seed = st.sidebar.number_input(translate('sidebar_seed', lang), value=0, step=1)
st.sidebar.markdown(translate('sidebar_groups_description', lang))
st.sidebar.markdown(translate('sidebar_odds_description', lang))
# default groups
default_groups = make_default_groups(teams)
st.header(translate('groups_header', lang))
grp_df = pd.DataFrame({g: default_groups[g] for g in default_groups})
st.dataframe(grp_df)
st.markdown(translate('round32_scheme', lang))
if 'simulation_results' not in st.session_state:
st.session_state.simulation_results = None
st.session_state.simulation_seed = None
st.session_state.simulation_params = None
run_sim = st.button(translate('button_start', lang))
if run_sim:
actual_seed = seed if seed != 0 else random.randint(1, 2**30)
st.session_state.simulation_seed = actual_seed
st.session_state.simulation_params = {'n': n, 'seed': actual_seed}
st.session_state.simulation_results = run_simulations(
default_groups,
n,
actual_seed,
annex_mapping=None,
team_rankings=strengths,
strengths=strengths,
focal_team=focus_team,
)
if st.session_state.simulation_results is not None:
if run_sim:
st.success(translate('done_message', lang))
counters, team_stats, meeting_stats, meeting_paths, both_group_winner_meetings = st.session_state.simulation_results
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 != focus_team],
key=lambda x: sum(meeting_stats[focus_team][x].values()),
reverse=True,
)
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[focus_team][opponent]
total_meet = sum(meet.values())
st.markdown(translate('germany_vs', lang, opponent=display_team_name(opponent, lang)))
if total_meet == 0:
st.write(translate('no_encounter', lang))
else:
st.markdown(
translate('total_encounters', lang, count=total_meet, prob=f'{total_meet / n:.1%}')
)
if both_group_winner_meetings[opponent] > 0:
st.markdown(
translate(
'both_group_winners',
lang,
count=both_group_winner_meetings[opponent],
prob=f'{both_group_winner_meetings[opponent] / n:.1%}',
)
)
else:
st.markdown(translate('no_both_group_winners', lang))
for r in ['R32', 'R16', 'QF', 'SF', 'Final']:
st.markdown(
translate(
'stage_count',
lang,
stage=stage_label(r, lang),
count=meet[r],
prob=f'{meet[r] / n:.1%}',
)
)
st.subheader(translate('possible_paths_header', lang))
placement_rows = []
for r in ['R32', 'R16', 'QF', 'SF', 'Final']:
if meet[r] == 0:
continue
for (germany_route, opp_route), path_count in meeting_paths[opponent][r].most_common():
focal_winner = is_group_winner_route(germany_route)
opp_winner = is_group_winner_route(opp_route)
focal_place = parse_group_place(germany_route)
opp_place = parse_group_place(opp_route)
placement_rows.append({
'Round': r,
'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%}',
'Focus path': ''.join(germany_route),
'Opponent path': ''.join(opp_route),
})
if placement_rows:
placement_df = pd.DataFrame(placement_rows)
stage_order = {'R32': 0, 'R16': 1, 'QF': 2, 'SF': 3, 'Final': 4}
placement_df['Stage'] = placement_df['Round'].map(lambda r: stage_label(r, lang))
placement_df['Round order'] = placement_df['Round'].map(stage_order)
placement_df = placement_df[
[
'Stage',
'Round',
'Focus group place',
'Opponent group place',
'Both group winners',
'Count',
'Share of round',
'Share of sims',
'Focus path',
'Opponent path',
'Round order',
]
]
placement_df = placement_df.sort_values(
['Both group winners', 'Round order', 'Count'],
ascending=[False, True, False],
)
placement_df = placement_df.drop(columns=['Round order'])
# 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 [
'background-color: rgba(255, 0, 0, 0.5)' if row[label('Both group winners', lang)] else ''
for _ in row
]
st.dataframe(placement_df.style.apply(highlight_row, axis=1))
grouping = pd.DataFrame(placement_rows).groupby(
['Focus group place', 'Opponent group place']
)['Count'].sum().reset_index()
grouping['Probability of meeting'] = grouping['Count'] / total_meet
grouping = grouping.rename(
columns={
'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(
[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(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,
)
)
opp_stats = team_stats[opponent]
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%}",
lang=lang,
)
)
st.markdown(translate('this_window_text', lang))
with tab2:
st.subheader(translate('germany_comparison_header', lang))
for r in ['R16', 'QF', 'SF', 'Final', 'Winner']:
st.markdown(
translate(
'germany_reaches',
lang,
team=display_team_name(focus_team, lang),
stage=stage_label(r, lang),
value=f"{team_stats[focus_team][r] / n:.1%}",
)
)
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())
st.markdown(f"**{stage_label(r, lang)}**")
if total == 0:
st.write(
translate(
'did_not_reach_stage',
lang,
team=display_team_name('Germany', lang),
stage=stage_label(r, lang),
)
)
continue
df = pd.DataFrame([
{'Opponent': k, 'Count': v, 'Probability': v / total}
for k, v in cnt.most_common()
])
df = df.rename(columns={
'Opponent': translate('column_opponent', lang),
'Count': label('Count', lang),
'Probability': translate('Probability', lang),
})
st.dataframe(df)
st.subheader(translate('team_summary_header', lang))
team_rows = []
for team, stats in team_stats.items():
team_rows.append({
label('team', lang): display_team_name(team, lang),
label('avg_group_wins', lang): stats['group_wins'] / n,
label('avg_group_draws', lang): stats['group_draws'] / n,
label('avg_group_losses', lang): stats['group_losses'] / n,
label('place_1', lang): stats['group_positions'][1] / n,
label('place_2', lang): stats['group_positions'][2] / n,
label('place_3', lang): stats['group_positions'][3] / n,
label('place_4', lang): stats['group_positions'][4] / n,
label('R16', lang): stats['R16'] / n,
label('QF', lang): stats['QF'] / n,
label('SF', lang): stats['SF'] / n,
label('Final', lang): stats['Final'] / n,
label('Winner', lang): stats['Winner'] / n,
})
summary_df = pd.DataFrame(team_rows).sort_values(label('R16', lang), ascending=False)
st.dataframe(summary_df)
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]
st.subheader(
translate('detailed_overview', lang, team=display_team_name(selected_team, lang))
)
st.markdown(
translate(
'group_place_line',
lang,
p1=f"{selected['group_positions'][1] / n:.1%}",
p2=f"{selected['group_positions'][2] / n:.1%}",
p3=f"{selected['group_positions'][3] / n:.1%}",
p4=f"{selected['group_positions'][4] / n:.1%}",
)
)
st.markdown(
translate(
'average_group_results',
lang,
wins=f"{selected['group_wins'] / n:.2f}",
draws=f"{selected['group_draws'] / n:.2f}",
losses=f"{selected['group_losses'] / n:.2f}",
)
)
st.markdown(
translate(
'stage_stats_line',
lang,
r16=f"{selected['R16'] / n:.1%}",
qf=f"{selected['QF'] / n:.1%}",
sf=f"{selected['SF'] / n:.1%}",
final=f"{selected['Final'] / n:.1%}",
winner=f"{selected['Winner'] / n:.1%}",
)
)
else:
st.warning(translate('please_start', lang))
st.markdown(translate('final_note', lang))
if __name__ == '__main__':
main()