Skip to content

Commit

Permalink
Merge pull request #27 from rkingsbury/volprop
Browse files Browse the repository at this point in the history
Solution.volume property getter/setter
  • Loading branch information
rkingsbury authored Aug 15, 2023
2 parents ca28528 + f2d6e5c commit 03e9fe1
Show file tree
Hide file tree
Showing 5 changed files with 81 additions and 54 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

![pyeql logo](pyeql-logo.png)

A Python library for solution chemistry
A human-friendly python interface for solution chemistry


## Description
Expand Down Expand Up @@ -44,18 +44,19 @@ pyEQL runs on Python 3.8+ and is licensed under LGPL.

### Documentation

Detailed documentation is available at [](https://pyeql.readthedocs.io/)
Detailed documentation is available at [https://pyeql.readthedocs.io/](https://pyeql.readthedocs.io/)

### Dependencies

- Python 3.8+. This project will attempt to adhere to NumPy's
[NEP 29](https://numpy.org/neps/nep-0029-deprecation_policy.html) deprecation policy
for older version of Python.
- [pint](https://github.com/hgrecco/pint) - for units-awarecalculations
- [scipy](https://www.scipy.org/) - for certain nonlinear equation solvers
- [pymatgen](https://github.com/materialsproject/pymatgen) - periodic table and chemical formula information
- [iapws](https://github.com/jjgomera/iapws/) - equations of state for water
- [monty](https://github.com/materialsvirtuallab/monty) - serialization and deserialization utilities
- [maggma](https://materialsproject.github.io/maggma/) - interface for accessing the property database
- [scipy](https://www.scipy.org/) - for certain nonlinear equation solvers

<!-- pyscaffold-notes -->

Expand Down
4 changes: 2 additions & 2 deletions src/pyEQL/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,10 @@ def mix(Solution1, Solution2):
# set the pressure for the new solution
p1 = Solution1.pressure
t1 = Solution1.temperature
v1 = Solution1.get_volume()
v1 = Solution1.volume
p2 = Solution2.pressure
t2 = Solution2.temperature
v2 = Solution2.get_volume()
v2 = Solution2.volume

# check to see if the solutions have the same temperature and pressure
if p1 != p2:
Expand Down
96 changes: 61 additions & 35 deletions src/pyEQL/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ def __init__(
# per-instance cache of get_property calls
self.get_property = lru_cache(maxsize=None)(self._get_property)

# initialize the volume recalculation flag
self.volume_update_required = False

# initialize the volume with a flag to distinguish user-specified volume
if volume is not None:
# volume_set = True
self.volume = unit.Quantity(volume).to("L")
self._volume = unit.Quantity(volume).to("L")
else:
# volume_set = False
self.volume = unit.Quantity("1 L")
self._volume = unit.Quantity("1 L")
# store the initial conditions as private variables in case they are
# changed later
self._temperature = unit.Quantity(temperature)
Expand All @@ -121,9 +124,6 @@ def __init__(
# where moles is the number of moles in the solution.
self.components: dict = {}

# initialize the volume recalculation flag
self.volume_update_required = False

# connect to the desired property database
if not isinstance(database, Store):
if database is None:
Expand Down Expand Up @@ -213,7 +213,7 @@ def add_solute(self, formula, amount):
"[mass]/[length]**3",
):
# store the original volume for later
orig_volume = self.get_volume()
orig_volume = self.volume

# add the new solute
quantity = unit.Quantity(amount)
Expand Down Expand Up @@ -326,15 +326,12 @@ def get_solvent_mass(self):

return self.components[self.solvent] * mw * unit.Quantity("1 kg")

def get_volume(self):
@property
def volume(self) -> Quantity:
"""
Return the volume of the solution.
Parameters
----------
None
Returns
Returns:
-------
Quantity: the volume of the solution, in L
"""
Expand All @@ -343,40 +340,39 @@ def get_volume(self):
self._update_volume()
self.volume_update_required = False

return self.volume.to("L")
return self._volume.to("L")

def set_volume(self, volume):
@volume.setter
def volume(self, volume: str):
"""Change the total solution volume to volume, while preserving
all component concentrations.
Parameters
----------
volume : str quantity
Total volume of the solution, including the unit, e.g. '1 L'
Args:
volume : Total volume of the solution, including the unit, e.g. '1 L'
Examples:
---------
>>> mysol = Solution([['Na+','2 mol/L'],['Cl-','0.01 mol/L']],volume='500 mL')
>>> print(mysol.get_volume())
>>> print(mysol.volume)
0.5000883925072983 l
>>> mysol.list_concentrations()
{'H2O': '55.508435061791985 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
>>> mysol.set_volume('200 mL')
>>> print(mysol.get_volume())
>>> mysol.volume = '200 mL')
>>> print(mysol.volume)
0.2 l
>>> mysol.list_concentrations()
{'H2O': '55.50843506179199 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
"""
# figure out the factor to multiply the old concentrations by
scale_factor = unit.Quantity(volume) / self.get_volume()
scale_factor = unit.Quantity(volume) / self.volume

# scale down the amount of all the solutes according to the factor
for solute in self.components:
self.components[solute] *= scale_factor.magnitude

# update the solution volume
self.volume = unit.Quantity(volume)
self._volume *= scale_factor.magnitude

@property
def mass(self) -> Quantity:
Expand Down Expand Up @@ -409,7 +405,7 @@ def density(self) -> Quantity:
-------
Quantity: The density of the solution.
"""
return self.mass / self.get_volume()
return self.mass / self.volume

@property
def dielectric_constant(self) -> Quantity:
Expand Down Expand Up @@ -1136,7 +1132,7 @@ def add_amount(self, solute, amount):
"[mass]/[length]**3",
):
# store the original volume for later
orig_volume = self.get_volume()
orig_volume = self.volume

# change the amount of the solute present to match the desired amount
self.components[solute] += (
Expand Down Expand Up @@ -1242,7 +1238,7 @@ def set_amount(self, solute, amount):
"[mass]/[length]**3",
):
# store the original volume for later
orig_volume = self.get_volume()
orig_volume = self.volume

# change the amount of the solute present to match the desired amount
self.components[solute] = (
Expand Down Expand Up @@ -1303,7 +1299,7 @@ def get_osmolarity(self, activity_correction=False):
depression. Defaults to FALSE if omitted.
"""
factor = self.get_osmotic_coefficient() if activity_correction is True else 1
return factor * self.get_total_moles_solute() / self.get_volume().to("L")
return factor * self.get_total_moles_solute() / self.volume.to("L")

def get_osmolality(self, activity_correction=False):
"""Return the osmolality of the solution in Osm/kg.
Expand Down Expand Up @@ -1473,7 +1469,7 @@ def get_activity_coefficient(
return molal
if scale == "molar":
total_molality = self.get_total_moles_solute() / self.get_solvent_mass()
total_molarity = self.get_total_moles_solute() / self.get_volume()
total_molarity = self.get_total_moles_solute() / self.volume
return (molal * self.water_substance.rho * unit.Quantity("1 g/L") * total_molality / total_molarity).to(
"dimensionless"
)
Expand Down Expand Up @@ -1994,7 +1990,7 @@ def get_lattice_distance(self, solute):

def _update_volume(self):
"""Recalculate the solution volume based on composition."""
self.volume = self._get_solvent_volume() + self._get_solute_volume()
self._volume = self._get_solvent_volume() + self._get_solute_volume()

def _get_solvent_volume(self):
"""Return the volume of the pure solvent."""
Expand Down Expand Up @@ -2038,9 +2034,10 @@ def copy(self):
def as_dict(self) -> dict:
"""
Convert the Solution into a dict representation that can be serialized to .json or other format.
This method is mostly inherited from MSONable
"""
# clear the volume update flag, if required
if self.volume_update_required:
self._update_volume()
d = super().as_dict()
# replace solutes with the current composition
d["solutes"] = {k: v * unit.Quantity("1 mol") for k, v in self.components.items()}
Expand All @@ -2052,10 +2049,25 @@ def as_dict(self) -> dict:
def from_dict(cls, d: dict) -> "Solution":
"""
Instantiate a Solution from a dictionary generated by as_dict().
This method is inherited from MSONable
"""
return super().from_dict(d)
# because of the automatic volume updating that takes place during the __init__ process,
# care must be taken here to recover the exact quantities of solute and volume
# first we store the volume of the serialized solution
orig_volume = unit.Quantity(d["volume"])
# then instantiate a new one
new_sol = super().from_dict(d)
# now determine how different the new solution volume is from the original
scale_factor = (orig_volume / new_sol.volume).magnitude
# reset the new solution volume to that of the original. In the process of
# doing this, all the solute amounts are scaled by new_sol.volume / volume
new_sol.volume = str(orig_volume)
# undo the scaling by diving by that scale factor
for sol in new_sol.components:
new_sol.components[sol] /= scale_factor
# ensure that another volume update won't be triggered by these changes
# (this line should in principle be unnecessary, but it doesn't hurt anything)
new_sol.volume_update_required = False
return new_sol

# informational methods
def list_solutes(self):
Expand Down Expand Up @@ -2139,7 +2151,7 @@ def list_activities(self, decimals=4):

def __str__(self):
# set output of the print() statement for the solution
str1 = f"Volume: {self.get_volume():.3f~}\n"
str1 = f"Volume: {self.volume:.3f~}\n"
str2 = f"Pressure: {self.pressure:.3f~}\n"
str3 = f"Temperature: {self.temperature:.3f~}\n"
str4 = f"Components: {self.list_solutes():}\n"
Expand Down Expand Up @@ -2238,6 +2250,20 @@ def set_pressure(self, pressure):
"""
self._pressure = unit.Quantity(pressure)

@deprecated(
message="get_volume() will be removed in the next release. Access the volume directly via Solution.volume"
)
def get_volume(self):
""" """
return self.volume

@deprecated(
message="set_pressure() will be removed in the next release. Set the pressure directly via Solution.pressure"
)
def set_volume(self, volume: str):
""" """
self.volume = volume

@deprecated(message="get_mass() will be removed in the next release. Use the Solution.mass property instead.")
def get_mass(self):
"""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_empty_solution_3():
# It should return type Solution
assert isinstance(s1, Solution)
# It should have exactly 1L volume
assert s1.get_volume().to("L").magnitude == 1.0
assert s1.volume.to("L").magnitude == 1.0
# the solvent should be water
assert s1.solvent == "H2O"
# It should have 0.997 kg water mass
Expand All @@ -59,7 +59,7 @@ def test_empty_solution_3():
def test_solute_addition(s2, s3, s4):
# if solutes are added at creation-time with substance / volume units,
# then the total volume of the solution should not change (should remain at 2 L)
assert s2.get_volume().to("L").magnitude == 2
assert s2.volume.to("L").magnitude == 2

# if solutes are added at creation-time with substance / volume units,
# then the resulting mol/L concentrations should be exactly what was specified
Expand Down
24 changes: 12 additions & 12 deletions tests/test_volume_concentration.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def test_set_amount_1(self, s2):
# unit, the volume should not change
s2.set_amount("Na+", "5 mol/L")
s2.set_amount("Cl-", "5 mol/L")
assert s2.get_volume().to("L").magnitude == 2
assert s2.volume.to("L").magnitude == 2

def test_set_amount_2(self, s2):
# If the concentration of a solute is directly set with a substance / volume
Expand All @@ -66,10 +66,10 @@ def test_set_amount_3(self, s2):
def test_set_amount_4(self, s2):
# If the concentration of a solute is directly set with a substance / mass
# unit, the volume should increase
original = s2.get_volume().to("L").magnitude
original = s2.volume.to("L").magnitude
s2.set_amount("Na+", "5 mol/kg")
s2.set_amount("Cl-", "5 mol/kg")
assert s2.get_volume().to("L").magnitude > original
assert s2.volume.to("L").magnitude > original

def test_set_amount_5(self, s2):
# If the concentration of a solute is directly set with a substance / mass
Expand All @@ -89,10 +89,10 @@ def test_set_amount_6(self, s2):
def test_set_amount_7(self, s2):
# If the concentration of a solute is directly set with a substance
# unit, the volume should increase
original = s2.get_volume().to("L").magnitude
original = s2.volume.to("L").magnitude
s2.set_amount("Na+", "10 mol")
s2.set_amount("Cl-", "10 mol")
assert s2.get_volume().to("L").magnitude > original
assert s2.volume.to("L").magnitude > original

def test_set_amount_8(self, s2):
# If the concentration of a solute is directly set with a substance
Expand All @@ -118,7 +118,7 @@ def test_add_amount_1(self, s2):
# unit, the volume should not change
s2.add_amount("Na+", "1 mol/L")
s2.add_amount("Cl-", "1 mol/L")
assert np.allclose(s2.get_volume().to("L").magnitude, 2)
assert np.allclose(s2.volume.to("L").magnitude, 2)

def test_add_amount_2(self, s2):
# If the concentration of a solute is directly increased with a substance / volume
Expand All @@ -140,10 +140,10 @@ def test_add_amount_4(self, s3):

# If the concentration of a solute is directly increased with a substance / mass
# unit, the volume should increase
original = s3.get_volume().to("L").magnitude
original = s3.volume.to("L").magnitude
s3.add_amount("Na+", "1 mol/kg")
s3.add_amount("Cl-", "1 mol/kg")
assert s3.get_volume().to("L").magnitude > original
assert s3.volume.to("L").magnitude > original

def test_add_amount_5(self, s3):
# If the concentration of a solute is directly increased with a substance / mass
Expand All @@ -167,10 +167,10 @@ def test_add_amount_6(self, s2):

# If the concentration of a solute is directly increased with a substance
# unit, the volume should increase
original = s2.get_volume().to("L").magnitude
original = s2.volume.to("L").magnitude
s2.add_amount("Na+", "2 mol")
s2.add_amount("Cl-", "2 mol")
assert s2.get_volume().to("L").magnitude > original
assert s2.volume.to("L").magnitude > original

def test_add_amount_7(self, s2):
# If the concentration of a solute is directly increased with a substance
Expand All @@ -191,10 +191,10 @@ def test_add_amount_9(self, s2):
# negative substance units
# If the concentration of a solute is directly decreased with a substance
# unit, the volume should decrease
original = s2.get_volume().to("L").magnitude
original = s2.volume.to("L").magnitude
s2.add_amount("Na+", "-2 mol")
s2.add_amount("Cl-", "-2 mol")
assert s2.get_volume().to("L").magnitude < original
assert s2.volume.to("L").magnitude < original

def test_add_amount_10(self, s2):
# If the concentration of a solute is directly changed with a substance
Expand Down

0 comments on commit 03e9fe1

Please sign in to comment.