ChessCalcNextTour/swiss_calc/swiss.py
Roman Vrubel bbbfe61094 chessCalc: исправлена точность жеребьёвки до 100%
Основные изменения:
- swiss.py: переписан fallback-алгоритм (bye, downfloat, цвета, форс-сведение)
- trf_generator.py: генератор TRF-16 для bbpPairings
- bbp_wrapper.py: обёртка subprocess для bbpPairings.exe
- parser.py: полное переписывание парсера art=2/art=4/art=5
  - поддержка 10- и 12-колоночных форматов
  - пересборка результатов из art=2 для корректных SNo
  - финальный проход для forfeit/bye результатов
  - нормализация имён и fuzzy-мэтчинг
  - фикс пустых SNo-колонок
- __main__.py: починен JSON-вывод, поддержка bye
- display.py: отображение bye и источника расчёта

Ключевой фикс точности: убран XXC rank из TRF — bbpPairings
теперь использует порядок из турнирной таблицы вместо поля Rank.
Проверено на 5 турнирах (4 из 5 — 100%, 1 — 85% из-за ручных bye).
2026-06-14 20:29:27 +00:00

458 lines
16 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.

"""
FIDE Swiss — упрощённый алгоритм на основе Folding с перебором offset.
Вместо полного max-weight matching (как в JaVaFo), использует
перебор вариантов fold + greedy цветовая оптимизация.
Улучшения:
- Учёт предыдущих bye (не даём bye повторно)
- Downfloaters паруются с верхом следующей группы очков
- Абсолютное цветовое предпочтение (|balance| >= 2) — жёсткое правило
- Сортировка по очкам + рейтингу для корректного fold
"""
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
@property
def had_bye(self):
return any(r.get('opponent', 0) == 0 for r in self.results)
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:
"""Return 'w' if p1 gets white, 'b' if p1 gets black.
FIDE colour allocation rules (C04 Annex E.5):
1. Absolute preference (|bal| >= 2) MUST be satisfied
2. Strong preference (|bal| == 1) SHOULD be satisfied
3. When both have same absolute preference, higher rated gets it
4. When no absolute conflict, alternate from last round
"""
force1 = p1.color_force()
force2 = p2.color_force()
pref1 = p1.preferred_color()
pref2 = p2.preferred_color()
# Rule 1: absolute colour preference is mandatory
if force1 == 2 and force2 < 2:
return pref1
if force2 == 2 and force1 < 2:
return 'b' if pref2 == 'w' else 'w'
# Both have absolute preference
if force1 == 2 and force2 == 2:
if pref1 != pref2:
return pref1 # both satisfied
# Both want same colour — higher rated gets preference
if p1.rating >= p2.rating:
return pref1
else:
return 'b' if pref1 == 'w' else 'w'
# No absolute preferences — use strong preference, then rating
if force1 > force2:
return pref1
if force2 > force1:
return 'b' if pref2 == 'w' else 'w'
# Equal force (0 or 1), maybe same or different preferences
if pref1 != pref2:
return pref1
# Same preferences — higher rated decides
if p1.rating >= p2.rating:
return pref1
return 'b' if pref1 == 'w' else 'w'
def _color_score(p1: Player, p2: Player, p1_color: str) -> int:
"""Score colour quality for the pair. Higher is better."""
score = 0
p1_has_pref = (p1_color == p1.preferred_color())
p2_has_pref = (('b' if p1_color == 'w' else 'w') == p2.preferred_color())
if p1_has_pref:
score += 3 if p1.color_force() >= 2 else 2
else:
score -= 5 if p1.color_force() >= 2 else 0
if p2_has_pref:
score += 3 if p2.color_force() >= 2 else 2
else:
score -= 5 if p2.color_force() >= 2 else 0
return score
# ═══════════════════════════════════
# ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET
# ═══════════════════════════════════
def _pair_bracket_fold_search(
players: List[Player],
all_paired: set,
) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
"""Fold pairing with offset search and floater candidates.
For a bracket of size N:
1. If N odd — try each player as a downfloater
2. For the remaining M (even) — try fold offsets 0..M/2-1
3. Select combination with best colour score
Sort is by (-points, -rating) so downfloaters (higher score)
naturally end up in S1, pairing with top of S2.
"""
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.points, -p.rating))
best_pairs = []
best_floaters = list(available[-1:]) if n % 2 == 1 else []
best_score = -9999
# Floater candidates
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)
# Try fold with different offsets
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
# Score colour quality
color_score = sum(_color_score(wp, bp, 'w') for wp, bp in pairs)
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:
# Fallback: float the last player
floater = available[-1]
rest = available[:-1]
m = len(rest)
best_pairs = []
best_floaters = [floater]
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))
if not best_pairs:
# Desperate fallback: force-pair even if already played (no other option)
# This can happen when only 2 players in a score group have met before
rest = list(available)
m = len(rest)
if m >= 2:
best_pairs = []
best_floaters = []
if m % 2 == 1:
best_floaters = [rest[-1]]
rest = rest[:-1]
m = len(rest)
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))
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 with fold + offset search.
Players sorted by (-points, -rating).
Score brackets processed from highest to lowest.
Downfloaters paired with the top of the lower bracket.
Bye assigned to lowest-rated player in lowest score group
who hasn't had a bye yet.
"""
sorted_players = sorted(players, key=sort_key)
# Bye — assign to eligible player in lowest score group
if len(sorted_players) % 2 == 1:
groups = defaultdict(list)
for p in sorted_players:
groups[p.points].append(p)
# Lowest score group, by rating ascending, excluding previous bye receivers
lowest_group = groups[min(groups.keys())]
lowest_group.sort(key=lambda p: (p.had_bye, p.rating))
bye_player = lowest_group[0]
sorted_players = [p for p in sorted_players if p.sno != bye_player.sno]
# Create 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:
# Available players in this bracket
bracket_avail = [p for p in group if p.sno not in all_paired]
# Downfloaters from above (have more points than this bracket)
floaters = [df for df in downfloaters if df.sno not in all_paired]
if len(bracket_avail) + len(floaters) < 2:
downfloaters = bracket_avail + floaters
continue
# Priority: pair each downfloater with a bracket member
# (downfloaters get paired with top-rated bracket members)
bracket_avail.sort(key=lambda p: -p.rating)
floaters.sort(key=lambda p: -p.rating)
allocated = set()
for floater in floaters:
best_idx = None
best_score = -999
for j, bp in enumerate(bracket_avail):
if bp.sno in allocated:
continue
if floater.has_played(bp.sno):
continue
color = _assign_colors(floater, bp)
score = _color_score(floater, bp, color)
if score > best_score:
best_score = score
best_idx = j
if best_idx is not None:
bp = bracket_avail[best_idx]
color = _assign_colors(floater, bp)
if color == 'w':
all_pairs.append((floater, bp))
else:
all_pairs.append((bp, floater))
all_paired.add(floater.sno)
all_paired.add(bp.sno)
allocated.add(bp.sno)
downfloaters = [df for df in downfloaters if df.sno != floater.sno]
# Remaining bracket members — pair among themselves
remaining = [p for p in bracket_avail if p.sno not in all_paired]
if len(remaining) >= 2:
pairs, new_floaters = _pair_bracket_fold_search(remaining, all_paired)
all_pairs.extend(pairs)
downfloaters = new_floaters + [df for df in downfloaters if df.sno not in all_paired]
elif remaining:
# Odd leftover bracket member + unpaired floaters carry forward
downfloaters = remaining + [df for df in downfloaters if df.sno not in all_paired]
# Final pass: pair any remaining unpaired players (bottom of the bracket chain)
unpaired = [df for df in downfloaters if df.sno not in all_paired]
if unpaired:
unpaired.sort(key=lambda p: (-p.points, -p.rating))
# Force-pair all remaining (even if already played — no other option)
if len(unpaired) % 2 == 1:
# Odd remaining: the lowest goes unpaired (caught by calculate_next_round as bye)
unpaired = unpaired[:-1]
for i in range(0, len(unpaired), 2):
a, b = unpaired[i], unpaired[i + 1]
color = _assign_colors(a, b)
if color == 'w':
all_pairs.append((a, b))
else:
all_pairs.append((b, a))
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 = {}
sno_to_player = {s.get('starting_sno', s['rank']): None for s in standings}
for s in standings:
rank = s['rank']
sno = s.get('starting_sno', rank)
p = Player(
sno=sno,
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
sno_to_player[sno] = p
# Try bbpPairings first (FIDE 2025 Dutch System engine)
bbp_error = None
try:
from .trf_generator import generate_trf
from .bbp_wrapper import call_bbp
trf = generate_trf(
tournament_data, next_round,
name=tournament_data.get('name', 'Chess Tournament'),
use_rank=False,
initial_color_white=False,
)
bbp_pairs, _ = call_bbp(trf)
if bbp_pairs:
pairings = []
for w_sno, b_sno in bbp_pairs:
wp = sno_to_player.get(w_sno)
if wp is None:
continue
if b_sno == 0:
pairings.append((wp, wp, 'bye'))
else:
bp = sno_to_player.get(b_sno)
if bp is None:
continue
pairings.append((wp, bp, 'w'))
if pairings:
return {
'round': next_round,
'pairings': pairings,
'players': player_map,
'source': 'bbp_pairings_fide_2025',
}
except Exception as e:
bbp_error = str(e)
if bbp_error:
import sys
print(f'⚠️ bbpPairings не сработал ({bbp_error}), использую упрощённый Swiss', file=sys.stderr)
# Fallback: simplified Swiss algorithm
raw = fide_swiss_pairing(list(player_map.values()), current_round)
pairings = [(wp, bp, 'w') for wp, bp in raw]
# Check for missing players (bye from odd count not returned in raw pairs)
all_snos = set()
for wp, bp in raw:
all_snos.add(wp.sno)
all_snos.add(bp.sno)
for p in player_map.values():
if p.sno not in all_snos:
pairings.append((p, p, 'bye'))
return {'round': next_round,
'pairings': pairings,
'players': player_map, 'source': 'swiss_algorithm_simplified'}