vibe-sketcherDocumentation
Source code for vibesketcher.document.solid_build
"""Typed build recipes for periodic structures, shared by the SDK, CLI, REST and MCP.
A recipe names one construction step (crystal, cell choice, ...) and its
parameters; `apply_recipe` runs it against a document and returns the new
structure. Recipes are recorded in the structure's provenance, so the
document schema does not change.
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
import numpy as np
from pydantic import BaseModel, ConfigDict, Field
from vibesketcher.chem.frameworks import FrameworkRecipe
from vibesketcher.chem.nanotube_cells import NanotubeRecipe
from vibesketcher.chem.rolled_tubes import RolledNanotubeRecipe
from vibesketcher.document import crystal
from vibesketcher.document.model import Document, PeriodicStructure
from vibesketcher.document.periodic import supercell, with_periodic
_ID = Annotated[str, Field(pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")]
CellName = Literal[
"primitive",
"conventional",
"supercell",
"wigner_seitz",
"wigner_seitz_supercell",
# With the symmetry extra (spglib, #199):
"standard_conventional",
"standard_primitive",
"niggli",
"delaunay",
]
class _Recipe(BaseModel):
model_config = ConfigDict(extra="forbid")
[docs]
class CrystalRecipe(_Recipe):
"""A prototype from the library, or a Bravais type with parameters and a motif."""
kind: Literal["crystal"] = "crystal"
identifier: _ID
prototype: str | None = None
#: A standard crystal from the template library, by name, alias or formula (#231).
template: str | None = None
bravais: str | None = None
#: A space group given by its ITA index, Hermann-Mauguin or Hall symbol, with
#: ``:H``, ``:R``, ``:1`` or ``:2`` for the setting; ``sites`` are then its
#: asymmetric unit (#199).
space_group: str | int | None = None
a: float | None = None
b: float | None = None
c: float | None = None
alpha: float | None = None
beta: float | None = None
gamma: float | None = None
vacuum: float | None = Field(default=None, gt=0)
sites: list[SiteSpec] = Field(default_factory=list, max_length=25000)
operations: list[str] = Field(default_factory=list, max_length=192)
cell: Literal["primitive", "conventional"] = "primitive"
species_map: dict[str, str] = Field(default_factory=dict)
[docs]
class CellRecipe(_Recipe):
"""A cell choice of an existing structure: primitive, conventional, supercell, WS."""
kind: Literal["cell"] = "cell"
identifier: _ID
source: _ID
choice: CellName
matrix: list[list[int]] | None = None
bravais: str | None = None
#: Standard cells: the length tolerance (angstrom) and whether to idealise positions.
symprec: float = Field(default=1e-3, gt=0, le=1)
idealize: bool = False
[docs]
class SlabRecipe(_Recipe):
"""A slab cut from a 3-D bulk structure (#190)."""
kind: Literal["slab"] = "slab"
identifier: _ID
#: The bulk structure in the document; or give ``template`` instead (#232).
source: _ID | None = None
#: A crystal template (see ``crystal_templates``); the slab is cut from its
#: conventional cell, so ``miller`` refers to that cell (hexagonal axes for R).
template: str | None = None
template_a: float | None = Field(default=None, gt=0)
template_c: float | None = Field(default=None, gt=0)
#: Admit a slab with a formal-charge dipole or net charge (binary ionic crystals
#: only); the default refuses polar slabs only when cut from a template.
allow_polar: bool | None = None
miller: tuple[int, int, int]
layers: int = Field(ge=1, le=200)
layer_unit: Literal["stoichiometric", "plane", "cell"] = "stoichiometric"
#: The bottom plane; without it, 0, or for a template slab the first
#: nonpolar termination where polarity is known.
termination: int | None = Field(default=None, ge=0)
vacuum: float = Field(default=15.0, gt=0)
repeat: list[int] | list[list[int]] = Field(default_factory=lambda: [1, 1])
fixed_layers: int = Field(default=0, ge=0)
periodicity: Literal["2d", "3d"] = "2d"
surface_cell: Literal["primitive", "given"] = "primitive"
[docs]
class AdsorbateRecipe(_Recipe):
"""A molecule placed on a slab: from SMILES, a document species or explicit atoms."""
kind: Literal["adsorbate"] = "adsorbate"
identifier: _ID
source: _ID
smiles: str | None = None
species: str | None = None
atoms: list[AtomSpec] = Field(default_factory=list, max_length=500)
site: Literal["top", "bridge", "hollow", "position"] = "top"
anchor: str | None = None
position: tuple[float, float] | None = None
height: float = Field(default=2.5, gt=0)
orientation: Literal["flat", "upright", "as-is"] = "flat"
rotate: float = 0.0
attach: int | None = None
min_distance: float = Field(default=1.2, gt=0)
[docs]
class DefectSpec(_Recipe):
kind: Literal["vacancy", "substitution", "interstitial"]
site: str | None = None
symbol: str | None = None
fractional: tuple[float, float, float] | None = None
[docs]
class DefectRecipe(_Recipe):
"""Vacancies, substitutions and interstitials with an explicit charge and spin (#191)."""
kind: Literal["defect"] = "defect"
identifier: _ID
source: _ID
defects: list[DefectSpec] = Field(min_length=1, max_length=1000)
charge: int | None = None
multiplicity: int | None = Field(default=None, ge=1)
AnyRecipe = (
CrystalRecipe
| CellRecipe
| SlabRecipe
| AdsorbateRecipe
| DefectRecipe
| NanotubeRecipe
| FrameworkRecipe
| RolledNanotubeRecipe
)
Recipe = Annotated[AnyRecipe, Field(discriminator="kind")]
[docs]
def build_crystal(recipe: CrystalRecipe) -> PeriodicStructure:
if recipe.template:
if (recipe.prototype or recipe.bravais or recipe.sites or recipe.operations
or recipe.space_group is not None): # fmt: skip
raise ValueError("give a template on its own, without a prototype, lattice or sites")
if any(getattr(recipe, k) is not None for k in ("b", "alpha", "beta", "gamma", "vacuum")):
raise ValueError("templates take only a and c overrides")
from vibesketcher.document import crystal_templates
return crystal_templates.build(
recipe.template,
identifier=recipe.identifier,
cell=recipe.cell,
a=recipe.a,
c=recipe.c,
species_map=recipe.species_map or None,
)
if recipe.prototype:
if recipe.bravais or recipe.sites or recipe.operations or recipe.space_group is not None:
raise ValueError("give a prototype or a lattice with sites, not both")
if any(getattr(recipe, k) is not None for k in ("b", "alpha", "beta", "gamma")):
raise ValueError("prototypes take only a, c and vacuum overrides")
return crystal.prototype(
recipe.prototype,
identifier=recipe.identifier,
cell=recipe.cell,
a=recipe.a,
c=recipe.c,
vacuum=recipe.vacuum,
species_map=recipe.species_map or None,
)
if recipe.space_group is not None:
if recipe.bravais or recipe.operations:
raise ValueError("give a space group or a Bravais lattice with operations, not both")
if recipe.a is None or not recipe.sites:
raise ValueError("a space-group crystal needs a and its asymmetric-unit sites")
return crystal.from_space_group(
recipe.space_group,
crystal.LatticeParameters(
recipe.a, recipe.b, recipe.c, recipe.alpha, recipe.beta, recipe.gamma
), # fmt: skip
[(s.symbol, s.fractional) for s in recipe.sites],
identifier=recipe.identifier,
cell=recipe.cell,
species_map=recipe.species_map or None,
)
if not recipe.bravais or recipe.a is None or not recipe.sites:
raise ValueError("a custom crystal needs bravais, a and at least one site")
parameters = crystal.LatticeParameters(
recipe.a,
recipe.b,
recipe.c,
recipe.alpha,
recipe.beta,
recipe.gamma,
recipe.vacuum if recipe.vacuum is not None else 20.0,
)
return crystal.build_crystal(
recipe.bravais,
parameters,
[(s.symbol, s.fractional) for s in recipe.sites],
identifier=recipe.identifier,
operations=recipe.operations,
cell=recipe.cell,
species_map=recipe.species_map or None,
)
def _bravais(structure: PeriodicStructure, given: str | None) -> str:
recorded = crystal._builder(structure).get("bravais")
bravais = given or (recorded if isinstance(recorded, str) else None)
if not bravais:
raise ValueError("this structure records no Bravais lattice; pass bravais")
if recorded and given and recorded != given:
raise ValueError(f"the structure was built as {recorded}, not {given}")
crystal.dimensionality(bravais)
return bravais
[docs]
def conventional_cell(
structure: PeriodicStructure, bravais: str, *, identifier: str
) -> PeriodicStructure:
"""Expand a primitive cell to the conventional cell of its Bravais type."""
transform = crystal.primitive_transform(bravais)
lattice = np.linalg.inv(transform) @ crystal.lattice_rows(structure)
inverse = np.linalg.inv(lattice)
centring = crystal.centring_vectors(bravais)
points = [
(site.symbol, np.asarray(structure.cartesian(site)) @ inverse + shift)
for site in structure.sites
for shift in centring
]
unique = crystal._unique_fractional(points, 1e-4, structure.pbc)
if len(unique) != len(structure.sites) * len(centring):
raise ValueError("the primitive cell does not expand cleanly to the conventional cell")
builder = crystal._builder(structure)
builder.update({"cell": "conventional", "bravais": bravais})
builder.pop("primitive_transform", None)
builder.pop("conventional", None)
return crystal._structure(
identifier,
lattice,
unique,
structure.pbc,
"conventional",
builder,
charge=None if structure.charge is None else structure.charge * len(centring),
)
[docs]
def cell_variant(recipe: CellRecipe, source: PeriodicStructure) -> PeriodicStructure:
choice = recipe.choice
if choice in ("standard_conventional", "standard_primitive", "niggli", "delaunay"):
from vibesketcher.document import symmetry
if recipe.matrix is not None:
raise ValueError(f"{choice} takes no matrix")
try:
if choice in ("niggli", "delaunay"):
return symmetry.reduce(source, identifier=recipe.identifier, method=choice)
return symmetry.standardize(
source,
identifier=recipe.identifier,
to="conventional" if choice == "standard_conventional" else "primitive",
idealize=recipe.idealize,
symprec=recipe.symprec,
)
except symmetry.SymmetryUnavailable as error:
raise ValueError(str(error)) from None
if choice in ("supercell", "wigner_seitz_supercell"):
if recipe.matrix is None:
raise ValueError(f"{choice} needs an integer matrix")
result = supercell(source, recipe.matrix, identifier=recipe.identifier)
builder = crystal._builder(source)
builder.update({"cell": "supercell", "matrix": recipe.matrix})
result = result.model_copy(
update={"provenance": {**result.provenance, "builder": crystal._json(builder)}}
)
if choice == "wigner_seitz_supercell":
result = crystal.wigner_seitz_cell(result, identifier=recipe.identifier)
return PeriodicStructure.model_validate(result.model_dump())
if recipe.matrix is not None:
raise ValueError(f"{choice} takes no matrix")
if choice == "wigner_seitz":
return crystal.wigner_seitz_cell(source, identifier=recipe.identifier)
bravais = _bravais(source, recipe.bravais)
if choice == "primitive":
if source.cell_setting == "primitive":
raise ValueError("the structure is already a primitive cell")
return crystal.primitive_cell(source, bravais, identifier=recipe.identifier)
if source.cell_setting == "conventional":
raise ValueError("the structure is already a conventional cell")
return conventional_cell(source, bravais, identifier=recipe.identifier)
def _molecule(document: Document, recipe: AdsorbateRecipe) -> tuple[list[tuple[str, Any]], str]:
given = [x for x in (recipe.smiles, recipe.species, recipe.atoms or None) if x]
if len(given) != 1:
raise ValueError("give exactly one of smiles, species or atoms for the adsorbate")
if recipe.atoms:
return [(a.symbol, a.xyz) for a in recipe.atoms], "atoms"
conformer = None
smiles = recipe.smiles
label = smiles or ""
if recipe.species:
found = {s.id: s for s in document.species}
if recipe.species not in found:
raise ValueError(f"species {recipe.species!r} not found")
species = found[recipe.species]
label = species.label or species.id
if species.conformers:
conformer = species.conformers[0]
smiles = species.smiles
if conformer is None:
if not smiles:
raise ValueError("the species has neither 3D coordinates nor SMILES")
from vibesketcher.chem.embed import embed_3d
conformer = embed_3d(smiles).conformers[0]
return [
(sym, tuple(xyz))
for sym, xyz in zip(conformer.symbols, conformer.xyz_angstrom, strict=True)
], label
def _check_terminations(
made: PeriodicStructure, bulk: PeriodicStructure, recipe: SlabRecipe, *, default: bool
) -> PeriodicStructure:
"""Record the slab's formal-charge polarity and refuse a polar slab unless allowed."""
from vibesketcher.document import slab
allowed = default if recipe.allow_polar is None else recipe.allow_polar
verdict = slab.polarity(made, slab.formal_charges(bulk))
if not allowed and verdict is not None and verdict["polar"]:
raise ValueError(
f"termination {recipe.termination} gives a polar slab (formal charge "
f"{verdict['charge']:g} e, dipole {verdict['dipole_e_per_angstrom']:g} e/A); "
"choose a nonpolar termination from the termination preview, or set allow_polar"
)
if verdict is None:
return made
builder = dict(crystal._builder(made))
builder["polarity"] = verdict
return made.model_copy(update={"provenance": {**made.provenance, "builder": builder}})
def _template_slab(recipe: SlabRecipe) -> PeriodicStructure:
"""Template, conventional bulk and slab in one step; both are recorded (#232)."""
from vibesketcher.document import crystal_templates, slab
if recipe.source is not None:
raise ValueError("give a source structure or a template, not both")
bulk = crystal_templates.build(recipe.template or "", identifier=f"{recipe.identifier}-bulk",
cell="conventional", a=recipe.template_a,
c=recipe.template_c) # fmt: skip
termination = recipe.termination
if termination is None:
preview = slab.terminations(bulk, recipe.miller, layers=recipe.layers,
layer_unit=recipe.layer_unit,
surface_cell=recipe.surface_cell) # fmt: skip
working = [e for e in preview if "error" not in e]
nonpolar = [e["termination"] for e in working
if e.get("polarity") is not None and not e["polarity"]["polar"]] # fmt: skip
if not working:
raise ValueError(f"no termination of this cut gives a slab: {preview[0]['error']}"
if preview else "this cut has no termination") # fmt: skip
termination = nonpolar[0] if nonpolar else working[0]["termination"]
recipe = recipe.model_copy(update={"termination": termination})
made = slab.build_slab(
bulk,
recipe.miller,
identifier=recipe.identifier,
layers=recipe.layers,
layer_unit=recipe.layer_unit,
termination=termination,
vacuum=recipe.vacuum,
repeat=recipe.repeat if isinstance(recipe.repeat[0], list) else tuple(recipe.repeat), # type: ignore[arg-type]
fixed_layers=recipe.fixed_layers,
periodicity=recipe.periodicity,
surface_cell=recipe.surface_cell,
)
made = _check_terminations(made, bulk, recipe, default=False)
builder = dict(crystal._builder(made))
builder["template"] = crystal._builder(bulk)["template"]
builder["source"] = None
provenance = dict(made.provenance)
provenance["builder"] = builder
return made.model_copy(update={"provenance": provenance})
[docs]
def apply_recipe(document: Document, recipe: AnyRecipe) -> PeriodicStructure:
"""Run one recipe; the new identifier must not exist yet."""
from vibesketcher.document import slab
existing = {s.id: s for s in document.periodic_structures}
if recipe.identifier in existing:
raise ValueError(f"periodic structure {recipe.identifier!r} already exists")
if isinstance(recipe, CrystalRecipe):
return build_crystal(recipe)
if isinstance(recipe, NanotubeRecipe):
from vibesketcher.chem.nanotube_cells import build_nanotube_cell
return build_nanotube_cell(recipe)
if isinstance(recipe, FrameworkRecipe):
from vibesketcher.chem.frameworks import build_framework
return build_framework(recipe)
if isinstance(recipe, SlabRecipe) and recipe.template:
return _template_slab(recipe)
if isinstance(recipe, SlabRecipe) and (recipe.template_a or recipe.template_c):
raise ValueError("template_a and template_c need a template")
if recipe.source is None or recipe.source not in existing:
raise ValueError(f"periodic structure {recipe.source!r} not found")
source = existing[recipe.source]
if isinstance(recipe, CellRecipe):
return cell_variant(recipe, source)
if isinstance(recipe, RolledNanotubeRecipe):
from vibesketcher.chem.rolled_tubes import roll_layer
return roll_layer(recipe, source)
if isinstance(recipe, SlabRecipe):
made = slab.build_slab(
source,
recipe.miller,
identifier=recipe.identifier,
layers=recipe.layers,
layer_unit=recipe.layer_unit,
termination=recipe.termination or 0,
vacuum=recipe.vacuum,
repeat=recipe.repeat if isinstance(recipe.repeat[0], list) else tuple(recipe.repeat), # type: ignore[arg-type]
fixed_layers=recipe.fixed_layers,
periodicity=recipe.periodicity,
surface_cell=recipe.surface_cell,
)
return _check_terminations(made, source, recipe, default=True)
if isinstance(recipe, AdsorbateRecipe):
molecule, label = _molecule(document, recipe)
return slab.adsorb(
source,
molecule,
identifier=recipe.identifier,
site=recipe.site,
anchor=recipe.anchor,
position=recipe.position,
height=recipe.height,
orientation=recipe.orientation,
rotate=recipe.rotate,
attach=recipe.attach,
min_distance=recipe.min_distance,
label=label or "adsorbate",
)
return slab.apply_defects(
source,
[d.model_dump(exclude_none=True) for d in recipe.defects],
identifier=recipe.identifier,
charge=recipe.charge,
multiplicity=recipe.multiplicity,
)
[docs]
def build(document: Document, recipe: AnyRecipe) -> Document:
"""Apply a recipe and add its structure to a copy of the document."""
return with_periodic(document, apply_recipe(document, recipe))
[docs]
def parse_recipe(data: dict[str, Any]) -> AnyRecipe:
from pydantic import TypeAdapter
adapter: TypeAdapter[AnyRecipe] = TypeAdapter(Recipe)
return adapter.validate_python(data)