1306 lines
60 KiB
Python
1306 lines
60 KiB
Python
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 l’Allemagne ?',
|
||
'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 (A–L) mit je 4 Teams verteilt.',
|
||
'en': 'Teams are distributed into 12 groups (A–L) of 4 teams each.',
|
||
'fr': 'Les équipes sont réparties en 12 groupes (A–L) de 4 équipes chacun.',
|
||
'es': 'Los equipos se distribuyen en 12 grupos (A–L) de 4 equipos cada uno.',
|
||
'ru': 'Команды распределяются на 12 групп (A–L) по 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 l’Allemagne',
|
||
'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 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': {
|
||
'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} n’a 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 l’Allemagne ({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, d’abord 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 l’Allemagne et de l’adversaire.',
|
||
'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 d’abord 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': 'L’Allemagne 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()
|