minelastic 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.
minelastic/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Utkarsh Singh
3
+ """minelastic: second-order elastic constants from minimal uncoupled stress-strain calculations.
4
+
5
+ The elastic constants C_ij = dsigma_i/de_j are obtained from finite differences of the stress of a few
6
+ strained cells, with the minimal uncoupled strain sets of S. I. Simak and U. Singh, arXiv:2610.11733 (2026),
7
+ for any crystal symmetry. Inputs are generated and outputs read for VASP, Quantum ESPRESSO, ABINIT, FHI-aims
8
+ and any ASE format (generate, collect); ASE calculators are used directly (calculate).
9
+ """
10
+ __version__ = "0.1.0"
11
+ __url__ = "https://github.com/utksi/minelastic"
12
+
13
+ from .core import ElasticResult, ElasticSetup # noqa: E402
14
+ from .calculator import calculate # noqa: E402
15
+ from .workflow import collect, generate # noqa: E402
16
+
17
+ __all__ = ["ElasticSetup", "ElasticResult", "calculate", "generate", "collect", "__version__"]
@@ -0,0 +1,163 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Utkarsh Singh
3
+ """Minimal uncoupled strain sets (Simak and Singh): the paper's construction and an exhaustive search.
4
+
5
+ A strain pattern is a vector u with support S (the Voigt components j with u_j != 0). Per unit step it
6
+ produces the stresses s_i = sum_j C_ij u_j = sum_p c_p sum_{j in S} (B_p)_ij u_j (C = sum_p c_p B_p, see
7
+ tensor_form). For generic amplitudes, s_i depends on constant p exactly when (B_p)_ij != 0 for some j in S.
8
+ s_i is uncoupled if it depends on one constant only; that constant then follows from s_i alone. A set of
9
+ patterns is complete if every constant is uncoupled in at least one of its stress components.
10
+
11
+ paper_greedy is the iterative construction of the paper (Sec. III); exact_minimum searches all
12
+ combinations of the 63 supports for the smallest complete set.
13
+ """
14
+ from itertools import combinations, product
15
+
16
+ import numpy as np
17
+
18
+ _TOL = 1e-8
19
+
20
+
21
+ def single_constant_rows(form, support):
22
+ """[(i, p)]: the stress components i that, for generic amplitudes on the support S, depend on exactly
23
+ one constant p, i.e. (B_q)_ij != 0 for some j in S only for q = p."""
24
+ out = []
25
+ for i in range(6):
26
+ involved = [p for p in range(form.n) if any(abs(form.basis[p, i, j]) > _TOL for j in support)]
27
+ if len(involved) == 1:
28
+ out.append((i, involved[0]))
29
+ return out
30
+
31
+
32
+ def extractable(form, support):
33
+ """The set of constants p that are uncoupled in some stress component of a pattern on the support."""
34
+ return {p for _, p in single_constant_rows(form, support)}
35
+
36
+
37
+ def reduced_auxiliary(form):
38
+ """Reduced auxiliary matrix of the paper (Sec. III B).
39
+
40
+ A_ij = 1 where C_ij is not identically zero, else 0. Row i is set to zero when the diagonal entry C_ii is
41
+ not +-1 times a single constant (a dependent diagonal such as C66 = (c11 - c12)/2), or when its constant
42
+ already appears on an earlier diagonal (an equivalent row, such as C22 = c11).
43
+ """
44
+ A = form.nonzero.astype(int)
45
+ seen = set()
46
+ for i in range(6):
47
+ d = form.entry(i, i)
48
+ nz = np.flatnonzero(np.abs(d) > _TOL)
49
+ if len(nz) != 1 or abs(abs(d[nz[0]]) - 1) > _TOL or nz[0] in seen:
50
+ A[i, :] = 0 # dependent diagonal (e.g. c66 = (c11-c12)/2) or equivalent to an earlier row
51
+ else:
52
+ seen.add(nz[0])
53
+ return A
54
+
55
+
56
+ def paper_greedy(form):
57
+ """Strain supports from the iterative construction of the paper (Sec. III C).
58
+
59
+ Start from the reduced auxiliary matrix A and the candidate support b (b_j = 1 for the nonzero rows of A).
60
+ While some component of v = A b exceeds 1 (row i would respond to more than one strained component),
61
+ remove the highest such index j from b and zero row j. The remaining b is a pattern; its rows are removed
62
+ from A and from the candidates, and the procedure repeats until A is zero. Stops early, without a
63
+ complete set, when no component survives (possible in a non-standard frame); the caller then uses
64
+ exact_minimum.
65
+ """
66
+ A = reduced_auxiliary(form)
67
+ b_in = (A.sum(axis=1) > 0).astype(int)
68
+ supports = []
69
+ while A.any():
70
+ At, b = A.copy(), b_in.copy()
71
+ while True:
72
+ v = At @ b
73
+ bad = np.flatnonzero(v > 1)
74
+ if bad.size == 0:
75
+ break
76
+ j = bad.max()
77
+ b[j] = 0
78
+ At[j, :] = 0
79
+ if not b.any():
80
+ break # no progress possible (non-standard frame); caller falls back
81
+ supports.append(tuple(int(j) for j in np.flatnonzero(b)))
82
+ A[b == 1, :] = 0
83
+ b_in[b == 1] = 0
84
+ return supports
85
+
86
+
87
+ def covers(form, supports):
88
+ """True if the union of extractable(S) over the supports contains every constant."""
89
+ got = set()
90
+ for s in supports:
91
+ got |= extractable(form, s)
92
+ return got == set(range(form.n))
93
+
94
+
95
+ def exact_minimum(form, max_patterns=6):
96
+ """A complete set of supports of minimum size, by exhaustive search.
97
+
98
+ Each of the 63 nonempty supports S is assigned X(S) = extractable(S). A support is dropped if another
99
+ support S2 has X(S) a subset of X(S2) and no more components (one of equal ones is kept). For
100
+ k = 1, 2, ..., max_patterns all
101
+ k-combinations are tested for complete coverage; among those of the smallest k, the one with the
102
+ fewest strained components in total (least symmetry lowering, then lexicographic) is returned.
103
+ """
104
+ target = set(range(form.n))
105
+ X = {}
106
+ for mask in range(1, 64):
107
+ s = tuple(j for j in range(6) if mask >> j & 1)
108
+ e = frozenset(extractable(form, s))
109
+ if e:
110
+ X[s] = e
111
+ # WHY: a support is never needed if another one extracts at least the same constants with no more strained
112
+ # components (dropping only such supports keeps both the count and the tie-break exact)
113
+ order = sorted(X, key=lambda s: (len(s), s))
114
+ cands = [s for n, s in enumerate(order)
115
+ if not any(X[s] <= X[s2] and len(s2) <= len(s) and (X[s] < X[s2] or len(s2) < len(s) or m < n)
116
+ for m, s2 in enumerate(order) if s2 != s)]
117
+ for k in range(1, max_patterns + 1):
118
+ best = None
119
+ for combo in combinations(cands, k):
120
+ if set().union(*(X[s] for s in combo)) == target:
121
+ key = (sum(len(s) for s in combo), combo)
122
+ if best is None or key < best[0]:
123
+ best = (key, combo)
124
+ if best is not None:
125
+ return list(best[1])
126
+ raise RuntimeError("no complete set of uncoupled strain patterns exists")
127
+
128
+
129
+ def choose_amplitudes(form, support):
130
+ """Pattern u on the support, first component 1, with every single-constant coefficient (B_p)_i . u nonzero.
131
+
132
+ For generic amplitudes each single-constant row (i, p) has a nonzero coefficient, but special amplitudes
133
+ can cancel it (e.g. c16 (u_1 - u_2) for u_1 = u_2). Amplitudes +-1 are tried first (all positive first),
134
+ then +-1 and +-2. RuntimeError if none keeps all relations.
135
+ """
136
+ rows = single_constant_rows(form, support)
137
+ for values in ((1.0, -1.0), (1.0, -1.0, 2.0, -2.0)):
138
+ for amps in product(values, repeat=len(support) - 1):
139
+ u = np.zeros(6)
140
+ u[list(support)] = (1.0,) + amps
141
+ if all(abs(form.basis[p, i] @ u) > _TOL for i, p in rows):
142
+ return u
143
+ raise RuntimeError(f"no amplitudes on support {support} keep all single-constant relations")
144
+
145
+
146
+ def relations(form, pattern):
147
+ """The six relations s_i = sum_p a_ip c_p of a pattern u (per unit step), a_ip = (B_p)_i . u.
148
+
149
+ One dict per row i: row, coef = (a_ip)_p, and kind 'zero' (all a_ip = 0), 'single' (one nonzero a_ip;
150
+ then also constant = p and factor = a_ip, so that s_i = factor * c_p) or 'coupled'.
151
+ """
152
+ u = np.asarray(pattern, float)
153
+ out = []
154
+ for i in range(6):
155
+ a = form.basis[:, i, :] @ u
156
+ nz = np.flatnonzero(np.abs(a) > _TOL)
157
+ if nz.size == 0:
158
+ out.append(dict(row=i, kind="zero", coef=a))
159
+ elif nz.size == 1:
160
+ out.append(dict(row=i, kind="single", coef=a, constant=int(nz[0]), factor=float(a[nz[0]])))
161
+ else:
162
+ out.append(dict(row=i, kind="coupled", coef=a))
163
+ return out
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Utkarsh Singh
3
+ """Elastic constants with an ASE calculator: every state of an ElasticSetup computed in memory.
4
+
5
+ ASE returns the stress in eV/A^3, tension positive, Voigt order xx yy zz yz xz xy; it is converted to GPa
6
+ (x 160.2177) and passed to ElasticSetup.compute with the forces of the clamped cells and, if a dipole function
7
+ is given, the dipole moment of every cell.
8
+ """
9
+ import numpy as np
10
+
11
+ from .core import ElasticSetup
12
+
13
+ EV_PER_A3_IN_GPA = 160.21766208
14
+
15
+
16
+ def calculate(atoms, calc, relax_ions=False, method="uncoupled", fmax=1e-4, steps=500, optimizer=None,
17
+ dipole=None, **setup_kwargs):
18
+ """Elastic tensor of atoms from the stresses of every strained cell, computed with an ASE calculator.
19
+
20
+ Builds ElasticSetup(atoms, **setup_kwargs), computes the stress (eV/A^3 from calc, converted to GPa,
21
+ tension positive) and the forces (eV/A) of every state with calc, and returns ElasticSetup.compute of them.
22
+
23
+ Parameters
24
+ ----------
25
+ atoms : ase.Atoms
26
+ Reference structure, relaxed and stress-free for the textbook constants.
27
+ calc : ase calculator
28
+ Calculator that returns the stress; it is attached to every strained cell.
29
+ relax_ions : bool
30
+ Relax the atoms of each strained cell at fixed cell (optimizer, default BFGS, to fmax eV/A within steps
31
+ steps); the constants are then the relaxed-ion constants and no forces are passed on. Otherwise the
32
+ cells are clamped and their forces give the internal-strain tensor.
33
+ method : str
34
+ Extraction method of ElasticSetup.compute: 'uncoupled', 'designated' or 'lstsq'.
35
+ fmax, steps, optimizer :
36
+ Convergence (eV/A), step limit and ASE optimizer class of the relaxations.
37
+ dipole : callable, optional
38
+ Function of an Atoms object (calculator attached, after any relaxation) that returns the dipole moment
39
+ of the cell in e*Angstrom, physical sign. It gives the piezoelectric tensor e = dP/de (clamped-ion for
40
+ clamped cells, relaxed-ion with relax_ions) and, with ionic=True, the Born charges, the total e and d.
41
+ **setup_kwargs :
42
+ Options of ElasticSetup: scheme, delta, stencil, symprec, frame, ionic, displacement.
43
+
44
+ Returns
45
+ -------
46
+ ElasticResult
47
+ With ionic=True, result.C_relaxed is the relaxed-ion stiffness from the clamped cells and the
48
+ displacements.
49
+ """
50
+ setup = ElasticSetup(atoms, **setup_kwargs)
51
+ if relax_ions and setup.ionic:
52
+ raise ValueError("ionic=True gives the relaxed-ion constants from clamped cells; do not combine it with "
53
+ "relax_ions=True")
54
+ stresses, forces, dipoles = {}, {}, {}
55
+ for st in setup.states:
56
+ at = setup.strained_atoms(st)
57
+ at.calc = calc
58
+ if relax_ions:
59
+ from ase.optimize import BFGS
60
+ (optimizer or BFGS)(at, logfile=None).run(fmax=fmax, steps=steps)
61
+ # ASE stress: eV/A^3, xx yy zz yz xz xy, positive in tension
62
+ stresses[st["id"]] = at.get_stress(voigt=True) * EV_PER_A3_IN_GPA
63
+ forces[st["id"]] = at.get_forces()
64
+ if dipole is not None:
65
+ dipoles[st["id"]] = np.asarray(dipole(at), float)
66
+ return setup.compute(stresses, forces=None if relax_ions else forces, method=method,
67
+ polarization=dipoles if dipole is not None else None)
minelastic/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Utkarsh Singh
3
+ """Command line interface: minelastic info | generate | collect | vasp-tensor.
4
+
5
+ info prints the symmetry, tensor form and strain set of a structure; generate and collect call
6
+ workflow.generate and workflow.collect; vasp-tensor converts a tensor printed by VASP (IBRION = 6) to GPa in
7
+ Voigt order and adds the Born stability check and the Voigt-Reuss-Hill moduli.
8
+ """
9
+ import argparse
10
+ import json
11
+ import sys
12
+
13
+ import numpy as np
14
+
15
+ from . import __version__
16
+ from .core import ElasticSetup
17
+ from .properties import born_stable, vrh
18
+ from .workflow import CODES, collect, generate, read_structure
19
+
20
+
21
+ def _common(p):
22
+ """Options of the strain set shared by info and generate."""
23
+ p.add_argument("--scheme", default="minimal", choices=["minimal", "asess"],
24
+ help="minimal uncoupled set (default) or the six single-component strains")
25
+ p.add_argument("--delta", type=float, default=0.01, help="strain step (default 0.01)")
26
+ p.add_argument("--stencil", default=None, choices=["central", "four-point", "forward"],
27
+ help="finite differences: four-point (default; -2, -1, 1, 2 steps) or central (+-1 step; the "
28
+ "default with --md) or forward")
29
+ p.add_argument("--symprec", type=float, default=1e-5, help="spglib tolerance in Angstrom")
30
+ p.add_argument("--frame", default="auto", choices=["auto", "input", "standard"],
31
+ help="orientation in which the strains are applied (default: input if it is standard)")
32
+
33
+
34
+ def main(argv=None):
35
+ """Parse the command line and run the subcommand; returns the exit status 0."""
36
+ ap = argparse.ArgumentParser(
37
+ prog="minelastic", epilog="Documentation: https://utksi.github.io/minelastic/",
38
+ description="Elastic constants from the minimal uncoupled stress-strain sets. info shows the symmetry and "
39
+ "the strain set of a structure, generate writes the inputs of the strained cells, collect reads "
40
+ "the finished calculations and prints the elastic tensor, and vasp-tensor analyses the tensor "
41
+ "that VASP prints for IBRION = 6.")
42
+ ap.add_argument("--version", action="version", version=__version__)
43
+ sub = ap.add_subparsers(dest="cmd", required=True)
44
+
45
+ p = sub.add_parser("info", help="symmetry, tensor form and strain set of a structure")
46
+ p.add_argument("structure")
47
+ p.add_argument("--code", default="generic", choices=sorted(CODES),
48
+ help="how to read the structure (template dir for vasp/aims/abinit, pw.in for qe)")
49
+ p.add_argument("--format", default=None, help="ASE format for --code generic")
50
+ _common(p)
51
+
52
+ p = sub.add_parser("generate", help="write the strained inputs")
53
+ p.add_argument("code", choices=sorted(CODES))
54
+ p.add_argument("template", help="vasp/aims/abinit: directory with the reference inputs; qe: pw.x input; "
55
+ "generic: structure file")
56
+ p.add_argument("-o", "--out", default="elastic", help="output directory (default ./elastic)")
57
+ p.add_argument("--relax-ions", action="store_true", help="relax the ions in each strained cell")
58
+ p.add_argument("--format", default=None, help="generic: ASE format of the structure files")
59
+ p.add_argument("--filename", default=None, help="generic: name of the structure file in each directory")
60
+ p.add_argument("--extra", nargs="*", default=(), help="generic: further files copied into each directory")
61
+ p.add_argument("--potcar", default="copy", choices=["copy", "link"], help="vasp: copy or symlink POTCAR")
62
+ p.add_argument("--fix-incar", action="store_true",
63
+ help="vasp: apply all recommended settings that the INCAR does not meet (EDIFF, ENCUT, PREC, "
64
+ "LREAL); without it they are only reported")
65
+ p.add_argument("--ionic", action="store_true",
66
+ help="also displace the symmetry-irreducible atoms (relaxed-ion tensors, Born charges and "
67
+ "piezoelectric response from clamped cells, as VASP IBRION=6)")
68
+ p.add_argument("--displacement", type=float, default=0.01, help="--ionic: atomic step in Angstrom")
69
+ p.add_argument("--md", action="store_true",
70
+ help="finite T: fixed-cell molecular dynamics in each strained cell, as set up in the template "
71
+ "(vasp: IBRION = 0; qe: calculation = 'md')")
72
+ _common(p)
73
+
74
+ p = sub.add_parser("collect", help="elastic constants from the finished calculations")
75
+ p.add_argument("directory", nargs="?", default="elastic")
76
+ p.add_argument("--method", default="uncoupled", choices=["uncoupled", "designated", "lstsq"])
77
+ p.add_argument("--output", default=None, help="name of the output file in each directory")
78
+ p.add_argument("--format", default=None, help="generic: ASE format of the output file")
79
+ p.add_argument("--md", action="store_true", help="average the stress over the ionic steps (finite T)")
80
+ p.add_argument("--skip", type=int, default=0, help="--md: steps discarded for equilibration")
81
+ p.add_argument("--samples", default=None,
82
+ help="glob of per-snapshot sub-directories in each state directory (e.g. 'conf-*'); "
83
+ "their stresses are averaged")
84
+ p.add_argument("--kinetic-temperature", type=float, default=None,
85
+ help="add the ideal-gas term n kB T (stresses without kinetic part, e.g. VASP MD)")
86
+ p.add_argument("--internal-strain", action="store_true", help="print the internal strain tensor")
87
+ p.add_argument("--piezo", action="store_true",
88
+ help="piezoelectric tensor from the Berry-phase dipoles of the strained cells (VASP LCALCPOL)")
89
+ p.add_argument("--born", default=None, metavar="OUTCAR",
90
+ help="Born charges and clamped-ion piezoelectric tensor from a VASP LEPSILON/LCALCEPS run of "
91
+ "the reference structure (with --ionic data)")
92
+ p.add_argument("--stress-measure", default="cauchy", choices=["cauchy", "kirchhoff"],
93
+ help="differentiate the Cauchy stress (default) or J*sigma (as VASP IBRION=6)")
94
+
95
+ p = sub.add_parser("vasp-tensor", help="analyse the elastic tensor printed by VASP IBRION=6")
96
+ p.add_argument("outcar")
97
+ p.add_argument("--which", default="total", choices=["total", "clamped", "ionic"])
98
+
99
+ a = ap.parse_args(argv)
100
+ kw = dict(scheme=a.scheme, delta=a.delta, stencil=a.stencil, symprec=a.symprec, frame=a.frame) \
101
+ if a.cmd in ("info", "generate") else {}
102
+
103
+ if a.cmd == "info":
104
+ atoms = read_structure(a.code, a.structure, a.format)
105
+ print(ElasticSetup(atoms, **{**kw, "stencil": a.stencil or "four-point"}).describe())
106
+ elif a.cmd == "generate":
107
+ if a.fix_incar and a.code != "vasp":
108
+ ap.error("--fix-incar is for vasp")
109
+ extra = dict(potcar=a.potcar, fix_incar=a.fix_incar) if a.code == "vasp" else {}
110
+ setup, notes = generate(a.code, a.template, a.out, relax_ions=a.relax_ions, fmt=a.format,
111
+ filename=a.filename, extra_files=a.extra, ionic=a.ionic,
112
+ displacement=a.displacement, md=a.md, **kw, **extra)
113
+ print(setup.describe())
114
+ print(f"\n{len(setup.states)} calculations written to {a.out}/ (list in {a.out}/STATES)")
115
+ for n in notes:
116
+ print("NOTE:", n)
117
+ elif a.cmd == "collect":
118
+ r = collect(a.directory, method=a.method, output=a.output, fmt=a.format, md=a.md, skip=a.skip,
119
+ kinetic_temperature=a.kinetic_temperature, samples=a.samples,
120
+ stress_measure=a.stress_measure, polarization=a.piezo, born_file=a.born)
121
+ print(r.summary())
122
+ if a.internal_strain and r.internal_strain is not None:
123
+ print("internal strain tensor dF/de (eV/A), columns xx yy zz yz xz xy:")
124
+ for n, L in enumerate(r.internal_strain):
125
+ for i, ax in enumerate("xyz"):
126
+ print(f" ion {n + 1:3d} {ax} " + " ".join(f"{v:10.4f}" for v in L[i]))
127
+ print(f"\nwritten {a.directory}/elastic.json")
128
+ elif a.cmd == "vasp-tensor":
129
+ from .io.vasp import read_elastic_tensor
130
+ which = {"total": "TOTAL ELASTIC MODULI", "clamped": "SYMMETRIZED ELASTIC MODULI",
131
+ "ionic": "ELASTIC MODULI CONTR FROM IONIC RELAXATION"}[a.which]
132
+ C = read_elastic_tensor(a.outcar, which)
133
+ print(f"{which} (GPa, Voigt xx yy zz yz xz xy):")
134
+ for row in C:
135
+ print(" " + " ".join(f"{v:9.2f}" for v in row))
136
+ if a.which != "ionic":
137
+ stable, ev = born_stable(C)
138
+ print(f"Born stable: {stable}")
139
+ if stable:
140
+ print(json.dumps({k: round(v, 3) for k, v in vrh(C).items()}))
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ sys.exit(main())