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

1# fmt: off 

2 

3"""nanoparticle.py - Window for setting up crystalline nanoparticles. 

4""" 

5from copy import copy 

6 

7import numpy as np 

8 

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 

16 

17# Delayed imports: 

18# ase.cluster.data 

19 

20 

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""") 

27 

28helptext = _(""" 

29The nanoparticle module sets up a nano-particle or a cluster with a given 

30crystal structure. 

31 

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. 

34 

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. 

38 

39How to specify the directions: 

40------------------------------ 

41 

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. 

48 

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""") 

53 

54py_template_layers = """ 

55import ase 

56%(import)s 

57 

58surfaces = %(surfaces)s 

59layers = %(layers)s 

60lc = %(latconst)s 

61atoms = %(factory)s('%(element)s', surfaces, layers, latticeconstant=lc) 

62 

63# OPTIONAL: Cast to ase.Atoms object, discarding extra information: 

64# atoms = ase.Atoms(atoms) 

65""" 

66 

67py_template_wulff = """ 

68import ase 

69from ase.cluster import wulff_construction 

70 

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) 

78 

79# OPTIONAL: Cast to ase.Atoms object, discarding extra information: 

80# atoms = ase.Atoms(atoms) 

81""" 

82 

83 

84class SetupNanoparticle: 

85 "Window for setting up a nanoparticle." 

86 

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')} 

93 

94 needs_4index = { # 3 or 4 index dimension 

95 'fcc': False, 'bcc': False, 'sc': False, 

96 'hcp': True, 'graphite': True} 

97 

98 needs_2lat = { # 1 or 2 lattice parameters 

99 'fcc': False, 'bcc': False, 'sc': False, 

100 'hcp': True, 'graphite': True} 

101 

102 structure_factories = { 

103 'fcc': FaceCenteredCubic, 

104 'bcc': BodyCenteredCubic, 

105 'sc': SimpleCubic, 

106 'hcp': HexagonalClosedPacked, 

107 'graphite': Graphite} 

108 

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'} 

116 

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)]} 

131 

132 def __init__(self, gui): 

133 self.atoms = None 

134 self.no_update = True 

135 self.old_structure = 'fcc' 

136 

137 win = self.win = ui.Window(_('Nanoparticle')) 

138 win.add(ui.Text(introtext)) 

139 

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) 

147 

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]) 

157 

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]) 

161 

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]) 

168 

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() 

174 

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() 

179 

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) 

187 

188 # Finalize setup 

189 self.update_structure() 

190 self.update_gui_method() 

191 self.no_update = False 

192 

193 self.auto = ui.CheckButton(_('Automatic Apply')) 

194 win.add(self.auto) 

195 

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)]) 

200 

201 self.gui = gui 

202 self.smaller_button = None 

203 self.largeer_button = None 

204 

205 self.element.grab_focus() 

206 

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)) 

213 

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() 

219 

220 def add_direction(self, direction, layers, energy): 

221 i = len(self.direction_table_rows) 

222 

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) 

227 

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) 

231 

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 

237 

238 if i > 0: 

239 down, delete = self.direction_table_rows[-2][3:] 

240 down.active = True 

241 delete.active = True 

242 

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 

248 

249 rows = self.new_direction_and_size_rows 

250 

251 rows.clear() 

252 

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('):') 

259 

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)) 

264 

265 row.append(ui.Button(_('Add'), self.row_add)) 

266 

267 rows.add(row) 

268 

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 

297 

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() 

311 

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:') 

321 

322 self.update() 

323 

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() 

331 

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() 

339 

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() 

362 

363 def row_delete(self, row): 

364 del self.direction_table[row] 

365 self.update_direction_table() 

366 

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() 

371 

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' 

376 

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() 

383 

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() 

391 

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() 

404 

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'] 

415 

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 

421 

422 self.structure_cb.value = self.structure_names[structure] 

423 

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 

432 

433 self.update_structure() 

434 

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() 

485 

486 return python 

487 

488 def clearatoms(self): 

489 self.atoms = None 

490 

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 

505 

506 def makeinfo(self): 

507 """Fill in information field about the atoms. 

508 

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} Å' 

520 

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 

525 

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 

537 

538 def ok(self): 

539 if self.apply(): 

540 self.win.close()