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 diff --git a/src/pyEQL/functions.py b/src/pyEQL/functions.py index 070e73df..fa60b56d 100644 --- a/src/pyEQL/functions.py +++ b/src/pyEQL/functions.py @@ -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: diff --git a/src/pyEQL/solution.py b/src/pyEQL/solution.py index 7331344e..94f392a8 100644 --- a/src/pyEQL/solution.py +++ b/src/pyEQL/solution.py @@ -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) @@ -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: @@ -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) @@ -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 """ @@ -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: @@ -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: @@ -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] += ( @@ -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] = ( @@ -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. @@ -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" ) @@ -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.""" @@ -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()} @@ -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): @@ -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" @@ -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): """ 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