-
Notifications
You must be signed in to change notification settings - Fork 19
/
gui.py
401 lines (349 loc) · 20.4 KB
/
gui.py
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
import os
import webbrowser
from typing import List
import dearpygui.dearpygui as dpg
import psutil
from BookBuilder import Grower
from gui_generation_status import GenerationStatus
from gui_themes import set_imgui_light_theme
from settings import Settings, Speed, Rating, Book, Order, Variant
WINDOW_WIDTH = 980
WINDOW_HEIGHT = 720
SETTINGS_GROUP_XOFFSET = 180
PRIMARY_WINDOW_TAG = "primary_window"
BLOG_LINK = "https://www.alexcrompton.com/blog/automatically-creating-a-practical-opening-repertoire-or-why-your-chess-openings-suck"
SOURCE_CODE_LINK = "https://github.com/raccrompton/BookBuilder"
class Gui:
def __init__(self, settings: Settings, grower: Grower):
self.settings = settings
self.grower = grower
def create(self):
dpg.create_context()
dpg.create_viewport(
title='BookBuilder',
width=WINDOW_WIDTH,
min_width=WINDOW_WIDTH,
height=WINDOW_HEIGHT,
x_pos=0,
y_pos=0)
set_imgui_light_theme()
self._create_primary_window()
dpg.set_exit_callback(callback=self._shutdown_callback)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
dpg.destroy_context()
def _shutdown_callback(self):
self.grower.stop()
def _create_primary_window(self):
with dpg.window(tag=PRIMARY_WINDOW_TAG):
dpg.set_primary_window(PRIMARY_WINDOW_TAG, True)
self._menu_bar()
self._summary()
self._book_settings()
self._database_settings()
self._move_selection_settings()
self._engine_settings()
def _menu_bar(self):
def reload_settings_and_restart_gui():
self.settings.load_from_file()
# the easiest way to show reloaded settings is to destroy the gui and recreate it
dpg.delete_item(PRIMARY_WINDOW_TAG)
self._create_primary_window()
with dpg.menu_bar():
with dpg.menu(label="Settings"):
dpg.add_menu_item(label="Load", callback=reload_settings_and_restart_gui)
dpg.add_menu_item(label="Save", callback=lambda: self.settings.save_to_file())
with dpg.menu(label="Help"):
_menu_link("Announcement blog post and FAQ", BLOG_LINK)
_menu_link("Source code", SOURCE_CODE_LINK)
def _summary(self):
s = self.settings
dpg.add_text("An automatic practical chess opening repertoire builder using Lichess opening explorer API")
dpg.add_text("Customize your settings and then press the button below to begin generating your repertoire")
status = GenerationStatus()
def get_invalid_books() -> List[Book]:
invalid_books = list()
for book in s.book.get_books():
if not book.is_valid_pgn():
invalid_books.append(book)
return invalid_books
def start_generation(button_tag):
# validate engine path
if s.engine.enabled:
if s.engine.path == s.engine.NO_FILE_SELECTED:
status.error("No engine path was provided",
"Provide it under 'Engine settings' or unselect 'Use engine' and retry")
return
if not os.path.exists(s.engine.path):
status.error(f"Provided engine path '{s.engine.path}' does not exist\n",
"Correct it under 'Engine settings' or unselect 'Use engine' and retry")
return
# validate books from free-text input are valid
invalid_books = get_invalid_books()
if len(invalid_books) > 0:
status.error("Book(s) listed below are invalid. Correct them under 'Book settings' and retry\n",
"\n\n".join([book.__str__() for book in invalid_books]))
return
def finish_callback():
dpg.enable_item(button_tag)
status.info("Finished generating your repertoire",
"You will find your PGNs in the same folder where BookBuilder is located")
status.info("PGN generation started")
dpg.disable_item(button_tag)
self.grower.run(self.settings, status, finish_callback)
dpg.add_button(label="Generate PGN", width=120, height=30, before=status._line1, callback=start_generation)
def _book_settings(self):
s = self.settings.book
with dpg.group():
with dpg.collapsing_header(label="Book settings", default_open=True):
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Variations order")
_help(
"Choose whether you want chapters ordered from long lines to short lines or the opposite way")
dpg.add_combo(items=[str(o.value) for o in Order], default_value=s.order.value,
callback=s.order_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Opening books")
_help("Add the starting point PGNs you want to create repertoires for\n"
"The format is book name, new line, PGN, new line(s), like so:\n\n"
"Book A\n"
"1. e4 e5\n\n"
"Book B\n"
"1. e4 e5 2. f4\n\n"
"Tip: Copying and pasting with keyboard shortcuts works in this input!")
dpg.add_input_text(multiline=True,
tab_input=True,
height=200,
default_value=s.books_string,
callback=s.books_string_callback)
def _database_settings(self):
s = self.settings.database
with dpg.group():
with dpg.collapsing_header(label="Database settings", default_open=True):
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Variant")
_help("Variant to use in the analysis")
dpg.add_combo(
items=[v.name for v in Variant],
default_value=s.variant.name,
callback=s.variant_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
with dpg.group():
dpg.add_text("Speeds")
_help("Formats to include in the analysis")
with dpg.group():
for speed in Speed:
dpg.add_selectable(
label=speed.name,
user_data=speed.name,
default_value=s.speeds.__contains__(speed),
callback=s.speed_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Ratings")
_help("Ratings of the players to include in the analysis")
with dpg.group():
for rating in Rating:
dpg.add_selectable(
label=str(rating.value),
user_data=rating.name,
default_value=s.ratings.__contains__(rating),
callback=s.rating_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Moves")
_help("The number of most played moves to search over for the best move (minimum 5)")
dpg.add_input_int(
min_value=5,
max_value=100,
min_clamped=True,
default_value=s.moves,
callback=s.moves_callback)
def _move_selection_settings(self):
s = self.settings.moveSelection
with dpg.group():
with dpg.collapsing_header(label="Move selection settings", default_open=True):
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Depth likelihood %")
_help("This controls how deep moves and lines are generated\n"
"The smaller the number the deeper the lines\n"
"Once cumulative line likelihood reaches this probability threshold, no further continuations will be added\n"
"E.g. for 1% only moves that appear at least once every 100 games will be considered")
dpg.add_input_float(
min_value=0,
max_value=10,
min_clamped=True,
format='%.4f',
step=0.01,
step_fast=0.1,
default_value=s.depth_likelihood * 100,
callback=s.depth_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Alpha %")
_help("The larger this number the more likely we are to select moves with less data\n"
"This is the confidence interval alpha (e.g. 1 = 99% CI), for deciding the lower bounds of how good a move's winrate is")
dpg.add_input_float(
min_value=0,
max_value=10,
min_clamped=True,
format='%.3f',
step=0.01,
step_fast=0.1,
default_value=s.alpha * 100,
callback=s.alpha_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Minimum play rate %")
_help(
"Minimum probability of our move being played in a position to be considered as a 'best move' candidate")
dpg.add_input_float(
min_value=0,
max_value=10,
min_clamped=True,
format='%.3f',
step=0.01,
step_fast=0.1,
default_value=s.min_play_rate * 100,
callback=s.min_play_rate_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Minimum games")
_help(
"Games where our moves were played this or fewer times will be discarded (unless top engine move)")
dpg.add_input_int(
min_value=0,
min_clamped=True,
default_value=s.min_games,
callback=s.min_games_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Continuation games")
_help(
"Games where moves played this or fewer times will not be considered a valid opponent continuation\n"
"I.e. we don't want to be inferring cumulative probability or likely lines from tiny amounts of games/1 game")
dpg.add_input_int(
min_value=0,
min_clamped=True,
default_value=s.continuation_games,
callback=s.continuation_games_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Draws are half")
_help(
"Select this if you want to count draws as half a win (0.5 points) for the win rate calculation\n"
"When not selected draws will count as as losses")
dpg.add_checkbox(default_value=s.draws_are_half, callback=s.draws_are_half_callback)
def _engine_settings(self):
s = self.settings.engine
with dpg.group():
with dpg.collapsing_header(label="Engine settings", default_open=False):
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Use engine")
_help("Select this if you want to use engine evaluations of positions or engine finishing")
dpg.add_checkbox(default_value=s.enabled, callback=s.enabled_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Engine path")
_help("Select where the engine is stored on your computer\n"
"It should be a file named similar to 'stockfish_15_x64_avx2.exe'")
with dpg.group():
with dpg.group(horizontal=True):
engine_path_text = dpg.add_text(s.path)
def call_path_callback_and_update_path_in_gui(_, file_selections):
s.path_callback(file_selections)
dpg.set_value(engine_path_text, s.path)
with dpg.file_dialog(label="Select engine file",
width=WINDOW_WIDTH - 100,
height=WINDOW_HEIGHT - 100,
show=False,
callback=call_path_callback_and_update_path_in_gui):
dpg.add_file_extension(".*")
dpg.add_file_extension("", color=(0, 0, 255, 255))
dpg.add_file_extension(".exe", color=(0, 180, 0, 255))
dpg.add_button(label="Select engine file",
user_data=dpg.last_container(),
callback=lambda _, a, u: dpg.configure_item(u, show=True))
with dpg.group(horizontal=True):
dpg.add_text("You can download the latest Stockfish executable from here:")
dpg.add_button(
label="Download Stockfish",
callback=lambda: webbrowser.open("https://stockfishchess.org/download/"))
dpg.add_text(
"On Mac download engine only (not the app) and run `which stockfish` in Terminal to see where it's installed")
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Engine finish")
_help(
"Select to allow the engine to complete lines upto the cumulative likelihood, where human data doesn't meet the minimum criteria\n"
"When not selected lines will end when there is no good human data for one player")
dpg.add_checkbox(default_value=s.finish, callback=s.finish_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Engine depth")
_help("To what depth the engine should evaluate best moves\n"
"The higher this number the longer the evaluation will take\n\n"
"RECOMMENDED a minimum of 20+, ideally 30+ for stable evaluations in the opening phase")
dpg.add_input_int(
min_value=1,
min_clamped=True,
default_value=s.depth,
callback=s.depth_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
logical_cores = psutil.cpu_count(logical=True)
dpg.add_text("Engine threads")
_help("How many threads you want the engine to use\n"
"Increase this number to speed the engine up, at the cost of higher CPU usage\n\n"
f"Your processor has {logical_cores} logical cores")
dpg.add_input_int(
min_value=1,
max_value=logical_cores,
min_clamped=True,
max_clamped=True,
default_value=s.threads,
callback=s.threads_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
total_ram_in_mb = int(psutil.virtual_memory().total / 1024 / 1024)
available_ram_in_mb = int(psutil.virtual_memory().available / 1024 / 1024)
dpg.add_text("Engine hash in MB")
_help("How much RAM you want the engine to use\n"
"Increase this number to speed the engine up\n\n"
f"You have {total_ram_in_mb} MB total RAM and around {available_ram_in_mb} MB available (unused) RAM")
dpg.add_input_int(
min_value=16,
max_value=total_ram_in_mb,
min_clamped=True,
max_clamped=True,
default_value=s.hash,
callback=s.hash_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Soundness limit")
_help(
"Maximum centipawns we are willing to be down in engine eval, provided the winrate is better (-300 = losing by 3 pawns in eval)\n"
"We never give up a forced mate, however\n\n"
"Example:\n"
"Move A has 60% human win rate at -1.1 engine evaluation\n"
"Move B has 55% human win rate at -0.9 engine evaluation\n"
"With soundness limit of -99 centipawns (-0.99) we will select move B, as -1.1 exceeds -0.99")
dpg.add_input_int(default_value=s.soundness_limit, callback=s.soundness_limit_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Move loss limit")
_help(
"Maximum centipawns we are willing to lose per move in favor of a higher human winrate move, compared to the top engine choice\n"
"We never give up a forced mate, however\n\n"
"RECOMMENDED to not go closer to 0 than -50 because engines are inconsistent when evaluating openings, especially on low depth\n\n"
"Example:\n"
"Move A has 80% human win rate at 1.0 engine evaluation\n"
"Move B has 60% human win rate at 2.0 engine evaluation\n"
"With move loss limit of -99 centipawns (-0.99) we will select move B, as 1.0-2.0=-1.0 exceeds -0.99")
dpg.add_input_int(default_value=s.move_loss_limit, callback=s.move_loss_limit_callback)
with dpg.group(horizontal=True, xoffset=SETTINGS_GROUP_XOFFSET):
dpg.add_text("Ignore loss limit")
_help(
"Centipawns advantage above which we won't care if we play a move that hits our loss limit, if it has a higher human win rate\n\n"
"Example:\n"
"Move A has 80% human win rate at 3.2 engine evaluation\n"
"Move B has 60% human win rate at 5.7 engine evaluation\n"
"With ignore loss limit of 300 centipawns (3.0) we will select move A, as 3.2 exceeds 3.0")
dpg.add_input_int(default_value=s.ignore_loss_limit, callback=s.ignore_loss_limit_callback)
def _menu_link(label: str, url: str):
dpg.add_menu_item(label=label, callback=lambda: webbrowser.open(url))
def _help(message: str):
last_item = dpg.last_item()
group = dpg.add_group(horizontal=True)
dpg.move_item(last_item, parent=group)
dpg.capture_next_item(lambda s: dpg.move_item(s, parent=group))
t = dpg.add_text("(?)", color=[0.26 * 255, 0.59 * 255, 0.98 * 255, 255])
with dpg.tooltip(t):
dpg.add_text(message)