weakness-report 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,3 @@
1
+ """Weakness Report -- what your whole game history says you are bad at."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,256 @@
1
+ """The arithmetic: your moves, sliced, counted, and compared against yourself.
2
+
3
+ Every number here is defined to match ChessAnalyzer's, because the point of
4
+ this app is to aggregate that app's reviews rather than to have a second
5
+ opinion about them:
6
+
7
+ * **ACPL** is the mean centipawn loss over your moves, **excluding book moves**
8
+ and moves the engine could not score -- exactly as ``_side_summary`` does it.
9
+ Including opening theory would flatter anyone with preparation and tell them
10
+ nothing about their play.
11
+ * **Accuracy** is the mean of the arithmetic and harmonic means of your move
12
+ accuracies in the bucket, which is what ``phase_accuracy`` does. The
13
+ harmonic half is what stops one brilliant move from hiding three terrible
14
+ ones. The function itself is imported rather than copied.
15
+
16
+ The one number this app adds is **excess loss**, and it is the number the
17
+ whole report is built on:
18
+
19
+ excess = moves_in_bucket x (bucket ACPL - your overall ACPL)
20
+
21
+ which is "how many centipawns this kind of position has cost you *beyond what
22
+ you cost yourself anyway*". Divided by the number of games, it becomes the
23
+ sentence you actually want: *queenless middlegames cost you 0.4 pawns a game
24
+ more than your average*.
25
+
26
+ That formula is chosen over the two obvious alternatives on purpose. Ranking
27
+ by bucket ACPL alone crowns whichever bucket has eleven moves in it; ranking
28
+ by total centipawns lost crowns the middlegame every time, because that is
29
+ where most of your moves are. Excess loss is the one that answers "what should
30
+ I work on", because it multiplies how bad you are at something by how often it
31
+ happens to you.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections import defaultdict
37
+
38
+ from . import features as feat
39
+ from .bridge import analyzer
40
+ from .buckets import DIMENSIONS
41
+
42
+ #: A bucket smaller than this is not evidence. Both have to be met: forty
43
+ #: moves inside one game is one game's worth of noise.
44
+ MIN_MOVES = 40
45
+ MIN_GAMES = 5
46
+
47
+
48
+ def _harmonic_mean(values: list) -> float:
49
+ """ChessAnalyzer's, when it can be found; the same formula when it cannot.
50
+
51
+ This is the only place in the app with a fallback, and it is four lines of
52
+ arithmetic with no judgement in it, so the two cannot drift in any way
53
+ that matters.
54
+ """
55
+ accuracy_module = analyzer("accuracy")
56
+ if accuracy_module is not None:
57
+ return accuracy_module.harmonic_mean(values)
58
+ positive = [max(value, 0.01) for value in values]
59
+ if not positive:
60
+ return 0.0
61
+ return len(positive) / sum(1.0 / value for value in positive)
62
+
63
+
64
+ def bucket_accuracy(accuracies: list) -> float | None:
65
+ """The accuracy of one bucket, by ChessAnalyzer's per-phase definition."""
66
+ if not accuracies:
67
+ return None
68
+ mean = sum(accuracies) / len(accuracies)
69
+ return round((mean + _harmonic_mean(accuracies)) / 2.0, 1)
70
+
71
+
72
+ def move_rows(games: list, reviews: dict) -> list:
73
+ """Flatten reviews into one list of *your* moves, ready to slice.
74
+
75
+ ``games`` are the records, ``reviews`` maps a game id to its review. A
76
+ game with no review is skipped silently; the caller counts those.
77
+ """
78
+ rows = []
79
+ for game in games:
80
+ review = reviews.get(game.get("id"))
81
+ if not review:
82
+ continue
83
+ you = game.get("you")
84
+ if you not in ("white", "black"):
85
+ continue
86
+
87
+ opening = review.get("opening") or {}
88
+ context = {
89
+ "id": game.get("id"),
90
+ "url": game.get("url", ""),
91
+ "you": you,
92
+ "them": game.get("them", ""),
93
+ "youElo": game.get("youElo", ""),
94
+ "themElo": game.get("themElo", ""),
95
+ "speed": game.get("speed", ""),
96
+ "result": game.get("result", "*"),
97
+ "date": game.get("date", ""),
98
+ "opening": opening,
99
+ }
100
+ me = you == "white"
101
+
102
+ for row in review.get("moves") or []:
103
+ if row.get("color") != you:
104
+ continue
105
+ rows.append({
106
+ "ply": row.get("ply"),
107
+ "moveNumber": row.get("moveNumber"),
108
+ "san": row.get("san", ""),
109
+ "phase": row.get("phase", ""),
110
+ "clock": row.get("clock"),
111
+ "cpLoss": row.get("cpLoss"),
112
+ "winLoss": row.get("winLoss"),
113
+ "accuracy": row.get("accuracy"),
114
+ "label": row.get("label", ""),
115
+ "judgment": row.get("judgment"),
116
+ "inBook": bool(row.get("inBook")),
117
+ "forced": bool(row.get("forced")),
118
+ "isBest": bool(row.get("isBest")),
119
+ "bestSan": row.get("bestSan"),
120
+ "fenBefore": row.get("fenBefore", ""),
121
+ "features": feat.describe(row.get("fenBefore", ""), me),
122
+ "game": context,
123
+ })
124
+ return rows
125
+
126
+
127
+ def _scored(move: dict) -> bool:
128
+ """Does this move count towards ACPL? ChessAnalyzer's rule, unchanged."""
129
+ return not move["inBook"] and move.get("cpLoss") is not None
130
+
131
+
132
+ class Tally:
133
+ """One bucket's running totals."""
134
+
135
+ __slots__ = ("moves", "scored", "cp", "accuracies", "games", "labels",
136
+ "judgments", "best", "forced")
137
+
138
+ def __init__(self):
139
+ self.moves = 0
140
+ self.scored = 0
141
+ self.cp = 0
142
+ self.accuracies = []
143
+ self.games = set()
144
+ self.labels = defaultdict(int)
145
+ self.judgments = defaultdict(int)
146
+ self.best = 0
147
+ self.forced = 0
148
+
149
+ def add(self, move: dict) -> None:
150
+ self.moves += 1
151
+ self.games.add((move.get("game") or {}).get("id"))
152
+ if move.get("label"):
153
+ self.labels[move["label"]] += 1
154
+ if move.get("judgment"):
155
+ self.judgments[move["judgment"]] += 1
156
+ if move.get("isBest"):
157
+ self.best += 1
158
+ if move.get("forced"):
159
+ self.forced += 1
160
+ if move.get("accuracy") is not None:
161
+ self.accuracies.append(move["accuracy"])
162
+ if _scored(move):
163
+ self.scored += 1
164
+ self.cp += move["cpLoss"]
165
+
166
+ @property
167
+ def acpl(self):
168
+ return self.cp / self.scored if self.scored else None
169
+
170
+ def to_json(self, name: str, *, baseline, total_games: int) -> dict:
171
+ acpl = self.acpl
172
+ excess = (self.scored * (acpl - baseline)
173
+ if acpl is not None and baseline is not None else None)
174
+ return {
175
+ "bucket": name,
176
+ "moves": self.moves,
177
+ "scored": self.scored,
178
+ "games": len(self.games),
179
+ "acpl": round(acpl, 1) if acpl is not None else None,
180
+ "accuracy": bucket_accuracy(self.accuracies),
181
+ "cpLost": self.cp,
182
+ "pawnsLost": round(self.cp / 100.0, 2),
183
+ "excessCp": round(excess, 1) if excess is not None else None,
184
+ "excessPawnsPerGame": (round(excess / total_games / 100.0, 3)
185
+ if excess is not None and total_games else None),
186
+ "pawnsPerGame": (round(self.cp / total_games / 100.0, 3)
187
+ if total_games else None),
188
+ "bestShare": (round(100.0 * self.best / self.moves, 1)
189
+ if self.moves else None),
190
+ "labels": dict(sorted(self.labels.items(), key=lambda kv: -kv[1])),
191
+ "judgments": dict(self.judgments),
192
+ }
193
+
194
+
195
+ def overall(moves: list, total_games: int) -> dict:
196
+ """Your baseline: the numbers every bucket is compared against."""
197
+ tally = Tally()
198
+ for move in moves:
199
+ tally.add(move)
200
+ data = tally.to_json("overall", baseline=None, total_games=total_games)
201
+ data["excessCp"] = 0.0
202
+ data["excessPawnsPerGame"] = 0.0
203
+ return data
204
+
205
+
206
+ def slice_by(moves: list, dimension, *, baseline: float | None,
207
+ total_games: int) -> list:
208
+ """Every bucket of one dimension, biggest share of your loss first."""
209
+ tallies: dict = {}
210
+ for move in moves:
211
+ for name in dimension.buckets_of(move):
212
+ tallies.setdefault(name, Tally()).add(move)
213
+
214
+ rows = [tally.to_json(name, baseline=baseline, total_games=total_games)
215
+ for name, tally in tallies.items()]
216
+
217
+ if dimension.order:
218
+ position = {name: index for index, name in enumerate(dimension.order)}
219
+ rows.sort(key=lambda row: (position.get(row["bucket"], 99),
220
+ -row["moves"]))
221
+ else:
222
+ rows.sort(key=lambda row: -row["moves"])
223
+ return rows
224
+
225
+
226
+ def build(moves: list, *, total_games: int) -> dict:
227
+ """Every dimension, sliced, with the baseline they are measured against."""
228
+ base = overall(moves, total_games)
229
+ baseline = base["acpl"]
230
+
231
+ slices = {}
232
+ for dimension in DIMENSIONS:
233
+ rows = slice_by(moves, dimension, baseline=baseline,
234
+ total_games=total_games)
235
+ if not rows:
236
+ continue
237
+ slices[dimension.key] = {
238
+ "key": dimension.key,
239
+ "label": dimension.label,
240
+ "note": dimension.note,
241
+ "buckets": rows,
242
+ }
243
+
244
+ return {"overall": base, "baselineAcpl": baseline, "slices": slices}
245
+
246
+
247
+ __all__ = [
248
+ "MIN_GAMES",
249
+ "MIN_MOVES",
250
+ "Tally",
251
+ "bucket_accuracy",
252
+ "build",
253
+ "move_rows",
254
+ "overall",
255
+ "slice_by",
256
+ ]
@@ -0,0 +1,303 @@
1
+ """Reviewing a few hundred games, once.
2
+
3
+ This is the expensive half of the app and everything here exists to make it
4
+ cost as little as possible and to make the result mean something.
5
+
6
+ **Reviews are kept per game, not per report.** Adding fifty games to a
7
+ history of four hundred costs fifty reviews. Re-running the same report costs
8
+ nothing at all.
9
+
10
+ **Batches are searched to a fixed depth, and with one thread.** This is the
11
+ one place where this app deliberately differs from ChessAnalyzer's defaults,
12
+ and the reason is worth stating:
13
+
14
+ * A **movetime** budget makes every number depend on how busy the machine was.
15
+ That is tolerable for one game you are reading move by move -- ChessAnalyzer
16
+ says as much about its own presets -- and it is not tolerable for a figure
17
+ averaged over four hundred games, because re-running the report next week
18
+ would move every number in it and you would have no way to tell a real
19
+ change from a noisy laptop.
20
+ * **Threads > 1** makes Stockfish non-deterministic even at a fixed depth: the
21
+ search is split across threads and the order they finish in changes the
22
+ result. One thread is slower and gives the same answer twice.
23
+
24
+ Together they make a report **reproducible**, which is what lets you run it
25
+ again in three months and believe the difference. Both are settings, so you
26
+ can trade either away for speed.
27
+
28
+ **Adopting other reviews.** ChessAnalyzer may already have reviewed some of
29
+ these games. Those are free -- but only comparable if they were searched the
30
+ same way, so by default a review is adopted when its engine settings match
31
+ this batch and re-done when they do not. ``adopt="any"`` takes them regardless
32
+ and the report says the mix out loud; ``adopt="none"`` ignores them.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import time
38
+
39
+ from .bridge import FeatureUnavailable, analyzer, require_analyzer
40
+
41
+ #: Fixed depths rather than movetimes. See the module docstring.
42
+ PRESETS = {
43
+ "sweep": {
44
+ "label": "Sweep",
45
+ "depth": 10,
46
+ "multipv": 2,
47
+ "detail": "Roughly a second a game per 10 moves. Finds blunders and "
48
+ "big patterns; too shallow to trust a 5-centipawn gap.",
49
+ },
50
+ "standard": {
51
+ "label": "Standard",
52
+ "depth": 14,
53
+ "multipv": 3,
54
+ "detail": "The sensible default for a few hundred games. Comparable "
55
+ "to ChessAnalyzer's Standard preset in strength.",
56
+ },
57
+ "deep": {
58
+ "label": "Deep",
59
+ "depth": 18,
60
+ "multipv": 3,
61
+ "detail": "Several times slower. Worth it for a history you intend to "
62
+ "keep and compare against later.",
63
+ },
64
+ }
65
+
66
+ DEFAULT_PRESET = "standard"
67
+
68
+ #: One thread by default, for reproducibility rather than speed.
69
+ DEFAULT_THREADS = 1
70
+ DEFAULT_HASH_MB = 256
71
+
72
+ ADOPT_MODES = ("matching", "any", "none")
73
+
74
+
75
+ class BatchError(RuntimeError):
76
+ """A batch could not run, with a message worth showing."""
77
+
78
+
79
+ def settings_for(preset: str = DEFAULT_PRESET, *, engine_id=None,
80
+ threads: int = DEFAULT_THREADS, hash_mb: int = DEFAULT_HASH_MB):
81
+ """A ChessAnalyzer ``Settings`` pinned to a depth. Raises without the app."""
82
+ review = require_analyzer("review", "Reviewing games")
83
+ chosen = PRESETS.get(preset) or PRESETS[DEFAULT_PRESET]
84
+ return review.Settings(
85
+ engine_id=engine_id,
86
+ preset=preset if preset in PRESETS else DEFAULT_PRESET,
87
+ movetime=0.0, # unused: depth wins in EngineOptions
88
+ depth=chosen["depth"],
89
+ multipv=chosen["multipv"],
90
+ threads=max(1, int(threads)),
91
+ hash_mb=max(16, int(hash_mb)),
92
+ )
93
+
94
+
95
+ def signature(settings) -> str:
96
+ """How a review made with these settings will identify itself."""
97
+ return settings.options().key()
98
+
99
+
100
+ def review_signature(review: dict) -> str:
101
+ """The settings key a saved review was made with, or ``""``."""
102
+ key = ((review or {}).get("engine") or {}).get("settingsKey") or ""
103
+ # "Stockfish 18|t1_h256_pv3_d14" -- the half after the bar is the options.
104
+ return key.split("|", 1)[1] if "|" in key else key
105
+
106
+
107
+ def acceptable(existing: dict | None, wanted: str, adopt: str) -> bool:
108
+ """Can this saved review stand in for one made with the current settings?
109
+
110
+ Only if it was searched the same way. Getting this wrong is exactly the
111
+ quiet failure this app is built to avoid: a report mixing depth-10 and
112
+ depth-18 numbers looks no different from one that does not, and every
113
+ figure in it is wrong. So changing the preset re-reviews -- and because
114
+ the position cache is keyed by settings too, the second preset is the
115
+ only thing actually paid for.
116
+
117
+ ``adopt="any"`` turns the check off for anyone who would rather have the
118
+ speed. The report then says out loud that its settings were mixed.
119
+ """
120
+ if existing is None:
121
+ return False
122
+ if adopt == "any":
123
+ return True
124
+ return review_signature(existing) == wanted
125
+
126
+
127
+ def outstanding(store, games: list, *, preset: str = DEFAULT_PRESET,
128
+ threads: int = DEFAULT_THREADS,
129
+ hash_mb: int = DEFAULT_HASH_MB, adopt: str = "matching") -> dict:
130
+ """How many of these games actually need the engine at these settings."""
131
+ try:
132
+ wanted = signature(settings_for(preset, threads=threads, hash_mb=hash_mb))
133
+ except FeatureUnavailable:
134
+ wanted = ""
135
+ ready = sum(1 for game in games
136
+ if acceptable(store.load_review(game.get("id")), wanted, adopt))
137
+ return {"total": len(games), "ready": ready,
138
+ "outstanding": len(games) - ready, "signature": wanted}
139
+
140
+
141
+ def _record_for(game: dict):
142
+ """A ChessAnalyzer record for one of our games, keeping our own id."""
143
+ sources = require_analyzer("sources", "Reviewing games")
144
+ return sources.record_from_pgn(
145
+ game["pgn"], source=game.get("source", "pgn"),
146
+ game_id=game["id"], url=game.get("url", ""))
147
+
148
+
149
+ def eval_cache(store):
150
+ """The position cache, ours rather than ChessAnalyzer's.
151
+
152
+ Sharing that app's cache file would be a write into another app's folder,
153
+ which this repository's apps do not do to each other. The cost is that the
154
+ first batch cannot reuse positions ChessAnalyzer has already seen; the
155
+ benefit is that neither app can corrupt the other's data.
156
+ """
157
+ library = analyzer("library")
158
+ if library is None:
159
+ return None
160
+ path = store.cache_path()
161
+ path.parent.mkdir(parents=True, exist_ok=True)
162
+ return library.EvalCache(path)
163
+
164
+
165
+ def run(store, games: list, *, preset: str = DEFAULT_PRESET, engine_id=None,
166
+ threads: int = DEFAULT_THREADS, hash_mb: int = DEFAULT_HASH_MB,
167
+ adopt: str = "matching", progress=None, should_stop=None) -> dict:
168
+ """Make sure every game has a review. Returns what it had to do.
169
+
170
+ ``progress(done, total, message)`` is called per game, and ``should_stop``
171
+ is checked between games and inside each one, so a cancelled batch keeps
172
+ every review it has already finished.
173
+ """
174
+ if adopt not in ADOPT_MODES:
175
+ raise BatchError(f"adopt must be one of: {', '.join(ADOPT_MODES)}")
176
+
177
+ review_module = require_analyzer("review", "Reviewing games")
178
+ engines = require_analyzer("engines", "Reviewing games")
179
+
180
+ settings = settings_for(preset, engine_id=engine_id, threads=threads,
181
+ hash_mb=hash_mb)
182
+ wanted = signature(settings)
183
+ cache = eval_cache(store)
184
+
185
+ counts = {"total": len(games), "already": 0, "adopted": 0, "reviewed": 0,
186
+ "failed": 0, "skipped": 0}
187
+ signatures: dict = {}
188
+ failures: list = []
189
+ started = time.time()
190
+
191
+ try:
192
+ for index, game in enumerate(games):
193
+ if should_stop and should_stop():
194
+ counts["skipped"] = len(games) - index
195
+ break
196
+
197
+ game_id = game.get("id")
198
+ if progress:
199
+ progress(index, len(games),
200
+ f"{game.get('white', '?')} vs {game.get('black', '?')}")
201
+
202
+ existing = store.load_review(game_id)
203
+ if acceptable(existing, wanted, adopt):
204
+ counts["already"] += 1
205
+ found = review_signature(existing)
206
+ signatures[found] = signatures.get(found, 0) + 1
207
+ continue
208
+
209
+ offered = game.get("existingReview")
210
+ if offered and adopt != "none" and acceptable(offered, wanted, adopt):
211
+ store.save_review(game_id, offered)
212
+ counts["adopted"] += 1
213
+ found = review_signature(offered)
214
+ signatures[found] = signatures.get(found, 0) + 1
215
+ continue
216
+
217
+ try:
218
+ result = review_module.review(
219
+ _record_for(game), settings, cache=cache,
220
+ should_stop=should_stop)
221
+ except review_module.ReviewCancelled:
222
+ counts["skipped"] = len(games) - index
223
+ break
224
+ except Exception as exc: # noqa: BLE001
225
+ # One unreviewable game must not lose the batch. Record it and
226
+ # carry on; the report counts what it actually has.
227
+ counts["failed"] += 1
228
+ failures.append({"id": game_id, "reason": str(exc)[:200]})
229
+ continue
230
+
231
+ store.save_review(game_id, result)
232
+ counts["reviewed"] += 1
233
+ signatures[wanted] = signatures.get(wanted, 0) + 1
234
+
235
+ if cache is not None:
236
+ cache.save()
237
+ finally:
238
+ # python-chess runs each engine on a non-daemon thread, and CPython
239
+ # joins those before atexit runs -- so a process that leaves one open
240
+ # prints its last line and then hangs for ever with no traceback.
241
+ # Every entry point must do this. See ChessAnalyzer's README.
242
+ try:
243
+ engines.close()
244
+ except Exception: # noqa: BLE001
245
+ pass
246
+
247
+ return {
248
+ "counts": counts,
249
+ "settings": settings.json(),
250
+ "signature": wanted,
251
+ "signatures": signatures,
252
+ "uniform": len(signatures) <= 1,
253
+ "failures": failures[:20],
254
+ "elapsed": round(time.time() - started, 1),
255
+ }
256
+
257
+
258
+ def load_reviews(store, games: list) -> dict:
259
+ """``{game id: review}`` for every game that has one on disk."""
260
+ out = {}
261
+ for game in games:
262
+ review = store.load_review(game.get("id"))
263
+ if review is not None:
264
+ out[game["id"]] = review
265
+ return out
266
+
267
+
268
+ def estimate(games: list, preset: str = DEFAULT_PRESET, *,
269
+ already: int = 0) -> dict:
270
+ """A rough time for a batch, so the button can say what it will cost.
271
+
272
+ Deliberately crude and labelled as such: engine speed varies by an order
273
+ of magnitude across machines, and a number with a plus-or-minus on it is
274
+ more honest than a spinner with no number at all.
275
+ """
276
+ chosen = PRESETS.get(preset) or PRESETS[DEFAULT_PRESET]
277
+ seconds_each = {10: 3.0, 14: 12.0, 18: 60.0}.get(chosen["depth"], 12.0)
278
+ outstanding = max(0, len(games) - already)
279
+ return {
280
+ "games": len(games),
281
+ "outstanding": outstanding,
282
+ "secondsPerGame": seconds_each,
283
+ "seconds": int(outstanding * seconds_each),
284
+ "rough": True,
285
+ }
286
+
287
+
288
+ __all__ = [
289
+ "ADOPT_MODES",
290
+ "acceptable",
291
+ "DEFAULT_PRESET",
292
+ "PRESETS",
293
+ "BatchError",
294
+ "FeatureUnavailable",
295
+ "estimate",
296
+ "eval_cache",
297
+ "load_reviews",
298
+ "outstanding",
299
+ "review_signature",
300
+ "run",
301
+ "settings_for",
302
+ "signature",
303
+ ]
@@ -0,0 +1,78 @@
1
+ """Board diagrams for the browser, drawn server-side as SVG.
2
+
3
+ A worst moment is only understandable as a picture, and this app shows a dozen
4
+ of them. python-chess draws the board itself, so this needs nothing beyond
5
+ the core dependency.
6
+
7
+ Unlike the sibling apps there is nothing to play here: a weakness report is a
8
+ document about games already finished, so the board is a diagram and stays
9
+ one. The palette matches the rest of the repository so a position looks the
10
+ same on screen as it does in the printed report.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import chess
16
+ import chess.svg
17
+
18
+ BOARD_COLORS = {
19
+ "square light": "#f0d9b5",
20
+ "square dark": "#b58863",
21
+ "square light lastmove": "#cdd26a",
22
+ "square dark lastmove": "#aaa23a",
23
+ "margin": "#f7f2e8",
24
+ "coord": "#5c4a33",
25
+ "arrow green": "#15781baa",
26
+ "arrow red": "#882020aa",
27
+ "arrow yellow": "#e68f00aa",
28
+ "arrow blue": "#003088aa",
29
+ }
30
+
31
+
32
+ def _arrow(uci: str, colour: str):
33
+ if not uci or len(uci) < 4:
34
+ return None
35
+ try:
36
+ move = chess.Move.from_uci(uci[:5])
37
+ except ValueError:
38
+ return None
39
+ return chess.svg.Arrow(move.from_square, move.to_square, color=colour)
40
+
41
+
42
+ def board_svg(fen: str, *, size: int = 320, flipped: bool = False,
43
+ arrows: str = "", coordinates: bool = True) -> str:
44
+ """One position.
45
+
46
+ ``arrows`` is the compact query form ``red:e2e4,green:g1f3``. Throughout
47
+ this app **red is the move you played** and **green is the move the engine
48
+ wanted**, which is the only pair of colours a reader needs to learn.
49
+ """
50
+ board = chess.Board(fen)
51
+
52
+ shapes = []
53
+ for item in filter(None, (arrows or "").split(",")):
54
+ colour, _, uci = item.partition(":")
55
+ arrow = _arrow(uci, colour or "green")
56
+ if arrow is not None:
57
+ shapes.append(arrow)
58
+
59
+ return chess.svg.board(
60
+ board, size=size,
61
+ orientation=chess.BLACK if flipped else chess.WHITE,
62
+ arrows=shapes, coordinates=coordinates,
63
+ check=board.king(board.turn) if board.is_check() else None,
64
+ colors=BOARD_COLORS)
65
+
66
+
67
+ def uci_of(fen: str, san: str) -> str:
68
+ """The UCI for a SAN move in a position, or ``""``. Used to draw arrows."""
69
+ if not san:
70
+ return ""
71
+ try:
72
+ board = chess.Board(fen)
73
+ return board.parse_san(san).uci()
74
+ except (ValueError, AssertionError):
75
+ return ""
76
+
77
+
78
+ __all__ = ["BOARD_COLORS", "board_svg", "uci_of"]