Source code for AFL.automation.mixing.Solution

import copy
import warnings
from itertools import chain
from typing import Optional, Dict, List

import numpy as np
import pint

from AFL.automation.mixing.Component import Component
from AFL.automation.mixing.Context import Context
from AFL.automation.mixing.MixDB import MixDB
from AFL.automation.shared.exceptions import EmptyException, NotFoundError
from AFL.automation.shared.units import (
    units,
    enforce_units,
    has_units,
    is_volume,
    is_mass,
    AVOGADROS_NUMBER,
)
from AFL.automation.shared.warnings import MixWarning

SANITY_MSG = """
Solution Check:
---------------
{results}
Potential Reasons:
------------------
{reasons}
"""


[docs] class Solution(Context): _stack_name = "stocks"
[docs] def __init__( self, name: str, total_mass: Optional[str | pint.Quantity] = None, total_volume: Optional[str | pint.Quantity] = None, masses: Optional[Dict] = None, volumes: Optional[Dict] = None, concentrations: Optional[Dict] = None, mass_fractions: Optional[Dict] = None, location: Optional[str] = None, solutes: Optional[List[str]] = None, sanity_check: Optional[bool] = True, ): """ Initialize a Solution object. Parameters ---------- name : str The name of the solution. total_mass : str or pint.Quantity, optional The total mass of the solution. total_volume : str or pint.Quantity, optional The total volume of the solution. masses : dict, optional A dictionary of component masses. volumes : dict, optional A dictionary of component volumes. concentrations : dict, optional A dictionary of component concentrations. mass_fractions : dict, optional A dictionary of component mass fractions. location : str, optional The location of the solution on the robot. Usually a deck location e.g., '1A1'. solutes : list of str, optional A list of solute names. If set, the components will be initialized as solutes and they won't contribute to the volume of the solution sanity_check : bool, optional Whether to perform a sanity check on the solution. Raises ------ ValueError If concentrations are set without specifying a component with volume. If mass fractions are set without specifying a component with mass or the total mass. """ super().__init__(name=name) self.context_type = "Solution" self.location = location self.protocol = None self.components: Dict = {} self.add_self_to_context() # Handle initialization of non-specific properties if masses is None: masses = {} if volumes is None: volumes = {} if concentrations is None: concentrations = {} if mass_fractions is None: mass_fractions = {} if solutes is None: solutes = [] # Initialize components for name in chain(masses, volumes, concentrations, mass_fractions, solutes): self.add_component(name, solutes) for name, mass in masses.items(): self.components[name].mass = mass for name, volume in volumes.items(): self.components[name].volume = volume if len(mass_fractions) > 0: if (total_mass is None) and ( (self.mass is None) or (self.mass.magnitude == 0) ): raise ValueError( "Cannot set concentrations without setting a component with mass or specifying the total_mass." ) else: # need to initialize all components with a mass for name in mass_fractions.keys(): self.components[name].mass = '1.0 mg' self.mass = total_mass self.mass_fraction = mass_fractions if len(concentrations) > 0 and ( (self.volume is None) or (self.volume.magnitude == 0) ): raise ValueError( "Cannot set concentrations without setting a component with volume." ) self.concentration = concentrations if total_mass is not None: self.mass = total_mass if total_volume is not None: self.volume = total_volume if sanity_check: self._sanity_check(masses, volumes, concentrations, mass_fractions, total_mass, total_volume)
def _sanity_check(self, masses, volumes, concentrations, mass_fractions, total_mass, total_volume): """ Perform a sanity check on the solution to ensure consistency of requested and final properties. Parameters ---------- masses : dict A dictionary of component masses. volumes : dict A dictionary of component volumes. concentrations : dict A dictionary of component concentrations. mass_fractions : dict A dictionary of component mass fractions. total_mass : str or pint.Quantity, optional The total mass of the solution. total_volume : str or pint.Quantity, optional The total volume of the solution. Raises ------ MixWarning If any inconsistencies are found in the solution properties. """ msg = "" for name, mass in masses.items(): mass = enforce_units(mass, "mass") if not np.isclose(self.components[name].mass, mass): msg += f"Mass of {name} was specified to be {mass} but is now to {self[name].mass}.\n" for name, volume in volumes.items(): volume = enforce_units(volume, "volume") if not np.isclose(self.components[name].volume, volume): msg += f"Volume of {name} was specified to be {volume} but is now {self[name].volume}.\n" for name, concentration in concentrations.items(): concentration = enforce_units(concentration, "concentration") if not np.isclose(self.concentration[name], concentration): msg += f"Concentration of {name} was specified to be {concentration} but is now {self.concentration[name]}.\n" for name, mass_fraction in mass_fractions.items(): if not np.isclose(self.mass_fraction[name], mass_fraction): msg += f"Mass fraction of {name} was specified to be {mass_fraction} but is now {self.mass_fraction[name]}.\n" if total_mass is not None: if not np.isclose(self.mass, enforce_units(total_mass, "mass")): msg += f"Total mass was specified to be {total_mass} but is now {self.mass}.\n" if total_volume is not None: if not np.isclose(self.volume, enforce_units(total_volume, "volume")): msg += f"Total volume was specified to be {total_volume} but is now {self.volume}.\n" if msg: reasons = "" if any( [ ((name in masses) and (name in volumes)) for name, component in self ] ): reasons += "- You have specified the same component(s) in both masses and volumes.\n" if any( [ ((name in masses) and (name in concentrations)) for name, component in self ] ): reasons += "- You have specified the same component(s) in both masses and concentrations.\n" if any( [ ((name in volumes) and (name in concentrations)) for name, component in self ] ): reasons += "- You have specified the same component(s) in both volumes and concentrations.\n" if any( [ ((name in masses) and (name in mass_fractions)) for name, component in self ] ): reasons += "- You have specified the same component(s) in both masses and mass fractions.\n" if any( [ ((name in volumes) and (name in mass_fractions)) for name, component in self ] ): reasons += "- You have specified the same component(s) in both volumes and mass fractions.\n" if any( [ ((name in concentrations) and (name in mass_fractions)) for name, component in self ] ): reasons += "- You have specified the same component(s) in both concentrations and mass fractions.\n" if (total_mass is not None) or (total_volume is not None): reasons += ( "- You specified total_mass and/or total_volume. These transforms happen at the end of the\n " "solution creation and, while they conserve mass_fractions, they do not conserve other\n " "quantities." ) if not reasons: reasons = f"- No clear reasons. This may be the sign of a bug, please report!\n" msg = SANITY_MSG.format(results=msg, reasons=reasons) warnings.warn(msg, MixWarning, stacklevel=2) def __call__(self, reset=False): if reset: self.components.clear() return self def __str__(self): out_str = f'<Solution name:"{self.name}" size:{self.size}>' return out_str def __repr__(self): return self.__str__() def __getitem__(self, name): try: return self.components[name] except KeyError: raise KeyError( f"The component '{name}' is not in this solution which contains: {list(self.components.keys())}" ) def __iter__(self): for name, component in self.components.items(): yield name, component
[docs] def __hash__(self): """Needed so Solutions can be dictionary keys""" return id(self)
[docs] def to_dict(self): out_dict = { "name": self.name, "components": list(self.components.keys()), "masses": {}, } for k, v in self: out_dict["masses"][k] = {"value": v.mass.to("mg").magnitude, "units": "mg"} return out_dict
[docs] def add_component(self, name, solutes: Optional[List[str]] = None): if name not in self.components: try: mixdb = MixDB.get_db() except ValueError: # attempt to instantiate from default location mixdb = MixDB() if solutes and (name in solutes): solute = True else: solute = False try: self.components[name] = Component( solute=solute, **mixdb.get_component(name) ) except NotFoundError: raise
[docs] def set_properties_from_dict(self, properties=None, inplace=False): if properties is not None: if inplace: solution = self else: solution = self.copy() for name, props in properties.items(): if name in ["mass", "volume", "density"]: setattr(solution, name, props) else: # assume setting component properties for prop_name, value in props.items(): setattr(solution.components[name], prop_name, value) return solution else: return self
[docs] def rename_component(self, old_name, new_name, inplace=False): if inplace: solution = self else: solution = self.copy() solution.components[new_name] = solution.components[old_name].copy() del solution.components[old_name] return solution
[docs] def copy(self, name=None): # Create a new instance without copying the context solution = Solution(name=name if name is not None else self.name) solution.context_type = self.context_type solution.location = self.location solution.protocol = self.protocol solution.components = {name: component.copy() for name, component in self.components.items()} return solution
[docs] def contains(self, name: str) -> bool: return name in self.components
@property def size(self): return len(self.components) @property def solutes(self): return [(name, component) for name, component in self if component.is_solute] @property def solvents(self): return [(name, component) for name, component in self if component.is_solvent] def __add__(self, other): mixture = self.copy() mixture.name = self.name + " + " + other.name for name, component in other: if mixture.contains(name): mixture.components[name] = mixture.components[name] + component.copy() else: mixture.components[name] = component.copy() return mixture
[docs] def __eq__(self, other): """Compare the mass,volume, and composition of two mixtures""" # list of true/false values that represent equality checks checks = [ np.isclose(self.mass, other.mass), np.isclose(self.volume, other.volume), ] for name, component in self: checks.append(np.isclose(self[name].mass, other[name].mass)) checks.append( np.isclose(self.mass_fraction[name], other.mass_fraction[name]) ) return all(checks)
[docs] def all_components_have_mass(self): return all([component.has_mass for name, component in self])
@property def mass(self) -> pint.Quantity: """Total mass of mixture.""" return sum([component.mass for name, component in self if component.has_mass]) @mass.setter def mass(self, value: str | pint.Quantity): """Set total mass of mixture.""" # assert self.all_components_have_mass(), ( # f"Cannot set mass of solution with components lacking mass. Current " # f"solution has: { {k:v.mass for k,v in self.components.items()} }" # ) value = enforce_units(value, "mass") scale_factor = value / self.mass for name, component in self: if component.has_mass: component.mass = enforce_units((component.mass * scale_factor), "mass")
[docs] def set_mass(self, value: str | pint.Quantity): """Setter for inline mass changes""" value = enforce_units(value, "mass") solution = self.copy() solution.mass = value return solution
@property def volume(self) -> pint.Quantity: """Total volume of mixture. Only solvents are included in volume calculation""" volumes = [component.volume for name, component in self.solvents] if len(volumes) == 0: return 0 * units("ml") else: return sum(volumes) @volume.setter def volume(self, value: str | pint.Quantity): """Set total volume of mixture. Mass composition will be preserved""" if len(self.solvents) == 0: raise ValueError("Cannot set Solution volume without any Solvents") total_volume = enforce_units(value, "volume") w = self.mass_fraction # grab the density of the first solvent rho_1 = self.solvents[0][1].density denominator = [1.0] # skip the first solvent for name, component in self.solvents[1:]: rho_2 = component.density denominator.append(-w[name] * (1 - rho_1 / rho_2)) for name, component in self.solutes: denominator.append(-w[name]) total_mass = enforce_units(total_volume * rho_1 / sum(denominator), "mass") self.mass = total_mass
[docs] def set_volume(self, value: str | pint.Quantity): """Setter for inline volume changes""" solution = self.copy() solution.volume = value return solution
@property def solvent_sld(self) -> pint.Quantity: sld = [] vfracs = [] for name, vfrac in self.volume_fraction.items(): component_sld = self.components[name].sld if component_sld is None: warnings.warn(f"SLD for solvent {name} is None. Check db", stacklevel=2) continue sld.append(component_sld) vfracs.append(vfrac) sld = [v * s / sum(vfracs) for v, s in zip(vfracs, sld)] return sum(sld) @property def solvent_density(self): m = self.solvent_mass v = self.solvent_volume return enforce_units(m / v, "density") @property def solvent_volume(self): return sum( [component.mass / component.density for name, component in self.solvents] ) @property def solvent_mass(self): return sum([component.mass for name, component in self.solvents]) @property def mass_fraction(self): """Mass fraction of components in mixture Returns ------- mass_fraction: dict Component mass fractions """ total_mass = self.mass mass_fraction = {} for name, component in self: mass_fraction[name] = component.mass / total_mass return {name: component.mass / total_mass for name, component in self} @mass_fraction.setter def mass_fraction(self, target_mass_fractions): """Mass fraction of components in mixture Returns ------- mass_fraction: dict Component mass fractions """ if len(target_mass_fractions) < len(self.components): warnings.warn( "Setting mass fractions for less than all components. This will set a partial mass fraction for those components", MixWarning, stacklevel=2, ) total_mass = sum( [self.components[name].mass for name in target_mass_fractions.keys()] ) for name, fraction in target_mass_fractions.items(): self.components[name].mass = enforce_units(fraction,'dimensionless') * total_mass @property def volume_fraction(self): """Volume fraction of solvents in mixture Returns ------- solvent_fraction: dict Component mass fractions """ total_volume = self.volume return { name: component.volume / total_volume for name, component in self.solvents } @volume_fraction.setter def volume_fraction(self, target_volume_fractions): """Volume fraction of components in mixture Returns ------- volume_fraction: dict Component volume fractions """ if len(target_volume_fractions) < len(self.components): warnings.warn( "Setting volume fractions for less than all components. This will set a partial volume fraction for those components", MixWarning, stacklevel=2, ) total_volume = sum( [self.components[name].volume for name in target_volume_fractions.keys()] ) for name, fraction in target_volume_fractions.items(): self.components[name].volume = enforce_units(fraction,'dimensionless') * total_volume @property def concentration(self): total_volume = self.volume return {name: component.mass / total_volume for name, component in self} @concentration.setter def concentration(self, concentration_dict): total_volume = self.volume for name, concentration in concentration_dict.items(): concentration = enforce_units(concentration, "concentration") self.components[name].mass = enforce_units( concentration * total_volume, "mass" ) @property def molarity(self): total_volume = self.volume result = {} for name, component in self: if component.has_formula: result[name] = enforce_units(component.moles / total_volume, "molarity") return result @molarity.setter def molarity(self, molarity_dict): total_volume = self.volume for name, molarity in molarity_dict.items(): if not self.components[name].has_formula: raise ValueError( f"Attempting to set molarity of component without formula: {name}" ) else: molar_mass = ( self.components[name].formula.molecular_mass * AVOGADROS_NUMBER * units("g") ) self.components[name].mass = enforce_units( molarity * molar_mass * total_volume, "mass" )
[docs] def measure_out( self, amount: str | pint.Quantity, deplete: object = False ) -> "Solution": """Create solution with identical composition at new total mass/volume""" if not has_units(amount): amount = units(amount) if is_volume(amount): solution = self.copy() solution.volume = amount elif is_mass(amount): solution = self.copy() solution.mass = amount else: raise ValueError( f"Must supply measure_out with a volume or mass not {amount.dimensionality}" ) if deplete: if self.volume >= solution.volume: self.volume = self.volume - solution.volume else: raise EmptyException(f"Cannot measure out {solution.volume} from a solution with volume {self.volume}") return solution