Chain Growth¶
The default packing algorithm treats every molecule as a rigid body: the template's shape is fixed, and the optimizer searches over translations and rotations. That model is right for water, urea, lipids, proteins — species whose conformation is settled before packing begins. For a dense polymer melt it is wrong, and wrong in geometry rather than in numerics: at melt density every point of the box lies inside the pervaded volume of many chains at once, so a valid structure consists of interpenetrating coils. No downhill search over rigid placements reaches such a state — in the PEO benchmark backing this feature, the rigid path stops converging at roughly a third of the real melt density and no amount of extra iterations recovers it. A melt must be grown into place, one torsion at a time, inside the final box.
CbmcGrow is that second algorithm, and it is a peer of the default,
not a preprocessor for it: growth consumes the same radii, tolerance,
and restraints, is judged by the same fdist / frest objective, and
returns the same State. You choose the algorithm by choosing the
engine — GencanPack places rigid bodies, CbmcGrow grows chains — and
every target in that call is handled by it. molpack never infers the
algorithm from the molecule and never silently falls back from one to
the other.
A pack that needs both (a polymer electrolyte, say) is two runs, written
out explicitly: grow the chains, then pack the small molecules around
the grown matrix held fixed. Target.fixed_from(result) is the joint —
see Staging a mixed pack below.
A complete growth pack¶
import molrs
from molpack import CbmcGrow, Target, TorsionPrior
# The template must carry its bond graph — growth consumes the
# template's chemistry, not its shape.
chain = molrs.io.read_pdb("peo_chain.pdb")
prior = TorsionPrior.three_state_from_c_inf(5.5, 1.9106) # PEO C∞, tetrahedral θ
peo = Target(chain, count=25)
result = (
CbmcGrow(prior)
.with_density(0.5) # g/cm³ — the box is cubic, periodic, final-sized
.with_seed(42)
.run([peo], max_loops=60)
)
print(result.converged, result.degraded)
The torsion prior is CbmcGrow's one mandatory constructor argument;
the growth knobs (with_trials, with_retract, with_relax, …) are
builders on the engine, alongside the shared ones (with_density,
with_seed, with_tolerance, …).
A target that cannot be grown — no bond graph, fewer than 3 atoms, a
fixed_at placement, or no box — makes run() raise ValueError
naming the problem and suggesting GencanPack where that is the right
fix.
Staging a mixed pack¶
Growth and rigid placement do not mix inside one call. Run them in order, and turn the first result into a single fixed obstacle for the second:
import molrs
from molpack import CbmcGrow, GencanPack, Target
grown = CbmcGrow(prior).with_density(0.5).with_seed(42).run([peo], max_loops=60)
# Same cell for stage two — read it off the grown frame, or declare the
# lengths you sized the melt to.
cell = molrs.core.Cuboid([0.0, 0.0, 0.0], [l, l, l])
result = (
GencanPack()
.with_seed(42)
.with_periodic_box([0.0, 0.0, 0.0], [l, l, l])
.run([Target.fixed_from(grown), salt.with_restraint(cell)], max_loops=200)
)
Target.fixed_from(result) wraps a whole State as one fixed
target whose coordinates are kept verbatim, so the second run places
only the new species and never disturbs the grown chains.
Branched trees and rings¶
Build the chemistry with molrs (SMILES + conformer) and grow the
architecture with molrs.builder.Assembler — do not invent coordinates. A
4-arm star is a tetrafunctional core plus EO arms (make_star); a
macrocycle is make_ring. Both CbmcGrow and LatticeGrow consume the bond
graph: a tree is legal for either grower; a cycle raises
RingTemplate on both, so pack_ring then picks rigid GencanPack.
python/examples/pack_peo_topo.py pack_star is one explicit pick:
LatticeGrow @ 2.0 Å (occupancy guard on) then caller-side
GencanPack.with_restart @ 2.0 Å — the same lattice-then-push-off
shape as the melt sample below. CbmcGrow remains a peer tree grower;
its reduced-EV (0.6 Å) then 2.0 Å push-off recipe stays on the
CbmcGrow path and is not copied onto LatticeGrow.
python python/examples/pack_peo_linear.py 8 8 0.5 42
python python/examples/pack_peo_mix.py 4 2 4 4 0.5 42
python python/examples/pack_peo_topo.py star 4 8 0.5 42
python python/examples/pack_peo_topo.py ring 6 8 0.4 42
python python/examples/pack_peo_mesh.py 4 4 30 42
python python/examples/pack_peo_void.py frame.data 4 4 42
pack_peo_mix.py puts two topologies in one LatticeGrow.run (linear
Target + 4-arm star Target, density-sized box). pack_peo_mesh.py is
the mesh-cavity scene: attach a molrs.core.Polyhedron read from STL and grow
with LatticeGrow — diamond sites outside the mesh are blocked
(Region ∩ lattice). pack_peo_void.py is the same grow through
~molrs.core.SphereUnion, the solvent-accessible void of a bead-spring frame.
The torsion prior is load-bearing¶
CbmcGrow has exactly one mandatory argument, and it is the one that
decides the physics. Torsion angles are the free variables of growth;
whatever distribution they are drawn from becomes the chain statistics
of the product. Sampling them uniformly gives the freely-rotating chain,
whose characteristic ratio is C∞ = 2.0 — but real PEO melts have
C∞ ≈ 5.5, and since chain size scales as √C∞, uniform sampling
undershoots the melt radius of gyration by about 40%. Crowding at melt
density does not repair this: excluded-volume screening corrects the
scaling exponent, not the prefactor. That is why the prior has no
default — TorsionPrior.uniform() exists as a negative control, and
using it must be a visible decision.
The practical calibration is a single scalar. Given a target
characteristic ratio (from the literature or from ⟨R²⟩₀/M) and the
backbone valence angle, TorsionPrior.three_state_from_c_inf(c_inf,
theta_rad) solves the trans fraction of a trans/gauche± three-state
prior in closed form — for PEO with tetrahedral angles,
three_state_from_c_inf(5.5, 1.9106) yields a trans fraction of about
0.645. When you have a measured torsion distribution instead, pass it
directly as TorsionPrior.states([(angle_rad, weight), ...]); and
TorsionPrior.template(kappa) spreads draws around the template's own
torsion values. Priors are geometric data, never force-field objects —
if you want force-field-derived weights, compute them outside and pass
the numbers in.
Density-sized boxes¶
Growth needs the box to be at its final volume from the first atom:
there is no compression stage, so the target density must hold from the
start. Rather than computing box lengths by hand,
CbmcGrow.with_density(rho) sizes a cubic, fully periodic box from the
total mass of all targets at rho g/cm³. Masses resolve from
element symbols; targets without usable elements (coarse-grained beads)
declare theirs with Target.with_mass(amu). A density combined with an
explicit with_periodic_box, or a mass that cannot be resolved, raises
ValueError — molpack does not guess. An explicit periodic box works
too, if you prefer to control the geometry yourself.
All-atom versus coarse-grained templates¶
Two per-target settings encode the difference between atomistic and CG chains; both default to the all-atom convention. Mixed AA/CG packs are one run — each target carries its own table and its own angle prior.
Bond angles: in an all-atom chain they are stiff coordinates, so the
default AnglePrior.template() copies them verbatim from the template.
In a CG model the angle potential is soft — it is usually fitted to
reproduce a persistence length — so copying template angles would freeze
the chain stiffness at whatever the template happened to be. CG chains
instead use AnglePrior.wlc_from_c_inf(c_inf), a worm-like-chain angle
prior calibrated from the target characteristic ratio (for Kremer–Grest
melts, wlc_from_c_inf(1.76)). The calibration assumes uniform
torsions; combining a WLC angle prior with a non-uniform torsion prior
double-counts stiffness.
Intramolecular skip table: atom pairs close along the chain must be
exempt from the hard core, because their distances are governed by
bonds, angles, and the torsion prior — a hard core applied to 1-4 pairs
would reject every gauche state. The table lives on the target, not the
engine: Target.with_special_bonds([0, 0, 0, 1]) is the default
(Cassandra depth 3: 1-2/1-3/1-4 exempt, 1-5+ scored). CG templates
conventionally use a shallower table — [0, 1] (depth 1) or
[0, 0, 1] (depth 2) — on that target only.
All-atom chains with explicit hydrogen keep the default depth-3 table
and shrink hydrogen with Target.with_atom_radius. Hydrogen's packing
radius is a first-class per-atom setting, not a reason to deepen the
skip table. On the 2026-09-04 dp5 PEO melt, depth 3 plus H = 0.85 Å
finished in 142 rounds / 0.3 s.
h = [i for i, e in enumerate(chain["atoms"]["element"]) if e == "H"]
aa = Target(chain, count=25).with_atom_radius(h, 0.85) # default [0, 0, 0, 1]
cg = Target(beads, count=40).with_special_bonds([0, 0, 1]) # depth 2
result = CbmcGrow(prior).with_density(0.5).run([aa, cg], max_loops=60)
Reading degraded¶
Growth's guarantee is constructive: a candidate placement that violates
the hard core or a restraint is rejected, never penalized, so a
successfully grown structure has fdist == 0 by construction rather
than by convergence. When a region of the box becomes so crowded that a
chain dead-ends repeatedly even after retracting and regrowing, the
solver's last resort is to shrink that chain's hard core — only the
chain that keeps dead-ending softens, every other chain stays at full
contact — and every one of those shrinks increments State.degraded. Each unit therefore records
one relaxation of the constructive guarantee; a grown structure only
counts as converged when the count is zero at full tolerance, and on the
rigid-body path it is always zero.
Softening is not a dead end, but the remedy is something you ask for —
an explicit second stage, never a hidden fallback. Feed the same free
targets to a seeded GencanPack:
grown = CbmcGrow(prior).with_density(0.9).run([peo], max_loops=60)
pushed = GencanPack().with_restart(grown).with_seed(7).run([peo], max_loops=60)
The seeded run continues on the very same state — zero coordinate
conversion — through the GENCAN phases, driving the remaining contact
violations out by rigid-body descent (the classic slow push-off). Each
link reports honestly: the grow result keeps its degraded count so you
can see the guarantee was relaxed, and the seeded run's converged
tells you whether the push-off restored the full tolerance.
Reading intra¶
State.fdist is intermolecular only. Same-copy contacts are
classified into result.intra.scored and result.intra.exempted (Å,
minimum image; an empty class is +∞). Exempted pairs are the ones the
target's special-bonds table skipped (1-2/1-3/1-4 at the default);
scored pairs are intramolecular contacts the hard core was supposed to
keep.
If scored intramolecular contacts dominate after a depth-3 all-atom run
with shrunk hydrogens, the residual is the signal to deepen that
target's table as an escape hatch — with_special_bonds([0, 0, 0, 0, 0, 1])
exempts out to 1-6 — not a reason to change the engine.
What growth delivers — and what it does not¶
The product is a geometric starting structure: no contacts below the declared tolerance, chain statistics governed by your priors, and homogeneous density. It is not an equilibrium Boltzmann ensemble — greedy Rosenbluth selection has a known, mild bias (grown chains come out slightly compact in crowded systems), and equilibration belongs to the downstream MD following the classic generate → push-off → equilibrate pipeline. Validate the product by measuring it: Rg against the unperturbed value, the internal-distance curve, density uniformity — not by expecting equilibrium statistics from a constructor.
Melt density: the lattice grower¶
Past ρ ≈ 0.5 the continuum grower grinds: its candidates are proposed in
continuous space and die by retraction. LatticeGrow maps the box onto a
diamond lattice where the same trans/gauche± RIS states are exact lattice
moves and excluded volume is an O(1) site check — a melt-density system
generates in milliseconds, and the backbone is then seated on the sites the
walk chose:
grown = (
LatticeGrow(prior) # same mandatory torsion prior
.with_seed(42)
.with_density(1.1)
.run([Target(frame, 200)], max_loops=60)
)
pushed = GencanPack().with_restart(grown).with_seed(42).run([Target(frame, 200)], max_loops=200)
A template supplies its topology, not its geometry. The backbone's torsions
are exactly the trans/gauche± the walk drew, its angles and its one bond
length are the lattice's — DiamondLattice::fit sizes that step from the
template's own mean backbone bond, and the cell's commensurability moves it a
percent or two — and only hydrogens and side atoms keep the template's local
geometry. Which atoms are hydrogens is per-target data: element symbol H by
default, or Target.with_hydrogens(indices) for a model that names them
differently ([] puts every atom of a coarse-grained chain on the lattice). The force field downstream sets bonded geometry in its first steps;
it cannot as cheaply undo a chain threaded through a wall, which is what
rebuilding from template internal coordinates costs. Residual contacts
are reported honestly in fdist and belong to the seeded push-off.
This repository's polymer-melt benchmark claim cap is ρ = 1.2 g/cm³; the
lattice sample above uses 1.1. with_density itself has no algorithm
upper bound.