chemphase 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.
- chemphase/__init__.py +35 -0
- chemphase/__main__.py +9 -0
- chemphase/cli.py +205 -0
- chemphase/core.py +369 -0
- chemphase/diagrams.py +280 -0
- chemphase/plotting.py +94 -0
- chemphase/py.typed +0 -0
- chemphase-0.1.0.dist-info/METADATA +111 -0
- chemphase-0.1.0.dist-info/RECORD +13 -0
- chemphase-0.1.0.dist-info/WHEEL +5 -0
- chemphase-0.1.0.dist-info/entry_points.txt +2 -0
- chemphase-0.1.0.dist-info/licenses/LICENSE +21 -0
- chemphase-0.1.0.dist-info/top_level.txt +1 -0
chemphase/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""chemphase — 相图与化学势热图生成器
|
|
2
|
+
|
|
3
|
+
一键从 Materials Project API 或本地 VASP 计算结果生成:
|
|
4
|
+
- 二元/三元成分相图(含 Hull 连线、标签自动避让)
|
|
5
|
+
- 化学势热图
|
|
6
|
+
- 结构对比报告
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
"""惰性导入:仅在实际使用子模块时才导入"""
|
|
14
|
+
if name == "PhaseDiagramConfig":
|
|
15
|
+
from chemphase.core import PhaseDiagramConfig
|
|
16
|
+
return PhaseDiagramConfig
|
|
17
|
+
if name == "generate_binary_composition_diagram":
|
|
18
|
+
from chemphase.diagrams import generate_binary_composition_diagram
|
|
19
|
+
return generate_binary_composition_diagram
|
|
20
|
+
if name == "generate_ternary_composition_diagram":
|
|
21
|
+
from chemphase.diagrams import generate_ternary_composition_diagram
|
|
22
|
+
return generate_ternary_composition_diagram
|
|
23
|
+
if name == "main":
|
|
24
|
+
from chemphase.cli import main
|
|
25
|
+
return main
|
|
26
|
+
raise AttributeError(f"module 'chemphase' has no attribute '{name}'")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"PhaseDiagramConfig",
|
|
32
|
+
"generate_binary_composition_diagram",
|
|
33
|
+
"generate_ternary_composition_diagram",
|
|
34
|
+
"main",
|
|
35
|
+
]
|
chemphase/__main__.py
ADDED
chemphase/cli.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""命令行接口 (CLI)
|
|
2
|
+
|
|
3
|
+
安装后通过终端命令 `chemphase` 或 `python -m chemphase` 调用
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import argparse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from itertools import combinations
|
|
10
|
+
|
|
11
|
+
from chemphase.core import (
|
|
12
|
+
PhaseDiagramConfig, validate_elements, ensure_api_key,
|
|
13
|
+
scan_local_phases, build_local_entries, structure_comparison_report,
|
|
14
|
+
get_entries_from_mp, get_system_name, get_all_element_combinations,
|
|
15
|
+
print_banner, DEFAULT_ELEMENTS,
|
|
16
|
+
)
|
|
17
|
+
from chemphase.diagrams import (
|
|
18
|
+
generate_binary_composition_diagram,
|
|
19
|
+
generate_ternary_composition_diagram,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_local_mode(config: PhaseDiagramConfig, elements, output_root: str):
|
|
24
|
+
"""本地数据模式:读取 VASP 计算结果生成相图"""
|
|
25
|
+
print(f"\n扫描本地目录: {config.local_dir}")
|
|
26
|
+
local_phases = scan_local_phases(config.local_dir, elements, config.eah_threshold)
|
|
27
|
+
print(f"找到 {len(local_phases)} 个相关相")
|
|
28
|
+
|
|
29
|
+
if not local_phases:
|
|
30
|
+
print("没有找到有效的相数据")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# 结构对比
|
|
34
|
+
if config.compare_structure:
|
|
35
|
+
print("\n" + "=" * 60)
|
|
36
|
+
print(" 结构对比分析")
|
|
37
|
+
print("=" * 60)
|
|
38
|
+
report = structure_comparison_report(local_phases, config)
|
|
39
|
+
print(f" 总共对比: {report['total_compared']}")
|
|
40
|
+
print(f" 结构匹配: {report['matching']}")
|
|
41
|
+
print(f" 结构不同: {report['different']}")
|
|
42
|
+
print(f" MP未收录: {report['not_found_in_mp']}")
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
report_file = Path(output_root) / "structure_comparison_report.json"
|
|
46
|
+
report_file.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
with open(report_file, 'w') as f:
|
|
48
|
+
json.dump(report, f, indent=2, sort_keys=True)
|
|
49
|
+
print(f" 报告已保存: {report_file}")
|
|
50
|
+
|
|
51
|
+
entries = build_local_entries(local_phases)
|
|
52
|
+
print(f"\n构建了 {len(entries)} 个PhaseDiagramEntry")
|
|
53
|
+
|
|
54
|
+
output_dir = Path(output_root)
|
|
55
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
binary_combos = list(combinations(elements, 2))
|
|
58
|
+
ternary_combos = list(combinations(elements, 3))
|
|
59
|
+
|
|
60
|
+
# 二元相图
|
|
61
|
+
print("\n" + "=" * 60)
|
|
62
|
+
print(" 生成二元相图")
|
|
63
|
+
print("=" * 60)
|
|
64
|
+
binary_dir = output_dir / "binary"
|
|
65
|
+
binary_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
for e1, e2 in binary_combos:
|
|
67
|
+
sub_entries = [e for e in entries if set(e.composition.as_dict().keys()) <= {e1, e2}]
|
|
68
|
+
if len(sub_entries) >= 2:
|
|
69
|
+
generate_binary_composition_diagram(sub_entries, (e1, e2), binary_dir, "本地数据")
|
|
70
|
+
|
|
71
|
+
# 三元相图
|
|
72
|
+
print("\n" + "=" * 60)
|
|
73
|
+
print(" 生成三元相图")
|
|
74
|
+
print("=" * 60)
|
|
75
|
+
ternary_dir = output_dir / "ternary"
|
|
76
|
+
ternary_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
for e1, e2, e3 in ternary_combos:
|
|
78
|
+
sub_entries = [e for e in entries if set(e.composition.as_dict().keys()) <= {e1, e2, e3}]
|
|
79
|
+
if len(sub_entries) >= 3:
|
|
80
|
+
generate_ternary_composition_diagram(sub_entries, (e1, e2, e3), ternary_dir, "本地数据")
|
|
81
|
+
|
|
82
|
+
print("\n" + "=" * 60)
|
|
83
|
+
print(" 本地数据模式完成")
|
|
84
|
+
print("=" * 60)
|
|
85
|
+
print(f"输出目录: {output_dir}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_api_mode(config: PhaseDiagramConfig, elements, output_root: str):
|
|
89
|
+
"""API 下载模式:从 Materials Project 下载数据生成相图"""
|
|
90
|
+
print(f"\n从Materials Project下载 {get_system_name(elements)} 体系数据")
|
|
91
|
+
|
|
92
|
+
output_dir = Path(output_root)
|
|
93
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
binary_combos = list(combinations(elements, 2))
|
|
96
|
+
ternary_combos = list(combinations(elements, 3))
|
|
97
|
+
|
|
98
|
+
# 二元相图
|
|
99
|
+
print("\n" + "=" * 60)
|
|
100
|
+
print(" 生成二元相图")
|
|
101
|
+
print("=" * 60)
|
|
102
|
+
binary_dir = output_dir / "binary"
|
|
103
|
+
binary_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
for e1, e2 in binary_combos:
|
|
105
|
+
try:
|
|
106
|
+
entries = get_entries_from_mp(config, [e1, e2])
|
|
107
|
+
if len(entries) >= 2:
|
|
108
|
+
from pymatgen.analysis.phase_diagram import PDEntry
|
|
109
|
+
pd_entries = [PDEntry(composition=e.composition, energy=e.energy, name=e.name)
|
|
110
|
+
for e in entries]
|
|
111
|
+
generate_binary_composition_diagram(pd_entries, (e1, e2), binary_dir, "MP数据")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
print(f" - {e1}-{e2}: {e}")
|
|
114
|
+
|
|
115
|
+
# 三元相图
|
|
116
|
+
print("\n" + "=" * 60)
|
|
117
|
+
print(" 生成三元相图")
|
|
118
|
+
print("=" * 60)
|
|
119
|
+
ternary_dir = output_dir / "ternary"
|
|
120
|
+
ternary_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
for e1, e2, e3 in ternary_combos:
|
|
122
|
+
try:
|
|
123
|
+
entries = get_entries_from_mp(config, [e1, e2, e3])
|
|
124
|
+
if len(entries) >= 3:
|
|
125
|
+
from pymatgen.analysis.phase_diagram import PDEntry
|
|
126
|
+
pd_entries = [PDEntry(composition=e.composition, energy=e.energy, name=e.name)
|
|
127
|
+
for e in entries]
|
|
128
|
+
generate_ternary_composition_diagram(pd_entries, (e1, e2, e3), ternary_dir, "MP数据")
|
|
129
|
+
except Exception as e:
|
|
130
|
+
print(f" - {e1}-{e2}-{e3}: {e}")
|
|
131
|
+
|
|
132
|
+
print("\n" + "=" * 60)
|
|
133
|
+
print(" API模式完成")
|
|
134
|
+
print("=" * 60)
|
|
135
|
+
print(f"输出目录: {output_dir}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main():
|
|
139
|
+
"""主入口函数"""
|
|
140
|
+
parser = argparse.ArgumentParser(
|
|
141
|
+
prog="chemphase",
|
|
142
|
+
description="相图与化学势热图统一生成器 v5.0",
|
|
143
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
144
|
+
epilog="""
|
|
145
|
+
使用示例:
|
|
146
|
+
# API模式 - 下载CuAgOSe数据(默认)
|
|
147
|
+
chemphase
|
|
148
|
+
|
|
149
|
+
# 指定API模式的元素
|
|
150
|
+
chemphase --elements Li O Co
|
|
151
|
+
|
|
152
|
+
# 本地模式 - 读取VASP计算结果
|
|
153
|
+
chemphase --local /path/to/calculations --elements Cu Ag O Se
|
|
154
|
+
|
|
155
|
+
# 混合模式 - 本地数据为主
|
|
156
|
+
chemphase --local /path/to/data --elements Cu Ag O Se
|
|
157
|
+
|
|
158
|
+
# 启用结构对比
|
|
159
|
+
chemphase --local /path/to/data --elements Cu Ag O Se --compare-structure
|
|
160
|
+
"""
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument('--local', type=str, help='本地VASP计算结果目录')
|
|
163
|
+
parser.add_argument('--elements', nargs='+', help='目标元素列表(如 Cu Ag O Se)')
|
|
164
|
+
parser.add_argument('--output', default='phase_diagrams_output', help='输出目录 (默认: phase_diagrams_output)')
|
|
165
|
+
parser.add_argument('--eah', type=float, default=0.05, help='Energy above hull 阈值 (默认: 0.05 eV/atom)')
|
|
166
|
+
parser.add_argument('--compare-structure', action='store_true', help='启用本地结构 vs MP数据库对比')
|
|
167
|
+
parser.add_argument('--debug', action='store_true', help='调试模式')
|
|
168
|
+
|
|
169
|
+
args = parser.parse_args()
|
|
170
|
+
|
|
171
|
+
print_banner()
|
|
172
|
+
|
|
173
|
+
config = PhaseDiagramConfig(
|
|
174
|
+
eah_threshold=args.eah,
|
|
175
|
+
output_root=args.output,
|
|
176
|
+
local_dir=Path(args.local) if args.local else None,
|
|
177
|
+
compare_structure=args.compare_structure,
|
|
178
|
+
debug=args.debug
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if args.local:
|
|
182
|
+
if not args.elements:
|
|
183
|
+
print("错误: 本地模式需要指定 --elements")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
ok, valid, errors = validate_elements(args.elements)
|
|
186
|
+
if not ok:
|
|
187
|
+
print("错误: " + ", ".join(errors))
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
run_local_mode(config, valid, args.output)
|
|
190
|
+
else:
|
|
191
|
+
ensure_api_key(config)
|
|
192
|
+
elements = args.elements if args.elements else DEFAULT_ELEMENTS
|
|
193
|
+
ok, valid, errors = validate_elements(elements)
|
|
194
|
+
if not ok:
|
|
195
|
+
print("错误: " + ", ".join(errors))
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
run_api_mode(config, valid, args.output)
|
|
198
|
+
|
|
199
|
+
print("\n" + "=" * 60)
|
|
200
|
+
print(" 执行完成")
|
|
201
|
+
print("=" * 60)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
chemphase/core.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""核心模块:配置管理、VASP 数据解析、Materials Project API、结构对比"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Tuple, Any
|
|
9
|
+
from itertools import combinations
|
|
10
|
+
|
|
11
|
+
# ============================================================
|
|
12
|
+
# 元素验证
|
|
13
|
+
# ============================================================
|
|
14
|
+
VALID_ELEMENTS = {
|
|
15
|
+
'H', 'He', 'Li', 'Be', 'B', 'C', 'N', 'O', 'F', 'Ne',
|
|
16
|
+
'Na', 'Mg', 'Al', 'Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca',
|
|
17
|
+
'Sc', 'Ti', 'V', 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn',
|
|
18
|
+
'Ga', 'Ge', 'As', 'Se', 'Br', 'Kr', 'Rb', 'Sr', 'Y', 'Zr',
|
|
19
|
+
'Nb', 'Mo', 'Tc', 'Ru', 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn',
|
|
20
|
+
'Sb', 'Te', 'I', 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd',
|
|
21
|
+
'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb',
|
|
22
|
+
'Lu', 'Hf', 'Ta', 'W', 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg',
|
|
23
|
+
'Tl', 'Pb', 'Bi', 'Po', 'At', 'Rn', 'Fr', 'Ra', 'Ac', 'Th',
|
|
24
|
+
'Pa', 'U', 'Np', 'Pu', 'Am', 'Cm', 'Bk', 'Cf', 'Es', 'Fm',
|
|
25
|
+
'Md', 'No', 'Lr', 'Rf', 'Db', 'Sg', 'Bh', 'Hs', 'Mt', 'Ds',
|
|
26
|
+
'Rg', 'Cn', 'Nh', 'Fl', 'Mc', 'Lv', 'Ts', 'Og'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
EAH_THRESHOLD = 0.05 # 能量 above hull 阈值 (eV/atom)
|
|
30
|
+
OUTPUT_ROOT = "phase_diagrams_output"
|
|
31
|
+
UNIFIED_PHASES_DIR = "unified_phases"
|
|
32
|
+
DEFAULT_ELEMENTS = ["Cu", "Ag", "O", "Se"]
|
|
33
|
+
STRUCTURE_TOLERANCE = 0.2 # 结构匹配容忍度
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_elements(elements: List[str]) -> Tuple[bool, List[str], List[str]]:
|
|
37
|
+
"""验证元素符号是否有效"""
|
|
38
|
+
errors = []
|
|
39
|
+
valid = []
|
|
40
|
+
for elem in elements:
|
|
41
|
+
elem = elem.strip().capitalize()
|
|
42
|
+
if elem in VALID_ELEMENTS:
|
|
43
|
+
valid.append(elem)
|
|
44
|
+
else:
|
|
45
|
+
errors.append(f"'{elem}' 不是有效元素符号")
|
|
46
|
+
return len(errors) == 0, valid, errors
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ============================================================
|
|
50
|
+
# 配置类
|
|
51
|
+
# ============================================================
|
|
52
|
+
class PhaseDiagramConfig:
|
|
53
|
+
"""相图生成配置"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, api_key: str = "", eah_threshold: float = EAH_THRESHOLD,
|
|
56
|
+
output_root: str = OUTPUT_ROOT, unified_phases_dir: str = UNIFIED_PHASES_DIR,
|
|
57
|
+
debug: bool = False, local_dir: Optional[Path] = None,
|
|
58
|
+
compare_structure: bool = False):
|
|
59
|
+
self.api_key = api_key or os.environ.get("MATERIALS_PROJECT_API_KEY", "")
|
|
60
|
+
self.eah_threshold = eah_threshold
|
|
61
|
+
self.output_root = output_root
|
|
62
|
+
self.unified_phases_dir = unified_phases_dir
|
|
63
|
+
self.debug = debug
|
|
64
|
+
self.local_dir = local_dir
|
|
65
|
+
self.compare_structure = compare_structure
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def database_file(self) -> Path:
|
|
69
|
+
return Path(self.unified_phases_dir) / "phases_database.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ensure_api_key(config: PhaseDiagramConfig) -> str:
|
|
73
|
+
"""确保 API 密钥可用,优先从环境变量读取"""
|
|
74
|
+
if not config.api_key:
|
|
75
|
+
print("\n" + "=" * 60)
|
|
76
|
+
print(" 请设置 Materials Project API 密钥")
|
|
77
|
+
print("=" * 60)
|
|
78
|
+
print(" 获取方式: https://materialsproject.org/api")
|
|
79
|
+
print(" 环境变量: export MATERIALS_PROJECT_API_KEY=你的密钥")
|
|
80
|
+
print()
|
|
81
|
+
key = input(" 请输入API密钥: ").strip()
|
|
82
|
+
if not key:
|
|
83
|
+
print(" 未输入密钥,退出")
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
config.api_key = key
|
|
86
|
+
print(" ⚠ 密钥仅本次有效,建议设置环境变量 MATERIALS_PROJECT_API_KEY")
|
|
87
|
+
return config.api_key
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ============================================================
|
|
91
|
+
# 本地 VASP 数据解析
|
|
92
|
+
# ============================================================
|
|
93
|
+
|
|
94
|
+
def parse_energy_from_vasprun(vasprun_path: str, debug: bool = False) -> Optional[float]:
|
|
95
|
+
"""从 vasprun.xml 解析最终能量"""
|
|
96
|
+
try:
|
|
97
|
+
from pymatgen.io.vasp.outputs import Vasprun
|
|
98
|
+
vr = Vasprun(vasprun_path)
|
|
99
|
+
return vr.final_energy
|
|
100
|
+
except Exception as e:
|
|
101
|
+
if debug:
|
|
102
|
+
print(f" vasprun.xml解析失败: {e}")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_energy_from_outcar(outcar_path: str, debug: bool = False) -> Optional[float]:
|
|
107
|
+
"""从 OUTCAR 解析最终能量"""
|
|
108
|
+
try:
|
|
109
|
+
with open(outcar_path, 'r') as f:
|
|
110
|
+
content = f.read()
|
|
111
|
+
matches = re.findall(r'energy\(sigma->0\)\s*=\s*([-\d.]+)', content)
|
|
112
|
+
if matches:
|
|
113
|
+
return float(matches[-1])
|
|
114
|
+
except Exception as e:
|
|
115
|
+
if debug:
|
|
116
|
+
print(f" OUTCAR解析失败: {e}")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_structure_info(poscar_path: str, debug: bool = False) -> Tuple[Optional[str], Optional[int]]:
|
|
121
|
+
"""从 POSCAR 解析化学式和原子数"""
|
|
122
|
+
try:
|
|
123
|
+
from pymatgen.core import Structure
|
|
124
|
+
structure = Structure.from_file(poscar_path)
|
|
125
|
+
return structure.composition.reduced_formula, structure.num_atoms
|
|
126
|
+
except Exception as e:
|
|
127
|
+
if debug:
|
|
128
|
+
print(f" POSCAR解析失败: {e}")
|
|
129
|
+
return None, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_phase_directory(phase_dir: Path, eah_threshold: float = EAH_THRESHOLD) -> Optional[Dict]:
|
|
133
|
+
"""解析单个 VASP 计算目录"""
|
|
134
|
+
dir_name = phase_dir.name
|
|
135
|
+
name_match = re.match(r'(.+?)_EaH_([\d.]+)(_stable)?', dir_name)
|
|
136
|
+
if not name_match:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
formula_from_name = name_match.group(1)
|
|
140
|
+
eah = float(name_match.group(2))
|
|
141
|
+
is_stable = name_match.group(3) == '_stable' or eah < eah_threshold
|
|
142
|
+
|
|
143
|
+
poscar = phase_dir / "POSCAR"
|
|
144
|
+
vasprun = phase_dir / "vasprun.xml"
|
|
145
|
+
outcar = phase_dir / "OUTCAR"
|
|
146
|
+
|
|
147
|
+
if not poscar.exists():
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
formula, num_atoms = parse_structure_info(str(poscar))
|
|
151
|
+
if not formula:
|
|
152
|
+
formula = formula_from_name
|
|
153
|
+
|
|
154
|
+
energy = None
|
|
155
|
+
if vasprun.exists():
|
|
156
|
+
energy = parse_energy_from_vasprun(str(vasprun))
|
|
157
|
+
elif outcar.exists():
|
|
158
|
+
energy = parse_energy_from_outcar(str(outcar))
|
|
159
|
+
|
|
160
|
+
if energy is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
energy_per_atom = energy / num_atoms if num_atoms else None
|
|
164
|
+
from pymatgen.core import Composition
|
|
165
|
+
elements = list(set(Composition(formula).as_dict().keys()))
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
'name': dir_name,
|
|
169
|
+
'formula': formula,
|
|
170
|
+
'energy': energy,
|
|
171
|
+
'energy_per_atom': energy_per_atom,
|
|
172
|
+
'num_atoms': num_atoms,
|
|
173
|
+
'eah': eah,
|
|
174
|
+
'is_stable': is_stable,
|
|
175
|
+
'elements': elements,
|
|
176
|
+
'path': str(phase_dir)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def scan_local_phases(local_dir: Path, target_elements: Optional[List[str]] = None,
|
|
181
|
+
eah_threshold: float = EAH_THRESHOLD) -> List[Dict]:
|
|
182
|
+
"""扫描本地目录中的所有 VASP 计算"""
|
|
183
|
+
if not local_dir.exists():
|
|
184
|
+
print(f"目录不存在: {local_dir}")
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
phases = []
|
|
188
|
+
target_set = set(target_elements) if target_elements else None
|
|
189
|
+
|
|
190
|
+
for phase_dir in local_dir.iterdir():
|
|
191
|
+
if not phase_dir.is_dir():
|
|
192
|
+
continue
|
|
193
|
+
phase_data = parse_phase_directory(phase_dir, eah_threshold)
|
|
194
|
+
if phase_data is None:
|
|
195
|
+
continue
|
|
196
|
+
if target_set:
|
|
197
|
+
phase_elements = set(phase_data['elements'])
|
|
198
|
+
if not phase_elements <= target_set:
|
|
199
|
+
continue
|
|
200
|
+
phases.append(phase_data)
|
|
201
|
+
|
|
202
|
+
return phases
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_local_entries(phases: List[Dict]) -> List:
|
|
206
|
+
"""从本地相数据构建 pymatgen PDEntry 列表"""
|
|
207
|
+
from pymatgen.core import Structure
|
|
208
|
+
from pymatgen.analysis.phase_diagram import PDEntry
|
|
209
|
+
|
|
210
|
+
entries = []
|
|
211
|
+
for phase in phases:
|
|
212
|
+
try:
|
|
213
|
+
structure = Structure.from_file(phase['path'] + "/POSCAR")
|
|
214
|
+
entry = PDEntry(
|
|
215
|
+
composition=structure.composition,
|
|
216
|
+
energy=phase['energy'],
|
|
217
|
+
name=phase['name']
|
|
218
|
+
)
|
|
219
|
+
entries.append(entry)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f" 构建Entry失败 {phase['name']}: {e}")
|
|
222
|
+
return entries
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ============================================================
|
|
226
|
+
# 结构对比功能
|
|
227
|
+
# ============================================================
|
|
228
|
+
|
|
229
|
+
def compare_structures(local_structure, mp_structure,
|
|
230
|
+
tolerance: float = STRUCTURE_TOLERANCE) -> Dict:
|
|
231
|
+
"""比较两个晶体结构的相似度"""
|
|
232
|
+
from pymatgen.analysis.structure_matcher import StructureMatcher
|
|
233
|
+
matcher = StructureMatcher(ltol=tolerance, stol=tolerance, angle_tol=10)
|
|
234
|
+
try:
|
|
235
|
+
is_same = matcher.fit(local_structure, mp_structure)
|
|
236
|
+
return {
|
|
237
|
+
'is_same': is_same,
|
|
238
|
+
'local_formula': local_structure.composition.reduced_formula,
|
|
239
|
+
'mp_formula': mp_structure.composition.reduced_formula,
|
|
240
|
+
'local_sites': len(local_structure.sites),
|
|
241
|
+
'mp_sites': len(mp_structure.sites),
|
|
242
|
+
'local_volume': local_structure.volume,
|
|
243
|
+
'mp_volume': mp_structure.volume,
|
|
244
|
+
'volume_diff_percent': abs(local_structure.volume - mp_structure.volume) / mp_structure.volume * 100
|
|
245
|
+
}
|
|
246
|
+
except Exception as e:
|
|
247
|
+
return {
|
|
248
|
+
'is_same': False,
|
|
249
|
+
'error': str(e),
|
|
250
|
+
'local_formula': local_structure.composition.reduced_formula,
|
|
251
|
+
'mp_formula': mp_structure.composition.reduced_formula
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def structure_comparison_report(local_phases: List[Dict], config: PhaseDiagramConfig) -> Dict:
|
|
256
|
+
"""生成结构对比报告"""
|
|
257
|
+
from pymatgen.ext.matproj import MPRester
|
|
258
|
+
from pymatgen.core import Structure
|
|
259
|
+
|
|
260
|
+
report = {
|
|
261
|
+
'total_compared': 0,
|
|
262
|
+
'matching': 0,
|
|
263
|
+
'different': 0,
|
|
264
|
+
'not_found_in_mp': 0,
|
|
265
|
+
'details': []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if not config.api_key:
|
|
269
|
+
print(" ! 无API密钥,跳过结构对比")
|
|
270
|
+
return report
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
mpr = MPRester(api_key=config.api_key)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
print(f" ! 无法连接Materials Project: {e}")
|
|
276
|
+
return report
|
|
277
|
+
|
|
278
|
+
for phase in local_phases:
|
|
279
|
+
formula = phase['formula']
|
|
280
|
+
report['total_compared'] += 1
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
mp_entries = mpr.get_entries(formula, inc_structure=True)
|
|
284
|
+
if not mp_entries:
|
|
285
|
+
report['not_found_in_mp'] += 1
|
|
286
|
+
report['details'].append({
|
|
287
|
+
'formula': formula,
|
|
288
|
+
'status': 'not_found_in_mp',
|
|
289
|
+
'message': f'在MP数据库中未找到 {formula}'
|
|
290
|
+
})
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
local_structure = Structure.from_file(phase['path'] + "/POSCAR")
|
|
294
|
+
mp_entry = mp_entries[0]
|
|
295
|
+
mp_structure = mp_entry.structure
|
|
296
|
+
|
|
297
|
+
comparison = compare_structures(local_structure, mp_structure)
|
|
298
|
+
comparison['formula'] = formula
|
|
299
|
+
comparison['local_path'] = phase['path']
|
|
300
|
+
|
|
301
|
+
if comparison['is_same']:
|
|
302
|
+
report['matching'] += 1
|
|
303
|
+
comparison['status'] = 'matching'
|
|
304
|
+
else:
|
|
305
|
+
report['different'] += 1
|
|
306
|
+
comparison['status'] = 'different'
|
|
307
|
+
|
|
308
|
+
report['details'].append(comparison)
|
|
309
|
+
|
|
310
|
+
except Exception as e:
|
|
311
|
+
report['not_found_in_mp'] += 1
|
|
312
|
+
report['details'].append({
|
|
313
|
+
'formula': formula,
|
|
314
|
+
'status': 'error',
|
|
315
|
+
'message': str(e)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return report
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ============================================================
|
|
322
|
+
# 工具函数
|
|
323
|
+
# ============================================================
|
|
324
|
+
|
|
325
|
+
def get_all_element_combinations(elements: List[str], min_size: int = 2) -> List:
|
|
326
|
+
"""获取元素的所有组合(从 min_size 到全部)"""
|
|
327
|
+
result = []
|
|
328
|
+
for r in range(min_size, len(elements) + 1):
|
|
329
|
+
for combo in combinations(elements, r):
|
|
330
|
+
result.append(combo)
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def load_database(config: PhaseDiagramConfig) -> Dict:
|
|
335
|
+
"""加载本地相数据库"""
|
|
336
|
+
Path(config.unified_phases_dir).mkdir(parents=True, exist_ok=True)
|
|
337
|
+
if config.database_file.exists():
|
|
338
|
+
with open(config.database_file, 'r') as f:
|
|
339
|
+
return json.load(f)
|
|
340
|
+
return {"phases": {}, "download_history": [], "element_systems": {}}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def save_database(config: PhaseDiagramConfig, db: Dict):
|
|
344
|
+
"""保存本地相数据库"""
|
|
345
|
+
Path(config.unified_phases_dir).mkdir(parents=True, exist_ok=True)
|
|
346
|
+
with open(config.database_file, 'w') as f:
|
|
347
|
+
json.dump(db, f, indent=2, sort_keys=True)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_entries_from_mp(config: PhaseDiagramConfig, elements: List[str]):
|
|
351
|
+
"""从 Materials Project 下载条目"""
|
|
352
|
+
from pymatgen.ext.matproj import MPRester
|
|
353
|
+
mpr = MPRester(api_key=config.api_key)
|
|
354
|
+
return mpr.get_entries_in_chemsys(elements)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_system_name(elements: List[str]) -> str:
|
|
358
|
+
"""获取体系名称(排序后的元素用下划线连接)"""
|
|
359
|
+
return "_".join(sorted(elements))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def print_banner():
|
|
363
|
+
"""打印启动横幅"""
|
|
364
|
+
print("""
|
|
365
|
+
╔═══════════════════════════════════════════════════════════════════════════╗
|
|
366
|
+
║ 相图与化学势热图统一生成器 v5.0 (chemphase) ║
|
|
367
|
+
║ Phase Diagram & Chemical Potential Heatmap Generator ║
|
|
368
|
+
╚═══════════════════════════════════════════════════════════════════════════╝
|
|
369
|
+
""")
|
chemphase/diagrams.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""相图生成模块:二元/三元成分相图 + Hull 连线"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib
|
|
5
|
+
matplotlib.use('Agg')
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from scipy.spatial import Delaunay
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
|
|
11
|
+
import plotly.graph_objects as go
|
|
12
|
+
from pymatgen.analysis.phase_diagram import PhaseDiagram
|
|
13
|
+
|
|
14
|
+
from chemphase.plotting import (
|
|
15
|
+
coord_to_cartesian, allocate_colors, calculate_label_positions,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ============================================================
|
|
19
|
+
# 绘图参数
|
|
20
|
+
# ============================================================
|
|
21
|
+
MARKER_SIZE = 25
|
|
22
|
+
MARKER_LINE_WIDTH = 2
|
|
23
|
+
TITLE_FONT_SIZE = 20
|
|
24
|
+
LABEL_BACKGROUND = 'rgba(255,255,255,0.9)'
|
|
25
|
+
|
|
26
|
+
BINARY_FIG_WIDTH = 12
|
|
27
|
+
BINARY_FIG_HEIGHT = 10
|
|
28
|
+
BINARY_LABEL_FONT_SIZE = 14
|
|
29
|
+
BINARY_XLABEL_FONT_SIZE = 14
|
|
30
|
+
BINARY_YLABEL_FONT_SIZE = 14
|
|
31
|
+
BINARY_HULL_LINE_COLOR = "gray"
|
|
32
|
+
BINARY_HULL_LINE_WIDTH = 1.5
|
|
33
|
+
BINARY_DPI = 150
|
|
34
|
+
|
|
35
|
+
TERNARY_FIG_WIDTH = 1000
|
|
36
|
+
TERNARY_FIG_HEIGHT = 900
|
|
37
|
+
TERNARY_LABEL_FONT_SIZE = 14
|
|
38
|
+
TERNARY_TITLE_FONT_SIZE = 20
|
|
39
|
+
TERNARY_HULL_LINE_COLOR = "gray"
|
|
40
|
+
TERNARY_HULL_LINE_WIDTH = 1.5
|
|
41
|
+
TERNARY_SHOW_HULL = True
|
|
42
|
+
TERNARY_LABEL_MARGIN = 0.12
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ============================================================
|
|
46
|
+
# 二元成分相图
|
|
47
|
+
# ============================================================
|
|
48
|
+
|
|
49
|
+
def generate_binary_composition_diagram(entries, elements: Tuple[str, str],
|
|
50
|
+
output_dir: Path, title_suffix: str = "") -> bool:
|
|
51
|
+
"""生成二元成分相图(ΔE vs 成分,含 Hull 连线)
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
entries : list of pymatgen PDEntry
|
|
56
|
+
热力学条目列表
|
|
57
|
+
elements : tuple of (str, str)
|
|
58
|
+
两种元素符号
|
|
59
|
+
output_dir : Path
|
|
60
|
+
输出目录
|
|
61
|
+
title_suffix : str
|
|
62
|
+
标题后缀(如 "MP数据" 或 "本地数据")
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
bool : 是否成功生成
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
pd = PhaseDiagram(entries)
|
|
70
|
+
phases = []
|
|
71
|
+
base_energy = None
|
|
72
|
+
|
|
73
|
+
for entry in pd.stable_entries:
|
|
74
|
+
comp_dict = entry.composition.as_dict()
|
|
75
|
+
formula = entry.composition.reduced_formula
|
|
76
|
+
total = sum(comp_dict.values())
|
|
77
|
+
x = comp_dict.get(elements[0], 0) / total if total > 0 else 0.5
|
|
78
|
+
e_per_atom = pd.get_hull_energy(entry.composition) / entry.composition.num_atoms
|
|
79
|
+
|
|
80
|
+
if base_energy is None or e_per_atom < base_energy:
|
|
81
|
+
base_energy = e_per_atom
|
|
82
|
+
|
|
83
|
+
phases.append({
|
|
84
|
+
'formula': formula, 'x': x, 'e_per_atom': e_per_atom,
|
|
85
|
+
'comp_dict': comp_dict
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
for phase in phases:
|
|
89
|
+
phase['delta_e'] = phase['e_per_atom'] - base_energy
|
|
90
|
+
|
|
91
|
+
phases = allocate_colors(phases)
|
|
92
|
+
fig, ax = plt.subplots(figsize=(BINARY_FIG_WIDTH, BINARY_FIG_HEIGHT))
|
|
93
|
+
|
|
94
|
+
ax.set_xlim(-0.05, 1.05)
|
|
95
|
+
y_max = max(p['delta_e'] for p in phases) if phases else 0.5
|
|
96
|
+
y_min = min(min(p['delta_e'] for p in phases), -0.05) if phases else -0.1
|
|
97
|
+
ax.set_ylim(y_min - 0.15, y_max + 0.5)
|
|
98
|
+
ax.set_xlabel(f'Composition (x in {elements[0]}$_{{1-x}}${elements[1]}$_x$)',
|
|
99
|
+
fontsize=BINARY_XLABEL_FONT_SIZE)
|
|
100
|
+
ax.set_ylabel('ΔE (eV/atom)', fontsize=BINARY_YLABEL_FONT_SIZE)
|
|
101
|
+
ax.spines['top'].set_visible(False)
|
|
102
|
+
ax.spines['right'].set_visible(False)
|
|
103
|
+
|
|
104
|
+
# Hull 连线(Delaunay 三角剖分)
|
|
105
|
+
if len(phases) >= 3:
|
|
106
|
+
points = np.array([[p['x'], p['delta_e']] for p in phases])
|
|
107
|
+
try:
|
|
108
|
+
tri = Delaunay(points)
|
|
109
|
+
for simplex in tri.simplices:
|
|
110
|
+
for i in range(3):
|
|
111
|
+
ax.plot([points[simplex[i], 0], points[simplex[(i + 1) % 3], 0]],
|
|
112
|
+
[points[simplex[i], 1], points[simplex[(i + 1) % 3], 1]],
|
|
113
|
+
'-', color=BINARY_HULL_LINE_COLOR, linewidth=BINARY_HULL_LINE_WIDTH)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# 标签避让
|
|
118
|
+
occupied = []
|
|
119
|
+
sorted_phases = sorted(phases, key=lambda p: p['delta_e'])
|
|
120
|
+
for phase in sorted_phases:
|
|
121
|
+
best_y_offset = 0.05
|
|
122
|
+
for y_offset_test in np.arange(0.02, 0.25, 0.02):
|
|
123
|
+
overlaps = any(abs(ey - (phase['delta_e'] + y_offset_test)) < 0.05
|
|
124
|
+
for (ex, ey, ef) in occupied)
|
|
125
|
+
if not overlaps:
|
|
126
|
+
best_y_offset = y_offset_test
|
|
127
|
+
break
|
|
128
|
+
phase['label_y_offset'] = best_y_offset
|
|
129
|
+
occupied.append((phase['x'], phase['delta_e'] + best_y_offset, phase['formula']))
|
|
130
|
+
|
|
131
|
+
for phase in phases:
|
|
132
|
+
ax.plot(phase['x'], phase['delta_e'], 'o', markersize=MARKER_SIZE / 3,
|
|
133
|
+
color=phase['color'])
|
|
134
|
+
ax.annotate(phase['formula'],
|
|
135
|
+
(phase['x'], phase['delta_e'] + phase['label_y_offset']),
|
|
136
|
+
xytext=(0, 5), textcoords='offset points',
|
|
137
|
+
fontsize=BINARY_LABEL_FONT_SIZE,
|
|
138
|
+
color=phase['color'], fontweight='bold', ha='center',
|
|
139
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='white',
|
|
140
|
+
alpha=0.8, edgecolor=phase['color']))
|
|
141
|
+
|
|
142
|
+
system_str = f"{elements[0]}-{elements[1]}"
|
|
143
|
+
suffix = f" ({title_suffix})" if title_suffix else ""
|
|
144
|
+
ax.set_title(f'{system_str}{suffix}', fontsize=TITLE_FONT_SIZE, fontweight='bold')
|
|
145
|
+
plt.tight_layout()
|
|
146
|
+
|
|
147
|
+
filename = f"binary_{elements[0]}-{elements[1]}_phase.png"
|
|
148
|
+
filepath = output_dir / filename
|
|
149
|
+
plt.savefig(str(filepath), dpi=BINARY_DPI, bbox_inches='tight')
|
|
150
|
+
plt.close('all')
|
|
151
|
+
print(f" + {filename}")
|
|
152
|
+
return True
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f" - {elements[0]}-{elements[1]}: {e}")
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ============================================================
|
|
159
|
+
# 三元成分相图
|
|
160
|
+
# ============================================================
|
|
161
|
+
|
|
162
|
+
def draw_triangle_boundary(fig, elems: Tuple[str, str, str]):
|
|
163
|
+
"""绘制三元相图的三角形边界"""
|
|
164
|
+
triangle_x = [0, 1, 0.5, 0]
|
|
165
|
+
triangle_y = [0, 0, np.sqrt(3) / 2, 0]
|
|
166
|
+
fig.add_trace(go.Scatter(
|
|
167
|
+
x=triangle_x, y=triangle_y, mode='lines',
|
|
168
|
+
line=dict(color='black', width=4), name='边界', hoverinfo='skip'
|
|
169
|
+
))
|
|
170
|
+
fig.add_trace(go.Scatter(
|
|
171
|
+
x=[0, 1, 0.5], y=[-0.12, -0.12, np.sqrt(3) / 2 + 0.14],
|
|
172
|
+
mode='text', text=[f"<b>{elems[0]}</b>", f"<b>{elems[1]}</b>", f"<b>{elems[2]}</b>"],
|
|
173
|
+
textfont=dict(size=24, color='black'), name='元素标签', hoverinfo='skip'
|
|
174
|
+
))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def draw_hull_lines(fig, phases: List[dict]):
|
|
178
|
+
"""绘制三元相图的 Hull 连线(Delaunay 三角剖分)"""
|
|
179
|
+
if not TERNARY_SHOW_HULL:
|
|
180
|
+
return
|
|
181
|
+
points = np.array([[p['x'], p['y']] for p in phases])
|
|
182
|
+
if len(points) < 3:
|
|
183
|
+
return
|
|
184
|
+
tri = Delaunay(points)
|
|
185
|
+
hull_x, hull_y = [], []
|
|
186
|
+
for simplex in tri.simplices:
|
|
187
|
+
for i in range(3):
|
|
188
|
+
hull_x.extend([points[simplex[i], 0], points[simplex[(i + 1) % 3], 0], np.nan])
|
|
189
|
+
hull_y.extend([points[simplex[i], 1], points[simplex[(i + 1) % 3], 1], np.nan])
|
|
190
|
+
fig.add_trace(go.Scatter(
|
|
191
|
+
x=hull_x, y=hull_y, mode='lines',
|
|
192
|
+
line=dict(color=TERNARY_HULL_LINE_COLOR, width=TERNARY_HULL_LINE_WIDTH),
|
|
193
|
+
name='Hull连线', hoverinfo='skip'
|
|
194
|
+
))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def draw_phases(fig, phases: List[dict]):
|
|
198
|
+
"""在三元相图上绘制相点和标签"""
|
|
199
|
+
for phase in phases:
|
|
200
|
+
fig.add_trace(go.Scatter(
|
|
201
|
+
x=[phase['x']], y=[phase['y']], mode='markers',
|
|
202
|
+
marker=dict(size=MARKER_SIZE, color=phase['color'],
|
|
203
|
+
line=dict(color='white', width=MARKER_LINE_WIDTH)),
|
|
204
|
+
name=phase['formula'],
|
|
205
|
+
hovertemplate=f"<b>{phase['formula']}</b><br>E = {phase['e_per_atom']:.4f} eV/atom",
|
|
206
|
+
showlegend=False
|
|
207
|
+
))
|
|
208
|
+
fig.add_annotation(
|
|
209
|
+
x=phase['label_x'], y=phase['label_y'],
|
|
210
|
+
text=f"<b>{phase['formula']}</b>", showarrow=False,
|
|
211
|
+
font=dict(size=TERNARY_LABEL_FONT_SIZE, color=phase['color']),
|
|
212
|
+
bgcolor=LABEL_BACKGROUND, bordercolor=phase['color'],
|
|
213
|
+
borderwidth=1, borderpad=3, xref='x', yref='y'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def generate_ternary_composition_diagram(entries, elements: Tuple[str, str, str],
|
|
218
|
+
output_dir: Path, title_suffix: str = "") -> bool:
|
|
219
|
+
"""生成三元成分相图(Gibbs 三角图,含 Hull 连线和自动避让标签)
|
|
220
|
+
|
|
221
|
+
Parameters
|
|
222
|
+
----------
|
|
223
|
+
entries : list of pymatgen PDEntry
|
|
224
|
+
热力学条目列表
|
|
225
|
+
elements : tuple of (str, str, str)
|
|
226
|
+
三种元素符号
|
|
227
|
+
output_dir : Path
|
|
228
|
+
输出目录
|
|
229
|
+
title_suffix : str
|
|
230
|
+
标题后缀
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
bool : 是否成功生成
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
pd = PhaseDiagram(entries)
|
|
238
|
+
phases = []
|
|
239
|
+
|
|
240
|
+
for entry in pd.stable_entries:
|
|
241
|
+
comp_dict = entry.composition.as_dict()
|
|
242
|
+
formula = entry.composition.reduced_formula
|
|
243
|
+
x, y = coord_to_cartesian(comp_dict, elements)
|
|
244
|
+
e_per_atom = pd.get_hull_energy(entry.composition) / entry.composition.num_atoms
|
|
245
|
+
phases.append({
|
|
246
|
+
'formula': formula, 'x': x, 'y': y, 'e_per_atom': e_per_atom,
|
|
247
|
+
'comp_dict': comp_dict
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
phases = allocate_colors(phases)
|
|
251
|
+
phases = calculate_label_positions(phases, TERNARY_LABEL_MARGIN)
|
|
252
|
+
|
|
253
|
+
fig = go.Figure()
|
|
254
|
+
draw_triangle_boundary(fig, elements)
|
|
255
|
+
draw_hull_lines(fig, phases)
|
|
256
|
+
draw_phases(fig, phases)
|
|
257
|
+
|
|
258
|
+
system_str = "-".join(elements)
|
|
259
|
+
suffix = f" ({title_suffix})" if title_suffix else ""
|
|
260
|
+
fig.update_layout(
|
|
261
|
+
title=dict(text=f"<b>{system_str}{suffix}</b><br><sup>{len(phases)} phases</sup>",
|
|
262
|
+
font=dict(size=TERNARY_TITLE_FONT_SIZE), x=0.5, xanchor='center'),
|
|
263
|
+
xaxis=dict(range=[-0.2, 1.2], showgrid=False, zeroline=False, showticklabels=False),
|
|
264
|
+
yaxis=dict(range=[-0.25, 1.1], showgrid=False, zeroline=False, showticklabels=False,
|
|
265
|
+
scaleanchor='x', scaleratio=1),
|
|
266
|
+
plot_bgcolor='white', width=TERNARY_FIG_WIDTH, height=TERNARY_FIG_HEIGHT
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
filename = f"ternary_{elements[0]}-{elements[1]}-{elements[2]}_phase.png"
|
|
270
|
+
filepath = output_dir / filename
|
|
271
|
+
try:
|
|
272
|
+
fig.write_image(str(filepath), scale=2)
|
|
273
|
+
except Exception:
|
|
274
|
+
fig.write_html(str(filepath).replace('.png', '.html'))
|
|
275
|
+
plt.close('all')
|
|
276
|
+
print(f" + {filename}")
|
|
277
|
+
return True
|
|
278
|
+
except Exception as e:
|
|
279
|
+
print(f" - {elements[0]}-{elements[1]}-{elements[2]}: {e}")
|
|
280
|
+
return False
|
chemphase/plotting.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""绘图辅助模块:坐标变换、标签定位、颜色分配、核心绘图函数"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib
|
|
5
|
+
matplotlib.use('Agg')
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from scipy.spatial import Delaunay
|
|
8
|
+
from typing import Dict, List, Tuple
|
|
9
|
+
|
|
10
|
+
# ============================================================
|
|
11
|
+
# 全局绘图参数
|
|
12
|
+
# ============================================================
|
|
13
|
+
COLOR_PALETTE = [
|
|
14
|
+
"#FF0000", "#FF8800", "#FFDD00", "#00FF00", "#00FFCC", "#00BBFF",
|
|
15
|
+
"#0066FF", "#8800FF", "#FF00AA", "#FF0044", "#AAFF00", "#00FF88",
|
|
16
|
+
"#00DDFF", "#4400FF", "#DD00FF", "#4400FF", "#FF3333", "#FF9933",
|
|
17
|
+
"#FFEE33", "#33FF33", "#33FFCC", "#33CCFF", "#3388FF", "#9933FF",
|
|
18
|
+
"#FF33AA", "#FF3388", "#99FF33", "#33FF99", "#33EEFF", "#5533FF",
|
|
19
|
+
"#EE33FF", "#FF5533", "#FF6666", "#FFAA66", "#FFFF66", "#66FF66",
|
|
20
|
+
"#66FFCC", "#66DDFF", "#66AAFF", "#AA66FF", "#FF66CC", "#AAFF66",
|
|
21
|
+
"#66FFAA", "#66EEFF", "#6644FF", "#FF66FF", "#FF6644", "#FFAAAA",
|
|
22
|
+
"#FFCCAA", "#FFFFAA", "#AAFFAA", "#AAFFCC", "#AAEEFF", "#AACCFF",
|
|
23
|
+
"#CCAAFF", "#FFAAEE", "#CCFFAA", "#CC0000", "#CC6600", "#CCCC00",
|
|
24
|
+
"#00CC00", "#00CCCC", "#0099CC", "#0033CC", "#6600CC", "#CC0099",
|
|
25
|
+
"#CC0033", "#99CC00", "#00CC66",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# ============================================================
|
|
29
|
+
# 三元相图坐标变换
|
|
30
|
+
# ============================================================
|
|
31
|
+
|
|
32
|
+
def coord_to_cartesian(comp_dict: Dict, elems: Tuple[str, str, str]) -> Tuple[float, float]:
|
|
33
|
+
"""将三元成分坐标转换为笛卡尔坐标(等边三角形底边朝下)"""
|
|
34
|
+
total = sum(comp_dict.values())
|
|
35
|
+
if total == 0:
|
|
36
|
+
return 0.5, 0.5
|
|
37
|
+
fracs = {e: comp_dict.get(e, 0) / total for e in elems}
|
|
38
|
+
# elems[0] 在左下角, elems[1] 在右下角, elems[2] 在顶部
|
|
39
|
+
x = fracs.get(elems[1], 0) + 0.5 * fracs.get(elems[2], 0)
|
|
40
|
+
y = (np.sqrt(3) / 2) * fracs.get(elems[2], 0)
|
|
41
|
+
return x, y
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ============================================================
|
|
45
|
+
# 标签自动避让算法
|
|
46
|
+
# ============================================================
|
|
47
|
+
|
|
48
|
+
def find_best_label_position(px: float, py: float, occupied: List[Tuple[float, float, str]],
|
|
49
|
+
margin: float = 0.12) -> Tuple[str, float, float]:
|
|
50
|
+
"""在三元相图中为标签找到最佳位置(避免与其他标签重叠)"""
|
|
51
|
+
positions = [
|
|
52
|
+
('top left', -0.12, 0.10), ('top right', 0.12, 0.10),
|
|
53
|
+
('bottom left', -0.12, -0.08), ('bottom right', 0.12, -0.08),
|
|
54
|
+
('middle left', -0.15, 0), ('middle right', 0.15, 0),
|
|
55
|
+
('top center', 0, 0.12), ('bottom center', 0, -0.10),
|
|
56
|
+
]
|
|
57
|
+
best_pos, best_x, best_y = 'top left', px - 0.12, py + 0.10
|
|
58
|
+
best_dist = -1
|
|
59
|
+
for pos_name, ox, oy in positions:
|
|
60
|
+
test_x, test_y = px + ox, py + oy
|
|
61
|
+
if test_y < -0.05 or test_y > 0.95:
|
|
62
|
+
continue
|
|
63
|
+
if test_x < -0.05 or test_x > 1.05:
|
|
64
|
+
continue
|
|
65
|
+
min_dist = min(((test_x - pox) ** 2 + (test_y - poy) ** 2) ** 0.5
|
|
66
|
+
for (pox, poy, _) in occupied) if occupied else 999
|
|
67
|
+
if min_dist > best_dist:
|
|
68
|
+
best_dist = min_dist
|
|
69
|
+
best_pos, best_x, best_y = pos_name, test_x, test_y
|
|
70
|
+
return best_pos, best_x, best_y
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def calculate_label_positions(phases: List[Dict], margin: float = 0.12) -> List[Dict]:
|
|
74
|
+
"""为所有相计算最佳标签位置"""
|
|
75
|
+
occupied = []
|
|
76
|
+
sorted_phases = sorted(phases, key=lambda p: p['y'])
|
|
77
|
+
for phase in sorted_phases:
|
|
78
|
+
pos, lx, ly = find_best_label_position(phase['x'], phase['y'], occupied, margin)
|
|
79
|
+
phase['label_pos'] = pos
|
|
80
|
+
phase['label_x'] = lx
|
|
81
|
+
phase['label_y'] = ly
|
|
82
|
+
occupied.append((lx, ly, phase['formula']))
|
|
83
|
+
return phases
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ============================================================
|
|
87
|
+
# 颜色分配
|
|
88
|
+
# ============================================================
|
|
89
|
+
|
|
90
|
+
def allocate_colors(phases: List[Dict]) -> List[Dict]:
|
|
91
|
+
"""为每个相分配颜色(循环使用调色板)"""
|
|
92
|
+
for i, phase in enumerate(phases):
|
|
93
|
+
phase['color'] = COLOR_PALETTE[i % len(COLOR_PALETTE)]
|
|
94
|
+
return phases
|
chemphase/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chemphase
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 相图与化学势热图统一生成器 — 一键生成二元/三元成分相图、化学势热图,支持 Materials Project API 和本地 VASP 数据
|
|
5
|
+
Author: Phase Diagram Generator Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/user/chemphase
|
|
8
|
+
Project-URL: Repository, https://github.com/user/chemphase
|
|
9
|
+
Project-URL: Issues, https://github.com/user/chemphase/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: pymatgen>=2023.0.0
|
|
22
|
+
Requires-Dist: doped>=3.2.0
|
|
23
|
+
Requires-Dist: matplotlib>=3.5.0
|
|
24
|
+
Requires-Dist: numpy>=1.21.0
|
|
25
|
+
Requires-Dist: scipy>=1.7.0
|
|
26
|
+
Requires-Dist: plotly>=5.0.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# chemphase
|
|
34
|
+
|
|
35
|
+
**相图与化学势热图统一生成器 v5.0**
|
|
36
|
+
|
|
37
|
+
[](https://python.org)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
一键从 Materials Project API 或本地 VASP 计算结果生成高质量相图。
|
|
41
|
+
|
|
42
|
+
## 功能
|
|
43
|
+
|
|
44
|
+
- **API 模式**:从 Materials Project 数据库下载热力学数据
|
|
45
|
+
- **本地模式**:读取本地 VASP 计算目录 (POSCAR + vasprun.xml/OUTCAR)
|
|
46
|
+
- **混合模式**:本地数据 + API 补充
|
|
47
|
+
- **二元成分相图**:ΔE vs 成分,含 Hull 连线、自动标签避让
|
|
48
|
+
- **三元成分相图**:Gibbs 三角图 (Plotly 交互式),含 Hull 三角剖分
|
|
49
|
+
- **结构对比**:检测本地计算与 MP 数据库的晶体结构差异
|
|
50
|
+
|
|
51
|
+
## 安装
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install chemphase
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 快速开始
|
|
58
|
+
|
|
59
|
+
### API 模式(默认)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# 使用默认元素 Cu-Ag-O-Se
|
|
63
|
+
chemphase
|
|
64
|
+
|
|
65
|
+
# 指定元素
|
|
66
|
+
chemphase --elements Li O Co
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 本地 VASP 数据模式
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
chemphase --local /path/to/vasp/calculations --elements Cu Ag O Se
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 结构对比
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
chemphase --local /path/to/vasp/calculations --elements Cu Ag O Se --compare-structure
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 依赖
|
|
82
|
+
|
|
83
|
+
- [pymatgen](https://pymatgen.org) — 材料分析库
|
|
84
|
+
- [doped](https://github.com/SMTG-Bham/doped) — 缺陷计算工具
|
|
85
|
+
- [matplotlib](https://matplotlib.org) — 二维绘图
|
|
86
|
+
- [plotly](https://plotly.com) — 交互式三元图
|
|
87
|
+
|
|
88
|
+
## API 密钥
|
|
89
|
+
|
|
90
|
+
API 模式需要 Materials Project API 密钥。设置环境变量:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
export MATERIALS_PROJECT_API_KEY=你的密钥
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
获取密钥:https://materialsproject.org/api
|
|
97
|
+
|
|
98
|
+
## 命令行参数
|
|
99
|
+
|
|
100
|
+
| 参数 | 说明 | 默认值 |
|
|
101
|
+
|------|------|--------|
|
|
102
|
+
| `--elements` | 元素列表 | `Cu Ag O Se` |
|
|
103
|
+
| `--local` | 本地VASP目录 | — |
|
|
104
|
+
| `--output` | 输出目录 | `phase_diagrams_output` |
|
|
105
|
+
| `--eah` | E above hull 阈值 | `0.05` eV/atom |
|
|
106
|
+
| `--compare-structure` | 启用结构对比 | `False` |
|
|
107
|
+
| `--debug` | 调试模式 | `False` |
|
|
108
|
+
|
|
109
|
+
## 许可证
|
|
110
|
+
|
|
111
|
+
MIT License — 详见 [LICENSE](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
chemphase/__init__.py,sha256=p9VbMzSHeJ7-DtW7BD37OIvtHvgSQN92qH-G_Jr2-lQ,1150
|
|
2
|
+
chemphase/__main__.py,sha256=4rL1MjgAJg4D9r8hlsuw07V9i3s1Tq9fhiJg_3z1Z0U,138
|
|
3
|
+
chemphase/cli.py,sha256=wqu0ICrxFyBA-gy41ji-F77GwJitCaqL55PjHwN2MMA,7493
|
|
4
|
+
chemphase/core.py,sha256=tloHRlBPta45vvA4rgGGrPy_HQzx55LCyG31Trep2YE,13331
|
|
5
|
+
chemphase/diagrams.py,sha256=ebpLwlQb6x5BE-oVh6Gh8yBnQ7pLMgHLBB9D41JLC64,10742
|
|
6
|
+
chemphase/plotting.py,sha256=vJYvCnj9t5uC7bUE2EZ4bYX7Pp-bqZ-8_pca_lsYI1Y,4088
|
|
7
|
+
chemphase/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
chemphase-0.1.0.dist-info/licenses/LICENSE,sha256=1SF_moV1CtyB-g2v-Yo3V1_l5jVV0SN7CGjT_yWD0O0,1084
|
|
9
|
+
chemphase-0.1.0.dist-info/METADATA,sha256=vSAioALKtA95jqeZ7C6KTaowvOsTEmew7PotYynDxys,3334
|
|
10
|
+
chemphase-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
chemphase-0.1.0.dist-info/entry_points.txt,sha256=6Z2apB54PumucA2xQ9DuE0R4u0i7yYBZKjC6NC1JCBw,49
|
|
12
|
+
chemphase-0.1.0.dist-info/top_level.txt,sha256=sqMrf27Atz_bzynj8ZzufVEXl_JtlldAdfy1ImMtSuc,10
|
|
13
|
+
chemphase-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Phase Diagram Generator Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chemphase
|