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:
parent
cf7fafd2ba
commit
bbbfe61094
11 changed files with 1432 additions and 488 deletions
181
swiss_calc/trf_generator.py
Normal file
181
swiss_calc/trf_generator.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue