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).
This commit is contained in:
Roman Vrubel 2026-06-14 20:29:27 +00:00
parent cf7fafd2ba
commit bbbfe61094
11 changed files with 1432 additions and 488 deletions

View file

@ -3,7 +3,11 @@ FIDE Swiss — упрощённый алгоритм на основе Folding
Вместо полного max-weight matching (как в JaVaFo), использует
перебор вариантов fold + greedy цветовая оптимизация.
Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений.
Улучшения:
- Учёт предыдущих bye (не даём bye повторно)
- Downfloaters паруются с верхом следующей группы очков
- Абсолютное цветовое предпочтение (|balance| >= 2) жёсткое правило
- Сортировка по очкам + рейтингу для корректного fold
"""
from collections import defaultdict
@ -31,6 +35,9 @@ class Player:
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:
@ -70,18 +77,70 @@ def compute_tiebreakers(players: list, rounds: int) -> None:
# ═══════════════════════════════════
def _assign_colors(p1: Player, p2: Player) -> str:
"""'w' если p1 белые, 'b' если p1 чёрные."""
"""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
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
# 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
# ═══════════════════════════════════
@ -90,57 +149,60 @@ 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. Выбираем комбинацию с лучшим цветовым качеством
"""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.rating)
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:]
rest = available[:fi] + available[fi + 1:]
else:
floater = None
rest = available
m = len(rest)
# Перебор offset
# 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))
@ -148,33 +210,25 @@ def _pair_bracket_fold_search(
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
# 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:
# Фолбэк: float последнего
# 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)
@ -182,12 +236,31 @@ def _pair_bracket_fold_search(
best_pairs.append((a, b))
else:
best_pairs.append((b, a))
best_floaters = [floater]
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
@ -196,19 +269,28 @@ def _pair_bracket_fold_search(
# ═══════════════════════════════════
def fide_swiss_pairing(players: list, current_round: int) -> list:
"""FIDE Swiss pairings через fold с перебором offset."""
"""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
# 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 = sorted(groups[min(groups.keys())], key=lambda p: p.rating)
bye_player = lowest[0]
# 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]
# Score brackets
# Create score brackets
brackets = []
i = 0
while i < len(sorted_players):
@ -218,24 +300,79 @@ def fide_swiss_pairing(players: list, current_round: int) -> list:
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
# 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
pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired)
all_pairs.extend(pairs)
# 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
@ -247,39 +384,75 @@ 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=s.get('starting_sno', rank),
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
# Предпочитаем предрассчитанные пары с сайта
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'))
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:
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'}
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': [(wp, bp, 'w') for wp, bp in raw],
'players': player_map, 'source': 'swiss_algorithm'}
'pairings': pairings,
'players': player_map, 'source': 'swiss_algorithm_simplified'}