Skip to content

Commit

Permalink
Feature: (Soft) Require Solid Density for Substance.solid() (Merge pu…
Browse files Browse the repository at this point in the history
…ll request #34 from ekwan/feature_solid_density)

Added optional but recommended density parameter to Substance.solid() along with other minor cleanup changes
  • Loading branch information
jlw387 authored Sep 9, 2024
2 parents be40d83 + af11ee6 commit b4f89ec
Show file tree
Hide file tree
Showing 10 changed files with 93 additions and 74 deletions.
6 changes: 3 additions & 3 deletions examples/Example.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# define reagents
print("reagents:")
sodium_sulfate = Substance.solid("sodium sulfate", mol_weight=142.04)
sodium_sulfate = Substance.solid("sodium sulfate", mol_weight=142.04, density=2.66)
triethylamine = Substance.liquid("triethylamine", mol_weight=101.19, density=0.726)
water_tap = Substance.liquid("tap water", 18.0153, 1)
water_DI = Substance.liquid("DI water", 18.0153, 1)
Expand Down Expand Up @@ -118,14 +118,14 @@
solvent=DMSO, total_quantity='10.0 mL')
print(triethylamine_10mM)
print("Diluting to 0.005 M")
result = triethylamine_10mM.dilute(solute=triethylamine, concentration='0.005 M', solvent=DMSO)
result = triethylamine_10mM.dilute_in_place(solute=triethylamine, concentration='0.005 M', solvent=DMSO)
print(result)
print("New concentration:", result.contents[triethylamine]/result.volume)

sodium_sulfate_halfM = Container.create_solution(solute=sodium_sulfate, concentration='0.5 M',
solvent=water_DI, total_quantity='10.0 mL')
print(sodium_sulfate_halfM)
result = sodium_sulfate_halfM.dilute(solute=sodium_sulfate, concentration='0.25 M', solvent=water_DI)
result = sodium_sulfate_halfM.dilute_in_place(solute=sodium_sulfate, concentration='0.25 M', solvent=water_DI)
print(result)
print(result.contents[sodium_sulfate]/result.volume)

Expand Down
12 changes: 5 additions & 7 deletions examples/Test_Example.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@


water = Substance.liquid('H2O', mol_weight=18.0153, density=1)
salt = Substance.solid('NaCl', 58.4428)
salt = Substance.solid('NaCl', 58.4428, 2.17)
triethylamine = Substance.liquid("triethylamine", mol_weight=101.19, density=0.726)

recipe = Recipe()
water_stock = recipe.create_container('water stock', 'inf L', [(water, '1000 mL')])
salt_source = recipe.create_container('salt source', 'inf L', [(salt, '1000 g')])
recipe.create_container('halfM salt water', '1 L', ((water, '100 mL'), (salt, '50 mmol')))
results = recipe.bake()
salt_water_halfM = results['halfM salt water']

water_stock = Container('water stock', 'inf L', [(water, '1000 mL')])
salt_source = Container('salt source', 'inf L', [(salt, '1000 g')])
salt_water_halfM = Container('halfM salt water', '1 L', ((water, '100 mL'), (salt, '50 mmol')))


water_stock_2 = Container('water stock', 'inf L', [(water, '1000 mL')])
Expand Down
1 change: 0 additions & 1 deletion pyplate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ def __init__(self):
assert ('/' in self.concentration_display_unit or self.concentration_display_unit[-1] == 'm' or
self.concentration_display_unit[-1] == 'M')
self.default_solid_density = float(yaml_config['default_solid_density'])
self.default_enzyme_density = float(yaml_config['default_enzyme_density'])
self.default_weight_volume_units = yaml_config['default_weight_volume_units']

self.default_colormap = yaml_config['default_colormap']
Expand Down
4 changes: 2 additions & 2 deletions pyplate/pyplate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ mass_display_unit: g
# Default concentration unit for get_concentration()
concentration_display_unit: M

# density for solids/enzymes in g/mL or U/mL. Can be set to float('inf') to give solids and enzymes zero volume.
# density for solids in g/mL.
# Can be set to float('inf') to give solids and zero volume.
default_solid_density: 1
default_enzyme_density: 1

# units for %w/v
default_weight_volume_units: g/mL
Expand Down
88 changes: 48 additions & 40 deletions pyplate/substance.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Allow typing reference while still building classes
from __future__ import annotations

import warnings

from pyplate.config import config

class Substance:
Expand All @@ -20,13 +22,17 @@ class Substance:

classes = {SOLID: 'Solids', LIQUID: 'Liquids'}

def __init__(self, name: str, mol_type: int, molecule=None):
def __init__(self, name: str, mol_type: int,
mol_weight: float, density: float,
molecule=None):
"""
Create a new substance.
Arguments:
name: Name of substance.
mol_type: Substance.SOLID or Substance.LIQUID.
mol_weight: The molecular weight of the substance in g/mol.
density: The density of the substance in g/mL.
molecule: (optional) A cctk.Molecule.
If cctk.Molecule is provided, molecular weight will automatically populate.
Expand All @@ -35,15 +41,31 @@ def __init__(self, name: str, mol_type: int, molecule=None):
"""
if not isinstance(name, str):
raise TypeError("Name must be a str.")

if not isinstance(mol_type, int):
raise TypeError("Type must be an int.")

if not isinstance(mol_weight, (int, float)):
raise TypeError("Molecular weight must be a float.")
if not isinstance(density, (int, float)):
raise TypeError("Density must be a float.")


if len(name) == 0:
raise ValueError("Name must not be empty.")
if mol_type not in Substance.classes.keys():
#TODO: Maybe improve this error message for users
raise ValueError("Molecular type unsupported. " +
f"Type must be one of: {Substance.classes}")
if not mol_weight > 0:
raise ValueError("Molecular weight must be positive.")
if not density > 0:
raise ValueError("Density must be positive.")

self.name = name
self._type = mol_type
self.mol_weight = self.concentration = None
self.density = float('inf')
self.mol_weight = mol_weight
self.density = density
self.molecule = molecule

def __repr__(self):
Expand All @@ -52,40 +74,43 @@ def __repr__(self):
def __eq__(self, other):
if not isinstance(other, Substance):
return False
return self.name == other.name and self._type == other._type and self.mol_weight == other.mol_weight \
and self.density == other.density and self.concentration == other.concentration
return self.name == other.name and \
self._type == other._type and \
self.mol_weight == other.mol_weight and \
self.density == other.density

def __hash__(self):
return hash((self.name, self._type, self.mol_weight, self.density, self.concentration))
return hash((self.name, self._type, self.mol_weight, self.density))

@staticmethod
def solid(name: str, mol_weight: float, molecule=None) -> Substance:
def solid(name: str, mol_weight: float,
density: float = None, molecule=None) -> Substance:
"""
Creates a solid substance.
Arguments:
name: Name of substance.
mol_weight: Molecular weight in g/mol
density: Density in g/mL. If not provided, a warning will be raised,
and a default value will be used.
molecule: (optional) A cctk.Molecule
Returns: New substance.
Returns: A new solid substance with the specified properties.
"""
if not isinstance(name, str):
raise TypeError("Name must be a str.")
if not isinstance(mol_weight, (int, float)):
raise TypeError("Molecular weight must be a float.")
if density is None:
warning_msg = (
f"Density not provided; using default value of {config.default_solid_density} g/mL. "
"This may result in unexpected volumes for quantities of this substance and "
"solutions containing it."
)
warnings.warn(warning_msg, stacklevel=2)
density = config.default_solid_density
return Substance(name, Substance.SOLID, mol_weight, density, molecule)

if not mol_weight > 0:
raise ValueError("Molecular weight must be positive.")

substance = Substance(name, Substance.SOLID, molecule)
substance.mol_weight = mol_weight
substance.density = config.default_solid_density
return substance

@staticmethod
def liquid(name: str, mol_weight: float, density: float, molecule=None) -> Substance:
def liquid(name: str, mol_weight: float,
density: float, molecule=None) -> Substance:
"""
Creates a liquid substance.
Expand All @@ -95,26 +120,9 @@ def liquid(name: str, mol_weight: float, density: float, molecule=None) -> Subst
density: Density in g/mL
molecule: (optional) A cctk.Molecule
Returns: New substance.
Returns: A new liquid substance with the specified properties.
"""
if not isinstance(name, str):
raise TypeError("Name must be a str.")
if not isinstance(mol_weight, (int, float)):
raise TypeError("Molecular weight must be a float.")
if not isinstance(density, (int, float)):
raise TypeError("Density must be a float.")

if not mol_weight > 0:
raise ValueError("Molecular weight must be positive.")
if not density > 0:
raise ValueError("Density must be positive.")

substance = Substance(name, Substance.LIQUID, molecule)
substance.mol_weight = mol_weight # g / mol
substance.density = density # g / mL
substance.concentration = density / mol_weight # mol / mL
return substance
return Substance(name, Substance.LIQUID, mol_weight, density, molecule)

def is_solid(self) -> bool:
"""
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

@pytest.fixture
def salt() -> Substance:
return Substance.solid('NaCl', 58.4428)
return Substance.solid('NaCl', 58.4428, 2.17)


@pytest.fixture
Expand All @@ -29,7 +29,7 @@ def dmso() -> Substance:

@pytest.fixture
def sodium_sulfate() -> Substance:
return Substance.solid('Sodium sulfate', 142.04)
return Substance.solid('Sodium sulfate', 142.04, 2.66)


@pytest.fixture
Expand Down
14 changes: 7 additions & 7 deletions tests/test_Container.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ def test_Container_transfer(water, salt, water_stock, salt_water):
salt_water_volume = Unit.convert_from_storage(salt_water.volume, 'mL')
container1, container2 = Container.transfer(salt_water, water_stock, f"{salt_water_volume * 0.1} mL")
# 10 mL of water and 5 mol of salt should have been transferred
assert container1.volume == Unit.convert(water, '90 mL', config.volume_storage_unit) \
+ Unit.convert(salt, '45 mmol', config.volume_storage_unit)
assert container1.contents[water] == Unit.convert(water, '90 mL', config.moles_storage_unit)
assert container1.contents[salt] == Unit.convert(salt, '45 mmol', config.moles_storage_unit)
assert container2.volume == Unit.convert(water, '20 mL', config.volume_storage_unit) \
+ Unit.convert(salt, '5 mmol', config.volume_storage_unit)
assert container1.volume == pytest.approx(Unit.convert(water, '90 mL', config.volume_storage_unit) \
+ Unit.convert(salt, '45 mmol', config.volume_storage_unit))
assert container1.contents[water] == pytest.approx(Unit.convert(water, '90 mL', config.moles_storage_unit))
assert container1.contents[salt] == pytest.approx(Unit.convert(salt, '45 mmol', config.moles_storage_unit))
assert container2.volume == pytest.approx(Unit.convert(water, '20 mL', config.volume_storage_unit) \
+ Unit.convert(salt, '5 mmol', config.volume_storage_unit))
assert salt in container2.contents and container2.contents[salt] == \
Unit.convert(salt, '5 mmol', config.moles_storage_unit)
pytest.approx(Unit.convert(salt, '5 mmol', config.moles_storage_unit))
assert container2.contents[water] == pytest.approx(Unit.convert(water, '20 mL', config.moles_storage_unit))

# Original containers should be unchanged.
Expand Down
21 changes: 16 additions & 5 deletions tests/test_Substance.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@ def test_make_solid():
"""
# Argument types checked
with pytest.raises(TypeError, match="Name must be a str"):
Substance.solid(1, 1)
Substance.solid(1, 1, 1)
with pytest.raises(ValueError, match="Name must not be empty"):
Substance.solid('', 1)
Substance.solid('', 1, 1)
with pytest.raises(TypeError, match="Molecular weight must be a float"):
Substance.solid('water', '1')
Substance.solid('water', [], 1)
with pytest.raises(TypeError, match="Molecular weight must be a float"):
Substance.solid('water', '1', 1)
with pytest.raises(TypeError, match="Density must be a float"):
Substance.solid('water', 18.0153, "")
with pytest.raises(TypeError, match="Density must be a float"):
Substance.solid('water', 18.0153, "1")

# Arguments are sane
with pytest.raises(ValueError, match="Molecular weight must be positive"):
Substance.solid('water', -1)
Substance.solid('water', -1, 1)
with pytest.raises(ValueError, match="Molecular weight must be positive"):
Substance.solid('water', 0)
Substance.solid('water', 0, 1)
with pytest.raises(ValueError, match="Density must be positive"):
Substance.solid('water', 1, -1)
with pytest.raises(ValueError, match="Density must be positive"):
Substance.solid('water', 1, 0)


def test_solid(salt):
Expand All @@ -34,6 +44,7 @@ def test_solid(salt):
"""
assert salt.name == 'NaCl'
assert salt.mol_weight == 58.4428
assert salt.density == 2.17


def test_make_liquid():
Expand Down
7 changes: 5 additions & 2 deletions tests/test_recipe_get_container_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ def test_container_flows(sodium_sulfate, water):
recipe.bake()

assert recipe.get_container_flows(container=stock_solution,
timeframe='all', unit='g') == {"in": 0, "out": 10}
assert recipe.get_container_flows(container=dest_container, timeframe='stage 2', unit='mL') == {"out": 9.29,
timeframe='all', unit='mL') == {"in": 0, "out": 10}
# TODO: Remove highly test-specific magic number; compute this from the
# properties of substances involved, and/or change "create_solution" to make
# this number easier to determine.
assert recipe.get_container_flows(container=dest_container, timeframe='stage 2', unit='mL') == {"out": 9.733,
"in": 0}


Expand Down
10 changes: 5 additions & 5 deletions tests/test_transfers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ def test_transfer_between_containers(solution1, solution2, water, salt, dmso, so
assert solution1.volume == Unit.convert_to_storage(solution1_volume, 'mL')
assert solution2.volume == Unit.convert_to_storage(solution2_volume, 'mL')
# 10 mL of water and 5 moles of salt should have been transferred
assert solution3.get_volume(unit='mL') == solution1_volume * 0.9
assert solution4.volume == Unit.convert_to_storage(solution2_volume + solution1_volume*0.1, 'mL')
assert solution3.contents[water] == Unit.convert(water, '90 mL', config.moles_storage_unit)
assert solution3.contents[salt] == Unit.convert(salt, '45 mmol', config.moles_storage_unit)
assert solution3.get_volume(unit='mL') == pytest.approx(solution1_volume * 0.9)
assert solution4.volume == pytest.approx(Unit.convert_to_storage(solution2_volume + solution1_volume*0.1, 'mL'))
assert solution3.contents[water] == pytest.approx(Unit.convert(water, '90 mL', config.moles_storage_unit))
assert solution3.contents[salt] == pytest.approx(Unit.convert(salt, '45 mmol', config.moles_storage_unit))
assert solution4.contents[water] == pytest.approx(Unit.convert(water, '10 mL', config.moles_storage_unit))
assert solution4.contents[salt] == Unit.convert(salt, '5 mmol', config.moles_storage_unit)
assert solution4.contents[salt] == pytest.approx(Unit.convert(salt, '5 mmol', config.moles_storage_unit))


def test_transfer_to_slice(plate1, solution1, salt):
Expand Down

0 comments on commit b4f89ec

Please sign in to comment.