pgn-postmortem 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """pgn-postmortem: turn a player's PGN collections into analyzed games and a
2
+ Wikipedia-style site, and (in a later release) an EPUB book.
3
+
4
+ The library API::
5
+
6
+ from pgn_postmortem import Collection
7
+
8
+ games = Collection.read(["games/**/*.pgn"], player="Ada Example", aliases=["adaex"])
9
+ games.write("games-clean/") # optional: one stripped PGN per game
10
+ games.analyze("analyzed/", depth=18, workers=4) # Stockfish, [%eval] comments, incremental
11
+
12
+ analyzed = Collection.read("analyzed/", player="Ada Example", aliases=["adaex"], keep_analysis=True)
13
+ analyzed.build_site("site/", title="Games of Ada Example") # an article per game, an index, the quiz
14
+
15
+ The same steps from the command line are ``pgn-postmortem read``,
16
+ ``pgn-postmortem analyze`` and ``pgn-postmortem site`` (see ``pgn_postmortem.cli``).
17
+ """
18
+
19
+ from pgn_postmortem.analysis import AnalysisReport, EngineFailure, Thresholds, analyze_games
20
+ from pgn_postmortem.collection import CollectedGame, Collection, ReadReport, find_pgn_files
21
+ from pgn_postmortem.site import SiteReport, build_site, critical_moments, shown_result
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "AnalysisReport",
27
+ "CollectedGame",
28
+ "Collection",
29
+ "EngineFailure",
30
+ "ReadReport",
31
+ "Thresholds",
32
+ "__version__",
33
+ "SiteReport",
34
+ "analyze_games",
35
+ "build_site",
36
+ "critical_moments",
37
+ "find_pgn_files",
38
+ "shown_result",
39
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from pgn_postmortem.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,288 @@
1
+ """The Stockfish step: analyze every game of a collection and write it, with
2
+ standard ``[%eval]`` comments, to an output directory.
3
+
4
+ For every move of a game (the mainline only; the collection has already
5
+ stripped whatever the source attached):
6
+
7
+ - the move gets the position's evaluation after it as a standard
8
+ ``[%eval ...]`` comment, from White's point of view: ``[%eval 0.23]`` in
9
+ pawns, or ``[%eval #3]`` / ``[%eval #-2]`` for a forced mate. A move that
10
+ gives checkmate gets none, as in lichess's exports: there is nothing left
11
+ to evaluate.
12
+ - a move is flagged with a NAG ($6 inaccuracy, $2 mistake, $4 blunder) by the
13
+ *win percentage* its side lost, not by raw centipawns (CLAUDE.md, *decided*):
14
+ a centipawn eval becomes a 0-100 win chance through lichess's logistic fit
15
+ (https://lichess.org/page/accuracy), and the thresholds default to lichess's
16
+ own 10/20/30 points.
17
+ - a flagged move gets two engine lines as variations: Stockfish's preferred
18
+ move from the position before it, when that differs from the move played
19
+ ("better was"), and Stockfish's best continuation from the position after it
20
+ ("how to punish it"). Each line's last move carries a position NAG ($10 to
21
+ $19: =, +=, =+, and so on).
22
+
23
+ The step is incremental: a game already analyzed into the output directory
24
+ is not analyzed again. "Analyzed" means a file there carries the game's
25
+ ``PostmortemId`` and the ``PostmortemAnalysis`` header, which only this step
26
+ writes (the engine and the search limit, e.g. ``Stockfish 16, depth 18``).
27
+ The file name alone is not enough: ``Collection.write`` uses the same
28
+ ``<date>-<id>.pgn`` names for games it has only stripped, so reading into a
29
+ directory and then analyzing in place analyzes every game. Nor is the file
30
+ name needed: the test goes by the id (``analyzed_ids``), so a game analyzed
31
+ under an older name (``2019-3-14-<id>.pgn``, before dates in names were
32
+ zero-padded) is not analyzed again. It runs one
33
+ single-threaded Stockfish process per worker, and each game starts with a
34
+ fresh engine state (``ucinewgame``), so a game's output does not depend on
35
+ which worker analyzed it or on what that worker analyzed before.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import math
41
+ import os
42
+ import queue
43
+ import shutil
44
+ from collections.abc import Callable, Iterable
45
+ from concurrent.futures import ThreadPoolExecutor, as_completed
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import NamedTuple
49
+
50
+ import chess
51
+ import chess.engine
52
+ import chess.pgn
53
+
54
+ from pgn_postmortem.collection import ANALYSIS_HEADER, CollectedGame, analyzed_ids, format_game
55
+
56
+ FALLBACK_ENGINE_PATH = "/usr/games/stockfish" # where Debian and Ubuntu install it, off the default PATH
57
+
58
+ MATE_SCORE = 100000
59
+ WIN_PCT_LOGISTIC_SCALE = 0.00368208
60
+ DEFAULT_TIME = 0.3
61
+ DEFAULT_PV_PLIES = 8
62
+
63
+
64
+ class Thresholds(NamedTuple):
65
+ """Win-percentage points a move must lose to be flagged."""
66
+
67
+ inaccuracy: float = 10.0
68
+ mistake: float = 20.0
69
+ blunder: float = 30.0
70
+
71
+
72
+ LICHESS_THRESHOLDS = Thresholds()
73
+
74
+
75
+ class EngineFailure(RuntimeError):
76
+ """The engine could not be started, or failed while analyzing a game.
77
+ The run stops at the first failure; the games already written stay, and
78
+ a rerun picks up the rest."""
79
+
80
+
81
+ ENGINE_ERRORS = (chess.engine.EngineError, chess.engine.EngineTerminatedError)
82
+
83
+
84
+ @dataclass
85
+ class AnalysisReport:
86
+ analyzed: int = 0
87
+ skipped: int = 0
88
+ written: list[Path] = field(default_factory=list)
89
+
90
+
91
+ def default_engine_path() -> str:
92
+ return shutil.which("stockfish") or FALLBACK_ENGINE_PATH
93
+
94
+
95
+ def win_percent(cp_white: int) -> float:
96
+ """White's winning chances (0-100) for a White-POV centipawn eval."""
97
+ return 50 + 50 * (2 / (1 + math.exp(-WIN_PCT_LOGISTIC_SCALE * cp_white)) - 1)
98
+
99
+
100
+ def classify(loss_pct: float, thresholds: Thresholds) -> int | None:
101
+ if loss_pct >= thresholds.blunder:
102
+ return chess.pgn.NAG_BLUNDER
103
+ if loss_pct >= thresholds.mistake:
104
+ return chess.pgn.NAG_MISTAKE
105
+ if loss_pct >= thresholds.inaccuracy:
106
+ return chess.pgn.NAG_DUBIOUS_MOVE
107
+ return None
108
+
109
+
110
+ def classify_position(cp_white: int) -> int:
111
+ """The standard position-evaluation NAG for a White-POV eval."""
112
+ pawns = cp_white / 100
113
+ if pawns <= -3.0:
114
+ return 19 # -+
115
+ if pawns <= -1.0:
116
+ return 17 # -/+
117
+ if pawns <= -0.4:
118
+ return 15 # =+
119
+ if pawns < 0.4:
120
+ return 10 # =
121
+ if pawns < 1.0:
122
+ return 14 # +=
123
+ if pawns < 3.0:
124
+ return 16 # +/-
125
+ return 18 # +-
126
+
127
+
128
+ def attach_line(
129
+ parent: chess.pgn.GameNode, board: chess.Board, pv: list[chess.Move], pv_plies: int, final_cp_white: int
130
+ ) -> None:
131
+ """Add ``pv`` (at most ``pv_plies`` moves) as a variation off ``parent``,
132
+ its last move tagged with a position NAG. ``parent``'s mainline child must
133
+ already exist, or the line would become the mainline."""
134
+ board = board.copy()
135
+ node = parent
136
+ for move in pv[:pv_plies]:
137
+ if move not in board.legal_moves:
138
+ break
139
+ node = node.add_variation(move)
140
+ board.push(move)
141
+ if node is not parent:
142
+ node.nags.add(classify_position(final_cp_white))
143
+
144
+
145
+ def analyze_game(
146
+ engine: chess.engine.SimpleEngine,
147
+ limit: chess.engine.Limit,
148
+ source: chess.pgn.Game,
149
+ engine_game: object,
150
+ pv_plies: int = DEFAULT_PV_PLIES,
151
+ thresholds: Thresholds = LICHESS_THRESHOLDS,
152
+ ) -> chess.pgn.Game:
153
+ """The analyzed copy of ``source`` (its headers, its mainline, our
154
+ comments, NAGs and lines, and the ``PostmortemAnalysis`` marker).
155
+ ``engine_game`` identifies the game to the engine: a new value makes
156
+ python-chess send ``ucinewgame`` first."""
157
+ out = chess.pgn.Game()
158
+ out.headers = source.headers.copy()
159
+ out.headers[ANALYSIS_HEADER] = describe_analysis(engine, limit)
160
+ board = out.board()
161
+ node: chess.pgn.GameNode = out
162
+
163
+ def search(position: chess.Board) -> tuple[chess.engine.PovScore, list[chess.Move]]:
164
+ info = engine.analyse(position, limit, game=engine_game)
165
+ return info["score"], info.get("pv") or []
166
+
167
+ score, pv = search(board)
168
+ # A flagged move's punishment line hangs off that move's own node, which
169
+ # must first get its mainline child (the next move played), so it is
170
+ # attached one move later.
171
+ pending: tuple[chess.pgn.GameNode, chess.Board, list[chess.Move], int] | None = None
172
+
173
+ for move in source.mainline_moves():
174
+ mover = board.turn
175
+ cp_before = score.white().score(mate_score=MATE_SCORE)
176
+ pv_before = pv
177
+
178
+ node = node.add_variation(move)
179
+ if pending is not None:
180
+ attach_line(*pending[:3], pv_plies, pending[3])
181
+ pending = None
182
+ board.push(move)
183
+
184
+ score, pv = search(board)
185
+ cp_after = score.white().score(mate_score=MATE_SCORE)
186
+ node.set_eval(score)
187
+
188
+ loss = win_percent(cp_before) - win_percent(cp_after)
189
+ if mover == chess.BLACK:
190
+ loss = -loss
191
+ nag = classify(max(loss, 0.0), thresholds)
192
+ if nag is not None:
193
+ node.nags.add(nag)
194
+ if pv_before and pv_before[0] != move:
195
+ attach_line(node.parent, node.parent.board(), pv_before, pv_plies, cp_before)
196
+ if pv:
197
+ pending = (node, board.copy(), pv, cp_after)
198
+
199
+ # A pending line left after the last move is dropped: with no next move,
200
+ # it would become the game's mainline.
201
+ return out
202
+
203
+
204
+ def describe_analysis(engine: chess.engine.SimpleEngine, limit: chess.engine.Limit) -> str:
205
+ budget = f"depth {limit.depth}" if limit.depth else f"{limit.time:g}s per position"
206
+ return f"{engine.id.get('name', 'UCI engine')}, {budget}"
207
+
208
+
209
+ def analyze_games(
210
+ games: Iterable[CollectedGame],
211
+ out_dir: str | Path,
212
+ *,
213
+ depth: int | None = None,
214
+ time: float = DEFAULT_TIME,
215
+ workers: int = 0,
216
+ engine_path: str | None = None,
217
+ pv_plies: int = DEFAULT_PV_PLIES,
218
+ thresholds: Thresholds = LICHESS_THRESHOLDS,
219
+ progress: Callable[[int, int, CollectedGame], None] | None = None,
220
+ ) -> AnalysisReport:
221
+ """Analyze every game not yet in ``out_dir`` into ``out_dir/<date>-<id>.pgn``.
222
+
223
+ ``depth`` searches each position to a fixed depth (reproducible);
224
+ otherwise each position gets ``time`` seconds. ``workers`` Stockfish
225
+ processes run side by side (0: one per CPU). No engine is started when
226
+ there is nothing to analyze. ``progress(done, total, game)`` is called
227
+ after each game.
228
+ """
229
+ out_dir = Path(out_dir)
230
+ out_dir.mkdir(parents=True, exist_ok=True)
231
+ done_ids = analyzed_ids(out_dir)
232
+ games = list(games)
233
+ todo = [g for g in games if g.id not in done_ids]
234
+ report = AnalysisReport(skipped=len(games) - len(todo))
235
+ if not todo:
236
+ return report
237
+
238
+ limit = chess.engine.Limit(depth=depth) if depth else chess.engine.Limit(time=time)
239
+ workers = max(1, min(workers or os.cpu_count() or 1, len(todo)))
240
+ engines: queue.Queue[chess.engine.SimpleEngine] = queue.Queue()
241
+
242
+ def run(item: CollectedGame) -> Path:
243
+ engine = engines.get()
244
+ try:
245
+ analyzed = analyze_game(engine, limit, item.game, item.id, pv_plies, thresholds)
246
+ except ENGINE_ERRORS as err:
247
+ raise EngineFailure(f"the engine failed on {item.origin}: {err}") from err
248
+ finally:
249
+ engines.put(engine)
250
+ path = out_dir / item.filename
251
+ partial = path.with_name(path.name + ".partial")
252
+ partial.write_text(format_game(analyzed), encoding="utf-8")
253
+ os.replace(partial, path) # a run that is interrupted never leaves a half-written game behind
254
+ return path
255
+
256
+ engine_path = engine_path or default_engine_path()
257
+ try:
258
+ for _ in range(workers):
259
+ try:
260
+ engine = chess.engine.SimpleEngine.popen_uci(engine_path)
261
+ except FileNotFoundError as err:
262
+ raise EngineFailure(f"Stockfish not found at {engine_path} (install it, or pass its path)") from err
263
+ except (OSError, *ENGINE_ERRORS) as err:
264
+ raise EngineFailure(f"could not start the engine {engine_path}: {err}") from err
265
+ engines.put(engine)
266
+ # only the options this engine has: another UCI engine may lack Stockfish's
267
+ engine.configure({k: v for k, v in {"Threads": 1, "Hash": 64}.items() if k in engine.options})
268
+ with ThreadPoolExecutor(max_workers=workers) as pool:
269
+ futures = {pool.submit(run, item): item for item in todo}
270
+ try:
271
+ for done, future in enumerate(as_completed(futures), start=1):
272
+ report.written.append(future.result())
273
+ report.analyzed += 1
274
+ if progress:
275
+ progress(done, len(todo), futures[future])
276
+ except BaseException:
277
+ # Fail fast: drop the queued games instead of trying each one first.
278
+ pool.shutdown(wait=True, cancel_futures=True)
279
+ raise
280
+ finally:
281
+ while not engines.empty():
282
+ engine = engines.get()
283
+ try:
284
+ engine.quit()
285
+ except ENGINE_ERRORS:
286
+ pass # it already died
287
+ report.written.sort()
288
+ return report
pgn_postmortem/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """pgn-postmortem command line.
2
+
3
+ pgn-postmortem read INPUT... [--player NAME] [--alias NAME]... [--out DIR]
4
+ Read PGN collections (files, directories, glob patterns; quote a
5
+ pattern with ** so the library expands it), keep the player's games
6
+ once each, strip comments, variations and NAGs, and print what was
7
+ kept. With --out, write one PGN per game to DIR.
8
+
9
+ pgn-postmortem analyze INPUT... --out DIR [--player NAME] [--alias NAME]...
10
+ [--depth N | --time SECONDS] [--workers N] [--engine PATH]
11
+ Read the same way, then analyze with Stockfish every game that is not
12
+ in DIR yet, writing it to DIR with [%eval] comments.
13
+
14
+ pgn-postmortem site INPUT... --out DIR [--player NAME] [--alias NAME]... [--title TEXT]
15
+ [--site-key KEY] [--no-history]
16
+ Read the same way, keeping the analysis of games that `analyze` wrote,
17
+ and write a static site to DIR: one article per game and an index by
18
+ year. Analyzed games get notes, diagrams and a "what would you play?"
19
+ question at each critical moment; the others get a plain article.
20
+ With --player or --alias, a quiz page lists the player's own critical
21
+ moments, the costliest first, each linking to its question.
22
+ Every page carries a small script that keeps a reading history in the
23
+ reader's browser; --site-key sets the key it is stored under (by
24
+ default one derived from the title), and --no-history leaves it out.
25
+
26
+ Without --player or --alias, every game is kept.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import sys
33
+
34
+ from pgn_postmortem import __version__
35
+ from pgn_postmortem.analysis import DEFAULT_TIME, EngineFailure, analyze_games
36
+ from pgn_postmortem.collection import CollectedGame, Collection
37
+ from pgn_postmortem.site import build_site, check_site_key, display_name
38
+
39
+
40
+ def add_reading_options(parser: argparse.ArgumentParser) -> None:
41
+ parser.add_argument("inputs", nargs="+", metavar="INPUT", help="a PGN file, a directory or a glob pattern")
42
+ parser.add_argument("--player", help="the player's name as it appears in White/Black")
43
+ parser.add_argument(
44
+ "--alias", action="append", default=[], metavar="NAME", help="another name of the player (repeatable)"
45
+ )
46
+
47
+
48
+ def read_collection(args: argparse.Namespace, keep_analysis: bool = False) -> Collection:
49
+ collection = Collection.read(args.inputs, player=args.player, aliases=args.alias, keep_analysis=keep_analysis)
50
+ for warning in collection.report.warnings:
51
+ print(f"warning: {warning}", file=sys.stderr)
52
+ print(collection.report.summary())
53
+ return collection
54
+
55
+
56
+ def cmd_read(args: argparse.Namespace) -> int:
57
+ collection = read_collection(args)
58
+ if args.out:
59
+ paths = collection.write(args.out)
60
+ kept = len(collection) - len(paths)
61
+ already = f"; {kept} already analyzed there, left as they are" if kept else ""
62
+ print(f"Wrote {len(paths)} game(s) to {args.out}{already}.")
63
+ return 0
64
+
65
+
66
+ def cmd_analyze(args: argparse.Namespace) -> int:
67
+ collection = read_collection(args)
68
+
69
+ def progress(done: int, total: int, item: CollectedGame) -> None:
70
+ print(f" [{done}/{total}] {item.filename}", flush=True)
71
+
72
+ report = analyze_games(
73
+ collection,
74
+ args.out,
75
+ depth=args.depth,
76
+ time=args.time,
77
+ workers=args.workers,
78
+ engine_path=args.engine,
79
+ progress=progress,
80
+ )
81
+ print(f"Analyzed {report.analyzed} game(s) into {args.out}; {report.skipped} already there.")
82
+ return 0
83
+
84
+
85
+ def site_key(value: str) -> str:
86
+ try:
87
+ return check_site_key(value)
88
+ except ValueError as err:
89
+ raise argparse.ArgumentTypeError(str(err)) from None
90
+
91
+
92
+ def cmd_site(args: argparse.Namespace) -> int:
93
+ collection = read_collection(args, keep_analysis=True)
94
+ title = args.title or (f"Games of {display_name(args.player)}" if args.player else "Games")
95
+ report = build_site(
96
+ collection,
97
+ args.out,
98
+ title=title,
99
+ history=args.history,
100
+ site_key=args.site_key,
101
+ player=args.player,
102
+ aliases=args.alias,
103
+ )
104
+ print(report.summary(args.out))
105
+ return 0
106
+
107
+
108
+ def build_parser() -> argparse.ArgumentParser:
109
+ parser = argparse.ArgumentParser(
110
+ prog="pgn-postmortem", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
111
+ )
112
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
113
+ sub = parser.add_subparsers(dest="command", required=True)
114
+
115
+ read = sub.add_parser("read", help="read and strip PGN collections")
116
+ add_reading_options(read)
117
+ read.add_argument("--out", metavar="DIR", help="write one stripped PGN per game here")
118
+ read.set_defaults(func=cmd_read)
119
+
120
+ analyze = sub.add_parser("analyze", help="analyze the games with Stockfish")
121
+ add_reading_options(analyze)
122
+ analyze.add_argument("--out", metavar="DIR", required=True, help="where analyzed games are written")
123
+ budget = analyze.add_mutually_exclusive_group()
124
+ budget.add_argument("--depth", type=int, help="search each position to this depth (reproducible)")
125
+ budget.add_argument(
126
+ "--time", type=float, default=DEFAULT_TIME, help=f"seconds per position (default {DEFAULT_TIME})"
127
+ )
128
+ analyze.add_argument("--workers", type=int, default=0, help="parallel Stockfish processes (default: one per CPU)")
129
+ analyze.add_argument("--engine", metavar="PATH", help="the Stockfish binary (default: stockfish on PATH)")
130
+ analyze.set_defaults(func=cmd_analyze)
131
+
132
+ site = sub.add_parser("site", help="write the static site: an article per game and an index")
133
+ add_reading_options(site)
134
+ site.add_argument("--out", metavar="DIR", required=True, help="where the site is written")
135
+ site.add_argument("--title", help='the site\'s title (default: "Games of <player>", or "Games")')
136
+ site.add_argument(
137
+ "--site-key",
138
+ type=site_key,
139
+ metavar="KEY",
140
+ help="the key the reading history is stored under in the reader's browser: 1 to 64 letters, digits, "
141
+ "'.', '_' or '-' (default: derived from the title)",
142
+ )
143
+ site.add_argument(
144
+ "--no-history",
145
+ dest="history",
146
+ action="store_false",
147
+ help="write the pages without the reading-history script, its section and its data- attributes",
148
+ )
149
+ site.set_defaults(func=cmd_site)
150
+ return parser
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> int:
154
+ args = build_parser().parse_args(argv)
155
+ try:
156
+ return args.func(args)
157
+ except (FileNotFoundError, EngineFailure) as err:
158
+ print(f"error: {err}", file=sys.stderr)
159
+ return 1