ipyvasp 0.9.91__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
@@ -202,7 +200,7 @@ class Files:
202
200
  lines = [(k,v) for k,v in lines if k in tags]
203
201
  d = {k:v for k,v in lines if not k.startswith('#')}
204
202
  d.update({k:len(v) for k,v in p.types.items()})
205
- 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]))
206
204
  return d
207
205
 
208
206
  return self.with_name('POSCAR').summarize(info, tags=tags)
@@ -219,6 +217,18 @@ class Files:
219
217
  if old in dd.options:
220
218
  dd.value = old
221
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
+
222
232
  def add(self, path_or_files, glob = '*', exclude=None, **kwargs):
223
233
  """Add more files or with a diffrent glob on top of exitsing files. Returns same instance.
224
234
  Useful to add multiple globbed files into a single chained call.
@@ -231,190 +241,39 @@ class Files:
231
241
  def _unique(self, *files_tuples):
232
242
  return tuple(np.unique(np.hstack(files_tuples)))
233
243
 
234
- def interactive(self, func, *args,
235
- free_widgets=None,
236
- options={"manual": False},
237
- height="400px",
238
- panel_size = 25,
239
- **kwargs):
240
- """
241
- 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.
242
- `args` are widgets to be modified by func, such as plotly's FigureWidget.
243
- Note that `free_widgets` can also be passed to function but does not run function when changed.
244
-
245
- See docs of self.interact for more details on the parameters. kwargs are passed to ipywidgets.interactive to create controls.
246
-
247
- >>> import plotly.graph_objects as go
248
- >>> import ipyvasp as ipv
249
- >>> fs = ipv.Files('.','**/POSCAR')
250
- >>> def plot(path, fig, bl,plot_cell,eqv_sites):
251
- >>> ipv.iplot2widget(ipv.POSCAR(path).iplot_lattice(
252
- >>> bond_length=bl,plot_cell=plot_cell,eqv_sites=eqv_sites
253
- >>> ),fig_widget=fig) # it keeps updating same view
254
- >>> out = fs.interactive(plot, go.FigureWidget(),bl=(0,6,2),plot_cell=True, eqv_sites=True)
255
-
256
- >>> out.f # function
257
- >>> out.args # arguments
258
- >>> out.kwargs # keyword arguments
259
- >>> out.result # result of function call which is same as out.f(*out.args, **out.kwargs)
260
-
261
- .. note::
262
- If you don't need to interpret the result of the function call, you can use the @self.interact decorator instead.
263
- """
264
- info = ipw.HTML().add_class("fprogess")
265
- dd = Dropdown(description='File', options=['',*self._files]) # allows single file workable
266
-
267
- def interact_func(fname, **kws):
268
- if fname and str(fname) != '': # This would be None if no file is selected
269
- info.value = _progress_svg
270
- try:
271
- start = time()
272
- if 'free_widgets' in func.__code__.co_varnames:
273
- kws['free_widgets'] = free_widgets # user allowed to pass this
274
- names = ', '.join(func.__code__.co_varnames)
275
- print(f"Running {func.__name__}({names})")
276
- func(Path(fname).absolute(), *args, **kws) # Have Path object absolue if user changes directory
277
- print(f"Finished in {time() - start:.3f} seconds.")
278
- finally:
279
- info.value = ""
280
-
281
- out = ipw.interactive(interact_func, options, fname=dd, **kwargs)
282
- out._dd = dd # save reference to dropdown
283
- out.output_widget = out.children[-1] # save reference to output widget for other widgets to use
284
-
285
- if options.get("manual", False):
286
- out.interact_button = out.children[-2] # save reference to interact button for other widgets to use
287
-
288
- output = out.children[-1] # get output widget
289
- output.clear_output(wait=True) # clear output by waiting to avoid flickering, this is important
290
- output.layout = Layout(
291
- overflow="auto", max_height="100%", width="100%"
292
- ) # make output scrollable and avoid overflow
293
-
294
- others = out.children[1:-1] # exclude files_dd and Output widget
295
- if not isinstance(panel_size,int):
296
- 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!")
297
248
 
298
- _style = f"""<style>
299
- .files-interact {{
300
- --jp-widgets-inline-label-width: 4em;
301
- --jp-widgets-inline-width: {panel_size-2}em;
302
- --jp-widgets-inline-width-short: 9em;
303
- }}
304
- .files-interact {{max-height:{height};width:100%;}}
305
- .files-interact > div {{overflow:auto;max-height:100%;padding:8px;}}
306
- .files-interact > div:first-child {{width:{panel_size}em}}
307
- .files-interact > div:last-child {{width:calc(100% - {panel_size}em)}}
308
- .files-interact .fprogess {{position:absolute !important; left:50%; top:50%; transform:translate(-50%,-50%); z-index:1}}
309
- </style>"""
310
- if others:
311
- others = [ipw.HTML(f"<hr/>{_style}"), *others]
312
- else:
313
- others = [ipw.HTML(_style)]
314
-
315
- if free_widgets and not isinstance(free_widgets, (list, tuple)):
316
- 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
317
258
 
318
- for w in args:
319
- if not isinstance(w,ipw.DOMWidget):
320
- 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!")
321
261
 
322
- if args:
323
- output.layout.max_height = "200px"
324
- output.layout.min_height = "8em" # fisrt fix
325
- out_collapser = Checkbox(description="Hide output widget", value=False)
326
-
327
- def toggle_output(change):
328
- if out_collapser.value:
329
- output.layout.height = "0px" # dont use display = 'none' as it will clear widgets and wont show again
330
- output.layout.min_height = "0px"
331
- else:
332
- output.layout.height = "auto"
333
- output.layout.min_height = "8em"
334
-
335
- out_collapser.observe(toggle_output, "value")
336
- others.append(out_collapser)
337
-
338
- # This should be below output collapser
339
- others = [
340
- *others,
341
- ipw.HTML(f"<hr/>"),
342
- *(free_widgets or []),
343
- ] # add hr to separate other controls
344
-
345
- out.children = [
346
- HBox([ # reset children to include new widgets
347
- VBox([dd, VBox(others)]), # other widgets in box to make scrollable independent file selection
348
- VBox([Box([output]), *args, info]), # output in box to make scrollable,
349
- ],layout=Layout(height=height, max_height=height),
350
- ).add_class("files-interact")
351
- ] # important for every widget separately
352
- return out
353
-
354
- def _attributed_interactive(self, box, func, *args, **kwargs):
355
- box._files = self
356
- box._interact = self.interactive(func, *args, **kwargs)
357
- box.children = box._interact.children
358
- box._files._dd = box._interact._dd
262
+ return ei.interactive(*funcs,auto_update=auto_update, app_layout = app_layout, grid_css=grid_css, file = self.to_dropdown(), **kwargs)
359
263
 
360
- def interact(self, *args,
361
- free_widgets=None,
362
- options={"manual": False},
363
- height="400px",
364
- panel_size=25,
365
- **kwargs,
366
- ):
367
- """Interact with a `func(path, *args, <free_widgets,optional>, **kwargs)`. `path` is passed from selected File.
368
- A CSS class 'files-interact' is added to the final widget to let you style it.
369
-
370
- Parameters
371
- ----------
372
- args :
373
- Any displayable widget can be passed. These are placed below the output widget of interact.
374
- For example you can add plotly's FigureWidget that updates based on the selection, these are passed to function after path.
375
- free_widgets : list/tuple
376
- Default is None. If not None, these are assumed to be ipywidgets and are placed below the widgets created by kwargs.
377
- These can be passed to the decorated function if added as arguemnt there like `func(..., free_widgets)`, but don't trigger execution.
378
- options : dict
379
- 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.
380
- height : str
381
- Default is '90vh'. height of the final widget. This is important to avoid very long widgets.
382
- panel_size: int
383
- Side panel size in units of em.
384
-
385
- kwargs are passed to ipywidgets.interactive and decorated function. Resulting widgets are placed below the file selection widget.
386
- 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)`.
387
-
388
- The decorated function can be called later separately as well, and has .args and .kwargs attributes to access the latest arguments
389
- and .result method to access latest. For a function `f`, `f.result` is same as `f(*f.args, **f.kwargs)`.
390
-
391
- >>> import plotly.graph_objects as go
392
- >>> import ipyvasp as ipv
393
- >>> fs = ipv.Files('.','**/POSCAR')
394
- >>> @fs.interact(go.FigureWidget(),bl=(0,6,2),plot_cell=True, eqv_sites=True)
395
- >>> def plot(path, fig, bl,plot_cell,eqv_sites):
396
- >>> ipv.iplot2widget(ipv.POSCAR(path).iplot_lattice(
397
- >>> bond_length=bl,plot_cell=plot_cell,eqv_sites=eqv_sites
398
- >>> ),fig_widget=fig) # it keeps updating same view
399
-
400
- .. note::
401
- Use self.interactive to get a widget that stores the argements and can be called later in a notebook cell.
402
- """
403
-
264
+ @_sub_doc(ei.interact)
265
+ def interact(self, *funcs, auto_update=True, app_layout=None, grid_css={},**kwargs):
404
266
  def inner(func):
405
- display(self.interactive(func, *args,
406
- free_widgets=free_widgets,
407
- options=options,
408
- height=height,
409
- panel_size=panel_size,
267
+ display(self.interactive(func, *funcs,
268
+ auto_update=auto_update, app_layout = app_layout, grid_css=grid_css,
410
269
  **kwargs)
411
270
  )
412
271
  return func
413
272
  return inner
414
273
 
415
274
  def kpath_widget(self, height='400px'):
416
- "Get KpathWidget instance with these files."
417
- 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)
418
277
 
419
278
  def bands_widget(self, height='450px'):
420
279
  "Get BandsWidget instance with these files."
@@ -469,86 +328,103 @@ class Files:
469
328
  return self.summarize(lambda path: {"size": get_file_size(path)})
470
329
 
471
330
 
331
+
472
332
  @fix_signature
473
333
  class _PropPicker(VBox):
334
+ """Single projection picker with atoms and orbitals selection"""
335
+ props = traitlets.Dict({})
336
+
474
337
  def __init__(self, system_summary=None):
475
338
  super().__init__()
476
- self._widgets = {
477
- "atoms": Dropdown(description="Atoms"),
478
- "orbs": Dropdown(description="Orbs"),
479
- }
480
- self._html = ipw.HTML() # to observe
481
-
482
- def observe_change(change):
483
- self._html.value = change.new # is a string
484
-
485
- self._widgets["atoms"].observe(observe_change, "value")
486
- self._widgets["orbs"].observe(observe_change, "value")
487
-
488
- self._atoms = {}
489
- 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')
490
348
  self._process(system_summary)
491
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
+
492
363
  def _process(self, system_summary):
493
- if not hasattr(system_summary, "orbs"):
494
- self.children = [
495
- ipw.HTML(f"❌ No projection data found from given summary!")
496
- ]
497
- 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
498
368
 
499
- self.children = [self._widgets["atoms"], self._widgets["orbs"]]
500
369
  sorbs = system_summary.orbs
370
+ self._orbs_map = {"-": [], "All": range(len(sorbs)), "s": [0]}
501
371
 
502
- orbs = {"-": [], "All": range(len(sorbs)), "s": [0]}
372
+ # p-orbitals
503
373
  if set(["px", "py", "pz"]).issubset(sorbs):
504
- orbs["p"] = range(1, 4)
505
- orbs["px+py"] = [
506
- idx for idx, key in enumerate(sorbs) if key in ("px", "py")
507
- ]
508
- 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
509
381
  if set(["dxy", "dyz"]).issubset(sorbs):
510
- orbs["d"] = range(4, 9)
511
- 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
512
388
  if len(sorbs) == 16:
513
- orbs["f"] = range(9, 16)
514
- orbs.update({k: [v] for k, v in zip(sorbs[9:], range(9, 16))})
515
- if len(sorbs) > 16: # What the hell here
516
- orbs.update({k: [idx] for idx, k in enumerate(sorbs[16:], start=16)})
517
-
518
- self._orbs = orbs
519
- old_orb = self._widgets["orbs"].value
520
- self._widgets["orbs"].options = list(orbs.keys())
521
- if old_orb in self._widgets["orbs"].options:
522
- self._widgets["orbs"].value = old_orb
523
-
524
- atoms = {"-": [], "All": range(system_summary.NIONS)}
525
- for key, tp in system_summary.types.to_dict().items():
526
- atoms[key] = tp
527
- for n, v in enumerate(tp, start=1):
528
- atoms[f"{key}{n}"] = [v]
529
-
530
- self._atoms = atoms
531
- old_atom = self._widgets["atoms"].value
532
- self._widgets["atoms"].options = list(atoms.keys())
533
- if old_atom in self._widgets["atoms"].options:
534
- 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
535
413
 
536
414
  def update(self, system_summary):
537
- return self._process(system_summary)
538
-
539
- @property
540
- def props(self):
541
- items = {k: w.value for k, w in self._widgets.items()}
542
- items["atoms"] = self._atoms.get(items["atoms"], [])
543
- items["orbs"] = self._orbs.get(items["orbs"], [])
544
- items[
545
- "label"
546
- ] = f"{self._widgets['atoms'].value or ''}-{self._widgets['orbs'].value or ''}"
547
- return items
548
-
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
549
425
 
550
426
  @fix_signature
551
- class PropsPicker(VBox):
427
+ class PropsPicker(VBox): # NOTE: remove New Later
552
428
  """
553
429
  A widget to pick atoms and orbitals for plotting.
554
430
 
@@ -556,88 +432,48 @@ class PropsPicker(VBox):
556
432
  ----------
557
433
  system_summary : (Vasprun,Vaspout).summary
558
434
  N : int, default is 3, number of projections to pick.
559
- on_button_click : callable, takes button as arguemnet. Default is None, a function to call when button is clicked.
560
- 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.
561
437
  """
562
-
563
- def __init__(
564
- self, system_summary=None, N=3, on_button_click=None, on_selection_changed=None
565
- ):
438
+ projections = traitlets.Dict({})
439
+
440
+ def __init__(self, system_summary=None, N=3):
566
441
  super().__init__()
567
- self._linked = Dropdown(
568
- options=[str(i + 1) for i in range(N)]
569
- if N != 3
570
- else ("Red", "Green", "Blue"),
571
- description="Projection" if N != 3 else "Color",
572
- )
573
- self._stacked = Stack(
574
- children=tuple(_PropPicker(system_summary) for _ in range(N)),
575
- 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)],
576
450
  )
577
- self._button = Button(description="Run Function")
578
-
579
- if callable(on_button_click):
580
- self._button.on_click(on_button_click)
581
-
582
- for w in [self._button, self._linked]:
583
- w.layout.width = "max-content"
584
-
585
- ipw.link((self._linked, "index"), (self._stacked, "selected_index"))
586
- self.children = [HBox([self._linked, self._button]), self._stacked]
587
-
588
- if callable(on_selection_changed):
589
- for child in self._stacked.children:
590
- child._html.observe(on_selection_changed, names="value")
591
-
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
+
592
473
  def update(self, system_summary):
593
- for child in self._stacked.children:
594
- child.update(system_summary)
595
-
596
- @property
597
- def button(self):
598
- return self._button
599
-
600
- @property
601
- def projections(self):
602
- out = {}
603
- for child in self._stacked.children:
604
- props = child.props
605
- if props["atoms"] and props["orbs"]: # discard empty
606
- out[props["label"]] = (props["atoms"], props["orbs"])
607
-
608
- return out
609
-
610
-
611
- def __store_figclick_data(fig, store_dict, callback=None, selection=False):
612
- "Store clicked data in a dict. callback takes trace as argument and is called after storing data."
613
- if not isinstance(fig, go.FigureWidget):
614
- raise TypeError("fig must be a FigureWidget")
615
- if not isinstance(store_dict, dict):
616
- raise TypeError("store_dict must be a dict")
617
- if callback and not callable(callback):
618
- raise TypeError("callback must be callable if given")
619
-
620
- def handle_click(trace, points, state):
621
- store_dict["data"] = points
622
- if callback:
623
- callback(trace)
624
-
625
- for trace in fig.data:
626
- if selection:
627
- trace.on_selection(handle_click)
628
- else:
629
- trace.on_click(handle_click)
630
-
631
-
632
- def store_clicked_data(fig, store_dict, callback=None):
633
- "Store clicked point data to a store_dict. callback takes trace being clicked as argument."
634
- return __store_figclick_data(fig, store_dict, callback, selection=False)
635
-
636
-
637
- def store_selected_data(fig, store_dict, callback=None):
638
- "Store multipoints selected data to a store_dict. callback takes trace being clicked as argument."
639
- return __store_figclick_data(fig, store_dict, callback, selection=True)
640
-
474
+ """Update all pickers with new system data"""
475
+ for picker in self._pickers:
476
+ picker.update(system_summary)
641
477
 
642
478
  def load_results(paths_list):
643
479
  "Loads result.json from paths_list and returns a dataframe."
@@ -658,211 +494,285 @@ def load_results(paths_list):
658
494
 
659
495
  return summarize(result_paths, load_data)
660
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)
661
576
 
662
577
  @fix_signature
663
- class BandsWidget(VBox):
578
+ class BandsWidget(_ThemedFigureInteract):
664
579
  """Visualize band structure from VASP calculation. You can click on the graph to get the data such as VBM, CBM, etc.
665
- Two attributes are important:
666
- self.clicked_data returns the last clicked point, that can also be stored as VBM, CBM etc, using Click dropdown.
667
- 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.
668
- 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.
669
589
  """
590
+ file = traitlets.Any(allow_none=True)
591
+ clicked_data = traitlets.Dict(allow_none=True)
592
+ selected_data = traitlets.Dict(allow_none=True)
670
593
 
671
594
  def __init__(self, files, height="450px"):
672
- super().__init__(_dom_classes=["BandsWidget"])
595
+ self.add_class("BandsWidget")
596
+ self._files = Files(files)
673
597
  self._bands = None
674
- self._fig = go.FigureWidget()
675
- self._tsd = Dropdown(
676
- description="Style", options=["plotly_white", "plotly_dark"]
677
- )
678
- self._click = Dropdown(description="Click", options=["None", "vbm", "cbm"])
679
- self._ktcicks = Text(description="kticks", tooltip="0 index maps to minimum value of kpoints slider.")
680
- self._brange = ipw.IntRangeSlider(description="bands",min=1, max=1) # number, not index
681
- self._krange = ipw.IntRangeSlider(description="kpoints",min=0, max=1,value=[0,1], tooltip="Includes non-zero weight kpoints")
682
- self._ppicks = PropsPicker(
683
- on_button_click=self._update_graph, on_selection_changed=self._warn_update
684
- )
685
- self._ppicks.button.description = "Update Graph"
686
- self._result = {} # store and save output results
687
- self._click_dict = {} # store clicked data
688
- self._select_dict = {} # store selection data
689
- 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'))
690
605
 
691
- Files(files)._attributed_interactive(self, self._load_data, self._fig,
692
- free_widgets=[
693
- self._tsd,
694
- self._brange,
695
- self._krange,
696
- self._ktcicks,
697
- ipw.HTML("<hr/>"),
698
- self._ppicks,
699
- ipw.HTML("<hr/>Click on graph to read selected option."),
700
- self._click,
606
+ self.relayout(
607
+ left_sidebar=[
608
+ 'head','file','krange','kticks','brange', 'ppicks',
609
+ [HBox(),('theme','button')],
701
610
  ],
702
- 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
703
615
  )
704
-
705
- self._tsd.observe(self._change_theme, "value")
706
- self._click.observe(self._click_save_data, "value")
707
- self._ktcicks.observe(self._warn_update, "value")
708
- self._krange.observe(self._set_krange, "value")
709
- self._brange.observe(self._warn_update, "value")
710
616
 
711
- @property
712
- def path(self):
713
- "Returns currently selected path."
714
- return self._interact._dd.value
617
+ @ei.callback
618
+ def _update_theme(self, fig, theme):
619
+ return super()._update_theme(fig, theme)
715
620
 
716
- @property
717
- def files(self):
718
- "Use slef.files.update(...) to keep state of widget preserved."
719
- 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
720
672
 
721
- def _load_data(self, path, fig): # Automatically redirectes to output widget
722
- if not hasattr(self, '_interact'): return # First time not availablebu
723
- self._interact.output_widget.clear_output(wait=True) # Why need again?
724
- with self._interact.output_widget:
725
- self._bands = (
726
- vp.Vasprun(path) if path.parts[-1].endswith('xml') else vp.Vaspout(path)
727
- ).bands
728
- self._ppicks.update(self.bands.source.summary)
729
-
730
- self._krange.max = self.bands.source.summary.NKPTS - 1
731
- self._krange.tooltip = f"Includes {self.bands.source.get_skipk()} non-zero weight kpoints"
732
- self.bands.source.set_skipk(0) # full range to view for slider flexibility after fix above
733
-
734
- self._kwargs['kpairs'] = [self._krange.value,]
735
- if (ticks := ", ".join(
736
- f"{k}:{v}" for k, v in self.bands.get_kticks()
737
- )): # Do not overwrite if empty
738
- self._ktcicks.value = ticks
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
739
700
 
740
- self._brange.max = self.bands.source.summary.NBANDS
741
- if self.bands.source.summary.LSORBIT:
742
- self._click.options = ["None", "vbm", "cbm", "so_max", "so_min"]
743
- else:
744
- self._click.options = ["None", "vbm", "cbm"]
745
-
746
- if (file := path.parent / "result.json").is_file():
747
- self._result = serializer.load(str(file.absolute())) # Old data loaded
748
-
749
- pdata = self.bands.source.poscar.data
750
- self._result.update(
751
- {
752
- "v": round(pdata.volume, 4),
753
- **{k: round(v, 4) for k, v in zip("abc", pdata.norms)},
754
- **{k: round(v, 4) for k, v in zip(["alpha","beta","gamma"], pdata.angles)},
755
- }
756
- )
757
- self._click_save_data(None) # Load into view
758
- self._warn_update(None)
701
+ # save cleaned data
702
+ serializer.dump(new_data,format="json",outfile=path)
703
+ return new_data
759
704
 
760
- @property
761
- def source(self):
762
- "Returns data source object such as Vasprun or Vaspout."
763
- 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
764
735
 
765
- @property
766
- def bands(self):
767
- "Bands class initialized"
768
- if not self._bands:
769
- raise ValueError("No data loaded by BandsWidget yet!")
770
- return self._bands
736
+ self._kws = {**self._kws, "kticks": kticks, "bands": _bands}
771
737
 
772
- @property
773
- def kwargs(self):
774
- "Selected kwargs from GUI"
775
- 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)
776
743
 
777
- @property
778
- def clicked_data(self):
779
- "Clicked data from graph"
780
- 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)
781
750
 
782
- @property
783
- def selected_data(self):
784
- "Data selected by box or lasso selection from graph"
785
- return self._select_dict.get("data", None)
786
-
787
- def _update_graph(self, btn):
788
- if not hasattr(self, '_interact'): return # First time not available
789
- self._interact.output_widget.clear_output(wait=True) # Why need again?
790
- with self._interact.output_widget:
791
- hsk = [
792
- [v.strip() for v in vs.split(":")]
793
- for vs in self._ktcicks.value.split(",")
794
- ]
795
- kticks = [(int(vs[0]), vs[1]) for vs in hsk if len(vs) == 2] or None
796
- self._kwargs = {**self._kwargs, "kticks": kticks, # below numbers instead of index and full shown range
797
- "bands": range(self._brange.value[0] - 1, self._brange.value[1]) if self._brange.value else None}
798
-
799
- if self._ppicks.projections:
800
- self._kwargs = {"projections": self._ppicks.projections, **self._kwargs}
801
- fig = self.bands.iplot_rgb_lines(**self._kwargs, name="Up")
802
- if self.bands.source.summary.ISPIN == 2:
803
- self.bands.iplot_rgb_lines(**self._kwargs, spin=1, name="Down", fig=fig)
804
-
805
- self.iplot = partial(self.bands.iplot_rgb_lines, **self._kwargs)
806
- self.splot = partial(self.bands.splot_rgb_lines, **self._kwargs)
807
- else:
808
- fig = self.bands.iplot_bands(**self._kwargs, name="Up")
809
- if self.bands.source.summary.ISPIN == 2:
810
- self.bands.iplot_bands(**self._kwargs, spin=1, name="Down", fig=fig)
811
-
812
- self.iplot = partial(self.bands.iplot_bands, **self._kwargs)
813
- self.splot = partial(self.bands.splot_bands, **self._kwargs)
814
-
815
- ptk.iplot2widget(fig, self._fig, template=self._tsd.value)
816
- self._click_dict.clear() # avoid data from previous figure
817
- self._select_dict.clear() # avoid data from previous figure
818
- store_clicked_data(
819
- self._fig,
820
- self._click_dict,
821
- callback=lambda trace: self._click_save_data("CLICK"),
822
- ) # 'CLICK' is needed to inntercept in a function
823
- store_selected_data(self._fig, self._select_dict, callback=None)
824
- self._ppicks.button.description = "Update Graph"
825
-
826
- def _change_theme(self, change):
827
- self._fig.layout.template = self._tsd.value
828
-
829
- def _set_krange(self, change):
830
- self._kwargs["kpairs"] = [self._krange.value,]
831
- self._warn_update(None) # Update warning
832
-
833
- def _click_save_data(self, change=None):
834
- def _show_and_save(data_dict):
835
- self._interact.output_widget.clear_output(wait=True) # Why need again?
836
- with self._interact.output_widget:
837
- print(pformat({key: value
838
- for key, value in data_dict.items()
839
- if key not in ("so_max", "so_min")
840
- }))
841
-
842
- serializer.dump(
843
- data_dict,
844
- format="json",
845
- outfile=self.path.parent / "result.json",
846
- )
751
+ self.iplot = partial(self.bands.iplot_bands, **self._kws)
752
+ self.splot = partial(self.bands.splot_bands, **self._kws)
847
753
 
848
- if change is None: # called from other functions but not from store_clicked_data
849
- return _show_and_save(self._result)
850
- # Should be after checking change
851
- if self._click.value and self._click.value == "None":
852
- return # No need to act on None
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
758
+
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)
853
763
 
854
764
  data_dict = self._result.copy() # Copy old data
855
765
 
856
- if data := self.clicked_data: # No need to make empty dict
857
- x = round(data.xs[0], 6)
858
- 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
859
769
 
860
- if key := self._click.value:
770
+ if key := self.params.cpoint.value:
861
771
  data_dict[key] = y # Assign value back
862
- if not key.startswith("so"):
863
- data_dict[key + "_k"] = round(
864
- x, 6
865
- ) # 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
866
776
 
867
777
  if data_dict.get("vbm", None) and data_dict.get("cbm", None):
868
778
  data_dict["gap"] = np.round(data_dict["cbm"] - data_dict["vbm"], 6)
@@ -873,22 +783,51 @@ class BandsWidget(VBox):
873
783
  )
874
784
 
875
785
  self._result.update(data_dict) # store new data
876
- _show_and_save(self._result)
877
-
878
- if change == "CLICK": # Called from store_clicked_data
879
- self._click.value = "None" # Reset to None to avoid accidental click
880
-
881
- def _warn_update(self, change):
882
- self._ppicks.button.description = "🔴 Update Graph"
883
-
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
+
884
805
  @property
885
806
  def results(self):
886
807
  "Generate a dataframe form result.json file in each folder."
887
- 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
+
888
827
 
889
828
 
890
829
  @fix_signature
891
- class KpathWidget(VBox):
830
+ class KPathWidget(_ThemedFigureInteract):
892
831
  """
893
832
  Interactively bulid a kpath for bandstructure calculation.
894
833
 
@@ -900,159 +839,163 @@ class KpathWidget(VBox):
900
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.
901
840
  - To break the path between two points "Γ" and "X" type "Γ 0,X" in the "Labels" box, zero means no points in interval.
902
841
 
903
- 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.
904
844
  """
845
+ file = traitlets.Any(None, allow_none=True)
905
846
 
906
- def __init__(self, files, height="400px"):
907
- super().__init__(_dom_classes=["KpathWidget"])
908
- self._fig = go.FigureWidget()
909
- self._sm = SelectMultiple(options=[], layout=Layout(width="auto"))
910
- self._lab = Text(description="Labels", continuous_update=True)
911
- self._kpt = Text(description="KPOINT", continuous_update=False)
912
- self._add = Button(description="Lock", tooltip="Lock/Unlock adding more points")
913
- self._del = Button(description="❌ Point", tooltip="Delete Selected Points")
914
- 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")
915
852
  self._poscar = None
916
- self._clicktime = None
853
+ self._oldclick = None
917
854
  self._kpoints = {}
918
-
919
- free_widgets = [
920
- HBox([self._add, self._del, self._tsb], layout=Layout(min_height="24px")),
921
- ipw.HTML(
922
- "<style>.KpathWidget .widget-select-multiple { min-height: 180px; }\n .widget-select-multiple > select {height: 100%;}</style>"
923
- ),
924
- self._sm,
925
- self._lab,
926
- self._kpt,
927
- ]
928
-
929
- Files(files)._attributed_interactive(self,
930
- 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
931
865
  )
932
-
933
- self._tsb.on_click(self._update_theme)
934
- self._add.on_click(self._toggle_lock)
935
- self._del.on_click(self._del_point)
936
- self._kpt.observe(self._take_kpt, "value")
937
- self._lab.observe(self._add_label)
938
-
939
- @property
940
- def path(self):
941
- "Returns currently selected path."
942
- return self._interact._dd.value # itself a Path object
943
-
944
- @property
945
- def files(self):
946
- "Use slef.files.update(...) to keep state of widget preserved."
947
- return self._files
948
866
 
949
- @property
950
- def poscar(self):
951
- "POSCAR class associated to current selection."
952
- 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
+ )
953
883
 
954
- def _update_fig(self, path, fig):
955
- if not hasattr(self, '_interact'): return # First time not available
956
- 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
957
887
 
958
- with self._interact.output_widget:
959
- template = (
960
- "plotly_dark" if "Light" in self._tsb.description else "plotly_white"
961
- )
962
- self._poscar = POSCAR(path)
963
- ptk.iplot2widget(
964
- self._poscar.iplot_bz(fill=False, color="red"), fig, template
965
- )
966
- with fig.batch_animate():
967
- fig.add_trace(
968
- go.Scatter3d(
969
- x=[],
970
- y=[],
971
- z=[],
972
- mode="lines+text",
973
- name="path",
974
- text=[],
975
- hoverinfo="none", # dont let it block other points
976
- textfont_size=18,
977
- )
978
- ) # add path that will be updated later
979
- self._click() # handle events
980
- print("Click points on plot to store for kpath.")
981
-
982
- def _click(self):
983
- def handle_click(trace, points, state):
984
- if self._clicktime and (time() - self._clicktime < 1):
985
- return # Avoid double clicks
986
-
987
- self._clicktime = time() # register this click's time
988
-
989
- if points.ys != []:
990
- index = points.point_inds[0]
991
- kp = trace.hovertext[index]
992
- kp = [float(k) for k in kp.split("[")[1].split("]")[0].split()]
993
-
994
- if self._sm.value:
995
- self._take_kpt(kp) # this updates plot back as well
996
- 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
997
922
  self._add_point(kp)
998
-
999
- for trace in self._fig.data:
1000
- if "HSK" in trace.name:
1001
- trace.on_click(handle_click)
1002
-
1003
- def _update_selection(self):
1004
- with self._interact.output_widget:
1005
- coords, labels = self.get_coords_labels()
1006
- with self._fig.batch_animate():
1007
- for trace in self._fig.data:
1008
- if "path" in trace.name and coords.any():
1009
- trace.x = coords[:, 0]
1010
- trace.y = coords[:, 1]
1011
- trace.z = coords[:, 2]
1012
- trace.text = _fmt_labels(
1013
- labels
1014
- ) # convert latex to html equivalent
1015
-
1016
- def get_coords_labels(self):
1017
- "Returns tuple of (coordinates, labels) to directly plot."
1018
- with self._interact.output_widget:
1019
- points = self.get_kpoints()
1020
-
1021
- coords = (
1022
- self.poscar.bz.to_cartesian([p[:3] for p in points]).tolist()
1023
- if points
1024
- else []
1025
- )
1026
- labels = [
1027
- p[3] if (len(p) >= 4 and isinstance(p[3], str)) else "" for p in points
1028
- ]
1029
- numbers = [
1030
- p[4]
1031
- if len(p) == 5
1032
- else p[3]
1033
- if (len(p) == 4 and isinstance(p[3], int))
1034
- else ""
1035
- for p in points
1036
- ]
1037
-
1038
- j = 0
1039
- for i, n in enumerate(numbers, start=1):
1040
- if isinstance(n, int) and n == 0:
1041
- labels.insert(i + j, "")
1042
- coords.insert(i + j, [np.nan, np.nan, np.nan])
1043
- j += 1
1044
-
1045
- 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
1046
991
 
1047
992
  def get_kpoints(self):
1048
993
  "Returns kpoints list including labels and numbers in intervals if given."
1049
- keys = [
1050
- idx for (_, idx) in self._sm.options if idx in self._kpoints
1051
- ] # 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
1052
995
  kpts = [self._kpoints[k] for k in keys]
1053
996
  LN = [
1054
997
  lab.split("⋮")[1].strip().split()
1055
- for (lab, idx) in self._sm.options
998
+ for (lab, idx) in self.params.sm.options
1056
999
  if idx in keys
1057
1000
  ]
1058
1001
 
@@ -1072,76 +1015,43 @@ class KpathWidget(VBox):
1072
1015
  )
1073
1016
  return kpts
1074
1017
 
1075
- def _update_theme(self, btn):
1076
- if "Dark" in btn.description:
1077
- self._fig.layout.template = "plotly_dark"
1078
- btn.description = "Light Plot"
1079
- else:
1080
- self._fig.layout.template = "plotly_white"
1081
- btn.description = "Dark Plot"
1082
-
1083
- def _add_point(self, kpt):
1084
- with self._interact.output_widget:
1085
- self._sm.options = [*self._sm.options, ("⋮", len(self._sm.options))]
1086
- self._sm.value = (
1087
- self._sm.options[-1][1],
1088
- ) # select to receive point as well
1089
- self._take_kpt(kpt) # add point, label and plot back
1090
-
1091
- def _toggle_lock(self, btn):
1092
- if self._add.description == "Lock":
1093
- self._add.description = "Unlock"
1094
- else:
1095
- self._add.description = "Lock"
1096
-
1097
- def _del_point(self, btn):
1098
- with self._interact.output_widget:
1099
- for (
1100
- v
1101
- ) in (
1102
- self._sm.value
1103
- ): # for loop here is important to update selection properly
1104
- self._sm.options = [opt for opt in self._sm.options if opt[1] != v]
1105
- 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
1106
1035
 
1107
- def _take_kpt(self, change_or_kpt):
1108
- with self._interact.output_widget:
1109
- if isinstance(change_or_kpt, (list, tuple)):
1110
- point = change_or_kpt
1111
- else:
1112
- point = [float(v) for v in self._kpt.value.split(",")]
1113
-
1114
- if len(point) != 3:
1115
- raise ValueError("Expects KPOINT of 3 floats")
1116
-
1117
- self._kpoints.update({v: point for v in self._sm.value})
1118
- label = "{:>8.4f} {:>8.4f} {:>8.4f}".format(*point)
1119
- self._sm.options = [
1120
- (label, value) if value in self._sm.value else (lab, value)
1121
- for (lab, value) in self._sm.options
1122
- ]
1123
- self._add_label(None) # Re-adjust labels and update plot as well
1124
-
1125
- def _add_label(self, change):
1126
- with self._interact.output_widget:
1127
- labs = [" ⋮ " for _ in self._sm.options] # as much as options
1128
- for idx, (_, lab) in enumerate(
1129
- zip(self._sm.options, self._lab.value.split(","))
1130
- ):
1131
- labs[idx] = labs[idx] + lab # don't leave empty anyhow
1132
-
1133
- self._sm.options = [
1134
- (v.split("⋮")[0].strip() + lab, idx)
1135
- for (v, idx), lab in zip(self._sm.options, labs)
1136
- ]
1137
-
1138
- 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
1139
1045
 
1140
1046
  @_sub_doc(lat.get_kpath, {"kpoints :.*n :": "n :", "rec_basis :.*\n\n": "\n\n"})
1141
1047
  @_sig_kwargs(lat.get_kpath, ("kpoints", "rec_basis"))
1142
1048
  def get_kpath(self, n=5, **kwargs):
1143
1049
  return self.poscar.get_kpath(self.get_kpoints(), n=n, **kwargs)
1144
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
+
1145
1055
  def splot(self, plane=None, fmt_label=lambda x: x, plot_kws={}, **kwargs):
1146
1056
  """
1147
1057
  Same as `ipyvasp.lattice.POSCAR.splot_bz` except it also plots path on BZ.
@@ -1165,10 +1075,5 @@ class KpathWidget(VBox):
1165
1075
  ) # plots on ax automatically
1166
1076
  return ax
1167
1077
 
1168
- def iplot(self):
1169
- "Returns disconnected current plotly figure"
1170
- return go.Figure(data=self._fig.data, layout=self._fig.layout)
1171
-
1172
-
1173
1078
  # Should be at end
1174
1079
  del fix_signature # no more need