Python 科研编程教程
1. 定位说明:本教程在科研编程中的角色
计算化学的日常代码,绝大多数是三类脚本,而非大型软件工程:
- 数据搬运与清洗:把 VASP/Gaussian/CP2K/LAMMPS 的输出文本、CSV、JSON 变成结构化的数字。
- 批量流程控制:对几十上百个结构文件做重命名、改参数、批量提交作业、汇总结果。
- 分析与可视化:算完的能量/力/吸附能,整理成表格、画图(配合
matplotlib、numpy、ase、pymatgen)。
本教程的目标是让你写出的脚本具备三个工程品质:
- 可复现:参数从命令行/配置文件传入,而不是写死在代码里。
- 可维护:一个月后回来看还看得懂(命名、类型注解、函数拆分)。
- 健壮:文件缺失、编码错误、计算没收敛时能给出清晰报错,而不是默默产出错误数据。
原则:科研代码的正确性永远排在“优雅”前面。 一个能量取错符号的漂亮脚本,比一个能跑的丑脚本危险一百倍。
2. 环境与基础
2.1 虚拟环境(conda vs venv)
不同项目对依赖版本要求不同(例如 ase 的某版本改了 VASP 接口),永远不要在 base 环境里混装所有包。
# conda 方式(推荐,配合 Miniconda)
conda create -n dft python=3.14 -y
conda activate dft
conda install -c conda-forge numpy scipy pandas matplotlib ase -y
# pip 方式(在已激活的 conda 环境里也可用 pip)
python -m venv .venv
# Windows 激活:
.venv\Scripts\activate
pip install -r requirements.txt
# 导出 / 复现环境(科研复现的命根子)
conda env export > environment.yml # conda 方式
pip freeze > requirements.txt # pip 方式
2.2 运行方式
python script.py # 直接跑脚本
python -i script.py # 跑完进入交互式,变量还留着(调试神器)
python -m pdb script.py # 用内置调试器
python -m script # 把脚本当模块跑(需要脚本在可导入路径下)
在 VS Code 里,用「Run and Debug」打断点比满屏 print 高效得多;print 留给快速探查即可。
2.3 PEP8 风格要点(只讲最常犯的)
- 4 空格缩进,不用 Tab。
- 每行 ≤ 79 字符(VS Code 装
Ruff扩展可自动提示/格式化)。 - 变量/函数用
snake_case,类用PascalCase,常量用UPPER_CASE。 - 导入顺序:标准库 → 第三方 → 本地,组间空一行。
import os
import re
from pathlib import Path
import numpy as np
from my_pkg.vasp import parse_outcar
2.4 Python 3.14 值得知道的新特性
- 模板字符串(PEP 750):
t"..."返回string.templatelib.Template对象而非str,把「模板」和「插值值」分开存储,适合做安全 HTML/SQL 拼接或结构化日志。日常科研用得少,了解即可。
name = "CO2"
tpl = t"吸附物:{name}" # 返回 Template 对象,不是 str
print(tpl.strings) # ('吸附物:', '')
print(tpl.values) # ('CO2',)
- 注解惰性求值(PEP 649):3.14 起函数/类的类型注解默认延迟求值,定义时不会真的去执行注解表达式,前向引用(类还没定义就拿来当注解)不再需要
from __future__ import annotations。注意访问fn.__annotations__时才真正求值。
版本敏感速查:
- 3.10 引入
match结构化匹配、int | str联合类型写法。- 3.11 引入异常组(
ExceptionGroup/except*)、BaseException.add_note()、更清晰的 traceback。- 3.12 增强 f-string(任意表达式、嵌套引号、多行、表达式内注释)。
- 3.13 引入
copy.replace()、自由线程(实验性,默认关闭)。- 3.14 模板字符串、注解惰性求值。
3. 内置数据结构
3.1 list
energies = [-3.1, -3.2, -3.0, -3.15]
energies.append(-3.25) # 追加
energies.extend([-3.3, -3.35]) # 扩展多个
energies.insert(0, -2.9) # 指定位置插入
last = energies.pop() # 弹出并返回末尾
energies.remove(-3.0) # 按值删除(只删第一个)
# 排序(计算化学常用:按能量从低到高 = 数值从小到大)
energies.sort() # 原地排序
ranked = sorted(energies) # 返回新列表
ranked_rev = sorted(energies, reverse=True)
# 切片:取能量最低的三个
lowest3 = sorted(energies)[:3]
3.2 dict(科研里的“万能容器”)
results = {"formula": "Cu(111)", "E_total": -432.1, "converged": True}
# 取值
e = results["E_total"] # 键不存在会 KeyError
e = results.get("E_total") # 不存在返回 None
e = results.get("E_total", 0.0) # 不存在返回默认值(推荐)
# setdefault:键不存在时写入默认值并返回,存在则返回已有值(计数器/分组高频)
counts = {}
for site in ["bridge", "top", "bridge", "hollow", "top"]:
counts[site] = counts.setdefault(site, 0) + 1
# 合并字典(3.9+ 的 | 运算符)
defaults = {"encut": 400, "kpts": [3, 3, 1]}
user = {"encut": 520}
merged = defaults | user # {'encut': 520, 'kpts': [3, 3, 1]}
# 遍历
for k, v in results.items():
print(k, v)
3.3 set(去重、集合运算)
ads_sites_A = {"top", "bridge", "fcc"}
ads_sites_B = {"bridge", "hcp"}
ads_sites_A | ads_sites_B # 并集
ads_sites_A & ads_sites_B # 交集
ads_sites_A - ads_sites_B # 差集
# 去重(元素类型必须可哈希;dict/list 不可哈希)
unique = set([1, 2, 2, 3, 3, 3]) # {1, 2, 3}
3.4 tuple(不可变,常用于坐标、常数打包)
lattice = (3.63, 3.63, 3.63) # 立方晶胞参数
a, b, c = lattice # 解包
# tuple 可作为 dict 键(因为不可变、可哈希)
coords = {(0.0, 0.0, 0.0): "Cu", (0.5, 0.5, 0.5): "O"}
4. 字符串与文件
4.1 格式化(f-string)
formula = "Cu"
E_ads = -1.2345
# 基本
s = f"{formula} 上的吸附能为 {E_ads} eV"
# 精度 / 宽度 / 对齐(写表格输出必备)
s = f"{formula:>6} {E_ads:12.4f}" # 右对齐宽度6;总宽12、4位小数
s = f"{0.001234:.3e}" # 科学计数法
# 3.12+:表达式内可用嵌套引号、注释、多行
s = f"{E_ads:.{2}f}" # 精度也可是表达式
4.2 pathlib(推荐用它取代 os.path)
from pathlib import Path
p = Path("D:/计算/CO2/Cu_111") # 正斜杠在 Windows 下也合法
p = Path("data") / "POSCAR" / "relax" # 用 / 拼路径
p.exists(); p.is_file(); p.is_dir()
p.name # 'relax'
p.stem # 'relax'(去后缀)
p.suffix # ''(无后缀则为空)
p.parent # PosixPath('data/POSCAR')
p.with_suffix(".vasp") # 换后缀
p.resolve() # 绝对路径
# 遍历
for f in Path(".").glob("**/*.cif"): # 递归匹配
print(f)
for f in Path(".").rglob("OUTCAR"): # rglob 等价于 **/OUTCAR
print(f)
# 读写
text = Path("INCAR").read_text(encoding="utf-8")
Path("out.txt").write_text(text, encoding="utf-8")
4.3 读写文本
# 推荐:with 自动关闭;显式指定 encoding(Windows 默认 GBK,见第 13 节)
with open("OUTCAR", encoding="utf-8", errors="replace") as f:
content = f.read() # 一次读完
with open("POSCAR", encoding="utf-8") as f:
lines = f.readlines() # 读成 list
# 逐行读(大文件,避免一次吃满内存)
with open("big.log", encoding="utf-8") as f:
for line in f:
if "TOTEN" in line:
...
4.4 读写 CSV
import csv
# 写:用 csv.writer 处理分隔符/引号转义
with open("results.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["formula", "E_ads_eV", "site"])
w.writerow(["Cu(111)", -1.2345, "bridge"])
# 读
with open("results.csv", encoding="utf-8") as f:
for row in csv.DictReader(f): # 首行作列名,返回 dict
print(row["formula"], row["E_ads_eV"])
4.5 读写 JSON(配置、结果交换通用格式)
import json
data = {"encut": 520, "kpts": [3, 3, 1], "functional": "PBE"}
with open("config.json", "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
with open("config.json", encoding="utf-8") as f:
loaded = json.load(f)
# 注意:JSON 里没有 tuple,会变成 list;没有 NaN(可用 allow_nan=True 写出 NaN/Infinity)
5. 函数
5.1 参数
def calc_E_ads(E_slab_ads, E_slab, E_gas, *, n=1.0, unit="eV"):
"""吸附能 = (E_slab+ads − E_slab − n·E_gas) / n,吸附能越负越稳。"""
return (E_slab_ads - E_slab - n * E_gas) / n
calc_E_ads(-435.1, -430.0, -3.1) # 位置参数
calc_E_ads(-435.1, -430.0, -3.1, n=1.0) # 关键字参数
# * 之后的参数只能以关键字形式传,防止 n 被误当位置参数
def submit(name, *args, **kwargs):
"""*args 收多余位置参数为 tuple;**kwargs 收多余关键字为 dict。"""
print(f"提交任务 {name}")
for a in args:
print("位置参数:", a)
for k, v in kwargs.items():
print(f"参数 {k} = {v}")
submit("relax", 400, 520, kpts=[3, 3, 1], xc="PBE")
# 解包反向操作:submit(*tup, **d)
5.2 默认参数陷阱(经典坑,见第 13 节)
# 错误:默认值在函数定义时只创建一次,list 会被多次调用共享!
def bad(paths=[]):
paths.append("x")
return paths
# 正确:用 None + 在函数体内创建
def good(paths=None):
paths = paths or []
paths.append("x")
return paths
5.3 lambda(短匿名函数)
# 常用于 sorted 的 key
species = [("Cu", 63.5), ("O", 16.0), ("C", 12.0)]
sorted(species, key=lambda x: x[1]) # 按原子量排序
# key= 其实可以直接给函数引用
sorted(species, key=lambda x: x[0])
lambda 只能写单条表达式;复杂逻辑请写
def,别硬塞进 lambda。
5.4 作用域(LEGB)
查找顺序:Local → Enclosing(闭包)→ Global → Builtin。
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local" # 本地赋值,不影响外层
print(x)
inner()
print(x) # enclosing
outer()
# 在函数里修改全局/外层变量需显式声明(科研脚本少用,多传参更清晰)
count = 0
def inc():
global count
count += 1
5.5 类型注解
from typing import Iterable, Optional, Callable
def parse_energies(lines: Iterable[str], scale: float = 1.0) -> list[float]:
"""从若干行里提取能量并缩放。"""
...
def get_ref(system: str) -> Optional[dict]:
"""可能返回 None 的引用数据。"""
...
# 3.10+ 可用 | 代替 Optional
def f(x: int | None) -> int | float:
...
注解只是文档/提示,运行时不强制检查;要真正校验需用
pydantic或手写assert。3.14 起注解惰性求值(见 2.4)。
6. 推导式与迭代器 / 生成器
6.1 推导式
# 列表推导
energies = [float(line.split("=")[1]) for line in lines if "TOTEN" in line]
# 字典推导
label = {"C": "carbon", "O": "oxygen"}
reverse = {v: k for k, v in label.items()}
# 集合推导
unique = {round(e, 3) for e in energies}
# 生成器表达式(圆括号,惰性,不一次性占内存)
gen = (e * 27.2114 for e in energies) # Hartree -> eV
total = sum(gen) # 消费时才计算
何时用生成器表达式而非列表推导?数据量大、且只需要遍历一次时(例如逐条求和)。
6.2 迭代器与生成器(yield)
# 生成器函数:读大文件时按“逻辑块”产出,而不是一次性载入
def iter_convergence(path):
"""逐块产出 OUTCAR 里每次电子步迭代的能量。"""
with open(path, encoding="utf-8") as f:
for line in f:
if "free energy TOTEN" in line:
yield float(line.split("=")[-1].split("eV")[0].strip())
for e in iter_convergence("OUTCAR"):
print(e)
# 判断收敛:最后两个电子步能量差 < 阈值
import itertools
es = iter_convergence("OUTCAR")
last2 = list(itertools.islice(es, -2, None)) # 取最后两个(需先耗尽生成器)
# 更直接:记录所有值再比较
yield让函数变成一个惰性序列:调用时函数体不执行,直到next()/for时才跑到下一个yield并暂停,现场(局部变量)保留。
def countdown(n):
while n > 0:
yield n
n -= 1
c = countdown(3)
print(next(c)) # 3
print(next(c)) # 2
print(list(c)) # [1]
7. 面向对象(科研里封装结构 / 参数很常用)
7.1 类与继承
class Molecule:
"""一个分子/团簇的最小封装。"""
def __init__(self, name, symbols, coords):
self.name = name
self.symbols = symbols
self.coords = coords
def formula(self):
from collections import Counter
c = Counter(self.symbols)
# C1 H2 O1 -> "CH2O" 的顺序按自定义规则
order = ["C", "H", "O", "N", "Cu"]
parts = [el if c[el] == 1 else f"{el}{c[el]}" for el in order if c[el]]
return "".join(parts)
def n_atoms(self):
return len(self.symbols)
mol = Molecule("formaldehyde", ["C", "H", "H", "O"], [[0,0,0],[0,0,1],[1,0,0],[0,1,0]])
print(mol.formula(), mol.n_atoms())
class Slab(Molecule):
"""继承 Molecule,扩展表面模型特有属性。"""
def __init__(self, name, symbols, coords, vacuum=15.0):
super().__init__(name, symbols, coords)
self.vacuum = vacuum
def surface_area(self, a, b):
return a * b
7.2 @property(把方法当属性读,且可加校验)
class Metal:
def __init__(self, name, atomic_mass):
self.name = name
self._mass = atomic_mass
@property
def mass(self):
return self._mass
@mass.setter
def mass(self, value):
if value <= 0:
raise ValueError("原子量必须为正")
self._mass = value
cu = Metal("Cu", 63.5)
print(cu.mass) # 像属性一样读
cu.mass = 64.0 # 走 setter 校验
7.3 dataclass(科研首选:自动生成 __init__/__repr__/__eq__)
from dataclasses import dataclass, field
@dataclass
class DFTJob:
"""一次 DFT 计算任务的全部参数。"""
directory: str
encut: float = 400.0
xc: str = "PBE"
kpts: list = field(default_factory=lambda: [3, 3, 1]) # 可变默认值必须用 default_factory
converged: bool = False
@property
def label(self):
return f"{self.directory}_{self.encut:.0f}"
job = DFTJob("Cu_111_relax", encut=520)
print(job) # 可读的 repr
job2 = DFTJob("Cu_111_relax", encut=520)
print(job == job2) # 自动按字段比较
# 不可变版本:@dataclass(frozen=True) —— 适合当“常量参数包”传遍全场
用
field(default_factory=list)而不是kpts: list = []——这正是第 5.2/13 节可变默认参数陷阱在类层面的翻版。
8. 异常处理与日志
8.1 try / except / finally
try:
energy = parse_energy("OUTCAR")
except FileNotFoundError:
print("OUTCAR 不存在,跳过")
except ValueError as e:
print(f"能量格式错误:{e}")
else:
print("成功解析,无异常") # 仅当 try 无异常时执行
finally:
print("无论是否异常都执行(收尾:关文件、删临时文件)")
捕获要具体(
FileNotFoundError、ValueError),别裸except:吞掉所有错误——那会把 bug 也藏起来。
# 主动抛出 + 附加说明(3.11+)
def require_converged(e_hist, tol=1e-4):
if abs(e_hist[-1] - e_hist[-2]) > tol:
raise RuntimeError(f"未收敛:最后两步能量差 {abs(e_hist[-1]-e_hist[-2]):.6f}")
8.2 自定义异常
class NotConvergedError(RuntimeError):
"""计算未收敛。"""
def __init__(self, system, e_diff):
self.system = system
self.e_diff = e_diff
super().__init__(f"{system} 未收敛,ΔE = {e_diff:.6f} eV")
raise NotConvergedError("Cu_111", 0.0012)
8.3 logging(替代满屏 print 的正规做法)
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("dft")
logger.debug("调试信息(默认不显示)")
logger.info("开始解析 OUTCAR")
logger.warning("k 点网格较稀疏")
logger.error("OUTCAR 缺失,任务失败")
科研脚本里至少做到:错误用
logger.error+ 抛出异常,进度用logger.info,把「机器可解析的输出」和「给人看的日志」分开(人看的走stderr/日志文件,别污染stdout的 CSV)。
9. 装饰器与上下文管理器
9.1 装饰器(给函数包一层“外套”)
import time
import functools
def timed(func):
@functools.wraps(func) # 保留原函数的 __name__/__doc__
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} 用时 {time.perf_counter() - t0:.3f}s")
return result
return wrapper
@timed
def heavy_parse(path):
return sum(1 for _ in open(path, encoding="utf-8"))
heavy_parse("OUTCAR")
# 带参数的装饰器(外面再套一层)
def retry(n=3):
def deco(func):
@functools.wraps(func)
def wrapper(*a, **kw):
for i in range(n):
try:
return func(*a, **kw)
except Exception:
if i == n - 1:
raise
return wrapper
return deco
@retry(3)
def flaky_read(path):
...
9.2 上下文管理器(with)
# 类方式:__enter__ 进入,__exit__ 离开(保证收尾)
class WorkingDir:
"""进入某目录执行,结束自动切回(批量提交常用)。"""
import os
def __init__(self, path):
self.path = path
self._prev = None
def __enter__(self):
self._prev = os.getcwd()
os.chdir(self.path)
return self
def __exit__(self, *exc):
os.chdir(self._prev)
with WorkingDir("Cu_111_relax"):
... # 在此目录下操作
# 生成器方式(更简洁):@contextlib.contextmanager
from contextlib import contextmanager
@contextmanager
def timer(name):
t0 = time.perf_counter()
yield
print(f"{name}: {time.perf_counter() - t0:.3f}s")
with timer("解析"):
heavy_parse("OUTCAR")
with open(...)就是最常见的内置上下文管理器。contextlib.suppress(FileNotFoundError)可优雅地忽略指定异常。
10. 常用标准库速查
import os # os.getcwd/chdir/listdir、os.path 已由 pathlib 取代大部分
import sys # sys.argv、sys.exit(code)、sys.stdin/stdout/stderr
import json # 结构化数据交换
import csv # 表格数据
10.1 collections
from collections import defaultdict, Counter, deque, OrderedDict, namedtuple
# defaultdict:访问不存在的键时用工厂函数生成默认值
d = defaultdict(list)
for site in ["bridge", "top", "bridge"]:
d[site].append(1) # 不用先判断键是否存在
# Counter:计数
c = Counter("formaldehyde")
c.most_common(3) # [('a', 2), ('d', 1), ('e', 1)](示意)
# deque:两端快速增删(当队列用)
q = deque(maxlen=100) # 只保留最近 100 个(滑动窗口,收敛监测常用)
for e in energies:
q.append(e)
# namedtuple:轻量只读“结构体”
Point = namedtuple("Point", ["x", "y", "z"])
p = Point(1.0, 2.0, 3.0)
print(p.x, p.y, p.z)
10.2 itertools
import itertools as it
it.product([0, 1], repeat=3) # 笛卡尔积:超胞组合、网格点
it.combinations([1,2,3], 2) # 组合(选吸附位配对)
it.permutations([1,2,3], 2) # 排列
it.chain(a, b) # 把多个可迭代对象串成一个
it.groupby(data, key=...) # 相邻元素分组(需先按 key 排序!)
it.islice(gen, start, stop) # 切片惰性序列
it.zip_longest(a, b, fillvalue=None) # 不等长 zip
it.count(), it.cycle(), it.repeat() # 无限迭代器(配合 islice 截断)
# 组合扫描示例:吸附位 × 覆盖率 × 泛函
for site, cov, xc in it.product(["bridge", "top"], [0.25, 0.5], ["PBE", "RPBE"]):
print(f"{site} {cov} {xc}")
10.3 functools
import functools as ft
@ft.lru_cache(maxsize=128) # 缓存纯函数结果(重复算能量读取时加速)
def read_energy(path: str) -> float:
return parse_energy(path)
ft.reduce(lambda a, b: a + b, [1,2,3,4]) # 累加
ft.partial(sorted, reverse=True) # 固定部分参数
# functools.cached_property:第一次访问后缓存(配合类使用)
from functools import cached_property
class Job:
@cached_property
def energy(self):
return parse_energy("OUTCAR") # 只解析一次
10.4 argparse(命令行工具必备,见第 12.3)
10.5 glob / re / time
from pathlib import Path
Path(".").glob("*.cif") # pathlib 版本(推荐)
import time
time.time() # 时间戳(秒)
time.perf_counter() # 高精度计时(测性能用这个)
11. 正则表达式 re(解析输出文件必备)
11.1 核心语法
| 模式 | 含义 | 模式 | 含义 |
|---|---|---|---|
. | 任意字符(除换行) | \d | 数字 |
\w | 字母数字下划线 | \s | 空白 |
* | 0+ 次 | + | 1+ 次 |
? | 0/1 次 | {m,n} | m~n 次 |
[abc] | 字符集 | [^abc] | 取反 |
^ | 行首 | $ | 行尾 |
( ) | 捕获组 | (?: ) | 非捕获组 |
| | 或 | (?P<name>) | 命名组 |
import re
line = " free energy TOTEN = -432.12345678 eV"
# 匹配并捕获
m = re.search(r"TOTEN\s*=\s*([-\d.]+)\s*eV", line)
print(m.group(1)) # '-432.12345678'(第一个括号)
print(float(m.group(1))) # -432.12345678
# 命名组更可读
m = re.search(r"TOTEN\s*=\s*(?P<energy>[-\d.]+)\s*eV", line)
print(m.group("energy"))
# findall / finditer:找所有匹配
text = open("OUTCAR").read()
all_e = re.findall(r"TOTEN\s*=\s*([-\d.]+)", text)
for m in re.finditer(r"TOTEN\s*=\s*([-\d.]+)", text):
print(m.start(), m.group(1))
11.2 关键注意点
# 1) 用 r"..." 原始字符串,避免反斜杠被转义
# 2) 贪婪 vs 非贪婪:.* 贪婪,.*? 非贪婪
re.search(r"<tag>.*</tag>", s) # 贪到最后一个 </tag>
re.search(r"<tag>.*?</tag>", s) # 到第一个 </tag>
# 3) 跨行匹配:re.DOTALL 让 . 匹配换行;re.MULTILINE 让 ^$ 匹配每行
re.search(r"TOTAL-FORCE.*?total drift", text, re.DOTALL)
# 4) 预编译(同一正则反复用时更快)
pat = re.compile(r"TOTEN\s*=\s*([-\d.]+)")
pat.findall(text)
# 5) 数字科学计数法:VASP 常用 E+03 形式
re.findall(r"[-\d.Ee+]+", "-.12345678E+03") # 别漏 E/e/+ 符号
正则最大的坑:能匹配对,但格式稍变就漏。写完后拿真实输出文件里 3~5 个不同样本验证;能用
split/strip/in解决就别上正则。
12. 科研实战示例
12.1 批量重命名 / 处理文件
from pathlib import Path
import shutil
root = Path("D:/计算/CO2")
# 把所有子目录里的 POSCAR 复制成带目录名的备份,避免覆盖
for pos in root.rglob("POSCAR"):
tag = "_".join(pos.parent.parts[-2:]) # 用上层目录名做标签
shutil.copy(pos, pos.parent / f"POSCAR_{tag}.bak")
print("备份 ->", pos.parent / f"POSCAR_{tag}.bak")
# 批量改后缀 / 清理
for f in root.rglob("*.cif"):
f.rename(f.with_suffix(".CIF"))
12.2 解析 VASP OUTCAR / OSZICAR 提取能量
import re
from pathlib import Path
def extract_total_energy(outcar: Path) -> float:
"""从 OUTCAR 提取最后一次 'free energy TOTEN'(单位 eV)。"""
text = outcar.read_text(encoding="utf-8", errors="replace")
matches = re.findall(r"free\s+energy\s+TOTEN\s*=\s*([-\d.]+)", text)
if not matches:
raise ValueError(f"{outcar} 中找不到 TOTEN")
return float(matches[-1]) # 取最后一次(最终结构)
def extract_energy_history(outcar: Path) -> list[float]:
"""提取所有电子步能量,用于画收敛曲线 / 判断收敛。"""
text = outcar.read_text(encoding="utf-8", errors="replace")
return [float(x) for x in re.findall(r"free\s+energy\s+TOTEN\s*=\s*([-\d.]+)", text)]
# 用 OSZICAR 的 E0= 作为补充(每电子步一行,更快)
def parse_oszicar(oszicar: Path) -> list[float]:
out = []
for line in oszicar.read_text(encoding="utf-8", errors="replace").splitlines():
m = re.search(r"E0=\s*([-\d.Ee+]+)", line)
if m:
out.append(float(m.group(1)))
return out
if __name__ == "__main__":
e = extract_total_energy(Path("OUTCAR"))
hist = extract_energy_history(Path("OUTCAR"))
print(f"总能 = {e:.6f} eV")
print(f"电子步数 = {len(hist)}")
if len(hist) >= 2:
dE = abs(hist[-1] - hist[-2])
print(f"最后两步能量差 = {dE:.6f} eV", "(已收敛)" if dE < 1e-4 else "(未收敛)")
VASP 能量单位:OUTCAR 默认 eV。转 Hartree 乘
1/27.211386,转 kJ/mol 乘96.485。处理吸附能时务必确认正负号约定(E_ads = E_slab+ads − E_slab − E_gas,负值表示放热吸附)。
12.3 写一个带 argparse 的命令行工具
"""extract_energy.py — 从 OUTCAR 提取能量并可选写 CSV。
用法:
python extract_energy.py OUTCAR
python extract_energy.py OUTCAR -o energy.csv --unit hartree
python extract_energy.py OUTCAR --history
"""
import argparse
import csv
import re
from pathlib import Path
EV2HARTREE = 1 / 27.211386
def parse_args():
p = argparse.ArgumentParser(description="从 VASP OUTCAR 提取总能量")
p.add_argument("outcar", type=Path, help="OUTCAR 文件路径")
p.add_argument("-o", "--output", type=Path, help="写 CSV 到该路径")
p.add_argument("--unit", choices=["eV", "hartree"], default="eV",
help="能量单位(默认 eV)")
p.add_argument("--history", action="store_true", help="输出所有电子步能量")
p.add_argument("--tol", type=float, default=1e-4,
help="收敛判据(最后两步能量差)")
return p.parse_args()
def extract(outcar: Path) -> list[float]:
text = outcar.read_text(encoding="utf-8", errors="replace")
vals = [float(x) for x in re.findall(r"free\s+energy\s+TOTEN\s*=\s*([-\d.]+)", text)]
if not vals:
raise SystemExit(f"错误:{outcar} 里没有 TOTEN 能量")
return vals
def main():
args = parse_args()
if not args.outcar.exists():
raise SystemExit(f"错误:文件不存在 {args.outcar}")
hist = extract(args.outcar)
factor = EV2HARTREE if args.unit == "hartree" else 1.0
if args.history:
for i, e in enumerate(hist, 1):
print(f"{i:4d} {e * factor:.8f} {args.unit}")
else:
total = hist[-1] * factor
print(f"总能量 = {total:.8f} {args.unit}")
if len(hist) >= 2:
dE = abs(hist[-1] - hist[-2])
status = "收敛" if dE < args.tol else "未收敛"
print(f"最后两步 ΔE = {dE:.8f} {args.unit}({status})")
if args.output:
with open(args.output, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["step", f"energy_{args.unit}"])
for i, e in enumerate(hist, 1):
w.writerow([i, e * factor])
print(f"已写入 {args.output}")
if __name__ == "__main__":
main()
13. 常见坑与调试
13.1 可变默认参数
def add(x, memo=[]): # 错!memo 在定义时创建一次,多次调用共享
memo.append(x)
return memo
print(add(1)) # [1]
print(add(2)) # [1, 2] —— 预期 [2],结果被污染了
def add_fixed(x, memo=None):
memo = memo if memo is not None else []
memo.append(x)
return memo
13.2 浅拷贝 vs 深拷贝
import copy
a = [[1, 2], [3, 4]]
b = a.copy() # 浅拷贝:外层新列表,内层仍是同一批 list
b[0][0] = 999
print(a[0][0]) # 999 —— 内层被“共享”改掉了
c = copy.deepcopy(a) # 深拷贝:完全独立
c[0][0] = 0
print(a[0][0]) # 999 —— 不受影响
numpy 数组同理:
b = a只是别名,b = a.copy()才是新副本。改动共享数组是科研脚本里最隐蔽的 bug 来源。
13.3 编码 GBK / UTF-8(Windows 重灾区)
# Windows 中文环境下 open() 默认用 GBK,读 UTF-8 文件会报
# UnicodeDecodeError。显式指定 encoding 是唯一稳妥做法。
with open("data.txt", encoding="utf-8") as f: # 正确
...
# 写中文到文件同理;写 JSON 时加 ensure_ascii=False 让中文直接可读
import json
json.dump({"说明": "吸附能"}, f, ensure_ascii=False)
# 若确实不确定来源编码,用 errors="replace" 防止崩溃,再手动核查
text = open("legacy.dat", encoding="gbk", errors="replace").read()
提醒:Windows 的 bash(如 Git Bash)里用
curl发微信推送中文会因 GBK 出问题,需改用 Python 做 UTF-8 编码(这是本项目已踩过的坑)。
13.4 Windows 路径
# 反斜杠会被当转义:C:\Users 里 \U、\n 都是转义序列
p = "C:\\Users\\33451\\data" # 老式:双反斜杠
p = r"C:\Users\33451\data" # 原始字符串
p = "C:/Users/33451/data" # 正斜杠,Windows 也认,最省心
# 首选 pathlib,自动处理分隔符
from pathlib import Path
p = Path("D:/计算") / "CO2" / "Cu_111"
13.5 调试技巧清单
repr()看字符串的真实内容(能看出隐藏的空格、\r、编码)。- 怀疑文件没读进来:先
print(len(text)),再print(text[:200])。 - 二分定位:在函数入口
print(type(x), x),确认类型对(尤其 str vs bytes、str vs list)。 - 用
python -i保留现场,逐行检查中间变量。 breakpoint()进入 pdb:p 变量打印、n下一行、c继续。- 断言前置条件,把错误尽早暴露:
def parse_energy(text):
assert isinstance(text, str), "输入必须是 str"
...
14. 延伸资源
- 官方文档:
docs.python.org/3/的 Tutorial + Library Reference(标准库的最终权威)。 - 科研库:
ase(Atomic Simulation Environment)—— 读写 POSCAR/CIF、建结构、跑 VASP/LAMMPS 接口。pymatgen—— Materials Project 的 Python 库,结构/相图/高通量分析。numpy/scipy/pandas/matplotlib—— 数值、数据处理与绘图基础四件套。ase.io或pymatgen.io.vasp读 VASP 输出,比自己写正则更稳(但自己写一遍能真正理解格式)。
- 风格与工具:
ruff(lint+format)、black(格式化)、mypy(类型检查)、pytest(测试)。 - 版本变化:
docs.python.org/3/whatsnew/里逐版本 What's New,关注 3.10~3.14 的变化。 - 正则调试:
regex101.com(可视化匹配过程)。 - 建议:把你反复用的解析/提交脚本沉淀成自己的
utils模块,写一次、处处复用;给关键脚本配一个最小pytest测试,防止改一处坏一片。
完。写代码时记住:科研脚本的正确性 > 可读性 > 性能。先让它对,再让它清楚,最后再让它快。
评论交流
欢迎留下你的想法