phononkit 0.1.0__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.
phononkit/__init__.py ADDED
@@ -0,0 +1,169 @@
1
+ """phononkit - A clean, modular Python library for phonon analysis.
2
+
3
+ This package provides tools for:
4
+ - Phonon mode representation and analysis
5
+ - Displacement generation and projections
6
+ - Supercell operations with commensurate q-points
7
+ - Structure analysis and symmetry
8
+ - Data I/O (MCIF export, phonopy integration)
9
+
10
+ The package is organized into domain-driven modules:
11
+ - core: Type definitions and constants
12
+ - qpoints: Q-point mathematics and grids
13
+ - modes: Phonon mode representation
14
+ - displacements: Displacement generation
15
+ - projections: Mass-weighted projections
16
+ - supercells: Supercell operations
17
+ - analysis: Structure analysis and symmetry
18
+ - io: Data import/export
19
+
20
+ All modules follow single responsibility principle (< 500 lines per file)
21
+ and strict layering with no circular dependencies.
22
+ """
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ from typing import Optional, List
27
+
28
+ from phononkit.modes import PhononMode, PhononModeCollection
29
+ from phononkit.displacements import (
30
+ generate_displacement,
31
+ generate_displaced_structure,
32
+ generate_mode_displacement,
33
+ generate_thermal_displacement,
34
+ generate_thermal_structure,
35
+ )
36
+ from phononkit.io import (
37
+ load_from_phonopy_yaml,
38
+ create_mode_from_phonopy_data,
39
+ export_to_mcif,
40
+ save_mcif_file,
41
+ )
42
+ from phononkit.supercells import (
43
+ build_supercell,
44
+ build_supercell_for_qpoint,
45
+ create_supercell_mode,
46
+ )
47
+ from phononkit.qpoints import (
48
+ is_commensurate_qpoint,
49
+ find_commensurate_qpoints,
50
+ validate_supercell_commensurability,
51
+ )
52
+
53
+
54
+ def generate_structure_for_mode(
55
+ yaml_path: str,
56
+ qpoint: List,
57
+ mode_index: int,
58
+ amplitude: float = 1.0,
59
+ supercell: Optional[List] = None,
60
+ ):
61
+ """Generate displaced structure for a specific phonon mode.
62
+
63
+ Simple one-function API to load phonon data and generate a displaced
64
+ structure for a specific mode at a given q-point, optionally in a supercell.
65
+
66
+ Parameters:
67
+ yaml_path: Path to phonopy YAML file
68
+ qpoint: Q-point coordinates [qx, qy, qz] (e.g., [0, 0, 0] for Gamma)
69
+ mode_index: Index of the mode at that q-point (0-based)
70
+ amplitude: Displacement amplitude in Angstroms (default: 1.0)
71
+ supercell: Supercell specification (optional):
72
+ - 3-vector [n1, n2, n3] for diagonal matrix (e.g., [2, 2, 2])
73
+ - Full 3x3 matrix [[n1, 0, 0], [0, n2, 0], [0, 0, n3]]
74
+ If None, uses primitive cell
75
+
76
+ Returns:
77
+ ASE Atoms object with displacements applied
78
+
79
+ Examples:
80
+ >>> # Primitive cell - 3rd mode at Gamma with 0.5 Å amplitude
81
+ >>> structure = generate_structure_for_mode(
82
+ ... 'phonopy_params.yaml',
83
+ ... qpoint=[0, 0, 0],
84
+ ... mode_index=2,
85
+ ... amplitude=0.5
86
+ ... )
87
+
88
+ >>> # 2x2x2 Supercell using diagonal notation
89
+ >>> structure = generate_structure_for_mode(
90
+ ... 'phonopy_params.yaml',
91
+ ... qpoint=[0, 0, 0],
92
+ ... mode_index=2,
93
+ ... amplitude=0.5,
94
+ ... supercell=[2, 2, 2]
95
+ ... )
96
+
97
+ >>> # Save to file
98
+ >>> from ase.io import write
99
+ >>> write('displaced.vasp', structure)
100
+ """
101
+ import numpy as np
102
+
103
+ # Load phonon data
104
+ primitive_structure, modes = load_from_phonopy_yaml(yaml_path)
105
+
106
+ # Filter modes by q-point
107
+ qpoint_array = np.array(qpoint)
108
+ qpoint_modes = modes.filter_by_qpoint(qpoint_array)
109
+
110
+ # Check if mode_index is valid
111
+ if mode_index >= qpoint_modes.n_modes:
112
+ raise ValueError(
113
+ f"Mode index {mode_index} out of range. "
114
+ f"Only {qpoint_modes.n_modes} modes at q-point {qpoint}."
115
+ )
116
+
117
+ # Select the mode
118
+ selected_mode = qpoint_modes[mode_index]
119
+
120
+ # Handle supercell
121
+ if supercell is not None:
122
+ supercell_array = np.array(supercell)
123
+
124
+ # Convert 3-vector to 3x3 diagonal matrix
125
+ if supercell_array.ndim == 1 and len(supercell_array) == 3:
126
+ supercell_matrix = np.diag(supercell_array)
127
+ elif supercell_array.shape == (3, 3):
128
+ supercell_matrix = supercell_array
129
+ else:
130
+ raise ValueError(
131
+ f"Invalid supercell format. "
132
+ f"Expected 3-vector [n1,n2,n3] or 3x3 matrix, got {supercell}"
133
+ )
134
+
135
+ # Create supercell mode (handles tiling and phase factors)
136
+ from phononkit.supercells import create_supercell_mode
137
+
138
+ supercell_mode = create_supercell_mode(selected_mode, supercell_matrix)
139
+
140
+ # Generate displacement for supercell mode
141
+ displaced_structure = generate_mode_displacement(supercell_mode, amplitude)
142
+ else:
143
+ # Use primitive mode directly
144
+ displaced_structure = generate_mode_displacement(selected_mode, amplitude)
145
+
146
+ return displaced_structure
147
+
148
+
149
+ __all__ = [
150
+ "__version__",
151
+ "PhononMode",
152
+ "PhononModeCollection",
153
+ "generate_structure_for_mode", # Simple API
154
+ "generate_displacement",
155
+ "generate_displaced_structure",
156
+ "generate_mode_displacement",
157
+ "generate_thermal_displacement",
158
+ "generate_thermal_structure",
159
+ "load_from_phonopy_yaml",
160
+ "create_mode_from_phonopy_data",
161
+ "export_to_mcif",
162
+ "save_mcif_file",
163
+ "build_supercell",
164
+ "build_supercell_for_qpoint",
165
+ "create_supercell_mode",
166
+ "is_commensurate_qpoint",
167
+ "find_commensurate_qpoints",
168
+ "validate_supercell_commensurability",
169
+ ]
@@ -0,0 +1,37 @@
1
+ """Structure analysis, species substitution, and symmetry.
2
+
3
+ This module provides:
4
+ - Atomic correspondence between structures
5
+ - Species substitution handling
6
+ - Symmetry analysis and irreducible representations
7
+ """
8
+
9
+ from phononkit.analysis.structure import (
10
+ find_atomic_correspondence,
11
+ apply_atomic_mapping,
12
+ )
13
+ from phononkit.analysis.substitution import (
14
+ create_substitution_map,
15
+ validate_substitution,
16
+ )
17
+ from phononkit.analysis.symmetry import (
18
+ find_degenerate_modes,
19
+ classify_mode_symmetry,
20
+ generate_mode_summary,
21
+ )
22
+ from phononkit.analysis.irreps import (
23
+ analyze_irreps,
24
+ IrRepsEigen,
25
+ )
26
+
27
+ __all__ = [
28
+ "find_atomic_correspondence",
29
+ "apply_atomic_mapping",
30
+ "create_substitution_map",
31
+ "validate_substitution",
32
+ "find_degenerate_modes",
33
+ "classify_mode_symmetry",
34
+ "generate_mode_summary",
35
+ "analyze_irreps",
36
+ "IrRepsEigen",
37
+ ]
@@ -0,0 +1,399 @@
1
+ """Irreducible representations (irreps) analysis for phonon modes.
2
+
3
+ This module provides functionality for identifying symmetry labels (irreps)
4
+ for phonon modes, adapted from the better implementation in anaddb_irreps.
5
+ """
6
+
7
+ import contextlib
8
+ import numpy as np
9
+ from io import StringIO
10
+ from typing import List, Optional, Tuple, Dict, Any
11
+
12
+ from ase import Atoms
13
+ from phonopy.phonon.irreps import IrReps, IrRepLabels
14
+ from phonopy.structure.symmetry import Symmetry
15
+ from phonopy.phonon.character_table import character_table
16
+ from phonopy.phonon.degeneracy import degenerate_sets as get_degenerate_sets
17
+ from phonopy.structure.cells import is_primitive_cell
18
+ from phonopy import load as phonopy_load
19
+
20
+
21
+ class IrRepsEigen(IrReps, IrRepLabels):
22
+ """Irreducible representations analysis from provided eigenvalues/vectors.
23
+
24
+ This class extends phonopy's IrReps and IrRepLabels to work with
25
+ already calculated frequencies and eigenvectors.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ primitive_atoms,
31
+ qpoint,
32
+ freqs,
33
+ eigvecs,
34
+ is_little_cogroup: bool = False,
35
+ symprec: float = 1e-5,
36
+ degeneracy_tolerance: float = 1e-5,
37
+ log_level: int = 0,
38
+ ) -> None:
39
+ """Initialize IrRepsEigen.
40
+
41
+ Parameters:
42
+ primitive_atoms: Phonopy Atoms object (primitive cell)
43
+ qpoint: Q-point in reduced coordinates
44
+ freqs: Frequencies at this q-point (THz)
45
+ eigvecs: Eigenvectors at this q-point
46
+ is_little_cogroup: Whether to use little co-group
47
+ symprec: Symmetry precision
48
+ degeneracy_tolerance: Tolerance for degeneracy detection
49
+ log_level: Verbosity level
50
+ """
51
+ self._is_little_cogroup = is_little_cogroup
52
+ self._log_level = log_level
53
+
54
+ self._qpoint = np.array(qpoint)
55
+ self._degeneracy_tolerance = degeneracy_tolerance
56
+ self._symprec = symprec
57
+ self._primitive = primitive_atoms
58
+ self._freqs, self._eig_vecs = freqs, eigvecs
59
+ self._character_table = None
60
+ self._verbose = False
61
+
62
+ def run(self) -> bool:
63
+ """Run the irreps analysis."""
64
+ self._symmetry_dataset = Symmetry(
65
+ self._primitive, symprec=self._symprec
66
+ ).dataset
67
+
68
+ if not is_primitive_cell(self._symmetry_dataset.rotations):
69
+ raise RuntimeError(
70
+ "Non-primitive cell is used. Your unit cell may be transformed to "
71
+ "a primitive cell by PRIMITIVE_AXIS tag."
72
+ )
73
+
74
+ (self._rotations_at_q, self._translations_at_q) = self._get_rotations_at_q()
75
+
76
+ self._g = len(self._rotations_at_q)
77
+
78
+ self._pointgroup_symbol = self._symmetry_dataset.pointgroup
79
+
80
+ (
81
+ self._transformation_matrix,
82
+ self._conventional_rotations,
83
+ ) = self._get_conventional_rotations()
84
+
85
+ self._ground_matrices = self._get_ground_matrix()
86
+ self._degenerate_sets = self._get_degenerate_sets()
87
+ self._irreps = self._get_irreps()
88
+ self._characters, self._irrep_dims = self._get_characters()
89
+
90
+ self._ir_labels = None
91
+
92
+ if (
93
+ self._pointgroup_symbol in character_table.keys()
94
+ and character_table[self._pointgroup_symbol] is not None
95
+ ):
96
+ (
97
+ self._rotation_symbols,
98
+ character_table_of_ptg,
99
+ ) = self._get_rotation_symbols(self._pointgroup_symbol)
100
+ self._character_table = character_table_of_ptg
101
+
102
+ if (abs(self._qpoint) < self._symprec).all() and self._rotation_symbols:
103
+ self._ir_labels = self._get_irrep_labels(character_table_of_ptg)
104
+ self._RamanIR_labels = self._get_infrared_raman()
105
+ else:
106
+ self._rotation_symbols = None
107
+
108
+ return True
109
+
110
+ def _get_degenerate_sets(self):
111
+ deg_sets = get_degenerate_sets(self._freqs, cutoff=self._degeneracy_tolerance)
112
+ return deg_sets
113
+
114
+ def _get_infrared_raman(self):
115
+ """Compute IR- and Raman-active irreps using symmetry operations."""
116
+ # make symops in cartesian space
117
+ rprim = self._primitive.cell
118
+ gprim = np.linalg.inv(rprim).T
119
+
120
+ # make cartesian symop matrices for each operation in each class
121
+ nclass = len(self._character_table["rotation_list"])
122
+ self._cartesian_rotations_at_q = np.zeros([nclass, 96, 3, 3])
123
+ degenclass = np.zeros(nclass)
124
+ characters_xyz = np.zeros(nclass)
125
+ chardegen_xyz = np.zeros(nclass)
126
+ characters_x2 = np.zeros(nclass)
127
+ chardegen_x2 = np.zeros(nclass)
128
+ iclass = 0
129
+ for opclass in self._character_table["mapping_table"].keys():
130
+ degenclass[iclass] = len(self._character_table["mapping_table"][opclass])
131
+ iop = 0
132
+ for symop in np.array(self._character_table["mapping_table"][opclass][:]):
133
+ self._cartesian_rotations_at_q[iclass][iop] = np.dot(
134
+ rprim, np.dot(symop, gprim.T)
135
+ )
136
+ iop += 1
137
+
138
+ m = self._cartesian_rotations_at_q[iclass][0]
139
+ # get representation characters for x,y,z functions
140
+ characters_xyz[iclass] = np.matrix.trace(m)
141
+
142
+ # get representation characters for quadratic functions
143
+ bigmat = np.zeros([6, 6])
144
+ ibig = 0
145
+ for ixyz in range(3):
146
+ for ixyz_prime in range(ixyz + 1):
147
+ outprod = np.ndarray.flatten(np.outer(m[:, ixyz], m[:, ixyz_prime]))
148
+ bigmat[ibig, :] = [
149
+ outprod[0],
150
+ outprod[1] + outprod[3],
151
+ outprod[4],
152
+ outprod[2] + outprod[6],
153
+ outprod[5] + outprod[7],
154
+ outprod[8],
155
+ ]
156
+ ibig += 1
157
+
158
+ characters_x2[iclass] = np.matrix.trace(bigmat)
159
+ chardegen_xyz[iclass] = characters_xyz[iclass] * degenclass[iclass]
160
+ chardegen_x2[iclass] = characters_x2[iclass] * degenclass[iclass]
161
+ iclass += 1
162
+
163
+ xyzlabels = ["x", "y", "z"]
164
+ x2labels = ["x^2", "xy", "y^2", "xz", "yz", "z^2"]
165
+ IR_dict = {"x": None, "y": None, "z": None}
166
+ Raman_dict = {"x^2": [], "xy": [], "y^2": [], "xz": [], "yz": [], "z^2": []}
167
+
168
+ # loop over irreducible representations
169
+ for irreplabel in self._character_table["character_table"].keys():
170
+ irr_char = self._character_table["character_table"][irreplabel]
171
+ len_irr = irr_char[0]
172
+ n_ir = int(np.round(np.dot(irr_char, chardegen_xyz) / self._g))
173
+ n_ram = int(np.round(np.dot(irr_char, chardegen_x2) / self._g))
174
+
175
+ for ixyz in range(3):
176
+ xyzvec = np.zeros(3)
177
+ for iclass in range(len(self._character_table["mapping_table"].keys())):
178
+ opclass = list(self._character_table["mapping_table"].keys())[
179
+ iclass
180
+ ]
181
+ degenclass_val = len(
182
+ self._character_table["mapping_table"][opclass][:]
183
+ )
184
+ for iop in range(degenclass_val):
185
+ xyzvec += (
186
+ irr_char[iclass]
187
+ * self._cartesian_rotations_at_q[iclass][iop][ixyz, :]
188
+ )
189
+ xyzvec *= len_irr / self._g
190
+ if np.linalg.norm(xyzvec) > 1.0e-6:
191
+ IR_dict[xyzlabels[ixyz]] = irreplabel
192
+
193
+ ibig = 0
194
+ for ixyz in range(3):
195
+ for ixyz_prime in range(ixyz + 1):
196
+ x2vec = np.zeros(6)
197
+ for iclass in range(
198
+ len(self._character_table["mapping_table"].keys())
199
+ ):
200
+ opclass = list(self._character_table["mapping_table"].keys())[
201
+ iclass
202
+ ]
203
+ degenclass_val = len(
204
+ self._character_table["mapping_table"][opclass][:]
205
+ )
206
+ for iop in range(degenclass_val):
207
+ m = self._cartesian_rotations_at_q[iclass][iop]
208
+ outprod = np.ndarray.flatten(
209
+ np.outer(m[:, ixyz], m[:, ixyz_prime])
210
+ )
211
+ bigvec = np.array(
212
+ [
213
+ outprod[0],
214
+ outprod[1] + outprod[3],
215
+ outprod[4],
216
+ outprod[2] + outprod[6],
217
+ outprod[5] + outprod[7],
218
+ outprod[8],
219
+ ]
220
+ )
221
+ x2vec += irr_char[iclass] * bigvec
222
+
223
+ x2vec *= len_irr / self._g
224
+ if np.linalg.norm(x2vec) > 1.0e-6:
225
+ Raman_dict[x2labels[ibig]].append(irreplabel)
226
+ ibig += 1
227
+
228
+ return IR_dict, Raman_dict
229
+
230
+ def get_summary_table(self) -> List[Dict[str, Any]]:
231
+ """Return core mode information as list of dicts."""
232
+ if not hasattr(self, "_freqs"):
233
+ raise RuntimeError("run() must be called before get_summary_table().")
234
+
235
+ q = tuple(float(x) for x in self._qpoint)
236
+ freqs_thz = self._freqs
237
+ conv = 33.35641 # 1 THz -> cm^-1
238
+ n_modes = len(freqs_thz)
239
+
240
+ irreps = getattr(self, "_irreps", None)
241
+
242
+ ir_active_map: Dict[str, bool] = {}
243
+ raman_active_map: Dict[str, bool] = {}
244
+
245
+ raman_ir = getattr(self, "_RamanIR_labels", None)
246
+ if raman_ir is not None:
247
+ ir_dict, raman_dict = raman_ir
248
+ for lbl in ir_dict.values():
249
+ if lbl:
250
+ ir_active_map[lbl] = True
251
+ for labels in raman_dict.values():
252
+ for lbl in labels:
253
+ if lbl:
254
+ raman_active_map[lbl] = True
255
+
256
+ raw_labels = [None] * n_modes
257
+ ir_labels_seq = getattr(self, "_ir_labels", None)
258
+ deg_sets = getattr(self, "_degenerate_sets", None)
259
+
260
+ mode_to_degset = {}
261
+ if deg_sets is not None:
262
+ for set_idx, deg_set in enumerate(deg_sets):
263
+ for mode_idx in deg_set:
264
+ mode_to_degset[mode_idx] = set_idx
265
+
266
+ for band_index in range(n_modes):
267
+ label = None
268
+ if irreps is not None and band_index < len(irreps):
269
+ ir = irreps[band_index]
270
+ if hasattr(ir, "label"):
271
+ label = ir.label
272
+ elif isinstance(ir, dict) and "label" in ir:
273
+ label = ir["label"]
274
+
275
+ if label is None and ir_labels_seq is not None:
276
+ set_idx = mode_to_degset.get(band_index)
277
+ if set_idx is not None and set_idx < len(ir_labels_seq):
278
+ cand = ir_labels_seq[set_idx]
279
+ if isinstance(cand, (tuple, list)) and cand:
280
+ label = cand[0]
281
+ elif isinstance(cand, str):
282
+ label = cand
283
+ raw_labels[band_index] = label
284
+
285
+ if deg_sets is not None:
286
+ for deg_set in deg_sets:
287
+ labels_in_set = {raw_labels[i] for i in deg_set if raw_labels[i]}
288
+ if len(labels_in_set) == 1:
289
+ lbl = labels_in_set.pop()
290
+ for i in deg_set:
291
+ raw_labels[i] = lbl
292
+
293
+ summary = []
294
+ for band_index, f_thz in enumerate(freqs_thz):
295
+ freq_thz = float(f_thz)
296
+ freq_cm1 = freq_thz * conv
297
+ label = raw_labels[band_index]
298
+ is_ir_active = bool(label and ir_active_map.get(label, False))
299
+ is_raman_active = bool(label and raman_active_map.get(label, False))
300
+
301
+ summary.append(
302
+ {
303
+ "qpoint": q,
304
+ "band_index": band_index,
305
+ "frequency_thz": freq_thz,
306
+ "frequency_cm1": freq_cm1,
307
+ "label": label,
308
+ "is_ir_active": is_ir_active,
309
+ "is_raman_active": is_raman_active,
310
+ }
311
+ )
312
+ return summary
313
+
314
+ def format_summary_table(self, include_header: bool = True) -> str:
315
+ """Format the summary table as a human-readable string."""
316
+ summary = self.get_summary_table()
317
+ lines = []
318
+
319
+ if summary:
320
+ qx, qy, qz = summary[0]["qpoint"]
321
+ lines.append(f"q-point: [{qx:.4f}, {qy:.4f}, {qz:.4f}]")
322
+
323
+ point_group = getattr(self, "_pointgroup_symbol", None)
324
+ if point_group:
325
+ lines.append(f"Point group: {point_group}")
326
+
327
+ if lines:
328
+ lines.append("")
329
+
330
+ if include_header:
331
+ header = "# qx qy qz band freq(THz) freq(cm-1) label IR Raman"
332
+ lines.append(header)
333
+
334
+ for row in summary:
335
+ qx, qy, qz = row["qpoint"]
336
+ bi = row["band_index"]
337
+ f_thz = row["frequency_thz"]
338
+ f_cm1 = row["frequency_cm1"]
339
+ label = row["label"] or "-"
340
+ ir_flag = "Y" if row["is_ir_active"] else "."
341
+ raman_flag = "Y" if row["is_raman_active"] else "."
342
+
343
+ line = (
344
+ f"{qx:7.4f} {qy:7.4f} {qz:7.4f} {bi:4d} "
345
+ f"{f_thz:10.4f} {f_cm1:11.2f} {label:10s} {ir_flag:^3s} {raman_flag:^5s}"
346
+ )
347
+ lines.append(line)
348
+
349
+ return "\n".join(lines)
350
+
351
+ def get_verbose_output(self) -> str:
352
+ """Get verbose phonopy-style irreps output."""
353
+ buf = StringIO()
354
+ with contextlib.redirect_stdout(buf):
355
+ show_method = getattr(self, "_show", None) or getattr(self, "show", None)
356
+ if show_method is None:
357
+ print(repr(self))
358
+ else:
359
+ try:
360
+ show_method(True)
361
+ except TypeError:
362
+ show_method()
363
+ return buf.getvalue()
364
+
365
+
366
+ def analyze_irreps(
367
+ primitive_atoms,
368
+ qpoint,
369
+ freqs,
370
+ eigvecs,
371
+ symprec: float = 1e-5,
372
+ degeneracy_tolerance: float = 1e-5,
373
+ log_level: int = 0,
374
+ ) -> IrRepsEigen:
375
+ """Analyze irreps for a single q-point.
376
+
377
+ Parameters:
378
+ primitive_atoms: Phonopy Atoms object
379
+ qpoint: Q-point
380
+ freqs: Frequencies
381
+ eigvecs: Eigenvectors
382
+ symprec: Symmetry precision
383
+ degeneracy_tolerance: Degeneracy tolerance
384
+ log_level: Log level
385
+
386
+ Returns:
387
+ IrRepsEigen object
388
+ """
389
+ irr = IrRepsEigen(
390
+ primitive_atoms,
391
+ qpoint,
392
+ freqs,
393
+ eigvecs,
394
+ symprec=symprec,
395
+ degeneracy_tolerance=degeneracy_tolerance,
396
+ log_level=log_level,
397
+ )
398
+ irr.run()
399
+ return irr