ChessCalcNextTour/swiss_calc/trf_generator.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

181 lines
5.9 KiB
Python

"""
TRF (Tournament Report File) generator for bbpPairings.
Generates TRF-2026 format from parsed chess-results.com data.
Format specification: FIDE C04 Annex 2 (TRF16).
bbpPairings extensions: XXC rank, XXR <round>, 152 W/B.
"""
import io
from typing import Optional
def _pad_right(text: str, width: int) -> str:
"""Pad text to exactly `width` chars, left-aligned."""
if len(text) > width:
text = text[:width]
return text.ljust(width)
def _pad_left(text: str, width: int) -> str:
"""Pad text to exactly `width` chars, right-aligned."""
if len(text) > width:
text = text[-width:]
return text.rjust(width)
def _fixed_field(line: list, position: int, value: str):
"""Place `value` at 1-indexed `position` in the mutable `line` list."""
pos = position - 1
# Ensure line is long enough
while len(line) < pos + len(value):
line.append(' ')
for i, ch in enumerate(value):
line[pos + i] = ch
RESULT_CODES = {
1.0: '1',
0.5: 'D', # Draw (TRF standard; '=' is not accepted by bbpPairings)
0.0: '0',
}
BYE_RESULT_CODES = {
1.0: '+', # forfeit win / full-point bye
0.5: 'H', # half-point bye
0.0: '-', # forfeit loss / zero-point bye
}
def generate_trf(
tournament: dict,
next_round: int,
name: str = "Chess Tournament",
use_rank: bool = True,
initial_color_white: bool = True,
) -> str:
"""Generate TRF file content for bbpPairings.
Args:
tournament: Parsed tournament data from parser.fetch_tournament().
next_round: Round number to pair (e.g., 4 if rounds 1-3 are complete).
name: Tournament name.
use_rank: If True, use XXC rank (rank from standings for ordering).
initial_color_white: If True, top player gets white in round 1.
Returns:
TRF file content as string.
"""
buf = io.StringIO()
num_rounds = tournament.get('num_rounds', next_round)
# Tournament header
buf.write(f"012 {name}\n")
# Configuration lines
config_parts = []
if use_rank:
config_parts.append("rank")
if initial_color_white:
config_parts.append("white1")
else:
config_parts.append("black1")
if config_parts:
buf.write(f"XXC {' '.join(config_parts)}\n")
# Players — sort by actual points (desc) then starting_sno for stable order.
# The order in the TRF file matters: bbpPairings uses it as fallback ordering
# within score brackets when ratings are unavailable.
standings = sorted(
tournament['standings'],
key=lambda s: (-s['points'], s.get('starting_sno', s['rank']))
)
for s in standings:
# Build a line as a list of chars (mutable)
line = [' '] * 200
# Position 1-3: Data identification (001)
_fixed_field(line, 1, '001')
# Position 5-8: Starting rank number (right-aligned, 4 chars)
sno = s.get('starting_sno', s['rank'])
_fixed_field(line, 5, _pad_left(str(sno), 4))
# Position 10: Sex (m/w)
# Not available from chess-results, leave empty
# Position 11-13: Title
# Not available from chess-results, leave empty
# Position 15-47: Name (33 chars)
# chess-results gives "Last, First" or "Last First" format
player_name = s['name']
if ',' in player_name:
# Already "Last, First"
pass
else:
# Try to split: last word = last name, rest = first name
parts = player_name.split()
if len(parts) >= 2:
player_name = f"{parts[0]}, {' '.join(parts[1:])}"
_fixed_field(line, 15, _pad_right(player_name, 33))
# Position 49-52: Rating (4 chars, right-aligned)
rating = s.get('rating', 0)
if rating:
_fixed_field(line, 49, _pad_left(str(rating), 4))
# Position 54-56: Federation (3 chars)
fed = s.get('fed', '')
if fed:
_fixed_field(line, 54, _pad_right(fed[:3], 3))
# Position 81-84: Points (4 chars, right-aligned)
points = s['points']
pts_str = f"{points:.1f}"
_fixed_field(line, 81, _pad_left(pts_str, 4))
# Position 86-89: Rank (4 chars, right-aligned)
# IMPORTANT: this MUST be the initial/stable rank (starting SNo),
# NOT the current standings position. bbpPairings uses this
# for ordering within score brackets when XXC rank is set.
_fixed_field(line, 86, _pad_left(str(sno), 4))
# Round results (starting at position 92, each round = 10 chars)
results = s.get('results', [])
sno = s.get('starting_sno', s['rank'])
for ri, r in enumerate(results):
base_pos = 92 + ri * 10
opp = r.get('opponent', r.get('opponent_sno', 0))
color = r.get('color', '-')
score = r.get('score', 0)
# Skip self-matches (data artifacts on chess-results.com)
if opp == sno:
opp = 0
if opp > 0:
# Regular game
# Position 92+ri*10 to 92+ri*10+3: opponent SNo (4 chars)
_fixed_field(line, base_pos, _pad_left(str(opp), 4))
# Position 92+ri*10+5: color (w/b)
_fixed_field(line, base_pos + 5, color)
# Position 92+ri*10+7: result code
result_char = RESULT_CODES.get(score, 'Z')
_fixed_field(line, base_pos + 7, result_char)
else:
# Bye / forfeit / unpaired
_fixed_field(line, base_pos, '0000')
_fixed_field(line, base_pos + 5, '-')
result_char = BYE_RESULT_CODES.get(score, '-')
_fixed_field(line, base_pos + 7, result_char)
# Trim trailing spaces
line_str = ''.join(line).rstrip()
if line_str.strip():
buf.write(line_str + '\n')
# Total rounds (XXR = total number of rounds in tournament)
buf.write(f"XXR {num_rounds}\n")
return buf.getvalue()