-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathchess_game_analyzer.py
More file actions
5934 lines (5157 loc) · 256 KB
/
chess_game_analyzer.py
File metadata and controls
5934 lines (5157 loc) · 256 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Enhanced Chess Game Analyzer with Positional Evaluation Metrics and Plots
==========================================================================
An extended version of the chess game analyzer that computes detailed positional
evaluation metrics directly from board analysis, including:
- **Space**: Control of territory, particularly in enemy's half of the board
- **Mobility**: Number of legal moves available to pieces (weighted by piece type)
- **King Safety**: Structural protection around the king
- **Pawn Structure**: Doubled pawns, isolated pawns, passed pawns
NEW IN THIS VERSION:
- Move-by-move matplotlib plots of evaluation, space, mobility, and king safety
- Optional ASCII plots for environments without matplotlib/graphics support
- Plots show White (solid) vs Black (dotted) on the same axis
- Game character classification (balanced/tense/tactical/chaotic, one-sided/seesaw)
- Multi-PV analysis: suggests playable alternative moves (within 50cp of best)
- Improved mistake annotations: "Consider instead: Nf3, Bg5." format
This module uses python-chess for direct board analysis to compute interpretable
positional metrics, while using Stockfish only for overall centipawn evaluation.
Ideal for creating educational chess content with detailed position assessment.
How the Code Computes These Metrics
------------------------------------
### Space
Space evaluation counts squares in the center (files c-f, ranks 2-4 for
White, ranks 5-7 for Black) that are:
1. Attacked by at least one friendly pawn, AND
2. Not occupied by any enemy piece
The computation is weighted by the number of pieces behind the pawn chain. This
rewards positions where you control territory AND have pieces positioned to use
that space. The space term is scaled based on total piece count - it matters more
in closed positions with many pieces.
### Mobility
Mobility counts legal moves for each piece type, with weights:
- Knights: Safe squares attacked (excl. squares attacked by enemy pawns)
- Bishops: Safe squares on diagonals (bonus for long diagonals)
- Rooks: Squares on files/ranks (bonus for open files, 7th rank)
- Queens: Combination of bishop + rook mobility
"Safe" squares exclude those defended by enemy pawns or those that would allow
piece exchange with material loss. Mobility in the ENEMY's territory is valued
higher than mobility in your own territory.
### King Safety
A combination of:
1. Pawn shield strength (pawns on 2nd/3rd rank in front of king)
2. King tropism (distance of enemy pieces to your king)
3. Attack units (enemy pieces attacking squares near your king)
4. Safe checks available to opponent
### Threats
Evaluates tactical tension (computed directly from board position):
- Hanging pieces (pieces attacked but not defended) - weighted by piece value × 2
- Pieces attacked by lower-value pieces (e.g., queen attacked by knight)
- Attacks on squares near the enemy king (king zone pressure)
- Safe checks available to each side
- Weak squares (holes) in pawn structure that cannot be defended by pawns
- Available checking moves for the side to move
Usage:
from chess_game_analyzer import (
analyze_game_with_positional_metrics,
PositionalEvaluation,
EnhancedMoveAnalysis,
analyze_games_to_book,
classify_game_character
)
# Get analysis with full positional breakdown and plots
game_pgn = "../wdj-games/james-rizzitano-vs-wdj-sussex-open-2026-01-10.pgn"
report_output = "../wdj-games/james-rizzitano-vs-wdj-sussex-open-2026-01-10-analysis.tex"
result = analyze_game_with_positional_metrics(
pgn_source=game_pgn,
output_path=report_output,
stockfish_path = "/usr/local/bin/stockfish", # modify as needed
include_plots=True, # matplotlib plots
include_ascii_plots=False, # ASCII verbatim plots
depth=22,
time_limit=5.0, # longer for complex games
plot_output_dir = "../wdj-games/plots/"
)
for move in result.moves:
pos = move.positional_eval
print(f"Move {move.ply}: Space W={pos.space_white:.2f} B={pos.space_black:.2f}")
print(f" Mobility W={pos.mobility_white:.2f} B={pos.mobility_black:.2f}")
# Access game character classification
gc = result.game_character
print(f"Game character: {gc['spread_class']} ({gc['direction_class']})")
print(f" m1={gc['m1']:.2f}, m2={gc['m2']:.2f}, spread={gc['spread']:.2f}")
>>> from chess_game_analyzer import (
... analyze_game_with_positional_metrics,
... PositionalEvaluation,
... EnhancedMoveAnalysis,
... analyze_games_to_book,
classify_game_character
... )
>>> game_pgn = "../wdj-games/Joyner-vs-Goodson_2023-05-16.pgn"
>>> report_output = "../wdj-games/Joyner-vs-Goodson_2023-05-16-analysis.tex"
>>> result = analyze_game_with_positional_metrics(pgn_source=game_pgn,output_path=report_output,include_plots=True,include_ascii_plots=False, plot_output_dir = "../wdj-games/plots/")
NEW IN VERSION 6q:
- Raw positional data preserved after \\end{document} in machine-readable format
- Data includes: ply, SAN, eval_cp, space_w/b, mobility_w/b, king_safety_w/b, threats_w/b
- New utility functions: parse_raw_positional_data(), compute_fireteam_index()
- Enables downstream analysis like the "Fireteam Index" for win prediction
NEW IN VERSION 6r:
- Win prediction algorithms: predict_outcome_per_ply() and predict_outcome_windowed()
- Optional Fireteam Index prediction section in LaTeX reports (include_prediction=True)
- Fireteam Index plots (per-ply and smoothed versions) in reports
- Renamed berliner_color to player_color with optional player_name parameter
- Configurable weights, cutoff, margin, streak length, and window size
- BUG FIX: eval_loss calculation now uses proper sign convention for Black moves
- BUG FIX: eval_loss capped at MAX_EVAL_LOSS_FOR_ACCURACY (1500cp) to prevent
mate score transitions from producing absurd values (8000+ cp) that distort
accuracy statistics. Previously, games with missed mates could show <20% accuracy
even for strong play, because a single "lose mate" move counted as 9000+ cp loss.
NEW IN VERSION 6v:
- BUG FIX: Added \\usepackage{amsmath} to LaTeX preamble (required for \\text{})
- NEW: Three FTI variants with different weight "signatures":
- FTI1: Harmonious (0.25, 0.25, 0.25, 0.25) - equal weights (with T pre-scaled)
- FTI2: Tactical (0.6, 0.1, 0.0, 0.3) - Space + Threats + some Mobility
- FTI3: Strategic (0.7, 0.1, 0.1, 0.1) - Space + Mobility + some K/T
- FTI weights can be thought of as "signatures" capturing different playing styles
- All three FTI variants tracked in book prediction summaries
- Updated methodology section (A.7) with all three formulas and footnotes
- New constants: FTI1_WEIGHTS, FTI2_WEIGHTS, FTI3_WEIGHTS
NEW IN VERSION 6v:
- RENAMED: FTI1 from "Balanced" to "Harmonious" (less confusing with Balance algorithm)
- RESCALED: Threats (T) is now normalized by dividing raw value by THREATS_SCALE_FACTOR (20)
This allows FTI1 to use true equal weights (1/4, 1/4, 1/4, 1/4) on comparable metrics
- UPDATED FTI WEIGHTS:
- FTI1 (Harmonious): (0.25, 0.25, 0.25, 0.25) - equal weights with T pre-scaled
- FTI2 (Tactical): (0.6, 0.1, 0.0, 0.3) - adds some Mobility to Space+Threats
- FTI3 (Strategic): (0.7, 0.1, 0.1, 0.1) - adds KingSafety+Threats to Space+Mobility
- IMPROVED Balance algorithm:
- Volatility threshold now scales with game length: V_threshold = V_RATIO * total_plies
- Default V_RATIO = 0.20 (20% of game length)
- New confidence-based prediction using: confidence = |B_n| * (1 - V/max_V)
- Predicts WIN/LOSS if confidence > CONFIDENCE_THRESHOLD (default 0.15)
- New constants: THREATS_SCALE_FACTOR, V_RATIO, CONFIDENCE_THRESHOLD
Author: Generated for David Joyner's chess analysis pipeline, 2026-01-27
distribution license: either modified BSD or MIT license, user's choice.
"""
import chess
import chess.pgn
import chess.engine
import io
import os
import re
import subprocess
import time
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Tuple, Union
from pathlib import Path
# Import plotting utilities
try:
from chess_plotting import ChessPlotter, is_matplotlib_available
PLOTTING_AVAILABLE = True
except ImportError:
PLOTTING_AVAILABLE = False
def is_matplotlib_available():
return False
# =============================================================================
# PIECE VALUES (centipawns)
# =============================================================================
PIECE_VALUES = {
chess.PAWN: 100,
chess.KNIGHT: 320,
chess.BISHOP: 330,
chess.ROOK: 500,
chess.QUEEN: 900,
chess.KING: 0
}
# Threshold (in centipawns) for considering alternative moves as "playable"
# Moves within this threshold of the best move will be suggested as alternatives
PLAYABLE_THRESHOLD = 50
# Maximum eval_loss to count towards accuracy calculations (in centipawns)
# This prevents mate score transitions from producing absurd values (8000+ cp)
# that would completely distort accuracy statistics. A cap of 1500cp (15 pawns)
# still represents a catastrophic blunder but won't ruin the entire game's stats.
MAX_EVAL_LOSS_FOR_ACCURACY = 1500
# Fireteam Index weight configurations: (Space, Mobility, KingSafety, Threats)
# These can be thought of as "signatures" capturing different playing styles.
# Note: Threats values are scaled by THREATS_SCALE_FACTOR before weighting.
# Scaling factor for Threats metric to bring it into comparable range with other metrics
# Raw Threats values are typically 5-20, while Space/Mobility/KingSafety are 0.1-0.5
# Dividing by 20 normalizes Threats to a similar scale
THREATS_SCALE_FACTOR = 20.0
# FTI1: Harmonious - equal weights across all four factors
# Baseline signature with all positional factors contributing equally
FTI1_WEIGHTS = (0.25, 0.25, 0.25, 0.25)
# FTI2: Tactical - Space and Threats emphasis for active pressure
# Captures a style that creates pressure through territory and concrete threats
FTI2_WEIGHTS = (0.6, 0.1, 0.0, 0.3)
# FTI3: Strategic - Space-dominant for long-term positional control
# Captures a style focused on territorial domination
FTI3_WEIGHTS = (0.7, 0.1, 0.1, 0.1)
# FTI4: Dynamic - Mobility-dominant for piece activity and maneuvering
# Captures a fluid style emphasizing piece play over static advantages
FTI4_WEIGHTS = (0.2, 0.6, 0.0, 0.2)
# FTI5: Solid - King Safety emphasis for defensive, prophylactic play
# Captures a style focused on security and preventing opponent's play
FTI5_WEIGHTS = (0.3, 0.2, 0.5, 0.0)
# Balance algorithm parameters
# Volatility ratio: V_threshold = V_RATIO * total_plies
# A 100-ply game with V_RATIO=0.20 has V_threshold=20
V_RATIO = 0.20
# Confidence threshold for predicting WIN/LOSS
# confidence = |B_n| * (1 - V/V_threshold)
# If confidence > CONFIDENCE_THRESHOLD, predict WIN/LOSS; else DRAW
CONFIDENCE_THRESHOLD = 0.15
# Epsilon threshold for FTI: the minimum FTI value to count as "meaningful advantage"
# Used in streak detection and balance calculations
FTI_EPSILON = 0.2
def parse_elo(elo_str: str) -> Optional[int]:
"""
Safely parse an ELO rating from a PGN header value.
Handles common non-numeric values like "?", "*", "", "-", "N/A", etc.
Returns None if the value cannot be parsed as a valid ELO rating.
"""
if not elo_str:
return None
elo_str = elo_str.strip()
if not elo_str or elo_str in ("?", "*", "-", "N/A", "n/a", "unknown", "Unknown"):
return None
try:
elo = int(elo_str)
# Sanity check: valid ELO ratings are typically between 100 and 4000
if 100 <= elo <= 4000:
return elo
elif elo == 0:
return None # 0 often means "unknown"
else:
return elo # Return anyway if outside typical range but parseable
except ValueError:
return None
# =============================================================================
# GAME CHARACTER CLASSIFICATION
# =============================================================================
def classify_game_character(moves: List) -> Dict[str, any]:
"""
Classify the overall character of a game based on evaluation swings.
Let m2 = maximum evaluation for White (in pawns)
Let m1 = minimum evaluation for White (in pawns)
Let d = m2 - m1 (the evaluation spread)
Spread classifications:
- balanced: d < 1 (minimal advantage shifts)
- tense: 1 <= d < 3 (normal competitive tension)
- tactical: 3 <= d < 6 (significant swings, tactical complications)
- chaotic: d >= 6 (wild swings, likely blunders or speculative play)
Directionality classifications:
- one-sided: m1 > -0.5 OR m2 < 0.5 (advantage never truly changed hands)
- seesaw: m1 < -1 AND m2 > 1 (advantage genuinely swung both ways)
Args:
moves: List of EnhancedMoveAnalysis objects
Returns:
Dictionary with:
- m1: minimum evaluation (pawns)
- m2: maximum evaluation (pawns)
- spread: m2 - m1
- spread_class: 'balanced', 'tense', 'tactical', or 'chaotic'
- direction_class: 'one-sided', 'seesaw', or 'normal'
- combined_description: human-readable summary
"""
if not moves:
return {
'm1': 0.0, 'm2': 0.0, 'spread': 0.0,
'spread_class': 'balanced',
'direction_class': 'normal',
'combined_description': 'No moves to analyze'
}
# Extract evaluations in pawns (eval_after is in centipawns)
evals = []
for m in moves:
# Cap extreme evaluations (mate scores) at ±15 pawns for classification
eval_pawns = m.eval_after / 100.0
if abs(eval_pawns) > 15:
eval_pawns = 15.0 if eval_pawns > 0 else -15.0
evals.append(eval_pawns)
m1 = min(evals) # minimum (most favorable for Black)
m2 = max(evals) # maximum (most favorable for White)
d = m2 - m1 # spread
# Spread classification
if d < 1:
spread_class = 'balanced'
elif d < 3:
spread_class = 'tense'
elif d < 6:
spread_class = 'tactical'
else:
spread_class = 'chaotic'
# Directionality classification
if m1 > -0.5 or m2 < 0.5:
direction_class = 'one-sided'
elif m1 < -1 and m2 > 1:
direction_class = 'seesaw'
else:
direction_class = 'normal'
# Generate combined description
if direction_class == 'one-sided':
if m1 > -0.5:
side_desc = "White maintained the advantage throughout"
else:
side_desc = "Black maintained the advantage throughout"
combined = f"A {spread_class}, one-sided game. {side_desc}."
elif direction_class == 'seesaw':
combined = f"A {spread_class} seesaw battle with the advantage changing hands."
else:
combined = f"A {spread_class} game with moderate swings."
return {
'm1': m1,
'm2': m2,
'spread': d,
'spread_class': spread_class,
'direction_class': direction_class,
'combined_description': combined
}
# =============================================================================
# POSITIONAL EVALUATION DATA CLASSES
# =============================================================================
@dataclass
class PositionalEvaluation:
"""
Detailed positional evaluation breakdown from Stockfish's classical eval.
All values are in pawns (not centipawns) for readability.
MG = Middlegame, EG = Endgame weights.
Stockfish blends MG and EG based on remaining material (phase).
"""
# Material and imbalance
material_mg: float = 0.0
material_eg: float = 0.0
imbalance_mg: float = 0.0
imbalance_eg: float = 0.0
# Piece-specific terms (these include placement bonuses)
pawns_white_mg: float = 0.0
pawns_white_eg: float = 0.0
pawns_black_mg: float = 0.0
pawns_black_eg: float = 0.0
knights_white_mg: float = 0.0
knights_white_eg: float = 0.0
knights_black_mg: float = 0.0
knights_black_eg: float = 0.0
bishops_white_mg: float = 0.0
bishops_white_eg: float = 0.0
bishops_black_mg: float = 0.0
bishops_black_eg: float = 0.0
rooks_white_mg: float = 0.0
rooks_white_eg: float = 0.0
rooks_black_mg: float = 0.0
rooks_black_eg: float = 0.0
queens_white_mg: float = 0.0
queens_white_eg: float = 0.0
queens_black_mg: float = 0.0
queens_black_eg: float = 0.0
# Key strategic terms (per-side)
mobility_white_mg: float = 0.0
mobility_white_eg: float = 0.0
mobility_black_mg: float = 0.0
mobility_black_eg: float = 0.0
king_safety_white_mg: float = 0.0
king_safety_white_eg: float = 0.0
king_safety_black_mg: float = 0.0
king_safety_black_eg: float = 0.0
threats_white_mg: float = 0.0
threats_white_eg: float = 0.0
threats_black_mg: float = 0.0
threats_black_eg: float = 0.0
passed_white_mg: float = 0.0
passed_white_eg: float = 0.0
passed_black_mg: float = 0.0
passed_black_eg: float = 0.0
space_white_mg: float = 0.0
space_white_eg: float = 0.0 # Usually 0, space matters in MG
space_black_mg: float = 0.0
space_black_eg: float = 0.0
# Winnable term (adjustment factor)
winnable_mg: float = 0.0
winnable_eg: float = 0.0
# Totals
total_white_mg: float = 0.0
total_white_eg: float = 0.0
total_black_mg: float = 0.0
total_black_eg: float = 0.0
total_mg: float = 0.0
total_eg: float = 0.0
# Final evaluations
classical_eval: float = 0.0
nnue_eval: float = 0.0
final_eval: float = 0.0
# Computed summary metrics (convenience)
@property
def space_white(self) -> float:
"""White's space advantage (MG, since EG is usually 0)."""
return self.space_white_mg
@property
def space_black(self) -> float:
"""Black's space control."""
return self.space_black_mg
@property
def space_advantage(self) -> float:
"""Net space advantage (positive = White)."""
return self.space_white_mg - self.space_black_mg
@property
def mobility_white(self) -> float:
"""White's mobility (average of MG and EG)."""
return (self.mobility_white_mg + self.mobility_white_eg) / 2
@property
def mobility_black(self) -> float:
"""Black's mobility."""
return (self.mobility_black_mg + self.mobility_black_eg) / 2
@property
def mobility_advantage(self) -> float:
"""Net mobility advantage (positive = White)."""
return self.mobility_white - self.mobility_black
@property
def king_safety_white(self) -> float:
"""White's king safety (MG more relevant)."""
return self.king_safety_white_mg
@property
def king_safety_black(self) -> float:
"""Black's king safety."""
return self.king_safety_black_mg
@property
def threats_white(self) -> float:
"""White's threat level (MG more relevant for active threats)."""
return self.threats_white_mg
@property
def threats_black(self) -> float:
"""Black's threat level."""
return self.threats_black_mg
@property
def threats_advantage(self) -> float:
"""Net threats advantage (positive = White has more threats)."""
return self.threats_white_mg - self.threats_black_mg
@dataclass
class EnhancedMoveAnalysis:
"""Analysis of a single move with positional breakdown."""
ply: int
move_san: str
move_uci: str
is_white_move: bool
eval_before: float
eval_after: float
best_move_san: str
best_move_uci: str
best_eval: float
eval_loss: float
classification: str
is_capture: bool
is_check: bool
material_balance: int
fen_after: str
pv_line: List[str]
# Enhanced: Positional evaluation after this move
positional_eval: Optional[PositionalEvaluation] = None
# Alternative moves: list of (san, eval_cp) tuples for playable alternatives
# Only includes moves within PLAYABLE_THRESHOLD of the best move
alternative_moves: List[Tuple[str, float]] = field(default_factory=list)
@dataclass
class BrilliantSacrifice:
"""Details of a detected brilliant sacrifice."""
ply: int
move_san: str
player: str
piece_type: str
material_lost: int
eval_before: float
eval_after: float
eval_improvement: float
is_sound: bool
@dataclass
class CriticalPosition:
"""A critical position worth showing a diagram for."""
ply: int
fen: str
move_san: str
eval_score: float
reason: str
best_continuation: List[str]
positional_eval: Optional[PositionalEvaluation] = None
is_biggest_swing: bool = False # True if this is a top-N biggest evaluation swing
eval_swing: float = 0.0 # Magnitude of the evaluation change
alternative_moves: List[Tuple[str, float]] = field(default_factory=list)
best_move_san: str = "" # The best move instead of the played move
@dataclass
class EnhancedGameAnalysisResult:
"""Complete analysis result with positional metrics."""
# Game metadata
white: str
black: str
white_elo: Optional[int]
black_elo: Optional[int]
result: str
date: str
event: str
site: str
round_num: str
opening_eco: str
opening_name: str
# Analysis data
moves: List[EnhancedMoveAnalysis]
brilliant_sacrifices: List[BrilliantSacrifice]
critical_positions: List[CriticalPosition]
# Statistics
white_stats: Dict
black_stats: Dict
# Positional summaries
positional_summary: Dict = field(default_factory=dict)
# Game character classification
game_character: Dict = field(default_factory=dict)
# Metadata
analysis_depth: int = 20
analysis_time: float = 0.0
engine_version: str = "Stockfish"
def compute_threats(board):
"""
Compute threat metrics for both sides.
Returns (white_threats, black_threats) as a tuple of scores.
Threats include:
- Hanging pieces (attacked and undefended)
- Pieces attacked by lower-value pieces
- Attacks on squares near enemy king
- Safe checks available
- Weak squares in pawn structure (holes)
"""
white_threats = 0
black_threats = 0
piece_values = {
chess.PAWN: 1,
chess.KNIGHT: 3,
chess.BISHOP: 3,
chess.ROOK: 5,
chess.QUEEN: 9,
chess.KING: 0 # Don't count king threats
}
# 1. Hanging pieces and pieces attacked by lower-value pieces
for square in chess.SQUARES:
piece = board.piece_at(square)
if piece is None or piece.piece_type == chess.KING:
continue
# Get attackers and defenders
attackers = board.attackers(not piece.color, square)
defenders = board.attackers(piece.color, square)
# Hanging piece (attacked but not defended)
if attackers and not defenders:
if piece.color == chess.WHITE:
black_threats += piece_values[piece.piece_type] * 2
else:
white_threats += piece_values[piece.piece_type] * 2
# Attacked by lower value piece
elif attackers:
min_attacker_value = min(
piece_values[board.piece_at(sq).piece_type]
for sq in attackers
)
if min_attacker_value < piece_values[piece.piece_type]:
if piece.color == chess.WHITE:
black_threats += piece_values[piece.piece_type] - min_attacker_value
else:
white_threats += piece_values[piece.piece_type] - min_attacker_value
# 2. Attacks near kings (king zone pressure)
for color in [chess.WHITE, chess.BLACK]:
king_square = board.king(color)
if king_square is None:
continue
# Squares around the king
king_zone = []
king_file = chess.square_file(king_square)
king_rank = chess.square_rank(king_square)
for df in [-1, 0, 1]:
for dr in [-1, 0, 1]:
f, r = king_file + df, king_rank + dr
if 0 <= f <= 7 and 0 <= r <= 7:
king_zone.append(chess.square(f, r))
# Count enemy attacks on king zone
enemy_attacks = sum(
1 for sq in king_zone
if board.attackers(not color, sq)
)
if color == chess.WHITE:
black_threats += enemy_attacks
else:
white_threats += enemy_attacks
# 3. Safe checks available
for color in [chess.WHITE, chess.BLACK]:
enemy_king_sq = board.king(not color)
if enemy_king_sq is None:
continue
safe_checks = 0
# Find all squares that would give check
for piece_type in [chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]:
# Get squares from which this piece type could attack the enemy king
if piece_type == chess.KNIGHT:
check_squares = chess.SquareSet(chess.BB_KNIGHT_ATTACKS[enemy_king_sq])
elif piece_type == chess.BISHOP:
check_squares = board.attacks_mask(enemy_king_sq) & chess.BB_DIAG_ATTACKS[enemy_king_sq][0]
# Use bishop attacks from enemy king square
check_squares = chess.SquareSet(chess.BB_DIAG_MASKS[enemy_king_sq])
elif piece_type == chess.ROOK:
check_squares = chess.SquareSet(chess.BB_FILE_MASKS[enemy_king_sq] | chess.BB_RANK_MASKS[enemy_king_sq])
else: # QUEEN
check_squares = chess.SquareSet(
chess.BB_DIAG_MASKS[enemy_king_sq] |
chess.BB_FILE_MASKS[enemy_king_sq] |
chess.BB_RANK_MASKS[enemy_king_sq]
)
for sq in check_squares:
piece = board.piece_at(sq)
if piece and piece.color == color and piece.piece_type == piece_type:
# This piece could potentially give check - check if square is safe
if not board.is_attacked_by(not color, sq):
# Verify it actually gives check (considering blockers)
if board.is_attacked_by(color, enemy_king_sq):
safe_checks += 1
if color == chess.WHITE:
white_threats += safe_checks * 0.5
else:
black_threats += safe_checks * 0.5
# 4. Weak squares (holes in pawn structure)
# A hole is a square that cannot be defended by pawns
for color in [chess.WHITE, chess.BLACK]:
enemy_color = not color
# Check central and near-central squares for holes
if color == chess.WHITE:
# White looks for holes in Black's position (ranks 5-7)
target_ranks = [4, 5, 6]
else:
# Black looks for holes in White's position (ranks 2-4, i.e., indices 1-3)
target_ranks = [1, 2, 3]
holes = 0
for rank in target_ranks:
for file in range(8):
sq = chess.square(file, rank)
# Check if any enemy pawn can ever defend this square
can_be_defended = False
# For a square to be defendable by a pawn, there must be a pawn
# on an adjacent file that can advance to defend it
for adj_file in [file - 1, file + 1]:
if 0 <= adj_file <= 7:
# Check if there's an enemy pawn that could defend
if enemy_color == chess.WHITE:
# White pawns defend by moving up
for pawn_rank in range(rank):
pawn_sq = chess.square(adj_file, pawn_rank)
p = board.piece_at(pawn_sq)
if p and p.piece_type == chess.PAWN and p.color == enemy_color:
can_be_defended = True
break
else:
# Black pawns defend by moving down
for pawn_rank in range(rank + 1, 8):
pawn_sq = chess.square(adj_file, pawn_rank)
p = board.piece_at(pawn_sq)
if p and p.piece_type == chess.PAWN and p.color == enemy_color:
can_be_defended = True
break
if can_be_defended:
break
if not can_be_defended:
# This is a hole - bonus if we control it with a piece
if board.is_attacked_by(color, sq):
holes += 0.3
else:
holes += 0.1
if color == chess.WHITE:
white_threats += holes
else:
black_threats += holes
# 5. Check threats (if side to move can give check)
if board.turn == chess.WHITE:
# Count checking moves available to White
check_moves = sum(1 for move in board.legal_moves if board.gives_check(move))
white_threats += check_moves * 0.3
else:
# Count checking moves available to Black
check_moves = sum(1 for move in board.legal_moves if board.gives_check(move))
black_threats += check_moves * 0.3
return (white_threats, black_threats)
# =============================================================================
# STOCKFISH EVAL PARSER
# =============================================================================
class StockfishEvalParser:
"""
Parses Stockfish's `eval` command output to extract positional metrics.
The `eval` command outputs a table showing contribution of each evaluation
term for both White and Black in both middlegame (MG) and endgame (EG).
Note: Different Stockfish versions may have slightly different output formats.
This parser attempts to handle variations, but if you get all zeros, your
Stockfish build may not output the classical eval table.
"""
# Debug flag - set to True to print parsing details
DEBUG = False
@staticmethod
def parse_eval_output(eval_text: str, debug: bool = False) -> PositionalEvaluation:
"""
Parse the text output from Stockfish's eval command.
Args:
eval_text: Raw text from `eval` command
debug: If True, print parsing details for troubleshooting
Returns:
PositionalEvaluation with all extracted metrics
"""
debug = debug or StockfishEvalParser.DEBUG
pos_eval = PositionalEvaluation()
if debug:
print(f"[DEBUG] Parsing eval output ({len(eval_text)} chars)")
print(f"[DEBUG] First 500 chars:\n{eval_text[:500]}")
# Check if this looks like valid Stockfish eval output
if 'Contributing terms' not in eval_text and 'classical eval' not in eval_text.lower():
if debug:
print("[DEBUG] WARNING: Output doesn't appear to contain classical eval table")
# Return empty evaluation - Stockfish may be NNUE-only build
return pos_eval
# Multiple regex patterns to handle different Stockfish versions
# Pattern 1: Standard format with fixed-width columns
# | Space | 0.34 0.00 | 0.30 0.00 | 0.04 0.00 |
patterns = [
# Primary pattern - handles most cases
re.compile(
r'\|\s*([A-Za-z][A-Za-z ]*?)\s*\|' # Term name
r'\s*(-?[\d.]+|----)\s+(-?[\d.]+|----)\s*\|' # White MG, EG
r'\s*(-?[\d.]+|----)\s+(-?[\d.]+|----)\s*\|' # Black MG, EG
r'\s*(-?[\d.]+|----)\s+(-?[\d.]+|----)\s*\|' # Total MG, EG
),
# Alternative pattern with looser spacing
re.compile(
r'\|\s*(\w+(?:\s+\w+)?)\s*\|' # Term name
r'\s*([\d.\-]+|----)\s+([\d.\-]+|----)\s*\|' # White MG, EG
r'\s*([\d.\-]+|----)\s+([\d.\-]+|----)\s*\|' # Black MG, EG
r'\s*([\d.\-]+|----)\s+([\d.\-]+|----)\s*\|' # Total MG, EG
),
]
# Term name normalization map
term_aliases = {
'king safety': 'king_safety',
'kingsafety': 'king_safety',
'king': 'king_safety', # Some versions abbreviate
}
matched_terms = set()
for line in eval_text.split('\n'):
for pattern in patterns:
match = pattern.search(line)
if match:
raw_term = match.group(1).strip().lower()
term = term_aliases.get(raw_term, raw_term.replace(' ', '_'))
w_mg = StockfishEvalParser._parse_value(match.group(2))
w_eg = StockfishEvalParser._parse_value(match.group(3))
b_mg = StockfishEvalParser._parse_value(match.group(4))
b_eg = StockfishEvalParser._parse_value(match.group(5))
t_mg = StockfishEvalParser._parse_value(match.group(6))
t_eg = StockfishEvalParser._parse_value(match.group(7))
if debug:
print(f"[DEBUG] Matched term '{raw_term}' -> '{term}': "
f"W({w_mg}, {w_eg}) B({b_mg}, {b_eg}) T({t_mg}, {t_eg})")
matched_terms.add(term)
# Map terms to attributes
if term == 'material':
pos_eval.material_mg = t_mg
pos_eval.material_eg = t_eg
elif term == 'imbalance':
pos_eval.imbalance_mg = t_mg
pos_eval.imbalance_eg = t_eg
elif term == 'pawns':
pos_eval.pawns_white_mg = w_mg
pos_eval.pawns_white_eg = w_eg
pos_eval.pawns_black_mg = b_mg
pos_eval.pawns_black_eg = b_eg
elif term == 'knights':
pos_eval.knights_white_mg = w_mg
pos_eval.knights_white_eg = w_eg
pos_eval.knights_black_mg = b_mg
pos_eval.knights_black_eg = b_eg
elif term == 'bishops':
pos_eval.bishops_white_mg = w_mg
pos_eval.bishops_white_eg = w_eg
pos_eval.bishops_black_mg = b_mg
pos_eval.bishops_black_eg = b_eg
elif term == 'rooks':
pos_eval.rooks_white_mg = w_mg
pos_eval.rooks_white_eg = w_eg
pos_eval.rooks_black_mg = b_mg
pos_eval.rooks_black_eg = b_eg
elif term == 'queens':
pos_eval.queens_white_mg = w_mg
pos_eval.queens_white_eg = w_eg
pos_eval.queens_black_mg = b_mg
pos_eval.queens_black_eg = b_eg
elif term == 'mobility':
pos_eval.mobility_white_mg = w_mg
pos_eval.mobility_white_eg = w_eg
pos_eval.mobility_black_mg = b_mg
pos_eval.mobility_black_eg = b_eg
elif term == 'king_safety':
pos_eval.king_safety_white_mg = w_mg
pos_eval.king_safety_white_eg = w_eg
pos_eval.king_safety_black_mg = b_mg
pos_eval.king_safety_black_eg = b_eg
elif term == 'threats':
pos_eval.threats_white_mg = w_mg
pos_eval.threats_white_eg = w_eg
pos_eval.threats_black_mg = b_mg
pos_eval.threats_black_eg = b_eg
elif term == 'passed':
pos_eval.passed_white_mg = w_mg
pos_eval.passed_white_eg = w_eg
pos_eval.passed_black_mg = b_mg
pos_eval.passed_black_eg = b_eg
elif term == 'space':
pos_eval.space_white_mg = w_mg
pos_eval.space_white_eg = w_eg
pos_eval.space_black_mg = b_mg
pos_eval.space_black_eg = b_eg
elif term == 'winnable':
pos_eval.winnable_mg = t_mg
pos_eval.winnable_eg = t_eg
elif term == 'total':
pos_eval.total_mg = t_mg
pos_eval.total_eg = t_eg
break # Don't try other patterns if this one matched
if debug:
print(f"[DEBUG] Matched {len(matched_terms)} terms: {matched_terms}")
# Parse final evaluation lines
classical_match = re.search(r'Classical evaluation\s+([+-]?\d+\.?\d*)', eval_text)
if classical_match:
pos_eval.classical_eval = float(classical_match.group(1))
nnue_match = re.search(r'NNUE evaluation\s+([+-]?\d+\.?\d*)', eval_text)
if nnue_match:
pos_eval.nnue_eval = float(nnue_match.group(1))
final_match = re.search(r'Final evaluation\s+([+-]?\d+\.?\d*)', eval_text)
if final_match:
pos_eval.final_eval = float(final_match.group(1))
return pos_eval
@staticmethod
def _parse_value(val_str: str) -> float:
"""Parse a value string, handling '----' as 0."""
if val_str is None or val_str == '----' or val_str.strip() == '':
return 0.0
try:
return float(val_str)
except ValueError:
return 0.0
# =============================================================================
# ENHANCED ANALYZER
# =============================================================================
class EnhancedGameAnalyzer:
def __init__(self, stockfish_path: str = "/usr/local/bin/stockfish",
depth: int = 20, time_limit: float = 1.0,
extract_positional: bool = True):
self.stockfish_path = stockfish_path
self.depth = depth
self.time_limit = time_limit
self.extract_positional = extract_positional
self.engine = None
self.engine_version = "Unknown"
def __enter__(self):
"""Protocol to support 'with' statement."""
self.engine = chess.engine.SimpleEngine.popen_uci(self.stockfish_path)
self.engine_version = self.engine.id.get('name', 'Stockfish')