testsAndMisc/python_pkg/stockfish_analysis/_move_analysis.py
Krzysztof kuhy Rudnicki 50fd6812d7 refactor: enforce 500-line limit on all Python source files
Split 18+ Python files that exceeded 500 lines into smaller modules
with helper files (prefixed with _). All functions are re-exported
from the original modules to maintain backward compatibility with
test patches and external imports.

Files split:
- moviepy_showcase.py (1212 -> 302 + 3 helpers)
- anki_generator.py (1174 -> 473 + 4 helpers)
- test_analyze_chess_game.py (1152 -> 361 + 2 parts)
- poker_modifier_app.py (1024 -> 263 + 2 helpers)
- transcribe_fw.py (1007 -> 342 + 3 helpers)
- music_generator.py (1002 -> 319 + 2 helpers)
- translator.py (951 -> 442 + 2 helpers)
- cinema_planner.py (893 -> 369 + 2 helpers)
- lichess_bot/main.py (757 -> 495 + _game_logic.py)
- test_translator.py (725 -> 289 + part2 + conftest)
- test_lichess_api.py (680 -> 475 + part2)
- learning_pipe.py (668 -> 375 + 2 helpers)
- cache.py (655 -> 360 + _cache_decks.py)
- analyze_chess_game.py (632 -> 463 + _move_analysis.py)
- visualize_q02.py (609 -> 371 + helper)
- repo_explorer.py (602 -> 347 + 2 helpers)
- keyboard_coop/main.py (515 -> 416 + _dictionary.py)
- scanning.py (501 -> 314 + _enforce_loop.py)

All tests pass: 144 lichess_bot (100% branch coverage), 243 others.
No new lint errors introduced.
2026-03-17 22:47:42 +01:00

189 lines
5.5 KiB
Python

"""Move scoring, classification, and single-move analysis helpers."""
from __future__ import annotations
from dataclasses import dataclass
import chess
import chess.engine
def score_to_cp(
score: chess.engine.PovScore, *, pov_white: bool
) -> tuple[int | None, int | None]:
"""Return tuple (cp, mate_in) from a PovScore for the given POV color.
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.
"""
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
# 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
# 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"),
]
def classify_cp_loss(cp_loss: int | None) -> str:
"""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"
for threshold, classification in _CP_LOSS_BANDS:
if cp_loss <= threshold:
return classification
return "Blunder"
def fmt_eval(cp: int | None, mate_in: int | None) -> str:
"""Format evaluation score as human-readable string."""
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
return f"{cp / 100.0:+.2f}"
@dataclass
class MoveAnalysis:
"""Container for single move analysis results."""
san: str
best_san: str
played_cp: int | None
played_mate: int | None
best_cp: int | None
best_mate: int | None
cp_loss: int | None
classification: str
@dataclass
class AnalysisContext:
"""Container for analysis parameters passed between functions."""
engine: chess.engine.SimpleEngine
limit: chess.engine.Limit
multipv: int
def _get_best_move(
engine: chess.engine.SimpleEngine,
board: chess.Board,
limit: chess.engine.Limit,
multipv: int,
) -> chess.Move | None:
"""Get the engine's best move for a position."""
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
info = info_raw[0] if isinstance(info_raw, list) else info_raw
if info is not None and "pv" in info and info["pv"]:
return info["pv"][0]
res = engine.play(board, limit)
return res.move
def _evaluate_position(
engine: chess.engine.SimpleEngine,
board: chess.Board,
limit: chess.engine.Limit,
multipv: int,
*,
pov_white: bool,
) -> tuple[int | None, int | None]:
"""Evaluate a position and return (cp, mate_in) from POV."""
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
info = info_raw[0] if isinstance(info_raw, list) else info_raw
if info is None or "score" not in info:
return None, None
return score_to_cp(info["score"], pov_white=pov_white)
def _classify_mate_move(best_mate: int | None, played_mate: int | None) -> str:
"""Classify a move when mate scores are involved."""
if best_mate is None or played_mate is None:
return "Blunder"
if (best_mate > 0) and (played_mate > 0):
if abs(played_mate) > abs(best_mate):
return "Inaccuracy"
return "Best"
if (best_mate < 0) and (played_mate < 0):
if abs(played_mate) < abs(best_mate):
return "Blunder"
return "Best" if abs(played_mate) == abs(best_mate) else "Good"
return "Blunder"
def _analyze_single_move(
ctx: AnalysisContext, board: chess.Board, move: chess.Move
) -> MoveAnalysis:
"""Analyze a single move and return analysis data."""
mover_white = board.turn
san = board.san(move)
best_move = _get_best_move(ctx.engine, board, ctx.limit, ctx.multipv)
best_san = board.san(best_move) if best_move is not None else "?"
board_played = board.copy()
board_played.push(move)
played_cp, played_mate = _evaluate_position(
ctx.engine, board_played, ctx.limit, ctx.multipv, pov_white=mover_white
)
if best_move is not None:
board_best = board.copy()
board_best.push(best_move)
best_cp, best_mate = _evaluate_position(
ctx.engine, board_best, ctx.limit, ctx.multipv, pov_white=mover_white
)
else:
best_cp, best_mate = None, None
cp_loss: int | None = None
if best_mate is not None or played_mate is not None:
classification = _classify_mate_move(best_mate, played_mate)
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)
else:
classification = "Unknown"
return MoveAnalysis(
san=san,
best_san=best_san,
played_cp=played_cp,
played_mate=played_mate,
best_cp=best_cp,
best_mate=best_mate,
cp_loss=cp_loss,
classification=classification,
)