Coverage for /builds/ase/ase/ase/gui/nanoparticle.py: 78.69%
291 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-08-02 00:12 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-08-02 00:12 +0000
1# fmt: off
3"""nanoparticle.py - Window for setting up crystalline nanoparticles.
4"""
5from copy import copy
7import numpy as np
9import ase
10import ase.data
11import ase.gui.ui as ui
12from ase.cluster import wulff_construction
13from ase.cluster.cubic import BodyCenteredCubic, FaceCenteredCubic, SimpleCubic
14from ase.cluster.hexagonal import Graphite, HexagonalClosedPacked
15from ase.gui.i18n import _
16from ase.gui.widgets import Element, pybutton
18# Delayed imports:
19# ase.cluster.data
22introtext = _("""\
23Create a nanoparticle either by specifying the number of layers, or using the
24Wulff construction. Please press the [Help] button for instructions on how to
25specify the directions.
26WARNING: The Wulff construction currently only works with cubic crystals!
27""")
29helptext = _("""
30The nanoparticle module sets up a nano-particle or a cluster with a given
31crystal structure.
331) Select the element, the crystal structure and the lattice constant(s).
34 The [Get structure] button will find the data for a given element.
362) Choose if you want to specify the number of layers in each direction, or if
37 you want to use the Wulff construction. In the latter case, you must
38 specify surface energies in each direction, and the size of the cluster.
40How to specify the directions:
41------------------------------
43First time a direction appears, it is interpreted as the entire family of
44directions, i.e. (0,0,1) also covers (1,0,0), (-1,0,0) etc. If one of these
45directions is specified again, the second specification overrules that specific
46direction. For this reason, the order matters and you can rearrange the
47directions with the [Up] and [Down] keys. You can also add a new direction,
48remember to press [Add] or it will not be included.
50Example: (1,0,0) (1,1,1), (0,0,1) would specify the {100} family of directions,
51the {111} family and then the (001) direction, overruling the value given for
52the whole family of directions.
53""")
55py_template_layers = """
56import ase
57%(import)s
59surfaces = %(surfaces)s
60layers = %(layers)s
61lc = %(latconst)s
62atoms = %(factory)s('%(element)s', surfaces, layers, latticeconstant=lc)
64# OPTIONAL: Cast to ase.Atoms object, discarding extra information:
65# atoms = ase.Atoms(atoms)
66"""
68py_template_wulff = """
69import ase
70from ase.cluster import wulff_construction
72surfaces = %(surfaces)s
73esurf = %(energies)s
74lc = %(latconst)s
75size = %(natoms)s # Number of atoms
76atoms = wulff_construction('%(element)s', surfaces, esurf,
77 size, '%(structure)s',
78 rounding='%(rounding)s', latticeconstant=lc)
80# OPTIONAL: Cast to ase.Atoms object, discarding extra information:
81# atoms = ase.Atoms(atoms)
82"""
85class SetupNanoparticle:
86 "Window for setting up a nanoparticle."
88 structure_names = {
89 'fcc': _('Face centered cubic (fcc)'),
90 'bcc': _('Body centered cubic (bcc)'),
91 'sc': _('Simple cubic (sc)'),
92 'hcp': _('Hexagonal closed-packed (hcp)'),
93 'graphite': _('Graphite')}
95 needs_4index = { # 3 or 4 index dimension
96 'fcc': False, 'bcc': False, 'sc': False,
97 'hcp': True, 'graphite': True}
99 needs_2lat = { # 1 or 2 lattice parameters
100 'fcc': False, 'bcc': False, 'sc': False,
101 'hcp': True, 'graphite': True}
103 structure_factories = {
104 'fcc': FaceCenteredCubic,
105 'bcc': BodyCenteredCubic,
106 'sc': SimpleCubic,
107 'hcp': HexagonalClosedPacked,
108 'graphite': Graphite}
110 # A list of import statements for the Python window.
111 import_names = {
112 'fcc': 'from ase.cluster.cubic import FaceCenteredCubic',
113 'bcc': 'from ase.cluster.cubic import BodyCenteredCubic',
114 'sc': 'from ase.cluster.cubic import SimpleCubic',
115 'hcp': 'from ase.cluster.hexagonal import HexagonalClosedPacked',
116 'graphite': 'from ase.cluster.hexagonal import Graphite'}
118 # Default layer specifications for the different structures.
119 default_layers = {'fcc': [((1, 0, 0), 6),
120 ((1, 1, 0), 9),
121 ((1, 1, 1), 5)],
122 'bcc': [((1, 0, 0), 6),
123 ((1, 1, 0), 9),
124 ((1, 1, 1), 5)],
125 'sc': [((1, 0, 0), 6),
126 ((1, 1, 0), 9),
127 ((1, 1, 1), 5)],
128 'hcp': [((0, 0, 0, 1), 5),
129 ((1, 0, -1, 0), 5)],
130 'graphite': [((0, 0, 0, 1), 5),
131 ((1, 0, -1, 0), 5)]}
133 def __init__(self, gui):
134 self.atoms = None
135 self.no_update = True
136 self.old_structure = 'fcc'
138 win = self.win = ui.Window(_('Nanoparticle'), wmtype='utility')
139 win.add(ui.Text(introtext))
141 self.element = Element('', self.apply)
142 lattice_button = ui.Button(_('Get structure'),
143 self.set_structure_data)
144 self.elementinfo = ui.Label(' ')
145 win.add(self.element)
146 win.add(self.elementinfo)
147 win.add(lattice_button)
149 # The structure and lattice constant
150 labels = []
151 values = []
152 for abbrev, name in self.structure_names.items():
153 labels.append(name)
154 values.append(abbrev)
155 self.structure_cb = ui.ComboBox(
156 labels=labels, values=values, callback=self.update_structure)
157 win.add([_('Structure:'), self.structure_cb])
159 self.a = ui.SpinBox(3.0, 0.0, 1000.0, 0.01, self.update)
160 self.c = ui.SpinBox(3.0, 0.0, 1000.0, 0.01, self.update)
161 win.add([_('Lattice constant: a ='), self.a, ' c =', self.c])
163 # Choose specification method
164 self.method_cb = ui.ComboBox(
165 labels=[_('Layer specification'), _('Wulff construction')],
166 values=['layers', 'wulff'],
167 callback=self.update_gui_method)
168 win.add([_('Method: '), self.method_cb])
170 self.layerlabel = ui.Label('Missing text') # Filled in later
171 win.add(self.layerlabel)
172 self.direction_table_rows = ui.Rows()
173 win.add(self.direction_table_rows)
174 self.default_direction_table()
176 win.add(_('Add new direction:'))
177 self.new_direction_and_size_rows = ui.Rows()
178 win.add(self.new_direction_and_size_rows)
179 self.update_new_direction_and_size_stuff()
181 # Information
182 win.add(_('Information about the created cluster:'))
183 self.info = [_('Number of atoms: '),
184 ui.Label('-'),
185 _(' Approx. diameter: '),
186 ui.Label('-')]
187 win.add(self.info)
189 # Finalize setup
190 self.update_structure()
191 self.update_gui_method()
192 self.no_update = False
194 self.auto = ui.CheckButton(_('Automatic Apply'))
195 win.add(self.auto)
197 win.add([pybutton(_('Creating a nanoparticle.'), self.makeatoms),
198 ui.helpbutton(helptext),
199 ui.Button(_('Apply'), self.apply),
200 ui.Button(_('OK'), self.ok)])
202 self.gui = gui
203 self.smaller_button = None
204 self.largeer_button = None
206 self.element.grab_focus()
208 def default_direction_table(self):
209 'Set default directions and values for the current crystal structure.'
210 self.direction_table = []
211 struct = self.structure_cb.value
212 for direction, layers in self.default_layers[struct]:
213 self.direction_table.append((direction, layers, 1.0))
215 def update_direction_table(self):
216 self.direction_table_rows.clear()
217 for direction, layers, energy in self.direction_table:
218 self.add_direction(direction, layers, energy)
219 self.update()
221 def add_direction(self, direction, layers, energy):
222 i = len(self.direction_table_rows)
224 if self.method_cb.value == 'wulff':
225 spin = ui.SpinBox(energy, 0.0, 1000.0, 0.1, self.update)
226 else:
227 spin = ui.SpinBox(layers, 1, 100, 1, self.update)
229 up = ui.Button(_('Up'), self.row_swap_next, i - 1)
230 down = ui.Button(_('Down'), self.row_swap_next, i)
231 delete = ui.Button(_('Delete'), self.row_delete, i)
233 self.direction_table_rows.add([str(direction) + ':',
234 spin, up, down, delete])
235 up.active = i > 0
236 down.active = False
237 delete.active = i > 0
239 if i > 0:
240 down, delete = self.direction_table_rows[-2][3:]
241 down.active = True
242 delete.active = True
244 def update_new_direction_and_size_stuff(self):
245 if self.needs_4index[self.structure_cb.value]:
246 n = 4
247 else:
248 n = 3
250 rows = self.new_direction_and_size_rows
252 rows.clear()
254 self.new_direction = row = ['(']
255 for i in range(n):
256 if i > 0:
257 row.append(',')
258 row.append(ui.SpinBox(0, -100, 100, 1))
259 row.append('):')
261 if self.method_cb.value == 'wulff':
262 row.append(ui.SpinBox(1.0, 0.0, 1000.0, 0.1))
263 else:
264 row.append(ui.SpinBox(5, 1, 100, 1))
266 row.append(ui.Button(_('Add'), self.row_add))
268 rows.add(row)
270 if self.method_cb.value == 'wulff':
271 # Extra widgets for the Wulff construction
272 self.size_radio = ui.RadioButtons(
273 [_('Number of atoms'), _('Diameter')],
274 ['natoms', 'diameter'],
275 self.update_gui_size)
276 self.size_natoms = ui.SpinBox(100, 1, 100000, 1,
277 self.update_size_natoms)
278 self.size_diameter = ui.SpinBox(5.0, 0, 100.0, 0.1,
279 self.update_size_diameter)
280 self.round_radio = ui.RadioButtons(
281 [_('above '), _('below '), _('closest ')],
282 ['above', 'below', 'closest'],
283 callback=self.update)
284 self.smaller_button = ui.Button(_('Smaller'), self.wulff_smaller)
285 self.larger_button = ui.Button(_('Larger'), self.wulff_larger)
286 rows.add(_('Choose size using:'))
287 rows.add(self.size_radio)
288 rows.add([_('atoms'), self.size_natoms,
289 _('ų'), self.size_diameter])
290 rows.add(
291 _('Rounding: If exact size is not possible, choose the size:'))
292 rows.add(self.round_radio)
293 rows.add([self.smaller_button, self.larger_button])
294 self.update_gui_size()
295 else:
296 self.smaller_button = None
297 self.larger_button = None
299 def update_structure(self, s=None):
300 'Called when the user changes the structure.'
301 s = self.structure_cb.value
302 if s != self.old_structure:
303 old4 = self.needs_4index[self.old_structure]
304 if self.needs_4index[s] != old4:
305 # The table of directions is invalid.
306 self.update_new_direction_and_size_stuff()
307 self.default_direction_table()
308 self.update_direction_table()
309 self.old_structure = s
310 self.c.active = self.needs_2lat[s]
311 self.update()
313 def update_gui_method(self, *args):
314 'Switch between layer specification and Wulff construction.'
315 self.update_direction_table()
316 self.update_new_direction_and_size_stuff()
317 if self.method_cb.value == 'wulff':
318 self.layerlabel.text = _(
319 'Surface energies (as energy/area, NOT per atom):')
320 else:
321 self.layerlabel.text = _('Number of layers:')
323 self.update()
325 def wulff_smaller(self, widget=None):
326 'Make a smaller Wulff construction.'
327 n = len(self.atoms)
328 self.size_radio.value = 'natoms'
329 self.size_natoms.value = n - 1
330 self.round_radio.value = 'below'
331 self.apply()
333 def wulff_larger(self, widget=None):
334 'Make a larger Wulff construction.'
335 n = len(self.atoms)
336 self.size_radio.value = 'natoms'
337 self.size_natoms.value = n + 1
338 self.round_radio.value = 'above'
339 self.apply()
341 def row_add(self, widget=None):
342 'Add a row to the list of directions.'
343 if self.needs_4index[self.structure_cb.value]:
344 n = 4
345 else:
346 n = 3
347 idx = tuple(a.value for a in self.new_direction[1:1 + 2 * n:2])
348 if not any(idx):
349 ui.error(_('At least one index must be non-zero'), '')
350 return
351 if n == 4 and sum(idx) != 0:
352 ui.error(_('Invalid hexagonal indices',
353 'The sum of the first three numbers must be zero'))
354 return
355 new = [idx, 5, 1.0]
356 if self.method_cb.value == 'wulff':
357 new[1] = self.new_direction[-2].value
358 else:
359 new[2] = self.new_direction[-2].value
360 self.direction_table.append(new)
361 self.add_direction(*new)
362 self.update()
364 def row_delete(self, row):
365 del self.direction_table[row]
366 self.update_direction_table()
368 def row_swap_next(self, row):
369 dt = self.direction_table
370 dt[row], dt[row + 1] = dt[row + 1], dt[row]
371 self.update_direction_table()
373 def update_gui_size(self, widget=None):
374 'Update gui when the cluster size specification changes.'
375 self.size_natoms.active = self.size_radio.value == 'natoms'
376 self.size_diameter.active = self.size_radio.value == 'diameter'
378 def update_size_natoms(self, widget=None):
379 at_vol = self.get_atomic_volume()
380 dia = 2.0 * (3 * self.size_natoms.value * at_vol /
381 (4 * np.pi))**(1 / 3)
382 self.size_diameter.value = dia
383 self.update()
385 def update_size_diameter(self, widget=None, update=True):
386 if self.size_diameter.active:
387 at_vol = self.get_atomic_volume()
388 n = round(np.pi / 6 * self.size_diameter.value**3 / at_vol)
389 self.size_natoms.value = int(n)
390 if update:
391 self.update()
393 def update(self, *args):
394 if self.no_update:
395 return
396 self.element.Z # Check
397 if self.auto.value:
398 self.makeatoms()
399 if self.atoms is not None:
400 self.gui.new_atoms(self.atoms)
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 return True
531 else:
532 ui.error(_('No valid atoms.'),
533 _('You have not (yet) specified a consistent set of '
534 'parameters.'))
535 return False
537 def ok(self):
538 if self.apply():
539 self.win.close()