Skip to content

多分散体系

从目标分子量分布到可直接用于 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()

png

自由基加成:无离去基团的单体偶联

这里使用的反应是自由基加成:每次连接时,从每个主链碳上移除一个氢原子,形成一条新的 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}")
  built 5/374 chains ...
  built 10/374 chains ...
built 10 chains, total atoms: 2080

填装和导出遵循与先前指南相同的模式

盒子尺寸由总分子量和目标密度确定。每条链作为独立目标(计数为 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)
packed: 2080 atoms, box: 71.5 A

引擎从导出数据组装可运行的输入脚本

写入数据文件只完成了一半任务。要实际运行模拟,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}")
molecules: 1, total_mass: 500000.0

故障排除

步骤 检查项
单体质量错误 验证单体在质量计算前是否包含显式氢原子
SystemPlanner 总质量偏差 检查 max_rel_error 设置
链拓扑缺失 在类型化之前调用 get_topo(gen_angle=True, gen_dihe=True)
连接点处未类型化原子 typifier 传给 PolymerBuilder 以执行增量重新类型化
填装失败 降低目标密度或增加 max_steps

参见:拓扑驱动组装交联网络