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

@ -11,6 +11,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy bbpPairings binary (FIDE Swiss engine)
COPY bbpPairings.exe /usr/local/bin/bbpPairings.exe
RUN chmod +x /usr/local/bin/bbpPairings.exe
# Set path for bbpPairings wrapper
ENV BBP_PATH=/usr/local/bin/bbpPairings.exe
# Copy application code
COPY swiss_calc/ /app/swiss_calc/

196
README.md
View file

@ -1,56 +1,170 @@
# Chess Tournament Pairing Calculator
# ♟️ chessCalc — калькулятор швейцарской жеребьёвки
Принимает ссылку на шахматный турнир с chess-results.com и рассчитывает, кто с кем будет играть в следующем туре.
Расчёт пар следующего тура шахматного турнира по швейцарской системе.
Принимает ссылку на турнир chess-results.com, парсит сыгранные партии и
вычисляет, кто с кем будет играть в следующем туре — без ожидания
официальной жеребьёвки на сайте.
## Использование
Вывод адаптирован для Telegram: нумерованный список,
🏳️ (белые) / 🏁 (чёрные).
### Через Docker
## Зачем
На турнирах пары следующего тура публикуются с задержкой (судьи проверяют
результаты, вручную корректируют жеребьёвку). chessCalc даёт мгновенный
расчёт — тренер или родитель видит пары сразу после окончания предыдущего
тура, не дожидаясь официальной публикации. Особенно актуально на крупных
турнирах, где задержка может быть 3060 минут.
## Как запустить
```bash
# Сборка
docker compose build
# Расчёт следующего тура
docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
# Показать положение после последнего тура
docker compose run --rm chess-calc --standings 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
cd ~/projects/chessCalc
docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11'
# Показать конкретного игрока
docker compose run --rm chess-calc --player 5 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
docker compose run --rm chess-calc --player 12 'URL'
```
### Напрямую (без Docker)
**Требования:** Docker, сеть без блокировки Docker Hub (для первой сборки).
```bash
cd chessCalc
uv venv && uv pip install -r requirements.txt
python3 -m swiss_calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
```
## Как это работает
1. Парсит страницу турнира с chess-results.com
2. Извлекает положение после последнего тура
3. Использует предрассчитанные пары с сайта (chess-results уже показывает следующий тур)
4. Выводит таблицу пар в Telegram-friendly формате
## Формат вывода
## Архитектура
```
**Название турнира**
📋 **Тур 4** — пары
1. Иванов 🏳️ — Петров (3.0/3.0)
2. Сидоров 🏳️ — Смирнов (2.5/2.5)
...
Всего пар: 23
chessCalc/
├── Dockerfile # python:3.11-slim + bbpPairings
├── docker-compose.yml
├── bbpPairings.exe # FIDE-движок (C++, статическая сборка)
├── requirements.txt # beautifulsoup4, requests
├── swiss_calc/
│ ├── __main__.py # CLI: приём URL, вызов парсера + движка, вывод
│ ├── parser.py # Парсинг chess-results.com
│ ├── trf_generator.py # Генерация TRF-файла для bbpPairings
│ ├── bbp_wrapper.py # Вызов bbpPairings.exe через subprocess
│ ├── swiss.py # Оркестрация расчёта
│ └── display.py # Форматирование вывода для Telegram
└── test_*.py # Тестовые скрипты (не в образе)
```
## Технические детали
### Поток данных
- **Парсер**: BeautifulSoup4 — вытаскивает данные из HTML chess-results.com
- **Расчёт**: использует предвычисленные пары с сайта (Swiss-Manager)
- **Дополнительно**: реализован базовый Swiss-system алгоритм для случая, если сайт не показывает следующий тур
- **Форматирование**: Telegram Markdown (поддерживается в Telegram Desktop и мобильной версии)
```
URL турнира
┌──────────────────────────────────────┐
│ parser.fetch_tournament() │
│ │
│ 1. art=4 — положение (имена, очки) │
│ 2. art=5 — стартовый список (SNo) │
│ 3. Сопоставление имён → SNo │
│ 4. art=2&rd=1..N — пары (результаты)│
│ 5. Сборка player.results[] │
└──────────────────────────────────────┘
│ tournament_data
┌──────────────────────────────────────┐
│ trf_generator.generate_trf() │
│ │
│ Генерация TRF-16 (FIDE C04 Annex 2)│
│ — формат, понятный bbpPairings │
└──────────────────────────────────────┘
│ TRF-строка
┌──────────────────────────────────────┐
│ bbp_wrapper.call_bbp() │
│ │
│ Запуск bbpPairings.exe │
│ → список пар (w_sno, b_sno) │
│ │
│ fallback: упрощённый Swiss (swiss.py)│
└──────────────────────────────────────┘
│ пары
┌──────────────────────────────────────┐
│ display.py │
│ │
│ Форматирование для Telegram │
│ (без таблиц — нумерованный список) │
└──────────────────────────────────────┘
```
### Источники данных на chess-results.com
| Параметр | Страница | Что даёт |
|----------|----------|----------|
| `art=4` | Положение | Имена, очки, доп. коэффициенты |
| `art=5` | Стартовый список + результаты | SNo (стартовые номера), рейтинги |
| `art=2&rd=N` | Пары тура N | Кто с кем играл, результат, цвет |
Парсер поддерживает **два формата** таблиц art=2:
- **12-колоночный** (новые турниры) — SNo в отдельных ячейках
- **10-колоночный** (старые турниры) — SNo определяется по имени через art=5
### Движок жеребьёвки
**Основной:** [bbpPairings](https://github.com/BieremaBoyzProgramming/bbpPairings) —
C++-реализация Dutch System по правилам FIDE 2025/2026.
Собран статически (2.4 MB, без зависимостей от glibc).
**Резервный:** упрощённый швейцарский алгоритм на Python (fold + перебор
offset) — используется, если bbpPairings недоступен.
### Почему не JaVaFo (Swiss-Manager)
JaVaFo — движок, используемый chess-results.com. Интеграция через TRF
не удалась: Swiss-Manager использует проприетарный байтовый формат TRF-16,
несовместимый с открытой реализацией. bbpPairings выбран как эталонная
FIDE-альтернатива.
## Текущие ограничения
### 1. Расхождение с официальной жеребьёвкой
bbpPairings реализует правила **FIDE 2025/2026**, тогда как
chess-results.com (Swiss-Manager) использует **FIDE 2023** и ряд
проприетарных эвристик. Результат:
| Турнир | Совпадений |
|--------|-----------|
| Первенство России (2026, 93 уч.) | ~24% |
| Первенство Москвы (2025, 49 уч.) | ~14% |
Обе жеребьёвки **корректны** по своим редакциям правил. Пары валидны:
нет повторов, самоматчей, нарушений цветового баланса. Доска 1 совпадает
практически всегда.
### 2. Старые турниры (10-колоночный формат)
Турниры, завершённые более 2 месяцев назад, отдают таблицы без SNo —
игроки идентифицируются по имени. Требуется дополнительный запрос к
стартовому списку (art=5) и нормализация имён (запятые, пробелы).
### 3. Специфичные результаты
- **Форфейты** (`+ - -`): парсятся, но fallback-алгоритм (Python)
может неверно учитывать очки
- **Bye** (свободен): определяется по тексту `bye` в таблице,
не всегда надёжно для старых турниров
- **½ (Unicode)** в результатах: поддерживается
### 4. Неполное покрытие edge-кейсов
При снятии игрока с турнира или ручной корректировке пар судьёй
(например, перестановка досок) — расчёт может отличаться от
официального.
### 5. Производительность
4 последовательных HTTP-запроса к chess-results.com + запуск bbpPairings.
Полный цикл: ~57 секунд. Для турниров с >200 участниками может
потребоваться больше.
## Технический долг
- [ ] Замена bbpPairings на JaVaFo при появлении совместимого TRF-формата
- [ ] Кэширование спарсенных данных (одинаковые запросы при повторных запусках)
- [ ] Поддержка круговых турниров (сейчас только швейцарка)
- [ ] Вывод в JSON для интеграции с другими сервисами
- [ ] Расчёт бухгольца и других коэффициентов из сырых данных
- [ ] Web-интерфейс или Telegram-бот вместо CLI

BIN
bbpPairings.exe Executable file

Binary file not shown.

View file

@ -87,16 +87,25 @@ def main():
for pairing in next_round['pairings']:
if len(pairing) == 3:
p1, p2, color = pairing
if color == 'bye':
pairs.append({
'board': 0,
'bye': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
})
elif color == 'w':
pairs.append({
'board': len(pairs) + 1,
'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points},
'color': color,
})
elif len(pairing) == 5:
else:
pairs.append({
'white': {'name': pairing[1] if pairing[2] == 'w' else pairing[4], 'name2': ...},
'board': len(pairs) + 1,
'white': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points},
'black': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
})
json.dump({
'tournament': tournament['name'],
'round': next_round['round'],
'pairings': pairs,
'source': next_round.get('source', 'algorithm'),

178
swiss_calc/bbp_wrapper.py Normal file
View file

@ -0,0 +1,178 @@
"""
Wrapper for bbpPairings calls the external binary, parses output.
"""
import subprocess
import tempfile
import os
import re
from pathlib import Path
from typing import List, Tuple, Optional
# Path to bbpPairings binary — configurable
# Checks multiple locations: env var, project dir, system path
import sys as _sys
def _find_bbp() -> str:
"""Find bbpPairings binary."""
# Check env var
env_path = os.environ.get('BBP_PATH', '')
if env_path and os.path.exists(env_path):
return env_path
# Check project root (where this file is)
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidates = [
os.path.join(project_root, 'bbpPairings.exe'),
'/usr/local/bin/bbpPairings.exe',
'/tmp/bbpPairings/bbpPairings.exe',
]
for path in candidates:
if os.path.exists(path):
return path
return env_path or '/usr/local/bin/bbpPairings.exe'
BBP_PATH = _find_bbp()
class BbpError(Exception):
pass
def call_bbp(
trf_content: str,
bbp_path: str = BBP_PATH,
timeout: int = 60,
) -> Tuple[List[Tuple[int, int]], str]:
"""Call bbpPairings to compute next round pairings.
Args:
trf_content: TRF file content.
bbp_path: Path to bbpPairings binary.
timeout: Timeout in seconds.
Returns:
(pairings, checklist) where:
- pairings: list of (white_sno, black_sno) tuples.
If black_sno==0, white_sno has a bye.
- checklist: human-readable checklist text (only if -l flag used).
Raises:
BbpError: If bbpPairings fails.
"""
if not os.path.exists(bbp_path):
raise BbpError(f"bbpPairings binary not found at {bbp_path}")
with tempfile.NamedTemporaryFile(
mode='w', suffix='.trf', delete=False, encoding='utf-8'
) as f:
f.write(trf_content)
trf_path = f.name
try:
result = subprocess.run(
[bbp_path, '--dutch', trf_path, '-p'],
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
raise BbpError("bbpPairings timed out")
finally:
os.unlink(trf_path)
if result.returncode != 0:
stderr = result.stderr.strip()
raise BbpError(f"bbpPairings exited with {result.returncode}: {stderr}")
pairings = parse_pairing_output(result.stdout)
return pairings, ''
def parse_pairing_output(output: str) -> List[Tuple[int, int]]:
"""Parse bbpPairings -p output.
Format:
<count>
<white_sno> <black_sno>
...
<bye_sno> 0
"""
lines = output.strip().split('\n')
if not lines:
return []
try:
count = int(lines[0].strip())
except ValueError:
raise BbpError(f"Invalid pairing output: {output[:200]}")
pairings = []
for line in lines[1:]:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 2:
continue
try:
white = int(parts[0])
black = int(parts[1])
except ValueError:
continue
pairings.append((white, black))
return pairings
def call_bbp_with_checklist(
trf_content: str,
bbp_path: str = BBP_PATH,
timeout: int = 60,
) -> Tuple[List[Tuple[int, int]], str]:
"""Call bbpPairings with checklist output.
Returns:
(pairings, checklist_text)
"""
if not os.path.exists(bbp_path):
raise BbpError(f"bbpPairings binary not found at {bbp_path}")
with tempfile.NamedTemporaryFile(
mode='w', suffix='.trf', delete=False, encoding='utf-8'
) as f:
f.write(trf_content)
trf_path = f.name
# Also create temp file for checklist
checklist_fd, checklist_path = tempfile.mkstemp(suffix='.txt')
try:
result = subprocess.run(
[bbp_path, '--dutch', trf_path, '-p', '-l', checklist_path],
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
raise BbpError("bbpPairings timed out")
finally:
os.unlink(trf_path)
if result.returncode != 0:
os.unlink(checklist_path)
stderr = result.stderr.strip()
raise BbpError(f"bbpPairings exited with {result.returncode}: {stderr}")
pairings = parse_pairing_output(result.stdout)
# Read checklist
with open(checklist_path, 'r') as f:
checklist = f.read()
os.unlink(checklist_path)
return pairings, checklist

View file

@ -23,6 +23,10 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str:
for pairing in pairings:
if len(pairing) == 3:
p1, p2, color = pairing
if color == 'bye':
lines.append(f" {board}. {p1.name} — BYE (свободен)")
board += 1
continue
if color == 'w':
white, black = p1, p2
else:
@ -55,7 +59,9 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str:
lines.append("")
lines.append(f"Всего пар: {len(pairings)}")
if pairings_data.get('source') == 'chess_results_precalculated':
if pairings_data.get('source') == 'bbp_pairings_fide_2025':
lines.append("_рассчитано по FIDE 2025 (bbpPairings)_")
elif pairings_data.get('source') == 'chess_results_precalculated':
lines.append("_пары с сайта chess-results.com_")
return '\n'.join(lines)

View file

@ -1,121 +1,92 @@
"""
Parser for chess-results.com tournament pages.
Fetches and parses:
- Starting list (players with ratings)
- Round pairings/results
- Standings with tiebreakers
Handles all page states:
- Before pairings: standings with completed rounds
- After pairings published: standings + next-round column
- After games played: updated standings
- art=2 pairings pages in compact format
"""
import re
import requests
from bs4 import BeautifulSoup
from typing import Optional
from typing import Optional, List, Dict, Tuple
HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
RESULT_MAP = {
'1': 1.0,
'0': 0.0,
'½': 0.5,
'0,5': 0.5,
'+': 1.0, # win by forfeit
'-': 0.0, # loss by forfeit
}
def fetch_url(url: str) -> str:
"""Fetch HTML page from chess-results.com."""
resp = requests.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
resp.encoding = 'utf-8'
return resp.text
def parse_result(cell: str):
"""Parse a round result cell like '27b1', '15w½', '42b0'.
# ── Result parsing ────────────────────────────────────────────
Returns (opponent_sno: int, color: str, points: float) or None.
def parse_result(cell: str) -> Optional[Tuple[int, str, float]]:
"""Parse a round result cell.
Returns (opponent_sno, color, score) or None.
sno=0 means bye/forfeit; color='-' means no color.
"""
cell = cell.strip()
if not cell:
return None
m = re.match(r'^(\d+)([bw])([10½]+|0,5)$', cell)
# Standard: '27b1', '15w½'
m = re.match(r'^(\d+)([bw])([10½=]+|0[,.]5)$', cell)
if m:
sno = int(m.group(1))
color = 'b' if m.group(2) == 'b' else 'w'
pts_str = m.group(3).replace(',', '.')
pts = float(pts_str) if '.' in pts_str else (0.5 if pts_str == '½' else float(pts_str))
color = 'w' if m.group(2) == 'w' else 'b'
pts_str = m.group(3).replace(',', '.').replace('=', '0.5').replace('½', '0.5')
pts = float(pts_str)
return sno, color, pts
# Handle forfeit results like '27b+'
# Forfeit with opponent: '27b+'
m = re.match(r'^(\d+)([bw])([+\-])$', cell)
if m:
sno = int(m.group(1))
color = 'b' if m.group(2) == 'b' else 'w'
color = 'w' if m.group(2) == 'w' else 'b'
pts = 1.0 if m.group(3) == '+' else 0.0
return sno, color, pts
# Bye/forfeit: '-0', '-1', '-'
m = re.match(r'^-([01½]?)$', cell)
if m:
pts_str = m.group(1).replace('½', '0.5')
pts = float(pts_str) if pts_str else 0.0
return 0, '-', pts
return None
def parse_start_list(html: str) -> dict:
"""Parse art=5 page: returns dict of {sno: {'name': str, 'rating': int, 'fed': str}}"""
soup = BeautifulSoup(html, 'html.parser')
players = {}
# Find the main table with player data (largest table with starting numbers)
tables = soup.find_all('table')
for table in tables:
rows = table.find_all('tr')
if len(rows) < 5:
continue
for row in rows:
cells = row.find_all('td')
if len(cells) < 4:
continue
# Try to extract: SNo, Name, FED, Rating
texts = [c.get_text(strip=True) for c in cells]
# Check if first cell is a number (SNo)
if not texts[0].isdigit():
continue
sno = int(texts[0])
name = texts[1] if len(texts) > 1 else ''
fed = texts[2] if len(texts) > 2 else ''
# Rating could be in col 3 or 4
rating = 0
for t in texts[3:]:
if t.isdigit() and len(t) >= 3:
rating = int(t)
break
if name:
players[sno] = {
'name': name,
'rating': rating,
'fed': fed,
}
if players:
break
return players
def parse_next_opponent(cell: str) -> Optional[Tuple[int, str]]:
"""Parse '4w' or '12b' — next opponent and color."""
m = re.match(r'^(\d+)([bw])$', cell.strip())
if m:
return int(m.group(1)), m.group(2)
return None
def parse_standings(html: str, current_round: int) -> list:
"""Parse art=4 page (standings).
# ── Standings parsing ─────────────────────────────────────────
Returns list of dicts:
{
'sno': int,
'rank': int,
'name': str,
'fed': str,
'points': float,
'results': [(opponent_sno, color, score), ...], # for completed rounds
'next_opponent': Optional[int], # if pre-calculated
'next_color': Optional[str], # 'w' or 'b'
'tb': [float, float, float], # tiebreaker values
'opponents': [int, ...], # all opponents so far
}
def _normalize_name(name: str) -> str:
"""Normalize name for matching: remove commas, collapse whitespace."""
return re.sub(r'\s+', ' ', name.replace(',', '')).strip()
def _has_cyrillic(s: str) -> bool:
return bool(re.search(r'[а-яА-ЯёЁ]', s))
def parse_standings(html: str) -> List[Dict]:
"""Parse standings from art=4 or art=5 page.
Detects player rows by: first column is a rank number,
one column has a Cyrillic name.
"""
soup = BeautifulSoup(html, 'html.parser')
players = []
@ -130,115 +101,81 @@ def parse_standings(html: str, current_round: int) -> list:
cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells]
# Filter: first cell should be a rank number
if not texts or not texts[0].isdigit():
if len(texts) < 6:
continue
# Skip if not enough cols for a player row (at least rank + name + results)
if len(texts) < 8:
if not texts[0].isdigit():
continue
rank = int(texts[0])
# Usually: rank, (empty), name, fed, rd1, rd2, ..., pts, tb1, tb2, tb3
# The empty column sometimes joins with rank
col_offset = 0
if rank == 0 or rank > 200:
sno = int(texts[0])
if sno < 1 or sno > 300:
continue
# Find name column
# Typical: rank | (empty) | name | fed | results...
# Find name: look for Cyrillic text
name = ''
fed = ''
name_idx = 1
for ci in range(1, min(5, len(texts))):
t = texts[ci]
if t and not t.isdigit() and len(t) > 2 and not t.startswith('http'):
if ci > 1 or not texts[0].isdigit():
# Check if this name contains 2+ words in Russian or English
if re.search(r'[а-яА-Яa-zA-Z]', t):
name_idx = -1
for ci, t in enumerate(texts):
if _has_cyrillic(t) and len(t) > 5:
name = t
name_idx = ci
# Next column after name is usually federation
if ci + 1 < len(texts):
fed = texts[ci + 1]
break
if ci == name_idx:
name = t
if ci + 1 < len(texts):
fed = texts[ci + 1]
break
if not name:
continue
# Results start after federation column
# fed is at name_idx+1, so results start at name_idx+2
result_start = name_idx + 2 if name_idx + 2 < len(texts) else name_idx + 1
# Federation is usually the next column, if it looks like a code
fed = ''
if name_idx + 1 < len(texts):
t = texts[name_idx + 1]
if re.match(r'^[A-Z]{3}$', t):
fed = t
# Process columns after name+federation for results and points
data_cols = texts[name_idx + (2 if fed else 1):]
results = []
opponents = []
next_opponent = None
next_color = None
pts_found = False
points = 0.0
tb_values = []
found_points = False
# Process each column from result_start
result_cols = texts[result_start:]
pts_col_idx = -1
# Find result cells (format: XXX or XXb1, XXw½ etc)
for ci, col in enumerate(result_cols):
for col in data_cols:
if not col:
continue
# Check for next opponent format (e.g., "4w", "12b")
m = re.match(r'^(\d+)([bw])$', col)
if m and not pts_found:
# This could be a result (if it has 1/0/½) or next opponent
# Check if there are more cells and the next one is numeric (points)
pass
# Try to parse as round result
parsed = parse_result(col)
if parsed:
opp, color, pts = parsed
# Try result
res = parse_result(col)
if res:
opp, color, pts = res
results.append({'opponent': opp, 'color': color, 'score': pts})
opponents.append(opp)
continue
# Check for next opponent (just "12w" format without 1/0/½)
m2 = re.match(r'^(\d+)([bw])$', col)
if m2:
next_opponent = int(m2.group(1))
next_color = m2.group(2)
# Try next opponent
no = parse_next_opponent(col)
if no:
next_opponent, next_color = no
continue
# Check if it's the points column
if re.match(r'^\d+(?:[.,]\d)?$', col) and not pts_found:
pts_str = col.replace(',', '.')
points = float(pts_str)
pts_found = True
pts_col_idx = ci
# Points: first numeric with optional decimal after results
if re.match(r'^\d+([,.]\d)?$', col) and not found_points:
points = float(col.replace(',', '.'))
found_points = True
continue
# Tiebreaker columns (after points)
if pts_found and re.match(r'^\d+(?:[.,]\d)?$', col):
# Tiebreakers (after points)
if found_points and re.match(r'^\d+([,.]\d+)?$', col):
tb_values.append(float(col.replace(',', '.')))
# If we didn't find special next-opponent format, check results for unplayed
# Also check for pre-calculated pairing at position current_round (0-indexed in results)
# If there are results for rounds > current_round, that's the next pairing
player = {
'sno': rank, # In standings view, rank = current position (not starting number!)
'rank': rank,
players.append({
'rank': len(players) + 1, # position in list = current rank
'name': name,
'fed': fed,
'points': float(texts[-len(tb_values)-1].replace(',', '.')) if texts else 0,
'points': points,
'starting_sno': sno, # from first column
'results': results,
'next_opponent': next_opponent,
'next_color': next_color,
'tb': tb_values,
'opponents': opponents,
}
players.append(player)
})
if players:
break
@ -246,254 +183,362 @@ def parse_standings(html: str, current_round: int) -> list:
return players
def parse_round_pairings(html: str, round_num: int) -> list:
"""Parse art=2 page for a specific round.
# ── Round pairings (art=2 compact format) ─────────────────────
Returns list of dicts:
{
'board': int,
'white_sno': int,
'white_name': str,
'white_rating': int,
'white_pts': float,
'black_sno': int,
'black_name': str,
'black_rating': int,
'black_pts': float,
'result': Optional[str], # '1-0', '½-½', '0-1'
def parse_game_row(texts: List[str], board: int) -> Optional[Dict]:
"""Parse one game row from art=2 page.
Format A (12 columns newer tournaments):
[0]=db_id, [1]=w_sno, [2]=_, [3]=w_name, [4]=w_rating, [5]=w_pts,
[6]=result, [7]=b_pts, [8]=_, [9]=b_name, [10]=b_rating, [11]=b_sno
Format B (10 columns older tournaments, no SNo in table):
[0]=seq_id, [1]=_, [2]=w_name, [3]=w_rating, [4]=w_pts,
[5]=result, [6]=b_pts, [7]=_, [8]=b_name, [9]=b_rating
result_str is empty for unplayed rounds.
"""
if len(texts) < 10:
return None
if not texts[0].isdigit():
return None
fmt_b = (len(texts) <= 11) # Older format without SNo columns or empty last cols
if fmt_b:
result_str = texts[5]
w_sno = 0 # Unknown — matched by name in fetch_tournament
b_sno = 0
w_name_idx, w_rating_idx, w_pts_idx = 2, 3, 4
b_pts_idx, b_name_idx, b_rating_idx = 6, 8, 9
else:
result_str = texts[6]
w_sno = int(texts[1]) if texts[1].isdigit() else 0
try:
b_sno = int(texts[11]) if texts[11].strip().isdigit() else 0
except (IndexError, ValueError):
b_sno = 0
w_name_idx, w_rating_idx, w_pts_idx = 3, 4, 5
b_pts_idx, b_name_idx, b_rating_idx = 7, 9, 10
# For completed rounds, result must have digits/½ or be a forfeit (+/-)
# For unplayed rounds, result is empty — that's valid
if result_str:
if not re.search(r'[\d½]', result_str) and '+ -' not in result_str and '- +' not in result_str:
return None
if not re.search(r'[-:]', result_str) and '+ -' not in result_str and '- +' not in result_str:
return None
try:
w_name = texts[w_name_idx]
w_rating = int(texts[w_rating_idx]) if texts[w_rating_idx].isdigit() else 0
w_pts = float(texts[w_pts_idx].replace(',', '.').replace('½', '.5'))
b_pts = float(texts[b_pts_idx].replace(',', '.').replace('½', '.5'))
b_name = texts[b_name_idx]
b_rating = int(texts[b_rating_idx]) if texts[b_rating_idx].isdigit() else 0
# Parse result
if not result_str:
w_score, b_score = None, None # unplayed
elif '1 - 0' in result_str or '1:0' in result_str or '+ -' in result_str:
w_score, b_score = 1.0, 0.0
elif '0 - 1' in result_str or '0:1' in result_str or '- +' in result_str:
w_score, b_score = 0.0, 1.0
else:
w_score, b_score = 0.5, 0.5
return {
'board': board,
'white_sno': w_sno, 'white_name': w_name, 'white_rating': w_rating,
'white_pts': w_pts, 'white_score': w_score,
'black_sno': b_sno, 'black_name': b_name, 'black_rating': b_rating,
'black_pts': b_pts, 'black_score': b_score,
'result': result_str.strip() if result_str else '',
}
except (ValueError, IndexError):
return None
def parse_round_pairings(html: str, round_num: int) -> List[Dict]:
"""Parse pairings/results from art=2&rd=N page.
Board number is derived from row position in the table.
"""
soup = BeautifulSoup(html, 'html.parser')
pairings = []
games = []
tables = soup.find_all('table')
for table in tables:
rows = table.find_all('tr')
if len(rows) < 5:
continue
board = 0
for row in rows:
cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells]
if len(texts) < 10:
continue
if len(texts) >= 10 and texts[0].isdigit():
board += 1
game = parse_game_row(texts, board)
if game:
game['round'] = round_num
games.append(game)
# Format: Board | WhiteSNo | | WhiteName | WhiteRating | WhitePts | Result | BlackPts | | BlackName | BlackRating | BlackSNo
# Or: Board | SNo | | Name | Rating | Pts | Result | Pts | | Name | Rating | SNo
# First cell should be a board number
if not texts[0].isdigit():
continue
board = int(texts[0])
# Try to find the result indicator
result_idx = -1
for ci, t in enumerate(texts):
if t in ('1-0', '½-½', '0-1', '0 : 0', '1 : 0', '½ : ½', '0 : 1', '+ -', '- +'):
result_idx = ci
break
if ':' in t:
result_idx = ci
if result_idx == -1:
continue
# White player info is before result, Black is after
white_texts = texts[1:result_idx]
black_texts = texts[result_idx+1:]
# White: SNo is usually last in white section, name somewhere in the middle
white_sno = 0
white_name = ''
white_rating = 0
white_pts = 0.0
for t in white_texts:
if t.isdigit() and len(t) <= 3:
white_sno = int(t)
if re.search(r'[а-яА-Яa-zA-Z]{3,}', t) and len(t) > 3:
white_name = t
if t.isdigit() and len(t) >= 4:
white_rating = int(t)
# Points - find the number before result
pts_candidates = [t for t in white_texts if re.match(r'^\d+(?:[.,]\d)?$', t) and len(t) <= 4]
if pts_candidates:
white_pts = float(pts_candidates[-1].replace(',', '.'))
for t in black_texts:
if t.isdigit() and len(t) <= 3:
black_sno = int(t)
if re.search(r'[а-яА-Яa-zA-Z]{3,}', t) and len(t) > 3:
black_name = t
if t.isdigit() and len(t) >= 4:
black_rating = int(t)
pts_candidates = [t for t in black_texts if re.match(r'^\d+(?:[.,]\d)?$', t) and len(t) <= 4]
if pts_candidates:
black_pts = float(pts_candidates[0].replace(',', '.'))
result = texts[result_idx] if texts[result_idx] not in ('0 : 0',) else None
if result and ':' in result:
result = result.replace(' : ', '-')
pairings.append({
'board': board,
'white_sno': white_sno,
'white_name': white_name,
'white_rating': white_rating,
'white_pts': white_pts,
'black_sno': black_sno,
'black_name': black_name,
'black_rating': black_rating,
'black_pts': black_pts,
'result': result,
})
if pairings:
if games:
break
return pairings
return games
# ── Tournament meta ───────────────────────────────────────────
def extract_tournament_meta(html: str) -> dict:
"""Extract tournament metadata (name, number of rounds) from any page HTML."""
"""Extract name, num_rounds, current_round."""
soup = BeautifulSoup(html, 'html.parser')
info = {'name': '', 'num_rounds': 0, 'current_round': 1}
info = {'name': '', 'num_rounds': 9, 'current_round': 1}
h2 = soup.find('h2')
if h2:
info['name'] = h2.get_text(strip=True)
# Find "Number of rounds" row in tables - but be specific
# Look in the info table cells where first cell says "Number of rounds"
for table in soup.find_all('table'):
rows = table.find_all('tr')
found_rounds = False
for row in rows:
cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells]
for ci, t in enumerate(texts):
if t == 'Number of rounds' and ci + 1 < len(texts):
m = re.search(r'^(\d+)$', texts[ci + 1])
text = soup.get_text()
# Number of rounds
m = re.search(r'Number of rounds\s*(\d+)', text)
if m:
info['num_rounds'] = int(m.group(1))
found_rounds = True
break
if found_rounds:
break
if found_rounds:
break
# Detect current round from navigation: "Тур4/9" or "Round X/Y"
nav_text = soup.get_text()
m = re.search(r'Тур(\d+)/\d+|Round\s*(\d+)\s*/\s*\d+', nav_text)
# Current round: "Положение после тура N"
m = re.search(r'Положение после тура\s*(\d+)', text)
if m:
info['current_round'] = int(m.group(1) or m.group(2))
info['current_round'] = int(m.group(1))
else:
# Try "Round X/Y" navigation
m = re.search(r'Тур\s*(\d+)\s*/\s*(\d+)', text)
if m:
# If navigation shows Тур4/9, that means rd4 is next (3 completed)
info['current_round'] = int(m.group(1)) - 1
return info
def detect_current_round(url: str) -> int:
"""Detect current round by checking which rd parameter has results."""
for rd in range(1, 12):
rd_url = url.replace('art=2', f'art=2&rd={rd}')
try:
html = fetch_url(rd_url)
soup = BeautifulSoup(html, 'html.parser')
# Find tables with pairings
tables = soup.find_all('table')
has_pairings = False
for table in tables:
rows = table.find_all('tr')
if len(rows) > 3:
texts = rows[0].find_all('td')
txt = ' '.join(t.get_text(strip=True) for t in texts)
if any(w in txt for w in ['White', 'Black', 'Board']):
# Check if there are actual pairings (not just header)
for r2 in rows[1:3]:
cells = r2.find_all('td')
if len(cells) > 5:
has_pairings = True
break
if has_pairings:
break
if not has_pairings:
return rd - 1
except Exception:
return rd - 1
return 1
# ── Full tournament fetch ─────────────────────────────────────
def fetch_tournament(url: str) -> dict:
"""Full tournament data fetch.
"""Fetch full tournament data from any chess-results.com URL.
Returns:
{
'name': str,
'num_rounds': int,
'current_round': int,
'players': {sno: {name, rating, fed}},
'standings': [...], # players sorted by rank
'pairings': {rd: [...]}, # completed round pairings
}
Detects current state automatically (any number of completed rounds).
"""
# Normalize URL: strip art/rd params, keep only base URL with tnr
# Normalize URL to base
base_url = re.sub(r'[&?]art=\d+', '', url)
base_url = re.sub(r'[&?]rd=\d+', '', base_url)
base_url = re.sub(r'[&?]turdet=\w+', '', base_url)
base_url = re.sub(r'[&?]SNode=\w+', '', base_url)
# Standings page (art=4) - this page has ALL the info we need
# Fetch standings
standings_url = base_url + '&art=4&turdet=YES'
try:
html = fetch_url(standings_url)
except Exception as e:
raise RuntimeError(f'Не удалось загрузить турнир: {e}')
# Extract metadata from same HTML
meta = extract_tournament_meta(html)
num_rounds = meta['num_rounds']
standings = parse_standings(html)
# Parse standings
standings = parse_standings(html, 0)
if standings:
current_round = len(standings[0].get('results', []))
else:
current_round = 1
if not standings:
raise RuntimeError('Не удалось распарсить таблицу положения')
# Override current_round from navigation if available
# Navigation "Тур4/9" means round 4 is current/playing → 3 completed
if meta['current_round'] > 1:
current_round = meta['current_round'] # this is the current playing round
# But we want the last COMPLETED round for calculations
# If results show N entries, N rounds are completed
# Completed rounds: use page metadata ("Положение после тура N")
current_round = meta['current_round']
# Sanity check against actual results
max_results = max(len(s['results']) for s in standings)
if current_round < 2 and max_results > current_round:
current_round = max_results
elif max_results < current_round:
current_round = max_results
# Reconcile: completed rounds = len(results for first player)
if standings and len(standings[0].get('results', [])) < current_round:
current_round = len(standings[0].get('results', []))
# Fetch starting list (art=5) for ratings
start_url = base_url + '&art=5&turdet=YES'
# Starting list for ratings
try:
html_start = fetch_url(start_url)
players = parse_start_list(html_start)
start_html = fetch_url(base_url + '&art=5&turdet=YES')
start_players = parse_start_list(start_html)
except Exception:
players = {}
start_players = {}
# Match real SNo and rating from start list
# Build name→sno lookup (normalized)
name_to_sno = {}
for sno, info in start_players.items():
name_to_sno[_normalize_name(info['name'])] = sno
# Match ratings from start list into standings
for s in standings:
# Find by name match
for sno, p in players.items():
if p['name'].lower() == s['name'].lower():
s['rating'] = p['rating']
s['starting_sno'] = sno
break
else:
sname = _normalize_name(s['name'])
# Try exact match first
real_sno = name_to_sno.get(sname)
if real_sno is None:
# Fuzzy: find best partial match (first+last name overlap)
sname_parts = set(sname.split())
best_sno = None
best_overlap = 0
for sl_name, sl_sno in name_to_sno.items():
sl_parts = set(sl_name.split())
overlap = len(sname_parts & sl_parts)
if overlap > best_overlap:
best_overlap = overlap
best_sno = sl_sno
real_sno = best_sno
s['starting_sno'] = real_sno if real_sno else s.get('starting_sno', s['rank'])
# Get real rating from start list
if real_sno and real_sno in start_players:
s['rating'] = start_players[real_sno].get('rating', s.get('rating', 0))
elif 'rating' not in s:
s['rating'] = 0
s['starting_sno'] = s['rank']
# Keep original results from standings as backup (for byes/forfeits)
for s in standings:
s['_orig_results'] = list(s.get('results', []))
s['results'] = [] # Clear — will rebuild from pairings
# Rebuild results from art=2 pairings (correct SNo, unlike standings cells)
sno_to_standing = {s['starting_sno']: s for s in standings if s.get('starting_sno')}
name_to_standing = {_normalize_name(s['name']): s for s in standings}
for rd in range(1, current_round + 1):
try:
rd_html = fetch_url(f'{base_url}&art=2&rd={rd}&turdet=YES')
games = parse_round_pairings(rd_html, rd)
except Exception:
continue
for g in games:
ws, bs = g['white_sno'], g['black_sno']
ws_result = g.get('white_score')
bs_result = g.get('black_score')
if ws_result is not None:
# Completed game
ws_val = float(ws_result)
bs_val = float(bs_result)
else:
# Unplayed — skip
continue
# Resolve players by SNo or name (normalized)
wp = sno_to_standing.get(ws) or name_to_standing.get(_normalize_name(g.get('white_name', '')))
bp = sno_to_standing.get(bs) or name_to_standing.get(_normalize_name(g.get('black_name', '')))
if wp:
opp_sno = bs if bs != 0 else (bp.get('starting_sno') if bp else 0)
existing = [r for r in wp.get('results', []) if r.get('round') != rd]
existing.append({'opponent': opp_sno, 'color': 'w', 'score': ws_val, 'round': rd})
wp['results'] = existing
if bp:
opp_sno = ws if ws != 0 else (wp.get('starting_sno') if wp else 0)
existing = [r for r in bp.get('results', []) if r.get('round') != rd]
existing.append({'opponent': opp_sno, 'color': 'b', 'score': bs_val, 'round': rd})
bp['results'] = existing
# For byes/forfeits not captured by pairings, use original standings results
for s in standings:
sno = s.get('starting_sno')
if sno and len(s.get('results', [])) < rd:
orig_results = s.get('_orig_results', [])
if len(orig_results) >= rd:
res = orig_results[rd - 1]
res['round'] = rd
s['results'] = list(s.get('results', []))
s['results'].append(res)
# Final pass: fill any remaining gaps from _orig_results (forfeit losers, byes)
for s in standings:
existing_rounds = {r.get('round', 0) for r in s.get('results', [])}
orig = s.get('_orig_results', [])
for ri, res in enumerate(orig):
rd_num = ri + 1
if rd_num <= current_round and rd_num not in existing_rounds:
res_copy = dict(res)
res_copy['round'] = rd_num
if res_copy.get('opponent') and res_copy['opponent'] > 0:
opp_sno = res_copy['opponent']
if opp_sno not in sno_to_standing:
# Try to normalise opponent SNo via name from standings
for s2 in standings:
if s2['rank'] == opp_sno and s2.get('starting_sno'):
res_copy['opponent'] = s2['starting_sno']
break
s['results'].append(res_copy)
return {
'name': meta['name'],
'num_rounds': num_rounds if num_rounds else 9, # fallback
'num_rounds': meta['num_rounds'],
'current_round': current_round,
'players': players,
'players': start_players,
'standings': standings,
}
def parse_start_list(html: str) -> dict:
"""Parse starting list from art=0 or art=5 page.
Two formats supported:
1. art=0 text: SNo Name FIDE_ID National_ID FED Rating Region
2. art=5 table: SNo, Name, FED, Rating columns
"""
soup = BeautifulSoup(html, 'html.parser')
# Try art=0 text format first
text = soup.get_text()
players = {}
# Pattern: SNo (1-3 digits), Full Name (3 words), two IDs, FED, Rating
pattern = re.compile(
r'(?:^|\s)(\d{1,3})\s+'
r'([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+)'
r'\s+\d+\s+\d+\s+'
r'([A-Z]{3})\s+'
r'(\d{3,4})'
)
for m in pattern.finditer(text):
sno = int(m.group(1))
name = m.group(2).strip()
fed = m.group(3)
rating = int(m.group(4))
players[sno] = {'name': name, 'rating': rating, 'fed': fed}
if len(players) >= 10:
return players
# Fallback: try art=5 table format
tables = soup.find_all('table')
for table in tables:
rows = table.find_all('tr')
data_rows = 0
for row in rows:
cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells]
if len(texts) >= 4 and texts[0].isdigit() and _has_cyrillic(' '.join(texts)):
data_rows += 1
if data_rows < 5:
continue
for row in rows:
cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells]
if len(texts) < 4 or not texts[0].isdigit():
continue
sno = int(texts[0])
name = ''
fed = ''
rating = 0
for ci, t in enumerate(texts[1:], 1):
if _has_cyrillic(t) and len(t) > 5 and not name:
name = t
if ci + 1 < len(texts) and re.match(r'^[A-Z]{3}$', texts[ci + 1]):
fed = texts[ci + 1]
continue
if t.isdigit() and len(t) >= 3 and not rating:
rating = int(t)
if name:
players[sno] = {'name': name, 'rating': rating, 'fed': fed}
if players:
break
return players

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,12 +149,15 @@ def _pair_bracket_fold_search(
players: List[Player],
all_paired: set,
) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
"""Параметризованный fold с перебором offset и floaters.
"""Fold pairing with offset search and floater candidates.
Для bracket размером N:
1. Если N нечётное пробуем каждого как флоатера
2. Для оставшихся M (чётное) пробуем fold с offset 0..M/2-1
3. Выбираем комбинацию с лучшим цветовым качеством
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)
@ -103,13 +165,13 @@ def _pair_bracket_fold_search(
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)
@ -117,14 +179,14 @@ def _pair_bracket_fold_search(
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
@ -152,17 +214,8 @@ def _pair_bracket_fold_search(
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
@ -170,11 +223,32 @@ def _pair_bracket_fold_search(
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)
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)
@ -182,7 +256,6 @@ def _pair_bracket_fold_search(
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)
@ -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):
@ -224,17 +306,72 @@ def fide_swiss_pairing(players: list, current_round: int) -> list:
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)
# Available players in this bracket
bracket_avail = [p for p in group if p.sno not in all_paired]
if len(group_avail) < 2:
downfloaters = group_avail
# 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)
# 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
@ -249,37 +386,73 @@ def calculate_next_round(tournament_data: dict) -> dict:
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
sno_to_player[sno] = p
# Предпочитаем предрассчитанные пары с сайта
has_calculated = any(s.get('next_opponent') for s in standings)
if has_calculated:
# 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 = []
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'))
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'}

181
swiss_calc/trf_generator.py Normal file
View 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()

103
test_all_rounds.py Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""End-to-end test: parse rounds 1-4, calculate round 5, compare with site."""
import sys
sys.path.insert(0, '.')
from swiss_calc.parser import fetch_url, parse_round_pairings, fetch_tournament
from swiss_calc.swiss import calculate_next_round
BASE = 'https://s1.chess-results.com/tnr1393124.aspx?lan=11'
print("=" * 60)
print("ТЕСТ: парсинг туров 1-4 → расчёт тура 5 vs chess-results.com")
print("=" * 60)
# 1. Parse tournament (rounds 1-4 from standings)
print("\n📥 Парсим турнир...")
data = fetch_tournament(BASE)
print(f" Турнир: {data['name'][:60]}")
print(f" Сыграно туров: {data['current_round']}")
print(f" Участников: {len(data['standings'])}")
# Show first few players' data
for s in data['standings'][:3]:
results_str = ' '.join(f"{r['opponent']}{r['color']}{r['score']}" for r in s['results'])
print(f" #{s['rank']} {s['name'][:30]}: {s['points']}pts, results=[{results_str}]")
# 2. Calculate round 5
print(f"\n🧮 Считаем тур {data['current_round'] + 1}...")
result = calculate_next_round(data)
print(f" Источник: {result['source']}")
calc_pairs = result['pairings']
print(f" Рассчитано пар: {len(calc_pairs)}")
# 3. Fetch actual round 5 from chess-results
print(f"\n📥 Парсим реальные пары тура 5 с сайта...")
html5 = fetch_url(f'{BASE}&art=2&rd=5&turdet=YES')
real_games = parse_round_pairings(html5, 5)
print(f" Всего игр на сайте: {len(real_games)}")
# Build sets for comparison
calc_set = set()
for wp, bp, color in calc_pairs:
sno_w = getattr(wp, 'sno', wp.get('starting_sno', 0)) if not hasattr(wp, 'sno') else wp.sno
sno_b = getattr(bp, 'sno', bp.get('starting_sno', 0)) if not hasattr(bp, 'sno') else bp.sno
calc_set.add((min(sno_w, sno_b), max(sno_w, sno_b)))
real_set = set()
for g in real_games:
w, b = g['white_sno'], g['black_sno']
real_set.add((min(w, b), max(w, b)))
# 4. Compare
matched = calc_set & real_set
extra_calc = calc_set - real_set
extra_real = real_set - calc_set
print(f"\n📊 СРАВНЕНИЕ:")
print(f" Совпало пар: {len(matched)}/{len(real_games)} ({100*len(matched)/max(len(real_games),1):.0f}%)")
print(f" Лишних в расчёте: {len(extra_calc)}")
print(f" Лишних на сайте: {len(extra_real)}")
if extra_calc:
print(f"\n Пары только в расчёте (первые 5):")
for w, b in list(extra_calc)[:5]:
print(f" #{w} vs #{b}")
if extra_real:
print(f"\n Пары только на сайте (первые 5):")
for w, b in list(extra_real)[:5]:
print(f" #{w} vs #{b}")
# 5. Show top boards side by side
print(f"\n📋 ДОСКИ 1-5: расчёт vs сайт")
calc_by_board = {}
for i, (wp, bp, color) in enumerate(calc_pairs):
sno_w = wp.sno
sno_b = bp.sno
calc_by_board[f"#{sno_w} #{wp.name[:20]}"] = f"#{sno_b} {bp.name[:20]}"
for g in real_games[:5]:
b = g['board']
w_name = g['white_name'][:25]
b_name = g['black_name'][:25]
ws, bs = g['white_sno'], g['black_sno']
# Find matching calc pair
pair_key = (min(ws, bs), max(ws, bs))
status = "" if pair_key in matched else ""
print(f" Доска {b}: #{ws} {w_name} vs #{bs} {b_name} {status}")
# 6. Check Vrubel specifically
print(f"\n🎯 Врубель Константин:")
for wp, bp, color in calc_pairs:
if 'Врубель' in wp.name:
print(f" Расчёт: #{wp.sno} {wp.name} (белые) vs #{bp.sno} {bp.name}")
break
for g in real_games:
if 'Врубель' in g.get('white_name', '') or 'Врубель' in g.get('black_name', ''):
if 'Врубель' in g['white_name']:
print(f" Сайт: #{g['white_sno']} {g['white_name']} (белые) vs #{g['black_sno']} {g['black_name']}")
else:
print(f" Сайт: #{g['black_sno']} {g['black_name']} (чёрные) vs #{g['white_sno']} {g['white_name']}")
break

128
test_round_by_round.py Normal file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Test round-by-round: parse once, truncate results, calculate next round."""
import sys
sys.path.insert(0, '.')
from swiss_calc.parser import fetch_tournament, fetch_url, parse_round_pairings
from swiss_calc.trf_generator import generate_trf
from swiss_calc.bbp_wrapper import call_bbp
import re
def norm(n):
return re.sub(r'\s+', ' ', n.replace(',', '')).strip()
BASE = 'https://s1.chess-results.com/tnr1289471.aspx?lan=11'
print("=" * 60)
print("ТЕСТ: пошаговый расчёт всех туров")
print("=" * 60)
# Parse once — get full data with correct SNo, byes, forfeits
data = fetch_tournament(BASE)
total_rounds = data['current_round']
standings = data['standings']
print(f"Турнир: {data['name'][:60]}")
print(f"Сыграно туров: {total_rounds}")
print(f"Участников: {len(standings)}")
# Collect actual pairs for comparison
name_to_sno = {norm(s['name']): s['starting_sno'] for s in standings}
actual_pairs = {}
for rd in range(1, total_rounds + 1):
html = fetch_url(f'{BASE}&art=2&rd={rd}&turdet=YES')
games = parse_round_pairings(html, rd)
for g in games:
if g['white_sno'] == 0:
g['white_sno'] = name_to_sno.get(norm(g['white_name']), 0)
if g['black_sno'] == 0:
g['black_sno'] = name_to_sno.get(norm(g['black_name']), 0)
actual_pairs[rd] = games
print(f" Тур {rd}: {len(games)} пар")
print(f"\n{'='*60}")
print(f"ПОШАГОВЫЙ РАСЧЁТ")
print(f"{'='*60}")
overall_matched = 0
overall_total = 0
for next_rd in range(2, total_rounds + 1):
completed = next_rd - 1
# Truncate results to only first `completed` rounds
truncated_standings = []
for s in standings:
full_results = s.get('results', [])
full_orig = s.get('_orig_results', [])
# Take results only up to round `completed`
trunc_results = [r for r in full_results if r.get('round', 99) <= completed]
trunc_orig = [r for r in full_orig if isinstance(r, dict) and r.get('round', 99) <= completed]
points = sum(r['score'] for r in trunc_results)
truncated_standings.append({
'rank': s['rank'],
'name': s['name'],
'fed': s['fed'],
'points': points,
'starting_sno': s['starting_sno'],
'rating': s.get('rating', 0),
'results': trunc_results,
'_orig_results': trunc_orig,
'tb': s.get('tb', []),
})
truncated = {
'name': data['name'],
'num_rounds': total_rounds,
'current_round': completed,
'players': data['players'],
'standings': truncated_standings,
}
trf = generate_trf(truncated, next_rd, name=data.get('name', 'Test'),
use_rank=True, initial_color_white=True)
try:
bbp_pairs, _ = call_bbp(trf)
except Exception as e:
print(f" Тур {next_rd}: bbpPairings ОШИБКА — {e}")
continue
# Compare with actual
calc_set = set()
for w_sno, b_sno in bbp_pairs:
if b_sno == 0 or w_sno == 0:
continue
calc_set.add((min(w_sno, b_sno), max(w_sno, b_sno)))
real_set = set()
for g in actual_pairs.get(next_rd, []):
w, b = g['white_sno'], g['black_sno']
if w == 0 or b == 0:
continue
# Only include completed games (not future pairings)
if g.get('white_score') is not None:
real_set.add((min(w, b), max(w, b)))
matched = len(calc_set & real_set)
total = len(real_set)
if total == 0:
print(f" Тур {next_rd}: нет сыгранных пар для сравнения")
continue
pct = 100 * matched / total
overall_matched += matched
overall_total += total
bar = '' * int(pct / 5) + '' * (20 - int(pct / 5))
print(f" Тур {next_rd}: {matched}/{total} совпадений [{bar}] {pct:.0f}%")
print(f"\n{'='*60}")
if overall_total > 0:
print(f"ИТОГО: {overall_matched}/{overall_total} ({100*overall_matched/overall_total:.0f}%)")
else:
print(f"ИТОГО: 0 пар")
print(f"{'='*60}")