"""
Module defines the basic structure of atomic scale samples, including materials, crystal structures, unit cells, and simulation cells.
It also includes the definition of atom attributes and various types of defects.
"""
from typing import List, Optional, Union
import os
import logging
import numpy as np
import yaml
import uuid
import json
from pydantic import Field, SkipValidation
from atomrdf.datamodels.basemodels import (
TemplateMixin,
RDFMixin,
BaseModel,
)
from atomrdf.datamodels.workflow.property import Property as DataProperty
from rdflib import Graph, Namespace, XSD, RDF, RDFS, BNode, URIRef
from atomrdf.namespace import (
CMSO,
LDO,
PLDO,
PODO,
CDCO,
PROV,
Literal,
ASMO,
DCAT,
)
import atomrdf.json_io as json_io
import atomrdf.datamodels.defects as defects
import atomrdf.datamodels.structure_io as structure_io
from atomrdf.utils import get_material, get_sample_id, get_sample_object, toPython
from atomrdf.datamodels.workflow.property import *
import atomrdf.properties as ap
logger = logging.getLogger(__name__)
# read element data file
file_location = os.path.dirname(__file__).split("/")
file_location = "/".join(file_location[:-1])
file_location = os.path.join(os.path.dirname(__file__), "../data/element.yml")
with open(file_location, "r") as fin:
element_identifiers = yaml.safe_load(fin)
[docs]
class UnitCell(BaseModel, TemplateMixin):
pid: Optional[str] = None
bravais_lattice: Optional[str] = None # Ontology URI
lattice_parameter: Optional[List[float]] = None # Angstroms (implicit)
angle: Optional[List[float]] = None # Degrees (implicit)
def to_graph(self, graph, sample_id, crystal_structure):
unit_cell = graph.create_node(f"{sample_id}_UnitCell", CMSO.UnitCell)
graph.add((crystal_structure, CMSO.hasUnitCell, unit_cell))
if self.bravais_lattice is not None:
bv = graph.create_node(
f"{sample_id}_BravaisLattice", URIRef(self.bravais_lattice)
)
graph.add(
(
unit_cell,
CMSO.hasBravaisLattice,
bv,
)
)
if self.lattice_parameter is not None:
lattice_parameter = graph.create_node(
f"{sample_id}_LatticeParameter", CMSO.LatticeParameter
)
graph.add(
(
unit_cell,
CMSO.hasLength_x,
Literal(self.lattice_parameter[0], datatype=XSD.float),
)
)
graph.add(
(
unit_cell,
CMSO.hasLength_y,
Literal(self.lattice_parameter[1], datatype=XSD.float),
)
)
graph.add(
(
unit_cell,
CMSO.hasLength_z,
Literal(self.lattice_parameter[2], datatype=XSD.float),
)
)
if self.angle is not None:
lattice_angle = graph.create_node(
f"{sample_id}_LatticeAngle", CMSO.LatticeAngle
)
graph.add((unit_cell, CMSO.hasAngle, lattice_angle))
graph.add(
(
lattice_angle,
CMSO.hasAngle_alpha,
Literal(self.angle[0], datatype=XSD.float),
)
)
graph.add(
(
lattice_angle,
CMSO.hasAngle_beta,
Literal(self.angle[1], datatype=XSD.float),
)
)
graph.add(
(
lattice_angle,
CMSO.hasAngle_gamma,
Literal(self.angle[2], datatype=XSD.float),
)
)
@classmethod
def from_graph(cls, graph, crystal_structure):
unit_cell = graph.value(crystal_structure, CMSO.hasUnitCell)
bv = graph.value(unit_cell, CMSO.hasBravaisLattice)
bv = graph.value(bv, RDF.type)
x = graph.value(unit_cell, CMSO.hasLength_x)
y = graph.value(unit_cell, CMSO.hasLength_y)
z = graph.value(unit_cell, CMSO.hasLength_z)
angle = graph.value(unit_cell, CMSO.hasAngle)
alpha = graph.value(angle, CMSO.hasAngle_alpha)
beta = graph.value(angle, CMSO.hasAngle_beta)
gamma = graph.value(angle, CMSO.hasAngle_gamma)
datadict = {}
if bv is not None:
datadict["bravais_lattice"] = str(bv)
if x is not None and y is not None and z is not None:
datadict["lattice_parameter"] = [x.toPython(), y.toPython(), z.toPython()]
if alpha is not None and beta is not None and gamma is not None:
datadict["angle"] = [alpha.toPython(), beta.toPython(), gamma.toPython()]
return cls(**datadict)
[docs]
class CrystalStructure(BaseModel, TemplateMixin):
pid: Optional[str] = None
name: Optional[str] = None
spacegroup_symbol: Optional[str] = None
spacegroup_number: Optional[int] = None
unit_cell: Optional[UnitCell] = None
def to_graph(self, graph, sample):
# Custom logic to convert UnitCell to graph representation
sample_id = get_sample_id(sample)
material = get_material(graph, sample)
crystal_structure = graph.create_node(
f"{sample_id}_CrystalStructure", CMSO.CrystalStructure
)
graph.add((material, CMSO.hasStructure, crystal_structure))
if self.spacegroup_symbol is not None:
graph.add(
(
crystal_structure,
CMSO.hasSpaceGroupSymbol,
Literal(self.spacegroup_symbol, datatype=XSD.string),
)
)
if self.spacegroup_number is not None:
graph.add(
(
crystal_structure,
CMSO.hasSpaceGroupNumber,
Literal(self.spacegroup_number, datatype=XSD.integer),
)
)
if self.unit_cell is not None:
self.unit_cell.to_graph(graph, sample_id, crystal_structure)
@classmethod
def from_graph(cls, graph, sample):
sample_id = get_sample_id(sample)
material = get_material(graph, sample)
crystal_structure = graph.value(material, CMSO.hasStructure)
spacegroup_symbol = graph.value(crystal_structure, CMSO.hasSpaceGroupSymbol)
spacegroup_number = graph.value(crystal_structure, CMSO.hasSpaceGroupNumber)
unit_cell = UnitCell.from_graph(graph, crystal_structure)
datadict = {
"spacegroup_symbol": spacegroup_symbol,
"spacegroup_number": spacegroup_number,
"unit_cell": unit_cell,
}
return cls(**datadict)
[docs]
class Material(BaseModel, TemplateMixin):
pid: Optional[str] = None
element_ratio: Optional[dict] = None
crystal_structure: Optional[CrystalStructure] = None
def to_graph(self, graph, sample):
sample_id = get_sample_id(sample)
# CrystallineMaterial is defined in CDCO, not CMSO. See ontology_term_audit.md.
material = graph.create_node(f"{sample_id}_Material", CDCO.CrystallineMaterial)
graph.add((sample, CMSO.hasMaterial, material))
composition = self.element_ratio
valid = composition is not None
for e, r in (composition or {}).items():
if e not in element_identifiers.keys():
valid = False
break
if valid:
chemical_species = graph.create_node(
f"{sample_id}_ChemicalSpecies", CMSO.ChemicalSpecies
)
graph.add((sample, CMSO.hasSpecies, chemical_species))
for e, r in composition.items():
if e in element_identifiers.keys():
element = graph.create_node(
f"{sample_id}_{e}", CMSO.ChemicalElement
)
graph.add((element, RDFS.seeAlso, URIRef(element_identifiers[e])))
graph.add((chemical_species, CMSO.hasElement, element))
graph.add(
(
element,
CMSO.hasChemicalSymbol,
Literal(e, datatype=XSD.string),
)
)
graph.add(
(element, CMSO.hasElementRatio, Literal(r, datatype=XSD.float))
)
if self.crystal_structure is not None:
self.crystal_structure.to_graph(graph, sample)
@classmethod
def from_graph(cls, graph, sample):
material = get_material(graph, sample)
element_ratio = {}
chemical_species = graph.value(sample, CMSO.hasSpecies)
for element in graph.objects(chemical_species, CMSO.hasElement):
symbol = graph.value(element, CMSO.hasChemicalSymbol)
ratio = graph.value(element, CMSO.hasElementRatio)
element_ratio[str(symbol)] = float(ratio)
crystal_structure = CrystalStructure.from_graph(graph, sample)
datadict = {
"element_ratio": element_ratio,
"crystal_structure": crystal_structure,
}
return cls(**datadict)
[docs]
class SimulationCell(BaseModel, TemplateMixin):
pid: Optional[str] = None
volume: Optional[DataProperty[float]] = None # Keep - has unit (Angstrom³)
number_of_atoms: Optional[int] = None
length: Optional[List[float]] = None # Angstroms (implicit)
vector: Optional[List[List[float]]] = None # Angstroms (implicit)
angle: Optional[List[float]] = None # Degrees (implicit)
repetitions: Optional[List[int]] = None
grain_size: Optional[float] = None # angstroms (implicit)
number_of_grains: Optional[int] = None
def to_graph(self, graph, sample):
sample_id = get_sample_id(sample)
simulation_cell = graph.create_node(
f"{sample_id}_SimulationCell", CMSO.SimulationCell
)
graph.add((sample, CMSO.hasSimulationCell, simulation_cell))
# Only add volume if it exists and has a value
if self.volume is not None and self.volume.value is not None:
volume = graph.create_node(
f"{sample_id}_Volume", ASMO.Volume, label="SimulationCellVolume"
)
graph.add((simulation_cell, CMSO.hasVolume, volume))
graph.add(
(
volume,
ASMO.hasValue,
Literal(
np.round(self.volume.value, decimals=2),
datatype=XSD.float,
),
)
)
graph.add(
(
volume,
ASMO.hasUnit,
URIRef(f"http://qudt.org/vocab/unit/ANGSTROM3"),
)
)
# Only add number of atoms if it's not None
if self.number_of_atoms is not None:
graph.add(
(
sample,
CMSO.hasNumberOfAtoms,
Literal(self.number_of_atoms, datatype=XSD.integer),
)
)
repetitions = self.repetitions
if repetitions is not None:
graph.add(
(
simulation_cell,
CMSO.hasRepetition_x,
Literal(repetitions[0], datatype=XSD.integer),
)
)
graph.add(
(
simulation_cell,
CMSO.hasRepetition_y,
Literal(repetitions[1], datatype=XSD.integer),
)
)
graph.add(
(
simulation_cell,
CMSO.hasRepetition_z,
Literal(repetitions[2], datatype=XSD.integer),
)
)
# Only add length if it exists and has data
if self.length is not None and len(self.length) >= 3:
simulation_cell_length = graph.create_node(
f"{sample_id}_SimulationCellLength", CMSO.SimulationCellLength
)
graph.add((simulation_cell, CMSO.hasLength, simulation_cell_length))
data = self.length
graph.add(
(
simulation_cell_length,
CMSO.hasLength_x,
Literal(data[0], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_length,
CMSO.hasLength_y,
Literal(data[1], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_length,
CMSO.hasLength_z,
Literal(data[2], datatype=XSD.float),
)
)
# Only add vectors if they exist and have data
if self.vector is not None and len(self.vector) >= 3:
simulation_cell_vector_01 = graph.create_node(
f"{sample_id}_SimulationCellVector_1", CMSO.SimulationCellVector
)
data = self.vector
graph.add((simulation_cell, CMSO.hasVector, simulation_cell_vector_01))
graph.add(
(
simulation_cell_vector_01,
CMSO.hasComponent_x,
Literal(data[0][0], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_vector_01,
CMSO.hasComponent_y,
Literal(data[0][1], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_vector_01,
CMSO.hasComponent_z,
Literal(data[0][2], datatype=XSD.float),
)
)
simulation_cell_vector_02 = graph.create_node(
f"{sample_id}_SimulationCellVector_2", CMSO.SimulationCellVector
)
graph.add((simulation_cell, CMSO.hasVector, simulation_cell_vector_02))
graph.add(
(
simulation_cell_vector_02,
CMSO.hasComponent_x,
Literal(data[1][0], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_vector_02,
CMSO.hasComponent_y,
Literal(data[1][1], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_vector_02,
CMSO.hasComponent_z,
Literal(data[1][2], datatype=XSD.float),
)
)
simulation_cell_vector_03 = graph.create_node(
f"{sample_id}_SimulationCellVector_3", CMSO.SimulationCellVector
)
graph.add((simulation_cell, CMSO.hasVector, simulation_cell_vector_03))
graph.add(
(
simulation_cell_vector_03,
CMSO.hasComponent_x,
Literal(data[2][0], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_vector_03,
CMSO.hasComponent_y,
Literal(data[2][1], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_vector_03,
CMSO.hasComponent_z,
Literal(data[2][2], datatype=XSD.float),
)
)
# Only add angles if they exist and have data
if self.angle is not None and len(self.angle) >= 3:
simulation_cell_angle = graph.create_node(
f"{sample_id}_SimulationCellAngle", CMSO.SimulationCellAngle
)
data = self.angle
graph.add((simulation_cell, CMSO.hasAngle, simulation_cell_angle))
graph.add(
(
simulation_cell_angle,
CMSO.hasAngle_alpha,
Literal(data[0], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_angle,
CMSO.hasAngle_beta,
Literal(data[1], datatype=XSD.float),
)
)
graph.add(
(
simulation_cell_angle,
CMSO.hasAngle_gamma,
Literal(data[2], datatype=XSD.float),
)
)
if self.grain_size is not None:
graph.add(
(
simulation_cell,
CMSO.hasGrainSize,
Literal(self.grain_size, datatype=XSD.float),
)
)
if self.number_of_grains is not None:
graph.add(
(
simulation_cell,
CMSO.hasNumberOfGrains,
Literal(self.number_of_grains, datatype=XSD.integer),
)
)
@classmethod
def from_graph(cls, graph, sample):
simulation_cell = graph.value(sample, CMSO.hasSimulationCell)
volume_item = graph.value(simulation_cell, CMSO.hasVolume)
volume = graph.value(volume_item, ASMO.hasValue)
number_of_atoms = graph.value(sample, CMSO.hasNumberOfAtoms)
rx = graph.value(simulation_cell, CMSO.hasRepetition_x) or 1
ry = graph.value(simulation_cell, CMSO.hasRepetition_y) or 1
rz = graph.value(simulation_cell, CMSO.hasRepetition_z) or 1
repetitions = [
int(rx),
int(ry),
int(rz),
]
simulation_cell_length = graph.value(
simulation_cell,
CMSO.hasLength,
)
length = None
if simulation_cell_length is not None:
lx = graph.value(simulation_cell_length, CMSO.hasLength_x)
ly = graph.value(simulation_cell_length, CMSO.hasLength_y)
lz = graph.value(simulation_cell_length, CMSO.hasLength_z)
if lx is not None and ly is not None and lz is not None:
length = [lx.toPython(), ly.toPython(), lz.toPython()]
vector = []
for v in graph.objects(simulation_cell, CMSO.hasVector):
vector.append(
[
toPython(graph.value(v, CMSO.hasComponent_x)),
toPython(graph.value(v, CMSO.hasComponent_y)),
toPython(graph.value(v, CMSO.hasComponent_z)),
]
)
cell_angle = graph.value(simulation_cell, CMSO.hasAngle)
angle = [
toPython(graph.value(cell_angle, CMSO.hasAngle_alpha)),
toPython(graph.value(cell_angle, CMSO.hasAngle_beta)),
toPython(graph.value(cell_angle, CMSO.hasAngle_gamma)),
]
datadict = {
"volume": {
"value": volume,
},
"number_of_atoms": (
int(number_of_atoms) if number_of_atoms is not None else None
),
"repetitions": repetitions,
"length": length,
"vector": vector,
"angle": angle,
}
return cls(**datadict)
[docs]
class AtomAttribute(BaseModel, TemplateMixin):
pid: Optional[str] = str(CMSO.AtomAttribute)
position: SkipValidation[Optional[List[List[float]]]] = (
None # Angstroms (implicit) - validation skipped for performance
)
species: SkipValidation[Optional[List[str]]] = (
None # Chemical symbols - validation skipped for performance
)
def write_attributes(
self, graph, sample_id, position_identifier, species_identifier
):
datadict = {
position_identifier: {
"value": self.position,
"label": "position",
},
species_identifier: {
"value": self.species,
"label": "species",
},
}
outfile = os.path.join(graph.structure_store, str(sample_id).split(":")[-1])
json_io.write_file(outfile, datadict)
return os.path.relpath(outfile + ".json")
def to_graph(self, graph, sample):
# now we write out file
sample_id = get_sample_id(sample)
position_identifier = str(uuid.uuid4())
species_identifier = str(uuid.uuid4())
outfile = self.write_attributes(
graph, sample_id, position_identifier, species_identifier
)
if self.position is not None:
position = graph.create_node(f"{sample_id}_Position", CMSO.AtomAttribute)
graph.add(
(
sample,
Namespace("http://purls.helmholtz-metadaten.de/cmso/").hasAttribute,
position,
)
)
graph.add(
(position, CMSO.hasName, Literal("Position", datatype=XSD.string))
)
graph.add(
(
position,
CMSO.hasIdentifier,
Literal(position_identifier, datatype=XSD.string),
)
)
graph.add((position, CMSO.hasPath, Literal(outfile, datatype=XSD.string)))
if self.species is not None:
species = graph.create_node(f"{sample_id}_Species", CMSO.AtomAttribute)
graph.add(
(
sample,
Namespace("http://purls.helmholtz-metadaten.de/cmso/").hasAttribute,
species,
)
)
graph.add((species, CMSO.hasName, Literal("Species", datatype=XSD.string)))
graph.add(
(
species,
CMSO.hasIdentifier,
Literal(species_identifier, datatype=XSD.string),
)
)
graph.add((species, CMSO.hasPath, Literal(outfile, datatype=XSD.string)))
@classmethod
def from_graph(cls, graph, sample):
sample_id = get_sample_id(sample)
# cell_vectors
filepath = graph.value(URIRef(f"{sample_id}_Position"), CMSO.hasPath).toPython()
position_identifier = graph.value(
URIRef(f"{sample_id}_Position"), CMSO.hasIdentifier
).toPython()
species_identifier = graph.value(
URIRef(f"{sample_id}_Species"), CMSO.hasIdentifier
).toPython()
# Resolve via structure_store so paths stored on a different machine/CWD still work
store_dir = getattr(graph, "structure_store", None)
if store_dir is not None:
filepath = os.path.join(store_dir, os.path.basename(filepath))
# open the file for reading
with open(filepath, "r") as fin:
data = json.load(fin)
positions = data[position_identifier]["value"]
species = data[species_identifier]["value"]
return cls(
position=positions,
species=species,
)
[docs]
class AtomicScaleSample(BaseModel, TemplateMixin):
pid: Optional[str] = str(CMSO.AtomicScaleSample)
material: Optional[Material] = None
simulation_cell: Optional[SimulationCell] = None
atom_attribute: Optional[AtomAttribute] = None
# add defects, all optional of course
# point defects
point_defect: Optional[defects.PointDefect] = None
vacancy: Optional[defects.Vacancy] = None
substitutional: Optional[defects.Substitutional] = None
interstitial: Optional[defects.Interstitial] = None
# dislocations
dislocation: Optional[defects.Dislocation] = None
edge_dislocation: Optional[defects.EdgeDislocation] = None
screw_dislocation: Optional[defects.ScrewDislocation] = None
mixed_dislocation: Optional[defects.MixedDislocation] = None
# stacking faults
stacking_fault: Optional[defects.StackingFault] = None
# grain boundaries
grain_boundary: Optional[defects.GrainBoundary] = None
tilt_grain_boundary: Optional[defects.TiltGrainBoundary] = None
twist_grain_boundary: Optional[defects.TwistGrainBoundary] = None
symmetric_tilt_grain_boundary: Optional[defects.SymmetricalTiltGrainBoundary] = None
mixed_grain_boundary: Optional[defects.MixedGrainBoundary] = None
# properties
calculated_property: Optional[List[CalculatedProperty]] = Field(
default=[], description="Calculated properties from the simulation"
)
# defect complex
defect_complex: Optional[defects.DefectComplex] = None
def __init__(self, **data):
super().__init__(**data)
# Initialize private accumulator (not a Pydantic field)
self._defect_complex_ids = []
def to_graph_calculated_properties(self, graph):
if self.calculated_property:
for param in self.calculated_property:
param_uri = param.to_graph(graph)
graph.add(
(
URIRef(self.id),
ASMO.hasCalculatedProperty,
param_uri,
)
)
def from_graph_calculated_properties(cls, graph, sample_id):
properties = []
for prop_uri in graph.objects(URIRef(sample_id), ASMO.hasCalculatedProperty):
prop = CalculatedProperty.from_graph(graph, prop_uri)
properties.append(prop)
cls.calculated_property = properties
return cls
def to_graph(self, graph, force=False):
# if force - creates a new ID and saves the structure again
if not force and self.id is not None:
return self.id
# the rest of the function is only if id isnt there or force is true
name = f"sample:{str(uuid.uuid4())}"
self.id = name
sample = graph.create_node(name, CMSO.AtomicScaleSample, label=self.label)
if self.material is not None:
self.material.to_graph(graph, sample)
if self.simulation_cell is not None:
self.simulation_cell.to_graph(graph, sample)
if self.atom_attribute is not None:
self.atom_attribute.to_graph(
graph,
sample,
)
# now add calculated properties
self.to_graph_calculated_properties(graph)
# now call defect methods
# Defects
defect_fields = [
"point_defect",
"vacancy",
"substitutional",
"interstitial",
"dislocation",
"edge_dislocation",
"screw_dislocation",
"mixed_dislocation",
"stacking_fault",
"grain_boundary",
"tilt_grain_boundary",
"twist_grain_boundary",
"symmetric_tilt_grain_boundary",
"mixed_grain_boundary",
]
# Reset the defect complex IDs accumulator
self._defect_complex_ids = []
# Map defect field names to their class names for URI construction
defect_class_names = {
"point_defect": "PointDefect",
"vacancy": "Vacancy",
"substitutional": "Substitutional",
"interstitial": "Interstitial",
"dislocation": "Dislocation",
"edge_dislocation": "EdgeDislocation",
"screw_dislocation": "ScrewDislocation",
"mixed_dislocation": "MixedDislocation",
"stacking_fault": "StackingFault",
"grain_boundary": "GrainBoundary",
"tilt_grain_boundary": "TiltGrainBoundary",
"twist_grain_boundary": "TwistGrainBoundary",
"symmetric_tilt_grain_boundary": "SymmetricTiltGrainBoundary",
"mixed_grain_boundary": "MixedGrainBoundary",
}
for defect in defect_fields:
obj = getattr(self, defect, None)
if obj is not None:
if isinstance(obj, BaseModel) and obj.model_fields_set:
if hasattr(obj, "to_graph"):
obj.to_graph(graph, sample)
# If this defect belongs to defect_complex, construct and collect its URI
if (
self.defect_complex
and self.defect_complex.ids
and defect in self.defect_complex.ids
):
# Construct the defect URI (matches the pattern used in defect.to_graph())
defect_uri = f"{sample}_{defect_class_names[defect]}"
self._defect_complex_ids.append(defect_uri)
# Now serialize DefectComplex with collected defect URIs
if self.defect_complex is not None:
self.defect_complex.to_graph(graph, sample, self._defect_complex_ids)
# Add content hash to the graph for deduplication (skip validation for external vocab)
content_hash = self._compute_hash()
graph.add((sample, DCAT.checksum, Literal(content_hash, datatype=XSD.string)))
return self.id
@classmethod
def from_graph(cls, graph, sample_id):
from typing import get_origin, get_args
kwargs = {}
sample = get_sample_object(sample_id)
# try a type query first
sample_type = graph.value(sample, RDF.type)
if sample_type is None:
raise ValueError(f"Sample {sample_id} not found in graph.")
# material, simulation_cell, atom_attribute handled separately (if needed)
kwargs["material"] = Material.from_graph(graph, sample)
kwargs["simulation_cell"] = SimulationCell.from_graph(graph, sample)
kwargs["atom_attribute"] = AtomAttribute.from_graph(graph, sample)
defect_fields = [
"point_defect",
"vacancy",
"substitutional",
"interstitial",
"dislocation",
"edge_dislocation",
"screw_dislocation",
"mixed_dislocation",
"stacking_fault",
"grain_boundary",
"tilt_grain_boundary",
"twist_grain_boundary",
"symmetric_tilt_grain_boundary",
"mixed_grain_boundary",
]
# Loop over defect fields
for field in defect_fields:
field_type = cls.model_fields[field].annotation
# Unwrap Optional types (Union[X, None] -> X)
origin = get_origin(field_type)
if origin is not None:
# Check if it's Optional/Union
args = get_args(field_type)
if len(args) == 2 and type(None) in args:
# It's Optional[X], extract X
field_type = args[0] if args[0] is not type(None) else args[1]
if hasattr(field_type, "from_graph"):
try:
result = field_type.from_graph(graph, sample)
kwargs[field] = result
except Exception as e:
kwargs[field] = None
cls = cls(**kwargs)
cls.id = sample_id
cls = cls.from_graph_calculated_properties(graph, sample_id)
return cls
[docs]
@classmethod
def from_file(
cls,
filename,
format="lammps-dump",
species=None,
lattice=None,
lattice_constant=None,
basis_box=None,
basis_positions=None,
repeat=None,
graph=None,
):
"""
Read structure from file and create an AtomicScaleSample instance.
Parameters
----------
filename : str
Path to the structure file
format : str, optional
File format (default: 'lammps-dump'). Any format supported by ASE.
species : list, optional
If provided, LAMMPS types will be matched to species. For example, if types 1 and 2 exist
in the input file, and species = ['Li', 'Al'] is given, type 1 will be matched to 'Li' and
type 2 will be matched to 'Al'
lattice : str, optional
Crystal structure name (e.g., 'bcc', 'fcc', 'hcp', 'diamond', 'l12', 'b2').
If provided, metadata such as unit cell, space group, etc. are automatically added.
lattice_constant : float, optional
Lattice constant of the system
basis_box : list of lists, optional
3x3 matrix specifying the basis unit cell. Not required if lattice is provided.
basis_positions : list of lists, optional
Nx3 array specifying relative positions of atoms in the unit cell.
Not required if lattice is provided.
repeat : tuple or int, optional
Number of repetitions of the unit cell in each direction.
graph : KnowledgeGraph, optional
If provided, the structure will be added to the graph.
Returns
-------
AtomicScaleSample
The created sample instance
Examples
--------
>>> sample = AtomicScaleSample.from_file('structure.lmp', format='lammps-dump')
>>> sample = AtomicScaleSample.from_file('POSCAR', format='vasp',
... lattice='bcc', lattice_constant=2.87)
"""
from ase.io import read as ase_read
from pyscal3.core import structure_dict
from atomrdf.build.bulk import _generate_atomic_sample_data
from atomrdf.build.buildutils import _declass
# Read structure with ASE
atoms = ase_read(filename, format=format)
# Handle species mapping for LAMMPS
if species is not None:
types = atoms.get_array("type") if "type" in atoms.arrays else None
if types is not None:
new_symbols = [species[int(t) - 1] for t in types]
atoms.set_chemical_symbols(new_symbols)
# Build metadata dict (sdict) for supplementing structure information
sdict = {}
if lattice is not None:
# Get structure information from known lattices
from pyscal3.core import structure_dict
if lattice in structure_dict.keys():
lattice_info = structure_dict[lattice].get("conventional", {})
sdict["structure"] = lattice
# Try to get spacegroup info
if "spacegroup_symbol" in lattice_info:
sdict["spacegroup_symbol"] = lattice_info["spacegroup_symbol"]
if "spacegroup_number" in lattice_info:
sdict["spacegroup_number"] = lattice_info["spacegroup_number"]
if lattice_constant is not None:
lattice_constant = _declass(lattice_constant)
sdict["a"] = lattice_constant
sdict["b"] = lattice_constant
sdict["c"] = lattice_constant
# If no lattice info provided, try to determine from structure
if not sdict and lattice is None:
try:
# Try to get spacegroup info from the atoms object
spacegroup_symbol = ap.get_spacegroup_symbol(atoms)
spacegroup_number = ap.get_spacegroup_number(atoms)
if spacegroup_symbol:
sdict["spacegroup_symbol"] = spacegroup_symbol
if spacegroup_number:
sdict["spacegroup_number"] = spacegroup_number
except Exception as e:
# If we can't determine spacegroup, that's okay
logger.debug("Spacegroup detection failed: %s", e)
# Generate sample data using the helper function from bulk.py
data = _generate_atomic_sample_data(atoms, sdict if sdict else None, repeat)
# Create the AtomicScaleSample instance
sample = cls(**data)
# Optionally add to graph
if graph is not None:
sample.to_graph(graph)
atoms.info["id"] = sample.id
atoms.info["graph"] = graph
return sample
[docs]
@classmethod
def from_repository(
cls,
repository="materials_project",
api_key=None,
material_ids=None,
chemical_system=None,
is_stable=True,
conventional=True,
graph=None,
):
"""
Fetch structure(s) from an external repository and create AtomicScaleSample instance(s).
Parameters
----------
repository : str, optional
Repository name. Currently supports: 'materials_project' (default).
api_key : str
API key for the repository.
material_ids : list of str, optional
List of material IDs to fetch. For Materials Project, these are mp-ids like ['mp-149', 'mp-13'].
chemical_system : str, optional
Chemical system string (e.g., 'Fe-C', 'Li-Co-O'). If provided, all stable materials
in this system will be fetched.
is_stable : bool, optional
If True (default), only fetch stable materials. Only used with chemical_system.
conventional : bool, optional
If True (default), use conventional cell. If False, use primitive cell.
graph : KnowledgeGraph, optional
If provided, the structure(s) will be added to the graph.
Returns
-------
AtomicScaleSample or list of AtomicScaleSample
If a single material is fetched, returns AtomicScaleSample.
If multiple materials are fetched, returns a list of AtomicScaleSample instances.
Raises
------
ValueError
If neither material_ids nor chemical_system is provided.
ImportError
If the required repository client library is not installed.
Examples
--------
Fetch a single material by ID:
>>> sample = AtomicScaleSample.from_repository(
... repository='materials_project',
... api_key='your_api_key',
... material_ids=['mp-149']
... )
Fetch all stable materials in a chemical system:
>>> samples = AtomicScaleSample.from_repository(
... repository='materials_project',
... api_key='your_api_key',
... chemical_system='Fe-C'
... )
Fetch and add to graph:
>>> kg = KnowledgeGraph()
>>> sample = AtomicScaleSample.from_repository(
... repository='materials_project',
... api_key='your_api_key',
... material_ids=['mp-149'],
... graph=kg
... )
"""
if repository.lower() == "materials_project":
return cls._from_materials_project(
api_key=api_key,
material_ids=material_ids,
chemical_system=chemical_system,
is_stable=is_stable,
conventional=conventional,
graph=graph,
)
else:
raise ValueError(
f"Repository '{repository}' is not supported. "
"Currently supported: 'materials_project'"
)
@classmethod
def _from_materials_project(
cls,
api_key,
material_ids=None,
chemical_system=None,
is_stable=True,
conventional=True,
graph=None,
):
"""
Internal method to fetch structures from Materials Project.
"""
try:
from mp_api.client import MPRester
except ImportError:
raise ImportError(
"mp-api is not installed. Please install it with: pip install mp-api"
)
if api_key is None:
raise ValueError("api_key is required for Materials Project")
if (chemical_system is None) and (material_ids is None):
raise ValueError(
"Please provide either 'chemical_system' or 'material_ids'"
)
rest = {
"use_document_model": False,
"include_user_agent": True,
"api_key": api_key,
}
with MPRester(**rest) as mpr:
if chemical_system is not None:
docs = mpr.materials.summary.search(
chemsys=chemical_system, is_stable=is_stable
)
else:
docs = mpr.materials.summary.search(material_ids=material_ids)
# Process documents and create samples
samples = []
for doc in docs:
struct = doc["structure"]
if conventional:
aseatoms = struct.to_conventional().to_ase_atoms()
else:
aseatoms = struct.to_primitive().to_ase_atoms()
symmetry = doc["symmetry"]
# Generate sample data
from atomrdf.build.bulk import _generate_atomic_sample_data
data = _generate_atomic_sample_data(aseatoms)
sample = cls(**data)
# Update spacegroup information
sample.material.crystal_structure.spacegroup_symbol = symmetry["symbol"]
sample.material.crystal_structure.spacegroup_number = symmetry["number"]
# Add energy as a calculated property
if "energy_per_atom" in doc and doc["energy_per_atom"] is not None:
from atomrdf.datamodels.workflow.property import CalculatedProperty
energy_prop = CalculatedProperty(
basename="PotentialEnergy",
label="Potential energy per atom",
value=float(doc["energy_per_atom"]),
unit="EV",
)
sample.calculated_property = [energy_prop]
# Add to graph if provided
if graph is not None:
sample.to_graph(graph)
aseatoms.info["id"] = sample.id
aseatoms.info["graph"] = graph
samples.append(sample)
# Return single sample or list
if len(samples) == 1:
return samples[0]
else:
return samples
[docs]
def update_attributes(self, atoms, repeat=None):
"""
Update the atom attributes based on the provided ASE Atoms object.
This would also reset the id, since the structure has changed.
"""
self.id = None
self.material.element_ratio = ap.get_chemical_composition(atoms)
self.simulation_cell.volume.value = ap.get_cell_volume(atoms)
self.simulation_cell.number_of_atoms = ap.get_number_of_atoms(atoms)
self.simulation_cell.length = ap.get_simulation_cell_length(atoms)
self.simulation_cell.vector = ap.get_simulation_cell_vector(atoms)
self.simulation_cell.angle = ap.get_simulation_cell_angle(atoms)
if repeat is not None:
if isinstance(repeat, int):
self.simulation_cell.repetitions = (repeat, repeat, repeat)
else:
self.simulation_cell.repetitions = repeat
self.atom_attribute.position = atoms.get_positions().tolist()
self.atom_attribute.species = atoms.get_chemical_symbols()
def to_structure(self, format="ase"):
if format == "ase":
return structure_io.sample_to_ase(self)
else:
raise ValueError(f"Unsupported format: {format}")
[docs]
def to_file(
self,
outfile,
format,
copy_from=None,
pseudo_files=None,
):
"""
Write the structure to a file in the specified format.
Parameters
----------
outfile : str
The path to the output file.
format : str, optional
The format of the output file. Defaults to 'lammps-dump'.
copy_from : str, optional
If provided, input options for quantum-espresso format will be copied from
the given file. Structure specific information will be replaced.
Note that the validity of input file is not checked.
pseudo_files : list, optional
if provided, add the pseudopotential filenames to file.
Should be in alphabetical order of chemical species symbols.
Returns
-------
None
"""
structure_io.write(
self,
outfile,
format=format,
copy_from=copy_from,
pseudo_files=pseudo_files,
)
def _compute_hash(self, precision=6):
"""
Compute a deterministic hash of the sample structure (internal method).
Excludes 'id' and 'graph' fields and rounds floating-point values
to the specified precision to ensure consistent hashing across
equivalent structures.
Parameters
----------
precision : int, optional
Number of decimal places for rounding floats (default: 6)
Returns
-------
str
MD5 hash (hexadecimal string) of the sample content
Examples
--------
>>> sample1 = AtomicScaleSample(**sample_data)
>>> sample2 = AtomicScaleSample(**sample_data)
>>> sample1._compute_hash() == sample2._compute_hash()
True
"""
import hashlib
import json
# Convert to dict and exclude id/label fields
data = self.model_dump(exclude={"id", "label"})
# Helper to round all floats in nested structures
def round_floats(obj, decimals):
if isinstance(obj, float):
return round(obj, decimals)
elif isinstance(obj, dict):
return {k: round_floats(v, decimals) for k, v in obj.items()}
elif isinstance(obj, list):
return [round_floats(v, decimals) for v in obj]
else:
return obj
# Round all floats to avoid precision issues
data_rounded = round_floats(data, precision)
# Create deterministic JSON string (sorted keys)
json_str = json.dumps(data_rounded, sort_keys=True)
# Compute and return MD5 hash
return hashlib.md5(json_str.encode()).hexdigest()