#!/usr/bin/env python
# coding: utf-8
"""
A dataclass for specifying a Reagent, Reagentmixture or Product.
"""
from __future__ import annotations
import chempy
from pandas import Timestamp
from dachs.equipment import Equipment
from dachs.helpers import whitespaceCleanup
# from dachs.metaclasses import EnvironmentClass # to get around using Mixture typing inside the Mixture class
__author__ = "Brian R. Pauw"
__contact__ = "brian@stack.nl"
__license__ = "GPLv3+"
__date__ = "2022/11/07"
__status__ = "beta"
import logging
from typing import Dict, List, Optional, Union
from attrs import Factory, define, field, validators
from dachs import ureg # get importError when using: "from . import ureg"
from dachs.additemstoattrs import addItemsToAttrs
from dachs.synthesis import SynthesisClass
# from dachsvalidators import isQuantity
[docs]
@define
class Chemical(addItemsToAttrs):
"""Base chemistry which underpins both Reagents and Products"""
ID: str = field(
default="Chemical",
validator=validators.instance_of(str),
converter=str,
)
ChemicalName: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
ChemicalFormula: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
ChemicalID: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
MolarMass: Optional[ureg.Quantity] = field(
default=None,
validator=validators.optional(validators.instance_of(ureg.Quantity)),
# converter=ureg,
)
Density: Optional[ureg.Quantity] = field(
default=None,
validator=validators.optional(validators.instance_of(ureg.Quantity)),
# converter=ureg,
)
Substance: Optional[chempy.Substance] = field(
default=None,
validator=validators.optional(validators.instance_of(chempy.Substance)),
# converter=chempy.Substance.from_formula,
)
SourceDOI: Optional[str] = field(
default=None,
validator=validators.optional(validators.instance_of(str)),
)
SpaceGroup: Optional[str] = field(
default=None,
validator=validators.optional(validators.instance_of(str)),
)
# internals, don't need a lot of validation:
_excludeKeys: list = ["_excludeKeys", "_storeKeys", "Substance"] # exclude from HDF storage
_storeKeys: list = [] # store these keys (will be filled in later)
_loadKeys: list = [] # load these keys from file if reconstructing
[docs]
@define
class Product(addItemsToAttrs):
"""
Defines a chemical Product as having a chemical structure, with a target mass (100% conversion)
and an actual mass.
"""
ID: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
Chemical: Chemical = field(
default=None,
validator=validators.instance_of(Chemical),
# converter=Reagent,
)
Mass: Optional[ureg.Quantity] = field(
default=None,
validator=validators.optional(validators.instance_of(ureg.Quantity)),
# converter=ureg,
)
Purity: Optional[float] = field(
default=None,
validator=validators.optional(validators.instance_of(ureg.Quantity)),
converter=ureg,
)
Evidence: Optional[str] = field(
default=None,
validator=validators.optional(validators.instance_of(str)),
converter=str,
)
_excludeKeys: list = [
"_excludeKeys",
"_storeKeys",
"SynthesisYield",
] # exclude from HDF storage
_storeKeys: list = [] # store these keys (will be filled in later)
_loadKeys: list = [] # load these keys from file if reconstructing
# def SynthesisYield(self):
# assert (self.ActualMass is not None) and (
# self.TargetMass is not None
# ), logging.warning(
# "Yied can only be calculated when both target mass and actual mass are set"
# )
# assert self.TargetMass > self.Mass, logging.warning(
# "target mass has to be bigger than actual mass"
# )
# return self.Mass / self.TargetMass
[docs]
@define
class Reagent(addItemsToAttrs):
ID: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
Chemical: Chemical = field(
default=None,
validator=validators.instance_of(Chemical),
# converter=Reagent,
)
# Name and the following are in Chemical
CASNumber: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
Brand: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
UNNumber: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
MinimumPurity: float = field(
default=None,
validator=validators.instance_of(ureg.Quantity),
converter=ureg,
)
OpenDate: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
StorageConditions: Optional[str] = field(
default=None,
validator=validators.optional(validators.instance_of(str)),
converter=str,
)
UnitPrice: Optional[float] = field(
default=None,
validator=validators.instance_of(ureg.Quantity),
converter=ureg,
)
UnitSize: float = field(
default=None,
validator=validators.instance_of(ureg.Quantity),
converter=ureg,
)
Used: bool = field(
default=False,
validator=validators.instance_of(bool),
converter=bool,
)
# internals, don't need a lot of validation:
_excludeKeys: list = ["_excludeKeys", "_storeKeys"] # exclude from HDF storage
_storeKeys: list = [] # store these keys (will be filled in later)
_loadKeys: list = [] # load these keys from file if reconstructing
def _CheckForDensity(self):
assert self.Chemical.Density is not None, logging.warning("Chemical.Density must be provided")
return
def _CheckForMolarMass(self):
assert self.Chemical.MolarMass is not None, logging.warning("Chemical.MolarMass must be provided")
return
# @property
def _CheckForPriceCalc(self):
self._CheckForDensity()
assert (self.UnitPrice is not None) and (self.UnitSize is not None), logging.warning(
"Price calculations can only be done when UnitSize and UnitPrice as well as Chemical.Density are set"
)
return
# @property
def PricePerUnit(self) -> ureg.Quantity:
self._CheckForPriceCalc()
return self.UnitPrice / self.UnitSize
# @property
def price_per_mass(self) -> Union[ureg.Quantity, None]:
self._CheckForPriceCalc()
if self.UnitSize.check("[mass]"):
return self.PricePerUnit()
elif self.UnitSize.check("[volume]"):
return (self.PricePerUnit() / self.Chemical.Density).to("euro/g")
else:
logging.warning(f"Price per mass cannot be calculated from {self.PricePerUnit=}")
return None
# @property
def PricePerMole(self) -> ureg.Quantity:
self._CheckForMolarMass()
assert (
self.price_per_mass() is not None
), "Price per mole cannot be calculated as price per mass cannot be calculated"
return (self.price_per_mass() * self.Chemical.MolarMass).to("euro/mole")
def MolesByMass(self, mass: ureg.Quantity) -> ureg.Quantity:
self._CheckForDensity()
self._CheckForMolarMass()
mass.check("[mass]")
return (mass / self.Chemical.MolarMass).to("mole")
def MassByVolume(self, volume: ureg.Quantity) -> ureg.Quantity:
self._CheckForDensity()
volume.check("[volume]")
return (volume * self.Chemical.Density).to("gram")
[docs]
@define
class Mixture(addItemsToAttrs):
"""This class supersedes the ReagentMixture class, and allows Mixtures of Reagents as well as Mixtures of
Mixtures."""
ID: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
MixtureName: str = field(
default=None,
validator=validators.instance_of(str),
converter=str,
)
Description: str = field(
default=None,
validator=validators.instance_of(str),
converter=whitespaceCleanup,
)
DetailedDescription: str = field(
default="",
validator=validators.instance_of(str),
converter=str,
)
ComponentList: List[Union[Reagent, None]] = (
field( # list of Reagents, there is a method to add Mixtures (as individual reagents)
default=Factory(list),
validator=validators.instance_of(list),
)
)
ComponentMasses: Dict[str, Union[ureg.Quantity, None]] = field( # masses of the aforementioned components.
default=Factory(dict),
validator=validators.instance_of(dict),
)
PreparationDate: Timestamp = field(default=None, validator=validators.instance_of(Timestamp))
# str = field(
# default=None,
# validator=validators.instance_of(str),
# converter=str,
# )
Density: Optional[ureg.Quantity] = field( # can be (re)defined for each mixture. cannot be calculated.
default=None,
validator=validators.optional(validators.instance_of(ureg.Quantity)),
# converter=ureg,
)
StorageConditions: Optional[str] = field(
default=None,
validator=validators.optional(validators.instance_of(str)),
converter=str,
)
Synthesis: Optional[SynthesisClass] = field(
default=None,
validator=validators.optional(validators.instance_of(SynthesisClass)),
)
# Environment: Optional[EnvironmentClass] = field(
# default=None,
# validator=validators.optional(validators.instance_of(EnvironmentClass)),
# )
Container: Optional[Equipment] = field(
default=None,
validator=validators.optional(validators.instance_of(Equipment)),
)
# internals, don't need a lot of validation:
_excludeKeys: list = ["_excludeKeys", "_storeKeys"] # exclude from HDF storage
_storeKeys: list = [] # store these keys (will be filled in later)
_loadKeys: list = [] # load these keys from file if reconstructing
def component_concentrations(self) -> List[ureg.Quantity]:
# returns a list of mole concentrations of the Reagents
return [self.component_concentration(MatchComponent=i).to("mole/mole") for i in self.ComponentList]
[docs]
def add_reagent_to_mix(self, reag: Reagent, ReagentMass: ureg.Quantity) -> None:
"""Adds a reagent to the mixture"""
if reag not in self.ComponentList:
self.ComponentList += [reag]
self.ComponentMasses[reag.ID] = ReagentMass
else:
self.ComponentMasses[reag.ID] += ReagentMass
self.DetailedDescription += f"{ReagentMass:.2f~P} of {reag.Chemical.ChemicalName}, and "
# mark the reagent as actually in use:
reag.Used = True
return
[docs]
def add_mixture_to_mix(
self,
mix: Mixture,
AddMixtureMass: ureg.Quantity = None,
AddMixtureVolume: ureg.Quantity = None,
MixtureDensity: ureg.Quantity = None,
) -> None:
"""Adds a mixture to this Mixture"""
# First we find out what fraction of the total mixture mass we are taking on board
# if we are supplied with volume and density, we convert to mass first:
if AddMixtureMass is None:
assert (AddMixtureVolume is not None) and (
MixtureDensity is not None
), "If added mixture mass is not supplied, both volume and density must be defined"
assert AddMixtureVolume.check("[volume]"), "AddMixtureVolume must be a volume"
assert MixtureDensity.check("[mass]/[volume]"), "MixtureDensity must be a density (mass per volume)"
AddMixtureMass = AddMixtureVolume * MixtureDensity
# Check that we are making sense
assert (
AddMixtureMass <= mix.total_mass
), "Sanity check failed, you are adding more mass of mixture than existed in the mixture."
MassFractionOfTotal = (AddMixtureMass / mix.total_mass).to("gram/gram")
for ci, component in enumerate(mix.ComponentList):
if component not in self.ComponentList:
self.ComponentList += [component]
self.ComponentMasses[component.ID] = mix.ComponentMasses[component.ID] * MassFractionOfTotal
else:
self.ComponentMasses[component.ID] += mix.ComponentMasses[component.ID] * MassFractionOfTotal
if AddMixtureVolume is not None:
self.DetailedDescription += (
f"{AddMixtureVolume:.2f~P} ({AddMixtureMass:.2f~P} when assuming a density of"
f" {MixtureDensity:.2f~P}) of {mix.Description}, and "
)
else:
self.DetailedDescription += f"{AddMixtureMass} of {mix.Description}, and "
return
def component_moles(self, MatchComponent: Reagent) -> ureg.Quantity:
componentMoles = 0
for ci, component in enumerate(self.ComponentList):
theseMoles = component.MolesByMass(self.ComponentMasses[component.ID])
if component == MatchComponent:
componentMoles += theseMoles
if componentMoles == 0:
logging.warning(f"Concentration of {MatchComponent=} is zero, component not found")
return componentMoles
def total_moles(self) -> ureg.Quantity:
totalMoles = 0
for ci, component in enumerate(self.ComponentList):
theseMoles = component.MolesByMass(self.ComponentMasses[component.ID])
totalMoles += theseMoles
return totalMoles
[docs]
def component_concentration(self, MatchComponent: Reagent) -> float:
"""
Finds the concentration of a component defined by its entry in the total mixture
This concentration will be in mole fraction.
"""
return self.component_moles(MatchComponent) / self.total_moles()
@property
def total_mass(self) -> ureg.Quantity:
# returns the total mass of the mixture
TMass = ureg.Quantity("0 gram")
for mass in self.ComponentMasses.values():
TMass += mass
return TMass
@property
def total_price(self) -> ureg.Quantity:
# returns the total cost of the miture
# assert False, 'Price calculation Not implemented yet.'
TPrice = 0
for ci, component in enumerate(self.ComponentList):
TPrice += component.price_per_mass() * self.ComponentMasses[component.ID]
return TPrice
def price_per_mass(self) -> ureg.Quantity:
# assert False, 'Price calculation Not implemented yet.'
# returns the cost per mass of the mixture
return self.total_price / self.total_mass