Disorder, vacancies, and attached species¶
A site in a real material is not always one atom. It can be an alloy site shared between two elements, a site that is only partly occupied, or an atom carrying particles whose positions nobody modelled. This notebook walks through how httk-atomistic represents all three, and — the practical part — which of its representations can carry them and which cannot.
Every code cell below is taken verbatim from the runnable script
examples/disorder_and_vacancies.py. The notebook stores no outputs; run it to
see them, or read the rendered example page
Disorder, vacancies, and attached species: what a site can actually hold, which shows the same script.
We start with the imports and four species that between them cover the whole story: a 50/50 Fe/Ni alloy site, a titanium site that is empty one time in ten, a methyl group, and one ordinary fully ordered oxygen.
from httk.core import unwrap
from httk.atomistic import (
Species,
SpeciesPrimitiveView,
Structure,
StructurePrimitiveView,
UnitcellStructureView,
)
CUBIC = [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]
#: A 50/50 substitutional Fe/Ni alloy site.
FE_NI = Species(name="M", chemical_symbols=("Fe", "Ni"), concentration=(0.5, 0.5))
#: A titanium site that is empty one time in ten.
TI_WITH_VACANCY = Species(name="Ti", chemical_symbols=("Ti", "vacancy"), concentration=(0.9, 0.1))
#: A carbon carrying three hydrogens whose positions are not modelled.
METHYL = Species(name="CH3", chemical_symbols=("C",), concentration=(1.0,), attached=("H",), nattached=(3,))
#: An ordinary, fully ordered species -- the only kind a primitive triple can hold.
OXYGEN = Species(name="O", chemical_symbols=("O",), concentration=(1.0,))
What a Species actually says¶
A Species carries a name (its label in the structure — it need not be a
chemical symbol), a tuple of chemical_symbols, and a matching tuple of
concentration values. Read together, those say what fraction of the time
each symbol occupies the site:
("Fe", "Ni")at(0.5, 0.5)is substitutional disorder — a statement about an ensemble, not about one atom.("Ti", "vacancy")at(0.9, 0.1)is a partial vacancy."vacancy"is one of OPTIMADE’s two pseudo-symbols (the other is"X", meaning unknown), and it names the absence of an atom.attached=("H",)withnattached=(3,)records attached particles: three hydrogens bound to the carbon, with no coordinates invented for them. The two fields must always be given together and share a length.
Species.is_single_element is the predicate that matters downstream: it is true
only for exactly one real element symbol with nothing attached. Watch which of
the four is true.
def explain(species: Species) -> None:
"""Print what a species says, and whether it survives the trip to an atomic number."""
occupancy = ", ".join(
f"{100 * fraction:g}% {symbol}" for symbol, fraction in zip(species.chemical_symbols, species.concentration)
)
attachments = ""
if species.attached is not None and species.nattached is not None:
attachments = " + attached " + ", ".join(
f"{count}x{symbol}" for symbol, count in zip(species.attached, species.nattached)
)
print(f" {species.name:<4} {occupancy}{attachments}")
print(f" is_single_element = {species.is_single_element}")
def species_are_richer_than_elements() -> None:
print("What each species says:")
for species in (FE_NI, TI_WITH_VACANCY, METHYL, OXYGEN):
explain(species)
species_are_richer_than_elements()
What each species says:
M 50% Fe, 50% Ni
is_single_element = False
Ti 90% Ti, 10% vacancy
is_single_element = False
CH3 100% C + attached 3xH
is_single_element = False
O 100% O
is_single_element = True
The Unitcell representation carries all of it¶
A Structure — and any UnitcellStructureView over any backend — stores the
Species objects themselves, so nothing is lost: the concentrations, the
vacancy pseudo-symbol, and the attachments all survive intact.
Two things worth noticing in the output. First, unwrap() on the view returns
the same object we put in, not a copy — views present, they do not convert.
Second, SpeciesPrimitiveView renders each species as an OPTIMADE species dict
with every field still present; that is unsurprising, since this model is
OPTIMADE’s.
def disorder_survives_the_simple_representation() -> None:
"""The Structure, and any UnitcellStructureView, keep every field intact."""
structure = Structure(
cell=CUBIC,
sites=[[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]],
species=[TI_WITH_VACANCY, METHYL],
species_at_sites=["Ti", "CH3"],
)
by_name = {species.name: species for species in structure.species}
print("Unitcell representation (a Structure):")
print(" Ti chemical_symbols ", by_name["Ti"].chemical_symbols)
print(" Ti concentration ", by_name["Ti"].concentration)
print(" CH3 attached ", by_name["CH3"].attached)
print(" CH3 nattached ", by_name["CH3"].nattached)
# A view over the same structure is the same data seen again, not a lossy copy:
# unwrap() hands back the original object.
view = UnitcellStructureView(structure)
print(" round-trips through UnitcellStructureView:", unwrap(view) is structure)
# ...and so does the OPTIMADE species dict, which is where this model comes from.
print(" as OPTIMADE dicts:")
for species in structure.species:
print(" ", dict(SpeciesPrimitiveView(species)))
disorder_survives_the_simple_representation()
Unitcell representation (a Structure):
Ti chemical_symbols ('Ti', 'vacancy')
Ti concentration (0.9, 0.1)
CH3 attached ('H',)
CH3 nattached (3,)
round-trips through UnitcellStructureView: True
as OPTIMADE dicts:
{'name': 'Ti', 'chemical_symbols': ['Ti', 'vacancy'], 'concentration': [0.9, 0.1]}
{'name': 'CH3', 'chemical_symbols': ['C'], 'concentration': [1.0], 'attached': ['H'], 'nattached': [3]}
The primitive representation rejects unsupported species¶
The primitive representation is the spglib-style (lattice, positions, numbers)
triple that symmetry libraries speak, and numbers is a list of bare atomic
numbers. There is no integer that means “90% Ti”, and none that means “carbon
plus three hydrogens”.
StructurePrimitiveView does not round, drop, or approximate. It raises
TypeError and names the species it could not express, preventing a modelling
statement from being silently converted into a different material. A fully
ordered structure goes through untouched.
def the_primitive_representation_refuses() -> None:
"""A bare atomic number cannot mean '90% Ti', so conversion raises TypeError."""
print("Primitive representation (an spglib-style triple):")
for label, species in (("partial vacancy", TI_WITH_VACANCY), ("attached particles", METHYL), ("alloy", FE_NI)):
structure = Structure(
cell=CUBIC,
sites=[[0.0, 0.0, 0.0]],
species=[species],
species_at_sites=[species.name],
)
try:
StructurePrimitiveView(structure)
except TypeError as error:
print(f" {label:<19} -> TypeError: {error}")
else: # pragma: no cover - unreachable while the check above holds
print(f" {label:<19} -> unexpectedly accepted")
# A fully ordered structure converts without complaint, of course:
ordered = Structure(cell=CUBIC, sites=[[0.0, 0.0, 0.0]], species=[OXYGEN], species_at_sites=["O"])
_lattice, _positions, numbers = StructurePrimitiveView(ordered)
print(" fully ordered -> atomic numbers", numbers)
the_primitive_representation_refuses()
Primitive representation (an spglib-style triple):
partial vacancy -> TypeError: This structure cannot be represented as a primitive structure (species 'Ti' is not a single, unattached chemical element)
attached particles -> TypeError: This structure cannot be represented as a primitive structure (species 'CH3' is not a single, unattached chemical element)
alloy -> TypeError: This structure cannot be represented as a primitive structure (species 'M' is not a single, unattached chemical element)
fully ordered -> atomic numbers (8,)
Checking before you convert¶
Since is_single_element is exactly the condition the primitive view enforces,
you can ask the question yourself before handing a structure to a symmetry
library, a force field, or anything else that speaks in atomic numbers — and get
back the names of the offending species rather than an exception.
The rule of thumb: keep disordered structures in the Unitcell representation, and
convert to primitive only at the boundary of a tool that genuinely cannot
represent disorder, where the TypeError is precisely the error you want.
def guard_before_converting() -> None:
"""The idiomatic pre-flight check before handing a structure to an element-only tool."""
mixed = Structure(
cell=CUBIC,
sites=[[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]],
species=[FE_NI, OXYGEN],
species_at_sites=["M", "O"],
)
offenders = [species.name for species in mixed.species if not species.is_single_element]
print("Pre-flight check:")
print(" species that cannot become an atomic number:", offenders)
print(" safe to convert to a primitive triple:", not offenders)
guard_before_converting()
Pre-flight check:
species that cannot become an atomic number: ['M']
safe to convert to a primitive triple: False
Where to go next¶
The Structures guide covers the
Structuremodel, its component families, and the exact numeric layer in reference form.Serving structures as OPTIMADE, with derived fields and custom properties shows what happens to a disordered structure at the OPTIMADE serving boundary:
structure_featuresbecomes["disorder"], and the derived composition fields servenullrather than inventing an element count for a half-occupied site.Building a Structure three ways, and reading its geometry back out exactly is the runnable tour of the three ways to construct a
Structureand of the exact geometry you can read back out.