Created a script that will detect the following tactical themes.
- pins
- forks
- double check
- undermine
It will read the games in the pgn file. Visit each position in the game and run stockfish at multipv 10 to find non-losing move that generates the said tactics.
Note the code is not optimized.
Code
"""Find pin, fork, double check and underminig moves.
requirements:
pip install chess
"""
import chess
import chess.pgn
import chess.engine
def detect_forks(init_epd, curmove, currank, curscore, board, wp, bp, event, date):
"""Find move the forks 2 or more pieces.
Args:
init_epd: The position in the game.
curmove: The move from stockfish from multipv analysis.
currank: The rank of the move from multipv analysis.
board: The board after executing the curmove.
wp: White player in the game.
bp: Black player in the game.
event: The event name.
date: The date the event is held.
Returns:
A list of positions with fork.
"""
forks = []
# The to_square of the move that forks must not be attacked by enemy.
move_attackers = board.attackers(board.turn, curmove.to_square)
if len(move_attackers):
return forks
prev_board = chess.Board(init_epd)
# Find the attacked squares from the move.
attacked_sq = board.attacks(curmove.to_square)
legal_attacked_sq = []
# Number of attacked squares must be 2 or more.
if len(attacked_sq) >= 2:
# Scan all the squares and pieces on the board.
for square, piece in board.piece_map().items():
# If this square is not in the attacked squares, skip.
if square not in attacked_sq:
continue
# If the color of this square is not an enemy color, skip.
if piece.color != board.turn:
continue
# The attacked square should not be defended.
defenders = board.attackers(board.turn, square)
# If there is defender, skip.
if len(defenders):
continue
# Store the legal attacked square.
legal_attacked_sq.append(square)
# Check if all the legal attacked squares are two or more.
if len(legal_attacked_sq) >= 2:
forks.append([init_epd, prev_board.san(curmove), currank, curscore, "fork"])
forked_sq = []
for s in legal_attacked_sq:
forked_sq.append(chess.square_name(s))
print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, rank: {currank}, score: {curscore}, forked_sq: {forked_sq}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')
return forks
def double_check(init_epd, curmove, currank, curscore, board, wp, bp, event, date):
"""Find move that attacks the enemy king twice."""
dchecks = []
prev_board = chess.Board(init_epd)
# If the to_square of curmove does not check, skip.
if board.is_check() and len(board.attackers(not board.turn, board.king(board.turn))) > 1:
dchecks.append([init_epd, prev_board.san(curmove), currank, curscore, "double check"])
print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, rank: {currank}, score: {curscore}, checked_sq: {board.king(board.turn)}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')
return dchecks
def undermine(init_epd, curmove, currank, curscore, board, pv, wp, bp, event, date):
"""
Check if curmove is an undermining move.
curmove, oppmove, oppmovereply ------ pv sequence
The curmove captures the enemy piece.
oppmove captures the piece from curmove
The piece captured by curmove is a defender_pc.
oppmovereply captures a piece that is defended by defender_pc.
"""
undermines = []
if len(pv) < 3:
return undermines
pv = pv[:3] # starts from init_epd
prev_board = chess.Board(init_epd)
is_capture = prev_board.is_capture(curmove)
if not is_capture:
return undermines
# Walk the pv starting from prev_board.
bt1 = prev_board.copy()
b1 = bt1.copy()
m1 = curmove
bt1.push(m1)
b2 = bt1.copy()
m2 = pv[1]
bt1.push(m2)
b3 = bt1.copy()
m3 = pv[2]
# The last move should be a capture.
if not b3.is_capture(m3):
return undermines
if m1.to_square == m2.to_square:
# The piece captured by m1 is a defender.
defended_sq = b1.attacks(m1.to_square)
if len(defended_sq):
# Loop thru the defended sq, if one of it is captured by m3 then m1 is an undermining move.
for s in defended_sq:
if m3.to_square == s and m3.to_square != m1.to_square:
pv_san = b1.variation_san(pv)
undermines.append([init_epd, prev_board.san(curmove), currank, curscore, "undermine"])
print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, pv: {pv_san}, rank: {currank}, score: {curscore}, undermined_sq: {chess.square_name(m3.to_square)}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')
return undermines
return undermines
def detect_good_pins(init_epd, curmove, currank, curscore, board, wp, bp, event, date):
pins = []
prev_board = chess.Board(init_epd)
# Loop thru the squares and pieces on the current board.
for square, piece in board.piece_map().items():
# Detect a pinned piece of the side to move.
# The pinned piece is not a king and not a pawn.
if (piece.color == board.turn and
piece.piece_type != chess.KING and
piece.piece_type != chess.PAWN):
# If this square is pinned by the side not to move.
if board.is_pinned(board.turn, square):
# Get the piece type that currently occupies the square, if this piece has no
# legal move, then it is pinned.
is_pin = True
for move in board.legal_moves:
if move.from_square == square:
is_pin = False
break
if is_pin:
# Is the last move pins the opponent?
# Takeback the move and check if the square is not pinned.
# If it is pinned, the move is not the cause of the pin.
# If it is not pinned, the move is the cause of the pin.
tmp_board = board.copy()
tmp_board.pop()
if not tmp_board.is_pinned(not tmp_board.turn, square):
# Save the info.
attackers = board.attackers(not board.turn, square)
defenders = board.attackers(board.turn, square)
if len(attackers) >= len(defenders):
pins.append([init_epd, prev_board.san(curmove), currank, curscore, "pin"])
print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, rank: {currank}, score: {curscore}, pinned_sq: {chess.square_name(square)}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')
return pins
if __name__ == '__main__':
fn = 'f:/project/pgn/tatamast23.pgn'
fn = 'f:/project/pgn/2023-pro-chess-league-main-event-regular-season.pgn'
gcnt = 0
all_pins = []
all_forks = []
all_double_check = []
all_undermines = []
multipv = 10
engine = chess.engine.SimpleEngine.popen_uci(
r"F:\Chess\Engines\stockfish\stockfish_15.1_win_x64_popcnt\stockfish-windows-2022-x86-64-modern.exe"
)
engine.configure({'Hash': 128})
with open(fn) as pgn:
while True:
game = chess.pgn.read_game(pgn)
if game is None:
break
gcnt += 1
print(f'game {gcnt}')
wp = game.headers['White']
bp = game.headers['Black']
event = game.headers['Event']
date = game.headers['Date']
for node in game.mainline():
board = node.parent.board()
if board.ply() < 24:
continue
# Analyze this board with the engine. Use multipv 10 to
# increase the chance of detecting a move that generates
# a dangerous pin.
info = engine.analyse(board, chess.engine.Limit(time=0.5), multipv=multipv)
for i in range(min(multipv, board.legal_moves.count())):
score = info[i]['score'].relative.score(mate_score=32000)
pv = info[i]['pv']
move = pv[0]
# The move score should not be lossing.
if score <= -200:
break
# Make the engine move on the board and check if this move pins an enemy piece.
tmp_board = board.copy()
tmp_board.push(move)
pins = detect_good_pins(board.epd(), move, i+1, score, tmp_board, wp, bp, event, date)
if len(pins):
all_pins.append(pins)
forks = detect_forks(board.epd(), move, i+1, score, tmp_board, wp, bp, event, date)
if len(forks):
all_forks.append(forks)
double_checks = double_check(board.epd(), move, i+1, score, tmp_board, wp, bp, event, date)
if len(double_checks):
all_double_check.append(double_checks)
undermines = undermine(board.epd(), move, i+1, score, tmp_board, pv, wp, bp, event, date)
if len(undermines):
all_undermines.append(undermines)
if gcnt >= 1000000:
break
engine.quit()
print('pins:')
for pins in all_pins:
for p in pins:
print(p)
print('forks:')
for forks in all_forks:
for f in forks:
print(f)
print('double checks:')
for dc in all_double_check:
for d in dc:
print(d)
print('undermines:')
for un in all_undermines:
for u in un:
print(u)
Sample pins
initepd: 3n2k1/p4r1p/1pR1p1p1/5q2/3P4/4QP2/P3N1P1/6K1 w - -, move: Rc8, rank: 2, score: 3, pinned_sq: d8, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 2Q5/p4nk1/1p4p1/3p2q1/3P3p/5P2/P3N1P1/5K2 w - -, move: Qc7, rank: 1, score: 0, pinned_sq: f7, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 2Q5/p4nk1/1p4p1/3p2q1/3P3p/5P2/P3N1P1/5K2 w - -, move: Qb7, rank: 2, score: -2, pinned_sq: f7, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 2Q5/p4nk1/1p4p1/3p2q1/3P3p/5P2/P3N1P1/5K2 w - -, move: Qd7, rank: 4, score: -39, pinned_sq: f7, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 8/Q4nk1/1p4p1/3p1q2/3P3p/5P2/P3N1P1/5K2 b - -, move: Qd3, rank: 7, score: -26, pinned_sq: e2, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
The rank is the ranking of move based from stockfish. Rank 1 is the best move based from a given analysis time of 0.5 sec.
The first output line is this.

The move that pins the enemy is Rc8. This is only ranked at number 2 (I have not recorded the top 1 engine move and the actual game move, but this is possible by revising the script) with a score of 3 cp from the point of view of the side to move.
The pinned square is d8. The game is between white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14.
Another line:
initepd: 6k1/1p3p2/1R3p1p/3pn3/8/2P1P3/r4PPP/1B2K3 b - -, move: Ra1, rank: 3, score: -108, pinned_sq: b1, white: "Abdusattorov, Nodirbek", black: "Caruana, Fabiano", event: "85th Tata Steel Masters", date: 2023.01.15

Sample fork output
initepd: 1k1r3r/ppqb1pQp/2n5/1B6/8/B3PN2/P4PPP/2R3K1 b - -, move: Qa5, rank: 3, score: -28, forked_sq: ['b5', 'a3'], white: "Carlsen, Magnus", black: "Abdusattorov, Nodirbek", event: "85th Tata Steel Masters", date: 2023.01.19

The move Qa5 will forked a3 and b5.
Sample double check
initepd: 5rk1/5pp1/8/7p/1QB2P2/1P2q2P/2P2nP1/4R1K1 b - -, move: Nxh3+, rank: 1, score: 0, checked_sq: 6, white: "Pranesh, M", black: "Dubov, Daniil", event: "Pro Chess League Main Event 2023 Regular Season", date: 2023.03.01

The move Nxh3 generates a double attacks to the white king.
Sample undermine 1
initepd: 5rk1/2q1pp1p/pnPp1bp1/1p1B4/2r2P2/P1N1Q2P/1PP3P1/3RR1K1 b - -, move: Bxc3, pv: 1...Bxc3 2. bxc3 Nxd5, rank: 1, score: 282, undermined_sq: d5, white: "Foisor, Sabina-Francesca", black: "Deac, Bogdan-Daniel", event: "Pro Chess League Main Event 2023 Regular Season", date: 2023.02.17

pv: 1...Bxc3 2. bxc3 Nxd5, rank: 1, score: 282, undermined_sq: d5
Sample undermine 2
initepd: 5rk1/2q2pp1/R1rp1n2/7p/1pBQ1P2/1P5P/2P3P1/3R2K1 w - -, move: Rxc6, pv: 1. Rxc6 Qxc6 2. Qxd6, rank: 1, score: 174, undermined_sq: d6, white: "Pranesh, M", black: "Dubov, Daniil", event: "Pro Chess League Main Event 2023 Regular Season", date: 2023.03.01

pv: 1. Rxc6 Qxc6 2. Qxd6, rank: 1, score: 174, undermined_sq: d6