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 SiteSpec(_Recipe): symbol: str fractional: tuple[float, float, float]
[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 AtomSpec(_Recipe): symbol: str xyz: tuple[float, float, float]
[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)