From 771cb07e2900a0db1b62a160c2f4c8bd2534e0c2 Mon Sep 17 00:00:00 2001 From: Ryan Kingsbury Date: Mon, 14 Aug 2023 07:53:09 -0400 Subject: [PATCH 1/3] WIP get_/set_volume() -> property --- src/pyEQL/functions.py | 4 +- src/pyEQL/solution.py | 72 +++++++++++++++++------------- tests/test_solution.py | 4 +- tests/test_volume_concentration.py | 24 +++++----- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/src/pyEQL/functions.py b/src/pyEQL/functions.py index 9f3da400..39aa022c 100644 --- a/src/pyEQL/functions.py +++ b/src/pyEQL/functions.py @@ -320,10 +320,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: diff --git a/src/pyEQL/solution.py b/src/pyEQL/solution.py index aa1691dd..66210f74 100644 --- a/src/pyEQL/solution.py +++ b/src/pyEQL/solution.py @@ -97,13 +97,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) @@ -122,9 +125,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: @@ -214,7 +214,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) @@ -242,7 +242,7 @@ def add_solute(self, formula, amount): else: # add the new solute - # new_solute = sol.Solute(formula, amount, self.get_volume(), self.get_solvent_mass()) + # new_solute = sol.Solute(formula, amount, self.volume, self.get_solvent_mass()) # self.components.update({new_solute.formula: new_solute}) quantity = unit.Quantity(amount) @@ -262,7 +262,7 @@ def add_solute(self, formula, amount): # and solvent_name will track which component it is. def add_solvent(self, formula, amount): """Same as add_solute but omits the need to pass solvent mass to pint.""" - # new_solvent = sol.Solute(formula, amount, self.get_volume(), amount) + # new_solvent = sol.Solute(formula, amount, self.volume, amount) # self.components.update({new_solvent.formula: new_solvent}) quantity = unit.Quantity(amount) @@ -333,14 +333,11 @@ 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: ------- Quantity: the volume of the solution, in L @@ -350,40 +347,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, mol in self.components.items(): self.components[solute] = mol * scale_factor # update the solution volume - self.volume = unit.Quantity(volume) + self._volume = unit.Quantity(volume) @property def mass(self) -> Quantity: @@ -416,7 +412,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: @@ -1068,7 +1064,7 @@ def get_amount(self, solute, units): "[substance]/[length]**3", "[mass]/[length]**3", ): - return moles.to(units, "chem", mw=mw, volume=self.get_volume()) + return moles.to(units, "chem", mw=mw, volume=self.volume) if unit.Quantity(units).dimensionality in ("[substance]/[mass]", "[mass]/[mass]"): return moles.to(units, "chem", mw=mw, solvent_mass=self.get_solvent_mass()) if unit.Quantity(units).dimensionality == "[mass]": @@ -1166,7 +1162,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] += ( @@ -1272,7 +1268,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] = ( @@ -1333,7 +1329,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. @@ -1509,7 +1505,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" ) @@ -2202,7 +2198,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" @@ -2290,6 +2286,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): """ diff --git a/tests/test_solution.py b/tests/test_solution.py index afdc6227..abc3f451 100644 --- a/tests/test_solution.py +++ b/tests/test_solution.py @@ -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 @@ -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 diff --git a/tests/test_volume_concentration.py b/tests/test_volume_concentration.py index e9a50f40..8abe50a2 100644 --- a/tests/test_volume_concentration.py +++ b/tests/test_volume_concentration.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 From 881a18acaa8c8c8a4106824d06783a8f6baffe7e Mon Sep 17 00:00:00 2001 From: Ryan Kingsbury Date: Mon, 14 Aug 2023 22:21:46 -0400 Subject: [PATCH 2/3] get_/set_volume -> property; from_dict fix --- src/pyEQL/solution.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/pyEQL/solution.py b/src/pyEQL/solution.py index 2dfc9174..94f392a8 100644 --- a/src/pyEQL/solution.py +++ b/src/pyEQL/solution.py @@ -365,14 +365,14 @@ def volume(self, volume: str): """ # figure out the factor to multiply the old concentrations by - scale_factor = unit.Quantity(volume) / self._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: @@ -1990,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.""" @@ -2034,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()} @@ -2048,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): From f2d6e5c3cebc793b5c1ab3e16b5208152f3344a6 Mon Sep 17 00:00:00 2001 From: Ryan Kingsbury Date: Mon, 14 Aug 2023 22:22:18 -0400 Subject: [PATCH 3/3] update README --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 03a474e4..dcd1c2aa 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![pyeql logo](pyeql-logo.png) -A Python library for solution chemistry +A human-friendly python interface for solution chemistry ## Description @@ -44,7 +44,7 @@ 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 @@ -52,10 +52,11 @@ Detailed documentation is available at [](https://pyeql.readthedocs.io/) [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