Skip to content

Commit cdbdbc1

Browse files
committed
Add multi-species defect chemical potentials
1 parent 65106b9 commit cdbdbc1

11 files changed

Lines changed: 151 additions & 18 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![CI](https://img.shields.io/github/actions/workflow/status/chatmaterials/defect-analysis/ci.yml?branch=main&label=CI)](https://github.com/chatmaterials/defect-analysis/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/chatmaterials/defect-analysis?display_name=tag)](https://github.com/chatmaterials/defect-analysis/releases)
44

5-
Standalone skill for defect-focused DFT result analysis, including automatic defect-type inference and multi-candidate screening.
5+
Standalone skill for defect-focused DFT result analysis, including automatic defect-type inference, multi-species chemical-potential support, and multi-candidate screening.
66

77
Supports VASP, QE, and ABINIT-style pristine/defect inputs.
88

@@ -20,8 +20,9 @@ npx skills add . --list
2020
python3 scripts/analyze_defect_formation.py fixtures/pristine fixtures/defect --mu -4.0 --temperature-k 1000 --site-density-cm3 1e22 --json
2121
python3 scripts/analyze_defect_formation.py fixtures/qe/pristine fixtures/qe/defect --mu -4.0 --json
2222
python3 scripts/analyze_defect_formation.py fixtures/abinit/pristine fixtures/abinit/defect --mu -4.0 --json
23+
python3 scripts/analyze_defect_formation.py fixtures/substitutional/pristine fixtures/substitutional/defect --mu-term Fe=-6.0 --mu-term Li=-1.5 --json
2324
python3 scripts/analyze_defect_structure.py fixtures/pristine/POSCAR fixtures/defect/POSCAR --json
24-
python3 scripts/compare_defect_candidates.py fixtures fixtures/candidates/high-energy-vacancy --mu -4.0 --max-volume-change-percent 5.0 --json
25+
python3 scripts/compare_defect_candidates.py fixtures fixtures/candidates/high-energy-vacancy --mu -4.0 --max-volume-change-percent 5.0 --temperature-k 1000 --site-density-cm3 1e22 --target-defect-type vacancy-like --target-concentration-cm3 1e10 --json
2526
python3 scripts/export_defect_report.py fixtures/pristine fixtures/defect --mu -4.0 --temperature-k 1000
2627
python3 scripts/run_regression.py
2728
```

SKILL.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: "defect-analysis"
3-
description: "Use when the task is to analyze defect-related DFT results, including neutral defect formation energies, automatic defect-type inference, stoichiometric changes, structural distortion around a defect, candidate ranking, and compact markdown reports from pristine and defect calculations. Supports VASP, QE, and ABINIT-style inputs."
3+
description: "Use when the task is to analyze defect-related DFT results, including neutral defect formation energies, automatic defect-type inference, multi-species chemical-potential terms, stoichiometric changes, abundance estimates, candidate ranking, and compact markdown reports from pristine and defect calculations. Supports VASP, QE, and ABINIT-style inputs."
44
---
55

66
# Defect Analysis
@@ -11,8 +11,10 @@ Use this skill for defect-focused post-processing rather than generic workflow s
1111

1212
- estimate a neutral defect formation energy from pristine and defect calculations
1313
- infer whether the defect looks vacancy-, interstitial-, substitutional-, or complex-like
14+
- apply multi-species chemical-potential terms for substitutional or complex defects
1415
- summarize how stoichiometry changes between pristine and defect cells
1516
- quantify structural or volume change caused by a defect
17+
- estimate compact abundance metrics under a temperature and site-density assumption
1618
- rank multiple defect candidates with a compact formation-plus-strain heuristic
1719
- write a compact defect-analysis report from finished calculations
1820

@@ -25,11 +27,11 @@ Supported backends:
2527
## Use the bundled helpers
2628

2729
- `scripts/analyze_defect_formation.py`
28-
Estimate a neutral defect formation energy, infer the defect type, and optionally estimate an equilibrium fraction.
30+
Estimate a neutral defect formation energy, infer the defect type, and optionally estimate abundance metrics.
2931
- `scripts/analyze_defect_structure.py`
3032
Compare pristine and defect structures and summarize size, stoichiometry, and inferred defect type.
3133
- `scripts/compare_defect_candidates.py`
32-
Rank multiple defect candidates with a compact formation-plus-strain heuristic.
34+
Rank multiple defect candidates with a compact formation-plus-strain-and-abundance heuristic.
3335
- `scripts/export_defect_report.py`
3436
Export a markdown defect-analysis report.
3537

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
free energy TOTEN = -19.000000 eV
2+
General timing and accounting informations for this job
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Li on Fe
2+
1.0
3+
5.020000 0.000000 0.000000
4+
0.000000 5.020000 0.000000
5+
0.000000 0.000000 5.020000
6+
Li O
7+
1 2
8+
Direct
9+
0.000000 0.000000 0.000000
10+
0.500000 0.500000 0.000000
11+
0.500000 0.000000 0.500000
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
free energy TOTEN = -25.000000 eV
2+
General timing and accounting informations for this job
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Pristine
2+
1.0
3+
5.000000 0.000000 0.000000
4+
0.000000 5.000000 0.000000
5+
0.000000 0.000000 5.000000
6+
Fe O
7+
1 2
8+
Direct
9+
0.000000 0.000000 0.000000
10+
0.500000 0.500000 0.000000
11+
0.500000 0.000000 0.500000

references/defect.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
- Automatic defect-type inference is a compact structural descriptor, not a substitute for a full site-resolved defect analysis.
44
- Neutral formation energies are useful for screening, but charged-defect corrections and Fermi-level dependence can dominate the final physics.
55
- Candidate ranking by formation energy and strain is a triage step, not a full defect thermodynamics workflow.
6+
- Multi-species chemical-potential terms are essential for substitutional defects; a single chemical potential is generally only meaningful for one-species changes.

scripts/analyze_defect_formation.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,41 @@
99
from defect_io import equilibrium_fraction, infer_species_and_delta, read_energy
1010

1111

12+
def parse_mu_terms(raw_terms: list[str] | None) -> dict[str, float]:
13+
terms: dict[str, float] = {}
14+
for raw in raw_terms or []:
15+
if "=" not in raw:
16+
raise SystemExit(f"Invalid --mu-term value `{raw}`; expected SPECIES=VALUE")
17+
label, value = raw.split("=", 1)
18+
terms[label.strip()] = float(value)
19+
return terms
20+
21+
22+
def classify_abundance(fraction: float | None, concentration: float | None) -> str | None:
23+
if concentration is not None:
24+
if concentration >= 1e18:
25+
return "abundant-like"
26+
if concentration >= 1e12:
27+
return "dilute-but-relevant"
28+
return "trace-like"
29+
if fraction is not None:
30+
if fraction >= 1e-4:
31+
return "abundant-like"
32+
if fraction >= 1e-10:
33+
return "dilute-but-relevant"
34+
return "trace-like"
35+
return None
36+
37+
1238
def analyze(
1339
pristine: Path,
1440
defect: Path,
1541
species: str | None,
1642
delta: int | None,
17-
mu: float,
43+
mu: float | None,
1844
temperature_K: float | None = None,
1945
site_density_cm3: float | None = None,
46+
mu_terms: dict[str, float] | None = None,
2047
) -> dict[str, object]:
2148
backend_pristine, e_bulk = read_energy(pristine)
2249
backend_defect, e_def = read_energy(defect)
@@ -25,9 +52,18 @@ def analyze(
2552
species, delta, defect_type, delta_map = infer_species_and_delta(pristine, defect, species, delta)
2653
if species is None or delta is None:
2754
raise SystemExit("Could not infer species and delta from the pristine/defect pair; provide --species and --delta explicitly")
28-
e_form = e_def - e_bulk - delta * mu
55+
required_species = [label for label, value in delta_map.items() if value != 0]
56+
chemical_terms = dict(mu_terms or {})
57+
if mu is not None and species not in chemical_terms:
58+
chemical_terms[species] = mu
59+
missing = [label for label in required_species if label not in chemical_terms]
60+
if missing:
61+
raise SystemExit(f"Missing chemical potentials for species: {', '.join(missing)}")
62+
chemical_sum = sum(delta_map[label] * chemical_terms[label] for label in required_species)
63+
e_form = e_def - e_bulk - chemical_sum
2964
fraction = equilibrium_fraction(e_form, temperature_K) if temperature_K is not None else None
3065
concentration = fraction * site_density_cm3 if fraction is not None and site_density_cm3 is not None else None
66+
abundance_class = classify_abundance(fraction, concentration)
3167
observations = ["Neutral defect formation energy estimated from pristine and defect total energies."]
3268
observations.append(f"Defect type inferred as `{defect_type}`.")
3369
return {
@@ -38,14 +74,16 @@ def analyze(
3874
"delta_species": delta,
3975
"species_delta_map": delta_map,
4076
"defect_type": defect_type,
41-
"chemical_potential_eV": mu,
77+
"chemical_potential_eV": chemical_terms.get(species),
78+
"chemical_potential_terms": chemical_terms,
4279
"pristine_energy_eV": e_bulk,
4380
"defect_energy_eV": e_def,
4481
"formation_energy_eV": e_form,
4582
"temperature_K": temperature_K,
4683
"site_density_cm3": site_density_cm3,
4784
"equilibrium_fraction": fraction,
4885
"equilibrium_concentration_cm3": concentration,
86+
"abundance_class": abundance_class,
4987
"observations": observations,
5088
}
5189

@@ -56,11 +94,14 @@ def main() -> None:
5694
parser.add_argument("defect")
5795
parser.add_argument("--species")
5896
parser.add_argument("--delta", type=int, help="Change in species count: defect minus pristine.")
59-
parser.add_argument("--mu", type=float, required=True, help="Chemical potential for the species in eV.")
97+
parser.add_argument("--mu", type=float, help="Chemical potential for the primary species in eV.")
98+
parser.add_argument("--mu-term", action="append", help="Chemical potential term in the form SPECIES=VALUE. Repeat as needed.")
6099
parser.add_argument("--temperature-k", type=float)
61100
parser.add_argument("--site-density-cm3", type=float)
62101
parser.add_argument("--json", action="store_true")
63102
args = parser.parse_args()
103+
if args.mu is None and not args.mu_term:
104+
raise SystemExit("Provide either --mu or at least one --mu-term")
64105
payload = analyze(
65106
Path(args.pristine).expanduser().resolve(),
66107
Path(args.defect).expanduser().resolve(),
@@ -69,6 +110,7 @@ def main() -> None:
69110
args.mu,
70111
args.temperature_k,
71112
args.site_density_cm3,
113+
parse_mu_terms(args.mu_term),
72114
)
73115
if args.json:
74116
print(json.dumps(payload, indent=2))

scripts/compare_defect_candidates.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,51 +6,74 @@
66
import json
77
from pathlib import Path
88

9-
from analyze_defect_formation import analyze as analyze_formation
9+
from analyze_defect_formation import analyze as analyze_formation, parse_mu_terms
1010
from analyze_defect_structure import analyze as analyze_structure
1111

1212

1313
def analyze_case(
1414
root: Path,
15-
mu: float,
15+
mu: float | None,
1616
species: str | None,
1717
delta: int | None,
1818
max_volume_change_percent: float,
19+
mu_terms: dict[str, float] | None,
20+
temperature_K: float | None,
21+
site_density_cm3: float | None,
22+
target_defect_type: str | None,
23+
target_concentration_cm3: float | None,
1924
) -> dict[str, object]:
2025
pristine = root / "pristine"
2126
defect = root / "defect"
22-
formation = analyze_formation(pristine, defect, species, delta, mu)
27+
formation = analyze_formation(pristine, defect, species, delta, mu, temperature_K, site_density_cm3, mu_terms)
2328
structure = analyze_structure(pristine, defect)
2429
formation_penalty = max(0.0, float(formation["formation_energy_eV"]))
2530
strain_penalty = max(0.0, abs(float(structure["relative_volume_change_percent"])) - max_volume_change_percent) / 5.0
26-
score = formation_penalty + strain_penalty
31+
type_penalty = 0.0 if target_defect_type is None or formation["defect_type"].startswith(target_defect_type) else 2.0
32+
concentration = formation["equilibrium_concentration_cm3"]
33+
abundance_penalty = 0.0
34+
if target_concentration_cm3 is not None:
35+
abundance_penalty = max(0.0, target_concentration_cm3 - (float(concentration) if concentration is not None else 0.0)) / target_concentration_cm3
36+
score = formation_penalty + strain_penalty + type_penalty + abundance_penalty
2737
return {
2838
"case": root.name,
2939
"path": str(root),
3040
"defect_type": formation["defect_type"],
3141
"species": formation["species"],
3242
"delta_species": formation["delta_species"],
3343
"formation_energy_eV": formation["formation_energy_eV"],
44+
"equilibrium_concentration_cm3": concentration,
45+
"abundance_class": formation["abundance_class"],
3446
"relative_volume_change_percent": structure["relative_volume_change_percent"],
3547
"formation_penalty": formation_penalty,
3648
"strain_penalty": strain_penalty,
49+
"type_penalty": type_penalty,
50+
"abundance_penalty": abundance_penalty,
3751
"screening_score": score,
3852
}
3953

4054

4155
def analyze_cases(
4256
roots: list[Path],
43-
mu: float,
57+
mu: float | None,
4458
species: str | None,
4559
delta: int | None,
4660
max_volume_change_percent: float,
61+
mu_terms: dict[str, float] | None,
62+
temperature_K: float | None,
63+
site_density_cm3: float | None,
64+
target_defect_type: str | None,
65+
target_concentration_cm3: float | None,
4766
) -> dict[str, object]:
48-
cases = [analyze_case(root, mu, species, delta, max_volume_change_percent) for root in roots]
67+
cases = [
68+
analyze_case(root, mu, species, delta, max_volume_change_percent, mu_terms, temperature_K, site_density_cm3, target_defect_type, target_concentration_cm3)
69+
for root in roots
70+
]
4971
ranked = sorted(cases, key=lambda item: item["screening_score"])
5072
return {
5173
"chemical_potential_eV": mu,
74+
"chemical_potential_terms": mu_terms,
5275
"max_volume_change_percent": max_volume_change_percent,
53-
"ranking_basis": "screening_score = formation_penalty + strain_penalty",
76+
"ranking_basis": "screening_score = formation_penalty + strain_penalty + type_penalty + abundance_penalty",
5477
"cases": ranked,
5578
"best_case": ranked[0]["case"] if ranked else None,
5679
"observations": [
@@ -62,18 +85,30 @@ def analyze_cases(
6285
def main() -> None:
6386
parser = argparse.ArgumentParser(description="Rank defect candidates with a compact formation-plus-strain heuristic.")
6487
parser.add_argument("paths", nargs="+")
65-
parser.add_argument("--mu", type=float, required=True)
88+
parser.add_argument("--mu", type=float)
89+
parser.add_argument("--mu-term", action="append")
6690
parser.add_argument("--species")
6791
parser.add_argument("--delta", type=int)
6892
parser.add_argument("--max-volume-change-percent", type=float, default=5.0)
93+
parser.add_argument("--temperature-k", type=float)
94+
parser.add_argument("--site-density-cm3", type=float)
95+
parser.add_argument("--target-defect-type")
96+
parser.add_argument("--target-concentration-cm3", type=float)
6997
parser.add_argument("--json", action="store_true")
7098
args = parser.parse_args()
99+
if args.mu is None and not args.mu_term:
100+
raise SystemExit("Provide either --mu or at least one --mu-term")
71101
payload = analyze_cases(
72102
[Path(path).expanduser().resolve() for path in args.paths],
73103
args.mu,
74104
args.species,
75105
args.delta,
76106
args.max_volume_change_percent,
107+
parse_mu_terms(args.mu_term),
108+
args.temperature_k,
109+
args.site_density_cm3,
110+
args.target_defect_type,
111+
args.target_concentration_cm3,
77112
)
78113
if args.json:
79114
print(json.dumps(payload, indent=2))

scripts/export_defect_report.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
def screening_note(formation: dict[str, object], structure: dict[str, object]) -> str:
1313
e_form = float(formation["formation_energy_eV"])
1414
strain = abs(float(structure["relative_volume_change_percent"]))
15+
abundance = formation.get("abundance_class")
1516
if e_form <= 1.0 and strain <= 5.0:
1617
return "This defect looks relatively accessible in a compact neutral-defect screen: low formation energy and limited structural swelling."
1718
if e_form > 2.5:
1819
return "The neutral formation energy is high enough that this defect is unlikely to be abundant without strong thermodynamic driving forces."
20+
if abundance == "trace-like":
21+
return "This defect is thermodynamically allowed in the compact model, but the estimated abundance remains trace-like under the supplied conditions."
1922
if strain > 10.0:
2023
return "The structural response is large enough that local strain accommodation may be important."
2124
return "The defect is intermediate in this compact screen and may need charged-defect or concentration analysis before stronger claims."
@@ -29,9 +32,11 @@ def render_markdown(formation: dict[str, object], structure: dict[str, object])
2932
f"- Defect type: `{formation['defect_type']}`",
3033
f"- Species: `{formation['species']}`",
3134
f"- Delta species: `{formation['delta_species']}`",
32-
f"- Chemical potential (eV): `{formation['chemical_potential_eV']:.4f}`",
35+
f"- Chemical potential terms: `{formation['chemical_potential_terms']}`",
3336
f"- Formation energy (eV): `{formation['formation_energy_eV']:.4f}`",
3437
f"- Equilibrium fraction: `{formation['equilibrium_fraction']:.4e}`" if formation["equilibrium_fraction"] is not None else "- Equilibrium fraction: `n/a`",
38+
f"- Equilibrium concentration (cm^-3): `{formation['equilibrium_concentration_cm3']:.4e}`" if formation["equilibrium_concentration_cm3"] is not None else "- Equilibrium concentration (cm^-3): `n/a`",
39+
f"- Abundance class: `{formation['abundance_class']}`" if formation["abundance_class"] is not None else "- Abundance class: `n/a`",
3540
"",
3641
"## Structural Change",
3742
f"- Pristine atoms: `{structure['natoms_pristine']}`",

0 commit comments

Comments
 (0)