ipyvasp 0.9.90__py2.py3-none-any.whl → 0.9.93__py2.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.
ipyvasp/widgets.py CHANGED
@@ -4,29 +4,24 @@ __all__ = [
4
4
  "Files",
5
5
  "PropsPicker",
6
6
  "BandsWidget",
7
- "KpathWidget",
7
+ "KPathWidget",
8
8
  ]
9
9
 
10
10
 
11
11
  import inspect, re
12
- from time import time
13
12
  from pathlib import Path
14
13
  from collections.abc import Iterable
15
14
  from functools import partial
16
- from pprint import pformat
17
15
 
18
16
  # Widgets Imports
19
17
  from IPython.display import display
20
- import ipywidgets as ipw
21
18
  from ipywidgets import (
22
19
  Layout,
23
20
  Button,
24
- Box,
25
21
  HBox,
26
22
  VBox,
27
23
  Dropdown,
28
24
  Text,
29
- Checkbox,
30
25
  Stack,
31
26
  SelectMultiple,
32
27
  )
@@ -34,7 +29,10 @@ from ipywidgets import (
34
29
  # More imports
35
30
  import numpy as np
36
31
  import pandas as pd
32
+ import ipywidgets as ipw
33
+ import traitlets
37
34
  import plotly.graph_objects as go
35
+ import einteract as ei
38
36
 
39
37
  # Internal imports
40
38
  from . import utils as gu
@@ -100,6 +98,7 @@ def fix_signature(cls):
100
98
  cls.__signature__ = inspect.signature(cls.__init__)
101
99
  return cls
102
100
 
101
+ @fix_signature
103
102
  class Files:
104
103
  """Creates a Batch of files in a directory recursively based on glob pattern or given list of files.
105
104
  This is a boilerplate abstraction to do analysis in multiple calculations simultaneously.
@@ -201,7 +200,7 @@ class Files:
201
200
  lines = [(k,v) for k,v in lines if k in tags]
202
201
  d = {k:v for k,v in lines if not k.startswith('#')}
203
202
  d.update({k:len(v) for k,v in p.types.items()})
204
- d.update(zip('abcvαβγ', [*p.norms,p.volume,*p.angles]))
203
+ d.update(zip(['a','b','c','v','alpha','beta','gamma'], [*p.norms,p.volume,*p.angles]))
205
204
  return d
206
205
 
207
206
  return self.with_name('POSCAR').summarize(info, tags=tags)
@@ -218,6 +217,18 @@ class Files:
218
217
  if old in dd.options:
219
218
  dd.value = old
220
219
 
220
+ def to_dropdown(self,description='File'):
221
+ """
222
+ Convert this instance to Dropdown. If there is only one file, adds an
223
+ empty option to make that file switchable.
224
+ Options of this dropdown are update on calling `Files.update` method."""
225
+ if hasattr(self,'_dd'):
226
+ return self._dd # already created
227
+
228
+ options = self._files if len(self._files) != 1 else ['', *self._files] # make single file work
229
+ self._dd = Dropdown(description=description, options=options)
230
+ return self._dd
231
+
221
232
  def add(self, path_or_files, glob = '*', exclude=None, **kwargs):
222
233
  """Add more files or with a diffrent glob on top of exitsing files. Returns same instance.
223
234
  Useful to add multiple globbed files into a single chained call.
@@ -230,190 +241,39 @@ class Files:
230
241
  def _unique(self, *files_tuples):
231
242
  return tuple(np.unique(np.hstack(files_tuples)))
232
243
 
233
- def interactive(self, func, *args,
234
- free_widgets=None,
235
- options={"manual": False},
236
- height="400px",
237
- panel_size = 25,
238
- **kwargs):
239
- """
240
- Interact with `func(path, *args, <free_widgets,optional>, **kwargs)` that takes selected Path as first argument. Returns a widget that saves attributes of the function call such as .f, .args, .kwargs.
241
- `args` are widgets to be modified by func, such as plotly's FigureWidget.
242
- Note that `free_widgets` can also be passed to function but does not run function when changed.
243
-
244
- See docs of self.interact for more details on the parameters. kwargs are passed to ipywidgets.interactive to create controls.
245
-
246
- >>> import plotly.graph_objects as go
247
- >>> import ipyvasp as ipv
248
- >>> fs = ipv.Files('.','**/POSCAR')
249
- >>> def plot(path, fig, bl,plot_cell,eqv_sites):
250
- >>> ipv.iplot2widget(ipv.POSCAR(path).iplot_lattice(
251
- >>> bond_length=bl,plot_cell=plot_cell,eqv_sites=eqv_sites
252
- >>> ),fig_widget=fig) # it keeps updating same view
253
- >>> out = fs.interactive(plot, go.FigureWidget(),bl=(0,6,2),plot_cell=True, eqv_sites=True)
254
-
255
- >>> out.f # function
256
- >>> out.args # arguments
257
- >>> out.kwargs # keyword arguments
258
- >>> out.result # result of function call which is same as out.f(*out.args, **out.kwargs)
259
-
260
- .. note::
261
- If you don't need to interpret the result of the function call, you can use the @self.interact decorator instead.
262
- """
263
- info = ipw.HTML().add_class("fprogess")
264
- dd = Dropdown(description='File', options=['',*self._files]) # allows single file workable
265
-
266
- def interact_func(fname, **kws):
267
- if fname and str(fname) != '': # This would be None if no file is selected
268
- info.value = _progress_svg
269
- try:
270
- start = time()
271
- if 'free_widgets' in func.__code__.co_varnames:
272
- kws['free_widgets'] = free_widgets # user allowed to pass this
273
- names = ', '.join(func.__code__.co_varnames)
274
- print(f"Running {func.__name__}({names})")
275
- func(Path(fname).absolute(), *args, **kws) # Have Path object absolue if user changes directory
276
- print(f"Finished in {time() - start:.3f} seconds.")
277
- finally:
278
- info.value = ""
279
-
280
- out = ipw.interactive(interact_func, options, fname=dd, **kwargs)
281
- out._dd = dd # save reference to dropdown
282
- out.output_widget = out.children[-1] # save reference to output widget for other widgets to use
283
-
284
- if options.get("manual", False):
285
- out.interact_button = out.children[-2] # save reference to interact button for other widgets to use
286
-
287
- output = out.children[-1] # get output widget
288
- output.clear_output(wait=True) # clear output by waiting to avoid flickering, this is important
289
- output.layout = Layout(
290
- overflow="auto", max_height="100%", width="100%"
291
- ) # make output scrollable and avoid overflow
292
-
293
- others = out.children[1:-1] # exclude files_dd and Output widget
294
- if not isinstance(panel_size,int):
295
- raise TypeError('panel_size should be integer in units of em')
244
+ @_sub_doc(ei.interactive)
245
+ def interactive(self, *funcs, auto_update=True, app_layout=None, grid_css={},**kwargs):
246
+ if 'file' in kwargs:
247
+ raise KeyError("file is a reserved keyword argument to select path to file!")
296
248
 
297
- _style = f"""<style>
298
- .files-interact {{
299
- --jp-widgets-inline-label-width: 4em;
300
- --jp-widgets-inline-width: {panel_size-2}em;
301
- --jp-widgets-inline-width-short: 9em;
302
- }}
303
- .files-interact {{max-height:{height};width:100%;}}
304
- .files-interact > div {{overflow:auto;max-height:100%;padding:8px;}}
305
- .files-interact > div:first-child {{width:{panel_size}em}}
306
- .files-interact > div:last-child {{width:calc(100% - {panel_size}em)}}
307
- .files-interact .fprogess {{position:absolute !important; left:50%; top:50%; transform:translate(-50%,-50%); z-index:1}}
308
- </style>"""
309
- if others:
310
- others = [ipw.HTML(f"<hr/>{_style}"), *others]
311
- else:
312
- others = [ipw.HTML(_style)]
313
-
314
- if free_widgets and not isinstance(free_widgets, (list, tuple)):
315
- raise TypeError("free_widgets must be a list or tuple of widgets.")
249
+ has_file_param = False
250
+ for func in funcs:
251
+ if not callable(func):
252
+ raise TypeError(f"Each item in *funcs should be callable, got {type(func)}")
253
+ params = [k for k,v in inspect.signature(func).parameters.items()]
254
+ for key in params:
255
+ if key == 'file':
256
+ has_file_param = True
257
+ break
316
258
 
317
- for w in args:
318
- if not isinstance(w,ipw.DOMWidget):
319
- raise TypeError(f'args can only contain a DOMWidget instance, got {type(w)}')
259
+ if funcs and not has_file_param: # may be no func yet, that is test below
260
+ raise KeyError("At least one of funcs should take 'file' as parameter, none got it!")
320
261
 
321
- if args:
322
- output.layout.max_height = "200px"
323
- output.layout.min_height = "8em" # fisrt fix
324
- out_collapser = Checkbox(description="Hide output widget", value=False)
325
-
326
- def toggle_output(change):
327
- if out_collapser.value:
328
- output.layout.height = "0px" # dont use display = 'none' as it will clear widgets and wont show again
329
- output.layout.min_height = "0px"
330
- else:
331
- output.layout.height = "auto"
332
- output.layout.min_height = "8em"
333
-
334
- out_collapser.observe(toggle_output, "value")
335
- others.append(out_collapser)
336
-
337
- # This should be below output collapser
338
- others = [
339
- *others,
340
- ipw.HTML(f"<hr/>"),
341
- *(free_widgets or []),
342
- ] # add hr to separate other controls
343
-
344
- out.children = [
345
- HBox([ # reset children to include new widgets
346
- VBox([dd, VBox(others)]), # other widgets in box to make scrollable independent file selection
347
- VBox([Box([output]), *args, info]), # output in box to make scrollable,
348
- ],layout=Layout(height=height, max_height=height),
349
- ).add_class("files-interact")
350
- ] # important for every widget separately
351
- return out
262
+ return ei.interactive(*funcs,auto_update=auto_update, app_layout = app_layout, grid_css=grid_css, file = self.to_dropdown(), **kwargs)
352
263
 
353
- def _attributed_interactive(self, box, func, *args, **kwargs):
354
- box._files = self
355
- box._interact = self.interactive(func, *args, **kwargs)
356
- box.children = box._interact.children
357
- box._files._dd = box._interact._dd
358
-
359
- def interact(self, *args,
360
- free_widgets=None,
361
- options={"manual": False},
362
- height="400px",
363
- panel_size=25,
364
- **kwargs,
365
- ):
366
- """Interact with a `func(path, *args, <free_widgets,optional>, **kwargs)`. `path` is passed from selected File.
367
- A CSS class 'files-interact' is added to the final widget to let you style it.
368
-
369
- Parameters
370
- ----------
371
- args :
372
- Any displayable widget can be passed. These are placed below the output widget of interact.
373
- For example you can add plotly's FigureWidget that updates based on the selection, these are passed to function after path.
374
- free_widgets : list/tuple
375
- Default is None. If not None, these are assumed to be ipywidgets and are placed below the widgets created by kwargs.
376
- These can be passed to the decorated function if added as arguemnt there like `func(..., free_widgets)`, but don't trigger execution.
377
- options : dict
378
- Default is {'manual':False}. If True, the decorated function is not called automatically, and you have to call it manually on button press. You can pass button name as 'manual_name' in options.
379
- height : str
380
- Default is '90vh'. height of the final widget. This is important to avoid very long widgets.
381
- panel_size: int
382
- Side panel size in units of em.
383
-
384
- kwargs are passed to ipywidgets.interactive and decorated function. Resulting widgets are placed below the file selection widget.
385
- Widgets in `args` can be controlled by `free_widgets` externally if defined gloablly or inside function if you pass `free_widgets` as argument like `func(..., free_widgets)`.
386
-
387
- The decorated function can be called later separately as well, and has .args and .kwargs attributes to access the latest arguments
388
- and .result method to access latest. For a function `f`, `f.result` is same as `f(*f.args, **f.kwargs)`.
389
-
390
- >>> import plotly.graph_objects as go
391
- >>> import ipyvasp as ipv
392
- >>> fs = ipv.Files('.','**/POSCAR')
393
- >>> @fs.interact(go.FigureWidget(),bl=(0,6,2),plot_cell=True, eqv_sites=True)
394
- >>> def plot(path, fig, bl,plot_cell,eqv_sites):
395
- >>> ipv.iplot2widget(ipv.POSCAR(path).iplot_lattice(
396
- >>> bond_length=bl,plot_cell=plot_cell,eqv_sites=eqv_sites
397
- >>> ),fig_widget=fig) # it keeps updating same view
398
-
399
- .. note::
400
- Use self.interactive to get a widget that stores the argements and can be called later in a notebook cell.
401
- """
402
-
264
+ @_sub_doc(ei.interact)
265
+ def interact(self, *funcs, auto_update=True, app_layout=None, grid_css={},**kwargs):
403
266
  def inner(func):
404
- display(self.interactive(func, *args,
405
- free_widgets=free_widgets,
406
- options=options,
407
- height=height,
408
- panel_size=panel_size,
267
+ display(self.interactive(func, *funcs,
268
+ auto_update=auto_update, app_layout = app_layout, grid_css=grid_css,
409
269
  **kwargs)
410
270
  )
411
271
  return func
412
272
  return inner
413
273
 
414
274
  def kpath_widget(self, height='400px'):
415
- "Get KpathWidget instance with these files."
416
- return KpathWidget(files = self.with_name('POSCAR'), height = height)
275
+ "Get KPathWidget instance with these files."
276
+ return KPathWidget(files = self.with_name('POSCAR'), height = height)
417
277
 
418
278
  def bands_widget(self, height='450px'):
419
279
  "Get BandsWidget instance with these files."
@@ -468,86 +328,103 @@ class Files:
468
328
  return self.summarize(lambda path: {"size": get_file_size(path)})
469
329
 
470
330
 
331
+
471
332
  @fix_signature
472
333
  class _PropPicker(VBox):
334
+ """Single projection picker with atoms and orbitals selection"""
335
+ props = traitlets.Dict({})
336
+
473
337
  def __init__(self, system_summary=None):
474
338
  super().__init__()
475
- self._widgets = {
476
- "atoms": Dropdown(description="Atoms"),
477
- "orbs": Dropdown(description="Orbs"),
478
- }
479
- self._html = ipw.HTML() # to observe
480
-
481
- def observe_change(change):
482
- self._html.value = change.new # is a string
483
-
484
- self._widgets["atoms"].observe(observe_change, "value")
485
- self._widgets["orbs"].observe(observe_change, "value")
486
-
487
- self._atoms = {}
488
- self._orbs = {}
339
+ self._atoms = Dropdown(description="Atoms")
340
+ self._orbs = Dropdown(description="Orbs")
341
+ self.children = [self._atoms, self._orbs]
342
+ self._atoms_map = {}
343
+ self._orbs_map = {}
344
+
345
+ # Link changes
346
+ self._atoms.observe(self._update_props, 'value')
347
+ self._orbs.observe(self._update_props, 'value')
489
348
  self._process(system_summary)
490
349
 
350
+ def _update_props(self, change):
351
+ """Update props trait when selections change"""
352
+ atoms = self._atoms_map.get(self._atoms.value, [])
353
+ orbs = self._orbs_map.get(self._orbs.value, [])
354
+
355
+ if atoms and orbs:
356
+ self.props = {
357
+ 'atoms': atoms, 'orbs': orbs,
358
+ 'label': f"{self._atoms.value or ''}-{self._orbs.value or ''}"
359
+ }
360
+ else:
361
+ self.props = {}
362
+
491
363
  def _process(self, system_summary):
492
- if not hasattr(system_summary, "orbs"):
493
- self.children = [
494
- ipw.HTML(f"❌ No projection data found from given summary!")
495
- ]
496
- return None
364
+ """Process system data and setup widget options"""
365
+ if system_summary is None or not hasattr(system_summary, "orbs"):
366
+ self.children = [ipw.HTML("❌ No projection data found!")]
367
+ return
497
368
 
498
- self.children = [self._widgets["atoms"], self._widgets["orbs"]]
499
369
  sorbs = system_summary.orbs
370
+ self._orbs_map = {"-": [], "All": range(len(sorbs)), "s": [0]}
500
371
 
501
- orbs = {"-": [], "All": range(len(sorbs)), "s": [0]}
372
+ # p-orbitals
502
373
  if set(["px", "py", "pz"]).issubset(sorbs):
503
- orbs["p"] = range(1, 4)
504
- orbs["px+py"] = [
505
- idx for idx, key in enumerate(sorbs) if key in ("px", "py")
506
- ]
507
- orbs.update({k: [v] for k, v in zip(sorbs[1:], range(1, 4))})
374
+ self._orbs_map.update({
375
+ "p": range(1, 4),
376
+ "px+py": [idx for idx, key in enumerate(sorbs) if key in ("px", "py")],
377
+ **{k: [v] for k, v in zip(sorbs[1:4], range(1, 4))}
378
+ })
379
+
380
+ # d-orbitals
508
381
  if set(["dxy", "dyz"]).issubset(sorbs):
509
- orbs["d"] = range(4, 9)
510
- orbs.update({k: [v] for k, v in zip(sorbs[4:], range(4, 9))})
382
+ self._orbs_map.update({
383
+ "d": range(4, 9),
384
+ **{k: [v] for k, v in zip(sorbs[4:9], range(4, 9))}
385
+ })
386
+
387
+ # f-orbitals
511
388
  if len(sorbs) == 16:
512
- orbs["f"] = range(9, 16)
513
- orbs.update({k: [v] for k, v in zip(sorbs[9:], range(9, 16))})
514
- if len(sorbs) > 16: # What the hell here
515
- orbs.update({k: [idx] for idx, k in enumerate(sorbs[16:], start=16)})
516
-
517
- self._orbs = orbs
518
- old_orb = self._widgets["orbs"].value
519
- self._widgets["orbs"].options = list(orbs.keys())
520
- if old_orb in self._widgets["orbs"].options:
521
- self._widgets["orbs"].value = old_orb
522
-
523
- atoms = {"-": [], "All": range(system_summary.NIONS)}
524
- for key, tp in system_summary.types.to_dict().items():
525
- atoms[key] = tp
526
- for n, v in enumerate(tp, start=1):
527
- atoms[f"{key}{n}"] = [v]
528
-
529
- self._atoms = atoms
530
- old_atom = self._widgets["atoms"].value
531
- self._widgets["atoms"].options = list(atoms.keys())
532
- if old_atom in self._widgets["atoms"].options:
533
- self._widgets["atoms"].value = old_atom
389
+ self._orbs_map.update({
390
+ "f": range(9, 16),
391
+ **{k: [v] for k, v in zip(sorbs[9:16], range(9, 16))}
392
+ })
393
+
394
+ # Extra orbitals beyond f
395
+ if len(sorbs) > 16:
396
+ self._orbs_map.update({
397
+ k: [idx] for idx, k in enumerate(sorbs[16:], start=16)
398
+ })
399
+
400
+ self._orbs.options = list(self._orbs_map.keys())
401
+
402
+ # Process atoms
403
+ self._atoms_map = {
404
+ "-": [],
405
+ "All": range(system_summary.NIONS),
406
+ **{k: v for k,v in system_summary.types.to_dict().items()},
407
+ **{f"{k}{n}": [v] for k,tp in system_summary.types.to_dict().items()
408
+ for n,v in enumerate(tp, 1)}
409
+ }
410
+ self._atoms.options = list(self._atoms_map.keys())
411
+ self.children = [self._atoms, self._orbs]
412
+ self._update_props(None) # then props trigger top projections
534
413
 
535
414
  def update(self, system_summary):
536
- return self._process(system_summary)
537
-
538
- @property
539
- def props(self):
540
- items = {k: w.value for k, w in self._widgets.items()}
541
- items["atoms"] = self._atoms.get(items["atoms"], [])
542
- items["orbs"] = self._orbs.get(items["orbs"], [])
543
- items[
544
- "label"
545
- ] = f"{self._widgets['atoms'].value or ''}-{self._widgets['orbs'].value or ''}"
546
- return items
547
-
415
+ """Update widget with new system data while preserving selections"""
416
+ old_atoms = self._atoms.value
417
+ old_orbs = self._orbs.value
418
+ self._process(system_summary)
419
+
420
+ # Restore previous selections if still valid
421
+ if old_atoms in self._atoms.options:
422
+ self._atoms.value = old_atoms
423
+ if old_orbs in self._orbs.options:
424
+ self._orbs.value = old_orbs
548
425
 
549
426
  @fix_signature
550
- class PropsPicker(VBox):
427
+ class PropsPicker(VBox): # NOTE: remove New Later
551
428
  """
552
429
  A widget to pick atoms and orbitals for plotting.
553
430
 
@@ -555,88 +432,48 @@ class PropsPicker(VBox):
555
432
  ----------
556
433
  system_summary : (Vasprun,Vaspout).summary
557
434
  N : int, default is 3, number of projections to pick.
558
- on_button_click : callable, takes button as arguemnet. Default is None, a function to call when button is clicked.
559
- on_selection_changed : callable, takes change as argument. Default is None, a function to call when selection is changed.
435
+
436
+ You can observe `projections` trait.
560
437
  """
561
-
562
- def __init__(
563
- self, system_summary=None, N=3, on_button_click=None, on_selection_changed=None
564
- ):
438
+ projections = traitlets.Dict({})
439
+
440
+ def __init__(self, system_summary=None, N=3):
565
441
  super().__init__()
566
- self._linked = Dropdown(
567
- options=[str(i + 1) for i in range(N)]
568
- if N != 3
569
- else ("Red", "Green", "Blue"),
570
- description="Projection" if N != 3 else "Color",
571
- )
572
- self._stacked = Stack(
573
- children=tuple(_PropPicker(system_summary) for _ in range(N)),
574
- selected_index=0,
442
+ self._N = N
443
+ self._pickers = [_PropPicker(system_summary) for _ in range(N)]
444
+ self.add_class("props-picker")
445
+
446
+ # Create widgets with consistent width
447
+ self._picker = Dropdown(
448
+ description="Color" if N == 3 else "Projection",
449
+ options=["Red", "Green", "Blue"] if N == 3 else [str(i+1) for i in range(N)],
575
450
  )
576
- self._button = Button(description="Run Function")
577
-
578
- if callable(on_button_click):
579
- self._button.on_click(on_button_click)
580
-
581
- for w in [self._button, self._linked]:
582
- w.layout.width = "max-content"
583
-
584
- ipw.link((self._linked, "index"), (self._stacked, "selected_index"))
585
- self.children = [HBox([self._linked, self._button]), self._stacked]
586
-
587
- if callable(on_selection_changed):
588
- for child in self._stacked.children:
589
- child._html.observe(on_selection_changed, names="value")
590
-
451
+ self._stack = Stack(children=self._pickers, selected_index=0)
452
+ # Link picker dropdown to stack
453
+ ipw.link((self._picker, 'index'), (self._stack, 'selected_index'))
454
+
455
+ # Setup layout
456
+ self.children = [self._picker, self._stack]
457
+
458
+ # Observe pickers for props changes and button click
459
+ for picker in self._pickers:
460
+ picker.observe(self._update_projections, names=['props'])
461
+
462
+ def _update_projections(self, change):
463
+ """Update combined projections when any picker changes"""
464
+ projs = {}
465
+ for picker in self._pickers:
466
+ if picker.props: # Only add non-empty selections
467
+ projs[picker.props['label']] = (
468
+ picker.props['atoms'],
469
+ picker.props['orbs']
470
+ )
471
+ self.projections = projs
472
+
591
473
  def update(self, system_summary):
592
- for child in self._stacked.children:
593
- child.update(system_summary)
594
-
595
- @property
596
- def button(self):
597
- return self._button
598
-
599
- @property
600
- def projections(self):
601
- out = {}
602
- for child in self._stacked.children:
603
- props = child.props
604
- if props["atoms"] and props["orbs"]: # discard empty
605
- out[props["label"]] = (props["atoms"], props["orbs"])
606
-
607
- return out
608
-
609
-
610
- def __store_figclick_data(fig, store_dict, callback=None, selection=False):
611
- "Store clicked data in a dict. callback takes trace as argument and is called after storing data."
612
- if not isinstance(fig, go.FigureWidget):
613
- raise TypeError("fig must be a FigureWidget")
614
- if not isinstance(store_dict, dict):
615
- raise TypeError("store_dict must be a dict")
616
- if callback and not callable(callback):
617
- raise TypeError("callback must be callable if given")
618
-
619
- def handle_click(trace, points, state):
620
- store_dict["data"] = points
621
- if callback:
622
- callback(trace)
623
-
624
- for trace in fig.data:
625
- if selection:
626
- trace.on_selection(handle_click)
627
- else:
628
- trace.on_click(handle_click)
629
-
630
-
631
- def store_clicked_data(fig, store_dict, callback=None):
632
- "Store clicked point data to a store_dict. callback takes trace being clicked as argument."
633
- return __store_figclick_data(fig, store_dict, callback, selection=False)
634
-
635
-
636
- def store_selected_data(fig, store_dict, callback=None):
637
- "Store multipoints selected data to a store_dict. callback takes trace being clicked as argument."
638
- return __store_figclick_data(fig, store_dict, callback, selection=True)
639
-
474
+ """Update all pickers with new system data"""
475
+ for picker in self._pickers:
476
+ picker.update(system_summary)
640
477
 
641
478
  def load_results(paths_list):
642
479
  "Loads result.json from paths_list and returns a dataframe."
@@ -657,196 +494,285 @@ def load_results(paths_list):
657
494
 
658
495
  return summarize(result_paths, load_data)
659
496
 
497
+ def _get_css(mode):
498
+ return {
499
+ '--jp-widgets-color': 'white' if mode == 'dark' else 'black',
500
+ '--jp-widgets-label-color': 'white' if mode == 'dark' else 'black',
501
+ '--jp-widgets-readout-color': 'white' if mode == 'dark' else 'black',
502
+ '--jp-widgets-input-color': 'white' if mode == 'dark' else 'black',
503
+ '--jp-widgets-input-background-color': '#222' if mode == 'dark' else '#f7f7f7',
504
+ '--jp-widgets-input-border-color': '#8988' if mode == 'dark' else '#ccc',
505
+ '--jp-layout-color2': '#555' if mode == 'dark' else '#ddd', # buttons
506
+ '--jp-ui-font-color1': 'whitesmoke' if mode == 'dark' else 'black', # buttons
507
+ '--jp-content-font-color1': 'white' if mode == 'dark' else 'black', # main text
508
+ '--jp-layout-color1': '#111' if mode == 'dark' else '#fff', # background
509
+ ':fullscreen': {'min-height':'100vh'},
510
+ 'background': 'var(--jp-widgets-input-background-color)', 'border-radius': '4px', 'padding':'4px 4px 0 4px',
511
+ '> *': {
512
+ 'box-sizing': 'border-box',
513
+ 'background': 'var(--jp-layout-color1)',
514
+ 'border-radius': '4px', 'grid-gap': '8px', 'padding': '8px',
515
+ },
516
+ '.left-sidebar .sm': {
517
+ 'flex-grow': 1,
518
+ 'select': {'height': '100%',},
519
+ },
520
+ '.footer': {'overflow': 'auto','padding':0},
521
+ '.widget-vslider, .jupyter-widget-vslider': {'width': 'auto'}, # otherwise it spans too much area
522
+ }
523
+
524
+ class _ThemedFigureInteract(ei.InteractBase):
525
+ "Keeps self._fig anf self._theme button attributes for subclasses to use."
526
+ def __init__(self, *args, **kwargs):
527
+ self._fig = ei.patched_plotly(go.FigureWidget())
528
+ self._theme = Button(icon='sun', description=' ', tooltip="Toggle Theme")
529
+ super().__init__(*args, **kwargs)
530
+
531
+ if not all([hasattr(self.params, 'fig'), hasattr(self.params, 'theme')]):
532
+ raise AttributeError("subclass must include already initialized "
533
+ "{'fig': self._fig,'theme':self._theme} in returned dict of _interactive_params() method.")
534
+ self._update_theme(self._fig,self._theme) # fix theme in starts
535
+
536
+ def _interactive_params(self): return {}
537
+
538
+ def __init_subclass__(cls):
539
+ if (not '_update_theme' in cls.__dict__) or (not hasattr(cls._update_theme,'_is_interactive_callback')):
540
+ raise AttributeError("implement _update_theme(self, fig, theme) decorated by @callback in subclass, "
541
+ "which should only call super()._update_theme(fig, theme) in its body.")
542
+ super().__init_subclass__()
543
+
544
+ @ei.callback
545
+ def _update_theme(self, fig, theme):
546
+ require_dark = (theme.icon == 'sun')
547
+ theme.icon = 'moon' if require_dark else 'sun' # we are not observing icon, so we can do this
548
+ fig.layout.template = "plotly_dark" if require_dark else "plotly_white"
549
+ self.set_css() # automatically sets dark/light, ensure after icon set
550
+ fig.layout.autosize = True # must
551
+
552
+ @_sub_doc(ei.InteractBase.set_css) # overriding to alway be able to set_css
553
+ def set_css(self, main=None, center=None):
554
+ # This is after setting icon above, so logic is fliipped
555
+ style = _get_css("light" if self._theme.icon == 'sun' else 'dark') # infer from icon to match
556
+ if isinstance(main, dict):
557
+ style = {**style, **main} # main should allow override
558
+ elif main is not None:
559
+ raise TypeError("main must be a dict or None, got: {}".format(type(main)))
560
+ super().set_css(style, center)
561
+
562
+ @property
563
+ def files(self):
564
+ "Use self.files.update(...) to keep state of widget preserved with new files."
565
+ if not hasattr(self, '_files'): # subclasses must set this, although no check unless user dots it
566
+ raise AttributeError("self._files = Files(...) was never set!")
567
+ return self._files
568
+
569
+ # NOTE: This to impelemet as selection
570
+ # import pandas as pd
571
+
572
+ # data = {k:v for k,v in kw.selected_data.items() if k != 'customdata' and 'indexes' not in k}
573
+ # data.update(pd.DataFrame(kw.selected_data.get('customdata',{})).to_dict(orient='list'))
574
+
575
+ # df = pd.DataFrame(data)
660
576
 
661
577
  @fix_signature
662
- class BandsWidget(VBox):
578
+ class BandsWidget(_ThemedFigureInteract):
663
579
  """Visualize band structure from VASP calculation. You can click on the graph to get the data such as VBM, CBM, etc.
664
- Two attributes are important:
665
- self.clicked_data returns the last clicked point, that can also be stored as VBM, CBM etc, using Click dropdown.
666
- self.selected_data returns the last selection of points within a box or lasso. You can plot that output separately as plt.plot(data.xs, data.ys) after a selection.
667
- You can use `self.files.update` method to change source files without effecting state of widget.
580
+
581
+ You can observe three traits:
582
+
583
+ - file: Currently selected file
584
+ - clicked_data: Last clicked point data, that is also stored as VBM, CBM etc, using Click dropdown.
585
+ - selected_data: Last selection of points within a box or lasso. You can plot that output separately as plt.plot(data['xs'], data['ys']) after a selection.
586
+
587
+ - You can use `self.files.update` method to change source files without effecting state of widget.
588
+ - You can also use `self.iplot`, `self.splot` with `self.kws` to get static plts of current state.
668
589
  """
590
+ file = traitlets.Any(allow_none=True)
591
+ clicked_data = traitlets.Dict(allow_none=True)
592
+ selected_data = traitlets.Dict(allow_none=True)
669
593
 
670
594
  def __init__(self, files, height="450px"):
671
- super().__init__(_dom_classes=["BandsWidget"])
595
+ self.add_class("BandsWidget")
596
+ self._files = Files(files)
672
597
  self._bands = None
673
- self._fig = go.FigureWidget()
674
- self._tsd = Dropdown(
675
- description="Style", options=["plotly_white", "plotly_dark"]
676
- )
677
- self._click = Dropdown(description="Click", options=["None", "vbm", "cbm"])
678
- self._ktcicks = Text(description="kticks")
679
- self._brange = ipw.IntRangeSlider(description="bands",min=1, max=1) # number, not index
680
- self._ppicks = PropsPicker(
681
- on_button_click=self._update_graph, on_selection_changed=self._warn_update
682
- )
683
- self._ppicks.button.description = "Update Graph"
684
- self._result = {} # store and save output results
685
- self._click_dict = {} # store clicked data
686
- self._select_dict = {} # store selection data
687
- self._kwargs = {}
598
+ self._kws = {}
599
+ self._result = {}
600
+ super().__init__()
601
+
602
+ traitlets.dlink((self.params.file,'value'),(self, 'file'))
603
+ traitlets.dlink((self.params.fig,'clicked'),(self, 'clicked_data'))
604
+ traitlets.dlink((self.params.fig,'selected'),(self, 'selected_data'))
688
605
 
689
- Files(files)._attributed_interactive(self, self._load_data, self._fig,
690
- free_widgets=[
691
- self._tsd,
692
- self._brange,
693
- self._ktcicks,
694
- ipw.HTML("<hr/>"),
695
- self._ppicks,
696
- ipw.HTML("<hr/>Click on graph to read selected option."),
697
- self._click,
606
+ self.relayout(
607
+ left_sidebar=[
608
+ 'head','file','krange','kticks','brange', 'ppicks',
609
+ [HBox(),('theme','button')],
698
610
  ],
699
- height=height,
611
+ center=['hdata','fig','cpoint'], footer = self.groups.outputs,
612
+ right_sidebar = ['showft'],
613
+ pane_widths=['25em',1,'2em'], pane_heights=[0,1,0], # footer only has uselessoutputs
614
+ height=height
700
615
  )
701
-
702
- self._tsd.observe(self._change_theme, "value")
703
- self._click.observe(self._click_save_data, "value")
704
- self._ktcicks.observe(self._warn_update, "value")
705
- self._brange.observe(self._warn_update, "value")
706
616
 
707
- @property
708
- def path(self):
709
- "Returns currently selected path."
710
- return self._interact._dd.value
617
+ @ei.callback
618
+ def _update_theme(self, fig, theme):
619
+ return super()._update_theme(fig, theme)
711
620
 
712
- @property
713
- def files(self):
714
- "Use slef.files.update(...) to keep state of widget preserved."
715
- return self._files
621
+ def _interactive_params(self):
622
+ return dict(
623
+ fig = self._fig, theme = self._theme, # include theme and fig
624
+ head = ipw.HTML("<b>Band Structure Visualizer</b>"),
625
+ file = self.files.to_dropdown(),
626
+ ppicks = PropsPicker(),
627
+ button = Button(description="Update Graph", icon= 'update'),
628
+ krange = ipw.IntRangeSlider(description="kpoints",min=0, max=1,value=[0,1], tooltip="Includes non-zero weight kpoints"),
629
+ kticks = Text(description="kticks", tooltip="0 index maps to minimum value of kpoints slider."),
630
+ brange = ipw.IntRangeSlider(description="bands",min=1, max=1), # number, not index
631
+ cpoint = ipw.ToggleButtons(description="Select from options and click on figure to store data points",
632
+ value=None, options=["vbm", "cbm"]), # the point where clicked
633
+ showft = ipw.IntSlider(description = 'h', orientation='vertical',min=0,max=50, value=0),
634
+ cdata = {'fig':'clicked'},
635
+ projs = {'ppicks': 'projections'}, # for visual feedback on button
636
+ hdata = ipw.HTML(), # to show data in one place
637
+ )
638
+
639
+ @ei.callback('out-data')
640
+ def _load_data(self, file):
641
+ if not file: return # First time not available
642
+ self._bands = (
643
+ vp.Vasprun(file) if file.parts[-1].endswith('xml') else vp.Vaspout(file)
644
+ ).bands
645
+ self.params.ppicks.update(self.bands.source.summary)
646
+ self.params.krange.max = self.bands.source.summary.NKPTS - 1
647
+ self.params.krange.tooltip = f"Includes {self.bands.source.get_skipk()} non-zero weight kpoints"
648
+ self.bands.source.set_skipk(0) # full range to view for slider flexibility after fix above
649
+ self._kws['kpairs'] = [self.params.krange.value,]
650
+ if (ticks := ", ".join(
651
+ f"{k}:{v}" for k, v in self.bands.get_kticks()
652
+ )): # Do not overwrite if empty
653
+ self.params.kticks.value = ticks
654
+
655
+ self.params.brange.max = self.bands.source.summary.NBANDS
656
+ if self.bands.source.summary.LSORBIT:
657
+ self.params.cpoint.options = ["vbm", "cbm", "so_max", "so_min"]
658
+ else:
659
+ self.params.cpoint.options = ["vbm", "cbm"]
660
+ if (path := file.parent / "result.json").is_file():
661
+ self._result = self._clean_legacy_data(path)
662
+
663
+ pdata = self.bands.source.poscar.data
664
+ self._result.update(
665
+ {
666
+ "sys": pdata.SYSTEM, "v": round(pdata.volume, 4),
667
+ **{k: round(v, 4) for k, v in zip("abc", pdata.norms)},
668
+ **{k: round(v, 4) for k, v in zip(["alpha","beta","gamma"], pdata.angles)},
669
+ }
670
+ )
671
+ self._show_data(self._result) # Load into view
716
672
 
717
- def _load_data(self, path, fig): # Automatically redirectes to output widget
718
- if not hasattr(self, '_interact'): return # First time not availablebu
719
- self._interact.output_widget.clear_output(wait=True) # Why need again?
720
- with self._interact.output_widget:
721
- self._bands = (
722
- vp.Vasprun(path) if path.parts[-1].endswith('xml') else vp.Vaspout(path)
723
- ).bands
724
- self._ppicks.update(self.bands.source.summary)
725
- self._ktcicks.value = ", ".join(
726
- f"{k}:{v}" for k, v in self.bands.get_kticks()
727
- )
728
- self._brange.max = self.bands.source.summary.NBANDS
729
- if self.bands.source.summary.LSORBIT:
730
- self._click.options = ["None", "vbm", "cbm", "so_max", "so_min"]
731
- else:
732
- self._click.options = ["None", "vbm", "cbm"]
733
-
734
- if (file := path.parent / "result.json").is_file():
735
- self._result = serializer.load(str(file.absolute())) # Old data loaded
736
-
737
- pdata = self.bands.source.poscar.data
738
- self._result.update(
739
- {
740
- "v": round(pdata.volume, 4),
741
- **{k: round(v, 4) for k, v in zip("abc", pdata.norms)},
742
- **{k: round(v, 4) for k, v in zip(["alpha","beta","gamma"], pdata.angles)},
743
- }
744
- )
745
- self._click_save_data(None) # Load into view
746
- self._warn_update(None)
673
+ def _clean_legacy_data(self, path):
674
+ "clean old style keys like VBM to vbm"
675
+ data = serializer.load(str(path.absolute())) # Old data loaded
676
+
677
+ if not any(key in data for key in ['VBM', 'α','vbm_k']):
678
+ return data # already clean
679
+
680
+ keys_map = {
681
+ "SYSTEM": "sys",
682
+ "VBM": "vbm", # Old: New
683
+ "CBM": "cbm",
684
+ "VBM_k": "kvbm",
685
+ "CBM_k": "kcbm",
686
+ "E_gap": "gap",
687
+ "\u0394_SO": "soc", "so_max":"so_max","so_min":"so_min", # need to include keys
688
+ "V": "v",
689
+ "α": "alpha",
690
+ "β": "beta",
691
+ "γ": "gamma",
692
+ }
693
+
694
+ new_data = {}
695
+ for old, new in keys_map.items():
696
+ if old in data:
697
+ new_data[new] = data[old] # Transfer value from old key to new key
698
+ elif new in data:
699
+ new_data[new] = data[new] # Keep existing new style keys
700
+
701
+ # save cleaned data
702
+ serializer.dump(new_data,format="json",outfile=path)
703
+ return new_data
747
704
 
748
- @property
749
- def source(self):
750
- "Returns data source object such as Vasprun or Vaspout."
751
- return self.bands.source
705
+ @ei.callback
706
+ def _toggle_footer(self, showft):
707
+ self._app.pane_heights = [0,100 - showft, showft]
708
+
709
+ @ei.callback
710
+ def _set_krange(self, krange):
711
+ self._kws["kpairs"] = [krange,]
712
+
713
+ @ei.callback
714
+ def _warn_update(self, file=None, kticks=None, brange=None, krange=None,projs=None):
715
+ self.params.button.description = "🔴 Update Graph"
716
+
717
+ @ei.callback('out-graph')
718
+ def _update_graph(self, fig, button):
719
+ if not self.bands: return # First time not available
720
+ fig.layout.autosize = True # must
721
+ hsk = [
722
+ [v.strip() for v in vs.split(":")]
723
+ for vs in self.params.kticks.value.split(",")
724
+ ]
725
+ kmin, kmax = self.params.krange.value or [0,0]
726
+ kticks = [(int(vs[0]), vs[1])
727
+ for vs in hsk # We are going to pick kticks silently in given range
728
+ if len(vs) == 2 and abs(int(vs[0])) < (kmax - kmin) # handle negative indices too
729
+ ] or None
730
+
731
+ _bands = None
732
+ if self.params.brange.value:
733
+ l, h = self.params.brange.value
734
+ _bands = range(l-1, h) # from number to index
752
735
 
753
- @property
754
- def bands(self):
755
- "Bands class initialized"
756
- if not self._bands:
757
- raise ValueError("No data loaded by BandsWidget yet!")
758
- return self._bands
736
+ self._kws = {**self._kws, "kticks": kticks, "bands": _bands}
759
737
 
760
- @property
761
- def kwargs(self):
762
- "Selected kwargs from GUI"
763
- return self._kwargs
738
+ if self.params.ppicks.projections:
739
+ self._kws = {**self._kws, "projections": self.params.ppicks.projections}
740
+ _fig = self.bands.iplot_rgb_lines(**self._kws, name="Up")
741
+ if self.bands.source.summary.ISPIN == 2:
742
+ self.bands.iplot_rgb_lines(**self._kws, spin=1, name="Down", fig=fig)
764
743
 
765
- @property
766
- def clicked_data(self):
767
- "Clicked data from graph"
768
- return self._click_dict.get("data", None)
744
+ self.iplot = partial(self.bands.iplot_rgb_lines, **self._kws)
745
+ self.splot = partial(self.bands.splot_rgb_lines, **self._kws)
746
+ else:
747
+ _fig = self.bands.iplot_bands(**self._kws, name="Up")
748
+ if self.bands.source.summary.ISPIN == 2:
749
+ self.bands.iplot_bands(**self._kws, spin=1, name="Down", fig=fig)
769
750
 
770
- @property
771
- def selected_data(self):
772
- "Data selected by box or lasso selection from graph"
773
- return self._select_dict.get("data", None)
774
-
775
- def _update_graph(self, btn):
776
- if not hasattr(self, '_interact'): return # First time not available
777
- self._interact.output_widget.clear_output(wait=True) # Why need again?
778
- with self._interact.output_widget:
779
- hsk = [
780
- [v.strip() for v in vs.split(":")]
781
- for vs in self._ktcicks.value.split(",")
782
- ]
783
- kticks = [(int(vs[0]), vs[1]) for vs in hsk if len(vs) == 2] or None
784
- self._kwargs = {"kticks": kticks, # below numbers instead of index and full shown range
785
- "bands": range(self._brange.value[0] - 1, self._brange.value[1]) if self._brange.value else None}
786
-
787
- if self._ppicks.projections:
788
- self._kwargs = {"projections": self._ppicks.projections, **self._kwargs}
789
- fig = self.bands.iplot_rgb_lines(**self._kwargs, name="Up")
790
- if self.bands.source.summary.ISPIN == 2:
791
- self.bands.iplot_rgb_lines(**self._kwargs, spin=1, name="Down", fig=fig)
792
-
793
- self.iplot = partial(self.bands.iplot_rgb_lines, **self._kwargs)
794
- self.splot = partial(self.bands.splot_rgb_lines, **self._kwargs)
795
- else:
796
- fig = self.bands.iplot_bands(**self._kwargs, name="Up")
797
- if self.bands.source.summary.ISPIN == 2:
798
- self.bands.iplot_bands(**self._kwargs, spin=1, name="Down", fig=fig)
799
-
800
- self.iplot = partial(self.bands.iplot_bands, **self._kwargs)
801
- self.splot = partial(self.bands.splot_bands, **self._kwargs)
802
-
803
- ptk.iplot2widget(fig, self._fig, template=self._tsd.value)
804
- self._click_dict.clear() # avoid data from previous figure
805
- self._select_dict.clear() # avoid data from previous figure
806
- store_clicked_data(
807
- self._fig,
808
- self._click_dict,
809
- callback=lambda trace: self._click_save_data("CLICK"),
810
- ) # 'CLICK' is needed to inntercept in a function
811
- store_selected_data(self._fig, self._select_dict, callback=None)
812
- self._ppicks.button.description = "Update Graph"
813
-
814
- def _change_theme(self, change):
815
- self._fig.layout.template = self._tsd.value
816
-
817
- def _click_save_data(self, change=None):
818
- def _show_and_save(data_dict):
819
- self._interact.output_widget.clear_output(wait=True) # Why need again?
820
- with self._interact.output_widget:
821
- print(pformat({key: value
822
- for key, value in data_dict.items()
823
- if key not in ("so_max", "so_min")
824
- }))
825
-
826
- serializer.dump(
827
- data_dict,
828
- format="json",
829
- outfile=self.path.parent / "result.json",
830
- )
751
+ self.iplot = partial(self.bands.iplot_bands, **self._kws)
752
+ self.splot = partial(self.bands.splot_bands, **self._kws)
753
+
754
+ ptk.iplot2widget(_fig, fig, template=fig.layout.template)
755
+ fig.clicked = {} # avoid data from previous figure
756
+ fig.selected = {} # avoid data from previous figure
757
+ button.description = "Update Graph" # clear trigger
831
758
 
832
- if change is None: # called from other functions but not from store_clicked_data
833
- return _show_and_save(self._result)
834
- # Should be after checking change
835
- if self._click.value and self._click.value == "None":
836
- return # No need to act on None
759
+ @ei.callback('out-click')
760
+ def _click_save_data(self, cdata):
761
+ if self.params.cpoint.value is None:
762
+ return self._show_and_save(self._result)
837
763
 
838
764
  data_dict = self._result.copy() # Copy old data
839
765
 
840
- if data := self.clicked_data: # No need to make empty dict
841
- x = round(data.xs[0], 6)
842
- y = round(float(data.ys[0]) + self.bands.data.ezero, 6) # Add ezero
766
+ if cdata: # No need to make empty dict
767
+ x = round(*cdata['xs'], 6) # unpack single point
768
+ y = round(float(*cdata['ys']) + self.bands.data.ezero, 6) # Add ezero
843
769
 
844
- if key := self._click.value:
770
+ if key := self.params.cpoint.value:
845
771
  data_dict[key] = y # Assign value back
846
- if not key.startswith("so"):
847
- data_dict[key + "_k"] = round(
848
- x, 6
849
- ) # Save x to test direct/indirect
772
+ if not key.startswith("so_"): # not spin-orbit points
773
+ cst, = cdata.get('customdata',[{}]) # single item
774
+ kp = [cst.get(f"k{n}", None) for n in 'xyz']
775
+ data_dict[f"k{key}"] = tuple([round(k,6) if k else k for k in kp]) # Save x to test direct/indirect
850
776
 
851
777
  if data_dict.get("vbm", None) and data_dict.get("cbm", None):
852
778
  data_dict["gap"] = np.round(data_dict["cbm"] - data_dict["vbm"], 6)
@@ -857,22 +783,51 @@ class BandsWidget(VBox):
857
783
  )
858
784
 
859
785
  self._result.update(data_dict) # store new data
860
- _show_and_save(self._result)
861
-
862
- if change == "CLICK": # Called from store_clicked_data
863
- self._click.value = "None" # Reset to None to avoid accidental click
864
-
865
- def _warn_update(self, change):
866
- self._ppicks.button.description = "🔴 Update Graph"
867
-
786
+ self._show_and_save(self._result)
787
+ self.params.cpoint.value = None # Reset to None to avoid accidental click at end
788
+
789
+ def _show_data(self, data):
790
+ "Show data in html widget, no matter where it was called."
791
+ data = data.copy() # no modify
792
+ kv, kc = data.pop('kvbm',[None]*3), data.pop('kcbm',[None]*3)
793
+ data['direct'] = (kv == kc) if None not in kv else False
794
+ headers = "".join(f"<th>{key}</th>" for key in data.keys())
795
+ values = "".join(f"<td>{format(value, '.4f') if isinstance(value, float) else value}</td>" for value in data.values())
796
+ self.params.hdata.value = f"""<table border='1' style='width:100%;max-width:100% !important;border-collapse:collapse;'>
797
+ <tr>{headers}</tr>\n<tr>{values}</tr></table>"""
798
+
799
+ def _show_and_save(self, data_dict):
800
+ self._show_data(data_dict)
801
+ if self.file:
802
+ serializer.dump(data_dict,format="json",
803
+ outfile=self.file.parent / "result.json")
804
+
868
805
  @property
869
806
  def results(self):
870
807
  "Generate a dataframe form result.json file in each folder."
871
- return load_results(self._interact._dd.options)
808
+ return load_results(self.params.file.options)
809
+
810
+ @property
811
+ def source(self):
812
+ "Returns data source object such as Vasprun or Vaspout."
813
+ return self.bands.source
814
+
815
+ @property
816
+ def bands(self):
817
+ "Bands class initialized"
818
+ if not self._bands:
819
+ raise ValueError("No data loaded by BandsWidget yet!")
820
+ return self._bands
821
+
822
+ @property
823
+ def kws(self):
824
+ "Selected keyword arguments from GUI"
825
+ return self._kws
826
+
872
827
 
873
828
 
874
829
  @fix_signature
875
- class KpathWidget(VBox):
830
+ class KPathWidget(_ThemedFigureInteract):
876
831
  """
877
832
  Interactively bulid a kpath for bandstructure calculation.
878
833
 
@@ -884,159 +839,163 @@ class KpathWidget(VBox):
884
839
  - Add labels to the points by typing in the "Labels" box such as "Γ,X" or "Γ 5,X" that will add 5 points in interval.
885
840
  - To break the path between two points "Γ" and "X" type "Γ 0,X" in the "Labels" box, zero means no points in interval.
886
841
 
887
- You can use `self.files.update` method to change source files without effecting state of widget.
842
+ - You can use `self.files.update` method to change source files without effecting state of widget.
843
+ - You can observe `self.file` trait to get current file selected and plot something, e.g. lattice structure.
888
844
  """
845
+ file = traitlets.Any(None, allow_none=True)
889
846
 
890
- def __init__(self, files, height="400px"):
891
- super().__init__(_dom_classes=["KpathWidget"])
892
- self._fig = go.FigureWidget()
893
- self._sm = SelectMultiple(options=[], layout=Layout(width="auto"))
894
- self._lab = Text(description="Labels", continuous_update=True)
895
- self._kpt = Text(description="KPOINT", continuous_update=False)
896
- self._add = Button(description="Lock", tooltip="Lock/Unlock adding more points")
897
- self._del = Button(description="❌ Point", tooltip="Delete Selected Points")
898
- self._tsb = Button(description="Dark Plot", tooltip="Toggle Plot Theme")
847
+ @property
848
+ def poscar(self): return self._poscar
849
+
850
+ def __init__(self, files, height="450px"):
851
+ self.add_class("KPathWidget")
899
852
  self._poscar = None
900
- self._clicktime = None
853
+ self._oldclick = None
901
854
  self._kpoints = {}
902
-
903
- free_widgets = [
904
- HBox([self._add, self._del, self._tsb], layout=Layout(min_height="24px")),
905
- ipw.HTML(
906
- "<style>.KpathWidget .widget-select-multiple { min-height: 180px; }\n .widget-select-multiple > select {height: 100%;}</style>"
907
- ),
908
- self._sm,
909
- self._lab,
910
- self._kpt,
911
- ]
912
-
913
- Files(files)._attributed_interactive(self,
914
- self._update_fig, self._fig, free_widgets=free_widgets, height=height
855
+ self._files = Files(files) # set name _files to ensure access to files
856
+ super().__init__()
857
+ traitlets.dlink((self.params.file,'value'),(self, 'file')) # update file trait
858
+
859
+ btns = [HBox(layout=Layout(min_height="24px")),('lock','delp', 'theme')]
860
+ self.relayout(
861
+ left_sidebar=['head','file',btns, 'info', 'sm','out-kpt','kpt', 'out-lab', 'lab'],
862
+ center=['fig'], footer = [c for c in self.groups.outputs if not c in ('out-lab','out-kpt')],
863
+ pane_widths=['25em',1,0], pane_heights=[0,1,0], # footer only has uselessoutputs
864
+ height=height
915
865
  )
916
-
917
- self._tsb.on_click(self._update_theme)
918
- self._add.on_click(self._toggle_lock)
919
- self._del.on_click(self._del_point)
920
- self._kpt.observe(self._take_kpt, "value")
921
- self._lab.observe(self._add_label)
922
866
 
923
- @property
924
- def path(self):
925
- "Returns currently selected path."
926
- return self._interact._dd.value # itself a Path object
927
-
928
- @property
929
- def files(self):
930
- "Use slef.files.update(...) to keep state of widget preserved."
931
- return self._files
932
-
933
- @property
934
- def poscar(self):
935
- "POSCAR class associated to current selection."
936
- return self._poscar
867
+ def _show_info(self, text, color='skyblue'):
868
+ self.params.info.value = f'<span style="color:{color}">{text}</span>'
869
+
870
+ def _interactive_params(self):
871
+ return dict(
872
+ fig = self._fig, theme = self._theme, # include theme and fig
873
+ head = ipw.HTML("<b>K-Path Builder</b>"),
874
+ file = self.files.to_dropdown(), # auto updatable on files.update
875
+ sm = SelectMultiple(description="KPOINTS", options=[], layout=Layout(width="auto")),
876
+ lab = Text(description="Labels", continuous_update=True),
877
+ kpt = Text(description="KPOINT", continuous_update=False),
878
+ delp = Button(description=" ", icon='trash', tooltip="Delete Selected Points"),
879
+ click = {'fig': 'clicked'},
880
+ lock = Button(description=" ", icon='unlock', tooltip="Lock/Unlock adding more points"),
881
+ info = ipw.HTML(), # consise information in one place
882
+ )
937
883
 
938
- def _update_fig(self, path, fig):
939
- if not hasattr(self, '_interact'): return # First time not available
940
- from .lattice import POSCAR # to avoid circular import
884
+ @ei.callback('out-fig')
885
+ def _update_fig(self, file, fig):
886
+ if not file: return # empty one
941
887
 
942
- with self._interact.output_widget:
943
- template = (
944
- "plotly_dark" if "Light" in self._tsb.description else "plotly_white"
945
- )
946
- self._poscar = POSCAR(path)
947
- ptk.iplot2widget(
948
- self._poscar.iplot_bz(fill=False, color="red"), fig, template
949
- )
950
- with fig.batch_animate():
951
- fig.add_trace(
952
- go.Scatter3d(
953
- x=[],
954
- y=[],
955
- z=[],
956
- mode="lines+text",
957
- name="path",
958
- text=[],
959
- hoverinfo="none", # dont let it block other points
960
- textfont_size=18,
961
- )
962
- ) # add path that will be updated later
963
- self._click() # handle events
964
- print("Click points on plot to store for kpath.")
965
-
966
- def _click(self):
967
- def handle_click(trace, points, state):
968
- if self._clicktime and (time() - self._clicktime < 1):
969
- return # Avoid double clicks
970
-
971
- self._clicktime = time() # register this click's time
972
-
973
- if points.ys != []:
974
- index = points.point_inds[0]
975
- kp = trace.hovertext[index]
976
- kp = [float(k) for k in kp.split("[")[1].split("]")[0].split()]
977
-
978
- if self._sm.value:
979
- self._take_kpt(kp) # this updates plot back as well
980
- elif self._add.description == "Lock": # only add when open
888
+ from ipyvasp.lattice import POSCAR # to avoid circular import
889
+ self._poscar = POSCAR(file)
890
+ ptk.iplot2widget(
891
+ self._poscar.iplot_bz(fill=False, color="red"), fig, self.params.fig.layout.template
892
+ )
893
+ fig.layout.autosize = True # must
894
+
895
+ with fig.batch_animate():
896
+ fig.add_trace(
897
+ go.Scatter3d(x=[], y=[], z=[],
898
+ mode="lines+text",
899
+ name="path",
900
+ text=[],
901
+ hoverinfo="none", # dont let it block other points
902
+ textfont_size=18,
903
+ )
904
+ ) # add path that will be updated later
905
+ self._show_info("Click points on plot to store for kpath.")
906
+
907
+ @ei.callback('out-click')
908
+ def _click(self, click):
909
+ # We are setting value on select multiple to get it done in one click conveniently
910
+ # But that triggers infinite loop, so we need to check if click is different next time
911
+ if click != self._oldclick and (tidx := click.get('trace_indexes',[])):
912
+ self._oldclick = click # for next time
913
+ data = self.params.fig.data # click depends on fig, so accessing here
914
+ if not [data[i] for i in tidx if 'HSK' in data[i].name]: return
915
+
916
+ if cp := [*click.get('xs', []),*click.get('ys', []),*click.get('zs', [])]:
917
+ kp = self._poscar.bz.to_fractional(cp) # reciprocal space
918
+
919
+ if self.params.sm.value:
920
+ self._set_kpt(kp) # this updates plot back as well
921
+ elif self.params.lock.icon == "unlock": # only add when open
981
922
  self._add_point(kp)
982
-
983
- for trace in self._fig.data:
984
- if "HSK" in trace.name:
985
- trace.on_click(handle_click)
986
-
987
- def _update_selection(self):
988
- with self._interact.output_widget:
989
- coords, labels = self.get_coords_labels()
990
- with self._fig.batch_animate():
991
- for trace in self._fig.data:
992
- if "path" in trace.name and coords.any():
993
- trace.x = coords[:, 0]
994
- trace.y = coords[:, 1]
995
- trace.z = coords[:, 2]
996
- trace.text = _fmt_labels(
997
- labels
998
- ) # convert latex to html equivalent
999
-
1000
- def get_coords_labels(self):
1001
- "Returns tuple of (coordinates, labels) to directly plot."
1002
- with self._interact.output_widget:
1003
- points = self.get_kpoints()
1004
-
1005
- coords = (
1006
- self.poscar.bz.to_cartesian([p[:3] for p in points]).tolist()
1007
- if points
1008
- else []
1009
- )
1010
- labels = [
1011
- p[3] if (len(p) >= 4 and isinstance(p[3], str)) else "" for p in points
1012
- ]
1013
- numbers = [
1014
- p[4]
1015
- if len(p) == 5
1016
- else p[3]
1017
- if (len(p) == 4 and isinstance(p[3], int))
1018
- else ""
1019
- for p in points
1020
- ]
1021
-
1022
- j = 0
1023
- for i, n in enumerate(numbers, start=1):
1024
- if isinstance(n, int) and n == 0:
1025
- labels.insert(i + j, "")
1026
- coords.insert(i + j, [np.nan, np.nan, np.nan])
1027
- j += 1
1028
-
1029
- return np.array(coords), labels
923
+
924
+ @ei.callback('out-kpt')
925
+ def _take_kpt(self, kpt):
926
+ print("Add kpoint e.g. 0,1,3 at selection(s)")
927
+ self._set_kpt(kpt)
928
+
929
+ @ei.callback('out-lab')
930
+ def _set_lab(self, lab):
931
+ print("Add label[:number] e.g. X:5,Y,L:9")
932
+ self._add_label(lab)
933
+
934
+ @ei.callback
935
+ def _update_theme(self, fig, theme):
936
+ super()._update_theme(fig, theme) # call parent method, but important
937
+
938
+ @ei.callback
939
+ def _toggle_lock(self, lock):
940
+ self.params.lock.icon = 'lock' if self.params.lock.icon == 'unlock' else 'unlock'
941
+ self._show_info(f"{self.params.lock.icon}ed adding/deleting kpoints!")
942
+
943
+ @ei.callback
944
+ def _del_point(self, delp):
945
+ if self.params.lock.icon == 'unlock': # Do not delete locked
946
+ sm = self.params.sm
947
+ for v in sm.value: # for loop here is important to update selection properly
948
+ sm.options = [opt for opt in sm.options if opt[1] != v]
949
+ self._update_selection() # update plot as well
950
+ else:
951
+ self._show_info("Select point(s) to delete")
952
+ else:
953
+ self._show_info("cannot delete point when locked!", 'red')
954
+
955
+ def _add_point(self, kpt):
956
+ sm = self.params.sm
957
+ sm.options = [*sm.options, ("⋮", len(sm.options))]
958
+ # select to receive point as well, this somehow makes infinit loop issues,
959
+ # but need to work, so self._oldclick is used to check in _click callback
960
+ sm.value = (sm.options[-1][1],)
961
+ self._set_kpt(kpt) # add point, label and plot back
962
+
963
+ def _set_kpt(self,kpt):
964
+ point = kpt
965
+ if isinstance(kpt, str) and kpt:
966
+ if len(kpt.split(",")) != 3: return # Enter at incomplete input
967
+ point = [float(v) for v in kpt.split(",")] # kpt is value widget
968
+
969
+ if not isinstance(point,(list, tuple,np.ndarray)): return # None etc
970
+
971
+ if len(point) != 3:
972
+ raise ValueError("Expects KPOINT of 3 floats")
973
+ self._kpoints.update({v: point for v in self.params.sm.value})
974
+ label = "{:>8.4f} {:>8.4f} {:>8.4f}".format(*point)
975
+ self.params.sm.options = [
976
+ (label, value) if value in self.params.sm.value else (lab, value)
977
+ for (lab, value) in self.params.sm.options
978
+ ]
979
+ self._add_label(self.params.lab.value) # Re-adjust labels and update plot as well
980
+
981
+ def _add_label(self, lab):
982
+ labs = [" ⋮ " for _ in self.params.sm.options] # as much as options
983
+ for idx, (_, lb) in enumerate(zip(self.params.sm.options, (lab or "").split(","))):
984
+ labs[idx] = labs[idx] + lb # don't leave empty anyhow
985
+
986
+ self.params.sm.options = [
987
+ (v.split("⋮")[0].strip() + lb, idx)
988
+ for (v, idx), lb in zip(self.params.sm.options, labs)
989
+ ]
990
+ self._update_selection() # Update plot in both cases, by click or manual input
1030
991
 
1031
992
  def get_kpoints(self):
1032
993
  "Returns kpoints list including labels and numbers in intervals if given."
1033
- keys = [
1034
- idx for (_, idx) in self._sm.options if idx in self._kpoints
1035
- ] # order and existence is important
994
+ keys = [idx for (_, idx) in self.params.sm.options if idx in self._kpoints] # order and existence is important
1036
995
  kpts = [self._kpoints[k] for k in keys]
1037
996
  LN = [
1038
997
  lab.split("⋮")[1].strip().split()
1039
- for (lab, idx) in self._sm.options
998
+ for (lab, idx) in self.params.sm.options
1040
999
  if idx in keys
1041
1000
  ]
1042
1001
 
@@ -1056,76 +1015,43 @@ class KpathWidget(VBox):
1056
1015
  )
1057
1016
  return kpts
1058
1017
 
1059
- def _update_theme(self, btn):
1060
- if "Dark" in btn.description:
1061
- self._fig.layout.template = "plotly_dark"
1062
- btn.description = "Light Plot"
1063
- else:
1064
- self._fig.layout.template = "plotly_white"
1065
- btn.description = "Dark Plot"
1066
-
1067
- def _add_point(self, kpt):
1068
- with self._interact.output_widget:
1069
- self._sm.options = [*self._sm.options, ("⋮", len(self._sm.options))]
1070
- self._sm.value = (
1071
- self._sm.options[-1][1],
1072
- ) # select to receive point as well
1073
- self._take_kpt(kpt) # add point, label and plot back
1074
-
1075
- def _toggle_lock(self, btn):
1076
- if self._add.description == "Lock":
1077
- self._add.description = "Unlock"
1078
- else:
1079
- self._add.description = "Lock"
1080
-
1081
- def _del_point(self, btn):
1082
- with self._interact.output_widget:
1083
- for (
1084
- v
1085
- ) in (
1086
- self._sm.value
1087
- ): # for loop here is important to update selection properly
1088
- self._sm.options = [opt for opt in self._sm.options if opt[1] != v]
1089
- self._update_selection() # update plot as well
1018
+ def get_coords_labels(self):
1019
+ "Returns tuple of (coordinates, labels) to directly plot."
1020
+ points = self.get_kpoints()
1021
+ coords = self.poscar.bz.to_cartesian([p[:3] for p in points]).tolist() if points else []
1022
+ labels = [p[3] if (len(p) >= 4 and isinstance(p[3], str)) else "" for p in points]
1023
+ numbers = [
1024
+ p[4] if len(p) == 5
1025
+ else p[3] if (len(p) == 4 and isinstance(p[3], int))
1026
+ else "" for p in points]
1027
+
1028
+ j = 0
1029
+ for i, n in enumerate(numbers, start=1):
1030
+ if isinstance(n, int) and n == 0:
1031
+ labels.insert(i + j, "")
1032
+ coords.insert(i + j, [np.nan, np.nan, np.nan])
1033
+ j += 1
1034
+ return np.array(coords), labels
1090
1035
 
1091
- def _take_kpt(self, change_or_kpt):
1092
- with self._interact.output_widget:
1093
- if isinstance(change_or_kpt, (list, tuple)):
1094
- point = change_or_kpt
1095
- else:
1096
- point = [float(v) for v in self._kpt.value.split(",")]
1097
-
1098
- if len(point) != 3:
1099
- raise ValueError("Expects KPOINT of 3 floats")
1100
-
1101
- self._kpoints.update({v: point for v in self._sm.value})
1102
- label = "{:>8.4f} {:>8.4f} {:>8.4f}".format(*point)
1103
- self._sm.options = [
1104
- (label, value) if value in self._sm.value else (lab, value)
1105
- for (lab, value) in self._sm.options
1106
- ]
1107
- self._add_label(None) # Re-adjust labels and update plot as well
1108
-
1109
- def _add_label(self, change):
1110
- with self._interact.output_widget:
1111
- labs = [" ⋮ " for _ in self._sm.options] # as much as options
1112
- for idx, (_, lab) in enumerate(
1113
- zip(self._sm.options, self._lab.value.split(","))
1114
- ):
1115
- labs[idx] = labs[idx] + lab # don't leave empty anyhow
1116
-
1117
- self._sm.options = [
1118
- (v.split("⋮")[0].strip() + lab, idx)
1119
- for (v, idx), lab in zip(self._sm.options, labs)
1120
- ]
1121
-
1122
- self._update_selection() # Update plot in both cases, by click or manual input
1036
+ def _update_selection(self):
1037
+ coords, labels = self.get_coords_labels()
1038
+ with self.params.fig.batch_animate():
1039
+ for trace in self.params.fig.data:
1040
+ if "path" in trace.name and coords.any():
1041
+ trace.x = coords[:, 0]
1042
+ trace.y = coords[:, 1]
1043
+ trace.z = coords[:, 2]
1044
+ trace.text = _fmt_labels(labels) # convert latex to html equivalent
1123
1045
 
1124
1046
  @_sub_doc(lat.get_kpath, {"kpoints :.*n :": "n :", "rec_basis :.*\n\n": "\n\n"})
1125
1047
  @_sig_kwargs(lat.get_kpath, ("kpoints", "rec_basis"))
1126
1048
  def get_kpath(self, n=5, **kwargs):
1127
1049
  return self.poscar.get_kpath(self.get_kpoints(), n=n, **kwargs)
1128
1050
 
1051
+ def iplot(self):
1052
+ "Returns disconnected current plotly figure"
1053
+ return go.Figure(data=self.params.fig.data, layout=self.params.fig.layout)
1054
+
1129
1055
  def splot(self, plane=None, fmt_label=lambda x: x, plot_kws={}, **kwargs):
1130
1056
  """
1131
1057
  Same as `ipyvasp.lattice.POSCAR.splot_bz` except it also plots path on BZ.
@@ -1149,10 +1075,5 @@ class KpathWidget(VBox):
1149
1075
  ) # plots on ax automatically
1150
1076
  return ax
1151
1077
 
1152
- def iplot(self):
1153
- "Returns disconnected current plotly figure"
1154
- return go.Figure(data=self._fig.data, layout=self._fig.layout)
1155
-
1156
-
1157
1078
  # Should be at end
1158
1079
  del fix_signature # no more need