Initial commit
This commit is contained in:
168
cleaning/format.py
Normal file
168
cleaning/format.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Turn refined PGN games (Site/WhiteElo/BlackElo headers + movetext with
|
||||
%eval comments) into a CSV where each row is the board state *after* one
|
||||
move.
|
||||
|
||||
Row layout (67 columns):
|
||||
game_id, a8, b8, c8, d8, e8, f8, g8, h8, a7, ... , h1, eval, turn
|
||||
|
||||
- game_id : 0-based index of the game in the input file. Not asked for
|
||||
explicitly, but with many games concatenated into one CSV
|
||||
there is no other way to tell which rows belong to which
|
||||
game, so I added it as column 1. Drop it with --no-game-id
|
||||
if you don't want it.
|
||||
- a8..h1 : the 64 squares (rank 8 down to rank 1, file a to h), each
|
||||
holding the FEN piece letter on that square after the move
|
||||
(uppercase = white, lowercase = black) or an empty string
|
||||
if the square is empty.
|
||||
- eval : the %eval attached to that move (blank if that particular
|
||||
move had none).
|
||||
- turn : "white" or "black" -- whose turn it is to move NEXT.
|
||||
|
||||
Streams game-by-game (like the two earlier scripts) so it stays cheap on
|
||||
RAM no matter how large the input file is. Requires `pip install chess`.
|
||||
|
||||
Usage:
|
||||
python pgn_to_csv.py refined.pgn out.csv
|
||||
python pgn_to_csv.py refined.pgn out.csv --no-game-id
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import re
|
||||
import sys
|
||||
|
||||
import chess
|
||||
|
||||
TAG_RE = re.compile(r'^\[(\w+)\s+"(.*)"\]\s*$')
|
||||
EVAL_RE = re.compile(r'\[%eval\s+([^\]]+)\]')
|
||||
MOVE_NUM_RE = re.compile(r'^\d+\.+$')
|
||||
RESULT_RE = re.compile(r'^(1-0|0-1|1/2-1/2|\*)$')
|
||||
# Strip trailing annotation glyphs python-chess's SAN parser won't accept
|
||||
# (e.g. "e5?!", "Nc3!!", "Qxd1+!") while keeping the "+"/"#" that ARE part
|
||||
# of legal SAN.
|
||||
SUFFIX_ANNOTATION_RE = re.compile(r'[!?]+$')
|
||||
|
||||
SQUARE_COLUMNS = [f + str(r) for r in range(8, 0, -1) for f in "abcdefgh"]
|
||||
|
||||
|
||||
def tokenize_movetext(movetext: str):
|
||||
"""Yield (san_move, eval_str_or_None) pairs from a movetext blob."""
|
||||
tokens = re.findall(r'\{[^}]*\}|\S+', movetext)
|
||||
i = 0
|
||||
n = len(tokens)
|
||||
while i < n:
|
||||
tok = tokens[i]
|
||||
if MOVE_NUM_RE.match(tok) or RESULT_RE.match(tok):
|
||||
i += 1
|
||||
continue
|
||||
move_san = tok
|
||||
eval_val = None
|
||||
if i + 1 < n and tokens[i + 1].startswith('{'):
|
||||
m = EVAL_RE.search(tokens[i + 1])
|
||||
if m:
|
||||
eval_val = m.group(1)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
yield move_san, eval_val
|
||||
|
||||
|
||||
def board_row(board: chess.Board):
|
||||
"""Return the 64 square values for the current board, a8..h1 order."""
|
||||
row = []
|
||||
for name in SQUARE_COLUMNS:
|
||||
sq = chess.parse_square(name)
|
||||
piece = board.piece_at(sq)
|
||||
row.append(piece.symbol() if piece else "")
|
||||
return row
|
||||
|
||||
|
||||
def process_game(game_id, header_lines, movetext_lines, writer, include_game_id):
|
||||
if not movetext_lines:
|
||||
return 0
|
||||
|
||||
movetext = ' '.join(line.strip() for line in movetext_lines if line.strip())
|
||||
|
||||
board = chess.Board()
|
||||
rows_written = 0
|
||||
|
||||
for move_san, eval_val in tokenize_movetext(movetext):
|
||||
clean_san = SUFFIX_ANNOTATION_RE.sub('', move_san)
|
||||
try:
|
||||
board.push_san(clean_san)
|
||||
except (chess.IllegalMoveError, chess.InvalidMoveError, chess.AmbiguousMoveError, ValueError) as e:
|
||||
print(f" [game {game_id}] stopping early: could not parse move "
|
||||
f"'{move_san}' ({e})", file=sys.stderr)
|
||||
break
|
||||
|
||||
turn = "white" if board.turn == chess.WHITE else "black"
|
||||
row = (board_row(board) + [eval_val if eval_val is not None else "", turn])
|
||||
if include_game_id:
|
||||
row = [game_id] + row
|
||||
writer.writerow(row)
|
||||
rows_written += 1
|
||||
|
||||
return rows_written
|
||||
|
||||
|
||||
def refine(input_path: str, output_path: str, include_game_id: bool):
|
||||
header_lines = []
|
||||
movetext_lines = []
|
||||
state = "seek_header"
|
||||
game_id = 0
|
||||
total_rows = 0
|
||||
|
||||
header_row = SQUARE_COLUMNS + ["eval", "turn"]
|
||||
if include_game_id:
|
||||
header_row = ["game_id"] + header_row
|
||||
|
||||
with open(input_path, "r", encoding="utf-8", errors="replace", buffering=1024 * 1024) as fin, \
|
||||
open(output_path, "w", encoding="utf-8", newline="") as fout:
|
||||
|
||||
writer = csv.writer(fout)
|
||||
writer.writerow(header_row)
|
||||
|
||||
for raw_line in fin:
|
||||
line = raw_line.rstrip("\n")
|
||||
|
||||
if state == "seek_header":
|
||||
if line.startswith("["):
|
||||
header_lines.append(line)
|
||||
elif line.strip() == "" and header_lines:
|
||||
state = "seek_movetext"
|
||||
elif state == "seek_movetext":
|
||||
if line.strip() == "":
|
||||
total_rows += process_game(game_id, header_lines, movetext_lines,
|
||||
writer, include_game_id)
|
||||
game_id += 1
|
||||
header_lines = []
|
||||
movetext_lines = []
|
||||
state = "seek_header"
|
||||
else:
|
||||
movetext_lines.append(line)
|
||||
|
||||
if header_lines and movetext_lines:
|
||||
total_rows += process_game(game_id, header_lines, movetext_lines,
|
||||
writer, include_game_id)
|
||||
game_id += 1
|
||||
|
||||
return game_id, total_rows
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert refined PGN games into a per-move board-state CSV.")
|
||||
parser.add_argument("input", help="Path to the refined PGN file")
|
||||
parser.add_argument("output", help="Path to write the CSV file")
|
||||
parser.add_argument("--no-game-id", action="store_true",
|
||||
help="Omit the leading game_id column (66-col strict layout).")
|
||||
args = parser.parse_args()
|
||||
|
||||
games, rows = refine(args.input, args.output, include_game_id=not args.no_game_id)
|
||||
print(f"Processed {games} games -> {rows} rows written to {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user