Skip to content

Commit

Permalink
New kinetic models, bug fixes and refactoring (#176)
Browse files Browse the repository at this point in the history
* Fix `path` wrongly converted to lower case (Fix #175)

* Update the calculation of propagators

This is to match the way stacked matrices are dealt with in SciPy. When
there is only one delay to compute, the function
`scipy.linalg.expm` is used instead of the diagonalization approach.

* Use `NonNegativeFloat` instead of `PositiveFloat`

This corrects a bug where `0.0` was not an acceptable value for
`h_larmor_frq`, `p_total`, and `l_total`.

* Add new kinetic models for various oligomerization and binding reactions.

* Bump packages versions to the latest

* Remove pre-commit

* Add docstrings and refactor for code maintenance

* Forbid unknown options in method files

* Refactor "spin_system.py" into a package

* Refactor "pick_cest.py" into a package

* Add 1H EXSY experiment
  • Loading branch information
Guillaume Bouvignies authored Nov 28, 2023
1 parent 55d1f7d commit fddcb7e
Show file tree
Hide file tree
Showing 108 changed files with 4,571 additions and 2,088 deletions.
29 changes: 0 additions & 29 deletions .pre-commit-config.yaml

This file was deleted.

63 changes: 56 additions & 7 deletions chemex/configuration/base.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,76 @@
"""Utility module for list and string manipulation in Pydantic models.
This module provides utility functions for ensuring a variable is in list format and
for converting strings to lowercase. It also includes a Pydantic BaseModel subclass
which applies these utilities to convert all keys in a model to lowercase.
Typical usage example:
variable = "Sample"
variable_list = ensure_list(variable)
lower_variable = to_lower("HELLO")
model_instance = BaseModelLowerCase.parse_obj({"Name": "Alice"})
"""
from __future__ import annotations

from typing import Any
from typing import TypeVar, cast

from pydantic import BaseModel, model_validator

T = TypeVar("T")


def ensure_list(variable: T | list[T] | None) -> T | list[T]:
"""Ensures that the input variable is returned as a list.
from pydantic import BaseModel, ConfigDict, model_validator
If the input variable is already a list, it is returned as-is.
If the variable is None, an empty list is returned.
Otherwise, the variable is wrapped in a list and returned.
Parameters:
variable (T | list[T] | None): The variable to be ensured as a list.
def ensure_list(variable: Any | list[Any] | None) -> list[Any]:
Returns:
T | list[T]: The input variable as a list.
"""
if isinstance(variable, list):
return variable
return cast(list[T], variable)
if variable is None:
return []
return [variable]


def to_lower(string: Any) -> Any:
def to_lower(string: T) -> T:
"""Converts a string to lowercase if it is of type str.
If the input is not a string, it is returned unchanged.
Parameters:
string (T): The string to be converted to lowercase.
Returns:
T: The lowercase string if input is a string; otherwise, the original input.
"""
if isinstance(string, str):
return string.lower()
return string


class BaseModelLowerCase(BaseModel):
model_config = ConfigDict(str_to_lower=True)
"""A Pydantic BaseModel class that converts all keys in the model to lowercase.
This is achieved using a model validator that operates before model initialization.
The validator applies the `to_lower` function to each key of the model.
"""

@model_validator(mode="before")
def key_to_lower(cls, model: dict[str, Any]) -> dict[str, Any]:
def key_to_lower(cls, model: dict[str, T]) -> dict[str, T]:
"""Model validator to convert all dictionary keys to lowercase.
Parameters:
model (dict[str, T]): The dictionary model with string keys.
Returns:
dict[str, T]: The modified dictionary with all keys in lowercase.
"""
return {to_lower(k): v for k, v in model.items()}
80 changes: 60 additions & 20 deletions chemex/configuration/conditions.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from __future__ import annotations

from functools import total_ordering
from typing import TYPE_CHECKING, Annotated, Any, Literal
from typing import TYPE_CHECKING, Annotated, Literal, TypeVar

from pydantic import (
BaseModel,
BeforeValidator,
Field,
PositiveFloat,
NonNegativeFloat,
ValidationError,
field_validator,
model_validator,
Expand All @@ -19,30 +19,46 @@
if TYPE_CHECKING:
from collections.abc import Hashable

Self = TypeVar("Self", bound="Conditions")
T = TypeVar("T")
LabelType = Annotated[Literal["1h", "2h", "13c", "15n"], BeforeValidator(to_lower)]


@total_ordering
class Conditions(BaseModel, frozen=True):
h_larmor_frq: PositiveFloat | None = None
"""Represents experimental conditions for NMR measurements.
Attributes:
h_larmor_frq (NonNegativeFloat | None): Larmor frequency of Hydrogen in MHz.
temperature (float | None): Experimental temperature in degrees Celsius.
p_total (NonNegativeFloat | None): Total concentration of protein in the sample.
l_total (NonNegativeFloat | None): Total ligand concentration in the sample.
d2o (float | None): Fraction of D2O in the solvent, between 0 and 1.
label (tuple[LabelType, ...]): Tuple of NMR active isotopes used in the experiment.
"""

h_larmor_frq: NonNegativeFloat | None = None
temperature: float | None = None
p_total: PositiveFloat | None = None
l_total: PositiveFloat | None = None
p_total: NonNegativeFloat | None = None
l_total: NonNegativeFloat | None = None
d2o: float | None = Field(gt=0.0, lt=1.0, default=None)
label: tuple[LabelType, ...] = ()

def rounded(self) -> Conditions:
def rounded(self: Self) -> Self:
"""Returns a new instance with rounded h_larmor_frq and temperature."""
h_larmor_frq = round(self.h_larmor_frq, 1) if self.h_larmor_frq else None
temperature = round(self.temperature, 1) if self.temperature else None
return self.model_copy(
update={"h_larmor_frq": h_larmor_frq, "temperature": temperature}
update={"h_larmor_frq": h_larmor_frq, "temperature": temperature},
)

def match(self, other: Conditions) -> bool:
def match(self: Self, other: Self) -> bool:
"""Checks if the current instance is equivalent to another."""
return self == self & other

@property
def search_keys(self) -> set[Hashable]:
"""Creates a set of hashable search keys based on conditions."""
return {
self.h_larmor_frq,
self.temperature,
Expand All @@ -53,6 +69,7 @@ def search_keys(self) -> set[Hashable]:

@property
def section(self) -> str:
"""Generates a string representation of the conditions."""
parts: list[str] = []
if self.temperature is not None:
parts.append(f"T->{self.temperature:.1f}C")
Expand All @@ -68,6 +85,7 @@ def section(self) -> str:

@property
def folder(self):
"""Generates a folder name representation of the conditions."""
parts: list[str] = []
if self.temperature is not None:
parts.append(f"{self.temperature:.1f}C")
Expand All @@ -83,26 +101,41 @@ def folder(self):

@property
def is_deuterated(self) -> bool:
"""Checks if the conditions include deuterium."""
return "2h" in self.label

def select_conditions(self, conditions_selection: tuple[str, ...]) -> Conditions:
return Conditions.model_construct(
def select_conditions(self: Self, conditions_selection: tuple[str, ...]) -> Self:
"""Selects specific conditions based on given keys.
Args:
conditions_selection (tuple[str, ...]): Keys to select conditions by.
Returns:
A new instance of Conditions with selected conditions.
"""
return type(self).model_construct(
**{
key: value
for key, value in self.model_dump().items()
if key in conditions_selection
}
},
)

def __and__(self, other: Conditions) -> Conditions:
def __and__(self: Self, other: object) -> Self:
if not isinstance(other, type(self)):
return NotImplemented
"""Defines bitwise AND operation for Conditions instances."""
self_dict = self.model_dump()
other_dict = other.model_dump()
intersection = {
key: value for key, value in self_dict.items() if other_dict[key] == value
}
return Conditions.model_construct(**intersection)
return type(self).model_construct(**intersection)

def __lt__(self, other: Conditions) -> bool:
def __lt__(self, other: object) -> bool:
"""Defines less than operation for Conditions instances."""
if not isinstance(other, type(self)):
return NotImplemented
tuple_self = tuple(
value if value is not None else -1e16
for value in self.model_dump().values()
Expand All @@ -116,30 +149,37 @@ def __lt__(self, other: Conditions) -> bool:

@total_ordering
class ConditionsFromFile(Conditions, frozen=True):
"""Extends Conditions to support loading experimental conditions from a file.
This class includes additional validations specific to the file-based input.
"""

@model_validator(mode="before")
def key_to_lower(cls, model: dict[str, Any]) -> dict[str, Any]:
def key_to_lower(cls, model: dict[str, T]) -> dict[str, T]:
"""Converts keys of the model dictionary to lowercase."""
return {to_lower(k): v for k, v in model.items()}

@field_validator("d2o")
def validate_d2o(cls, d2o: float | None) -> float | None:
"""Validates the d2o field for specific model requirements."""
if "hd" in model.name and d2o is None:
msg = 'To use the "hd" model, d2o must be provided'
raise ValidationError(msg)
return d2o

@field_validator("temperature")
def validate_temperature(cls, temperature: float | None) -> float | None:
"""Validates temperature field for specific model requirements."""
if "eyring" in model.name and temperature is None:
msg = 'To use the "eyring" model, "temperature" must be provided'
raise ValidationError(msg)
return temperature

@model_validator(mode="after")
def validate_p_total_l_total(
cls, conditions: ConditionsFromFile
) -> ConditionsFromFile:
are_not_both_set = conditions.p_total is None or conditions.l_total is None
def validate_p_total_l_total(self: Self) -> Self:
"""Validates both p_total and l_total for specific model requirements."""
are_not_both_set = self.p_total is None or self.l_total is None
if "binding" in model.name and are_not_both_set:
msg = 'To use the "binding" model, "p_total" and "l_total" must be provided'
raise ValidationError(msg)
return conditions
return self
7 changes: 6 additions & 1 deletion chemex/configuration/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Generic, TypeVar

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

from chemex.configuration.base import BaseModelLowerCase
from chemex.configuration.conditions import ConditionsFromFile
Expand All @@ -17,14 +17,17 @@ class ToBeFitted:


class ExperimentNameSettings(BaseModelLowerCase):
model_config = ConfigDict(str_to_lower=True)
name: str


class RelaxationSettings(BaseModelLowerCase):
model_config = ConfigDict(str_to_lower=True)
name: str


class CpmgSettings(BaseModelLowerCase):
model_config = ConfigDict(str_to_lower=True)
name: str
even_ncycs: bool = False

Expand All @@ -34,11 +37,13 @@ class CpmgSettingsEvenNcycs(CpmgSettings):


class CestSettings(BaseModelLowerCase):
model_config = ConfigDict(str_to_lower=True)
name: str
sw: float = np.inf


class ShiftSettings(BaseModelLowerCase):
model_config = ConfigDict(str_to_lower=True)
name: str


Expand Down
3 changes: 2 additions & 1 deletion chemex/configuration/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, Literal

from pydantic import BeforeValidator, Field, ValidationError
from pydantic import BeforeValidator, ConfigDict, Field, ValidationError
from pydantic.types import PositiveInt

from chemex.configuration.base import BaseModelLowerCase
Expand Down Expand Up @@ -35,6 +35,7 @@ class Selection:


class Method(BaseModelLowerCase):
model_config = ConfigDict(str_to_lower=True, extra="forbid")
fitmethod: str = "leastsq"
include: SelectionType = None
exclude: SelectionType = None
Expand Down
Loading

0 comments on commit fddcb7e

Please sign in to comment.