vibe-sketcherDocumentation

Source code for vibesketcher.document.solid_edit

"""Editing periodic structures: cell parameters, selected sites and slab layers.

Every edit returns a new validated `PeriodicStructure` and appends a record to
``provenance["builder"]["edits"]``; by default the edited structure replaces
its source under the same ID. Cell parameters follow the IUCr convention
(a, b, c in angstrom, alpha = angle(b, c), beta = angle(a, c),
gamma = angle(a, b) in degrees). The cell keeps its orientation: the new rows
are the standard rows of the new parameters carried by the same rotation that
maps the old standard rows onto the old cell.
"""

from __future__ import annotations

import math
from collections import Counter
from typing import Annotated, Any, Literal

import numpy as np
from pydantic import BaseModel, ConfigDict, Field

from vibesketcher.document import crystal
from vibesketcher.document.model import Document, PeriodicStructure

_ID = Annotated[str, Field(pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")]
Keep = Literal["fractional", "cartesian"]


[docs] def cell_parameters(structure: PeriodicStructure) -> dict[str, float]: """a, b, c (angstrom), alpha, beta, gamma (degrees) and the volume.""" rows = crystal.lattice_rows(structure) a, b, c = (float(np.linalg.norm(r)) for r in rows) def angle(u: np.ndarray, v: np.ndarray) -> float: cos = float(u @ v / (np.linalg.norm(u) * np.linalg.norm(v))) return math.degrees(math.acos(max(-1.0, min(1.0, cos)))) return { "a": a, "b": b, "c": c, "alpha": angle(rows[1], rows[2]), "beta": angle(rows[0], rows[2]), "gamma": angle(rows[0], rows[1]), "volume": abs(float(np.linalg.det(rows))), }
def _record(structure: PeriodicStructure, edit: dict[str, Any]) -> dict[str, Any]: builder = crystal._builder(structure) edits = list(builder.get("edits") or []) edits.append(crystal._json(edit)) builder["edits"] = edits return {**structure.provenance, "builder": crystal._json(builder)} def _rebuild( structure: PeriodicStructure, identifier: str, lattice: np.ndarray, cartesian: np.ndarray, edit: dict[str, Any], *, symbols: list[str] | None = None, ) -> PeriodicStructure: inverse = np.linalg.inv(lattice) fractional = cartesian @ inverse data = structure.model_dump() data["id"] = identifier data["lattice_angstrom"] = tuple(tuple(float(x) for x in row) for row in lattice) for i, site in enumerate(data["sites"]): site["fractional"] = tuple(crystal._clean(x) for x in fractional[i]) if symbols is not None: site["symbol"] = symbols[i] data["provenance"] = _record(structure, edit) data["source_cif"] = None data["source_model_sha256"] = None edited = PeriodicStructure.model_validate(data) _check_contacts(edited) return edited def _check_contacts(structure: PeriodicStructure, limit: float = 0.5) -> None: """Refuse an edit that puts two atoms (or an atom and its image) within ``limit``.""" lattice = crystal.lattice_rows(structure) xyz = crystal.cartesian(structure) ranges = [(-1, 0, 1) if p else (0,) for p in structure.pbc] shifts = np.asarray([(i, j, k) for i in ranges[0] for j in ranges[1] for k in ranges[2]]) shifts = shifts @ lattice if len(xyz) * len(shifts) * len(xyz) > 4e7: return # too large for the all-pairs check; the builders bound such cells for n, s in enumerate(shifts): delta = xyz[:, None, :] - (xyz[None, :, :] + s) distance = np.linalg.norm(delta, axis=-1) if not np.any(s): np.fill_diagonal(distance, np.inf) i, j = np.unravel_index(int(np.argmin(distance)), distance.shape) if distance[i, j] < limit: raise ValueError( f"the edit puts {structure.sites[i].id} within {distance[i, j]:.2f} A of " f"{structure.sites[j].id}" + (" (periodic image)" if n and np.any(s) else "") )
[docs] def edit_cell( structure: PeriodicStructure, *, identifier: str | 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, scale: float | None = None, vacuum: float | None = None, keep: Keep = "fractional", ) -> PeriodicStructure: """Change lattice lengths and angles, scale the cell, or set a slab's vacuum. ``keep="fractional"`` moves the atoms with the cell (strain); ``"cartesian"`` leaves them in place. Along a nonperiodic direction atoms always keep their physical distance from the middle of that vector, so a layer is never stretched by a vacuum change. ``vacuum`` sets the gap between a 2-D slab's top and its image along the normal. """ old = cell_parameters(structure) new = { k: (v if v is not None else old[k]) for k, v in {"a": a, "b": b, "c": c, "alpha": alpha, "beta": beta, "gamma": gamma}.items() } for key in ("a", "b", "c"): if not (math.isfinite(new[key]) and new[key] > 0): raise ValueError(f"{key} must be a positive length") if scale is not None: if not (math.isfinite(scale) and scale > 0): raise ValueError("scale must be a positive factor") for key, axis in (("a", 0), ("b", 1), ("c", 2)): if structure.pbc[axis]: new[key] *= scale rows = crystal.lattice_rows(structure) xyz = crystal.cartesian(structure) if vacuum is not None: if structure.pbc != (True, True, False): raise ValueError("vacuum applies to 2-D slabs (PBC T, T, F)") normal = np.cross(rows[0], rows[1]) normal /= np.linalg.norm(normal) heights = xyz @ normal thickness = float(np.ptp(heights)) if not (math.isfinite(vacuum) and vacuum > 0): raise ValueError("vacuum must be a positive length") along = float(rows[2] @ normal) new["c"] = (thickness + vacuum) * new["c"] / along old_standard = crystal.cell_from_parameters( old["a"], old["b"], old["c"], old["alpha"], old["beta"], old["gamma"] ) rotation = np.linalg.solve(old_standard, rows) # rows = old_standard @ rotation lattice = ( crystal.cell_from_parameters( new["a"], new["b"], new["c"], new["alpha"], new["beta"], new["gamma"] )
[docs] @ rotation ) if np.linalg.det(lattice) <= 0: raise ValueError("the new parameters do not describe a right-handed cell") fractional = xyz @ np.linalg.inv(rows) cartesian = fractional @ lattice if keep == "fractional" else xyz.copy() # Nonperiodic directions: keep the physical offset from the vector's middle. for axis in range(3): if structure.pbc[axis]: continue old_vec, new_vec = rows[axis], lattice[axis] old_len, new_len = np.linalg.norm(old_vec), np.linalg.norm(new_vec) offset = (fractional[:, axis] - 0.5) * old_len centre_new = cartesian - np.outer((cartesian @ np.linalg.inv(lattice))[:, axis], new_vec) cartesian = centre_new + np.outer(0.5 + offset / new_len, new_vec) edit = { "edit": "cell", "from": {k: round(v, 10) for k, v in old.items() if k != "volume"}, "to": {k: round(v, 10) for k, v in new.items()}, "keep": keep, **({"scale": scale} if scale is not None else {}), **({"vacuum": vacuum} if vacuum is not None else {}), } bravais = crystal._builder(structure).get("bravais") if isinstance(bravais, str) and any( abs(new[k] - old[k]) > 1e-9 for k in ("alpha", "beta", "gamma") ): edit["warning"] = f"angles changed: the cell may no longer be {bravais}" return _rebuild(structure, identifier or structure.id, lattice, cartesian, edit)
def select_sites( structure: PeriodicStructure, *, sites: list[str] | None = None, elements: list[str] | None = None, layers: list[int] | None = None, adsorbate: int | str | None = None, z_min: float | None = None, z_max: float | None = None, ) -> list[str]: """Site IDs matching every given criterion (all sites when none is given). ``layers`` uses the slab builder's layer numbers (0 is the bottom); ``adsorbate`` an index or label of a recorded adsorbate; ``z_min``/``z_max`` Cartesian heights in angstrom. """ builder = crystal._builder(structure) chosen = [s.id for s in structure.sites] known = set(chosen) if sites is not None: unknown = sorted(set(sites) - known) if unknown: raise ValueError(f"sites not in the structure: {', '.join(unknown)}") chosen = [s for s in chosen if s in set(sites)] by_id = {s.id: s for s in structure.sites} if elements: chosen = [s for s in chosen if by_id[s].symbol in set(elements)] if layers is not None: layer_of = builder.get("layer_of_site") if not isinstance(layer_of, dict): raise ValueError("layers apply to structures made by the slab builder") chosen = [s for s in chosen if layer_of.get(s) in set(layers)] if adsorbate is not None: records = builder.get("adsorbates") if not isinstance(records, list) or not records: raise ValueError("this structure records no adsorbate") if isinstance(adsorbate, int): if not 0 <= adsorbate < len(records): raise ValueError("adsorbate index out of range") record = records[adsorbate] else: matches = [r for r in records if isinstance(r, dict) and r.get("label") == adsorbate] if not matches: raise ValueError(f"no adsorbate labelled {adsorbate!r}") record = matches[0] members = set(record.get("sites", [])) if isinstance(record, dict) else set() chosen = [s for s in chosen if s in members] if z_min is not None or z_max is not None: xyz = {s.id: structure.cartesian(s)[2] for s in structure.sites} lo = -math.inf if z_min is None else z_min hi = math.inf if z_max is None else z_max chosen = [s for s in chosen if lo <= xyz[s] <= hi] return chosen
def _indices(structure: PeriodicStructure, sites: list[str]) -> list[int]: if not sites: raise ValueError("select at least one site") ids = [s.id for s in structure.sites] unknown = sorted(set(sites) - set(ids)) if unknown: raise ValueError(f"sites not in the structure: {', '.join(unknown)}") wanted = set(sites) return [i for i, sid in enumerate(ids) if sid in wanted]
[docs] def move_sites( structure: PeriodicStructure, sites: list[str], translation: tuple[float, float, float], *, frame: Literal["cartesian", "fractional"] = "cartesian", identifier: str | None = None, ) -> PeriodicStructure: """Translate the selected sites by a vector (angstrom, or fractions of the cell).""" vector = np.asarray(translation, dtype=float) if vector.shape != (3,) or not np.isfinite(vector).all(): raise ValueError("a translation is three finite numbers") lattice = crystal.lattice_rows(structure) shift = vector @ lattice if frame == "fractional" else vector xyz = crystal.cartesian(structure) chosen = _indices(structure, sites) xyz[chosen] += shift edit = {"edit": "move", "sites": sites, "translation": list(vector), "frame": frame} return _rebuild(structure, identifier or structure.id, lattice, xyz, edit)
[docs] def rotate_sites( structure: PeriodicStructure, sites: list[str], angle: float, *, axis: tuple[float, float, float] = (0.0, 0.0, 1.0), centre: tuple[float, float, float] | None = None, identifier: str | None = None, ) -> PeriodicStructure: """Rotate the selected sites by ``angle`` degrees about an axis through a point. The default axis is z (the slab normal for builder slabs) through the selection's centroid (Rodrigues' rotation formula). """ u = np.asarray(axis, dtype=float) if u.shape != (3,) or not np.isfinite(u).all() or np.linalg.norm(u) < 1e-12: raise ValueError("the rotation axis is a nonzero vector") if not math.isfinite(angle): raise ValueError("the rotation angle must be finite") u /= np.linalg.norm(u) xyz = crystal.cartesian(structure) chosen = _indices(structure, sites) point = xyz[chosen].mean(axis=0) if centre is None else np.asarray(centre, dtype=float) theta = math.radians(angle) k = np.asarray([[0, -u[2], u[1]], [u[2], 0, -u[0]], [-u[1], u[0], 0]]) rot = np.eye(3) + math.sin(theta) * k + (1 - math.cos(theta)) * (k @ k) xyz[chosen] = (xyz[chosen] - point) @ rot.T + point edit = { "edit": "rotate", "sites": sites, "angle": angle, "axis": list(u), "centre": list(point), } return _rebuild( structure, identifier or structure.id, crystal.lattice_rows(structure), xyz, edit )
[docs] def set_sites( structure: PeriodicStructure, edits: list[dict[str, Any]], *, identifier: str | None = None, ) -> PeriodicStructure: """Set positions (``fractional`` or ``cartesian``) and/or elements of named sites.""" from vibesketcher.elements import ATOMIC_NUMBER lattice = crystal.lattice_rows(structure) xyz = crystal.cartesian(structure) symbols = [s.symbol for s in structure.sites] ids = [s.id for s in structure.sites] for change in edits: sid = change.get("id") if sid not in ids: raise ValueError(f"site {sid!r} not found") i = ids.index(str(sid)) if "fractional" in change and "cartesian" in change: raise ValueError("give a fractional or a Cartesian position, not both") if "fractional" in change: xyz[i] = np.asarray(change["fractional"], dtype=float) @ lattice if "cartesian" in change: xyz[i] = np.asarray(change["cartesian"], dtype=float) if "symbol" in change: if change["symbol"] not in ATOMIC_NUMBER: raise ValueError(f"unknown element {change['symbol']!r}") symbols[i] = str(change["symbol"]) if not np.isfinite(xyz).all(): raise ValueError("positions must be finite") edit = {"edit": "sites", "changes": edits} return _rebuild(structure, identifier or structure.id, lattice, xyz, edit, symbols=symbols)
[docs] def add_slab_layers( document: Document, structure: PeriodicStructure, count: int, *, identifier: str | None = None, ) -> PeriodicStructure: """Add ``count`` layers on top of a builder slab, under any adsorbates. The slab is cut again from its recorded bulk source with the same Miller index, layer unit, termination and repeat; the existing sites, including any moved or edited ones, stay where they are, the new layers' atoms are added above the old top layer, adsorbate atoms move up with the surface and the normal vector grows by the added thickness. """ from vibesketcher.document.slab import build_slab if not 1 <= count <= 100: raise ValueError("add between 1 and 100 layers") builder = crystal._builder(structure) if builder.get("kind") not in ("slab", "adsorbate") or "layers" not in builder: raise ValueError("layers can be added to slabs made by the slab builder") source_id = builder.get("source") bulk = {s.id: s for s in document.periodic_structures}.get(str(source_id)) template = builder.get("template") if bulk is None and source_id is None and isinstance(template, dict): # A one-step template slab (#232): rebuild its conventional bulk. from vibesketcher.document import crystal_templates overrides = template.get("overrides") or {} bulk = crystal_templates.build(str(template["name"]), identifier="bulk", cell="conventional", a=overrides.get("a"), c=overrides.get("c")) # fmt: skip if bulk is None: raise ValueError(f"the slab's bulk source {source_id!r} is not in the document") layers = int(builder["layers"]) repeat = builder.get("repeat") h, k, l_ = (int(x) for x in builder["miller"]) taller = build_slab( bulk, (h, k, l_), identifier="taller", layers=layers + count, layer_unit=builder.get("layer_unit", "stoichiometric"), termination=int(builder.get("termination", 0)), vacuum=float(builder.get("vacuum", 15.0)), repeat=repeat if isinstance(repeat, list) else (1, 1), fixed_layers=0, periodicity="3d" if structure.pbc[2] else "2d", surface_cell=builder.get("surface_cell", "primitive"), ) tall = crystal._builder(taller) tall_layers = tall["layer_of_site"] assert isinstance(tall_layers, dict) old_layers = builder.get("layer_of_site") if not isinstance(old_layers, dict): raise ValueError("the slab records no layer map") slab_ids = set(old_layers) old_count = Counter(int(v) for v in old_layers.values()) tall_count = Counter(int(v) for v in tall_layers.values()) if any(tall_count[k] != old_count[k] for k in old_count): raise ValueError("the slab no longer matches its recipe; rebuild it with more layers") delta = float(tall["thickness"]) - float(builder["thickness"]) old_rows = crystal.lattice_rows(structure) tall_rows = crystal.lattice_rows(taller) if not np.allclose(old_rows[:2], tall_rows[:2], atol=1e-6): raise ValueError( "the in-plane cell was edited since the slab was cut; rebuild the slab with more layers" ) xyz = crystal.cartesian(structure) # Align the recut slab on the existing bottom layer (vacuum edits move it). bottom_old = [i for i, s in enumerate(structure.sites) if old_layers.get(s.id) == 0] bottom_new = [i for i, s in enumerate(taller.sites) if tall_layers.get(s.id) == 0] tall_xyz = crystal.cartesian(taller) lift = float(xyz[bottom_old, 2].mean() - tall_xyz[bottom_new, 2].mean()) # Adsorbate atoms (not in the slab's layer map) ride up with the surface. for i, site in enumerate(structure.sites): if site.id not in slab_ids: xyz[i, 2] += delta new_atoms = [ (s.symbol, tall_xyz[i] + np.asarray([0.0, 0.0, lift]), int(tall_layers[s.id])) for i, s in enumerate(taller.sites) if int(tall_layers[s.id]) >= layers ] lattice = old_rows.copy() lattice[2] = old_rows[2] + delta * old_rows[2] / np.linalg.norm(old_rows[2]) counts: Counter[str] = Counter(s.symbol for s in structure.sites) taken = {s.id for s in structure.sites} inverse = np.linalg.inv(lattice) data = structure.model_dump() data["id"] = identifier or structure.id data["lattice_angstrom"] = tuple(tuple(float(x) for x in row) for row in lattice) sites = [] for i, site in enumerate(data["sites"]): site["fractional"] = tuple(crystal._clean(x) for x in xyz[i] @ inverse) sites.append(site) layer_of = dict(old_layers) for symbol, position, layer in new_atoms: counts[symbol] += 1 sid = f"{symbol}{counts[symbol]}" while sid in taken: counts[symbol] += 1 sid = f"{symbol}{counts[symbol]}" taken.add(sid) layer_of[sid] = layer sites.append( {"id": sid, "symbol": symbol, "fractional": tuple(crystal._clean(x) for x in position @ inverse)} ) # fmt: skip data["sites"] = sites builder.update( { "layers": layers + count, "thickness": float(tall["thickness"]), "layer_of_site": layer_of, "top_layer": layers + count - 1, "top_composition": tall["top_composition"], "warnings": tall["warnings"], } ) edits = list(builder.get("edits") or []) edits.append({"edit": "add_layers", "count": count, "added_thickness": delta}) builder["edits"] = edits data["provenance"] = {**structure.provenance, "builder": crystal._json(builder)} data["source_cif"] = None data["source_model_sha256"] = None return PeriodicStructure.model_validate(data)
class _Edit(BaseModel): model_config = ConfigDict(extra="forbid")
[docs] class EditCellRecipe(_Edit): kind: Literal["edit_cell"] = "edit_cell" source: _ID identifier: _ID | 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 scale: float | None = None vacuum: float | None = None keep: Keep = "fractional"
[docs] class Selection(_Edit): sites: list[str] | None = None elements: list[str] | None = None layers: list[int] | None = None adsorbate: int | str | None = None z_min: float | None = None z_max: float | None = None
[docs] class MoveSitesRecipe(_Edit): kind: Literal["move_sites"] = "move_sites" source: _ID identifier: _ID | None = None select: Selection translation: tuple[float, float, float] = (0.0, 0.0, 0.0) frame: Literal["cartesian", "fractional"] = "cartesian" rotate: float | None = None axis: tuple[float, float, float] = (0.0, 0.0, 1.0)
[docs] class SetSitesRecipe(_Edit): kind: Literal["set_sites"] = "set_sites" source: _ID identifier: _ID | None = None sites: list[dict[str, Any]] = Field(min_length=1, max_length=25000)
[docs] class AddLayersRecipe(_Edit): kind: Literal["add_layers"] = "add_layers" source: _ID identifier: _ID | None = None count: int = Field(ge=1, le=100)
EditRecipe = EditCellRecipe | MoveSitesRecipe | SetSitesRecipe | AddLayersRecipe EDIT_KINDS = ("edit_cell", "move_sites", "set_sites", "add_layers")
[docs] def apply_edit(document: Document, recipe: EditRecipe) -> PeriodicStructure: """Run one edit; without ``identifier`` the result replaces its source.""" existing = {s.id: s for s in document.periodic_structures} if recipe.source not in existing: raise ValueError(f"periodic structure {recipe.source!r} not found") target = recipe.identifier or recipe.source if target != recipe.source and target in existing: raise ValueError(f"periodic structure {target!r} already exists") source = existing[recipe.source] if isinstance(recipe, EditCellRecipe): return edit_cell( source, identifier=target, **recipe.model_dump(include={"a", "b", "c", "alpha", "beta", "gamma", "scale", "vacuum", "keep"}), ) # fmt: skip if isinstance(recipe, MoveSitesRecipe): sites = select_sites(source, **recipe.select.model_dump()) moved = source if recipe.rotate: moved = rotate_sites(moved, sites, recipe.rotate, axis=recipe.axis, identifier=target) if any(recipe.translation) or not recipe.rotate: moved = move_sites( moved, sites, recipe.translation, frame=recipe.frame, identifier=target ) return moved if isinstance(recipe, SetSitesRecipe): return set_sites(source, recipe.sites, identifier=target) return add_slab_layers(document, source, recipe.count, identifier=target)
[docs] def parse_edit(data: dict[str, Any]) -> EditRecipe: from pydantic import TypeAdapter adapter: TypeAdapter[EditRecipe] = TypeAdapter( Annotated[EditRecipe, Field(discriminator="kind")] ) return adapter.validate_python(data)
[docs] def edit(document: Document, recipe: EditRecipe) -> Document: """Apply an edit and put its structure into a copy of the document.""" from vibesketcher.document.periodic import with_periodic return with_periodic(document, apply_edit(document, recipe))