Coverage for ase / gui / nanoparticle.py: 78.42%
292 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 10:20 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 10:20 +0000
1# fmt: off
3"""nanoparticle.py - Window for setting up crystalline nanoparticles.
4"""
5from copy import copy
7import numpy as np
9import ase.data
10import ase.gui.ui as ui
11from ase.cluster import wulff_construction
12from ase.cluster.cubic import BodyCenteredCubic, FaceCenteredCubic, SimpleCubic
13from ase.cluster.hexagonal import Graphite, HexagonalClosedPacked
14from ase.gui.i18n import _
15from ase.gui.widgets import Element, pybutton
17# Delayed imports:
18# ase.cluster.data
21introtext = _("""\
22Create a nanoparticle either by specifying the number of layers, or using the
23Wulff construction. Please press the [Help] button for instructions on how to
24specify the directions.
25WARNING: The Wulff construction currently only works with cubic crystals!
26""")
28helptext = _("""
29The nanoparticle module sets up a nano-particle or a cluster with a given
30crystal structure.
321) Select the element, the crystal structure and the lattice constant(s).
33 The [Get structure] button will find the data for a given element.
352) Choose if you want to specify the number of layers in each direction, or if
36 you want to use the Wulff construction. In the latter case, you must
37 specify surface energies in each direction, and the size of the cluster.
39How to specify the directions:
40------------------------------
42First time a direction appears, it is interpreted as the entire family of
43directions, i.e. (0,0,1) also covers (1,0,0), (-1,0,0) etc. If one of these
44directions is specified again, the second specification overrules that specific
45direction. For this reason, the order matters and you can rearrange the
46directions with the [Up] and [Down] keys. You can also add a new direction,
47remember to press [Add] or it will not be included.
49Example: (1,0,0) (1,1,1), (0,0,1) would specify the {100} family of directions,
50the {111} family and then the (001) direction, overruling the value given for
51the whole family of directions.
52""")
54py_template_layers = """
55import ase
56%(import)s
58surfaces = %(surfaces)s
59layers = %(layers)s
60lc = %(latconst)s
61atoms = %(factory)s('%(element)s', surfaces, layers, latticeconstant=lc)
63# OPTIONAL: Cast to ase.Atoms object, discarding extra information:
64# atoms = ase.Atoms(atoms)
65"""
67py_template_wulff = """
68import ase
69from ase.cluster import wulff_construction
71surfaces = %(surfaces)s
72esurf = %(energies)s
73lc = %(latconst)s
74size = %(natoms)s # Number of atoms
75atoms = wulff_construction('%(element)s', surfaces, esurf,
76 size, '%(structure)s',
77 rounding='%(rounding)s', latticeconstant=lc)
79# OPTIONAL: Cast to ase.Atoms object, discarding extra information:
80# atoms = ase.Atoms(atoms)
81"""
84class SetupNanoparticle:
85 "Window for setting up a nanoparticle."
87 structure_names = {
88 'fcc': _('Face centered cubic (fcc)'),
89 'bcc': _('Body centered cubic (bcc)'),
90 'sc': _('Simple cubic (sc)'),
91 'hcp': _('Hexagonal closed-packed (hcp)'),
92 'graphite': _('Graphite')}
94 needs_4index = { # 3 or 4 index dimension
95 'fcc': False, 'bcc': False, 'sc': False,
96 'hcp': True, 'graphite': True}
98 needs_2lat = { # 1 or 2 lattice parameters
99 'fcc': False, 'bcc': False, 'sc': False,
100 'hcp': True, 'graphite': True}
102 structure_factories = {
103 'fcc': FaceCenteredCubic,
104 'bcc': BodyCenteredCubic,
105 'sc': SimpleCubic,
106 'hcp': HexagonalClosedPacked,
107 'graphite': Graphite}
109 # A list of import statements for the Python window.
110 import_names = {
111 'fcc': 'from ase.cluster.cubic import FaceCenteredCubic',
112 'bcc': 'from ase.cluster.cubic import BodyCenteredCubic',
113 'sc': 'from ase.cluster.cubic import SimpleCubic',
114 'hcp': 'from ase.cluster.hexagonal import HexagonalClosedPacked',
115 'graphite': 'from ase.cluster.hexagonal import Graphite'}
117 # Default layer specifications for the different structures.
118 default_layers = {'fcc': [((1, 0, 0), 6),
119 ((1, 1, 0), 9),
120 ((1, 1, 1), 5)],
121 'bcc': [((1, 0, 0), 6),
122 ((1, 1, 0), 9),
123 ((1, 1, 1), 5)],
124 'sc': [((1, 0, 0), 6),
125 ((1, 1, 0), 9),
126 ((1, 1, 1), 5)],
127 'hcp': [((0, 0, 0, 1), 5),
128 ((1, 0, -1, 0), 5)],
129 'graphite': [((0, 0, 0, 1), 5),
130 ((1, 0, -1, 0), 5)]}
132 def __init__(self, gui):
133 self.atoms = None
134 self.no_update = True
135 self.old_structure = 'fcc'
137 win = self.win = ui.Window(_('Nanoparticle'))
138 win.add(ui.Text(introtext))
140 self.element = Element('', self.apply)
141 lattice_button = ui.Button(_('Get structure'),
142 self.set_structure_data)
143 self.elementinfo = ui.Label(' ')
144 win.add(self.element)
145 win.add(self.elementinfo)
146 win.add(lattice_button)
148 # The structure and lattice constant
149 labels = []
150 values = []
151 for abbrev, name in self.structure_names.items():
152 labels.append(name)
153 values.append(abbrev)
154 self.structure_cb = ui.ComboBox(
155 labels=labels, values=values, callback=self.update_structure)
156 win.add([_('Structure:'), self.structure_cb])
158 self.a = ui.SpinBox(3.0, 0.0, 1000.0, 0.01, self.update)
159 self.c = ui.SpinBox(3.0, 0.0, 1000.0, 0.01, self.update)
160 win.add([_('Lattice constant: a ='), self.a, ' c =', self.c])
162 # Choose specification method
163 self.method_cb = ui.ComboBox(
164 labels=[_('Layer specification'), _('Wulff construction')],
165 values=['layers', 'wulff'],
166 callback=self.update_gui_method)
167 win.add([_('Method: '), self.method_cb])
169 self.layerlabel = ui.Label('Missing text') # Filled in later
170 win.add(self.layerlabel)
171 self.direction_table_rows = ui.Rows()
172 win.add(self.direction_table_rows)
173 self.default_direction_table()
175 win.add(_('Add new direction:'))
176 self.new_direction_and_size_rows = ui.Rows()
177 win.add(self.new_direction_and_size_rows)
178 self.update_new_direction_and_size_stuff()
180 # Information
181 win.add(_('Information about the created cluster:'))
182 self.info = [_('Number of atoms: '),
183 ui.Label('-'),
184 _(' Approx. diameter: '),
185 ui.Label('-')]
186 win.add(self.info)
188 # Finalize setup
189 self.update_structure()
190 self.update_gui_method()
191 self.no_update = False
193 self.auto = ui.CheckButton(_('Automatic Apply'))
194 win.add(self.auto)
196 win.add([pybutton(_('Creating a nanoparticle.'), self.makeatoms),
197 ui.helpbutton(helptext),
198 ui.Button(_('Apply'), self.apply),
199 ui.Button(_('OK'), self.ok)])
201 self.gui = gui
202 self.smaller_button = None
203 self.largeer_button = None
205 self.element.grab_focus()
207 def default_direction_table(self):
208 'Set default directions and values for the current crystal structure.'
209 self.direction_table = []
210 struct = self.structure_cb.value
211 for direction, layers in self.default_layers[struct]:
212 self.direction_table.append((direction, layers, 1.0))
214 def update_direction_table(self):
215 self.direction_table_rows.clear()
216 for direction, layers, energy in self.direction_table:
217 self.add_direction(direction, layers, energy)
218 self.update()
220 def add_direction(self, direction, layers, energy):
221 i = len(self.direction_table_rows)
223 if self.method_cb.value == 'wulff':
224 spin = ui.SpinBox(energy, 0.0, 1000.0, 0.1, self.update)
225 else:
226 spin = ui.SpinBox(layers, 1, 100, 1, self.update)
228 up = ui.Button(_('Up'), self.row_swap_next, i - 1)
229 down = ui.Button(_('Down'), self.row_swap_next, i)
230 delete = ui.Button(_('Delete'), self.row_delete, i)
232 self.direction_table_rows.add([str(direction) + ':',
233 spin, up, down, delete])
234 up.active = i > 0
235 down.active = False
236 delete.active = i > 0
238 if i > 0:
239 down, delete = self.direction_table_rows[-2][3:]
240 down.active = True
241 delete.active = True
243 def update_new_direction_and_size_stuff(self):
244 if self.needs_4index[self.structure_cb.value]:
245 n = 4
246 else:
247 n = 3
249 rows = self.new_direction_and_size_rows
251 rows.clear()
253 self.new_direction = row = ['(']
254 for i in range(n):
255 if i > 0:
256 row.append(',')
257 row.append(ui.SpinBox(0, -100, 100, 1))
258 row.append('):')
260 if self.method_cb.value == 'wulff':
261 row.append(ui.SpinBox(1.0, 0.0, 1000.0, 0.1))
262 else:
263 row.append(ui.SpinBox(5, 1, 100, 1))
265 row.append(ui.Button(_('Add'), self.row_add))
267 rows.add(row)
269 if self.method_cb.value == 'wulff':
270 # Extra widgets for the Wulff construction
271 self.size_radio = ui.RadioButtons(
272 [_('Number of atoms'), _('Diameter')],
273 ['natoms', 'diameter'],
274 self.update_gui_size)
275 self.size_natoms = ui.SpinBox(100, 1, 100000, 1,
276 self.update_size_natoms)
277 self.size_diameter = ui.SpinBox(5.0, 0, 100.0, 0.1,
278 self.update_size_diameter)
279 self.round_radio = ui.RadioButtons(
280 [_('above '), _('below '), _('closest ')],
281 ['above', 'below', 'closest'],
282 callback=self.update)
283 self.smaller_button = ui.Button(_('Smaller'), self.wulff_smaller)
284 self.larger_button = ui.Button(_('Larger'), self.wulff_larger)
285 rows.add(_('Choose size using:'))
286 rows.add(self.size_radio)
287 rows.add([_('atoms'), self.size_natoms,
288 _('ų'), self.size_diameter])
289 rows.add(
290 _('Rounding: If exact size is not possible, choose the size:'))
291 rows.add(self.round_radio)
292 rows.add([self.smaller_button, self.larger_button])
293 self.update_gui_size()
294 else:
295 self.smaller_button = None
296 self.larger_button = None
298 def update_structure(self, s=None):
299 'Called when the user changes the structure.'
300 s = self.structure_cb.value
301 if s != self.old_structure:
302 old4 = self.needs_4index[self.old_structure]
303 if self.needs_4index[s] != old4:
304 # The table of directions is invalid.
305 self.update_new_direction_and_size_stuff()
306 self.default_direction_table()
307 self.update_direction_table()
308 self.old_structure = s
309 self.c.active = self.needs_2lat[s]
310 self.update()
312 def update_gui_method(self, *args):
313 'Switch between layer specification and Wulff construction.'
314 self.update_direction_table()
315 self.update_new_direction_and_size_stuff()
316 if self.method_cb.value == 'wulff':
317 self.layerlabel.text = _(
318 'Surface energies (as energy/area, NOT per atom):')
319 else:
320 self.layerlabel.text = _('Number of layers:')
322 self.update()
324 def wulff_smaller(self, widget=None):
325 'Make a smaller Wulff construction.'
326 n = len(self.atoms)
327 self.size_radio.value = 'natoms'
328 self.size_natoms.value = n - 1
329 self.round_radio.value = 'below'
330 self.apply()
332 def wulff_larger(self, widget=None):
333 'Make a larger Wulff construction.'
334 n = len(self.atoms)
335 self.size_radio.value = 'natoms'
336 self.size_natoms.value = n + 1
337 self.round_radio.value = 'above'
338 self.apply()
340 def row_add(self, widget=None):
341 'Add a row to the list of directions.'
342 if self.needs_4index[self.structure_cb.value]:
343 n = 4
344 else:
345 n = 3
346 idx = tuple(a.value for a in self.new_direction[1:1 + 2 * n:2])
347 if not any(idx):
348 ui.error(_('At least one index must be non-zero'), '')
349 return
350 if n == 4 and sum(idx) != 0:
351 ui.error(_('Invalid hexagonal indices',
352 'The sum of the first three numbers must be zero'))
353 return
354 new = [idx, 5, 1.0]
355 if self.method_cb.value == 'wulff':
356 new[1] = self.new_direction[-2].value
357 else:
358 new[2] = self.new_direction[-2].value
359 self.direction_table.append(new)
360 self.add_direction(*new)
361 self.update()
363 def row_delete(self, row):
364 del self.direction_table[row]
365 self.update_direction_table()
367 def row_swap_next(self, row):
368 dt = self.direction_table
369 dt[row], dt[row + 1] = dt[row + 1], dt[row]
370 self.update_direction_table()
372 def update_gui_size(self, widget=None):
373 'Update gui when the cluster size specification changes.'
374 self.size_natoms.active = self.size_radio.value == 'natoms'
375 self.size_diameter.active = self.size_radio.value == 'diameter'
377 def update_size_natoms(self, widget=None):
378 at_vol = self.get_atomic_volume()
379 dia = 2.0 * (3 * self.size_natoms.value * at_vol /
380 (4 * np.pi))**(1 / 3)
381 self.size_diameter.value = dia
382 self.update()
384 def update_size_diameter(self, widget=None, update=True):
385 if self.size_diameter.active:
386 at_vol = self.get_atomic_volume()
387 n = round(np.pi / 6 * self.size_diameter.value**3 / at_vol)
388 self.size_natoms.value = int(n)
389 if update:
390 self.update()
392 def update(self, *args):
393 if self.no_update:
394 return
395 self.element.Z # Check
396 if self.auto.value:
397 self.makeatoms()
398 if self.atoms is not None:
399 self.gui.new_atoms(self.atoms)
400 self.gui.clear_history()
401 else:
402 self.clearatoms()
403 self.makeinfo()
405 def set_structure_data(self, *args):
406 'Called when the user presses [Get structure].'
407 z = self.element.Z
408 if z is None:
409 return
410 ref = ase.data.reference_states[z]
411 if ref is None:
412 structure = None
413 else:
414 structure = ref['symmetry']
416 if ref is None or structure not in self.structure_names:
417 ui.error(_('Unsupported or unknown structure'),
418 _('Element = {0}, structure = {1}')
419 .format(self.element.symbol, structure))
420 return
422 self.structure_cb.value = self.structure_names[structure]
424 a = ref['a']
425 self.a.value = a
426 if self.needs_4index[structure]:
427 try:
428 c = ref['c']
429 except KeyError:
430 c = ref['c/a'] * a
431 self.c.value = c
433 self.update_structure()
435 def makeatoms(self, *args):
436 'Make the atoms according to the current specification.'
437 symbol = self.element.symbol
438 if symbol is None:
439 self.clearatoms()
440 self.makeinfo()
441 return False
442 struct = self.structure_cb.value
443 if self.needs_2lat[struct]:
444 # a and c lattice constants
445 lc = {'a': self.a.value,
446 'c': self.c.value}
447 lc_str = str(lc)
448 else:
449 lc = self.a.value
450 lc_str = f'{lc:.5f}'
451 if self.method_cb.value == 'wulff':
452 # Wulff construction
453 surfaces = [x[0] for x in self.direction_table]
454 surfaceenergies = [x[1].value
455 for x in self.direction_table_rows.rows]
456 self.update_size_diameter(update=False)
457 rounding = self.round_radio.value
458 self.atoms = wulff_construction(symbol,
459 surfaces,
460 surfaceenergies,
461 self.size_natoms.value,
462 self.structure_factories[struct],
463 rounding, lc)
464 python = py_template_wulff % {'element': symbol,
465 'surfaces': str(surfaces),
466 'energies': str(surfaceenergies),
467 'latconst': lc_str,
468 'natoms': self.size_natoms.value,
469 'structure': struct,
470 'rounding': rounding}
471 else:
472 # Layer-by-layer specification
473 surfaces = [x[0] for x in self.direction_table]
474 layers = [x[1].value for x in self.direction_table_rows.rows]
475 self.atoms = self.structure_factories[struct](
476 symbol, copy(surfaces), layers, latticeconstant=lc)
477 imp = self.import_names[struct]
478 python = py_template_layers % {'import': imp,
479 'element': symbol,
480 'surfaces': str(surfaces),
481 'layers': str(layers),
482 'latconst': lc_str,
483 'factory': imp.split()[-1]}
484 self.makeinfo()
486 return python
488 def clearatoms(self):
489 self.atoms = None
491 def get_atomic_volume(self):
492 s = self.structure_cb.value
493 a = self.a.value
494 c = self.c.value
495 if s == 'fcc':
496 return a**3 / 4
497 elif s == 'bcc':
498 return a**3 / 2
499 elif s == 'sc':
500 return a**3
501 elif s == 'hcp':
502 return np.sqrt(3.0) / 2 * a * a * c / 2
503 elif s == 'graphite':
504 return np.sqrt(3.0) / 2 * a * a * c / 4
506 def makeinfo(self):
507 """Fill in information field about the atoms.
509 Also turns the Wulff construction buttons [Larger] and
510 [Smaller] on and off.
511 """
512 if self.atoms is None:
513 self.info[1].text = '-'
514 self.info[3].text = '-'
515 else:
516 at_vol = self.get_atomic_volume()
517 dia = 2 * (3 * len(self.atoms) * at_vol / (4 * np.pi))**(1 / 3)
518 self.info[1].text = str(len(self.atoms))
519 self.info[3].text = f'{dia:.1f} Å'
521 if self.method_cb.value == 'wulff':
522 if self.smaller_button is not None:
523 self.smaller_button.active = self.atoms is not None
524 self.larger_button.active = self.atoms is not None
526 def apply(self, callbackarg=None):
527 self.makeatoms()
528 if self.atoms is not None:
529 self.gui.new_atoms(self.atoms)
530 self.gui.clear_history()
531 return True
532 else:
533 ui.error(_('No valid atoms.'),
534 _('You have not (yet) specified a consistent set of '
535 'parameters.'))
536 return False
538 def ok(self):
539 if self.apply():
540 self.win.close()