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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 application code
COPY swiss_calc/ /app/swiss_calc/ 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 ```bash
# Сборка cd ~/projects/chessCalc
docker compose build docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11'
# Расчёт следующего тура
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'
# Показать конкретного игрока # Показать конкретного игрока
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 формате
## Формат вывода
``` ```
**Название турнира** chessCalc/
📋 **Тур 4** — пары ├── Dockerfile # python:3.11-slim + bbpPairings
├── docker-compose.yml
1. Иванов 🏳️ — Петров (3.0/3.0) ├── bbpPairings.exe # FIDE-движок (C++, статическая сборка)
2. Сидоров 🏳️ — Смирнов (2.5/2.5) ├── requirements.txt # beautifulsoup4, requests
... ├── swiss_calc/
│ ├── __main__.py # CLI: приём URL, вызов парсера + движка, вывод
Всего пар: 23 │ ├── 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) URL турнира
- **Дополнительно**: реализован базовый Swiss-system алгоритм для случая, если сайт не показывает следующий тур
- **Форматирование**: Telegram Markdown (поддерживается в Telegram Desktop и мобильной версии)
┌──────────────────────────────────────┐
│ 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']: for pairing in next_round['pairings']:
if len(pairing) == 3: if len(pairing) == 3:
p1, p2, color = pairing p1, p2, color = pairing
pairs.append({ if color == 'bye':
'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points}, pairs.append({
'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points}, 'board': 0,
'color': color, 'bye': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
}) })
elif len(pairing) == 5: elif color == 'w':
pairs.append({ pairs.append({
'white': {'name': pairing[1] if pairing[2] == 'w' else pairing[4], 'name2': ...}, '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},
})
else:
pairs.append({
'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({ json.dump({
'tournament': tournament['name'],
'round': next_round['round'], 'round': next_round['round'],
'pairings': pairs, 'pairings': pairs,
'source': next_round.get('source', 'algorithm'), '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: for pairing in pairings:
if len(pairing) == 3: if len(pairing) == 3:
p1, p2, color = pairing p1, p2, color = pairing
if color == 'bye':
lines.append(f" {board}. {p1.name} — BYE (свободен)")
board += 1
continue
if color == 'w': if color == 'w':
white, black = p1, p2 white, black = p1, p2
else: else:
@ -55,7 +59,9 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str:
lines.append("") lines.append("")
lines.append(f"Всего пар: {len(pairings)}") 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_") lines.append("_пары с сайта chess-results.com_")
return '\n'.join(lines) return '\n'.join(lines)

View file

@ -1,121 +1,92 @@
""" """
Parser for chess-results.com tournament pages. Parser for chess-results.com tournament pages.
Fetches and parses: Handles all page states:
- Starting list (players with ratings) - Before pairings: standings with completed rounds
- Round pairings/results - After pairings published: standings + next-round column
- Standings with tiebreakers - After games played: updated standings
- art=2 pairings pages in compact format
""" """
import re import re
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Optional from typing import Optional, List, Dict, Tuple
HEADERS = { HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' '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: def fetch_url(url: str) -> str:
"""Fetch HTML page from chess-results.com."""
resp = requests.get(url, headers=HEADERS, timeout=20) resp = requests.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status() resp.raise_for_status()
resp.encoding = 'utf-8' resp.encoding = 'utf-8'
return resp.text return resp.text
def parse_result(cell: str): # ── Result parsing ────────────────────────────────────────────
"""Parse a round result cell like '27b1', '15w½', '42b0'.
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() cell = cell.strip()
if not cell: if not cell:
return None 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: if m:
sno = int(m.group(1)) sno = int(m.group(1))
color = 'b' if m.group(2) == 'b' else 'w' color = 'w' if m.group(2) == 'w' else 'b'
pts_str = m.group(3).replace(',', '.') pts_str = m.group(3).replace(',', '.').replace('=', '0.5').replace('½', '0.5')
pts = float(pts_str) if '.' in pts_str else (0.5 if pts_str == '½' else float(pts_str)) pts = float(pts_str)
return sno, color, pts return sno, color, pts
# Handle forfeit results like '27b+'
# Forfeit with opponent: '27b+'
m = re.match(r'^(\d+)([bw])([+\-])$', cell) m = re.match(r'^(\d+)([bw])([+\-])$', cell)
if m: if m:
sno = int(m.group(1)) 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 pts = 1.0 if m.group(3) == '+' else 0.0
return sno, color, pts 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 return None
def parse_start_list(html: str) -> dict: def parse_next_opponent(cell: str) -> Optional[Tuple[int, str]]:
"""Parse art=5 page: returns dict of {sno: {'name': str, 'rating': int, 'fed': str}}""" """Parse '4w' or '12b' — next opponent and color."""
soup = BeautifulSoup(html, 'html.parser') m = re.match(r'^(\d+)([bw])$', cell.strip())
players = {} if m:
return int(m.group(1)), m.group(2)
# Find the main table with player data (largest table with starting numbers) return None
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_standings(html: str, current_round: int) -> list: # ── Standings parsing ─────────────────────────────────────────
"""Parse art=4 page (standings).
Returns list of dicts: def _normalize_name(name: str) -> str:
{ """Normalize name for matching: remove commas, collapse whitespace."""
'sno': int, return re.sub(r'\s+', ' ', name.replace(',', '')).strip()
'rank': int,
'name': str, def _has_cyrillic(s: str) -> bool:
'fed': str, return bool(re.search(r'[а-яА-ЯёЁ]', s))
'points': float,
'results': [(opponent_sno, color, score), ...], # for completed rounds
'next_opponent': Optional[int], # if pre-calculated def parse_standings(html: str) -> List[Dict]:
'next_color': Optional[str], # 'w' or 'b' """Parse standings from art=4 or art=5 page.
'tb': [float, float, float], # tiebreaker values
'opponents': [int, ...], # all opponents so far Detects player rows by: first column is a rank number,
} one column has a Cyrillic name.
""" """
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, 'html.parser')
players = [] players = []
@ -130,115 +101,81 @@ def parse_standings(html: str, current_round: int) -> list:
cells = row.find_all('td') cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells] texts = [c.get_text(strip=True) for c in cells]
# Filter: first cell should be a rank number if len(texts) < 6:
if not texts or not texts[0].isdigit():
continue continue
# Skip if not enough cols for a player row (at least rank + name + results) if not texts[0].isdigit():
if len(texts) < 8:
continue continue
rank = int(texts[0]) sno = int(texts[0])
# Usually: rank, (empty), name, fed, rd1, rd2, ..., pts, tb1, tb2, tb3 if sno < 1 or sno > 300:
# The empty column sometimes joins with rank
col_offset = 0
if rank == 0 or rank > 200:
continue continue
# Find name column # Find name: look for Cyrillic text
# Typical: rank | (empty) | name | fed | results...
name = '' name = ''
fed = '' name_idx = -1
name_idx = 1 for ci, t in enumerate(texts):
for ci in range(1, min(5, len(texts))): if _has_cyrillic(t) and len(t) > 5:
t = texts[ci] name = t
if t and not t.isdigit() and len(t) > 2 and not t.startswith('http'): name_idx = ci
if ci > 1 or not texts[0].isdigit(): break
# Check if this name contains 2+ words in Russian or English
if re.search(r'[а-яА-Яa-zA-Z]', t):
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: if not name:
continue continue
# Results start after federation column # Federation is usually the next column, if it looks like a code
# fed is at name_idx+1, so results start at name_idx+2 fed = ''
result_start = name_idx + 2 if name_idx + 2 < len(texts) else name_idx + 1 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 = [] results = []
opponents = []
next_opponent = None next_opponent = None
next_color = None next_color = None
pts_found = False points = 0.0
tb_values = [] tb_values = []
found_points = False
# Process each column from result_start for col in data_cols:
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):
if not col: if not col:
continue 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 # Try result
parsed = parse_result(col) res = parse_result(col)
if parsed: if res:
opp, color, pts = parsed opp, color, pts = res
results.append({'opponent': opp, 'color': color, 'score': pts}) results.append({'opponent': opp, 'color': color, 'score': pts})
opponents.append(opp)
continue continue
# Check for next opponent (just "12w" format without 1/0/½) # Try next opponent
m2 = re.match(r'^(\d+)([bw])$', col) no = parse_next_opponent(col)
if m2: if no:
next_opponent = int(m2.group(1)) next_opponent, next_color = no
next_color = m2.group(2)
continue continue
# Check if it's the points column # Points: first numeric with optional decimal after results
if re.match(r'^\d+(?:[.,]\d)?$', col) and not pts_found: if re.match(r'^\d+([,.]\d)?$', col) and not found_points:
pts_str = col.replace(',', '.') points = float(col.replace(',', '.'))
points = float(pts_str) found_points = True
pts_found = True
pts_col_idx = ci
continue continue
# Tiebreaker columns (after points) # Tiebreakers (after points)
if pts_found and re.match(r'^\d+(?:[.,]\d)?$', col): if found_points and re.match(r'^\d+([,.]\d+)?$', col):
tb_values.append(float(col.replace(',', '.'))) tb_values.append(float(col.replace(',', '.')))
# If we didn't find special next-opponent format, check results for unplayed players.append({
# Also check for pre-calculated pairing at position current_round (0-indexed in results) 'rank': len(players) + 1, # position in list = current rank
# 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,
'name': name, 'name': name,
'fed': fed, 'fed': fed,
'points': float(texts[-len(tb_values)-1].replace(',', '.')) if texts else 0, 'points': points,
'starting_sno': sno, # from first column
'results': results, 'results': results,
'next_opponent': next_opponent, 'next_opponent': next_opponent,
'next_color': next_color, 'next_color': next_color,
'tb': tb_values, 'tb': tb_values,
'opponents': opponents, })
}
players.append(player)
if players: if players:
break break
@ -246,254 +183,362 @@ def parse_standings(html: str, current_round: int) -> list:
return players return players
def parse_round_pairings(html: str, round_num: int) -> list: # ── Round pairings (art=2 compact format) ─────────────────────
"""Parse art=2 page for a specific round.
Returns list of dicts: def parse_game_row(texts: List[str], board: int) -> Optional[Dict]:
{ """Parse one game row from art=2 page.
'board': int,
'white_sno': int, Format A (12 columns newer tournaments):
'white_name': str, [0]=db_id, [1]=w_sno, [2]=_, [3]=w_name, [4]=w_rating, [5]=w_pts,
'white_rating': int, [6]=result, [7]=b_pts, [8]=_, [9]=b_name, [10]=b_rating, [11]=b_sno
'white_pts': float,
'black_sno': int, Format B (10 columns older tournaments, no SNo in table):
'black_name': str, [0]=seq_id, [1]=_, [2]=w_name, [3]=w_rating, [4]=w_pts,
'black_rating': int, [5]=result, [6]=b_pts, [7]=_, [8]=b_name, [9]=b_rating
'black_pts': float,
'result': Optional[str], # '1-0', '½-½', '0-1' 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') soup = BeautifulSoup(html, 'html.parser')
pairings = [] games = []
tables = soup.find_all('table') tables = soup.find_all('table')
for table in tables: for table in tables:
rows = table.find_all('tr') rows = table.find_all('tr')
if len(rows) < 5: board = 0
continue
for row in rows: for row in rows:
cells = row.find_all('td') cells = row.find_all('td')
texts = [c.get_text(strip=True) for c in cells] texts = [c.get_text(strip=True) for c in cells]
if len(texts) < 10: if len(texts) >= 10 and texts[0].isdigit():
continue 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 if games:
# 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:
break break
return pairings return games
# ── Tournament meta ───────────────────────────────────────────
def extract_tournament_meta(html: str) -> dict: 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') 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') h2 = soup.find('h2')
if h2: if h2:
info['name'] = h2.get_text(strip=True) info['name'] = h2.get_text(strip=True)
# Find "Number of rounds" row in tables - but be specific text = soup.get_text()
# 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])
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" # Number of rounds
nav_text = soup.get_text() m = re.search(r'Number of rounds\s*(\d+)', text)
m = re.search(r'Тур(\d+)/\d+|Round\s*(\d+)\s*/\s*\d+', nav_text)
if m: if m:
info['current_round'] = int(m.group(1) or m.group(2)) info['num_rounds'] = int(m.group(1))
# Current round: "Положение после тура N"
m = re.search(r'Положение после тура\s*(\d+)', text)
if m:
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 return info
def detect_current_round(url: str) -> int: # ── Full tournament fetch ─────────────────────────────────────
"""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
def fetch_tournament(url: str) -> dict: def fetch_tournament(url: str) -> dict:
"""Full tournament data fetch. """Fetch full tournament data from any chess-results.com URL.
Returns: Detects current state automatically (any number of completed rounds).
{
'name': str,
'num_rounds': int,
'current_round': int,
'players': {sno: {name, rating, fed}},
'standings': [...], # players sorted by rank
'pairings': {rd: [...]}, # completed round pairings
}
""" """
# 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'[&?]art=\d+', '', url)
base_url = re.sub(r'[&?]rd=\d+', '', base_url) base_url = re.sub(r'[&?]rd=\d+', '', base_url)
base_url = re.sub(r'[&?]turdet=\w+', '', base_url) base_url = re.sub(r'[&?]turdet=\w+', '', base_url)
base_url = re.sub(r'[&?]SNode=\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' standings_url = base_url + '&art=4&turdet=YES'
try: try:
html = fetch_url(standings_url) html = fetch_url(standings_url)
except Exception as e: except Exception as e:
raise RuntimeError(f'Не удалось загрузить турнир: {e}') raise RuntimeError(f'Не удалось загрузить турнир: {e}')
# Extract metadata from same HTML
meta = extract_tournament_meta(html) meta = extract_tournament_meta(html)
num_rounds = meta['num_rounds'] standings = parse_standings(html)
# Parse standings if not standings:
standings = parse_standings(html, 0) raise RuntimeError('Не удалось распарсить таблицу положения')
if standings:
current_round = len(standings[0].get('results', []))
else:
current_round = 1
# Override current_round from navigation if available # Completed rounds: use page metadata ("Положение после тура N")
# Navigation "Тур4/9" means round 4 is current/playing → 3 completed current_round = meta['current_round']
if meta['current_round'] > 1: # Sanity check against actual results
current_round = meta['current_round'] # this is the current playing round max_results = max(len(s['results']) for s in standings)
# But we want the last COMPLETED round for calculations if current_round < 2 and max_results > current_round:
# If results show N entries, N rounds are completed current_round = max_results
elif max_results < current_round:
current_round = max_results
# Reconcile: completed rounds = len(results for first player) # Starting list for ratings
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'
try: try:
html_start = fetch_url(start_url) start_html = fetch_url(base_url + '&art=5&turdet=YES')
players = parse_start_list(html_start) start_players = parse_start_list(start_html)
except Exception: 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: for s in standings:
# Find by name match sname = _normalize_name(s['name'])
for sno, p in players.items(): # Try exact match first
if p['name'].lower() == s['name'].lower(): real_sno = name_to_sno.get(sname)
s['rating'] = p['rating'] if real_sno is None:
s['starting_sno'] = sno # Fuzzy: find best partial match (first+last name overlap)
break sname_parts = set(sname.split())
else: 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['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 { return {
'name': meta['name'], 'name': meta['name'],
'num_rounds': num_rounds if num_rounds else 9, # fallback 'num_rounds': meta['num_rounds'],
'current_round': current_round, 'current_round': current_round,
'players': players, 'players': start_players,
'standings': standings, '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), использует Вместо полного max-weight matching (как в JaVaFo), использует
перебор вариантов fold + greedy цветовая оптимизация. перебор вариантов fold + greedy цветовая оптимизация.
Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений. Улучшения:
- Учёт предыдущих bye (не даём bye повторно)
- Downfloaters паруются с верхом следующей группы очков
- Абсолютное цветовое предпочтение (|balance| >= 2) жёсткое правило
- Сортировка по очкам + рейтингу для корректного fold
""" """
from collections import defaultdict from collections import defaultdict
@ -31,6 +35,9 @@ class Player:
def color_balance(self): return self.white_count - self.black_count def color_balance(self): return self.white_count - self.black_count
@property @property
def last_color(self): return self.colors[-1] if self.colors else None 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: def preferred_color(self) -> str:
if not self.colors: if not self.colors:
@ -70,18 +77,70 @@ def compute_tiebreakers(players: list, rounds: int) -> None:
# ═══════════════════════════════════ # ═══════════════════════════════════
def _assign_colors(p1: Player, p2: Player) -> str: 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() pref1 = p1.preferred_color()
pref2 = p2.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: if pref1 != pref2:
return pref1 return pref1
force1, force2 = p1.color_force(), p2.color_force()
if force1 > force2: return pref1 # Same preferences — higher rated decides
if force2 > force1: return 'b' if pref1 == 'w' else 'w' if p1.rating >= p2.rating:
if p1.rating >= p2.rating: return pref1 return pref1
return 'b' if pref1 == 'w' else 'w' 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 # ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET
# ═══════════════════════════════════ # ═══════════════════════════════════
@ -90,57 +149,60 @@ def _pair_bracket_fold_search(
players: List[Player], players: List[Player],
all_paired: set, all_paired: set,
) -> Tuple[List[Tuple[Player, Player]], List[Player]]: ) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
"""Параметризованный fold с перебором offset и floaters. """Fold pairing with offset search and floater candidates.
Для bracket размером N: For a bracket of size N:
1. Если N нечётное пробуем каждого как флоатера 1. If N odd try each player as a downfloater
2. Для оставшихся M (чётное) пробуем fold с offset 0..M/2-1 2. For the remaining M (even) try fold offsets 0..M/2-1
3. Выбираем комбинацию с лучшим цветовым качеством 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] available = [p for p in players if p.sno not in all_paired]
n = len(available) n = len(available)
if n < 2: if n < 2:
return [], list(available) return [], list(available)
available.sort(key=lambda p: -p.rating) available.sort(key=lambda p: (-p.points, -p.rating))
best_pairs = [] best_pairs = []
best_floaters = list(available[-1:]) if n % 2 == 1 else [] best_floaters = list(available[-1:]) if n % 2 == 1 else []
best_score = -9999 best_score = -9999
# Кандидаты на флоат # Floater candidates
floater_candidates = [None] floater_candidates = [None]
if n % 2 == 1: if n % 2 == 1:
floater_candidates = range(n) floater_candidates = range(n)
for fi in floater_candidates: for fi in floater_candidates:
if fi is not None: if fi is not None:
floater = available[fi] floater = available[fi]
rest = available[:fi] + available[fi+1:] rest = available[:fi] + available[fi + 1:]
else: else:
floater = None floater = None
rest = available rest = available
m = len(rest) m = len(rest)
# Перебор offset # Try fold with different offsets
for offset in range(m // 2): for offset in range(m // 2):
pairs = [] pairs = []
ok = True ok = True
used = set() used = set()
for i in range(m // 2): for i in range(m // 2):
a = rest[i] a = rest[i]
b = rest[m // 2 + ((i + offset) % (m // 2))] b = rest[m // 2 + ((i + offset) % (m // 2))]
if a.has_played(b.sno): if a.has_played(b.sno):
ok = False ok = False
break break
if a.sno in used or b.sno in used: if a.sno in used or b.sno in used:
ok = False ok = False
break break
color = _assign_colors(a, b) color = _assign_colors(a, b)
if color == 'w': if color == 'w':
pairs.append((a, b)) pairs.append((a, b))
@ -148,33 +210,25 @@ def _pair_bracket_fold_search(
pairs.append((b, a)) pairs.append((b, a))
used.add(a.sno) used.add(a.sno)
used.add(b.sno) used.add(b.sno)
if not ok or len(pairs) < m // 2: if not ok or len(pairs) < m // 2:
continue continue
# Оценка: цветовые несовпадения # Score colour quality
color_score = 0 color_score = sum(_color_score(wp, bp, 'w') for wp, bp in pairs)
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
if color_score > best_score: if color_score > best_score:
best_score = color_score best_score = color_score
best_pairs = pairs best_pairs = pairs
best_floaters = [floater] if floater else [] best_floaters = [floater] if floater else []
if not best_pairs and n % 2 == 1: if not best_pairs and n % 2 == 1:
# Фолбэк: float последнего # Fallback: float the last player
floater = available[-1] floater = available[-1]
rest = available[:-1] rest = available[:-1]
m = len(rest) m = len(rest)
best_pairs = [] best_pairs = []
best_floaters = [floater]
for i in range(m // 2): for i in range(m // 2):
a, b = rest[i], rest[m // 2 + i] a, b = rest[i], rest[m // 2 + i]
color = _assign_colors(a, b) color = _assign_colors(a, b)
@ -182,12 +236,31 @@ def _pair_bracket_fold_search(
best_pairs.append((a, b)) best_pairs.append((a, b))
else: else:
best_pairs.append((b, a)) 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: for wp, bp in best_pairs:
all_paired.add(wp.sno) all_paired.add(wp.sno)
all_paired.add(bp.sno) all_paired.add(bp.sno)
return best_pairs, best_floaters return best_pairs, best_floaters
@ -196,19 +269,28 @@ def _pair_bracket_fold_search(
# ═══════════════════════════════════ # ═══════════════════════════════════
def fide_swiss_pairing(players: list, current_round: int) -> list: 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) sorted_players = sorted(players, key=sort_key)
# Bye # Bye — assign to eligible player in lowest score group
if len(sorted_players) % 2 == 1: if len(sorted_players) % 2 == 1:
groups = defaultdict(list) groups = defaultdict(list)
for p in sorted_players: for p in sorted_players:
groups[p.points].append(p) groups[p.points].append(p)
lowest = sorted(groups[min(groups.keys())], key=lambda p: p.rating) # Lowest score group, by rating ascending, excluding previous bye receivers
bye_player = lowest[0] 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] sorted_players = [p for p in sorted_players if p.sno != bye_player.sno]
# Score brackets # Create score brackets
brackets = [] brackets = []
i = 0 i = 0
while i < len(sorted_players): while i < len(sorted_players):
@ -218,24 +300,79 @@ def fide_swiss_pairing(players: list, current_round: int) -> list:
group.append(sorted_players[i]) group.append(sorted_players[i])
i += 1 i += 1
brackets.append(group) brackets.append(group)
all_pairs = [] all_pairs = []
all_paired = set() all_paired = set()
downfloaters = [] downfloaters = []
for group in brackets: for group in brackets:
group_avail = [p for p in group if p.sno not in all_paired] # Available players in this bracket
for df in downfloaters: bracket_avail = [p for p in group if p.sno not in all_paired]
if df.sno not in all_paired:
group_avail.append(df) # Downfloaters from above (have more points than this bracket)
floaters = [df for df in downfloaters if df.sno not in all_paired]
if len(group_avail) < 2:
downfloaters = group_avail if len(bracket_avail) + len(floaters) < 2:
downfloaters = bracket_avail + floaters
continue continue
pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired) # Priority: pair each downfloater with a bracket member
all_pairs.extend(pairs) # (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 return all_pairs
@ -247,39 +384,75 @@ def calculate_next_round(tournament_data: dict) -> dict:
standings = tournament_data['standings'] standings = tournament_data['standings']
current_round = tournament_data['current_round'] current_round = tournament_data['current_round']
next_round = current_round + 1 next_round = current_round + 1
player_map = {} player_map = {}
sno_to_player = {s.get('starting_sno', s['rank']): None for s in standings}
for s in standings: for s in standings:
rank = s['rank'] rank = s['rank']
sno = s.get('starting_sno', rank)
p = Player( p = Player(
sno=s.get('starting_sno', rank), sno=sno,
name=s['name'], rating=s.get('rating', 0), name=s['name'], rating=s.get('rating', 0),
fed=s['fed'], points=s['points'], results=s['results']) fed=s['fed'], points=s['points'], results=s['results'])
p.rank = rank p.rank = rank
p.tb = s.get('tb', []) p.tb = s.get('tb', [])
player_map[rank] = p player_map[rank] = p
sno_to_player[sno] = p
# Предпочитаем предрассчитанные пары с сайта
has_calculated = any(s.get('next_opponent') for s in standings) # Try bbpPairings first (FIDE 2025 Dutch System engine)
if has_calculated: bbp_error = None
pairings = [] try:
paired = set() from .trf_generator import generate_trf
for s in standings: from .bbp_wrapper import call_bbp
rank = s['rank']
if rank in paired: continue trf = generate_trf(
opp_rank = s.get('next_opponent') tournament_data, next_round,
if opp_rank and opp_rank in player_map and opp_rank not in paired: name=tournament_data.get('name', 'Chess Tournament'),
color = s.get('next_color', 'w') use_rank=False,
p1, p2 = player_map[rank], player_map[opp_rank] initial_color_white=False,
if color == 'w': )
pairings.append((p1, p2, 'w')) 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: else:
pairings.append((p2, p1, 'w')) bp = sno_to_player.get(b_sno)
paired.add(rank); paired.add(opp_rank) if bp is None:
return {'round': next_round, 'pairings': pairings, continue
'players': player_map, 'source': 'chess_results_precalculated'} 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) 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, return {'round': next_round,
'pairings': [(wp, bp, 'w') for wp, bp in raw], 'pairings': pairings,
'players': player_map, 'source': 'swiss_algorithm'} '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}")