2025-08-22 20:07:59 +02:00
|
|
|
#!/usr/bin/env python3
|
2025-11-30 13:42:16 +01:00
|
|
|
"""Analyze a chess game's moves using a local Stockfish engine and rate each move.
|
2025-08-22 20:07:59 +02:00
|
|
|
|
|
|
|
|
Usage:
|
2025-11-30 21:20:17 +01:00
|
|
|
python3 python_pkg/analyze_chess_game.py <path-to-file>
|
2025-08-22 22:30:47 +02:00
|
|
|
[--engine stockfish]
|
|
|
|
|
[--time 0.5 | --depth 20]
|
|
|
|
|
[--threads auto|N]
|
|
|
|
|
[--hash-mb auto|MB]
|
|
|
|
|
[--multipv N]
|
2025-08-23 15:16:26 +02:00
|
|
|
[--last-move-only]
|
2025-08-22 20:07:59 +02:00
|
|
|
|
|
|
|
|
Notes:
|
2025-11-30 21:20:17 +01:00
|
|
|
- Requires python-chess. Install from python_pkg/stockfish_analysis/requirements.txt
|
2025-08-22 22:30:47 +02:00
|
|
|
- The input file can be a pure PGN or a log file containing a PGN section.
|
2025-11-30 13:59:21 +01:00
|
|
|
- The script tries to locate the PGN by looking for a 'PGN:' marker,
|
|
|
|
|
PGN tags '[...]', or a move list starting with '1.'.
|
|
|
|
|
- Stockfish is CPU-based; it doesn't use GPU VRAM. "Full power" here means
|
|
|
|
|
using many CPU threads and a large transposition table (Hash).
|
2025-08-22 20:07:59 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
2025-11-30 13:59:21 +01:00
|
|
|
import contextlib
|
2025-08-22 20:07:59 +02:00
|
|
|
import io
|
2025-11-30 14:36:13 +01:00
|
|
|
import logging
|
2025-11-30 13:42:16 +01:00
|
|
|
import multiprocessing
|
2025-08-22 20:07:59 +02:00
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
2025-08-22 22:30:47 +02:00
|
|
|
|
|
|
|
|
try:
|
2025-11-30 15:06:51 +01:00
|
|
|
import psutil # type: ignore[import-untyped]
|
2025-08-22 22:30:47 +02:00
|
|
|
except Exception: # pragma: no cover - optional dependency; we fall back if unavailable
|
2025-11-30 15:06:51 +01:00
|
|
|
psutil = None # type: ignore[assignment]
|
2025-08-22 20:07:59 +02:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import chess
|
|
|
|
|
import chess.engine
|
|
|
|
|
import chess.pgn
|
2025-11-30 13:42:16 +01:00
|
|
|
except Exception: # pragma: no cover
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.exception("Missing dependency. Please install python-chess:")
|
2025-11-30 21:20:17 +01:00
|
|
|
logging.exception(" pip install -r python_pkg/stockfish_analysis/requirements.txt")
|
2025-08-22 20:07:59 +02:00
|
|
|
raise
|
|
|
|
|
|
2025-11-30 15:01:14 +01:00
|
|
|
# Memory configuration constants
|
|
|
|
|
MEMINFO_PARTS_MIN = 2
|
|
|
|
|
HIGH_THREAD_COUNT = 16
|
|
|
|
|
|
2025-08-22 20:07:59 +02:00
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
def extract_pgn_text(raw: str) -> str | None:
|
2025-08-22 20:07:59 +02:00
|
|
|
"""Try to extract a PGN block from a possibly noisy file.
|
|
|
|
|
|
|
|
|
|
Strategies tried in order:
|
|
|
|
|
1) Everything after a line that equals or starts with 'PGN:'
|
|
|
|
|
2) From the first PGN tag line '[' to the end
|
|
|
|
|
3) From the first line starting with an integer and a dot (e.g., '1.') to the end
|
|
|
|
|
"""
|
|
|
|
|
lines = raw.splitlines()
|
|
|
|
|
|
|
|
|
|
# 1) After 'PGN:' marker
|
|
|
|
|
for i, line in enumerate(lines):
|
|
|
|
|
if line.strip().startswith("PGN:"):
|
|
|
|
|
# everything after this line
|
|
|
|
|
pgn = "\n".join(lines[i + 1 :]).strip()
|
|
|
|
|
if pgn:
|
|
|
|
|
return pgn
|
|
|
|
|
|
|
|
|
|
# 2) From first tag line
|
|
|
|
|
for i, line in enumerate(lines):
|
|
|
|
|
if line.strip().startswith("[") and "]" in line:
|
|
|
|
|
pgn = "\n".join(lines[i:]).strip()
|
|
|
|
|
if pgn:
|
|
|
|
|
return pgn
|
|
|
|
|
|
|
|
|
|
# 3) From first move number
|
|
|
|
|
move_start_re = re.compile(r"^\s*\d+\.")
|
|
|
|
|
for i, line in enumerate(lines):
|
|
|
|
|
if move_start_re.match(line):
|
|
|
|
|
pgn = "\n".join(lines[i:]).strip()
|
|
|
|
|
if pgn:
|
|
|
|
|
return pgn
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 14:25:35 +01:00
|
|
|
def score_to_cp(
|
2025-11-30 16:23:16 +01:00
|
|
|
score: chess.engine.PovScore, *, pov_white: bool
|
2025-11-30 14:25:35 +01:00
|
|
|
) -> tuple[int | None, int | None]:
|
2025-08-22 20:07:59 +02:00
|
|
|
"""Return tuple (cp, mate_in) from a PovScore for the given POV color.
|
|
|
|
|
|
2025-11-30 14:25:35 +01:00
|
|
|
If it's a mate score, cp will be None and mate_in will be +/-N
|
|
|
|
|
(positive means mate for POV side). If it's a cp score, mate_in will be None.
|
2025-08-22 20:07:59 +02:00
|
|
|
"""
|
|
|
|
|
pov = chess.WHITE if pov_white else chess.BLACK
|
|
|
|
|
s = score.pov(pov)
|
|
|
|
|
if s.is_mate():
|
|
|
|
|
mi = s.mate()
|
|
|
|
|
return None, mi
|
|
|
|
|
return s.score(mate_score=None), None
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 15:01:14 +01:00
|
|
|
# Centipawn loss thresholds for move quality classification (Lichess-like bands)
|
|
|
|
|
CP_LOSS_BEST = 10
|
|
|
|
|
CP_LOSS_EXCELLENT = 20
|
|
|
|
|
CP_LOSS_GOOD = 50
|
|
|
|
|
CP_LOSS_INACCURACY = 99
|
|
|
|
|
CP_LOSS_MISTAKE = 299
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 16:03:14 +01:00
|
|
|
# Centipawn loss thresholds for move classification
|
|
|
|
|
_CP_LOSS_BANDS = [
|
|
|
|
|
(CP_LOSS_BEST, "Best"),
|
|
|
|
|
(CP_LOSS_EXCELLENT, "Excellent"),
|
|
|
|
|
(CP_LOSS_GOOD, "Good"),
|
|
|
|
|
(CP_LOSS_INACCURACY, "Inaccuracy"),
|
|
|
|
|
(CP_LOSS_MISTAKE, "Mistake"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
def classify_cp_loss(cp_loss: int | None) -> str:
|
2025-08-22 20:07:59 +02:00
|
|
|
"""Classify move quality using Lichess-like centipawn loss bands.
|
|
|
|
|
|
|
|
|
|
Loss is best_eval(cp) - played_eval(cp), from the mover's POV (positive is worse).
|
|
|
|
|
Bands (approx, widely cited):
|
|
|
|
|
- Best: 0..10 cp
|
|
|
|
|
- Excellent: 11..20 cp
|
|
|
|
|
- Good: 21..50 cp
|
|
|
|
|
- Inaccuracy: 51..99 cp
|
|
|
|
|
- Mistake: 100..299 cp
|
|
|
|
|
- Blunder: >=300 cp
|
|
|
|
|
"""
|
|
|
|
|
if cp_loss is None:
|
|
|
|
|
return "Unknown"
|
2025-11-30 16:03:14 +01:00
|
|
|
for threshold, classification in _CP_LOSS_BANDS:
|
|
|
|
|
if cp_loss <= threshold:
|
|
|
|
|
return classification
|
2025-08-22 20:07:59 +02:00
|
|
|
return "Blunder"
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
def fmt_eval(cp: int | None, mate_in: int | None) -> str:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Format evaluation score as human-readable string."""
|
2025-08-22 20:07:59 +02:00
|
|
|
if mate_in is not None:
|
|
|
|
|
sign = "+" if mate_in > 0 else ""
|
|
|
|
|
return f"M{sign}{mate_in}"
|
|
|
|
|
if cp is None:
|
|
|
|
|
return "?"
|
|
|
|
|
# Convert cp to pawns with sign and 2 decimals
|
2025-11-30 13:49:00 +01:00
|
|
|
return f"{cp / 100.0:+.2f}"
|
2025-08-22 20:07:59 +02:00
|
|
|
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
def _parse_threads(value: str) -> int | None:
|
2025-08-22 22:30:47 +02:00
|
|
|
v = value.strip().lower()
|
|
|
|
|
if v in ("auto", "max", ""): # auto-detect
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
n = int(v)
|
|
|
|
|
return max(1, n)
|
|
|
|
|
except ValueError:
|
2025-11-30 13:59:21 +01:00
|
|
|
msg = "--threads must be an integer or 'auto'"
|
2025-11-30 20:47:38 +01:00
|
|
|
raise argparse.ArgumentTypeError(msg) from None
|
2025-08-22 22:30:47 +02:00
|
|
|
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
def _parse_hash_mb(value: str) -> int | None:
|
2025-08-22 22:30:47 +02:00
|
|
|
v = value.strip().lower()
|
|
|
|
|
if v in ("auto", "max", ""): # auto-detect
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
mb = int(v)
|
|
|
|
|
return max(16, mb)
|
|
|
|
|
except ValueError:
|
2025-11-30 13:59:21 +01:00
|
|
|
msg = "--hash-mb must be an integer (MB) or 'auto'"
|
2025-11-30 20:47:38 +01:00
|
|
|
raise argparse.ArgumentTypeError(msg) from None
|
2025-08-22 22:30:47 +02:00
|
|
|
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
def _detect_total_mem_mb() -> int | None:
|
2025-08-22 22:30:47 +02:00
|
|
|
# Prefer psutil if available
|
|
|
|
|
if psutil is not None:
|
2025-11-30 21:20:17 +01:00
|
|
|
with contextlib.suppress(Exception):
|
2025-08-22 22:30:47 +02:00
|
|
|
return int(psutil.virtual_memory().total // (1024 * 1024))
|
2025-11-30 15:09:17 +01:00
|
|
|
# Fallback approach for Linux systems using proc meminfo.
|
2025-11-30 21:20:17 +01:00
|
|
|
with (
|
|
|
|
|
contextlib.suppress(Exception),
|
|
|
|
|
open("/proc/meminfo", encoding="utf-8", errors="ignore") as f,
|
|
|
|
|
):
|
|
|
|
|
for line in f:
|
|
|
|
|
if line.startswith("MemTotal:"):
|
|
|
|
|
parts = line.split()
|
|
|
|
|
if len(parts) >= MEMINFO_PARTS_MIN and parts[1].isdigit():
|
|
|
|
|
# Value is in kB
|
|
|
|
|
kb = int(parts[1])
|
|
|
|
|
return kb // 1024
|
2025-08-22 22:30:47 +02:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def _auto_hash_mb(threads_wanted: int, engine_options: dict[str, object]) -> int:
|
2025-08-22 22:30:47 +02:00
|
|
|
total_mb = _detect_total_mem_mb() or 2048
|
|
|
|
|
# Heuristic: cap at 4 GiB by default; keep at most half of RAM; ensure >= 64MB
|
|
|
|
|
half_ram = max(64, total_mb // 2)
|
|
|
|
|
target = half_ram
|
|
|
|
|
# Respect engine "Hash" max if exposed
|
|
|
|
|
opt = engine_options.get("Hash")
|
|
|
|
|
max_allowed = None
|
|
|
|
|
try:
|
2025-11-30 15:49:40 +01:00
|
|
|
max_allowed = opt.max if opt is not None else None # type: ignore[attr-defined]
|
2025-08-22 22:30:47 +02:00
|
|
|
except Exception:
|
|
|
|
|
max_allowed = None
|
|
|
|
|
if isinstance(max_allowed, int):
|
|
|
|
|
target = min(target, max_allowed)
|
|
|
|
|
# Some rough scaling: if very many threads, give a bit more (but not huge)
|
2025-11-30 15:01:14 +01:00
|
|
|
if threads_wanted >= HIGH_THREAD_COUNT:
|
2025-08-22 22:30:47 +02:00
|
|
|
target = min(target + 1024, (total_mb * 3) // 4)
|
|
|
|
|
return max(64, int(target))
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def main() -> None:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Parse arguments and run chess game analysis."""
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
2025-11-30 14:25:35 +01:00
|
|
|
ap = argparse.ArgumentParser(
|
|
|
|
|
description="Analyze a chess game's moves with Stockfish and rate each move."
|
|
|
|
|
)
|
2025-08-22 20:07:59 +02:00
|
|
|
ap.add_argument("file", help="Path to a PGN file or a log containing a PGN section")
|
2025-11-30 13:42:16 +01:00
|
|
|
ap.add_argument(
|
|
|
|
|
"--engine",
|
|
|
|
|
default="stockfish",
|
|
|
|
|
help="Path to stockfish executable (default: stockfish)",
|
|
|
|
|
)
|
2025-08-22 20:07:59 +02:00
|
|
|
# Exactly one of time or depth may be provided; default to time
|
2025-11-30 13:42:16 +01:00
|
|
|
ap.add_argument(
|
|
|
|
|
"--time",
|
|
|
|
|
type=float,
|
|
|
|
|
default=0.5,
|
|
|
|
|
help="Analysis time per evaluation in seconds (default: 0.5)",
|
|
|
|
|
)
|
|
|
|
|
ap.add_argument(
|
|
|
|
|
"--depth",
|
|
|
|
|
type=int,
|
|
|
|
|
default=None,
|
|
|
|
|
help="Fixed depth per evaluation (overrides --time)",
|
|
|
|
|
)
|
2025-08-22 22:30:47 +02:00
|
|
|
# Performance knobs
|
2025-11-30 13:42:16 +01:00
|
|
|
ap.add_argument(
|
|
|
|
|
"--threads",
|
|
|
|
|
type=_parse_threads,
|
|
|
|
|
default=None,
|
|
|
|
|
metavar="auto|N",
|
|
|
|
|
help="Engine threads to use (default: auto = all logical cores)",
|
|
|
|
|
)
|
|
|
|
|
ap.add_argument(
|
|
|
|
|
"--hash-mb",
|
|
|
|
|
type=_parse_hash_mb,
|
|
|
|
|
default=None,
|
|
|
|
|
metavar="auto|MB",
|
|
|
|
|
help="Hash table size in MB (default: auto = up to half RAM, capped)",
|
|
|
|
|
)
|
|
|
|
|
ap.add_argument(
|
|
|
|
|
"--multipv",
|
|
|
|
|
type=int,
|
|
|
|
|
default=2,
|
|
|
|
|
help="Number of principal variations to compute (default: 1)",
|
|
|
|
|
)
|
|
|
|
|
ap.add_argument(
|
|
|
|
|
"--last-move-only",
|
|
|
|
|
action="store_true",
|
2025-11-30 14:25:35 +01:00
|
|
|
help=(
|
|
|
|
|
"Analyze only the last move of the main line "
|
|
|
|
|
"(reports its eval and the best move)"
|
|
|
|
|
),
|
2025-11-30 13:42:16 +01:00
|
|
|
)
|
2025-08-22 20:07:59 +02:00
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
|
|
|
if not os.path.isfile(args.file):
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.error(f"Input not found: {args.file}")
|
2025-08-22 20:07:59 +02:00
|
|
|
sys.exit(1)
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
with open(args.file, encoding="utf-8", errors="replace") as f:
|
2025-08-22 20:07:59 +02:00
|
|
|
raw = f.read()
|
|
|
|
|
|
|
|
|
|
pgn_text = extract_pgn_text(raw)
|
|
|
|
|
if not pgn_text:
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.error("Could not locate PGN text in the file.")
|
2025-08-22 20:07:59 +02:00
|
|
|
sys.exit(2)
|
|
|
|
|
|
|
|
|
|
game = chess.pgn.read_game(io.StringIO(pgn_text))
|
|
|
|
|
if game is None:
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.error("Failed to parse PGN.")
|
2025-08-22 20:07:59 +02:00
|
|
|
sys.exit(3)
|
|
|
|
|
|
|
|
|
|
# Prepare engine
|
|
|
|
|
try:
|
|
|
|
|
engine = chess.engine.SimpleEngine.popen_uci([args.engine])
|
|
|
|
|
except FileNotFoundError:
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.exception(f"Could not launch engine at: {args.engine}")
|
|
|
|
|
logging.exception(
|
|
|
|
|
"Ensure Stockfish is installed and in PATH, or specify with --engine."
|
2025-11-30 13:42:16 +01:00
|
|
|
)
|
2025-08-22 20:07:59 +02:00
|
|
|
sys.exit(4)
|
|
|
|
|
|
2025-08-22 22:30:47 +02:00
|
|
|
# Configure engine performance options if available
|
|
|
|
|
try:
|
|
|
|
|
options = engine.options # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
options = {}
|
|
|
|
|
|
|
|
|
|
# Threads
|
2025-11-30 14:25:35 +01:00
|
|
|
wanted_threads = (
|
|
|
|
|
args.threads if args.threads is not None else (multiprocessing.cpu_count() or 1)
|
|
|
|
|
)
|
2025-08-22 22:30:47 +02:00
|
|
|
# Respect engine bounds if present
|
|
|
|
|
if "Threads" in options:
|
|
|
|
|
try:
|
|
|
|
|
max_thr = getattr(options["Threads"], "max", None)
|
|
|
|
|
min_thr = getattr(options["Threads"], "min", 1)
|
|
|
|
|
if isinstance(max_thr, int):
|
|
|
|
|
wanted_threads = min(wanted_threads, max_thr)
|
|
|
|
|
if isinstance(min_thr, int):
|
|
|
|
|
wanted_threads = max(wanted_threads, min_thr)
|
|
|
|
|
engine.configure({"Threads": int(wanted_threads)})
|
|
|
|
|
except Exception:
|
2025-11-30 21:20:17 +01:00
|
|
|
logging.debug("Failed to configure Threads option")
|
2025-08-22 22:30:47 +02:00
|
|
|
|
2025-11-30 15:09:17 +01:00
|
|
|
# Configure hash table size in MB.
|
2025-08-22 22:30:47 +02:00
|
|
|
if "Hash" in options:
|
|
|
|
|
try:
|
|
|
|
|
if args.hash_mb is not None:
|
|
|
|
|
target_hash = int(args.hash_mb)
|
|
|
|
|
else:
|
|
|
|
|
target_hash = _auto_hash_mb(int(wanted_threads), options)
|
|
|
|
|
# Respect bounds
|
|
|
|
|
max_hash = getattr(options["Hash"], "max", None)
|
|
|
|
|
min_hash = getattr(options["Hash"], "min", 16)
|
|
|
|
|
if isinstance(max_hash, int):
|
|
|
|
|
target_hash = min(target_hash, max_hash)
|
|
|
|
|
if isinstance(min_hash, int):
|
|
|
|
|
target_hash = max(target_hash, min_hash)
|
|
|
|
|
engine.configure({"Hash": int(target_hash)})
|
|
|
|
|
except Exception:
|
2025-11-30 21:20:17 +01:00
|
|
|
logging.debug("Failed to configure Hash option")
|
2025-08-22 22:30:47 +02:00
|
|
|
|
|
|
|
|
# MultiPV
|
|
|
|
|
effective_mpv = max(1, int(args.multipv))
|
|
|
|
|
if "MultiPV" in options:
|
|
|
|
|
try:
|
|
|
|
|
max_mpv = getattr(options["MultiPV"], "max", None)
|
|
|
|
|
if isinstance(max_mpv, int):
|
|
|
|
|
effective_mpv = min(effective_mpv, max_mpv)
|
|
|
|
|
engine.configure({"MultiPV": int(effective_mpv)})
|
|
|
|
|
except Exception:
|
2025-11-30 21:20:17 +01:00
|
|
|
logging.debug("Failed to configure MultiPV option")
|
2025-08-22 22:30:47 +02:00
|
|
|
|
|
|
|
|
# Enable NNUE if the option exists
|
|
|
|
|
for nnue_key in ("Use NNUE", "UseNNUE"):
|
|
|
|
|
if nnue_key in options:
|
2025-11-30 13:59:21 +01:00
|
|
|
with contextlib.suppress(Exception):
|
2025-08-22 22:30:47 +02:00
|
|
|
engine.configure({nnue_key: True})
|
|
|
|
|
|
2025-08-22 20:07:59 +02:00
|
|
|
limit: chess.engine.Limit
|
|
|
|
|
if args.depth is not None:
|
|
|
|
|
limit = chess.engine.Limit(depth=args.depth)
|
|
|
|
|
else:
|
|
|
|
|
limit = chess.engine.Limit(time=max(0.05, args.time))
|
|
|
|
|
|
|
|
|
|
board = game.board()
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.info("Game:")
|
2025-08-22 20:07:59 +02:00
|
|
|
white = game.headers.get("White", "White")
|
|
|
|
|
black = game.headers.get("Black", "Black")
|
|
|
|
|
result = game.headers.get("Result", "*")
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.info(f" {white} vs {black} Result: {result}")
|
|
|
|
|
logging.info("")
|
|
|
|
|
logging.info(
|
2025-11-30 14:25:35 +01:00
|
|
|
"Columns: ply side move played_eval best_eval loss class best_suggestion"
|
|
|
|
|
)
|
2025-08-22 22:30:47 +02:00
|
|
|
# Brief performance summary (best-effort)
|
|
|
|
|
try:
|
|
|
|
|
thr_show = int(wanted_threads)
|
|
|
|
|
except Exception:
|
|
|
|
|
thr_show = 1
|
|
|
|
|
try:
|
2025-11-30 13:42:16 +01:00
|
|
|
hash_show = (
|
2025-11-30 14:25:35 +01:00
|
|
|
int(engine.options.get("Hash").value)
|
|
|
|
|
if hasattr(engine, "options") and engine.options.get("Hash")
|
|
|
|
|
else None
|
2025-11-30 13:42:16 +01:00
|
|
|
)
|
2025-08-22 22:30:47 +02:00
|
|
|
except Exception:
|
|
|
|
|
hash_show = None
|
|
|
|
|
if hash_show is not None:
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.info(
|
2025-11-30 14:25:35 +01:00
|
|
|
f"Using engine options: Threads={thr_show}, "
|
|
|
|
|
f"Hash={hash_show} MB, MultiPV={effective_mpv}"
|
|
|
|
|
)
|
2025-08-22 22:30:47 +02:00
|
|
|
else:
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.info(
|
|
|
|
|
f"Using engine options: Threads={thr_show}, MultiPV={effective_mpv}"
|
|
|
|
|
)
|
2025-08-22 20:07:59 +02:00
|
|
|
|
|
|
|
|
ply = 1
|
|
|
|
|
try:
|
|
|
|
|
node = game
|
2025-08-23 15:16:26 +02:00
|
|
|
|
|
|
|
|
if args.last_move_only:
|
|
|
|
|
# Walk to the last move in the main line and analyze only that ply.
|
|
|
|
|
if not node.variations:
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.warning("No moves found in the game.")
|
2025-08-22 20:07:59 +02:00
|
|
|
else:
|
2025-08-23 15:16:26 +02:00
|
|
|
while node.variations:
|
|
|
|
|
move_node = node.variations[0]
|
|
|
|
|
move = move_node.move
|
|
|
|
|
mover_white = board.turn
|
|
|
|
|
|
|
|
|
|
# If this is the final move in the mainline, analyze it and stop.
|
|
|
|
|
if not move_node.variations:
|
|
|
|
|
# Analyse current position to get engine best move suggestion
|
2025-11-30 14:25:35 +01:00
|
|
|
info_root_raw = engine.analyse(
|
|
|
|
|
board, limit=limit, multipv=effective_mpv
|
|
|
|
|
)
|
|
|
|
|
info_root = (
|
|
|
|
|
info_root_raw[0]
|
|
|
|
|
if isinstance(info_root_raw, list)
|
|
|
|
|
else info_root_raw
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
best_move = None
|
2025-11-30 14:25:35 +01:00
|
|
|
if (
|
|
|
|
|
info_root is not None
|
|
|
|
|
and "pv" in info_root
|
|
|
|
|
and info_root["pv"]
|
|
|
|
|
):
|
2025-08-23 15:16:26 +02:00
|
|
|
best_move = info_root["pv"][0]
|
|
|
|
|
if best_move is None:
|
|
|
|
|
res = engine.play(board, limit)
|
|
|
|
|
best_move = res.move
|
|
|
|
|
|
|
|
|
|
san = board.san(move)
|
|
|
|
|
|
|
|
|
|
# Evaluate played move
|
|
|
|
|
board_played = board.copy()
|
|
|
|
|
board_played.push(move)
|
2025-11-30 14:25:35 +01:00
|
|
|
info_played_raw = engine.analyse(
|
|
|
|
|
board_played, limit=limit, multipv=effective_mpv
|
|
|
|
|
)
|
|
|
|
|
info_played = (
|
|
|
|
|
info_played_raw[0]
|
|
|
|
|
if isinstance(info_played_raw, list)
|
|
|
|
|
else info_played_raw
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
if info_played is None or "score" not in info_played:
|
|
|
|
|
played_cp, played_mate = None, None
|
|
|
|
|
else:
|
2025-11-30 14:25:35 +01:00
|
|
|
played_cp, played_mate = score_to_cp(
|
|
|
|
|
info_played["score"], pov_white=mover_white
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
|
|
|
|
|
# Evaluate best move position (for mover POV)
|
2025-11-30 14:25:35 +01:00
|
|
|
best_san = (
|
|
|
|
|
board.san(best_move) if best_move is not None else "?"
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
if best_move is not None:
|
|
|
|
|
board_best = board.copy()
|
|
|
|
|
board_best.push(best_move)
|
2025-11-30 14:25:35 +01:00
|
|
|
info_best_raw = engine.analyse(
|
|
|
|
|
board_best, limit=limit, multipv=effective_mpv
|
|
|
|
|
)
|
|
|
|
|
info_best = (
|
|
|
|
|
info_best_raw[0]
|
|
|
|
|
if isinstance(info_best_raw, list)
|
|
|
|
|
else info_best_raw
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
if info_best is None or "score" not in info_best:
|
|
|
|
|
best_cp, best_mate = None, None
|
|
|
|
|
else:
|
2025-11-30 14:25:35 +01:00
|
|
|
best_cp, best_mate = score_to_cp(
|
|
|
|
|
info_best["score"], pov_white=mover_white
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
else:
|
|
|
|
|
best_cp, best_mate = None, None
|
|
|
|
|
|
|
|
|
|
# Compute loss/classification
|
2025-11-30 13:42:16 +01:00
|
|
|
cp_loss: int | None = None
|
2025-08-23 15:16:26 +02:00
|
|
|
classification = "Unknown"
|
|
|
|
|
if best_mate is not None or played_mate is not None:
|
|
|
|
|
if best_mate is not None and played_mate is not None:
|
|
|
|
|
if (best_mate > 0) and (played_mate > 0):
|
|
|
|
|
if abs(played_mate) == abs(best_mate):
|
|
|
|
|
classification = "Best"
|
|
|
|
|
elif abs(played_mate) > abs(best_mate):
|
|
|
|
|
classification = "Inaccuracy"
|
|
|
|
|
else:
|
|
|
|
|
classification = "Best"
|
|
|
|
|
elif (best_mate < 0) and (played_mate < 0):
|
|
|
|
|
if abs(played_mate) == abs(best_mate):
|
|
|
|
|
classification = "Best"
|
|
|
|
|
elif abs(played_mate) < abs(best_mate):
|
|
|
|
|
classification = "Blunder"
|
|
|
|
|
else:
|
|
|
|
|
classification = "Good"
|
|
|
|
|
else:
|
|
|
|
|
classification = "Blunder"
|
|
|
|
|
else:
|
|
|
|
|
classification = "Blunder"
|
2025-11-30 13:42:16 +01:00
|
|
|
elif best_cp is not None and played_cp is not None:
|
|
|
|
|
cp_loss = max(0, best_cp - played_cp)
|
|
|
|
|
classification = classify_cp_loss(cp_loss)
|
2025-08-23 15:16:26 +02:00
|
|
|
|
|
|
|
|
side = "W" if mover_white else "B"
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.info(
|
2025-11-30 14:25:35 +01:00
|
|
|
f"{ply:>3} {side} {san:<8} "
|
|
|
|
|
f"{fmt_eval(played_cp, played_mate):>10} "
|
2025-08-23 15:16:26 +02:00
|
|
|
f"{fmt_eval(best_cp, best_mate):>9} "
|
2025-11-30 14:25:35 +01:00
|
|
|
f"{(str(cp_loss) if cp_loss is not None else '—'):>5} "
|
|
|
|
|
f"{classification:<12} {best_san}"
|
2025-08-23 15:16:26 +02:00
|
|
|
)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Advance to keep searching for the last move
|
|
|
|
|
board.push(move)
|
|
|
|
|
node = move_node
|
|
|
|
|
ply += 1
|
|
|
|
|
else:
|
|
|
|
|
# Default behavior: analyze all moves
|
|
|
|
|
while node.variations:
|
|
|
|
|
move_node = node.variations[0]
|
|
|
|
|
move = move_node.move
|
|
|
|
|
mover_white = board.turn
|
|
|
|
|
|
|
|
|
|
# Analyse position to get engine best move suggestion
|
2025-11-30 14:25:35 +01:00
|
|
|
info_root_raw = engine.analyse(
|
|
|
|
|
board, limit=limit, multipv=effective_mpv
|
|
|
|
|
)
|
|
|
|
|
info_root = (
|
|
|
|
|
info_root_raw[0]
|
|
|
|
|
if isinstance(info_root_raw, list)
|
|
|
|
|
else info_root_raw
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
best_move = None
|
|
|
|
|
if info_root is not None and "pv" in info_root and info_root["pv"]:
|
|
|
|
|
best_move = info_root["pv"][0]
|
|
|
|
|
# Fallback to engine.play if PV missing
|
|
|
|
|
if best_move is None:
|
|
|
|
|
res = engine.play(board, limit)
|
|
|
|
|
best_move = res.move
|
|
|
|
|
|
|
|
|
|
# Evaluate played move position (for mover POV) using a temp board
|
|
|
|
|
san = board.san(move)
|
|
|
|
|
board_played = board.copy()
|
|
|
|
|
board_played.push(move)
|
2025-11-30 14:25:35 +01:00
|
|
|
info_played_raw = engine.analyse(
|
|
|
|
|
board_played, limit=limit, multipv=effective_mpv
|
|
|
|
|
)
|
|
|
|
|
info_played = (
|
|
|
|
|
info_played_raw[0]
|
|
|
|
|
if isinstance(info_played_raw, list)
|
|
|
|
|
else info_played_raw
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
if info_played is None or "score" not in info_played:
|
|
|
|
|
played_cp, played_mate = None, None
|
2025-08-22 20:07:59 +02:00
|
|
|
else:
|
2025-11-30 14:25:35 +01:00
|
|
|
played_cp, played_mate = score_to_cp(
|
|
|
|
|
info_played["score"], pov_white=mover_white
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
|
|
|
|
|
# Evaluate best move position (for mover POV)
|
|
|
|
|
best_san = board.san(best_move) if best_move is not None else "?"
|
|
|
|
|
if best_move is not None:
|
|
|
|
|
board_best = board.copy()
|
|
|
|
|
board_best.push(best_move)
|
2025-11-30 14:25:35 +01:00
|
|
|
info_best_raw = engine.analyse(
|
|
|
|
|
board_best, limit=limit, multipv=effective_mpv
|
|
|
|
|
)
|
|
|
|
|
info_best = (
|
|
|
|
|
info_best_raw[0]
|
|
|
|
|
if isinstance(info_best_raw, list)
|
|
|
|
|
else info_best_raw
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
if info_best is None or "score" not in info_best:
|
|
|
|
|
best_cp, best_mate = None, None
|
|
|
|
|
else:
|
2025-11-30 14:25:35 +01:00
|
|
|
best_cp, best_mate = score_to_cp(
|
|
|
|
|
info_best["score"], pov_white=mover_white
|
|
|
|
|
)
|
2025-08-23 15:16:26 +02:00
|
|
|
else:
|
|
|
|
|
best_cp, best_mate = None, None
|
|
|
|
|
|
|
|
|
|
# Compute centipawn loss bands
|
2025-11-30 13:42:16 +01:00
|
|
|
cp_loss: int | None = None
|
2025-08-23 15:16:26 +02:00
|
|
|
classification = "Unknown"
|
|
|
|
|
# Handle mate cases first
|
|
|
|
|
if best_mate is not None or played_mate is not None:
|
|
|
|
|
if best_mate is not None and played_mate is not None:
|
|
|
|
|
# Same sign -> compare speed
|
|
|
|
|
if (best_mate > 0) and (played_mate > 0):
|
2025-11-30 14:25:35 +01:00
|
|
|
# Keeping a mate: equal speed Best;
|
|
|
|
|
# slower -> Inaccuracy; faster -> Best
|
2025-08-23 15:16:26 +02:00
|
|
|
if abs(played_mate) == abs(best_mate):
|
|
|
|
|
classification = "Best"
|
|
|
|
|
elif abs(played_mate) > abs(best_mate):
|
|
|
|
|
classification = "Inaccuracy"
|
|
|
|
|
else:
|
|
|
|
|
classification = "Best"
|
|
|
|
|
elif (best_mate < 0) and (played_mate < 0):
|
2025-11-30 14:25:35 +01:00
|
|
|
# Defending: equal delay Best;
|
|
|
|
|
# sooner mate -> Blunder;
|
2025-11-30 13:59:21 +01:00
|
|
|
# if played delays more -> Good
|
2025-08-23 15:16:26 +02:00
|
|
|
if abs(played_mate) == abs(best_mate):
|
|
|
|
|
classification = "Best"
|
|
|
|
|
elif abs(played_mate) < abs(best_mate):
|
|
|
|
|
classification = "Blunder"
|
|
|
|
|
else:
|
|
|
|
|
classification = "Good"
|
2025-08-22 20:07:59 +02:00
|
|
|
else:
|
2025-08-23 15:16:26 +02:00
|
|
|
# Sign flip across who mates -> Blunder
|
2025-08-22 20:07:59 +02:00
|
|
|
classification = "Blunder"
|
|
|
|
|
else:
|
2025-08-23 15:16:26 +02:00
|
|
|
# Losing a forced mate or missing one
|
2025-08-22 20:07:59 +02:00
|
|
|
classification = "Blunder"
|
2025-11-30 13:42:16 +01:00
|
|
|
elif best_cp is not None and played_cp is not None:
|
|
|
|
|
cp_loss = max(0, best_cp - played_cp)
|
|
|
|
|
classification = classify_cp_loss(cp_loss)
|
2025-08-23 15:16:26 +02:00
|
|
|
|
|
|
|
|
side = "W" if mover_white else "B"
|
2025-11-30 14:36:13 +01:00
|
|
|
logging.info(
|
2025-11-30 14:25:35 +01:00
|
|
|
f"{ply:>3} {side} {san:<8} "
|
|
|
|
|
f"{fmt_eval(played_cp, played_mate):>10} "
|
2025-08-23 15:16:26 +02:00
|
|
|
f"{fmt_eval(best_cp, best_mate):>9} "
|
2025-11-30 14:25:35 +01:00
|
|
|
f"{(str(cp_loss) if cp_loss is not None else '—'):>5} "
|
|
|
|
|
f"{classification:<12} {best_san}"
|
2025-08-23 15:16:26 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
node = move_node
|
|
|
|
|
ply += 1
|
|
|
|
|
# Advance the live board for the next ply
|
|
|
|
|
board.push(move)
|
2025-08-22 20:07:59 +02:00
|
|
|
finally:
|
|
|
|
|
engine.quit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|