多分散体系¶
从目标分子量分布到可直接用于 LAMMPS 模拟的盒子:链采样、链构建、连接点重新类型化、填装、导出。
先决条件
本指南需要 RDKit、Packmol 和 oplsaa.xml 力场。读者应已了解逐步聚合物构建。
从分布到模拟盒子¶
大多数聚合物呈多分散性,而非单分散。本工作流程从目标分子量分布出发,依次完成显式采样、链构建和填装,最终输出一个可直接用于模拟的盒子。
本指南中的体系是苯乙烯(Sty,80 mol%)与丙烯酸甲酯(MA,20 mol%)通过可控自由基聚合得到的统计共聚物。对应的 GBigSMILES 如下:
CCOC(=O)C(C)(C){[>][<|8|]CC([>|8|])c1ccccc1, [<|2|]CC([>|2|])C(=O)OC [<]}|schulz_zimm(1400,1500)|[Br].|5e5|
描述多分散性的三个汇总统计量是数均分子量 Mn、重均分子量 Mw 以及分散度 PDI = Mw/Mn。单分散样品的 PDI 为 1.0。
类型化以单体为单位,而非以链为单位¶
每个单体独立解析、扩展为带氢原子的 3D 结构,再逐一分配力场类型。链生长过程中,构建器在各个连接点处执行增量重新类型化,组装完成后不需要再对整个链重新类型化。
import molpy as mp
from molpy.core.element import Element
from molpy.typifier import OplsTypifier
ff = mp.io.read_xml_forcefield("oplsaa.xml")
typifier = OplsTypifier(ff, strict_typing=False)
BIGSMILES = {
"Sty": "{[][<]CC(c1ccccc1)[>][]}",
"MA": "{[][<]CC(C(=O)OC)[>][]}",
}
def build_typed_monomer(bigsmiles, typifier):
monomer = mp.parser.parse_monomer(bigsmiles)
monomer = mp.adapter.RDKitAdapter(monomer).generate_3d(add_hydrogens=True, optimize=False)
monomer = monomer.get_topo(gen_angle=True, gen_dihe=True)
monomer = typifier.typify(monomer)
return monomer
library = {label: build_typed_monomer(bs, typifier) for label, bs in BIGSMILES.items()}
monomer_mass = {}
for label, mon in library.items():
mass = sum(Element(a.get("element")).mass for a in mon.atoms)
ports = [a.get("port") for a in mon.atoms if a.get("port")]
monomer_mass[label] = mass
print(f"{label}: atoms={len(mon.atoms)}, mass={mass:.1f}, ports={ports}")
2026-06-30 21:08:42,748 - molpy.io.forcefield.xml - INFO - Using built-in force field: /Users/roykid/work/molcrafts/molpy/src/molpy/data/forcefield/oplsaa.xml
2026-06-30 21:08:42,753 - molpy.io.forcefield.xml - INFO - Parsing force field: OPLS-AA v0.1.0
2026-06-30 21:08:42,753 - molpy.io.forcefield.xml - INFO - Combining rule: geometric
2026-06-30 21:08:42,759 - molpy.io.forcefield.xml - INFO - Parsed 825 atom types
2026-06-30 21:08:42,760 - molpy.io.forcefield.xml - INFO - Parsed 307 bond types (OPLS-AA with unit conversion)
2026-06-30 21:08:42,763 - molpy.io.forcefield.xml - INFO - Parsed 964 angle types (OPLS-AA with unit conversion)
2026-06-30 21:08:42,765 - molpy.io.forcefield._rb_opls - WARNING - RB coefficients do not lie on the ideal 4-term OPLS manifold (C0+C1+C2+C3+C4 = 10.041600, expected ≈ 0). Conversion will preserve forces and relative energies exactly, but will introduce a constant energy offset of ΔE = 10.041600 kJ/mol. This does not affect MD simulations.
2026-06-30 21:08:42,767 - molpy.io.forcefield.xml - INFO - Parsed 1089 dihedral types (OPLS-AA with unit conversion)
2026-06-30 21:08:42,769 - molpy.io.forcefield.xml - INFO - Parsed 825 nonbonded parameters (OPLS-AA with unit conversion)
2026-06-30 21:08:42,769 - molpy.io.forcefield.xml - INFO - Parsed 825 atom types (by type)
Sty: atoms=24, mass=112.2, ports=['<', '>']
MA: atoms=14, mass=88.1, ports=['<', '>']
采样从统计分布中抽取链长¶
采样层包含三个组件,彼此可自由组合。WeightedSequenceGenerator 控制单体摩尔比(此处为 80:20)。PolydisperseChainGenerator 为每条链从所选分布中抽取聚合度或分子量。SystemPlanner 依次累加链,直到总质量达到目标值(偏差由 max_rel_error 控制)。下面同时展示四种分布,方便在下一节中对比形状差异。
import numpy as np
from molpy.builder.polymer import (
SchulzZimmPolydisperse,
UniformPolydisperse,
PoissonPolydisperse,
FlorySchulzPolydisperse,
WeightedSequenceGenerator,
PolydisperseChainGenerator,
SystemPlanner,
)
distributions = {
"Schulz-Zimm": SchulzZimmPolydisperse(Mn=1400, Mw=1500, random_seed=42),
"Uniform": UniformPolydisperse(min_dp=8, max_dp=22, random_seed=42),
"Poisson": PoissonPolydisperse(lambda_param=14, random_seed=42),
"Flory-Schulz": FlorySchulzPolydisperse(a=0.08, random_seed=42),
}
seq_gen = WeightedSequenceGenerator(monomer_weights={"Sty": 8.0, "MA": 2.0})
target_total_mass = 5e5
results = {}
for name, dist in distributions.items():
chain_gen = PolydisperseChainGenerator(
seq_generator=seq_gen,
monomer_mass=monomer_mass,
end_group_mass=0.0,
distribution=dist,
)
planner = SystemPlanner(
chain_generator=chain_gen,
target_total_mass=target_total_mass,
max_rel_error=0.02,
)
plan = planner.plan_system(np.random.default_rng(42))
results[name] = plan.chains
for name, chains in results.items():
mw = np.array([c.mass for c in chains])
Mn = float(np.mean(mw))
Mw = float(np.sum(mw**2) / np.sum(mw))
print(f"{name:15s}: {len(chains):4d} chains, Mn={Mn:.0f}, PDI={Mw / Mn:.3f}")
Schulz-Zimm : 374 chains, Mn=1338, PDI=1.076
Uniform : 312 chains, Mn=1609, PDI=1.086
Poisson : 334 chains, Mn=1500, PDI=1.073
Flory-Schulz : 193 chains, Mn=2597, PDI=1.619
可视化采样系综的分布形状¶
下面四个面板将采样直方图与理论曲线叠加显示。Schulz-Zimm 分布以连续概率密度函数呈现;其余三种以聚合度的概率质量函数呈现。垂直虚线标出每个系综的 Mn 和 Mw。
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
# ── colour palette ──
CLR_HIST = "#6baed6" # steel blue – sampled histogram
CLR_EDGE = "#3182bd" # darker blue – histogram edge
CLR_THEO = "#e6550d" # orange – theoretical curve
CLR_MN = "#31a354" # green – Mn line
CLR_MW = "#de2d26" # red – Mw line
CLR_BOX = "#f7f7f7" # near-white – annotation box
def annotate_stats(ax, Mn, Mw, PDI, n_chains):
txt = "\n".join(
[
rf"$M_n = {Mn:.0f}$ g/mol",
rf"$M_w = {Mw:.0f}$ g/mol",
rf"PDI $= {PDI:.3f}$",
rf"$N = {n_chains}$",
]
)
ax.text(
0.97,
0.97,
txt,
transform=ax.transAxes,
ha="right",
va="top",
fontsize=6.5,
linespacing=1.4,
family="monospace",
bbox=dict(
boxstyle="round,pad=0.35",
facecolor=CLR_BOX,
edgecolor="0.75",
alpha=0.95,
linewidth=0.6,
),
)
fig, axes = plt.subplots(2, 2, figsize=(7.5, 6), constrained_layout=True)
for idx, (ax, (name, chains)) in enumerate(zip(axes.flatten(), results.items())):
dist_obj = distributions[name]
mw = np.array([c.mass for c in chains])
dps = np.array([c.dp for c in chains])
Mn = float(np.mean(mw))
Mw = float(np.sum(mw**2) / np.sum(mw))
PDI = Mw / Mn
if isinstance(dist_obj, SchulzZimmPolydisperse):
# ── continuous: Freedman–Diaconis bins ──
iqr = np.subtract(*np.percentile(mw, [75, 25]))
bw = max(2.0 * iqr / len(mw) ** (1 / 3), 20)
bins = np.arange(mw.min() - bw, mw.max() + 2 * bw, bw)
ax.hist(
mw,
bins=bins,
density=True,
color=CLR_HIST,
edgecolor=CLR_EDGE,
linewidth=0.5,
alpha=0.55,
)
M_grid = np.linspace(max(0, mw.min() * 0.3), mw.max() * 1.3, 500)
ax.plot(
M_grid,
dist_obj.mass_pdf(M_grid),
color=CLR_THEO,
linewidth=1.6,
label="Theory",
)
ax.axvline(Mn, color=CLR_MN, ls="--", lw=1, label=r"$M_n$")
ax.axvline(Mw, color=CLR_MW, ls="--", lw=1, label=r"$M_w$")
ax.set_xlabel(r"Molecular weight $M$ (g mol$^{-1}$)", fontsize=8)
ax.set_ylabel("Probability density", fontsize=8)
else:
# ── discrete: unit-width bars centred on integers ──
dp_min, dp_max = int(dps.min()), int(dps.max())
counts = np.bincount(dps)[dp_min:]
freq = counts / (counts.sum() or 1)
x_bar = np.arange(dp_min, dp_min + len(counts))
ax.bar(
x_bar,
freq,
width=1.0,
align="center",
color=CLR_HIST,
edgecolor=CLR_EDGE,
linewidth=0.5,
alpha=0.55,
)
# Theory curve extends to 99th-percentile of the sample
if isinstance(dist_obj, UniformPolydisperse):
support = np.arange(dp_min, dp_max + 1)
else:
hi = max(dp_max, int(np.percentile(dps, 99) * 1.15))
support = np.arange(max(1, dp_min), hi + 1)
pmf = dist_obj.dp_pmf(support)
ax.plot(
support,
pmf,
"-o",
color=CLR_THEO,
markersize=2.2,
linewidth=1.3,
markeredgewidth=0,
label="Theory",
)
avg_mass = float(np.mean(mw / dps))
ax.axvline(Mn / avg_mass, color=CLR_MN, ls="--", lw=1, label=r"$M_n$")
ax.axvline(Mw / avg_mass, color=CLR_MW, ls="--", lw=1, label=r"$M_w$")
# Clip x-axis so long tails don't crush the peak
x_hi = int(np.percentile(dps, 99.5)) + 2
ax.set_xlim(max(0, dp_min - 1), x_hi)
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xlabel(r"Degree of polymerization $n$", fontsize=8)
ax.set_ylabel("Probability", fontsize=8)
ax.set_title(name, fontsize=9.5, fontweight="semibold", pad=6)
ax.tick_params(labelsize=7)
ax.spines[["top", "right"]].set_visible(False)
annotate_stats(ax, Mn, Mw, PDI, len(chains))
if idx == 0:
ax.legend(fontsize=7, loc="upper left", framealpha=0.85)
plt.savefig("05_polydisperse_distributions.png", dpi=200, bbox_inches="tight")
plt.show()
自由基加成:无离去基团的单体偶联¶
这里使用的反应是自由基加成:每次连接时,从每个主链碳上移除一个氢原子,形成一条新的 C–C 键。这与之前指南中的脱水缩合不同——没有羟基离去基团,只从两侧移除氢原子。
将类型器传给 PolymerBuilder,由它在每个偶联步骤中对连接点原子执行增量重新类型化。构建过程结束后不再进行全链类型化;只有紧邻新键的原子会被重新类型化。
from molpy.builder.polymer import (
Connector,
CovalentSeparator,
LinearOrienter,
Placer,
PolymerBuilder,
)
from molpy.reacter import (
Reacter,
form_single_bond,
select_hydrogens,
select_self,
)
rxn = Reacter(
name="addition",
anchor_selector_left=select_self,
anchor_selector_right=select_self,
leaving_selector_left=select_hydrogens(1),
leaving_selector_right=select_hydrogens(1),
bond_former=form_single_bond,
)
rules = {(l, r): (">", "<") for l in library for r in library}
connector = Connector(port_map=rules, reacter=rxn)
placer = Placer(
separator=CovalentSeparator(buffer=-0.1),
orienter=LinearOrienter(),
)
builder = PolymerBuilder(
library=library,
connector=connector,
placer=placer,
typifier=typifier, # incremental re-typification at junctions
)
sz_chains = results["Schulz-Zimm"]
atomistic_chains = []
n_chains = 10 # truncated for this tutorial; use len(sz_chains) for a production run
for i, chain in enumerate(sz_chains[:n_chains]):
labels = " ".join(f"[#{m}]" for m in chain.monomers)
cgsmiles = "{" + labels + "}"
result = builder.build(cgsmiles)
atomistic_chains.append(result.polymer)
if (i + 1) % 5 == 0:
print(f" built {i + 1}/{len(sz_chains)} chains ...")
total_atoms = sum(len(c.atoms) for c in atomistic_chains)
print(f"built {len(atomistic_chains)} chains, total atoms: {total_atoms}")
填装和导出遵循与先前指南相同的模式¶
盒子尺寸由总分子量和目标密度确定。每条链作为独立目标(计数为 1)添加到填装器中,填装后的帧连同力场一起写入 LAMMPS data 文件。
from pathlib import Path
from molpy.pack import InsideBoxConstraint, Packmol
total_mw = sum(
sum(Element(a.get("element")).mass for a in c.atoms) for c in atomistic_chains
)
target_density = 0.05 # g/cm^3 (use ~1.0 for production)
volume = (total_mw / 6.022e23) / target_density * 1e24
box_length = volume ** (1 / 3)
packer = Packmol(workdir=Path("05_output/packmol"))
constraint = InsideBoxConstraint(
length=np.array([box_length] * 3),
origin=np.zeros(3),
)
for chain in atomistic_chains:
packer.def_target(chain.to_frame(), number=1, constraint=constraint)
packed = packer(max_steps=10000, seed=42)
packed.box = mp.Box.cubic(length=box_length)
mp.io.write_lammps_system("05_output/lammps", packed, ff)
print(f"packed: {packed['atoms'].nrows} atoms, box: {box_length:.1f} A")
/Users/roykid/work/molcrafts/molpy/src/molpy/io/data/lammps.py:607: UserWarning: 'impropers' block has 25 entries but no 'type' column; these are untyped topology (a relation kind the force field does not parameterize) and are omitted from the LAMMPS data file.
counts = self._get_counts(frame)
引擎从导出数据组装可运行的输入脚本¶
写入数据文件只完成了一半任务。要实际运行模拟,LAMMPS 还需要一个输入脚本,说明如何读取数据文件、启用哪些力场样式以及执行什么协议。MolPy 通过 LAMMPSEngine 建模这个过程,它将 Script 对象与子进程管理整合在一起。
Script 是一个可编辑的行列表,支持以编程方式构建并保存到磁盘,无需实际执行。这种设计的好处是:正式运行前可以检查、修改和版本控制脚本。一切就绪后,调用 engine.run() 将脚本写入工作目录并启动 lmp -in input.lmp -log log.lammps -screen none。
下面的代码为填装后的系统构建了一个最小化的 OPLS-AA 平衡协议。力场样式必须与 write_lammps_system 写入的样式保持一致——harmonic 键和角、opls 二面角、lj/cut/coul/long 非键相互作用——因为 LAMMPS 在读取数据文件时会验证样式一致性。
from molpy.core.script import Script
from molpy.engine import LAMMPSEngine
# Build the LAMMPS input script line-by-line.
# Script.from_text() dedents and normalises the block.
lmp_script = Script.from_text(
name="input",
language="other",
text="""
# Polydisperse PS/PMA system — generated by MolPy
units real
atom_style full
read_data lammps.data
include lammps.ff
pair_style lj/cut/coul/long 12.0
pair_modify mix arithmetic tail yes
kspace_style pppm 1e-4
bond_style harmonic
angle_style harmonic
dihedral_style opls
improper_style cvff
# Energy minimisation before dynamics
minimize 1.0e-4 1.0e-6 10000 100000
timestep 1.0
thermo 1000
thermo_style custom step temp press etotal
# NVT equilibration at 300 K
fix nvt all nvt temp 300.0 300.0 100.0
run 100000
""",
)
# Save the script alongside the data files without launching LAMMPS.
# check_executable=False lets the call succeed in notebooks where lmp
# may not be on PATH.
engine = LAMMPSEngine("lmp", check_executable=False)
script_path = lmp_script.save("05_output/lammps/input.lmp")
print("Input script written to:", script_path)
print(lmp_script.preview(max_lines=12))
# To run the simulation, replace the two lines above with:
# result = engine.run(lmp_script, workdir="05_output/lammps")
# print("Exit code:", result.returncode)
Input script written to: 05_output/lammps/input.lmp
1 |
2 | # Polydisperse PS/PMA system — generated by MolPy
3 | units real
4 | atom_style full
5 |
6 | read_data lammps.data
7 | include lammps.ff
8 |
9 | pair_style lj/cut/coul/long 12.0
10 | pair_modify mix arithmetic tail yes
11 | kspace_style pppm 1e-4
12 |
... (15 more lines)
GBigSMILES 将整个工作流编码在一个字符串中¶
只要重复单元、对应权重、分布参数和目标质量已知,就可以将整个规范写成一个 GBigSMILES 字符串。解析器从该表示法中直接提取单体身份、随机权重、分布参数和系统质量,从而使字符串同时具备自文档化和可移植性。
gbigsmiles = (
"CCOC(=O)C(C)(C)" # initiator
"{[>]"
"[<|8|]CC([>|8|])c1ccccc1," # styrene (80 mol%)
" [<|2|]CC([>|2|])C(=O)OC" # methyl acrylate (20 mol%)
" [<]}"
"|schulz_zimm(1400,1500)|" # Mn=1400, Mw=1500
"[Br]" # end group
".|5e5|" # total system mass
)
ir = mp.parser.parse_gbigsmiles(gbigsmiles)
print(f"molecules: {len(ir.molecules)}, total_mass: {ir.total_mass}")
故障排除¶
| 步骤 | 检查项 |
|---|---|
| 单体质量错误 | 验证单体在质量计算前是否包含显式氢原子 |
| SystemPlanner 总质量偏差 | 检查 max_rel_error 设置 |
| 链拓扑缺失 | 在类型化之前调用 get_topo(gen_angle=True, gen_dihe=True) |
| 连接点处未类型化原子 | 将 typifier 传给 PolymerBuilder 以执行增量重新类型化 |
| 填装失败 | 降低目标密度或增加 max_steps |