Building Systems#
The build module purpose is to automate the construction of complex atomistic systems, from simple crystal bulks to intricate solid-liquid interfaces.
Workflow Overview#
Building a simulation-ready system generally follows these three steps:
Blueprint: Define composition and target density with a builder (
SolutionBuilder,GlassBuilder, …). A blueprint describes what to build, not how.Construction: Call the blueprint’s
build()method (or anAtomicSystemmethod such asadd_liquid_layer()) to generate the system, usually via Packmol.Refinement: Assign force-field parameters, apply topology rules, or repair broken bonds as needed.
Note
Every builder that calls Packmol requires the packmol binary to be
installed and available on your $PATH (see Installation).
Construction Examples#
Building a Liquid Solution#
SolutionBuilder is a blueprint for a solution: give it
a target density and either explicit molecule/ion counts or target molar
concentrations, and it automatically balances the rest with water.
from cemd.build import SolutionBuilder
# By explicit counts
blueprint = SolutionBuilder(density=1.04, counts={"Na": 16, "Cl": 16})
# Or by target molar concentration -- converted to counts for the box
# volume when built
blueprint = SolutionBuilder(density=1.04, molarities={"Na": 1.0, "Cl": 1.0})
# Build a standalone box
system = blueprint.build(box=[30, 30, 30])
print(system)
<AtomicSystem with 2693 atoms, 1774 bonds, 887 angles>
Box
a (Å) b (Å) c (Å) α (°) β (°) γ (°)
30 30 30 90 90 90
Atoms
type number mass charge
Cl 16 35.453200 0.000
Hw 1774 1.007947 0.446
Na 16 22.989769 0.000
Ow 887 15.999430 -0.892
Bonds
type number
Hw-Ow 1774
Angles
type number
Hw-Ow-Hw 887
Total charge: 0.000 e
Volume: 27.00 nm³
Density: 1.04 g/cm³
Note
The water template’s own O/H charges come along with it (here from its
built-in .lt file). Na/Cl come from a generated single-atom
template with no assigned charge (0.0) – assign real force-field
charges afterward with
set_ff_from_database() or
set_charges() (see the
force-field guide).
Pure water, or a hemispherical droplet instead of a box, are also available:
# Pure water
water = SolutionBuilder.from_water(density=1.0)
box_of_water = water.build(box=[20, 20, 20])
# A standalone hemispherical droplet
droplet = water.build_hemisphere(radius=15.0, axis="z")
Building a Glass Structure#
GlassBuilder builds an amorphous structure from a
composition given as elements, oxides, or a mix of both:
from cemd.build import GlassBuilder
# By oxides
blueprint = GlassBuilder(
density=2.3,
composition={"SiO2": 3, "Al2O3": 2, "Na2O": 2},
)
glass = blueprint.build(box=[25, 25, 25])
print(glass.density) # close to 2.3 g/cm³
# Equivalently, by elements
blueprint = GlassBuilder.from_elements(
density=2.3,
elements={"Si": 3, "Al": 4, "Na": 4, "O": 14},
)
If the composition is not charge-neutral, GlassBuilder raises a
UserWarning when the blueprint is created (it still builds the
structure).
Building C-S-H Models#
For cementitious materials, CSHBuilder generates
C-S-H structures from a target Calcium/Silicate (Ca/Si) and Water/Silicate
(H2O/Si) ratio, starting from a tobermorite crystal:
from cemd.build import CSHBuilder
builder = CSHBuilder(cs_ratio=1.5, ws_ratio=1.2)
csh_system = builder.build(
supercell=[3, 5, 1], # replication of the base cell
model="tob11a_hamid.cif", # or "tob11a_merlino.cif"
min_mcl=3.0, # minimum mean chain length
)
# The builder keeps an analysis of the built structure
analysis = builder.analyze()
print(f"Real Ca/Si: {analysis['Ca/Si']:.3f}")
print(f"MCL: {analysis['MCL']:.2f}")
print(f"Qⁿ distribution: {analysis['Qn_distribution']}")
CSHBuilder can also wrap an existing C-S-H AtomicSystem (e.g. one
loaded from a file) to analyze or convert it without rebuilding it:
system = AtomicSystem.from_file("csh_structure.data")
builder = CSHBuilder.from_system(system)
analysis = builder.analyze()
C-A-S-H Models (Aluminum Substitution)#
Convert a C-S-H structure to C-A-S-H by substituting a fraction of the bridging silicon atoms with aluminum:
cash_system = builder.to_cash(as_ratio=0.10) # target Al/Si ratio
Note
Substitution targets bridging silicon atoms specifically (matching real Al-for-Si substitution chemistry), so it requires enough of them to be present – a very short or non-periodic silicate chain may have none, in which case the system is returned unchanged.
Building AFt/AFm Structures#
AFBuilder builds AFt (ettringite) or AFm structures
by hydrating a sulfate-containing framework:
from cemd.build import AFBuilder
# AFt (ettringite)
builder = AFBuilder(ws_ratio=10.0)
aft_system = builder.build_aft(supercell=[2, 2, 1])
# AFm
builder = AFBuilder(ws_ratio=8.0)
afm_system = builder.build_afm(supercell=[3, 1, 1])
Creating Surfaces#
SurfaceBuilder cuts crystalline surfaces from a bulk
structure along a chosen crystallographic plane and adds a vacuum layer.
from cemd import AtomicSystem
from cemd.build import SurfaceBuilder
# Load a bulk structure
bulk = AtomicSystem.from_file("tobermorite.cif")
# Generate surfaces cut along the (1, 0, 4) plane
builder = SurfaceBuilder(bulk)
surfaces, shifts, dipoles, broken_bonds = builder.build(
miller_indices=[1, 0, 4],
min_slab_size=25.0, # Å
min_vacuum_size=15.0, # Å
)
# Select the first generated surface
my_surface = surfaces[0]
print(f"Generated {len(surfaces)} surfaces")
print(f"Broken bonds: {broken_bonds}")
Understanding the Parameters#
Miller Indices: Define the cutting plane orientation (
[1,0,0],[1,1,0],[1,1,1], etc.)min_slab_size: Minimum thickness of the material (too thin = bulk properties not representative)
min_vacuum_size: “Gap” in PBC to prevent surface-surface interactions
Broken Bonds: Number of bonds broken during the cut (diagnostic metric);
build()automatically tries increasing tolerances up tomax_broken_bondsuntil it finds a valid cut
Creating Solid/Liquid Interfaces#
Once you have a solid surface (and optionally a SolutionBuilder
blueprint), add liquid directly onto it with
AtomicSystem methods:
from cemd.build import SolutionBuilder
blueprint = SolutionBuilder(density=1.0, counts={"Na": 10, "Cl": 10})
# Add a flat liquid layer on top of the surface (in-place, returns self)
my_surface.add_liquid_layer(
blueprint,
thickness=20.0, # Å
distance=2.0, # gap between solid and liquid
vacuum=10.0, # vacuum above the liquid
)
# Or a hemispherical droplet instead of a flat layer
droplet_blueprint = SolutionBuilder(density=1.0, counts={"Ca": 5, "Cl": 10})
my_surface.add_droplet(
droplet_blueprint,
radius=15.0,
distance=2.0,
)
Adding Custom Structures#
To place any existing AtomicSystem on top of a surface (aligned by
center of mass, at a fixed distance), use
add_structure():
from cemd import AtomicSystem
custom = AtomicSystem.from_file("custom_molecule.pdb")
my_surface.add_structure(
custom,
distance=3.0,
axis="z",
vacuum=15.0,
)
Splitting Systems#
Splitter cuts a system open along an axis, creating a
gap, and can optionally fill that gap with a solution in the same call
(fluent API):
from cemd.build import Splitter, SolutionBuilder
# Simple split: cut at z=15 and open a 20 A gap there
split_system = Splitter(system, coordinate=15.0, axis="z", gap_size=20.0).split()
# Split and fill the gap with a solution
blueprint = SolutionBuilder.from_water(density=1.0)
filled_system = (
Splitter(system, coordinate=15.0, axis="z", gap_size=30.0)
.add_solution(blueprint, padding=2.0)
.split()
)
Any bond stretched across the newly opened gap (and any angle/dihedral/ improper built on it) is automatically removed.
Choosing where to cut#
A cut can also sever contacts that carry no explicit bond – the Si-O
framework under ClayFF/CSHFF is treated as non-bonded, so nothing in the
topology marks it, yet slicing through it leaves broken tetrahedra.
count_broken_bonds() and
scan_broken_bonds() find those contacts
geometrically, from a cutoff table, so you can pick a cut plane that goes
through an interlayer rather than through a silicate sheet:
splitter = Splitter(csh_system, coordinate=0.0, axis="z", gap_size=20.0)
scan = splitter.scan_broken_bonds(step=0.5)
print(scan.head())
coordinate n_broken
0 0.003344 0
1 0.503344 15
2 1.003344 19
3 1.503344 24
4 2.003344 37
# Cut where the structure suffers least
best = scan.loc[scan["n_broken"].idxmin(), "coordinate"]
result = splitter.with_coordinate(best).split()
The cutoff table (bonds_dict) accepts atom types first, elements as
a fallback – the same layered convention as
SurfaceBuilder. Types matter because elements alone
cannot separate structure-bearing oxygens from ones that merely sit
nearby:
# Default table keys on elements, so water O-H counts as breakable too
splitter.count_broken_bonds()
# Framework only: water is free to sit anywhere across the cut
splitter.count_broken_bonds(bonds_dict={("Si", "O"): 1.8})
Repairing what the cut breaks#
When a cut through the framework is unavoidable, repair=True caps the
atoms it leaves under-coordinated: an exposed oxygen is protonated into a
hydroxyl, and an exposed cation gets a hydroxyl group back in the
direction its partner used to occupy.
splitter = Splitter(csh_system, coordinate=7.5, axis="z", gap_size=20.0)
result = splitter.split(repair=True)
print(splitter.repair_report)
{'broken': 66, 'capped': 192, 'skipped': 3}
skipped counts caps that would have landed on top of an existing atom
— a bridging oxygen that loses both its cations would otherwise have
each of them restore a copy of it at the same site; those atoms are left
under-coordinated rather than duplicated.
Note
The added atoms are placed collinearly with the broken contact and
carry a zero charge: this is a starting geometry to be relaxed. Re-run
set_topology() and
set_ff_from_database()
afterwards so the new atoms get proper types and charges.