diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 1ddbe78d..6442c16e 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -27,4 +27,4 @@ jobs: - name: Lint run: | - flake8 --max-line-length=120 --ignore=F401,W503,E203 --count --show-source --statistics orsopy + flake8 --max-line-length=120 --ignore=F401,W503,E203,E704 --count --show-source --statistics orsopy diff --git a/examples/sample_model_complex.yml b/examples/sample_model_complex.yml new file mode 100644 index 00000000..b36b1d9a --- /dev/null +++ b/examples/sample_model_complex.yml @@ -0,0 +1,34 @@ +sample: + model: + origin: ORSO test complex resolution + stack: air | 10 ( Si 70 | Fe 70 ) | ss1 | (ss1 in Fe) | ((ss2 | ss3) | 3 (ss1 | ss2) ) | ((((ss4 200))) in air ) | Si + sub_stacks: + ss1: + repetitions: 2 + stack: ss2 | ss3 + ss2: + repetitions: 1 + sequence: + - {thickness: 2.0, material: Si} + - {thickness: 5.0, material: Ti} + ss3: + repetitions: 1 + sequence: + - {thickness: 3.0, material: copper} + - {thickness: 1.0, material: mixin} + ss4: + sub_stack_class: FunctionTwoElements + material1: Ni + material2: Ti + function: x + materials: + copper: + formula: Cu + Fe: + formula: Fe[57] + mass_density: 7.0 + composits: + mixin: + composition: + copper: 0.5 + Si: 0.5 diff --git a/examples/simple_model_example_1.yml b/examples/sample_model_example_1.yml similarity index 100% rename from examples/simple_model_example_1.yml rename to examples/sample_model_example_1.yml diff --git a/examples/simple_model_example_2.yml b/examples/sample_model_example_2.yml similarity index 100% rename from examples/simple_model_example_2.yml rename to examples/sample_model_example_2.yml diff --git a/examples/simple_model_example_3.yml b/examples/sample_model_example_3.yml similarity index 100% rename from examples/simple_model_example_3.yml rename to examples/sample_model_example_3.yml diff --git a/examples/sample_model_example_4.yml b/examples/sample_model_example_4.yml new file mode 100644 index 00000000..2cd31938 --- /dev/null +++ b/examples/sample_model_example_4.yml @@ -0,0 +1,38 @@ +sample: + model: + origin: refnx example + stack: Si | LL | rLL | solvent + sub_stacks: + LL: + sub_stack_class: LipidLeaflet + apm: 56.0 + b_heads: 6.01e-4 + vm_heads: 319.0 + b_tails: -2.92e-4 + vm_tails: 782.0 + thickness_heads: 9.0 + head_solvent: solvent + tail_solvent: solvent + thickness: 23.0 + comment: Shows the equivalence of using Value or float for apm/vm_heads/vm_tails + rLL: + sub_stack_class: LipidLeaflet + apm: {magnitude: 56.0, unit: Angstrom^2} + b_heads: 6.01e-4 + vm_heads: {magnitude: 319.0, unit: Angstrom^3} + b_tails: -2.92e-4 + vm_tails: {magnitude: 782.0, unit: Angstrom^3} + thickness_heads: 9.0 + head_solvent: solvent + tail_solvent: solvent + thickness: 23.0 + reverse_monolayer: true + composits: + solvent: + composition: + H2O: 0.3 + D2O: 0.7 + + globals: + length_unit: angstrom + roughness: {magnitude: 3.0, unit: angstrom} diff --git a/examples/simple_model_example_5.yml b/examples/sample_model_example_5.yml similarity index 100% rename from examples/simple_model_example_5.yml rename to examples/sample_model_example_5.yml diff --git a/examples/sample_model_example_6.yml b/examples/sample_model_example_6.yml new file mode 100644 index 00000000..75aaf763 --- /dev/null +++ b/examples/sample_model_example_6.yml @@ -0,0 +1,20 @@ +sample: + model: + comment: Saw tooth sample model + stack: air | gtop 50 | 5 (rgradient 50 | gradient 50) | Si + sub_stacks: + gradient: + sub_stack_class: FunctionTwoElements + material1: Ni + material2: Ti + function: x + rgradient: + like: gradient + but: + function: 1-x + gtop: + like: gradient + but: + roughness: 15.0 + globals: + slice_resolution: {magnitude: 10.0, unit: nm} \ No newline at end of file diff --git a/examples/sample_model_example_7.yml b/examples/sample_model_example_7.yml new file mode 100644 index 00000000..5a9bf783 --- /dev/null +++ b/examples/sample_model_example_7.yml @@ -0,0 +1,10 @@ +sample: + model: + comment: Sine Wave SLD + stack: air | 5 (wave 10) | Si + sub_stacks: + wave: + sub_stack_class: FunctionTwoElements + material1: Ni + material2: Ti + function: 0.5+0.5*sin(x*2*pi) diff --git a/examples/sample_model_example_8.yml b/examples/sample_model_example_8.yml new file mode 100644 index 00000000..b0d1bb5c --- /dev/null +++ b/examples/sample_model_example_8.yml @@ -0,0 +1,10 @@ +sample: + model: + comment: Modulated Sine Wave SLD + stack: air | wave 200 | Si + sub_stacks: + wave: + sub_stack_class: FunctionTwoElements + material1: Ni + material2: Ti + function: (0.5+0.5*sin(x*20.0*2*pi))*exp(-(x-0.5)**2/0.25**2) diff --git a/examples/simple_model_export_genx.yml b/examples/sample_model_export_genx.yml similarity index 100% rename from examples/simple_model_export_genx.yml rename to examples/sample_model_export_genx.yml diff --git a/examples/simple_model_example_4.yml b/examples/simple_model_example_4.yml deleted file mode 100644 index 570eea76..00000000 --- a/examples/simple_model_example_4.yml +++ /dev/null @@ -1,28 +0,0 @@ -sample: - model: - origin: softmatter example - stack: Si | LL | rLL | D2O - sub_stacks: - LL: - sequence: - - material: {sld: 1.88401254e-06} - thickness: 9.0 - roughness: 3.0 - - material: {sld: -3.73401535e-07} - thickness: 1.4 - roughness: 3.0 - represents: refnx.reflect.LipidLeaflet - arguments: [56, 6.01e-4, 319, 9, -2.92e-4, 782, 14, 3, 3] - rLL: - sequence: - - material: {sld: -3.73401535e-07} - thickness: 1.4 - roughness: 0.0 - - material: {sld: 1.88401254e-06} - thickness: 9.0 - roughness: 3.0 - represents: refnx.reflect.LipidLeaflet - arguments: [56, 6.01e-4, 319, 9, -2.92e-4, 782, 14, 3, 0] - keywords: {reverse_monolayer: true} - globals: - length_unit: angstrom \ No newline at end of file diff --git a/orsopy/__init__.py b/orsopy/__init__.py index 040f4dfd..63c6ddf6 100644 --- a/orsopy/__init__.py +++ b/orsopy/__init__.py @@ -1,3 +1,3 @@ """Top-level package for orsopy.""" -__version__ = "1.2.1" +__version__ = "1.3.0" diff --git a/orsopy/fileio/base.py b/orsopy/fileio/base.py index 76f7deae..f55c1cff 100644 --- a/orsopy/fileio/base.py +++ b/orsopy/fileio/base.py @@ -58,6 +58,7 @@ class Header: """ _orso_optionals: List[str] = [] + # _orso_name_export_priority: List[str] # an optional list of attribute names to put first in the yaml export _subclass_dict_ = {} def __init_subclass__(cls, **kwargs): @@ -271,12 +272,23 @@ def _resolve_type(hint: type, item: Any) -> Any: # check if the item is in the list of allowed # subtypes return item + potential_res = [] for subt in subtypes: # if it's not, then try to resolve its type. - res = Header._resolve_type(subt, item) - if res is not None: - # This type conversion worked, return the result. - return res + with warnings.catch_warnings(record=True) as w: + res = Header._resolve_type(subt, item) + if res is not None: + # This type conversion worked, return the result. + if len(w) > 0: + potential_res.append((w, res)) + else: + return res + if len(potential_res) > 0: + # a potential type was found, but it raised a warning + w, res = potential_res[0] + # make sure the warning is displayed + warnings.warn(w[-1].message, w[-1].category, w[-1].lineno) + return res elif hbase is Literal: # Special case of a string Literal, which defines a list of valid strings. # TODO: Should we first convert the value to a string? @@ -375,6 +387,9 @@ def to_yaml(self) -> str: def _to_object_dict(self): output = {} + # define dictionary entries for attributes to be exported first + for fname in getattr(self, "_orso_name_export_priority", []): + output[fname] = None for i, value in self.__dict__.items(): if i.startswith("_") or (value is None and i in self._orso_optionals): continue @@ -514,6 +529,16 @@ def represent_data(self, data): unit_registry = None +def get_unit_registry(): + global unit_registry + if unit_registry is None: + import pint + + unit_registry = pint.UnitRegistry() + # unit_registry.define("Angstrom = angstrom") # optional extra units + return unit_registry + + @dataclass class ErrorValue(Header): """ @@ -598,11 +623,7 @@ def as_unit(self, output_unit): if output_unit == self.unit: return self.magnitude - global unit_registry - if unit_registry is None: - import pint - - unit_registry = pint.UnitRegistry() + unit_registry = get_unit_registry() val = self.magnitude * unit_registry(self.unit) return val.to(output_unit).magnitude @@ -642,11 +663,7 @@ def as_unit(self, output_unit): if output_unit == self.unit: return value - global unit_registry - if unit_registry is None: - import pint - - unit_registry = pint.UnitRegistry() + unit_registry = get_unit_registry() val = value * unit_registry(self.unit) return val.to(output_unit).magnitude @@ -684,11 +701,7 @@ def as_unit(self, output_unit): if output_unit == self.unit: return (self.min, self.max) - global unit_registry - if unit_registry is None: - import pint - - unit_registry = pint.UnitRegistry() + unit_registry = get_unit_registry() vmin = self.min * unit_registry(self.unit) vmax = self.max * unit_registry(self.unit) @@ -725,11 +738,7 @@ def as_unit(self, output_unit): if output_unit == self.unit: return (self.x, self.y, self.z) - global unit_registry - if unit_registry is None: - import pint - - unit_registry = pint.UnitRegistry() + unit_registry = get_unit_registry() vx = self.x * unit_registry(self.unit) vy = self.y * unit_registry(self.unit) diff --git a/orsopy/fileio/model_building_blocks.py b/orsopy/fileio/model_building_blocks.py new file mode 100644 index 00000000..49fe89cf --- /dev/null +++ b/orsopy/fileio/model_building_blocks.py @@ -0,0 +1,299 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Union + +from ..utils.chemical_formula import Formula +from ..utils.density_resolver import MaterialResolver +from .base import ComplexValue, Header, Value + +DENSITY_RESOLVERS: List[MaterialResolver] = [] + + +class SubStackType(ABC): + # Protocol for all items that can be placed in sub_stack + _orso_name_export_priority = ["sub_stack_class"] + + @property + @abstractmethod + def sub_stack_class(self) -> str: ... + + @abstractmethod + def resolve_names(self, resolvable_items): ... + + @abstractmethod + def resolve_defaults(self, defaults: "ModelParameters"): ... + + @abstractmethod + def resolve_to_layers(self) -> List["Layer"]: ... + + def resolve_to_blocks(self) -> List[Union["Layer", "SubStackType"]]: + return [self] + + +@dataclass +class Material(Header): + formula: Optional[str] = None + mass_density: Optional[Union[float, Value]] = None + number_density: Optional[Union[float, Value]] = None + sld: Optional[Union[float, ComplexValue, Value]] = None + magnetic_moment: Optional[Union[float, Value]] = None + relative_density: Optional[float] = None + + original_name = None + + def resolve_defaults(self, defaults: "ModelParameters"): + if self.formula is None and self.sld is None: + if self.original_name is None: + raise ValueError("Material has to either define sld or formula") + else: + self.formula = self.original_name + if self.mass_density is not None: + if isinstance(self.mass_density, Value) and self.mass_density.unit is None: + self.mass_density.unit = defaults.mass_density_unit + elif not isinstance(self.mass_density, Value): + self.mass_density = Value(self.mass_density, unit=defaults.mass_density_unit) + if self.number_density is not None: + if isinstance(self.number_density, Value) and self.number_density.unit is None: + self.number_density.unit = defaults.number_density_unit + elif not isinstance(self.number_density, Value): + self.number_density = Value(self.number_density, unit=defaults.number_density_unit) + if self.sld is not None: + if isinstance(self.sld, (Value, ComplexValue)) and self.sld.unit is None: + self.sld.unit = defaults.sld_unit + elif not isinstance(self.sld, (Value, ComplexValue)): + self.sld = Value(self.sld, unit=defaults.sld_unit) + if self.magnetic_moment is not None: + if isinstance(self.magnetic_moment, Value) and self.magnetic_moment.unit is None: + self.magnetic_moment.unit = defaults.magnetic_moment_unit + elif not isinstance(self.magnetic_moment, Value): + self.magnetic_moment = Value(self.magnetic_moment, unit=defaults.magnetic_moment_unit) + + def generate_density(self): + if self.sld is not None or self.mass_density is not None or self.number_density is not None: + # this material already contains density information + return + if len(DENSITY_RESOLVERS) == 0: + from ..utils.resolver_slddb import ResolverSLDDB + + DENSITY_RESOLVERS.append(ResolverSLDDB()) + + if self.formula in CACHED_MATERIALS: + self.number_density = CACHED_MATERIALS[self.formula][0] + self.comment = CACHED_MATERIALS[self.formula][1] + return + + try: + formula = Formula(self.formula, strict=True) + except ValueError: + self.number_density = Value(magnitude=0.0, unit="1/nm^3") + self.comment = "could not locate density information for material" + return + + # first search for formula itself + for ri in DENSITY_RESOLVERS: + try: + dens = ri.resolve_formula(formula) + except ValueError: + pass + else: + self.number_density = Value(magnitude=dens, unit="1/nm^3") + self.comment = ri.comment + CACHED_MATERIALS[self.formula] = (self.number_density, ri.comment) + return + # mix elemental density to approximate alloys + for ri in DENSITY_RESOLVERS: + try: + dens = ri.resolve_elemental(formula) + except ValueError: + pass + else: + self.number_density = Value(magnitude=dens, unit="1/nm^3") + self.comment = ri.comment + CACHED_MATERIALS[self.formula] = (self.number_density, ri.comment) + return + self.number_density = Value(magnitude=0.0, unit="1/nm^3") + self.comment = "could not locate density information for material" + + def get_sld(self, xray_energy=None) -> complex: + if self.relative_density is None: + rel = 1.0 + else: + rel = self.relative_density + if self.sld is not None: + return rel * self.sld.as_unit("1/angstrom^2") + 0j + + from orsopy.slddb.material import Material, get_element + + formula = Formula(self.formula, strict=True) + if self.mass_density is not None: + material = Material( + [(get_element(element), amount) for element, amount in formula], + dens=self.mass_density.as_unit("g/cm^3"), + ) + if xray_energy is None: + return rel * material.rho_n + else: + return rel * material.rho_of_E(xray_energy) + elif self.number_density is not None: + material = Material( + [(get_element(element), amount) for element, amount in formula], + fu_dens=self.number_density.as_unit("1/angstrom^3"), + ) + if xray_energy is None: + return rel * material.rho_n + else: + return rel * material.rho_of_E(xray_energy) + else: + return 0.0j + + +@dataclass +class ModelParameters(Header): + roughness: Value = field(default_factory=lambda: Value(0.3, "nm")) + length_unit: str = "nm" + mass_density_unit: str = "g/cm^3" + number_density_unit: str = "1/nm^3" + sld_unit: str = "1/angstrom^2" + magnetic_moment_unit: str = "muB" + slice_resolution: Value = field(default_factory=lambda: Value(1.0, "nm")) + default_solvent: Material = field( + default_factory=lambda: Material(formula="H2O", mass_density=Value(1.0, "g/cm^3")) + ) + + +@dataclass +class Composit(Header): + composition: Dict[str, float] + + original_name = None + + def resolve_names(self, resolvable_items): + self._composition_materials = {} + for key, value in self.composition.items(): + if key in resolvable_items: + material = resolvable_items[key] + elif key in SPECIAL_MATERIALS: + material = SPECIAL_MATERIALS[key] + else: + material = Material(formula=key) + + if isinstance(material, Layer): + # There was a layer that used a formula as name + # resolve to the material of that layer + material = material.material + + self._composition_materials[key] = material + + def resolve_defaults(self, defaults: ModelParameters): + for mat in self._composition_materials.values(): + mat.resolve_defaults(defaults) + + def generate_density(self, xray_energy=None): + """ + Create a material based on the composition attribute. + """ + sld = 0.0 + for key, value in self.composition.items(): + mi = self._composition_materials[key] + mi.generate_density() + sldi = mi.get_sld(xray_energy=xray_energy) + sld += value * sldi + mix_str = ";".join([f"{value}x{key}" for key, value in self.composition.items()]) + return Material( + sld=ComplexValue(real=sld.real, imag=sld.imag, unit="1/angstrom^2"), + comment=f"composition material: {mix_str}", + ) + + def get_sld(self, xray_energy=None): + material = self.generate_density(xray_energy=xray_energy) + return material.get_sld(xray_energy=xray_energy) + + +SPECIAL_MATERIALS = { + "vacuum": Material(sld=ComplexValue(real=0.0, imag=0.0, unit="1/angstrom^2")), + "air": Material(formula="N8O2", mass_density=Value(1.225, unit="kg/m^3")), + "water": Material(formula="H2O", mass_density=Value(1.0, unit="g/cm^3")), +} +CACHED_MATERIALS = {} + + +@dataclass +class Layer(Header): + thickness: Optional[Union[float, Value]] = None + roughness: Optional[Union[float, Value]] = None + material: Optional[Union[Material, Composit, str]] = None + composition: Optional[Dict[str, float]] = None + + original_name = None + + def resolve_names(self, resolvable_items): + if self.material is None and self.composition is None and self.original_name is None: + raise ValueError("Layer has to either define material or composition") + if self.material is not None: + if isinstance(self.material, Material): + pass + elif isinstance(self.material, Composit): + self.material.resolve_names(resolvable_items) + elif self.material in resolvable_items: + possible_material = resolvable_items[self.material] + if isinstance(possible_material, Layer): + # There was another layer that used a formula as name + # fall back to formula from material name. + self.material = Material(formula=self.material) + else: + self.material = possible_material + if isinstance(self.material, Composit): + self.material.resolve_names(resolvable_items) + elif self.material in SPECIAL_MATERIALS: + self.material = SPECIAL_MATERIALS[self.material] + else: + self.material = Material(formula=self.material) + elif self.composition is not None: + self._composition_materials = {} + for key, value in self.composition.items(): + if key in resolvable_items: + material = resolvable_items[key] + elif key in SPECIAL_MATERIALS: + material = SPECIAL_MATERIALS[key] + else: + material = Material(formula=key) + self._composition_materials[key] = material + else: + self.material = Material(formula=self.original_name) + + def resolve_defaults(self, defaults: ModelParameters): + if self.roughness is None: + self.roughness = defaults.roughness + elif not isinstance(self.roughness, Value): + self.roughness = Value(self.roughness, unit=defaults.length_unit) + elif self.roughness.unit is None: + self.roughness.unit = defaults.length_unit + + if self.thickness is None: + self.thickness = Value(0.0, unit=defaults.length_unit) + elif not isinstance(self.thickness, Value): + self.thickness = Value(self.thickness, unit=defaults.length_unit) + elif self.thickness.unit is None: + self.thickness.unit = defaults.length_unit + + if self.material is not None: + self.material.resolve_defaults(defaults) + else: + for mat in self._composition_materials.values(): + mat.resolve_defaults(defaults) + + def generate_material(self): + """ + Create a material based on the composition attribute. + """ + sld = 0.0 + for key, value in self.composition.items(): + mi = self._composition_materials[key] + mi.generate_density() + sldi = mi.get_sld() + sld += value * sldi + mix_str = ";".join([f"{value}x{key}" for key, value in self.composition.items()]) + self.material = Material( + sld=ComplexValue(real=sld.real, imag=sld.imag, unit="1/angstrom^2"), + comment=f"composition material: {mix_str}", + ) diff --git a/orsopy/fileio/model_complex.py b/orsopy/fileio/model_complex.py new file mode 100644 index 00000000..b82cccca --- /dev/null +++ b/orsopy/fileio/model_complex.py @@ -0,0 +1,110 @@ +""" +Build-in blocks of physical units used in model to describe more complex systems. + +All these need to follow the .model_building_blocks.SubStackType protocol and +have a common "sub_stack_class" attribute that has to be set to the class name. +""" + +from dataclasses import dataclass +from typing import List, Optional, Union + +from .base import ComplexValue, Header, Literal, Value +from .model_building_blocks import SPECIAL_MATERIALS, Composit, Layer, Material, ModelParameters, SubStackType + + +@dataclass +class FunctionTwoElements(Header, SubStackType): + """ + Models a continuous variation between two materials/SLDs according to an analytical function. + + The profile rho(z) is defined according to the relative layer thickness as fraction of material 2: + rho(z) = (1-f((x-x0)/thickness))*rho_1 + f((x-x0)/thickness)*rho_2 + + f is bracketed between 0 and 1 to prevent any artefacts with SLDs that are non-physical. + + The function string is evaluated according to python syntax using only build-in operators + and a limited set of mathematical functions and constants defined in the class constant **ALLOWED_FUNCTIONS**. + + TODO: Review class parameters within ORSO. + """ + + material1: str + material2: str + function: str + thickness: Optional[Union[float, Value]] = None + roughness: Optional[Union[float, Value]] = None + slice_resolution: Optional[Union[float, Value]] = None + sub_stack_class: Literal["FunctionTwoElements"] = "FunctionTwoElements" + + ALLOWED_FUNCTIONS = [ + "pi", + "sqrt", + "exp", + "sin", + "cos", + "tan", + "sinh", + "cosh", + "tanh", + "asin", + "acos", + "atan", + ] + + def resolve_names(self, resolvable_items): + self._materials = [] + for i, mi in enumerate([self.material1, self.material2]): + if mi in resolvable_items: + material = resolvable_items[mi] + elif mi in SPECIAL_MATERIALS: + material = SPECIAL_MATERIALS[mi] + else: + material = Material(formula=mi) + self._materials.append(material) + + def resolve_defaults(self, defaults: ModelParameters) -> None: + if self.thickness is None: + self.thickness = Value(0.0, unit=defaults.length_unit) + elif not isinstance(self.thickness, Value): + self.thickness = Value(self.thickness, unit=defaults.length_unit) + elif self.thickness.unit is None: + self.thickness.unit = defaults.length_unit + + if self.roughness is None: + self.roughness = defaults.roughness + elif not isinstance(self.roughness, Value): + self.roughness = Value(self.roughness, unit=defaults.length_unit) + elif self.roughness.unit is None: + self.roughness.unit = defaults.length_unit + + if self.slice_resolution is None: + self.slice_resolution = defaults.slice_resolution + elif not isinstance(self.slice_resolution, Value): + self.slice_resolution = Value(self.slice_resolution, unit=defaults.length_unit) + elif self.slice_resolution.unit is None: + self.slice_resolution.unit = defaults.length_unit + + def resolve_to_layers(self) -> List[Layer]: + # pre-defined math functions allowed + glo = {} + import math + + for name in self.ALLOWED_FUNCTIONS: + param = getattr(math, name) + glo[name] = param + + # use the approximate slice resolution but make sure the total thickness is exact + length_unit = self.thickness.unit + slices = int(round(self.thickness.magnitude / self.slice_resolution.as_unit(length_unit))) + di = self.thickness.magnitude / slices + thickness = Value(magnitude=di, unit=length_unit) + roughness = Value(magnitude=di / 2.0, unit=length_unit) + output = [] + for i in range(slices): + loc = {"x": (i + 0.5) / slices} + fraction = max(0.0, min(1.0, eval(self.function, glo, loc))) + composition = Composit(composition={self.material1: (1.0 - fraction), self.material2: fraction}) + composition.resolve_names({self.material1: self._materials[0], self.material2: self._materials[1]}) + output.append(Layer(material=composition, thickness=thickness, roughness=roughness)) + output[0].roughness = self.roughness + return output diff --git a/orsopy/fileio/model_language.py b/orsopy/fileio/model_language.py index 535d7f57..7a7e19be 100644 --- a/orsopy/fileio/model_language.py +++ b/orsopy/fileio/model_language.py @@ -4,16 +4,17 @@ It includes parsing of models from header or different input information and resolving the model to a simple list of slabs. """ + import warnings -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Union +from dataclasses import dataclass +from typing import Dict, List, Optional, Union from ..utils.chemical_formula import Formula -from ..utils.density_resolver import DensityResolver -from .base import ComplexValue, Header, Value - -DENSITY_RESOLVERS: List[DensityResolver] = [] +from . import model_complex +from .base import Header, Literal +from .model_building_blocks import (DENSITY_RESOLVERS, SPECIAL_MATERIALS, Composit, Layer, Material, ModelParameters, + SubStackType) def find_idx(string, start, value): @@ -25,275 +26,43 @@ def find_idx(string, start, value): return next_idx -@dataclass -class ModelParameters(Header): - roughness: Value = field(default_factory=lambda: Value(0.3, "nm")) - length_unit: str = "nm" - mass_density_unit: str = "g/cm^3" - number_density_unit: str = "1/nm^3" - sld_unit: str = "1/angstrom^2" - magnetic_moment_unit: str = "muB" - - -@dataclass -class Material(Header): - formula: Optional[str] = None - mass_density: Optional[Union[float, Value]] = None - number_density: Optional[Union[float, Value]] = None - sld: Optional[Union[float, ComplexValue, Value]] = None - magnetic_moment: Optional[Union[float, Value]] = None - relative_density: Optional[float] = None - - original_name = None - - def __post_init__(self): - super().__post_init__() - - def resolve_defaults(self, defaults: ModelParameters): - if self.formula is None and self.sld is None: - if self.original_name is None: - raise ValueError("Material has to either define sld or formula") - else: - self.formula = self.original_name - if self.mass_density is not None: - if isinstance(self.mass_density, Value) and self.mass_density.unit is None: - self.mass_density.unit = defaults.mass_density_unit - elif not isinstance(self.mass_density, Value): - self.mass_density = Value(self.mass_density, unit=defaults.mass_density_unit) - if self.number_density is not None: - if isinstance(self.number_density, Value) and self.number_density.unit is None: - self.number_density.unit = defaults.number_density_unit - elif not isinstance(self.number_density, Value): - self.number_density = Value(self.number_density, unit=defaults.number_density_unit) - if self.sld is not None: - if isinstance(self.sld, (Value, ComplexValue)) and self.sld.unit is None: - self.sld.unit = defaults.sld_unit - elif not isinstance(self.sld, (Value, ComplexValue)): - self.sld = Value(self.sld, unit=defaults.sld_unit) - if self.magnetic_moment is not None: - if isinstance(self.magnetic_moment, Value) and self.magnetic_moment.unit is None: - self.magnetic_moment.unit = defaults.magnetic_moment_unit - elif not isinstance(self.magnetic_moment, Value): - self.magnetic_moment = Value(self.magnetic_moment, unit=defaults.magnetic_moment_unit) - - def generate_density(self): - if self.sld is not None or self.mass_density is not None or self.number_density is not None: - # this material already contains density information - return - if len(DENSITY_RESOLVERS) == 0: - from ..utils.resolver_slddb import ResolverSLDDB - - DENSITY_RESOLVERS.append(ResolverSLDDB()) - - if self.formula in CACHED_MATERIALS: - self.number_density = CACHED_MATERIALS[self.formula][0] - self.comment = CACHED_MATERIALS[self.formula][1] - return - - formula = Formula(self.formula) - # first search for formula itself - for ri in DENSITY_RESOLVERS: - try: - dens = ri.resolve_formula(formula) - except ValueError: - pass - else: - self.number_density = Value(magnitude=dens, unit="1/nm^3") - self.comment = ri.comment - CACHED_MATERIALS[self.formula] = (self.number_density, ri.comment) - return - # mix elemental density to approximate alloys - for ri in DENSITY_RESOLVERS: - try: - dens = ri.resolve_elemental(formula) - except ValueError: - pass - else: - self.number_density = Value(magnitude=dens, unit="1/nm^3") - self.comment = ri.comment - CACHED_MATERIALS[self.formula] = (self.number_density, ri.comment) - return - self.number_density = Value(magnitude=0.0, unit="1/nm^3") - self.comment = "could not locate density information for material" - - def get_sld(self, xray_energy=None) -> complex: - if self.relative_density is None: - rel = 1.0 - else: - rel = self.relative_density - if self.sld is not None: - return rel * self.sld.as_unit("1/angstrom^2") + 0j - - from orsopy.slddb.material import Material, get_element - - formula = Formula(self.formula) - if self.mass_density is not None: - material = Material( - [(get_element(element), amount) for element, amount in formula], - dens=self.mass_density.as_unit("g/cm^3"), - ) - if xray_energy is None: - return rel * material.rho_n - else: - return rel * material.rho_of_E(xray_energy) - elif self.number_density is not None: - material = Material( - [(get_element(element), amount) for element, amount in formula], - fu_dens=self.number_density.as_unit("1/angstrom^3"), - ) - if xray_energy is None: - return rel * material.rho_n - else: - return rel * material.rho_of_E(xray_energy) - else: - return 0.0j - - -@dataclass -class Composit(Header): - composition: Dict[str, float] - - original_name = None - - def resolve_names(self, resolvable_items): - self._composition_materials = {} - for key, value in self.composition.items(): - if key in resolvable_items: - material = resolvable_items[key] - elif key in SPECIAL_MATERIALS: - material = SPECIAL_MATERIALS[key] - else: - material = Material(formula=key) - self._composition_materials[key] = material - - def resolve_defaults(self, defaults: ModelParameters): - for mat in self._composition_materials.values(): - mat.resolve_defaults(defaults) - - def generate_density(self): - """ - Create a material based on the composition attribute. - """ - sld = 0.0 - for key, value in self.composition.items(): - mi = self._composition_materials[key] - mi.generate_density() - sldi = mi.get_sld() - sld += value * sldi - mix_str = ";".join([f"{value}x{key}" for key, value in self.composition.items()]) - self.material = Material( - sld=ComplexValue(real=sld.real, imag=sld.imag, unit="1/angstrom^2"), - comment=f"composition material: {mix_str}", - ) - - def get_sld(self, xray_energy=None): - return self.material.get_sld(xray_energy=xray_energy) - - -SPECIAL_MATERIALS = { - "vacuum": Material(sld=ComplexValue(real=0.0, imag=0.0, unit="1/angstrom^2")), - "air": Material(formula="N8O2", mass_density=Value(1.225, unit="kg/m^3")), - "water": Material(formula="H2O", mass_density=Value(1.0, unit="g/cm^3")), -} - -CACHED_MATERIALS = {} - - -@dataclass -class Layer(Header): - thickness: Optional[Union[float, Value]] = None - roughness: Optional[Union[float, Value]] = None - material: Optional[Union[Material, Composit, str]] = None - composition: Optional[Dict[str, float]] = None - - original_name = None - - def __post_init__(self): - super().__post_init__() - - def resolve_names(self, resolvable_items): - if self.material is None and self.composition is None and self.original_name is None: - raise ValueError("Layer has to either define material or composition") - if self.material is not None: - if isinstance(self.material, Material): - pass - elif isinstance(self.material, Composit): - self.material.resolve_names(resolvable_items) - elif self.material in resolvable_items: - possible_material = resolvable_items[self.material] - if isinstance(possible_material, Layer): - # There was another layer that used a formula as name - # fall back to formula from material name. - self.material = Material(formula=self.material) - else: - self.material = possible_material - elif self.material in SPECIAL_MATERIALS: - self.material = SPECIAL_MATERIALS[self.material] - else: - self.material = Material(formula=self.material) - elif self.composition: - self._composition_materials = {} - for key, value in self.composition.items(): - if key in resolvable_items: - material = resolvable_items[key] - elif key in SPECIAL_MATERIALS: - material = SPECIAL_MATERIALS[key] - else: - material = Material(formula=key) - self._composition_materials[key] = material - else: - self.material = Material(formula=self.original_name) - - def resolve_defaults(self, defaults: ModelParameters): - if self.roughness is None: - self.roughness = defaults.roughness - elif not isinstance(self.roughness, Value): - self.roughness = Value(self.roughness, unit=defaults.length_unit) - elif self.roughness.unit is None: - self.roughness.unit = defaults.length_unit - - if self.thickness is None: - self.thickness = Value(0.0, unit=defaults.length_unit) - elif not isinstance(self.thickness, Value): - self.thickness = Value(self.thickness, unit=defaults.length_unit) - elif self.thickness.unit is None: - self.thickness.unit = defaults.length_unit - - if self.material is not None: - self.material.resolve_defaults(defaults) - else: - for mat in self._composition_materials.values(): - mat.resolve_defaults(defaults) - - def generate_material(self): - """ - Create a material based on the composition attribute. - """ - sld = 0.0 - for key, value in self.composition.items(): - mi = self._composition_materials[key] - mi.generate_density() - sldi = mi.get_sld() - sld += value * sldi - mix_str = ";".join([f"{value}x{key}" for key, value in self.composition.items()]) - self.material = Material( - sld=ComplexValue(real=sld.real, imag=sld.imag, unit="1/angstrom^2"), - comment=f"composition material: {mix_str}", - ) +def find_closing(string, start): + open_brackets = 1 + idx = start + while idx < len(string): + if string[idx] == "(": + open_brackets += 1 + if string[idx] == ")": + open_brackets -= 1 + if open_brackets == 0: + return idx + idx += 1 + return -1 @dataclass -class SubStack(Header): +class SubStack(Header, SubStackType): repetitions: int = 1 stack: Optional[str] = None sequence: Optional[List[Layer]] = None - represents: Optional[str] = None - arguments: Optional[List[Any]] = None - keywords: Optional[Dict[str, Any]] = None + sub_stack_class: Literal["SubStack"] = "SubStack" + environment: Optional[Union[str, Material, Composit]] = None original_name = None def resolve_names(self, resolvable_items): + if isinstance(self.environment, str): + env_orig = self.environment + if self.environment in resolvable_items: + self.environment = resolvable_items[self.environment] + elif self.environment in SPECIAL_MATERIALS: + self.environment = SPECIAL_MATERIALS[self.environment] + else: + self.environment = Material(formula=self.environment) + self.environment.original_name = env_orig + if self.environment is not None: + resolvable_items = {"environment": self.environment, **resolvable_items} + if self.stack is None and self.sequence is None: raise ValueError("SubStack has to either define stack or sequence") if self.sequence is None: @@ -303,28 +72,72 @@ def resolve_names(self, resolvable_items): while idx < len(stack): next_idx = find_idx(stack, idx, "|") if "(" in stack[idx:next_idx]: - close_idx = find_idx(stack, idx, ")") + close_idx = find_closing(stack, find_idx(stack, idx, "(") + 1) next_idx = find_idx(stack, close_idx, "|") rep, sub_stack = stack[idx:close_idx].split("(", 1) - rep = int(rep) - obj = SubStack(repetitions=rep, stack=sub_stack.strip()) + if rep.strip() == "": + rep = 1 + else: + rep = int(rep) + rest = stack[close_idx + 1 : next_idx] + if rest.strip().startswith("in "): + # the Stack has elements within a matrix material + environment = rest.strip()[3:] + else: + # if there is a higher level envirnment, it is kept if not overwritten + environment = self.environment + obj = SubStack(repetitions=rep, stack=sub_stack.strip(), environment=environment) else: items = stack[idx:next_idx].strip().rsplit(None, 1) item = items[0].strip() if len(items) == 2: - thickness = float(items[1]) + try: + thickness = float(items[1]) + except ValueError: + # it can't be interpreted as number, assume name has space + thickness = 0.0 + item = stack[idx:next_idx].strip() else: thickness = 0.0 if item in resolvable_items: obj = resolvable_items[item] + if isinstance(obj, SubStackType): + # create a copy of the object to allow different environments for same key + obj = obj.__class__.from_dict(obj.to_dict()) if isinstance(obj, Material) or isinstance(obj, Composit): obj = Layer(material=obj, thickness=thickness) elif getattr(obj, "thickness", "ignore") is None: obj.thickness = thickness else: - obj = Layer(material=item, thickness=thickness) - obj.original_name = item + try: + Formula(item, strict=True) + except ValueError: + # try to resolve name directly with databse + res = None + for resolver in DENSITY_RESOLVERS: + res = resolver.resolve_item(item) + if res is not None: + break + if res is None: + # assume name is a Formula to resolve within Layer + obj = Layer(material=item, thickness=thickness) + obj.original_name = item + else: + if "material" in res: + obj = Layer.from_dict(res) + elif "composition" in res: + obj = Layer(material=Composit.from_dict(res), thickness=thickness) + elif "formula" in res or "sld" in res: + obj = Layer(material=Material.from_dict(res), thickness=thickness) + else: + obj = Layer(material=item, thickness=thickness) + obj.original_name = item + if getattr(obj, "thickness", "ignore") is None: + obj.thickness = thickness + else: + obj = Layer(material=item, thickness=thickness) + obj.original_name = item if hasattr(obj, "resolve_names"): obj.resolve_names(resolvable_items) output.append(obj) @@ -338,8 +151,27 @@ def resolve_defaults(self, defaults: ModelParameters): for li in self.sequence: if hasattr(li, "resolve_defaults"): li.resolve_defaults(defaults) + if self.environment is not None: + self.environment.resolve_defaults(defaults) + + def resolve_to_blocks(self) -> List[Union[Layer, SubStackType]]: + # like resovle_to_layers but keeping SubStackType classes in tact + blocks = list(self.sequence) + added = 0 + for i in range(len(blocks)): + if isinstance(blocks[i + added], Layer): + if blocks[i + added].material is None: + # TODO: verify this case actually exists + blocks[i + added].generate_material() + blocks[i + added].material.generate_density() + else: + obj = blocks.pop(i + added) + sub_blocks = obj.resolve_to_blocks() + blocks = blocks[: i + added] + sub_blocks + blocks[i + added :] + added += len(sub_blocks) - 1 + return blocks - def resolve_to_layers(self): + def resolve_to_layers(self) -> List[Layer]: layers = list(self.sequence) added = 0 for i in range(len(layers)): @@ -355,11 +187,28 @@ def resolve_to_layers(self): return layers * self.repetitions +SUBSTACK_TYPE = SubStack +for T in SubStackType.__subclasses__(): + SUBSTACK_TYPE = Union[SUBSTACK_TYPE, T] + + +@dataclass +class ItemChanger(Header): + """ + Allows to define a simple change in SubStackType item by + just updating a selected set of parameters. + """ + + like: str + but: dict + original_name = None + + @dataclass class SampleModel(Header): stack: str origin: Optional[str] = None - sub_stacks: Optional[Dict[str, SubStack]] = None + sub_stacks: Optional[Dict[str, Union[ItemChanger, SUBSTACK_TYPE]]] = None layers: Optional[Dict[str, Layer]] = None materials: Optional[Dict[str, Material]] = None composits: Optional[Dict[str, Composit]] = None @@ -382,6 +231,12 @@ def resolvable_items(self): output = {} if self.sub_stacks: for key, ssi in self.sub_stacks.items(): + if isinstance(ssi, ItemChanger): + ssi_ref = self.sub_stacks[ssi.like] + ssi_ref_data = ssi_ref.to_dict() + ssi_ref_data.update(ssi.but) + ssi = ssi_ref.__class__.from_dict(ssi_ref_data) + self.sub_stacks[key] = ssi ssi.original_name = key output.update(self.sub_stacks) if self.layers: @@ -410,16 +265,30 @@ def resolve_stack(self): while idx < len(stack): next_idx = find_idx(stack, idx, "|") if "(" in stack[idx:next_idx]: - close_idx = find_idx(stack, idx, ")") + close_idx = find_closing(stack, find_idx(stack, idx, "(") + 1) next_idx = find_idx(stack, close_idx, "|") rep, sub_stack = stack[idx:close_idx].split("(", 1) - rep = int(rep) - obj = SubStack(repetitions=rep, stack=sub_stack.strip()) + if rep.strip() == "": + rep = 1 + else: + rep = int(rep) + rest = stack[close_idx + 1 : next_idx] + if rest.strip().startswith("in "): + # the Stack has elements within a matrix material + environment = rest.strip()[3:] + else: + environment = None + obj = SubStack(repetitions=rep, stack=sub_stack.strip(), environment=environment) else: items = stack[idx:next_idx].strip().rsplit(None, 1) item = items[0].strip() if len(items) == 2: - thickness = float(items[1]) + try: + thickness = float(items[1]) + except ValueError: + # if can't be interpreted as umber, assume name has space + thickness = 0.0 + item = stack[idx:next_idx].strip() else: thickness = 0.0 @@ -430,8 +299,34 @@ def resolve_stack(self): elif getattr(obj, "thickness", "ignore") is None: obj.thickness = thickness else: - obj = Layer(material=item, thickness=thickness) - obj.original_name = item + try: + Formula(item, strict=True) + except ValueError: + # try to resolve name directly with databse + res = None + for resolver in DENSITY_RESOLVERS: + res = resolver.resolve_item(item) + if res is not None: + break + if res is None: + # assume name is a Formula to resolve within Layer + obj = Layer(material=item, thickness=thickness) + obj.original_name = item + else: + if "material" in res: + obj = Layer.from_dict(res) + elif "composition" in res: + obj = Layer(material=Composit.from_dict(res), thickness=thickness) + elif "formula" in res or "sld" in res: + obj = Layer(material=Material.from_dict(res), thickness=thickness) + else: + obj = Layer(material=item, thickness=thickness) + obj.original_name = item + if getattr(obj, "thickness", "ignore") is None: + obj.thickness = thickness + else: + obj = Layer(material=item, thickness=thickness) + obj.original_name = item if hasattr(obj, "resolve_names"): obj.resolve_names(ri) if hasattr(obj, "resolve_defaults"): @@ -440,7 +335,23 @@ def resolve_stack(self): idx = next_idx + 1 return output - def resolve_to_layers(self): + def resolve_to_blocks(self) -> List[Union[Layer, SubStackType]]: + # like resovle_to_layers but keeping SubStackType classes in tact + blocks = self.resolve_stack() + added = 0 + for i in range(len(blocks)): + if isinstance(blocks[i + added], Layer): + if blocks[i + added].material is None: + blocks[i + added].generate_material() + blocks[i + added].material.generate_density() + else: + obj = blocks.pop(i + added) + sub_blocks = obj.resolve_to_blocks() + blocks = blocks[: i + added] + sub_blocks + blocks[i + added :] + added += len(sub_blocks) - 1 + return blocks + + def resolve_to_layers(self) -> List[Layer]: layers = self.resolve_stack() added = 0 for i in range(len(layers)): diff --git a/orsopy/fileio/orso.py b/orsopy/fileio/orso.py index 8cf910ce..0199bd5c 100644 --- a/orsopy/fileio/orso.py +++ b/orsopy/fileio/orso.py @@ -13,7 +13,7 @@ from .data_source import DataSource from .reduction import Reduction -ORSO_VERSION = "1.1" +ORSO_VERSION = "1.2" ORSO_DESIGNATE = ( f"# ORSO reflectivity data file | {ORSO_VERSION} standard " "| YAML encoding | https://www.reflectometry.org/" ) diff --git a/orsopy/fileio/schema/refl_header.schema.json b/orsopy/fileio/schema/refl_header.schema.json index 340db391..a3b94ca3 100644 --- a/orsopy/fileio/schema/refl_header.schema.json +++ b/orsopy/fileio/schema/refl_header.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/reflectivity/orsopy/v1.1/orsopy/fileio/schema/refl_header.schema.json", + "$id": "https://raw.githubusercontent.com/reflectivity/orsopy/v1.2/orsopy/fileio/schema/refl_header.schema.json", "$defs": { "AlternatingField": { "properties": { @@ -540,6 +540,112 @@ "title": "File", "type": "object" }, + "FunctionTwoElements": { + "properties": { + "material1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "material2": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "function": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "thickness": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ], + "default": null + }, + "roughness": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ], + "default": null + }, + "slice_resolution": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ], + "default": null + }, + "sub_stack_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "const": "FunctionTwoElements", + "default": "FunctionTwoElements" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "material1", + "material2", + "function" + ], + "title": "FunctionTwoElements", + "type": "object" + }, "InstrumentSettings": { "properties": { "incident_angle": { @@ -614,6 +720,48 @@ "title": "InstrumentSettings", "type": "object" }, + "ItemChanger": { + "properties": { + "like": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "but": { + "additionalProperties": true, + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "like", + "but" + ], + "title": "ItemChanger", + "type": "object" + }, "Layer": { "properties": { "thickness": { @@ -690,6 +838,208 @@ "title": "Layer", "type": "object" }, + "LipidLeaflet": { + "properties": { + "apm": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ] + }, + "b_heads": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/ComplexValue" + }, + { + "type": "null" + } + ] + }, + "vm_heads": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ] + }, + "b_tails": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/ComplexValue" + }, + { + "type": "null" + } + ] + }, + "vm_tails": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ] + }, + "thickness_heads": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ] + }, + "roughness_head_tail": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ], + "default": null + }, + "head_solvent": { + "anyOf": [ + { + "$ref": "#/$defs/Material" + }, + { + "$ref": "#/$defs/Composit" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "tail_solvent": { + "anyOf": [ + { + "$ref": "#/$defs/Material" + }, + { + "$ref": "#/$defs/Composit" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "thickness": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ], + "default": null + }, + "roughness": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ], + "default": null + }, + "reverse_monolayer": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false + }, + "sub_stack_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "const": "LipidLeaflet", + "default": "LipidLeaflet" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "apm", + "b_heads", + "vm_heads", + "b_tails", + "vm_tails", + "thickness_heads" + ], + "title": "LipidLeaflet", + "type": "object" + }, "Material": { "properties": { "formula": { @@ -929,6 +1279,12 @@ ], "default": "muB" }, + "slice_resolution": { + "$ref": "#/$defs/Value" + }, + "default_solvent": { + "$ref": "#/$defs/Material" + }, "comment": { "anyOf": [ { @@ -1287,7 +1643,20 @@ "anyOf": [ { "additionalProperties": { - "$ref": "#/$defs/SubStack" + "anyOf": [ + { + "$ref": "#/$defs/ItemChanger" + }, + { + "$ref": "#/$defs/SubStack" + }, + { + "$ref": "#/$defs/FunctionTwoElements" + }, + { + "$ref": "#/$defs/LipidLeaflet" + } + ] }, "type": "object" }, @@ -1469,7 +1838,7 @@ ], "default": null }, - "represents": { + "sub_stack_class": { "anyOf": [ { "type": "string" @@ -1478,24 +1847,19 @@ "type": "null" } ], - "default": null + "const": "SubStack", + "default": "SubStack" }, - "arguments": { + "environment": { "anyOf": [ { - "items": {}, - "type": "array" + "$ref": "#/$defs/Material" }, { - "type": "null" - } - ], - "default": null - }, - "keywords": { - "anyOf": [ + "$ref": "#/$defs/Composit" + }, { - "type": "object" + "type": "string" }, { "type": "null" diff --git a/orsopy/fileio/schema/refl_header.schema.yaml b/orsopy/fileio/schema/refl_header.schema.yaml index c610eeb1..892994ed 100644 --- a/orsopy/fileio/schema/refl_header.schema.yaml +++ b/orsopy/fileio/schema/refl_header.schema.yaml @@ -284,6 +284,55 @@ $defs: - file title: File type: object + FunctionTwoElements: + properties: + comment: + anyOf: + - type: string + - type: 'null' + default: null + function: + anyOf: + - type: string + - type: 'null' + material1: + anyOf: + - type: string + - type: 'null' + material2: + anyOf: + - type: string + - type: 'null' + roughness: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + default: null + slice_resolution: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + default: null + sub_stack_class: + anyOf: + - type: string + - type: 'null' + const: FunctionTwoElements + default: FunctionTwoElements + thickness: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + default: null + required: + - material1 + - material2 + - function + title: FunctionTwoElements + type: object InstrumentSettings: properties: comment: @@ -320,6 +369,27 @@ $defs: - wavelength title: InstrumentSettings type: object + ItemChanger: + properties: + but: + additionalProperties: true + anyOf: + - type: object + - type: 'null' + comment: + anyOf: + - type: string + - type: 'null' + default: null + like: + anyOf: + - type: string + - type: 'null' + required: + - like + - but + title: ItemChanger + type: object Layer: properties: comment: @@ -355,6 +425,95 @@ $defs: default: null title: Layer type: object + LipidLeaflet: + properties: + apm: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + b_heads: + anyOf: + - type: number + - $ref: '#/$defs/ComplexValue' + - type: 'null' + b_tails: + anyOf: + - type: number + - $ref: '#/$defs/ComplexValue' + - type: 'null' + comment: + anyOf: + - type: string + - type: 'null' + default: null + head_solvent: + anyOf: + - $ref: '#/$defs/Material' + - $ref: '#/$defs/Composit' + - type: string + - type: 'null' + default: null + reverse_monolayer: + anyOf: + - type: boolean + - type: 'null' + default: false + roughness: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + default: null + roughness_head_tail: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + default: null + sub_stack_class: + anyOf: + - type: string + - type: 'null' + const: LipidLeaflet + default: LipidLeaflet + tail_solvent: + anyOf: + - $ref: '#/$defs/Material' + - $ref: '#/$defs/Composit' + - type: string + - type: 'null' + default: null + thickness: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + default: null + thickness_heads: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + vm_heads: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + vm_tails: + anyOf: + - type: number + - $ref: '#/$defs/Value' + - type: 'null' + required: + - apm + - b_heads + - vm_heads + - b_tails + - vm_tails + - thickness_heads + title: LipidLeaflet + type: object Material: properties: comment: @@ -446,6 +605,8 @@ $defs: - type: string - type: 'null' default: null + default_solvent: + $ref: '#/$defs/Material' length_unit: anyOf: - type: string @@ -473,6 +634,8 @@ $defs: - type: string - type: 'null' default: 1/angstrom^2 + slice_resolution: + $ref: '#/$defs/Value' title: ModelParameters type: object Person: @@ -793,7 +956,11 @@ $defs: sub_stacks: anyOf: - additionalProperties: - $ref: '#/$defs/SubStack' + anyOf: + - $ref: '#/$defs/ItemChanger' + - $ref: '#/$defs/SubStack' + - $ref: '#/$defs/FunctionTwoElements' + - $ref: '#/$defs/LipidLeaflet' type: object - type: 'null' default: null @@ -828,20 +995,16 @@ $defs: type: object SubStack: properties: - arguments: - anyOf: - - items: {} - type: array - - type: 'null' - default: null comment: anyOf: - type: string - type: 'null' default: null - keywords: + environment: anyOf: - - type: object + - $ref: '#/$defs/Material' + - $ref: '#/$defs/Composit' + - type: string - type: 'null' default: null repetitions: @@ -849,11 +1012,6 @@ $defs: - type: integer - type: 'null' default: 1 - represents: - anyOf: - - type: string - - type: 'null' - default: null sequence: anyOf: - items: @@ -866,6 +1024,12 @@ $defs: - type: string - type: 'null' default: null + sub_stack_class: + anyOf: + - type: string + - type: 'null' + const: SubStack + default: SubStack title: SubStack type: object Value: @@ -1073,7 +1237,7 @@ $defs: - error_of title: sR type: object -$id: https://raw.githubusercontent.com/reflectivity/orsopy/v1.1/orsopy/fileio/schema/refl_header.schema.json +$id: https://raw.githubusercontent.com/reflectivity/orsopy/v1.2/orsopy/fileio/schema/refl_header.schema.json $schema: https://json-schema.org/draft/2020-12/schema properties: columns: diff --git a/orsopy/fileio/tests/test_model_language.py b/orsopy/fileio/tests/test_model_language.py index cb71959e..d4bb58a2 100644 --- a/orsopy/fileio/tests/test_model_language.py +++ b/orsopy/fileio/tests/test_model_language.py @@ -1,8 +1,10 @@ """ Tests for fileio.model_language module """ + # pylint: disable=R0201 +import sys import unittest from datetime import datetime @@ -11,6 +13,7 @@ import pytest from orsopy.fileio import ComplexValue, Value +from orsopy.fileio import model_complex as mc from orsopy.fileio import model_language as ml @@ -21,7 +24,7 @@ def test_empty(self): mat.resolve_defaults({}) def test_values(self): - m = ml.Material(formula="Fe2O3", mass_density={"magnitude": 7.0, "unit": "g/cm^3"}) + m = ml.Material(formula="Fe2O3", mass_density=Value(magnitude=7.0, unit="g/cm^3")) assert m.mass_density == Value(7.0, "g/cm^3") def test_default(self): @@ -67,6 +70,30 @@ def test_density_lookup_elements(self): m.generate_density() assert m.number_density is not None assert m.comment == "density from average element density from ORSO SLD db" + # non-resolvable items + m = ml.Material(formula="Ac") + m.generate_density() + assert m.number_density is not None + assert m.comment == "could not locate density information for material" + m = ml.Material(formula="blabla") + m.generate_density() + assert m.number_density is not None + assert m.comment == "could not locate density information for material" + + def test_relative_density(self): + for element in ["Co", "Ni", "Si", "C"]: + m = ml.Material(formula=element) + m.generate_density() + m2 = ml.Material(formula=element, relative_density=0.5) + m2.generate_density() + self.assertEqual(m.get_sld().real * 0.5, m2.get_sld().real) + self.assertEqual(m.get_sld(xray_energy="Cu").real * 0.5, m2.get_sld(xray_energy="Cu").real) + m = ml.Material(formula=element, mass_density=Value(7.0, "g/cm^3")) + m.generate_density() + m2 = ml.Material(formula=element, mass_density=Value(7000.0, "kg/m^3"), relative_density=0.5) + m2.generate_density() + self.assertAlmostEqual(m.get_sld().real * 0.5, m2.get_sld().real) + self.assertAlmostEqual(m.get_sld(xray_energy="Cu").real * 0.5, m2.get_sld(xray_energy="Cu").real) def test_sld(self): m = ml.Material(sld=ComplexValue(3.4e-6, -2e-6, "1/angstrom^2")) @@ -111,6 +138,11 @@ def test_resolution(self): c.resolve_names(materials) for key in ["air", "water", "Si", "Co"]: assert key in c._composition_materials + materials = {"Si": ml.Layer(material=ml.Material(formula="Si"))} + c = ml.Composit({"air": 0.3, "water": 0.3, "Si": 0.2, "Co": 0.1}) + c.resolve_names(materials) + for key in ["air", "water", "Si", "Co"]: + assert key in c._composition_materials def test_defaults(self): defaults = ml.ModelParameters( @@ -184,6 +216,13 @@ def test_resolution(self): lay.resolve_names({}) assert lay.material.formula == "Si" + lay = ml.Layer(material="Si") + lay.resolve_names({"Si": ml.Layer(material=ml.Material(formula="Si"))}) + assert lay.material.formula == "Si" + + lay = ml.Layer(material="Si") + lay.resolve_names({"Si": ml.Layer(material=ml.Material(formula="Si"))}) + def test_defaults(self): defaults = ml.ModelParameters( mass_density_unit="g/cm^3", @@ -207,7 +246,9 @@ def test_defaults(self): assert lay.material.sld.unit == defaults.sld_unit lay = ml.Layer( - material=ml.Material(sld=Value(2.0e-6, defaults.sld_unit)), thickness=Value(31.2), roughness=Value(1.3) + material=ml.Material(sld=Value(2.0e-6, defaults.sld_unit)), + thickness=Value(31.2), + roughness=Value(1.3), ) lay.resolve_defaults(defaults) @@ -236,39 +277,73 @@ def test_empty(self): empty.resolve_names({}) def test_resolution(self): - s = ml.SubStack(stack="air | b 13 |c|d") - resolvable_items = { - "d": ml.Layer(material=ml.Material(sld=Value(2e-6, "1/angstrom^2"))), - "b": ml.Material(formula="Co"), - "c": ml.Composit({"b": 1.0}), - } - s.resolve_names(resolvable_items) - assert len(s.sequence) == 4 - assert s.sequence[0] == ml.Layer(thickness=0.0, material=ml.SPECIAL_MATERIALS["air"]) - assert s.sequence[1] == ml.Layer(thickness=13.0, material=ml.Material(formula="Co")) - assert s.sequence[2] == ml.Layer(thickness=0.0, material=ml.Composit({"b": 1.0})) - assert s.sequence[3] == resolvable_items["d"] - - s = ml.SubStack(stack="air | 2( b 13 | c 5)|d") - s.resolve_names(resolvable_items) - assert len(s.sequence) == 3 - assert s.sequence[0] == ml.Layer(thickness=0.0, material=ml.SPECIAL_MATERIALS["air"]) - assert s.sequence[1] == ml.SubStack( - repetitions=2, - stack="b 13 | c 5", - sequence=[ + with self.subTest("names in stack string"): + s = ml.SubStack(stack="air | b 13 |c|d") + resolvable_items = { + "d": ml.Layer(material=ml.Material(sld=Value(2e-6, "1/angstrom^2"))), + "b": ml.Material(formula="Co"), + "c": ml.Composit({"b": 1.0}), + } + s.resolve_names(resolvable_items) + assert len(s.sequence) == 4 + assert s.sequence[0] == ml.Layer(thickness=0.0, material=ml.SPECIAL_MATERIALS["air"]) + assert s.sequence[1] == ml.Layer(thickness=13.0, material=ml.Material(formula="Co")) + assert s.sequence[2] == ml.Layer(thickness=0.0, material=ml.Composit({"b": 1.0})) + assert s.sequence[3] == resolvable_items["d"] + + with self.subTest("stack in substack"): + s = ml.SubStack(stack="air | 2( b 13 | c 5)|d") + s.resolve_names(resolvable_items) + assert len(s.sequence) == 3 + assert s.sequence[0] == ml.Layer(thickness=0.0, material=ml.SPECIAL_MATERIALS["air"]) + assert s.sequence[1] == ml.SubStack( + repetitions=2, + stack="b 13 | c 5", + sequence=[ + ml.Layer(thickness=13.0, material=ml.Material(formula="Co")), + ml.Layer(thickness=5.0, material=ml.Composit(composition={"b": 1.0})), + ], + ) + assert s.sequence[2] == resolvable_items["d"] + + with self.subTest("direct from sequence"): + s = ml.SubStack( + sequence=[ + ml.Layer(thickness=13.0, material="b"), + ml.Layer(thickness=5.0, material="c"), + ] + ) + s.resolve_names(resolvable_items) + assert s.sequence == [ ml.Layer(thickness=13.0, material=ml.Material(formula="Co")), ml.Layer(thickness=5.0, material=ml.Composit(composition={"b": 1.0})), - ], - ) - assert s.sequence[2] == resolvable_items["d"] - - s = ml.SubStack(sequence=[ml.Layer(thickness=13.0, material="b"), ml.Layer(thickness=5.0, material="c")]) - s.resolve_names(resolvable_items) - assert s.sequence == [ - ml.Layer(thickness=13.0, material=ml.Material(formula="Co")), - ml.Layer(thickness=5.0, material=ml.Composit(composition={"b": 1.0})), - ] + ] + + with self.subTest("environment from names"): + s = ml.SubStack(stack="L1", environment="L1") + resolvable_items = { + "L1": ml.Layer(material=ml.Material(sld=Value(2e-6, "1/angstrom^2"))), + } + s.resolve_names(resolvable_items) + assert s.environment == resolvable_items["L1"] + s = ml.SubStack(stack="L1", environment="air") + resolvable_items = { + "L1": ml.Layer(material=ml.Material(sld=Value(2e-6, "1/angstrom^2"))), + } + s.resolve_names(resolvable_items) + assert s.environment == ml.SPECIAL_MATERIALS["air"] + s = ml.SubStack(stack="L1", environment="Fe") + resolvable_items = { + "L1": ml.Layer(material=ml.Material(sld=Value(2e-6, "1/angstrom^2"))), + } + s.resolve_names(resolvable_items) + assert s.environment.formula == "Fe" + s = ml.SubStack(stack="(L1) in air") + resolvable_items = { + "L1": ml.Layer(material=ml.Material(sld=Value(2e-6, "1/angstrom^2"))), + } + s.resolve_names(resolvable_items) + assert s.sequence[0].environment == ml.SPECIAL_MATERIALS["air"] def test_defaults(self): defaults = ml.ModelParameters( @@ -316,6 +391,24 @@ def test_duplicate_name(self): materials={"a": ml.Material(sld=13.4)}, ) + def test_space_in_name(self): + sm = ml.SampleModel( + stack="c|a b|c", + layers={"a": ml.Layer(thickness=13.4, material="c")}, + materials={"a b": ml.Material(sld=13.4)}, + ) + res = sm.resolvable_items + assert list(res.keys()) == ["a", "a b"] + + def test_name_is_formula(self): + sm = ml.SampleModel( + stack="air | Fe | Si", + layers={"Fe": ml.Layer(thickness=13.4)}, + ) + res = sm.resolvable_items + sm.layers["Fe"].resolve_names(res) + self.assertEqual(sm.layers["Fe"].material.formula, "Fe") + def test_resolve_stack(self): defaults = ml.ModelParameters( mass_density_unit="g/cm^3", @@ -331,7 +424,7 @@ def test_resolve_stack(self): materials = {"c": ml.Material(sld=13.4)} composits = {"d": ml.Composit({"c": 1.0})} sm = ml.SampleModel( - stack="c|2(a|c 15)|d 14|c", + stack="c|2(a|c 15) in b|d 14|c", sub_stacks=sub_stacks, layers=layers, materials=materials, @@ -339,27 +432,107 @@ def test_resolve_stack(self): globals=defaults, ) stack = sm.resolve_stack() - subs = ml.SubStack(repetitions=2, stack="a|c 15") - subs.resolve_names({"a": sub_stacks["a"], "c": materials["c"]}) + subs = ml.SubStack(repetitions=2, stack="a|c 15", environment="b") + subs.resolve_names({"a": sub_stacks["a"], "b": layers["b"], "c": materials["c"]}) subs.resolve_defaults(defaults) assert len(stack) == 4 - assert stack[0] == ml.Layer( - thickness=ml.Value(0.0, "nm"), roughness=defaults.roughness, material=materials["c"] - ) + assert stack[0] == ml.Layer(thickness=Value(0.0, "nm"), roughness=defaults.roughness, material=materials["c"]) assert stack[1] == subs - assert stack[2] == ml.Layer( - thickness=ml.Value(14.0, "nm"), roughness=defaults.roughness, material=composits["d"] - ) - assert stack[3] == ml.Layer( - thickness=ml.Value(0.0, "nm"), roughness=defaults.roughness, material=materials["c"] - ) + assert stack[2] == ml.Layer(thickness=Value(14.0, "nm"), roughness=defaults.roughness, material=composits["d"]) + assert stack[3] == ml.Layer(thickness=Value(0.0, "nm"), roughness=defaults.roughness, material=materials["c"]) sm = ml.SampleModel("Si") stack = sm.resolve_stack() assert len(stack) == 1 assert stack[0] == ml.Layer( - ml.Value(0.0, "nm"), roughness=defaults.roughness, material=ml.Material(formula="Si") + Value(0.0, "nm"), + roughness=defaults.roughness, + material=ml.Material(formula="Si"), ) + def test_resolve_direct_name(self): + sm = ml.SampleModel(stack="air | heavy water 12 | silicon") + stack = sm.resolve_stack() + sm2 = ml.SampleModel(stack="air | heavy water | silicon") + stack2 = sm2.resolve_stack() + sm3 = ml.SampleModel(stack="air | (heavy water) | silicon") + stack3 = sm3.resolve_stack() + self.assertEqual(len(stack), 3) + self.assertEqual(stack[1].material.formula, "D2O") + self.assertEqual(stack[1].thickness.magnitude, 12.0) + self.assertEqual(stack[2].material.formula, "Si") + self.assertEqual(stack2[1].material.formula, "D2O") + self.assertEqual(stack3[1].sequence[0].material.formula, "D2O") + + def test_resolve_dbID(self): + sm = ml.SampleModel(stack="air | ID=276 12 | Si") + stack = sm.resolve_stack() + self.assertEqual(len(stack), 3) + self.assertEqual(stack[1].material.formula, "D2O") + self.assertEqual(stack[1].thickness.magnitude, 12.0) + + def test_resolve_function2e(self): + sm = ml.SampleModel( + stack="air | gradient | Si", + sub_stacks={ + "gradient": mc.FunctionTwoElements( + material1="Cr", material2="Fe", thickness=150.0, function="x", slice_resolution=15.0 + ) + }, + ) + layers = sm.resolve_to_layers() + self.assertEqual(len(layers), 12) + for li in layers: + li.material.generate_density() + li.material.get_sld() + if sys.version_info >= (3, 8, 0): + sm = ml.SampleModel.from_dict(sm.to_dict()) + layers = sm.resolve_to_layers() + self.assertEqual(len(layers), 12) + + def test_resolve_item_changer(self): + sm = ml.SampleModel( + stack="air | L1 | L2 | Si", + sub_stacks={ + "L1": mc.FunctionTwoElements( + material1="Fe", + material2="Cr", + function="x", + thickness=23.0, + ), + "L2": ml.ItemChanger(like="L1", but={"thickness": 12.0}), + }, + ) + layers = sm.resolve_stack() + self.assertEqual(len(layers), 4) + # check that the ItemChanger results in the correctly resolved class + l1 = layers[1] + l2 = layers[2] + self.assertEqual(l1.__class__, l2.__class__) + self.assertEqual(l1.material1, l2.material1) + self.assertEqual(l1.material2, l2.material2) + self.assertEqual(l1.function, l2.function) + self.assertNotEqual(l1.thickness, l2.thickness) + + def test_resolve_element_material_name(self): + sm = ml.SampleModel( + stack="air | Fe | Si", + materials={"Fe": ml.Material(mass_density=Value(7.0, "kg/m^3"))}, + ) + layers = sm.resolve_to_layers() + self.assertEqual(layers[1].material.formula, "Fe") + + def test_resolve_to_blocks(self): + sm = ml.SampleModel( + stack="air | ( gradient | Co ) | Cr | Si", + sub_stacks={ + "gradient": mc.FunctionTwoElements( + material1="Cr", material2="Fe", thickness=150.0, function="x", slice_resolution=15.0 + ) + }, + ) + blocks = sm.resolve_to_blocks() + self.assertTrue(isinstance(blocks[1], mc.FunctionTwoElements)) + def test_resolve_to_layers(self): defaults = ml.ModelParameters( mass_density_unit="g/cm^3", @@ -371,7 +544,10 @@ def test_resolve_to_layers(self): ) sub_stacks = {"a": ml.SubStack(stack="b")} - layers = {"b": ml.Layer(thickness=13.4, material="c"), "b2": ml.Layer(thickness=2.0, composition={"c": 1.0})} + layers = { + "b": ml.Layer(thickness=13.4, material="c"), + "b2": ml.Layer(thickness=2.0, composition={"c": 1.0}), + } materials = {"c": ml.Material(sld=13.4)} composits = {"d": ml.Composit({"c": 1.0})} sm = ml.SampleModel( @@ -386,15 +562,15 @@ def test_resolve_to_layers(self): mSi.generate_density() ls = sm.resolve_to_layers() assert len(ls) == 9 - assert ls[0] == ml.Layer(thickness=ml.Value(0.0, "nm"), roughness=defaults.roughness, material=materials["c"]) + assert ls[0] == ml.Layer(thickness=Value(0.0, "nm"), roughness=defaults.roughness, material=materials["c"]) assert ls[1] == ls[3] assert ls[2] == ls[4] - assert ls[5] == ml.Layer(thickness=ml.Value(13.4, "nm"), roughness=defaults.roughness, material=materials["c"]) + assert ls[5] == ml.Layer(thickness=Value(13.4, "nm"), roughness=defaults.roughness, material=materials["c"]) assert ls[6] == ml.Layer( - thickness=ml.Value(2.0, "nm"), + thickness=Value(2.0, "nm"), roughness=defaults.roughness, material=ml.Material(sld=ComplexValue(13.4, 0.0, "1/angstrom^2"), comment="composition material: 1.0xc"), composition={"c": 1.0}, ) - assert ls[7] == ml.Layer(thickness=ml.Value(14.0, "nm"), roughness=defaults.roughness, material=composits["d"]) + assert ls[7] == ml.Layer(thickness=Value(14.0, "nm"), roughness=defaults.roughness, material=composits["d"]) assert ls[8] == ml.Layer(thickness=Value(0.0, "nm"), roughness=defaults.roughness, material=mSi) diff --git a/orsopy/fileio/tests/test_schema.py b/orsopy/fileio/tests/test_schema.py index 5096b243..9ecc760e 100644 --- a/orsopy/fileio/tests/test_schema.py +++ b/orsopy/fileio/tests/test_schema.py @@ -1,6 +1,6 @@ import json -import unittest import sys +import unittest from pathlib import Path diff --git a/orsopy/utils/chemical_formula.py b/orsopy/utils/chemical_formula.py index 50f038d8..6e33fcf7 100644 --- a/orsopy/utils/chemical_formula.py +++ b/orsopy/utils/chemical_formula.py @@ -26,7 +26,8 @@ class Formula(list): r"\[[1-9][0-9]{0,2}\]" ) - def __init__(self, string, sort=True): + def __init__(self, string, sort=True, strict=False): + self._strict = strict if isinstance(string, list): list.__init__(self, string) if isinstance(string, Formula): @@ -52,6 +53,8 @@ def parse_string(self, string): try: items = self.parse_group(group, case_sensitive=True) except ValueError: + if self._strict: + raise ValueError("Could not parse formula in case sensitive mode") items = self.parse_group(group, case_sensitive=False) items = [(i[0], i[1] * factor) for i in items] # noinspection PyMethodFirstArgAssignment diff --git a/orsopy/utils/density_resolver.py b/orsopy/utils/density_resolver.py index 1af2dd1e..4bc01370 100644 --- a/orsopy/utils/density_resolver.py +++ b/orsopy/utils/density_resolver.py @@ -3,13 +3,23 @@ """ from abc import ABC, abstractmethod +from typing import Union from .chemical_formula import Formula -class DensityResolver(ABC): +class MaterialResolver(ABC): comment = None # comment can be set during a resolve to specify the origin of the data, it can also be constant + def resolve_item(self, name) -> Union[None, dict]: + """ + Optional method for resolving names directly ot Layer or SubStack + compatible class. + + Returns such object or None if name cannot be resolved. + """ + return None + @abstractmethod def resolve_formula(self, formula: Formula) -> float: """ diff --git a/orsopy/utils/resolver_slddb.py b/orsopy/utils/resolver_slddb.py index e7231784..656f2420 100644 --- a/orsopy/utils/resolver_slddb.py +++ b/orsopy/utils/resolver_slddb.py @@ -4,12 +4,41 @@ from ..slddb import api from .chemical_formula import Formula -from .density_resolver import DensityResolver +from .density_resolver import MaterialResolver -class ResolverSLDDB(DensityResolver): +class ResolverSLDDB(MaterialResolver): comment = "" + def resolve_item(self, name): + if name.startswith("ID="): + try: + ID = int(name[3:]) + except ValueError: + pass + else: + m = api.material(ID) + self.comment = f"material from ORSO SLD db ID={ID}" + out = { + "formula": m.formula, + "number_density": 1e3 * m.fu_dens, + "comment": self.comment, + } + return out + + res = api.search(name=name) + for ri in res: + if ri["name"].lower() == name.lower(): + m = api.material(ri["ID"]) + self.comment = f"material '{ri['name']}' from ORSO SLD db ID={res[0]['ID']}" + out = { + "formula": m.formula, + "number_density": 1e3 * m.fu_dens, + "comment": self.comment, + } + return out + return None + def resolve_formula(self, formula: Formula) -> float: res = api.search(formula=formula) if len(res) > 0: @@ -24,6 +53,8 @@ def resolve_elemental(self, formula: Formula) -> float: dens = 0.0 for i in range(len(formula)): res = api.search(formula=formula[i][0]) + if len(res) == 0: + raise ValueError(f"Could not find element {formula[i][0]}") m = api.material(res[0]["ID"]) n += formula[i][1] dens += 1e3 * m.fu_dens