Fu01978 commited on
Commit
fa6b852
·
verified ·
1 Parent(s): b7b76fe

Delete chess_analyzer.py

Browse files
Files changed (1) hide show
  1. chess_analyzer.py +0 -715
chess_analyzer.py DELETED
@@ -1,715 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Chess Analysis Tool — Chess.com-style move classification using Stockfish.
4
- """
5
-
6
- import math
7
- import chess
8
- import chess.engine
9
- import chess.pgn
10
- import chess.polyglot
11
- import argparse
12
- import io
13
- import os
14
- import sys
15
- from dataclasses import dataclass, field
16
- from enum import Enum
17
- from typing import Optional, List
18
-
19
-
20
- # ─── Expected-score thresholds (Chess.com model) ─────────────────────────────
21
- # Expected score E = (wins + draws×0.5) / 1000, ranges 0.0–1.0
22
- # These are Chess.com's exact published cutoffs.
23
-
24
- E_EXCELLENT = 0.02 # 0.00–0.02 loss → Excellent
25
- E_GOOD = 0.05 # 0.02–0.05 loss → Good
26
- E_INACCURACY = 0.10 # 0.05–0.10 loss → Inaccuracy
27
- E_MISTAKE = 0.20 # 0.10–0.20 loss → Mistake
28
- E_BLUNDER = 1.00 # 0.20+ loss → Blunder
29
-
30
- ONLY_MOVE_GAP = 100 # cp gap to 2nd best to qualify as "only good move"
31
- MISSED_TACTIC = 0.15 # e_loss above this → Omission candidate
32
- SACRIFICE_NET = 80 # SEE net loss (cp) required to count as a sacrifice
33
- BOOK_MOVES = 2 # first N half-moves per side auto-classified as Book
34
- SEE_MAX_DEPTH = 12 # recursion cap for SEE (avoids slowdowns in busy positions)
35
-
36
- # Special move thresholds
37
- E_GREAT_TURN = 0.10 # e must improve by at least this to be "position-turning"
38
- E_GREAT_TARGET = 0.55 # and must reach at least this after the move
39
- E_BRILLIANT_MIN_AFTER = 0.45 # sac must leave us in an OK position
40
- E_BRILLIANT_MAX_BEFORE = 0.80 # sac only brilliant if we weren't already winning
41
- E_MISS_BEST_TARGET = 0.65 # best move would have been winning
42
- E_MISS_PLAYED_CAP = 0.55 # but played move didn't capitalise
43
-
44
- MULTIPV = 5
45
- DEFAULT_DEPTH = 18
46
- DEFAULT_SF_PATH = "stockfish"
47
-
48
-
49
- # ─── Data structures ─────────────────────────────────────────────────────────
50
-
51
- class Classification(Enum):
52
- BOOK = "📖 Book"
53
- BRILLIANT = "!! Brilliant"
54
- GREAT = "! Great"
55
- BEST = "★ Best"
56
- EXCELLENT = "👍 Excellent"
57
- GOOD = "✓ Good"
58
- INACCURACY = "?! Inaccuracy"
59
- MISTAKE = "? Mistake"
60
- BLUNDER = "?? Blunder"
61
- OMISSION = "✖ Missed Tactic"
62
-
63
-
64
- PIECE_VALUES = {
65
- chess.PAWN: 100,
66
- chess.KNIGHT: 300,
67
- chess.BISHOP: 320,
68
- chess.ROOK: 500,
69
- chess.QUEEN: 900,
70
- chess.KING: 20000,
71
- }
72
-
73
-
74
- @dataclass
75
- class EngineMove:
76
- move: chess.Move
77
- score: chess.engine.Score
78
- cp: float
79
- pv: List[chess.Move] = field(default_factory=list)
80
- expected: Optional[float] = None # WDL-based expected score 0.0–1.0
81
-
82
-
83
- @dataclass
84
- class MoveAnalysis:
85
- move: chess.Move
86
- san: str
87
- classification: Classification
88
- cp_loss: float
89
- eval_before: float
90
- eval_after: float
91
- best_move: Optional[chess.Move]
92
- notes: List[str] = field(default_factory=list)
93
- e_loss: float = 0.0 # expected-score loss (primary severity metric)
94
-
95
-
96
- # ─── Score / WDL helpers ──────────────────────────────────────────────────────
97
-
98
- MATE_CP = 10_000
99
-
100
-
101
- def score_to_cp(score: chess.engine.Score) -> float:
102
- if score.is_mate():
103
- m = score.mate()
104
- return (MATE_CP - abs(m)) * (1 if m > 0 else -1)
105
- v = score.score()
106
- return float(v) if v is not None else 0.0
107
-
108
-
109
- def wdl_to_expected(wdl) -> Optional[float]:
110
- """
111
- Convert a Stockfish WDL tuple (wins, draws, losses), each 0–1000,
112
- to an expected score in [0.0, 1.0] from the side-to-move's perspective.
113
- """
114
- if wdl is None:
115
- return None
116
- try:
117
- w, d, l = wdl
118
- total = w + d + l
119
- return (w + d * 0.5) / total if total else None
120
- except Exception:
121
- return None
122
-
123
-
124
- def cp_to_expected(cp: float) -> float:
125
- """Fallback sigmoid: ±100cp ≈ 0.63/0.37, ±300cp ≈ 0.80/0.20."""
126
- return 1.0 / (1.0 + math.exp(-cp / 320.0))
127
-
128
-
129
- # ─── Static Exchange Evaluation ───────────────────────────────────────────────
130
-
131
- def _least_valuable_attacker(board: chess.Board, square: chess.Square,
132
- color: chess.Color) -> Optional[chess.Square]:
133
- attackers = board.attackers(color, square)
134
- if not attackers:
135
- return None
136
- return min(
137
- attackers,
138
- key=lambda sq: PIECE_VALUES.get(board.piece_at(sq).piece_type, 99999)
139
- if board.piece_at(sq) else 99999
140
- )
141
-
142
-
143
- def see(board: chess.Board, square: chess.Square,
144
- attacker_color: chess.Color, depth: int = 0) -> int:
145
- """
146
- Static Exchange Evaluation on `square` for `attacker_color`.
147
- Returns net material gain (cp): positive = attacker profits, negative = loses.
148
- """
149
- if depth >= SEE_MAX_DEPTH:
150
- return 0
151
-
152
- attacker_sq = _least_valuable_attacker(board, square, attacker_color)
153
- if attacker_sq is None:
154
- return 0
155
-
156
- attacker_piece = board.piece_at(attacker_sq)
157
- target_piece = board.piece_at(square)
158
- if attacker_piece is None:
159
- return 0
160
-
161
- target_val = PIECE_VALUES.get(target_piece.piece_type, 0) if target_piece else 0
162
-
163
- b = board.copy()
164
- capture = chess.Move(attacker_sq, square)
165
- if not b.is_legal(capture):
166
- return 0
167
- b.push(capture)
168
-
169
- opponent_gain = see(b, square, not attacker_color, depth + 1)
170
- return target_val - max(0, opponent_gain)
171
-
172
-
173
- def see_move(board: chess.Board, move: chess.Move) -> int:
174
- """SEE for a specific move. Positive = gain, negative = loss."""
175
- piece = board.piece_at(move.from_square)
176
- captured = board.piece_at(move.to_square)
177
- if piece is None:
178
- return 0
179
-
180
- captured_val = PIECE_VALUES.get(captured.piece_type, 0) if captured else 0
181
-
182
- b = board.copy()
183
- if not b.is_legal(move):
184
- return 0
185
- b.push(move)
186
-
187
- opponent_gain = see(b, move.to_square, b.turn)
188
- return captured_val - max(0, opponent_gain)
189
-
190
-
191
- def is_sacrifice(board: chess.Board, move: chess.Move) -> bool:
192
- """
193
- True if the move results in a verified net material loss >= SACRIFICE_NET.
194
- Excludes promotions and profitable captures.
195
- """
196
- piece = board.piece_at(move.from_square)
197
- if piece is None or move.promotion:
198
- return False
199
- return see_move(board, move) <= -SACRIFICE_NET
200
-
201
-
202
- def is_recapture(board: chess.Board, move: chess.Move,
203
- prev_move: Optional[chess.Move]) -> bool:
204
- """True if the move captures back on the square the opponent just moved to."""
205
- if prev_move is None:
206
- return False
207
- return (board.piece_at(move.to_square) is not None
208
- and move.to_square == prev_move.to_square)
209
-
210
-
211
- # ─── Tactic helpers ──────────────────────────────────────────────────────────
212
-
213
- def creates_fork(board: chess.Board, move: chess.Move) -> bool:
214
- """True if the move attacks 2+ valuable opponent pieces simultaneously."""
215
- b = board.copy()
216
- b.push(move)
217
- attacker = b.piece_at(move.to_square)
218
- if attacker is None:
219
- return False
220
- targets = sum(
221
- 1 for sq in b.attacks(move.to_square)
222
- if b.piece_at(sq)
223
- and b.piece_at(sq).color != attacker.color
224
- and b.piece_at(sq).piece_type in (chess.QUEEN, chess.ROOK,
225
- chess.KNIGHT, chess.BISHOP, chess.KING)
226
- )
227
- return targets >= 2
228
-
229
-
230
- def forced_mate_available(lines: List[EngineMove]) -> Optional[int]:
231
- """Return mate-in count if the engine's best move forces mate, else None."""
232
- if lines and lines[0].score.is_mate():
233
- m = lines[0].score.mate()
234
- return m if m > 0 else None
235
- return None
236
-
237
-
238
- def allows_forced_mate(lines_after: List[EngineMove]) -> Optional[int]:
239
- """
240
- Return opponent's mate-in count if the played move hands them a forced mate.
241
- lines_after[0] is from the OPPONENT's POV: m > 0 means they have forced mate.
242
- """
243
- if lines_after and lines_after[0].score.is_mate():
244
- m = lines_after[0].score.mate()
245
- if m > 0:
246
- return m
247
- return None
248
-
249
-
250
- # ─── PV to SAN ───────────────────────────────────────────────────────────────
251
-
252
- def pv_to_san(board: chess.Board, pv: List[chess.Move], max_moves: int = 5) -> List[str]:
253
- """Convert a PV list to SAN notation strings."""
254
- result = []
255
- b = board.copy()
256
- for move in pv[:max_moves]:
257
- if move not in b.legal_moves:
258
- break
259
- result.append(b.san(move))
260
- b.push(move)
261
- return result
262
-
263
-
264
- def _material_for(board: chess.Board, color: chess.Color) -> int:
265
- """Total piece value for `color` (pawns included, king excluded)."""
266
- return sum(
267
- len(board.pieces(pt, color)) * PIECE_VALUES[pt]
268
- for pt in (chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN)
269
- )
270
-
271
-
272
- def pv_wins_material_back(board: chess.Board, move: chess.Move,
273
- pv: List[chess.Move], max_moves: int = 8) -> bool:
274
- """
275
- True if, after playing `move` followed by the PV continuation, the side
276
- that made the sacrifice has recovered at least as much material as they lost.
277
- Catches "combination sacrifices" — give a piece, win it (or more) back.
278
-
279
- Requires at least 2 PV moves after the sacrifice (the opponent's recapture
280
- + one response) to avoid false positives when the opponent hasn't replied yet.
281
- """
282
- if len(pv) < 2:
283
- return False # not enough continuation to judge
284
-
285
- mover = board.turn
286
- mat_before = _material_for(board, mover)
287
-
288
- b = board.copy()
289
- if move not in b.legal_moves:
290
- return False
291
- b.push(move)
292
-
293
- # Play out the PV continuation (starts with opponent's response)
294
- for m in pv[:max_moves]:
295
- if m not in b.legal_moves:
296
- break
297
- b.push(m)
298
-
299
- mat_after = _material_for(b, mover)
300
- # Won back at least as much as sacrificed (within a pawn of tolerance)
301
- return mat_after >= mat_before - PIECE_VALUES[chess.PAWN]
302
-
303
-
304
- # ─── Core classification ──────────────────────────────────────────────────────
305
-
306
- def classify_move(
307
- board: chess.Board,
308
- move: chess.Move,
309
- lines_before: List[EngineMove],
310
- lines_after: List[EngineMove],
311
- is_book: bool = False,
312
- half_move_idx: int = 99,
313
- prev_move: Optional[chess.Move] = None,
314
- ) -> MoveAnalysis:
315
- san = board.san(move)
316
-
317
- # ── Centipawn evals (kept for notes and CLI output) ───────────────────────
318
- eval_before = lines_before[0].cp if lines_before else 0.0
319
- eval_after = (-lines_after[0].cp) if lines_after else eval_before
320
- cp_loss = eval_before - eval_after
321
- best_move = lines_before[0].move if lines_before else None
322
-
323
- # ── Expected score ────────────────────────────────────────────────────────
324
- # e_before : current player's expected score BEFORE the move (side-to-move POV)
325
- # e_after : current player's expected score AFTER the move
326
- # lines_after[0].expected is from the OPPONENT's POV → flip with 1 - x
327
- e_before = (lines_before[0].expected
328
- if lines_before and lines_before[0].expected is not None
329
- else cp_to_expected(eval_before))
330
- e_after_raw = (lines_after[0].expected
331
- if lines_after and lines_after[0].expected is not None
332
- else None)
333
- e_after = ((1.0 - e_after_raw) if e_after_raw is not None
334
- else cp_to_expected(eval_after))
335
- e_loss = max(0.0, e_before - e_after)
336
-
337
- # ── Auto-Book ─────────────────────────────────────────────────────────────
338
- if is_book or ((half_move_idx // 2) + 1) <= BOOK_MOVES:
339
- return MoveAnalysis(move, san, Classification.BOOK,
340
- 0.0, eval_before, eval_after, best_move,
341
- ["Opening theory"], e_loss=0.0)
342
-
343
- # ── Move rank among engine suggestions ───────────────────────────────────
344
- move_rank: Optional[int] = None
345
- for i, em in enumerate(lines_before):
346
- if em.move == move:
347
- move_rank = i
348
- break
349
-
350
- notes: List[str] = []
351
-
352
- # ── Tactical context ──────────────────────────────────────────────────────
353
- mate_in = forced_mate_available(lines_before)
354
- mate_allowed = allows_forced_mate(lines_after)
355
- move_captures = board.piece_at(move.to_square) is not None
356
- move_see = see_move(board, move)
357
- sacrifice = is_sacrifice(board, move)
358
- recapture = is_recapture(board, move, prev_move)
359
- is_engine_best = (move_rank == 0)
360
-
361
- test_board = board.copy()
362
- test_board.push(move)
363
- gives_checkmate = test_board.is_checkmate()
364
-
365
- # ── Context-aware severity cap ────────────────────────────────────────────
366
- if e_after > 0.70 and not mate_allowed:
367
- max_severity = "inaccuracy" # still clearly winning
368
- elif e_after > 0.55 and not mate_allowed:
369
- max_severity = "mistake" # still has an edge
370
- else:
371
- max_severity = "blunder"
372
-
373
- # Wrong recapture choice caps at Inaccuracy
374
- if recapture and max_severity in ("blunder", "mistake"):
375
- max_severity = "inaccuracy"
376
-
377
- # ── BLUNDER ───────────────────────────────────────────────────────────────
378
- if mate_allowed and not is_engine_best:
379
- notes.append(f"Allows mate in {mate_allowed}")
380
- return MoveAnalysis(move, san, Classification.BLUNDER,
381
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
382
-
383
- if not is_engine_best and max_severity == "blunder" and e_loss >= E_MISTAKE:
384
- if move_captures and move_see >= -50:
385
- pass # fair trade — fall through
386
- elif e_loss >= E_BLUNDER:
387
- notes.append("Hangs material")
388
- return MoveAnalysis(move, san, Classification.BLUNDER,
389
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
390
-
391
- # ── MISTAKE ───────────────────────────────────────────────────────────────
392
- if not is_engine_best and e_loss >= E_MISTAKE and max_severity in ("blunder", "mistake"):
393
- return MoveAnalysis(move, san, Classification.MISTAKE,
394
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
395
-
396
- # ── INACCURACY ────────────────────────────────────────────────────────────
397
- if not is_engine_best and e_loss >= E_INACCURACY and move_rank is not None:
398
- return MoveAnalysis(move, san, Classification.INACCURACY,
399
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
400
-
401
- # ── OMISSION ─────────────────────────────────────────────────────────────
402
- # Case 1: Missed a forced mate
403
- if mate_in and move_rank is not None and move_rank > 0:
404
- notes.append(f"Missed mate in {mate_in}")
405
- return MoveAnalysis(move, san, Classification.OMISSION,
406
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
407
-
408
- # Case 2: Missed a fork
409
- if e_loss >= MISSED_TACTIC and not is_engine_best:
410
- if (lines_before
411
- and creates_fork(board, lines_before[0].move)
412
- and not creates_fork(board, move)):
413
- notes.append("Missed a fork")
414
- return MoveAnalysis(move, san, Classification.OMISSION,
415
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
416
-
417
- # Case 3: Missed opportunity after opponent's mistake
418
- # e_before is the expected score for the current player given optimal play from here.
419
- # If e_before (= what we'd get with the best move) is winning, but e_after (what we
420
- # actually got) is not, we missed the chance to capitalise.
421
- if (not is_engine_best
422
- and move_rank is not None
423
- and e_before >= E_MISS_BEST_TARGET # best move was winning
424
- and e_after <= E_MISS_PLAYED_CAP # played move didn't capitalise
425
- and e_before <= E_MISS_PLAYED_CAP + E_MISS_BEST_TARGET): # not already winning before
426
- notes.append("Missed opportunity to reach a winning position")
427
- return MoveAnalysis(move, san, Classification.OMISSION,
428
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
429
-
430
- # ── Positive classifications ──────────────────────────────────────────────
431
-
432
- if move_rank is None:
433
- return MoveAnalysis(move, san, Classification.GOOD,
434
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
435
-
436
- if move_rank == 0:
437
- if gives_checkmate:
438
- notes.append("Checkmate!")
439
- return MoveAnalysis(move, san, Classification.BEST,
440
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
441
-
442
- # ── BRILLIANT ─────────────────────────────────────────────────────────
443
- if sacrifice:
444
- piece = board.piece_at(move.from_square)
445
- piece_name = chess.piece_name(piece.piece_type) if piece else "piece"
446
-
447
- # Path A: positional sacrifice — material stays down but position is winning
448
- positional_sac = (
449
- e_after >= E_BRILLIANT_MIN_AFTER
450
- and e_before <= E_BRILLIANT_MAX_BEFORE
451
- )
452
-
453
- # Path B: combination sacrifice — material is won back in the engine's PV
454
- combo_sac = (
455
- e_after >= E_BRILLIANT_MIN_AFTER
456
- and lines_before
457
- and pv_wins_material_back(board, move, lines_before[0].pv[1:])
458
- )
459
-
460
- if positional_sac or combo_sac:
461
- label = ("Wins material back in the combination"
462
- if combo_sac and not positional_sac
463
- else f"Sacrifices the {piece_name} for advantage")
464
- notes.append(label)
465
- return MoveAnalysis(move, san, Classification.BRILLIANT,
466
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
467
- notes.append("Piece sacrifice") # conditions not met — just Best
468
-
469
- # ── GREAT ─────────────────────────────────────────────────────────────
470
- if not recapture:
471
- only_good_move = (
472
- len(lines_before) >= 2
473
- and (lines_before[0].cp - lines_before[1].cp) >= ONLY_MOVE_GAP
474
- )
475
- position_turning = (
476
- e_after >= E_GREAT_TARGET
477
- and (e_after - e_before) >= E_GREAT_TURN
478
- and e_before < E_GREAT_TARGET
479
- )
480
- if only_good_move:
481
- gap = int(lines_before[0].cp - lines_before[1].cp)
482
- notes.append(f"Only good move (2nd best is {gap} cp worse)")
483
- return MoveAnalysis(move, san, Classification.GREAT,
484
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
485
- if position_turning:
486
- notes.append("Turns a losing position around"
487
- if e_before < 0.45
488
- else "Turns an equal position into a winning one")
489
- return MoveAnalysis(move, san, Classification.GREAT,
490
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
491
-
492
- return MoveAnalysis(move, san, Classification.BEST,
493
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
494
-
495
- if move_rank in (1, 2):
496
- return MoveAnalysis(move, san, Classification.EXCELLENT,
497
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
498
-
499
- return MoveAnalysis(move, san, Classification.GOOD,
500
- cp_loss, eval_before, eval_after, best_move, notes, e_loss)
501
-
502
-
503
- # ─── Analyzer ─────────────────────────────────────────────────────────────────
504
-
505
- class ChessAnalyzer:
506
- def __init__(self,
507
- stockfish_path: str = DEFAULT_SF_PATH,
508
- depth: int = DEFAULT_DEPTH,
509
- multipv: int = MULTIPV,
510
- time_ms: Optional[int] = None):
511
- self.stockfish_path = stockfish_path
512
- self.depth = depth
513
- self.multipv = multipv
514
- self.time_ms = time_ms
515
- self.engine: Optional[chess.engine.SimpleEngine] = None
516
-
517
- def __enter__(self):
518
- self.engine = chess.engine.SimpleEngine.popen_uci(self.stockfish_path)
519
- try:
520
- self.engine.configure({"UCI_ShowWDL": True})
521
- except Exception:
522
- pass # older Stockfish builds silently skip
523
- return self
524
-
525
- def __exit__(self, *_):
526
- if self.engine:
527
- self.engine.quit()
528
-
529
- def _limit(self) -> chess.engine.Limit:
530
- if self.time_ms is not None:
531
- return chess.engine.Limit(time=self.time_ms / 1000.0)
532
- return chess.engine.Limit(depth=self.depth)
533
-
534
- def _top_moves(self, board: chess.Board,
535
- multipv: Optional[int] = None) -> List[EngineMove]:
536
- n = multipv if multipv is not None else self.multipv
537
- infos = self.engine.analyse(board, self._limit(), multipv=n)
538
- result = []
539
- for info in infos:
540
- if "pv" not in info or not info["pv"]:
541
- continue
542
- pov_score = info["score"].relative
543
- cp = score_to_cp(pov_score)
544
- expected = wdl_to_expected(info.get("wdl")) or cp_to_expected(cp)
545
- result.append(EngineMove(
546
- move=info["pv"][0], score=pov_score, cp=cp,
547
- pv=list(info["pv"]), expected=expected,
548
- ))
549
- return result
550
-
551
- def _is_book(self, board: chess.Board, move: chess.Move,
552
- book_path: Optional[str]) -> bool:
553
- if not book_path or not os.path.exists(book_path):
554
- return False
555
- try:
556
- with chess.polyglot.open_reader(book_path) as reader:
557
- return any(e.move == move for e in reader.find_all(board))
558
- except Exception:
559
- return False
560
-
561
- def analyze_game(self, pgn_string: str,
562
- book_path: Optional[str] = None) -> List[MoveAnalysis]:
563
- game = chess.pgn.read_game(io.StringIO(pgn_string))
564
- if game is None:
565
- raise ValueError("Could not parse PGN.")
566
-
567
- board = game.board()
568
- moves = list(game.mainline_moves())
569
- total = len(moves)
570
-
571
- print(f"\n White: {game.headers.get('White', '?')}")
572
- print(f" Black: {game.headers.get('Black', '?')}")
573
- print(f" Depth: {self.depth} | MultiPV: {self.multipv}\n")
574
-
575
- lines_before = self._top_moves(board)
576
- prev_move: Optional[chess.Move] = None
577
- results: List[MoveAnalysis] = []
578
-
579
- for i, move in enumerate(moves):
580
- move_num = (i // 2) + 1
581
- dot = "." if board.turn == chess.WHITE else "..."
582
- sys.stdout.write(f"\r Analyzing {i+1}/{total} "
583
- f"{move_num}{dot}{board.san(move):<10} ")
584
- sys.stdout.flush()
585
-
586
- book = self._is_book(board, move, book_path)
587
-
588
- board.push(move)
589
- lines_after = self._top_moves(board, multipv=1)
590
- board.pop()
591
-
592
- analysis = classify_move(board, move, lines_before, lines_after,
593
- is_book=book, half_move_idx=i,
594
- prev_move=prev_move)
595
- board.push(move)
596
- results.append(analysis)
597
- prev_move = move
598
-
599
- if i + 1 < total:
600
- lines_before = self._top_moves(board)
601
-
602
- print("\r" + " " * 60)
603
- return results
604
-
605
- def analyze_position(self, fen: str) -> List[EngineMove]:
606
- return self._top_moves(chess.Board(fen))
607
-
608
-
609
- # ─── Reporting ────────────────────────────────────────────────────────────────
610
-
611
- COLORS = {
612
- Classification.BRILLIANT: "\033[96m",
613
- Classification.GREAT: "\033[94m",
614
- Classification.BEST: "\033[92m",
615
- Classification.EXCELLENT: "\033[32m",
616
- Classification.GOOD: "\033[37m",
617
- Classification.BOOK: "\033[90m",
618
- Classification.INACCURACY: "\033[33m",
619
- Classification.MISTAKE: "\033[91m",
620
- Classification.BLUNDER: "\033[31m",
621
- Classification.OMISSION: "\033[35m",
622
- }
623
- RESET = "\033[0m"
624
-
625
-
626
- def print_analysis(analyses: List[MoveAnalysis], pgn_string: str,
627
- white_only: bool = False, black_only: bool = False) -> None:
628
- print("=" * 68)
629
- print(" GAME ANALYSIS")
630
- print("=" * 68)
631
-
632
- for color_label, start_idx in (("White", 0), ("Black", 1)):
633
- player_moves = analyses[start_idx::2]
634
- counts: dict = {}
635
- for a in player_moves:
636
- counts[a.classification] = counts.get(a.classification, 0) + 1
637
- avg_e_loss = (
638
- sum(a.e_loss for a in player_moves
639
- if a.classification not in (Classification.BOOK, Classification.BRILLIANT,
640
- Classification.GREAT, Classification.BEST,
641
- Classification.EXCELLENT))
642
- / max(1, len(player_moves))
643
- )
644
- print(f"\n ── {color_label} ──")
645
- for cls in Classification:
646
- if cls in counts:
647
- print(f" {COLORS.get(cls,'')}{cls.value:<22}{RESET} ×{counts[cls]}")
648
- print(f" Avg expected score loss: {avg_e_loss:.4f}")
649
-
650
- print("\n" + "=" * 68)
651
- print(" MOVE-BY-MOVE")
652
- print("=" * 68)
653
-
654
- for i, a in enumerate(analyses):
655
- if white_only and i % 2 != 0: continue
656
- if black_only and i % 2 != 1: continue
657
- move_num = (i // 2) + 1
658
- dot = "." if i % 2 == 0 else "..."
659
- color = COLORS.get(a.classification, "")
660
- e_str = f" (−{a.e_loss:.3f} EP)" if a.e_loss > 0.005 else ""
661
- note_str = f" — {', '.join(a.notes)}" if a.notes else ""
662
- eval_str = f" [{a.eval_after/100:+.2f}]"
663
- print(f" {move_num:>3}{dot}{a.san:<8} "
664
- f"{color}{a.classification.value:<22}{RESET}"
665
- f"{eval_str}{e_str}{note_str}")
666
-
667
-
668
- def print_position_analysis(moves: List[EngineMove], fen: str) -> None:
669
- board = chess.Board(fen)
670
- turn = "White" if board.turn == chess.WHITE else "Black"
671
- print(f"\n Position: {fen}\n Turn: {turn}\n")
672
- print(f" {'Rank':<6} {'Move':<10} {'Eval':>8} {'E-score':>8}")
673
- print(" " + "-" * 40)
674
- for i, em in enumerate(moves, 1):
675
- san = board.san(em.move)
676
- score_str = f"M{em.score.mate()}" if em.score.is_mate() else f"{em.cp/100:+.2f}"
677
- e_str = f"{em.expected:.3f}" if em.expected is not None else "—"
678
- print(f" {i:<6} {san:<10} {score_str:>8} {e_str:>8}")
679
-
680
-
681
- # ─── CLI ──────────────────────────────────────────────────────────────────────
682
-
683
- def main():
684
- parser = argparse.ArgumentParser(
685
- description="Chess analysis — Chess.com-style move classification")
686
- parser.add_argument("pgn", nargs="?", help="Path to .pgn file")
687
- parser.add_argument("--fen", help="Analyse single position (FEN string)")
688
- parser.add_argument("--stockfish", default=DEFAULT_SF_PATH)
689
- parser.add_argument("--depth", type=int, default=DEFAULT_DEPTH)
690
- parser.add_argument("--multipv", type=int, default=MULTIPV)
691
- parser.add_argument("--book", help="Polyglot opening book (.bin)")
692
- parser.add_argument("--white", action="store_true")
693
- parser.add_argument("--black", action="store_true")
694
- args = parser.parse_args()
695
-
696
- if not args.pgn and not args.fen:
697
- parser.print_help()
698
- sys.exit(1)
699
-
700
- try:
701
- with ChessAnalyzer(args.stockfish, args.depth, args.multipv) as analyzer:
702
- if args.fen:
703
- print_position_analysis(analyzer.analyze_position(args.fen), args.fen)
704
- return
705
- with open(args.pgn) as f:
706
- pgn_string = f.read()
707
- print_analysis(analyzer.analyze_game(pgn_string, args.book),
708
- pgn_string, args.white, args.black)
709
- except FileNotFoundError:
710
- print(f"\n ✗ Stockfish not found at '{args.stockfish}'.")
711
- sys.exit(1)
712
-
713
-
714
- if __name__ == "__main__":
715
- main()