ChessCalcNextTour/swiss_calc/swiss.py
Roman Vrubel cf7fafd2ba chessCalc: парсер chess-results.com + FIDE Swiss + Docker
Принимает URL турнира, показывает пары следующего тура в Telegram-формате.
- parser.py: парсинг chess-results.com
- swiss.py: FIDE Dutch System
- display.py: форматирование для Telegram
- Docker: docker compose run --rm chess-calc 'URL'
2026-06-14 13:57:32 +00:00

285 lines
9.8 KiB
Python
Raw 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.

"""
FIDE Swiss — упрощённый алгоритм на основе Folding с перебором offset.
Вместо полного max-weight matching (как в JaVaFo), использует
перебор вариантов fold + greedy цветовая оптимизация.
Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений.
"""
from collections import defaultdict
from typing import Optional, List, Tuple
class Player:
def __init__(self, sno: int, name: str, rating: int, fed: str = '',
points: float = 0.0, results: list = None):
self.sno = sno
self.name = name
self.rating = rating
self.fed = fed
self.points = points
self.results = results or []
self.opponents = [r['opponent'] for r in self.results]
self.colors = [r['color'] for r in self.results]
self.tb = []
@property
def white_count(self): return sum(1 for c in self.colors if c == 'w')
@property
def black_count(self): return sum(1 for c in self.colors if c == 'b')
@property
def color_balance(self): return self.white_count - self.black_count
@property
def last_color(self): return self.colors[-1] if self.colors else None
def preferred_color(self) -> str:
if not self.colors:
return 'w'
bal = self.color_balance
if bal >= 2: return 'b'
if bal <= -2: return 'w'
if bal == 1: return 'b'
if bal == -1: return 'w'
return 'b' if self.last_color == 'w' else 'w'
def color_force(self) -> int:
bal = abs(self.color_balance)
if bal >= 2: return 2
if bal == 1: return 1
return 0
def has_played(self, opponent_sno: int) -> bool:
return opponent_sno in self.opponents
def __repr__(self):
return f"#{self.sno} {self.name} ({self.points}pts, R{self.rating})"
def sort_key(player: Player) -> tuple:
return (-player.points, -player.rating)
def compute_tiebreakers(players: list, rounds: int) -> None:
pts_map = {p.sno: p.points for p in players}
for p in players:
p.tb = [round(sum(pts_map.get(o, 0) for o in p.opponents), 1)]
# ═══════════════════════════════════
# ЦВЕТА
# ═══════════════════════════════════
def _assign_colors(p1: Player, p2: Player) -> str:
"""'w' если p1 белые, 'b' если p1 чёрные."""
pref1 = p1.preferred_color()
pref2 = p2.preferred_color()
if pref1 != pref2:
return pref1
force1, force2 = p1.color_force(), p2.color_force()
if force1 > force2: return pref1
if force2 > force1: return 'b' if pref1 == 'w' else 'w'
if p1.rating >= p2.rating: return pref1
return 'b' if pref1 == 'w' else 'w'
# ═══════════════════════════════════
# ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET
# ═══════════════════════════════════
def _pair_bracket_fold_search(
players: List[Player],
all_paired: set,
) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
"""Параметризованный fold с перебором offset и floaters.
Для bracket размером N:
1. Если N нечётное — пробуем каждого как флоатера
2. Для оставшихся M (чётное) — пробуем fold с offset 0..M/2-1
3. Выбираем комбинацию с лучшим цветовым качеством
"""
available = [p for p in players if p.sno not in all_paired]
n = len(available)
if n < 2:
return [], list(available)
available.sort(key=lambda p: -p.rating)
best_pairs = []
best_floaters = list(available[-1:]) if n % 2 == 1 else []
best_score = -9999
# Кандидаты на флоат
floater_candidates = [None]
if n % 2 == 1:
floater_candidates = range(n)
for fi in floater_candidates:
if fi is not None:
floater = available[fi]
rest = available[:fi] + available[fi+1:]
else:
floater = None
rest = available
m = len(rest)
# Перебор offset
for offset in range(m // 2):
pairs = []
ok = True
used = set()
for i in range(m // 2):
a = rest[i]
b = rest[m // 2 + ((i + offset) % (m // 2))]
if a.has_played(b.sno):
ok = False
break
if a.sno in used or b.sno in used:
ok = False
break
color = _assign_colors(a, b)
if color == 'w':
pairs.append((a, b))
else:
pairs.append((b, a))
used.add(a.sno)
used.add(b.sno)
if not ok or len(pairs) < m // 2:
continue
# Оценка: цветовые несовпадения
color_score = 0
for wp, bp in pairs:
if wp.preferred_color() == 'w':
color_score += 2
elif wp.color_force() >= 2:
color_score -= 5
if bp.preferred_color() == 'b':
color_score += 2
elif bp.color_force() >= 2:
color_score -= 5
if color_score > best_score:
best_score = color_score
best_pairs = pairs
best_floaters = [floater] if floater else []
if not best_pairs and n % 2 == 1:
# Фолбэк: float последнего
floater = available[-1]
rest = available[:-1]
m = len(rest)
best_pairs = []
for i in range(m // 2):
a, b = rest[i], rest[m // 2 + i]
color = _assign_colors(a, b)
if color == 'w':
best_pairs.append((a, b))
else:
best_pairs.append((b, a))
best_floaters = [floater]
for wp, bp in best_pairs:
all_paired.add(wp.sno)
all_paired.add(bp.sno)
return best_pairs, best_floaters
# ═══════════════════════════════════
# ОСНОВНОЙ АЛГОРИТМ
# ═══════════════════════════════════
def fide_swiss_pairing(players: list, current_round: int) -> list:
"""FIDE Swiss pairings через fold с перебором offset."""
sorted_players = sorted(players, key=sort_key)
# Bye
if len(sorted_players) % 2 == 1:
groups = defaultdict(list)
for p in sorted_players:
groups[p.points].append(p)
lowest = sorted(groups[min(groups.keys())], key=lambda p: p.rating)
bye_player = lowest[0]
sorted_players = [p for p in sorted_players if p.sno != bye_player.sno]
# Score brackets
brackets = []
i = 0
while i < len(sorted_players):
score = sorted_players[i].points
group = []
while i < len(sorted_players) and sorted_players[i].points == score:
group.append(sorted_players[i])
i += 1
brackets.append(group)
all_pairs = []
all_paired = set()
downfloaters = []
for group in brackets:
group_avail = [p for p in group if p.sno not in all_paired]
for df in downfloaters:
if df.sno not in all_paired:
group_avail.append(df)
if len(group_avail) < 2:
downfloaters = group_avail
continue
pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired)
all_pairs.extend(pairs)
return all_pairs
def swiss_pairing(players: list, current_round: int) -> list:
return fide_swiss_pairing(players, current_round)
def calculate_next_round(tournament_data: dict) -> dict:
standings = tournament_data['standings']
current_round = tournament_data['current_round']
next_round = current_round + 1
player_map = {}
for s in standings:
rank = s['rank']
p = Player(
sno=s.get('starting_sno', rank),
name=s['name'], rating=s.get('rating', 0),
fed=s['fed'], points=s['points'], results=s['results'])
p.rank = rank
p.tb = s.get('tb', [])
player_map[rank] = p
# Предпочитаем предрассчитанные пары с сайта
has_calculated = any(s.get('next_opponent') for s in standings)
if has_calculated:
pairings = []
paired = set()
for s in standings:
rank = s['rank']
if rank in paired: continue
opp_rank = s.get('next_opponent')
if opp_rank and opp_rank in player_map and opp_rank not in paired:
color = s.get('next_color', 'w')
p1, p2 = player_map[rank], player_map[opp_rank]
if color == 'w':
pairings.append((p1, p2, 'w'))
else:
pairings.append((p2, p1, 'w'))
paired.add(rank); paired.add(opp_rank)
return {'round': next_round, 'pairings': pairings,
'players': player_map, 'source': 'chess_results_precalculated'}
raw = fide_swiss_pairing(list(player_map.values()), current_round)
return {'round': next_round,
'pairings': [(wp, bp, 'w') for wp, bp in raw],
'players': player_map, 'source': 'swiss_algorithm'}