From aaf7b5bc2102c5f26dc863740a13a6e5c8931e64 Mon Sep 17 00:00:00 2001 From: Clemens Prescher Date: Tue, 23 Jul 2024 14:38:37 +0200 Subject: [PATCH] Changed configuration objects into pydantic objects and added proper documentation for the configuration parameters --- docs/apidoc/glassure.configuration.rst | 4 + docs/conf.py | 3 + glassure/configuration.py | 155 ++++++++++++++++++------- pyproject.toml | 1 + tests/__init__.py | 2 +- tests/test_configuration.py | 7 +- tests/test_pattern.py | 12 +- 7 files changed, 131 insertions(+), 53 deletions(-) diff --git a/docs/apidoc/glassure.configuration.rst b/docs/apidoc/glassure.configuration.rst index 0eb364d..25d766c 100644 --- a/docs/apidoc/glassure.configuration.rst +++ b/docs/apidoc/glassure.configuration.rst @@ -1,6 +1,10 @@ glassure.configuration module ============================= +This module contains all configurations classes necessary for the glassure package. +The main entrypoint is the :class:`glassure.configuration.Input` pydantic model containing all necessary information for the analysis. +The models are then further split into submodels for better readability and maintainability. + .. automodule:: glassure.configuration :members: :undoc-members: diff --git a/docs/conf.py b/docs/conf.py index ea48c00..4f6184d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,7 @@ 'sphinx.ext.doctest', 'sphinx.ext.mathjax', 'nbsphinx', + 'sphinxcontrib.autodoc_pydantic', ] source_suffix = ['.rst', '.md'] @@ -35,3 +36,5 @@ html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] + +nbsphinx_allow_errors = True diff --git a/glassure/configuration.py b/glassure/configuration.py index d6cffde..b0fdcbe 100644 --- a/glassure/configuration.py +++ b/glassure/configuration.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*/: from typing import Optional, Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field from dataclasses import dataclass, field from .utility import Composition, convert_density_to_atoms_per_cubic_angstrom @@ -9,59 +9,116 @@ from .methods import FourierTransformMethod, NormalizationMethod, ExtrapolationMethod -@dataclass -class SampleConfig: +class SampleConfig(BaseModel): composition: Composition = field(default_factory=dict) - density: Optional[float] = None - atomic_density: Optional[float] = None + density: Optional[float] = field( + default=None, + ) + atomic_density: Optional[float] = field( + default=None, + ) - def __post_init__(self): + def model_post_init(self, __context): if self.density is not None: self.atomic_density = convert_density_to_atoms_per_cubic_angstrom( self.composition, self.density ) -@dataclass -class FitNormalization: - TYPE: Literal["fit"] = "fit" - q_cutoff: float = 3.0 - method: str = "squared" - multiple_scattering: bool = False - incoherent_scattering: bool = True - container_scattering: Optional[Composition] = None +class FitNormalization(BaseModel): + TYPE: Literal["fit"] = Field(default="fit", description="Normalization type") + q_cutoff: float = Field( + default=3.0, + description="Cutoff q in 1/A for the normalization. Only above this value the normalization is performed.", + ) + method: str = Field( + default="squared", + description='How to scale the values in respect to q during fitting. "linear" or "squared" are possible.', + ) + multiple_scattering: bool = Field( + default=False, + description="Whether to consider multiple scattering - if true, the multiple scattering is approximated by a constant value.", + ) + incoherent_scattering: bool = Field( + default=True, + description="Whether to subtract the incoherent scattering during the normalization.", + ) + container_scattering: Optional[Composition] = Field( + default=None, + description="""Composition of the container material in the experiment. Only the incoherent scattering of the + container is considered. The container scattering is subtracted from the total scattering and the amount is + fitted by just muliplying it with a constant value.""", + ) -@dataclass -class IntNormalization: - TYPE: Literal["integral"] = "integral" - attenuation_factor: float = 0.001 - incoherent_scattering: bool = True +class IntNormalization(BaseModel): + TYPE: Literal["integral"] = Field( + default="integral", description="Normalization type" + ) + attenuation_factor: float = Field( + default=0.001, description="Attenuation factor for the normalization" + ) + incoherent_scattering: bool = Field( + default=True, + description="Whether to subtract the incoherent scattering during the normalization", + ) -@dataclass -class OptimizeConfig: - r_cutoff: float = 1.4 - iterations: int = 5 - use_modification_fcn: bool = False +class OptimizeConfig(BaseModel): + r_cutoff: float = Field( + default=1.4, + description="Cutoff r for the Kaplow optimization scheme. Should be below the first peak in g(r).", + ) + iterations: int = Field( + default=5, description="Number of iterations for the Kaplow optimization." + ) + use_modification_fcn: bool = Field( + default=False, + description="Whether to use the Lorch modification function during the optimization procedure. " + + "This can be different from the transform configuration.", + ) -@dataclass -class ExtrapolationConfig: - method: ExtrapolationMethod = ExtrapolationMethod.STEP - s0: Optional[float] = field(default=None) - overlap: float = 0.2 - replace: bool = False +class ExtrapolationConfig(BaseModel): + method: ExtrapolationMethod = Field( + default=ExtrapolationMethod.STEP, + description="Method for the extrapolation of the structure factor S(q) from q_min to zero.", + ) + s0: Optional[float] = Field( + default=None, + description="Target value at S(0) for the extrapolation to. If is None, the theorethical value is used.", + ) + overlap: float = Field( + default=0.2, + description="Overlap in q-space [1/A] for the extrapolation. E.g. the fitting range.", + ) + replace: bool = Field( + default=False, + description="Whether to replace the original S(q) data in the overlap region with the extrapolated values.", + ) -@dataclass -class TransformConfig: - q_min: float = 0.0 - q_max: float = 10.0 +class TransformConfig(BaseModel): + q_min: float = Field( + default=0.0, + description="Minimum q in 1/Angstrom from the data. Below it will be extended to zero.", + ) + q_max: float = Field( + default=10.0, description="Maximum q in 1/Angstrom from the data." + ) - r_min: float = 0.0 - r_max: float = 10.0 - r_step: float = 0.01 + r_min: float = Field( + default=0.0, + description="Minimum r in Angstrom for the calculated pair distribution function g(r).", + ) + r_max: float = Field( + default=10.0, + description="Maximum r in Angstrom for the calculated pair distribution function g(r).", + ) + r_step: float = Field( + default=0.01, + description="Step size for the r values in Angstrom for the calculated pair distribution function g(r).", + ) normalization: FitNormalization | IntNormalization = field( default_factory=FitNormalization @@ -76,14 +133,28 @@ class TransformConfig: fourier_transform_method: FourierTransformMethod = FourierTransformMethod.FFT -@dataclass -class Config: - sample: SampleConfig = field(default_factory=SampleConfig) - transform: TransformConfig = field(default_factory=TransformConfig) - optimize: Optional[OptimizeConfig] = None +class Config(BaseModel): + """Main configuration model for the glassure data processing. Does not contain any data, but only the information + how to process the dataset.""" + + sample: SampleConfig = Field( + default_factory=SampleConfig, + description="Sample configuration model, containing the composition and density of the material.", + ) + transform: TransformConfig = Field( + default_factory=TransformConfig, + description="""Transform configuration model, containing the normalization, transform and + extrapolation settings.""", + ) + optimize: Optional[OptimizeConfig] = Field( + default=None, + description="Optimization configuration model. If None, no optimization is performed", + ) class Input(BaseModel): + """Main input configuration for the glassure data processing. contains data and configuration.""" + data: Optional[Pattern] = None bkg: Optional[Pattern] = None bkg_scaling: float = 1.0 diff --git a/pyproject.toml b/pyproject.toml index 1731f77..a02f558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ scipy = [ lmfit = "^1.2.0" pandas = "^2.1.0" pydantic = "^2.7.1" +autodoc-pydantic = "^2.2.0" [tool.poetry.group.dev.dependencies] mock = "^5.0.1" diff --git a/tests/__init__.py b/tests/__init__.py index 5297d51..07ae7d0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,7 +2,7 @@ import os -unittest_data_path = os.path.join(os.path.dirname(__file__), 'data') +unittest_data_path = os.path.join(os.path.dirname(__file__), "data") def data_path(filename): diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 916f607..ce945d0 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- from pytest import approx -import json -from dataclasses import asdict import numpy as np @@ -68,8 +66,9 @@ def test_fit_normalization_config(): def test_calculation_config(): c = Config() - c_dict = asdict(c) - test = json.dumps(c_dict) + c_json = c.model_dump_json() + assert type(c.model_dump()) == dict + assert type(c_json) == str def test_input_config(): diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 4c56297..fc0b125 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -124,26 +124,26 @@ def test_from_dict(): assert pattern1.name == pattern2.name -class TestModel(BaseModel): +class DummyModel(BaseModel): x: PydanticNpArray def test_pydantic_nparray_with_array_input(): input_array = np.linspace(0, 10, 1000) - t = TestModel(x=input_array) + t = DummyModel(x=input_array) json = t.model_dump() - t = TestModel(**json) + t = DummyModel(**json) assert np.array_equal(t.x, input_array) def test_pydantic_nparray_with_list_input(): input_array = np.array([1, 2, 3]) - t = TestModel(x=input_array.tolist()) + t = DummyModel(x=input_array.tolist()) assert np.array_equal(t.x, input_array) json = t.model_dump() - t = TestModel(**json) + t = DummyModel(**json) assert np.array_equal(t.x, input_array) def test_pydantic_nparray_from_json(): input = {"x": [1, 2, 3]} - t = TestModel(**input) + t = DummyModel(**input) assert np.array_equal(t.x, np.array([1, 2, 3]))