From 6cfd088a6074f12c7124db5429d06b61881e402f Mon Sep 17 00:00:00 2001 From: hallaali <77864659+hallaali@users.noreply.github.com> Date: Tue, 2 Mar 2021 21:42:17 -0500 Subject: [PATCH 01/41] Implement unit conversion file into Cantera Template file added to Sconscript to autogenerate code in solution.py.in --- .gitignore | 1 + interfaces/cython/SConscript | 116 ++++++++++++++++++ interfaces/cython/cantera/units/__init__.py | 1 + .../cython/cantera/units/solution.py.in | 108 ++++++++++++++++ interfaces/cython/setup.cfg.in | 1 + 5 files changed, 227 insertions(+) create mode 100644 interfaces/cython/cantera/units/__init__.py create mode 100644 interfaces/cython/cantera/units/solution.py.in diff --git a/.gitignore b/.gitignore index e0b14d0596..d81ef0c235 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ coverage.info .coverage doc/sphinx/matlab/*.rst !doc/sphinx/matlab/index.rst +interfaces/cython/cantera/units/solution.py diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index debe8dd9b1..c3f224a222 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -3,6 +3,8 @@ import re from pathlib import Path from buildutils import * +from string import Template + Import('env', 'build', 'install') localenv = env.Clone() @@ -107,6 +109,120 @@ for f in (multi_glob(localenv, 'cantera', 'py') + multi_glob(localenv, 'cantera/*', 'py')): localenv.Depends(mod, f) +UNITS = { + "cp_mass": '"J/kg/K"', "cp_mole": '"J/kmol/K"', "cv_mass": '"J/kg/K"', + "cv_mole": '"J/kmol/K"', "density_mass": '"kg/m**3"', "density_mole": '"kmol/m**3"', + "enthalpy_mass": '"J/kg"', "enthalpy_mole": '"J/kmol"', "entropy_mass": '"J/kg/K"', + "entropy_mole": '"J/kmol/K"', "gibbs_mass": '"J/kg"', "gibbs_mole": '"J/kmol"', + "int_energy_mass": '"J/kg"', "int_energy_mole": '"J/kmol"', + "volume_mass": '"m**3/kg"', "volume_mole": '"m**3/kmol"', "T": '"K"', "P": '"Pa"', + "X": '"dimensionless"', "Y": '"dimensionless"', "Q": '"dimensionless"', + "cp": '"J/K/" + self.basis_units', "cv": '"J/K/" + self.basis_units', + "density": 'self.basis_units + "/m**3"', "h": '"J/" + self.basis_units', + "s": '"J/K/" + self.basis_units', "g": '"J/" + self.basis_units', + "u": '"J/" + self.basis_units', "v": '"m**3/" + self.basis_units', + "H": '"J/" + self.basis_units', "V": '"m**3/" + self.basis_units', + "S": '"J/K/" + self.basis_units', "D": 'self.basis_units + "/m**3"', + "U": '"J/" + self.basis_units', "P_sat": '"Pa"', "T_sat": '"K"', + "atomic_weight": '"kg/kmol"', "chemical_potentials": '"J/kmol"', + "concentrations": '"kmol/m**3"', "critical_pressure": '"Pa"', + "critical_temperature": '"K"', "electric_potential": '"V"', + "electrochemical_potentials": '"J/kmol"', "isothermal_compressibility": '"1/Pa"', + "max_temp": '"K"', "mean_molecular_weight": '"kg/kmol"', "min_temp": '"K"', + "molecular_weights": '"kg/kmol"', "partial_molar_cp": '"J/kmol/K"', + "partial_molar_enthalpies": '"J/kmol"', "partial_molar_entropies": '"J/kmol/K"', + "partial_molar_int_energies": '"J/kmol"', "partial_molar_volumes": '"m**3/kmol"', + "reference_pressure": '"Pa"', "thermal_expansion_coeff": '"1/K"' +} + +getter_template = Template(""" + @property + def ${name}(self): + return Q_(self._phase.${name}, ${units}) +""") + +getter_properties = [ + "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", + "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", + "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "P", "P_sat", "T", "T_sat", + "atomic_weight", "chemical_potentials", "concentrations", "critical_pressure", + "critical_temperature", "electric_potential", "electrochemical_potentials", + "isothermal_compressibility", "max_temp", "mean_molecular_weight", "min_temp", + "molecular_weights", "partial_molar_cp", "partial_molar_enthalpies", + "partial_molar_entropies", "partial_molar_int_energies", "partial_molar_volumes", + "reference_pressure", "thermal_expansion_coeff", "cp", "cv", "density", + "h", "s", "g", "u", "v" +] + +getter_string = "".join( + getter_template.substitute(name=name, units=UNITS[name]) for name in getter_properties +) +pf_getter_string = getter_template.substitute(name="Q", units=UNITS["Q"]) + +setter_template = Template(""" + @property + def ${name}(self): + ${n0}, ${n1} = self._phase.${name} + return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}) + + @${name}.setter + def ${name}(self, value): + ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} + ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} + self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude +""") + +setter_properties = ["TP", "DP", "HP", "SP", "SV", "TD", "UV"] +pf_setter_properties = ["PQ", "TQ", "PV", "SH", "ST", "TH", "TV", "UP", "VH"] + +setter_string = "".join( + setter_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) for name in setter_properties +) +pf_setter_string = "".join( + setter_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) for name in pf_setter_properties +) + +setter1_template = Template(""" + @property + def ${name}(self): + ${n0}, ${n1}, ${n2} = self._phase.${name} + return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) + + @${name}.setter + def ${name}(self, value): + ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} + ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} + if value[2] is not None: + try: + ${n2} = value[2].to(${u2}).magnitude + except AttributeError: + ${n2} = value[2] + else: + ${n2} = self.${n2}.magnitude + self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude, ${n2} +""") + +setter1_properties = [ + "TPX", "TPY", "DPX", "DPY", "HPX", "HPY", "SPX", "SPY", "SVX", "SVY", + "TDX", "TDY", "UVX", "UVY" +] +pf_setter1_properties = ["TPQ", "DPQ", "HPQ", "SPQ", "SVQ", "TDQ", "UVQ"] + +setter1_string = "".join( + setter1_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], n2=name[2], u2=UNITS[name[2]]) for name in setter1_properties +) +pf_setter1_string = "".join( + setter1_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], n2=name[2], u2=UNITS[name[2]]) for name in pf_setter1_properties +) + +solution_properties = "".join([getter_string, setter_string, setter1_string]) +pf_properties = "".join([pf_getter_string, pf_setter_string, pf_setter1_string]) + +localenv["solution_properties"] = solution_properties.strip() +localenv["purefluid_properties"] = pf_properties.strip() +units = localenv.SubstFile("cantera/units/solution.py", "cantera/units/solution.py.in") +localenv.Depends(mod, units) + # Determine installation path and install the Python module install_cmd = ["$python_cmd_esc", "-m", "pip", "install"] user_install = False diff --git a/interfaces/cython/cantera/units/__init__.py b/interfaces/cython/cantera/units/__init__.py new file mode 100644 index 0000000000..7a493060d7 --- /dev/null +++ b/interfaces/cython/cantera/units/__init__.py @@ -0,0 +1 @@ +from .solution import * diff --git a/interfaces/cython/cantera/units/solution.py.in b/interfaces/cython/cantera/units/solution.py.in new file mode 100644 index 0000000000..296a163d74 --- /dev/null +++ b/interfaces/cython/cantera/units/solution.py.in @@ -0,0 +1,108 @@ +from .. import Solution as _Solution, PureFluid as _PureFluid + +from pint import UnitRegistry +units = UnitRegistry() +Q_ = units.Quantity + +__all__ = ( + "units", "Q_", "Solution", "PureFluid", "Heptane", "CarbonDioxide", + "Hfc134a", "Hydrogen", "Methane", "Nitrogen", "Oxygen", "Water") + + +class Solution: + def __init__(self, infile, phasename=""): + self._phase = _Solution(infile, phasename) + + @property + def basis_units(self): + if self._phase.basis == "mass": + return "kg" + else: + return "kmol" + + def __getattr__(self, name): + return getattr(self._phase, name) + + def __setattr__(self, name, value): + return super(Solution, self).__setattr__(name, value) + + @property + def X(self): + X = self._phase.X + return Q_(X, "dimensionless") + + @X.setter + def X(self, value): + if value is not None: + try: + X = value.to("dimensionless").magnitude + except AttributeError: + X = value + else: + X = self.X.magnitude + self._phase.X = X + + @property + def Y(self): + Y = self._phase.Y + return Q_(Y, "dimensionless") + + @Y.setter + def Y(self, value): + if value is not None: + try: + Y = value.to("dimensionless").magnitude + except AttributeError: + Y = value + else: + Y = self.Y.magnitude + self._phase.Y = Y + + @solution_properties@ + + +class PureFluid(Solution): + def __init__(self, infile, phasename=""): + self._phase = _PureFluid(infile, phasename) + + @purefluid_properties@ + + +class Heptane(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "heptane") + + +class CarbonDioxide(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "carbon-dioxide") + + +class Hfc134a(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "hfc134a") + + +class Hydrogen(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "hydrogen") + + +class Methane(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "methane") + + +class Nitrogen(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "nitrogen") + + +class Oxygen(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "oxygen") + + +class Water(PureFluid): + def __init__(self): + super().__init__("liquidvapor.yaml", "water") diff --git a/interfaces/cython/setup.cfg.in b/interfaces/cython/setup.cfg.in index 0373a73817..81ec1d409a 100644 --- a/interfaces/cython/setup.cfg.in +++ b/interfaces/cython/setup.cfg.in @@ -49,6 +49,7 @@ packages = cantera.data cantera.test cantera.examples + cantera.units [options.package_data] # The module extension needs to be here since we don't want setuptools to compile From 6dabf1aefcb2bf056d5d29967f3469eb52c6a106 Mon Sep 17 00:00:00 2001 From: hallaali <77864659+hallaali@users.noreply.github.com> Date: Thu, 18 Mar 2021 22:49:00 -0400 Subject: [PATCH 02/41] Create test_units.py --- interfaces/cython/cantera/test/test_units.py | 770 +++++++++++++++++++ 1 file changed, 770 insertions(+) create mode 100644 interfaces/cython/cantera/test/test_units.py diff --git a/interfaces/cython/cantera/test/test_units.py b/interfaces/cython/cantera/test/test_units.py new file mode 100644 index 0000000000..43d321a637 --- /dev/null +++ b/interfaces/cython/cantera/test/test_units.py @@ -0,0 +1,770 @@ +import itertools +import numpy as np +import warnings +from pint import UnitRegistry +units = UnitRegistry() +Q_ = units.Quantity + +import cantera.units as ct +from . import utilities + +class TestSolutionUnits(utilities.CanteraTest): + def setUp(self): + self.phase = ct.Solution("h2o2.yaml") + + def test_mass_basis(self): + self.assertEqual(self.phase.basis_units, 'kg') + self.assertQuantityNear(self.phase.density_mass, self.phase.density) + self.assertQuantityNear(self.phase.enthalpy_mass, self.phase.h) + self.assertQuantityNear(self.phase.entropy_mass, self.phase.s) + self.assertQuantityNear(self.phase.int_energy_mass, self.phase.u) + self.assertQuantityNear(self.phase.volume_mass, self.phase.v) + self.assertQuantityNear(self.phase.gibbs_mass, self.phase.g) + self.assertQuantityNear(self.phase.cp_mass, self.phase.cp) + self.assertQuantityNear(self.phase.cv_mass, self.phase.cv) + + def test_molar_basis(self): + self.assertEqual(self.phase.basis_units, 'kmol') + self.assertQuantityNear(self.phase.density_mole, self.phase.density) + self.assertQuantityNear(self.phase.enthalpy_mole, self.phase.h) + self.assertQuantityNear(self.phase.entropy_mole, self.phase.s) + self.assertQuantityNear(self.phase.int_energy_mole, self.phase.u) + self.assertQuantityNear(self.phase.volume_mole, self.phase.v) + self.assertQuantityNear(self.phase.gibbs_mole, self.phase.g) + self.assertQuantityNear(self.phase.cp_mole, self.phase.cp) + self.assertQuantityNear(self.phase.cv_mole, self.phase.cv) + + def test_dimensions(self): + #basis-independent + dims_T = self.phase.T.dimensionality + self.assertIn("[temperature]", dims_T) + self.assertEqual(dims_T["[temperature]"], 1.0) + dims_P = self.phase.P.dimensionality + self.assertIn("[pressure]", dims_P) + self.assertEqual(dims_P["[pressure]"], 1.0) + dims_X = self.phase.X.dimensionality + self.assertIn("[dimensionless]", dims_X) + self.assertEqual(dims_X["[dimensionless]"], 1.0) + dims_Y = self.phase.Y.dimensionality + self.assertIn("[dimensionless]", dims_Y) + self.assertEqual(dims_Y["[dimensionless]"], 1.0) + dims_Q = self.phase.Q.dimensionality + self.assertIn("[dimensionless]", dims_Q) + self.assertEqual(dims_Q["[dimensionless]"], 1.0) + dims_T_sat = self.phase.T_sat.dimensionality + self.assertIn("[temperature]", dims_T_sat) + self.assertEqual(dims_T_sat["[temperature]"], 1.0) + dims_P_sat = self.phase.P_sat.dimensionality + self.assertIn("[pressure]", dims_P_sat) + self.assertEqual(dims_P_sat["[pressure]"], 1.0) + dims_atomic_weight = self.phase.atomic_weight.dimensionality + self.assertIn("[mass/molar]", dims_atomic_weight) + self.assertEqual(dims_atomic_weight["[mass]"], 1.0) + self.assertEqual(dims_atomic_weight["[molar]"], -1.0) + dims_chemical_potentials = self.phase.chemical_potentials.dimensionality + self.assertIn("[energy/molar]", dims_chemical_potentials) + self.assertEqual(dims_chemical_potentials["[energy]"], 1.0) + self.assertEqual(dims_chemical_potentials["[molar]"], -1.0) + dims_concentration = self.phase.concentration.dimensionality + self.assertIn("[molar/length]", dims_concentration) + self.assertEqual(dims_concentration["[molar]"], 1.0) + self.assertEqual(dims_concentration["[length]"], -3.0) + dims_critical_temperature = self.phase.critical_temperature.dimensionality + self.assertIn("[temperature]", dims_critical_temperature) + self.assertEqual(dims_critical_temperature["[temperature]"], 1.0) + dims_critical_pressure = self.phase.critical_pressure.dimensionality + self.assertIn("[pressure]", dims_critical_pressure) + self.assertEqual(dims_critical_pressure["[pressure]"], 1.0) + dims_electric_potential = self.phase.electric_potential.dimensionality + self.assertIn("[volts]", dims_electric_potential) + self.assertEqual(dims_electric_potential["[volts]"], 1.0) + dims_electrochemical_potentials = self.phase.electrochemical_potentials.dimensionality + self.assertIn("[energy/molar]", dims_electrochemical_potentials) + self.assertEqual(dims_electrochemical_potentials["[energy]"], 1.0) + self.assertEqual(dims_electrochemical_potentials["[molar]"], -1.0) + dims_isothermal_compressibility = self.phase.isothermal_compressibility.dimensionality + self.assertIn("[1/pressure]", dims_isothermal_compressibility) + self.assertEqual(dims_isothermal_compressibility["[pressure]"], -1.0) + dims_max_temp = self.phase.max_temp.dimensionality + self.assertIn("[temperature]", dims_max_temp) + self.assertEqual(dims_max_temp["[temperature]"], 1.0) + dims_mean_molecular_weight = self.phase.mean_molecular_weight.dimensionality + self.assertIn("[mass/molar]", dims_mean_molecular_weight) + self.assertEqual(dims_mean_molecular_weight["[mass]"], 1.0) + self.assertEqual(dims_mean_molecular_weight["[molar]"], -1.0) + dims_min_temp = self.phase.min_temp.dimensionality + self.assertIn("[temperature]", dims_min_temp) + self.assertEqual(dims_min_temp["[temperature]"], 1.0) + dims_molecular_weights = self.phase.molecular_weights.dimensionality + self.assertIn("[mass/molar]", dims_molecular_weights) + self.assertEqual(dims_molecular_weights["[mass]"], 1.0) + self.assertEqual(dims_molecular_weights["[molar]"], -1.0) + dims_partial_molar_cp = self.phase.partial_molar_cp.dimensionality + self.assertIn("[energy/molar/temperature]", dims_partial_molar_cp) + self.assertEqual(dims_partial_molar_cp["[energy]"], 1.0) + self.assertEqual(dims_partial_molar_cp["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_cp["[temperature]"], -1.0) + dims_partial_molar_enthalpies = self.phase.partial_molar_enthalpies.dimensionality + self.assertIn("[energy/molar/temperature]", dims_partial_molar_enthalpies) + self.assertEqual(dims_partial_molar_enthalpies["[energy]"], 1.0) + self.assertEqual(dims_partial_molar_enthalpies["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_enthalpies["[temperature]"], -1.0) + dims_partial_molar_entropies = self.phase.partial_molar_entropies.dimensionality + self.assertIn("[energy/molar/temperature]", dims_partial_molar_entropies) + self.assertEqual(dims_partial_molar_entropies["[energy]"], 1.0) + self.assertEqual(dims_partial_molar_entropies["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_entropies["[temperature]"], -1.0) + dims_partial_molar_int_energies = self.phase.partial_molar_int_energies.dimensionality + self.assertIn("[energy/molar]", dims_partial_molar_int_energies) + self.assertEqual(dims_partial_molar_int_energies["[energy]"], 1.0) + self.assertEqual(dims_partial_molar_int_energies["[molar]"], -1.0) + dims_partial_molar_volumes = self.phase.partial_molar_volumes.dimensionality + self.assertIn("[length/molar]", dims_partial_molar_volumes) + self.assertEqual(dims_partial_molar_volumes["[length]"], 3.0) + self.assertEqual(dims_partial_molar_volumes["[molar]"], -1.0) + dims_reference_pressure = self.phase.reference_pressure.dimensionality + self.assertIn("[pressure]", dims_reference_pressure) + self.assertEqual(dims_reference_pressure["[pressure]"], 1.0) + dims_thermal_expansion_coeff = self.phase.thermal_expansion_coeff.dimensionality + self.assertIn("[1/temperature]", dims_thermal_expansion_coeff) + self.assertEqual(dims_thermal_expansion_coeff["[temperature]"], -1.0) + #basis-dependent (mass) + dims_density_mass = self.phase.density_mass.dimensionality + self.assertIn("[mass/length]", dims_density_mass) + self.assertEqual(dims_density_mass["[mass]"], 1.0) + self.assertEqual(dims_density_mass["[length]"], -3.0) + dims_enthalpy_mass = self.phase.enthalpy_mass.dimensionality + self.assertIn("[energy/mass]", dims_enthalpy_mass) + self.assertEqual(dims_enthalpy_mass["[energy]"], 1.0) + self.assertEqual(dims_enthalpy_mass["[mass]"], -1.0) + dims_entropy_mass = self.phase.entropy_mass.dimensionality + self.assertIn("[energy/mass/temperature]", dims_entropy_mass) + self.assertEqual(dims_entropy_mass["[energy]"], 1.0) + self.assertEqual(dims_entropy_mass["[mass]"], -1.0) + self.assertEqual(dims_entropy_mass["[temperature]"], -1.0) + dims_int_energy_mass = self.phase.int_energy_mass.dimensionality + self.assertIn("[energy/mass]", dims_int_energy_mass) + self.assertEqual(dims_int_energy_mass["[energy]"], 1.0) + self.assertEqual(dims_int_energy_mass["[mass]"], -1.0) + dims_volume_mass = self.phase.volume_mass.dimensionality + self.assertIn("[length/mass]", dims_volume_mass) + self.assertEqual(dims_volume_mass["[length]"], 3.0) + self.assertEqual(dims_volume_mass["[mass]"], 1.0) + dims_gibbs_mass = self.phase.gibbs_mass.dimensionality + self.assertIn("[energy/mass]", dims_gibbs_mass) + self.assertEqual(dims_gibbs_mass["[energy]"], 1.0) + self.assertEqual(dims_gibbs_mass["[mass]"], -1.0) + dims_cp_mass = self.phase.cp_mass.dimensionality + self.assertIn("[energy/mass/temperature]", dims_cp_mass) + self.assertEqual(dims_cp_mass["[energy]"], 1.0) + self.assertEqual(dims_cp_mass["[mass]"], -1.0) + self.assertEqual(dims_cp_mass["[temperature]"], -1.0) + dims_cv_mass = self.phase.cv.dimensionality + self.assertIn("[energy/mass/temperature]", dims_cv_mass) + self.assertEqual(dims_cv_mass["[energy]"], 1.0) + self.assertEqual(dims_cv_mass["[mass]"], -1.0) + self.assertEqual(dims_cv_mass["[temperature]"], -1.0) + #basis-dependent (molar) + dims_density_mole = self.phase.density_mole.dimensionality + self.assertIn("[molar/length]", dims_density_mole) + self.assertEqual(dims_density_mole["[molar]"], 1.0) + self.assertEqual(dims_density_mole["[length]"], -3.0) + dims_enthalpy_mole = self.phase.enthalpy_mole.dimensionality + self.assertIn("[energy/molar]", dims_enthalpy_mole) + self.assertEqual(dims_enthalpy_mole["[energy]"], 1.0) + self.assertEqual(dims_enthalpy_mole["[molar]"], -1.0) + dims_entropy_mole = self.phase.entropy_mole.dimensionality + self.assertIn("[energy/molar/temperature]", dims_entropy_mole) + self.assertEqual(dims_entropy_mole["[energy]"], 1.0) + self.assertEqual(dims_entropy_mole["[molar]"], -1.0) + self.assertEqual(dims_entropy_mole["[temperature]"], -1.0) + dims_int_energy_mole = self.phase.int_energy_mole.dimensionality + self.assertIn("[energy/molar]", dims_int_energy_mole) + self.assertEqual(dims_int_energy_mole["[energy]"], 1.0) + self.assertEqual(dims_int_energy_mole["[molar]"], -1.0) + dims_volume_mole = self.phase.volume_mole.dimensionality + self.assertIn("[length/molar]", dims_volume_mole) + self.assertEqual(dims_volume_mole["[length]"], 3.0) + self.assertEqual(dims_volume_mole["[molar]"], 1.0) + dims_gibbs_mole = self.phase.gibbs_mole.dimensionality + self.assertIn("[energy/molar]", dims_gibbs_mole) + self.assertEqual(dims_gibbs_mole["[energy]"], 1.0) + self.assertEqual(dims_gibbs_mole["[molar]"], -1.0) + dims_cp_mole = self.phase.cp_mole.dimensionality + self.assertIn("[energy/molar/temperature]", dims_cp_mole) + self.assertEqual(dims_cp_mole["[energy]"], 1.0) + self.assertEqual(dims_cp_mole["[molar]"], -1.0) + self.assertEqual(dims_cp_mole["[temperature]"], -1.0) + dims_cv_mole = self.phase.cv.dimensionality + self.assertIn("[energy/molar/temperature]", dims_cv_mole) + self.assertEqual(dims_cv_mole["[energy]"], 1.0) + self.assertEqual(dims_cv_mole["[molar]"], -1.0) + self.assertEqual(dims_cv_mole["[temperature]"], -1.0) + + def check_setters(self, T1, rho1, Y1): + T0, rho0, Y0 = self.phase.TDY + self.phase.TDY = T1, rho1, Y1 + X1 = self.phase.X + P1 = self.phase.P + h1 = self.phase.h + s1 = self.phase.s + u1 = self.phase.u + v1 = self.phase.v + + def check_state(T, rho, Y): + self.assertNear(self.phase.T, T) + self.assertNear(self.phase.density, rho) + self.assertArrayNear(self.phase.Y, Y) + + self.phase.TDY = T0, rho0, Y0 + self.phase.TPY = T1, P1, Y1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.UVY = u1, v1, Y1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.HPY = h1, P1, Y1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.SPY = s1, P1, Y1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.TPX = T1, P1, X1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.UVX = u1, v1, X1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.HPX = h1, P1, X1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.SPX = s1, P1, X1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.SVX = s1, v1, X1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.SVY = s1, v1, Y1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.DPX = rho1, P1, X1 + check_state(T1, rho1, Y1) + + self.phase.TDY = T0, rho0, Y0 + self.phase.DPY = rho1, P1, Y1 + check_state(T1, rho1, Y1) + + def test_setState_mass(self): + self.check_setters(T1 = Q_(500.0, "K"), rho1 = Q_(1.5, 'self.basis+"/m**3"'), + Y1 = Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2], "dimensionless") + + def test_setState_mole(self): + self.phase.basis = 'molar' + self.check_setters(T1 = Q_(750.0, "K"), rho1 = Q_(0.02, 'self.basis+"/m**3"'), + Y1 = Q_([0.2, 0.1, 0.0, 0.3, 0.1, 0.0, 0.0, 0.2, 0.1], "dimensionless")) + + def test_setters_hold_constant(self): + props = ('T','P','s','h','u','v','X','Y') + pairs = [('TP', 'T', 'P'), ('SP', 's', 'P'), + ('UV', 'u', 'v')] + + self.phase.TDX = 1000, 1.5, 'H2O:0.1, O2:0.95, AR:3.0' + values = {} + for p in props: + values[p] = getattr(self.phase, p) + + for pair, first, second in pairs: + self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + first_val = getattr(self.phase, first) + second_val = getattr(self.phase, second) + + setattr(self.phase, pair, (values[first], None)) + self.assertNear(getattr(self.phase, first), values[first]) + self.assertNear(getattr(self.phase, second), second_val) + + self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + setattr(self.phase, pair, (None, values[second])) + self.assertNear(getattr(self.phase, first), first_val) + self.assertNear(getattr(self.phase, second), values[second]) + + self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + setattr(self.phase, pair + 'X', (None, None, values['X'])) + self.assertNear(getattr(self.phase, first), first_val) + self.assertNear(getattr(self.phase, second), second_val) + + self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + setattr(self.phase, pair + 'Y', (None, None, values['Y'])) + self.assertNear(getattr(self.phase, first), first_val) + self.assertNear(getattr(self.phase, second), second_val) + + def check_getters(self): + T,D,X = self.phase.TDX + self.assertNear(T, self.phase.T) + self.assertNear(D, self.phase.density) + self.assertArrayNear(X, self.phase.X) + + T,D,Y = self.phase.TDY + self.assertNear(T, self.phase.T) + self.assertNear(D, self.phase.density) + self.assertArrayNear(Y, self.phase.Y) + + T,D = self.phase.TD + self.assertNear(T, self.phase.T) + self.assertNear(D, self.phase.density) + + T,P,X = self.phase.TPX + self.assertNear(T, self.phase.T) + self.assertNear(P, self.phase.P) + self.assertArrayNear(X, self.phase.X) + + T,P,Y = self.phase.TPY + self.assertNear(T, self.phase.T) + self.assertNear(P, self.phase.P) + self.assertArrayNear(Y, self.phase.Y) + + T,P = self.phase.TP + self.assertNear(T, self.phase.T) + self.assertNear(P, self.phase.P) + + H,P,X = self.phase.HPX + self.assertNear(H, self.phase.h) + self.assertNear(P, self.phase.P) + self.assertArrayNear(X, self.phase.X) + + H,P,Y = self.phase.HPY + self.assertNear(H, self.phase.h) + self.assertNear(P, self.phase.P) + self.assertArrayNear(Y, self.phase.Y) + + H,P = self.phase.HP + self.assertNear(H, self.phase.h) + self.assertNear(P, self.phase.P) + + U,V,X = self.phase.UVX + self.assertNear(U, self.phase.u) + self.assertNear(V, self.phase.v) + self.assertArrayNear(X, self.phase.X) + + U,V,Y = self.phase.UVY + self.assertNear(U, self.phase.u) + self.assertNear(V, self.phase.v) + self.assertArrayNear(Y, self.phase.Y) + + U,V = self.phase.UV + self.assertNear(U, self.phase.u) + self.assertNear(V, self.phase.v) + + S,P,X = self.phase.SPX + self.assertNear(S, self.phase.s) + self.assertNear(P, self.phase.P) + self.assertArrayNear(X, self.phase.X) + + S,P,Y = self.phase.SPY + self.assertNear(S, self.phase.s) + self.assertNear(P, self.phase.P) + self.assertArrayNear(Y, self.phase.Y) + + S,P = self.phase.SP + self.assertNear(S, self.phase.s) + self.assertNear(P, self.phase.P) + + S,V,X = self.phase.SVX + self.assertNear(S, self.phase.s) + self.assertNear(V, self.phase.v) + self.assertArrayNear(X, self.phase.X) + + S,V,Y = self.phase.SVY + self.assertNear(S, self.phase.s) + self.assertNear(V, self.phase.v) + self.assertArrayNear(Y, self.phase.Y) + + S,V = self.phase.SV + self.assertNear(S, self.phase.s) + self.assertNear(V, self.phase.v) + + D,P,X = self.phase.DPX + self.assertNear(D, self.phase.density) + self.assertNear(P, self.phase.P) + self.assertArrayNear(X, self.phase.X) + + D,P,Y = self.phase.DPY + self.assertNear(D, self.phase.density) + self.assertNear(P, self.phase.P) + self.assertArrayNear(Y, self.phase.Y) + + D,P = self.phase.DP + self.assertNear(D, self.phase.density) + self.assertNear(P, self.phase.P) + + def test_getState_mass(self): + self.phase.TDY = 350.0, 0.7, 'H2:0.1, H2O2:0.1, AR:0.8' + self.check_getters() + + def test_getState_mole(self): + self.phase.basis = 'molar' + self.phase.TDX = 350.0, 0.01, 'H2:0.1, O2:0.3, AR:0.6' + self.check_getters() + + def test_isothermal_compressibility(self): + self.assertNear(self.phase.isothermal_compressibility, 1.0/self.phase.P) + +class TestPureFluidUnits(utilities.CanteraTest): + def setUp(self): + self.water = ct.Water() + + def test_critical_properties(self): + self.assertNear(self.water.critical_pressure, 22.089e6) + self.assertNear(self.water.critical_temperature, 647.286) + self.assertNear(self.water.critical_density, 317.0) + + def test_temperature_limits(self): + co2 = ct.CarbonDioxide() + self.assertNear(co2.min_temp, 216.54) + self.assertNear(co2.max_temp, 1500.0) + + def test_set_state(self): + self.water.PQ = 101325, 0.5 + self.assertNear(self.water.P, 101325) + self.assertNear(self.water.Q, 0.5) + + self.water.TQ = 500, 0.8 + self.assertNear(self.water.T, 500) + self.assertNear(self.water.Q, 0.8) + + def test_substance_set(self): + self.water.TV = 400, 1.45 + self.assertNear(self.water.T, 400) + self.assertNear(self.water.v, 1.45) + with self.assertRaisesRegex(ct.CanteraError, 'Negative specific volume'): + self.water.TV = 300, -1. + + self.water.PV = 101325, 1.45 + self.assertNear(self.water.P, 101325) + self.assertNear(self.water.v, 1.45) + + self.water.UP = -1.45e7, 101325 + self.assertNear(self.water.u, -1.45e7) + self.assertNear(self.water.P, 101325) + + self.water.VH = 1.45, -1.45e7 + self.assertNear(self.water.v, 1.45) + self.assertNear(self.water.h, -1.45e7) + + self.water.TH = 400, -1.45e7 + self.assertNear(self.water.T, 400) + self.assertNear(self.water.h, -1.45e7) + + self.water.SH = 5000, -1.45e7 + self.assertNear(self.water.s, 5000) + self.assertNear(self.water.h, -1.45e7) + + self.water.ST = 5000, 400 + self.assertNear(self.water.s, 5000) + self.assertNear(self.water.T, 400) + + def test_states(self): + self.assertEqual(self.water._native_state, ('T', 'D')) + self.assertNotIn('TPY', self.water._full_states.values()) + self.assertIn('TQ', self.water._partial_states.values()) + + def test_set_Q(self): + self.water.TQ = 500, 0.0 + p = self.water.P + self.water.Q = 0.8 + self.assertNear(self.water.P, p) + self.assertNear(self.water.T, 500) + self.assertNear(self.water.Q, 0.8) + + self.water.TP = 650, 101325 + with self.assertRaises(ct.CanteraError): + self.water.Q = 0.1 + + self.water.TP = 300, 101325 + with self.assertRaises(ValueError): + self.water.Q = 0.3 + + def test_set_minmax(self): + self.water.TP = self.water.min_temp, 101325 + self.assertNear(self.water.T, self.water.min_temp) + + self.water.TP = self.water.max_temp, 101325 + self.assertNear(self.water.T, self.water.max_temp) + + def check_fd_properties(self, T1, P1, T2, P2, tol): + # Properties which are computed as finite differences + self.water.TP = T1, P1 + h1a = self.water.enthalpy_mass + cp1 = self.water.cp_mass + cv1 = self.water.cv_mass + k1 = self.water.isothermal_compressibility + alpha1 = self.water.thermal_expansion_coeff + h1b = self.water.enthalpy_mass + + self.water.TP = T2, P2 + h2a = self.water.enthalpy_mass + cp2 = self.water.cp_mass + cv2 = self.water.cv_mass + k2 = self.water.isothermal_compressibility + alpha2 = self.water.thermal_expansion_coeff + h2b = self.water.enthalpy_mass + + self.assertNear(cp1, cp2, tol) + self.assertNear(cv1, cv2, tol) + self.assertNear(k1, k2, tol) + self.assertNear(alpha1, alpha2, tol) + + # calculating these finite difference properties should not perturb the + # state of the object (except for checks on edge cases) + self.assertNear(h1a, h1b, 1e-9) + self.assertNear(h2a, h2b, 1e-9) + + def test_properties_near_min(self): + self.check_fd_properties(self.water.min_temp*(1+1e-5), 101325, + self.water.min_temp*(1+1e-4), 101325, 1e-2) + + def test_properties_near_max(self): + self.check_fd_properties(self.water.max_temp*(1-1e-5), 101325, + self.water.max_temp*(1-1e-4), 101325, 1e-2) + + def test_properties_near_sat1(self): + for T in [340,390,420]: + self.water.TQ = T, 0.0 + P = self.water.P + self.check_fd_properties(T, P+0.01, T, P+0.5, 1e-4) + + def test_properties_near_sat2(self): + for T in [340,390,420]: + self.water.TQ = T, 0.0 + P = self.water.P + self.check_fd_properties(T, P-0.01, T, P-0.5, 1e-4) + + def test_isothermal_compressibility_lowP(self): + # Low-pressure limit corresponds to ideal gas + ref = ct.Solution('gri30.xml') + ref.TPX = 450, 12, 'H2O:1.0' + self.water.TP = 450, 12 + self.assertNear(ref.isothermal_compressibility, + self.water.isothermal_compressibility, 1e-5) + + def test_thermal_expansion_coeff_lowP(self): + # Low-pressure limit corresponds to ideal gas + ref = ct.Solution('gri30.xml') + ref.TPX = 450, 12, 'H2O:1.0' + self.water.TP = 450, 12 + self.assertNear(ref.thermal_expansion_coeff, + self.water.thermal_expansion_coeff, 1e-5) + + def test_thermal_expansion_coeff_TD(self): + for T in [440, 550, 660]: + self.water.TD = T, 0.1 + self.assertNear(T * self.water.thermal_expansion_coeff, 1.0, 1e-2) + + def test_pq_setter_triple_check(self): + self.water.PQ = 101325, .2 + T = self.water.T + # change T such that it would result in a Psat larger than P + self.water.TP = 400, 101325 + # ensure that correct triple point pressure is recalculated + # (necessary as this value is not stored by the C++ base class) + self.water.PQ = 101325, .2 + self.assertNear(T, self.water.T, 1e-9) + with self.assertRaisesRegex(ct.CanteraError, 'below triple point'): + # min_temp is triple point temperature + self.water.TP = self.water.min_temp, 101325 + P = self.water.P_sat # triple-point pressure + self.water.PQ = .999*P, .2 + + def test_quality_exceptions(self): + # Critical point + self.water.TP = 300, ct.one_atm + self.water.TQ = self.water.critical_temperature, .5 + self.assertNear(self.water.P, self.water.critical_pressure) + self.water.TP = 300, ct.one_atm + self.water.PQ = self.water.critical_pressure, .5 + self.assertNear(self.water.T, self.water.critical_temperature) + + # Supercritical + with self.assertRaisesRegex(ct.CanteraError, 'supercritical'): + self.water.TQ = 1.001 * self.water.critical_temperature, 0. + with self.assertRaisesRegex(ct.CanteraError, 'supercritical'): + self.water.PQ = 1.001 * self.water.critical_pressure, 0. + + # Q negative + with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): + self.water.TQ = 373.15, -.001 + with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): + self.water.PQ = ct.one_atm, -.001 + + # Q larger than one + with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): + self.water.TQ = 373.15, 1.001 + with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): + self.water.PQ = ct.one_atm, 1.001 + + def test_saturated_mixture(self): + self.water.TP = 300, ct.one_atm + with self.assertRaisesRegex(ct.CanteraError, 'Saturated mixture detected'): + self.water.TP = 300, self.water.P_sat + + w = ct.Water() + + # Saturated vapor + self.water.TQ = 373.15, 1. + self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') + w.TP = self.water.T, .999 * self.water.P_sat + self.assertNear(self.water.cp, w.cp, 1.e-3) + self.assertNear(self.water.cv, w.cv, 1.e-3) + self.assertNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-3) + self.assertNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-3) + + # Saturated mixture + self.water.TQ = 373.15, .5 + self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') + self.assertEqual(self.water.cp, np.inf) + self.assertTrue(np.isnan(self.water.cv)) + self.assertEqual(self.water.isothermal_compressibility, np.inf) + self.assertEqual(self.water.thermal_expansion_coeff, np.inf) + + # Saturated liquid + self.water.TQ = 373.15, 0. + self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') + w.TP = self.water.T, 1.001 * self.water.P_sat + self.assertNear(self.water.cp, w.cp, 1.e-3) + self.assertNear(self.water.cv, w.cv, 1.e-3) + self.assertNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-3) + self.assertNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-3) + + def test_saturation_near_limits(self): + # Low temperature limit (triple point) + self.water.TP = 300, ct.one_atm + self.water.P_sat # ensure that solver buffers sufficiently different values + self.water.TP = self.water.min_temp, ct.one_atm + psat = self.water.P_sat + self.water.TP = 300, ct.one_atm + self.water.P_sat # ensure that solver buffers sufficiently different values + self.water.TP = 300, psat + self.assertNear(self.water.T_sat, self.water.min_temp) + + # High temperature limit (critical point) - saturation temperature + self.water.TP = 300, ct.one_atm + self.water.P_sat # ensure that solver buffers sufficiently different values + self.water.TP = self.water.critical_temperature, self.water.critical_pressure + self.assertNear(self.water.T_sat, self.water.critical_temperature) + + # High temperature limit (critical point) - saturation pressure + self.water.TP = 300, ct.one_atm + self.water.P_sat # ensure that solver buffers sufficiently different values + self.water.TP = self.water.critical_temperature, self.water.critical_pressure + self.assertNear(self.water.P_sat, self.water.critical_pressure) + + # Supercricital + with self.assertRaisesRegex(ct.CanteraError, 'Illegal temperature value'): + self.water.TP = 1.001 * self.water.critical_temperature, self.water.critical_pressure + self.water.P_sat + with self.assertRaisesRegex(ct.CanteraError, 'Illegal pressure value'): + self.water.TP = self.water.critical_temperature, 1.001 * self.water.critical_pressure + self.water.T_sat + + # Below triple point + with self.assertRaisesRegex(ct.CanteraError, 'Illegal temperature'): + self.water.TP = .999 * self.water.min_temp, ct.one_atm + self.water.P_sat + # @TODO: test disabled pending fix of GitHub issue #605 + # with self.assertRaisesRegex(ct.CanteraError, 'Illegal pressure value'): + # self.water.TP = 300, .999 * psat + # self.water.T_sat + + def test_TPQ(self): + self.water.TQ = 400, 0.8 + T, P, Q = self.water.TPQ + self.assertNear(T, 400) + self.assertNear(Q, 0.8) + + # a supercritical state + self.water.TPQ = 800, 3e7, 1 + self.assertNear(self.water.T, 800) + self.assertNear(self.water.P, 3e7) + + self.water.TPQ = T, P, Q + self.assertNear(self.water.Q, 0.8) + with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): + self.water.TPQ = T, .999*P, Q + with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): + self.water.TPQ = T, 1.001*P, Q + with self.assertRaises(TypeError): + self.water.TPQ = T, P, 'spam' + + self.water.TPQ = 500, 1e5, 1 # superheated steam + self.assertNear(self.water.P, 1e5) + with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): + self.water.TPQ = 500, 1e5, 0 # vapor fraction should be 1 (T < Tc) + with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): + self.water.TPQ = 700, 1e5, 0 # vapor fraction should be 1 (T > Tc) + + def test_phase_of_matter(self): + self.water.TP = 300, 101325 + self.assertEqual(self.water.phase_of_matter, "liquid") + self.water.TP = 500, 101325 + self.assertEqual(self.water.phase_of_matter, "gas") + self.water.TP = self.water.critical_temperature*2, 101325 + self.assertEqual(self.water.phase_of_matter, "supercritical") + self.water.TP = 300, self.water.critical_pressure*2 + self.assertEqual(self.water.phase_of_matter, "supercritical") + self.water.TQ = 300, 0.4 + self.assertEqual(self.water.phase_of_matter, "liquid-gas-mix") + + # These cases work after fixing GH-786 + n2 = ct.Nitrogen() + n2.TP = 100, 1000 + self.assertEqual(n2.phase_of_matter, "gas") + + co2 = ct.CarbonDioxide() + self.assertEqual(co2.phase_of_matter, "gas") + + def test_water_backends(self): + w = ct.Water(backend='Reynolds') + self.assertEqual(w.thermo_model, 'PureFluid') + w = ct.Water(backend='IAPWS95') + self.assertEqual(w.thermo_model, 'liquid-water-IAPWS95') + with self.assertRaisesRegex(KeyError, 'Unknown backend'): + ct.Water('foobar') + + def test_water_iapws(self): + w = ct.Water(backend='IAPWS95') + self.assertNear(w.critical_density, 322.) + self.assertNear(w.critical_temperature, 647.096) + self.assertNear(w.critical_pressure, 22064000.0) + + # test internal TP setters (setters update temperature at constant + # density before updating pressure) + w.TP = 300, ct.one_atm + dens = w.density + w.TP = 2000, ct.one_atm # supercritical + self.assertEqual(w.phase_of_matter, "supercritical") + w.TP = 300, ct.one_atm # state goes from supercritical -> gas -> liquid + self.assertNear(w.density, dens) + self.assertEqual(w.phase_of_matter, "liquid") + + # test setters for critical conditions + w.TP = w.critical_temperature, w.critical_pressure + self.assertNear(w.density, 322.) + w.TP = 2000, ct.one_atm # uses current density as initial guess + w.TP = 273.16, ct.one_atm # uses fixed density as initial guess + self.assertNear(w.density, 999.84376) + self.assertEqual(w.phase_of_matter, "liquid") + w.TP = w.T, w.P_sat + self.assertEqual(w.phase_of_matter, "liquid") + with self.assertRaisesRegex(ct.CanteraError, "assumes liquid phase"): + w.TP = 273.1599999, ct.one_atm + with self.assertRaisesRegex(ct.CanteraError, "assumes liquid phase"): + w.TP = 500, ct.one_atm \ No newline at end of file From 594e50f6758b56cd03a9cd8e79a245039e4e683b Mon Sep 17 00:00:00 2001 From: hallaali <77864659+hallaali@users.noreply.github.com> Date: Thu, 15 Apr 2021 18:40:45 -0400 Subject: [PATCH 03/41] Create Python examples with units These examples use the units module to implement the existing examples, but demonstrate how alternative units can be used for the input and output quantities. --- samples/python/thermo/isentropic_units.py | 65 ++++++++++++++++++ samples/python/thermo/rankine_units.py | 77 ++++++++++++++++++++++ samples/python/thermo/sound_speed.py | 5 +- samples/python/thermo/sound_speed_units.py | 60 +++++++++++++++++ 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 samples/python/thermo/isentropic_units.py create mode 100644 samples/python/thermo/rankine_units.py create mode 100644 samples/python/thermo/sound_speed_units.py diff --git a/samples/python/thermo/isentropic_units.py b/samples/python/thermo/isentropic_units.py new file mode 100644 index 0000000000..724773356f --- /dev/null +++ b/samples/python/thermo/isentropic_units.py @@ -0,0 +1,65 @@ +""" +Isentropic, adiabatic flow example - calculate area ratio vs. Mach number curve + +Requires: cantera >= 2.5.0, matplotlib >= 2.0 +""" + +import cantera.units as ct +import math +import numpy as np +ct.units.default_format = ".2F~P" +label_string = "area ratio\tMach number\ttemperature\tpressure ratio" +output_string = "{0:.2E~P}\t{1} {2}\t{3:.2E~P}" + + +def soundspeed(gas): + """The speed of sound. Assumes an ideal gas.""" + + gamma = gas.cp / gas.cv + return np.sqrt(gamma * ct.units.molar_gas_constant + * gas.T / gas.mean_molecular_weight).to("m/s") + + +def isentropic(gas=None): + """ + In this example, the area ratio vs. Mach number curve is computed. If a gas + object is supplied, it will be used for the calculations, with the + stagnation state given by the input gas state. Otherwise, the calculations + will be done for a 10:1 hydrogen/nitrogen mixture with stagnation T0 = 1700.33 + degrees Fahrenheit, P0 = 10 atm. + """ + if gas is None: + gas = ct.Solution('gri30.yaml') + gas.TPX = 2160 * ct.units.degR, 10.0 * ct.units.atm, 'H2:1,N2:0.1' + + # get the stagnation state parameters + s0 = gas.s + h0 = gas.h + p0 = gas.P + + mdot = 1 * ct.units.kg / ct.units.s # arbitrary + amin = 1.e14 * ct.units.m**2 + + data = [] + + # compute values for a range of pressure ratios + p_range = np.logspace(-3, 0, 200) * p0 + for p in p_range: + + # set the state using (p,s0) + gas.SP = s0, p + + v = np.sqrt(2.0*(h0 - gas.h)).to("m/s") # h + V^2/2 = h0 + area = mdot/(gas.density*v) # rho*v*A = constant + amin = min(amin, area) + data.append([area, v/soundspeed(gas), gas.T, p/p0]) + + return data, amin + + +if __name__ == "__main__": + print(__doc__) + data, amin = isentropic() + print(label_string) + for row in data: + print(output_string.format(row[0] / amin, row[1], row[2], row[3])) diff --git a/samples/python/thermo/rankine_units.py b/samples/python/thermo/rankine_units.py new file mode 100644 index 0000000000..eaabb02089 --- /dev/null +++ b/samples/python/thermo/rankine_units.py @@ -0,0 +1,77 @@ +""" +A Rankine vapor power cycle + +Requires: Cantera >= 2.5.0 +""" + +import cantera.units as ct + +# parameters +eta_pump = 0.6 * ct.units.dimensionless # pump isentropic efficiency +eta_turbine = 0.8 * ct.units.dimensionless # turbine isentropic efficiency +p_max = 116.03 * ct.units.psi # maximum pressure + + +def pump(fluid, p_final, eta): + """Adiabatically pump a fluid to pressure p_final, using + a pump with isentropic efficiency eta.""" + h0 = fluid.h + s0 = fluid.s + fluid.SP = s0, p_final + h1s = fluid.h + isentropic_work = h1s - h0 + actual_work = isentropic_work / eta + h1 = h0 + actual_work + fluid.HP = h1, p_final + return actual_work + + +def expand(fluid, p_final, eta): + """Adiabatically expand a fluid to pressure p_final, using + a turbine with isentropic efficiency eta.""" + h0 = fluid.h + s0 = fluid.s + fluid.SP =s0, p_final + h1s = fluid.h + isentropic_work = h0 - h1s + actual_work = isentropic_work * eta + h1 = h0 - actual_work + fluid.HP = h1, p_final + return actual_work + + +def printState(n, fluid): + print('\n***************** State {0} ******************'.format(n)) + print(fluid.report()) + + +if __name__ == '__main__': + # create an object representing water + w = ct.Water() + + # start with saturated liquid water at 80.33 degrees Fahrenheit + w.TQ = 540 * ct.units.degR, 0.0 * ct.units.dimensionless + h1 = w.h + p1 = w.P + printState(1, w) + + # pump it adiabatically to p_max + pump_work = pump(w, p_max, eta_pump) + h2 = w.h + printState(2, w) + + # heat it at constant pressure until it reaches the saturated vapor state + # at this pressure + w.PQ = p_max, 1.0 * ct.units.dimensionless + h3 = w.h + heat_added = h3 - h2 + printState(3, w) + + # expand back to p1 + turbine_work = expand(w, p1, eta_turbine) + printState(4, w) + + # efficiency + eff = (turbine_work - pump_work)/heat_added + + print('efficiency = ', eff) diff --git a/samples/python/thermo/sound_speed.py b/samples/python/thermo/sound_speed.py index 3a152fc2e4..db39dc9ab9 100644 --- a/samples/python/thermo/sound_speed.py +++ b/samples/python/thermo/sound_speed.py @@ -7,6 +7,7 @@ import cantera as ct import math +import numpy as np def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): @@ -53,7 +54,7 @@ def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): if __name__ == "__main__": gas = ct.Solution('gri30.yaml') gas.X = 'CH4:1.00, O2:2.0, N2:7.52' - for n in range(27): - T = 300.0 + 100.0 * n + T_range = np.linspace(300, 2900, 27) + for T in T_range: gas.TP = T, ct.one_atm print(T, equilSoundSpeeds(gas)) diff --git a/samples/python/thermo/sound_speed_units.py b/samples/python/thermo/sound_speed_units.py new file mode 100644 index 0000000000..9c58a324b8 --- /dev/null +++ b/samples/python/thermo/sound_speed_units.py @@ -0,0 +1,60 @@ +""" +Compute the "equilibrium" and "frozen" sound speeds for a gas + +Requires: cantera >= 2.5.0 +""" + +import cantera.units as ct +import numpy as np +import math + +ct.units.default_format = ".2F~P" + +def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): + """ + Returns a tuple containing the equilibrium and frozen sound speeds for a + gas with an equilibrium composition. The gas is first set to an + equilibrium state at the temperature and pressure of the gas, since + otherwise the equilibrium sound speed is not defined. + """ + + # set the gas to equilibrium at its current T and P + gas.equilibrate('TP', rtol=rtol, max_iter=max_iter) + + # save properties + s0 = gas.s + p0 = gas.P + r0 = gas.density + + # perturb the pressure + p1 = p0*1.0001 + + # set the gas to a state with the same entropy and composition but + # the perturbed pressure + gas.SP = s0, p1 + + # frozen sound speed + afrozen = np.sqrt((p1 - p0)/(gas.density - r0)).to("ft/s") + + # now equilibrate the gas holding S and P constant + gas.equilibrate('SP', rtol=rtol, max_iter=max_iter) + + # equilibrium sound speed + aequil = np.sqrt((p1 - p0)/(gas.density - r0)).to("ft/s") + + # compute the frozen sound speed using the ideal gas expression as a check + gamma = gas.cp/gas.cv + afrozen2 = np.sqrt(gamma * ct.units.molar_gas_constant * gas.T / + gas.mean_molecular_weight).to("ft/s") + + return aequil, afrozen, afrozen2 + +# test program +if __name__ == "__main__": + gas = ct.Solution('gri30.yaml') + gas.X = 'CH4:1.00, O2:2.0, N2:7.52' + T_range = np.linspace(80.33, 4760.33, 50) * ct.units.degF + print("Temperature Equilibrium Sound Speed Frozen Sound Speed Frozen Sound Speed Check") + for T in T_range: + gas.TP = T, 1.0 * ct.units.atm + print(T, *equilSoundSpeeds(gas), sep = " ") From b7051d43d8a255f8dfad873d2274be9ed88cb899 Mon Sep 17 00:00:00 2001 From: hallaali <77864659+hallaali@users.noreply.github.com> Date: Thu, 29 Apr 2021 16:37:02 -0400 Subject: [PATCH 04/41] fix tests and update unit examples --- interfaces/cython/SConscript | 7 +- interfaces/cython/cantera/test/test_units.py | 691 +++++++++--------- .../cython/cantera/units/solution.py.in | 16 + samples/python/thermo/isentropic_units.py | 3 +- samples/python/thermo/rankine_units.py | 2 +- samples/python/thermo/sound_speed_units.py | 3 +- 6 files changed, 350 insertions(+), 372 deletions(-) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index c3f224a222..c8fcb62ae2 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -144,8 +144,8 @@ getter_template = Template(""" getter_properties = [ "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", - "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "P", "P_sat", "T", "T_sat", - "atomic_weight", "chemical_potentials", "concentrations", "critical_pressure", + "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "cv_mass", "cv_mole", "P", "P_sat", "T", + "T_sat", "atomic_weight", "chemical_potentials", "concentrations", "critical_pressure", "critical_temperature", "electric_potential", "electrochemical_potentials", "isothermal_compressibility", "max_temp", "mean_molecular_weight", "min_temp", "molecular_weights", "partial_molar_cp", "partial_molar_enthalpies", @@ -157,7 +157,6 @@ getter_properties = [ getter_string = "".join( getter_template.substitute(name=name, units=UNITS[name]) for name in getter_properties ) -pf_getter_string = getter_template.substitute(name="Q", units=UNITS["Q"]) setter_template = Template(""" @property @@ -216,7 +215,7 @@ pf_setter1_string = "".join( ) solution_properties = "".join([getter_string, setter_string, setter1_string]) -pf_properties = "".join([pf_getter_string, pf_setter_string, pf_setter1_string]) +pf_properties = "".join([pf_setter_string, pf_setter1_string]) localenv["solution_properties"] = solution_properties.strip() localenv["purefluid_properties"] = pf_properties.strip() diff --git a/interfaces/cython/cantera/test/test_units.py b/interfaces/cython/cantera/test/test_units.py index 43d321a637..c079b473a3 100644 --- a/interfaces/cython/cantera/test/test_units.py +++ b/interfaces/cython/cantera/test/test_units.py @@ -6,9 +6,17 @@ Q_ = units.Quantity import cantera.units as ct +import cantera as cn from . import utilities -class TestSolutionUnits(utilities.CanteraTest): +class CanteraUnitsTest(utilities.CanteraTest): + def assertQuantityNear(self, a, b, atol=1.0E-12, rtol=1.0E-8): + if not np.isclose(a, b, atol=atol, rtol=rtol): + message = (f'AssertNear: {a:.14g} - {b:.14g} = {a-b:.14g}\n' + + f'rtol = {rtol:10e}; atol = {atol:10e}') + self.fail(message) + +class TestSolutionUnits(CanteraUnitsTest): def setUp(self): self.phase = ct.Solution("h2o2.yaml") @@ -24,6 +32,7 @@ def test_mass_basis(self): self.assertQuantityNear(self.phase.cv_mass, self.phase.cv) def test_molar_basis(self): + self.phase._phase.basis = "molar" self.assertEqual(self.phase.basis_units, 'kmol') self.assertQuantityNear(self.phase.density_mole, self.phase.density) self.assertQuantityNear(self.phase.enthalpy_mole, self.phase.h) @@ -37,170 +46,164 @@ def test_molar_basis(self): def test_dimensions(self): #basis-independent dims_T = self.phase.T.dimensionality - self.assertIn("[temperature]", dims_T) self.assertEqual(dims_T["[temperature]"], 1.0) dims_P = self.phase.P.dimensionality - self.assertIn("[pressure]", dims_P) - self.assertEqual(dims_P["[pressure]"], 1.0) + self.assertEqual(dims_P["[mass]"], 1.0) + self.assertEqual(dims_P["[length]"], -1.0) + self.assertEqual(dims_P["[time]"], -2.0) dims_X = self.phase.X.dimensionality - self.assertIn("[dimensionless]", dims_X) - self.assertEqual(dims_X["[dimensionless]"], 1.0) + #units container for dimensionless is empty + self.assertEqual(len(dims_X), 0) dims_Y = self.phase.Y.dimensionality - self.assertIn("[dimensionless]", dims_Y) - self.assertEqual(dims_Y["[dimensionless]"], 1.0) - dims_Q = self.phase.Q.dimensionality - self.assertIn("[dimensionless]", dims_Q) - self.assertEqual(dims_Q["[dimensionless]"], 1.0) - dims_T_sat = self.phase.T_sat.dimensionality - self.assertIn("[temperature]", dims_T_sat) - self.assertEqual(dims_T_sat["[temperature]"], 1.0) - dims_P_sat = self.phase.P_sat.dimensionality - self.assertIn("[pressure]", dims_P_sat) - self.assertEqual(dims_P_sat["[pressure]"], 1.0) + self.assertEqual(len(dims_Y), 0) + # dims_T_sat = self.phase.T_sat.dimensionality + # self.assertEqual(dims_T_sat["[temperature]"], 1.0) + # dims_P_sat = self.phase.P_sat.dimensionality + # self.assertEqual(dims_P_sat["[mass]"], 1.0) + # self.assertEqual(dims_P_sat["[length]"], -1.0) + # self.assertEqual(dims_P_sat["[time]"], -2.0) dims_atomic_weight = self.phase.atomic_weight.dimensionality - self.assertIn("[mass/molar]", dims_atomic_weight) self.assertEqual(dims_atomic_weight["[mass]"], 1.0) - self.assertEqual(dims_atomic_weight["[molar]"], -1.0) + self.assertEqual(dims_atomic_weight["[substance]"], -1.0) dims_chemical_potentials = self.phase.chemical_potentials.dimensionality - self.assertIn("[energy/molar]", dims_chemical_potentials) - self.assertEqual(dims_chemical_potentials["[energy]"], 1.0) - self.assertEqual(dims_chemical_potentials["[molar]"], -1.0) - dims_concentration = self.phase.concentration.dimensionality - self.assertIn("[molar/length]", dims_concentration) - self.assertEqual(dims_concentration["[molar]"], 1.0) - self.assertEqual(dims_concentration["[length]"], -3.0) - dims_critical_temperature = self.phase.critical_temperature.dimensionality - self.assertIn("[temperature]", dims_critical_temperature) - self.assertEqual(dims_critical_temperature["[temperature]"], 1.0) - dims_critical_pressure = self.phase.critical_pressure.dimensionality - self.assertIn("[pressure]", dims_critical_pressure) - self.assertEqual(dims_critical_pressure["[pressure]"], 1.0) + self.assertEqual(dims_chemical_potentials["[mass]"], 1.0) + self.assertEqual(dims_chemical_potentials["[length]"], 2.0) + self.assertEqual(dims_chemical_potentials["[time]"], -2.0) + self.assertEqual(dims_chemical_potentials["[substance]"], -1.0) + dims_concentrations = self.phase.concentrations.dimensionality + self.assertEqual(dims_concentrations["[substance]"], 1.0) + self.assertEqual(dims_concentrations["[length]"], -3.0) + # dims_critical_temperature = self.phase.critical_temperature.dimensionality + # self.assertEqual(dims_critical_temperature["[temperature]"], 1.0) + # dims_critical_pressure = self.phase.critical_pressure.dimensionality + # self.assertEqual(dims_critical_pressure["[mass]"], 1.0) + # self.assertEqual(dims_critical_pressure["[length]"], -1.0) + # self.assertEqual(dims_critical_pressure["[time]"], -2.0) dims_electric_potential = self.phase.electric_potential.dimensionality - self.assertIn("[volts]", dims_electric_potential) - self.assertEqual(dims_electric_potential["[volts]"], 1.0) + self.assertEqual(dims_electric_potential["[mass]"], 1.0) + self.assertEqual(dims_electric_potential["[length]"], 2.0) + self.assertEqual(dims_electric_potential["[time]"], -3.0) + self.assertEqual(dims_electric_potential["[current]"], -1.0) dims_electrochemical_potentials = self.phase.electrochemical_potentials.dimensionality - self.assertIn("[energy/molar]", dims_electrochemical_potentials) - self.assertEqual(dims_electrochemical_potentials["[energy]"], 1.0) - self.assertEqual(dims_electrochemical_potentials["[molar]"], -1.0) + self.assertEqual(dims_electrochemical_potentials["[mass]"], 1.0) + self.assertEqual(dims_electrochemical_potentials["[length]"], 2.0) + self.assertEqual(dims_electrochemical_potentials["[time]"], -2.0) + self.assertEqual(dims_electrochemical_potentials["[substance]"], -1.0) dims_isothermal_compressibility = self.phase.isothermal_compressibility.dimensionality - self.assertIn("[1/pressure]", dims_isothermal_compressibility) - self.assertEqual(dims_isothermal_compressibility["[pressure]"], -1.0) + self.assertEqual(dims_isothermal_compressibility["[mass]"], -1.0) + self.assertEqual(dims_isothermal_compressibility["[length]"], 1.0) + self.assertEqual(dims_isothermal_compressibility["[time]"], 2.0) dims_max_temp = self.phase.max_temp.dimensionality - self.assertIn("[temperature]", dims_max_temp) self.assertEqual(dims_max_temp["[temperature]"], 1.0) dims_mean_molecular_weight = self.phase.mean_molecular_weight.dimensionality - self.assertIn("[mass/molar]", dims_mean_molecular_weight) self.assertEqual(dims_mean_molecular_weight["[mass]"], 1.0) - self.assertEqual(dims_mean_molecular_weight["[molar]"], -1.0) + self.assertEqual(dims_mean_molecular_weight["[substance]"], -1.0) dims_min_temp = self.phase.min_temp.dimensionality - self.assertIn("[temperature]", dims_min_temp) self.assertEqual(dims_min_temp["[temperature]"], 1.0) dims_molecular_weights = self.phase.molecular_weights.dimensionality - self.assertIn("[mass/molar]", dims_molecular_weights) self.assertEqual(dims_molecular_weights["[mass]"], 1.0) - self.assertEqual(dims_molecular_weights["[molar]"], -1.0) + self.assertEqual(dims_molecular_weights["[substance]"], -1.0) dims_partial_molar_cp = self.phase.partial_molar_cp.dimensionality - self.assertIn("[energy/molar/temperature]", dims_partial_molar_cp) - self.assertEqual(dims_partial_molar_cp["[energy]"], 1.0) - self.assertEqual(dims_partial_molar_cp["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_cp["[mass]"], 1.0) + self.assertEqual(dims_partial_molar_cp["[length]"], 2.0) + self.assertEqual(dims_partial_molar_cp["[time]"], -2.0) + self.assertEqual(dims_partial_molar_cp["[substance]"], -1.0) self.assertEqual(dims_partial_molar_cp["[temperature]"], -1.0) dims_partial_molar_enthalpies = self.phase.partial_molar_enthalpies.dimensionality - self.assertIn("[energy/molar/temperature]", dims_partial_molar_enthalpies) - self.assertEqual(dims_partial_molar_enthalpies["[energy]"], 1.0) - self.assertEqual(dims_partial_molar_enthalpies["[molar]"], -1.0) - self.assertEqual(dims_partial_molar_enthalpies["[temperature]"], -1.0) + self.assertEqual(dims_partial_molar_enthalpies["[mass]"], 1.0) + self.assertEqual(dims_partial_molar_enthalpies["[length]"], 2.0) + self.assertEqual(dims_partial_molar_enthalpies["[time]"], -2.0) + self.assertEqual(dims_partial_molar_enthalpies["[substance]"], -1.0) dims_partial_molar_entropies = self.phase.partial_molar_entropies.dimensionality - self.assertIn("[energy/molar/temperature]", dims_partial_molar_entropies) - self.assertEqual(dims_partial_molar_entropies["[energy]"], 1.0) - self.assertEqual(dims_partial_molar_entropies["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_entropies["[mass]"], 1.0) + self.assertEqual(dims_partial_molar_entropies["[length]"], 2.0) + self.assertEqual(dims_partial_molar_entropies["[time]"], -2.0) + self.assertEqual(dims_partial_molar_entropies["[substance]"], -1.0) self.assertEqual(dims_partial_molar_entropies["[temperature]"], -1.0) dims_partial_molar_int_energies = self.phase.partial_molar_int_energies.dimensionality - self.assertIn("[energy/molar]", dims_partial_molar_int_energies) - self.assertEqual(dims_partial_molar_int_energies["[energy]"], 1.0) - self.assertEqual(dims_partial_molar_int_energies["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_int_energies["[mass]"], 1.0) + self.assertEqual(dims_partial_molar_int_energies["[length]"], 2.0) + self.assertEqual(dims_partial_molar_int_energies["[time]"], -2.0) + self.assertEqual(dims_partial_molar_int_energies["[substance]"], -1.0) dims_partial_molar_volumes = self.phase.partial_molar_volumes.dimensionality - self.assertIn("[length/molar]", dims_partial_molar_volumes) self.assertEqual(dims_partial_molar_volumes["[length]"], 3.0) - self.assertEqual(dims_partial_molar_volumes["[molar]"], -1.0) + self.assertEqual(dims_partial_molar_volumes["[substance]"], -1.0) dims_reference_pressure = self.phase.reference_pressure.dimensionality - self.assertIn("[pressure]", dims_reference_pressure) - self.assertEqual(dims_reference_pressure["[pressure]"], 1.0) + self.assertEqual(dims_reference_pressure["[mass]"], 1.0) + self.assertEqual(dims_reference_pressure["[length]"], -1.0) + self.assertEqual(dims_reference_pressure["[time]"], -2.0) dims_thermal_expansion_coeff = self.phase.thermal_expansion_coeff.dimensionality - self.assertIn("[1/temperature]", dims_thermal_expansion_coeff) self.assertEqual(dims_thermal_expansion_coeff["[temperature]"], -1.0) #basis-dependent (mass) dims_density_mass = self.phase.density_mass.dimensionality - self.assertIn("[mass/length]", dims_density_mass) self.assertEqual(dims_density_mass["[mass]"], 1.0) self.assertEqual(dims_density_mass["[length]"], -3.0) dims_enthalpy_mass = self.phase.enthalpy_mass.dimensionality - self.assertIn("[energy/mass]", dims_enthalpy_mass) - self.assertEqual(dims_enthalpy_mass["[energy]"], 1.0) - self.assertEqual(dims_enthalpy_mass["[mass]"], -1.0) + self.assertEqual(dims_enthalpy_mass["[length]"], 2.0) + self.assertEqual(dims_enthalpy_mass["[time]"], -2.0) dims_entropy_mass = self.phase.entropy_mass.dimensionality - self.assertIn("[energy/mass/temperature]", dims_entropy_mass) - self.assertEqual(dims_entropy_mass["[energy]"], 1.0) - self.assertEqual(dims_entropy_mass["[mass]"], -1.0) + self.assertEqual(dims_entropy_mass["[length]"], 2.0) + self.assertEqual(dims_entropy_mass["[time]"], -2.0) self.assertEqual(dims_entropy_mass["[temperature]"], -1.0) dims_int_energy_mass = self.phase.int_energy_mass.dimensionality - self.assertIn("[energy/mass]", dims_int_energy_mass) - self.assertEqual(dims_int_energy_mass["[energy]"], 1.0) - self.assertEqual(dims_int_energy_mass["[mass]"], -1.0) + self.assertEqual(dims_int_energy_mass["[length]"], 2.0) + self.assertEqual(dims_int_energy_mass["[time]"], -2.0) dims_volume_mass = self.phase.volume_mass.dimensionality - self.assertIn("[length/mass]", dims_volume_mass) self.assertEqual(dims_volume_mass["[length]"], 3.0) - self.assertEqual(dims_volume_mass["[mass]"], 1.0) + self.assertEqual(dims_volume_mass["[mass]"], -1.0) dims_gibbs_mass = self.phase.gibbs_mass.dimensionality - self.assertIn("[energy/mass]", dims_gibbs_mass) - self.assertEqual(dims_gibbs_mass["[energy]"], 1.0) - self.assertEqual(dims_gibbs_mass["[mass]"], -1.0) + self.assertEqual(dims_gibbs_mass["[length]"], 2.0) + self.assertEqual(dims_gibbs_mass["[time]"], -2.0) dims_cp_mass = self.phase.cp_mass.dimensionality - self.assertIn("[energy/mass/temperature]", dims_cp_mass) - self.assertEqual(dims_cp_mass["[energy]"], 1.0) - self.assertEqual(dims_cp_mass["[mass]"], -1.0) + self.assertEqual(dims_cp_mass["[length]"], 2.0) + self.assertEqual(dims_cp_mass["[time]"], -2.0) self.assertEqual(dims_cp_mass["[temperature]"], -1.0) dims_cv_mass = self.phase.cv.dimensionality - self.assertIn("[energy/mass/temperature]", dims_cv_mass) - self.assertEqual(dims_cv_mass["[energy]"], 1.0) - self.assertEqual(dims_cv_mass["[mass]"], -1.0) + self.assertEqual(dims_cv_mass["[length]"], 2.0) + self.assertEqual(dims_cv_mass["[time]"], -2.0) self.assertEqual(dims_cv_mass["[temperature]"], -1.0) #basis-dependent (molar) dims_density_mole = self.phase.density_mole.dimensionality - self.assertIn("[molar/length]", dims_density_mole) - self.assertEqual(dims_density_mole["[molar]"], 1.0) + self.assertEqual(dims_density_mole["[substance]"], 1.0) self.assertEqual(dims_density_mole["[length]"], -3.0) dims_enthalpy_mole = self.phase.enthalpy_mole.dimensionality - self.assertIn("[energy/molar]", dims_enthalpy_mole) - self.assertEqual(dims_enthalpy_mole["[energy]"], 1.0) - self.assertEqual(dims_enthalpy_mole["[molar]"], -1.0) + self.assertEqual(dims_enthalpy_mole["[mass]"], 1.0) + self.assertEqual(dims_enthalpy_mole["[length]"], 2.0) + self.assertEqual(dims_enthalpy_mole["[time]"], -2.0) + self.assertEqual(dims_enthalpy_mole["[substance]"], -1.0) dims_entropy_mole = self.phase.entropy_mole.dimensionality - self.assertIn("[energy/molar/temperature]", dims_entropy_mole) - self.assertEqual(dims_entropy_mole["[energy]"], 1.0) - self.assertEqual(dims_entropy_mole["[molar]"], -1.0) + self.assertEqual(dims_entropy_mole["[mass]"], 1.0) + self.assertEqual(dims_entropy_mole["[length]"], 2.0) + self.assertEqual(dims_entropy_mole["[time]"], -2.0) + self.assertEqual(dims_entropy_mole["[substance]"], -1.0) self.assertEqual(dims_entropy_mole["[temperature]"], -1.0) dims_int_energy_mole = self.phase.int_energy_mole.dimensionality - self.assertIn("[energy/molar]", dims_int_energy_mole) - self.assertEqual(dims_int_energy_mole["[energy]"], 1.0) - self.assertEqual(dims_int_energy_mole["[molar]"], -1.0) + self.assertEqual(dims_int_energy_mole["[mass]"], 1.0) + self.assertEqual(dims_int_energy_mole["[length]"], 2.0) + self.assertEqual(dims_int_energy_mole["[time]"], -2.0) + self.assertEqual(dims_int_energy_mole["[substance]"], -1.0) dims_volume_mole = self.phase.volume_mole.dimensionality - self.assertIn("[length/molar]", dims_volume_mole) self.assertEqual(dims_volume_mole["[length]"], 3.0) - self.assertEqual(dims_volume_mole["[molar]"], 1.0) + self.assertEqual(dims_volume_mole["[substance]"], -1.0) dims_gibbs_mole = self.phase.gibbs_mole.dimensionality - self.assertIn("[energy/molar]", dims_gibbs_mole) - self.assertEqual(dims_gibbs_mole["[energy]"], 1.0) - self.assertEqual(dims_gibbs_mole["[molar]"], -1.0) + self.assertEqual(dims_gibbs_mole["[mass]"], 1.0) + self.assertEqual(dims_gibbs_mole["[length]"], 2.0) + self.assertEqual(dims_gibbs_mole["[time]"], -2.0) + self.assertEqual(dims_gibbs_mole["[substance]"], -1.0) dims_cp_mole = self.phase.cp_mole.dimensionality - self.assertIn("[energy/molar/temperature]", dims_cp_mole) - self.assertEqual(dims_cp_mole["[energy]"], 1.0) - self.assertEqual(dims_cp_mole["[molar]"], -1.0) + self.assertEqual(dims_cp_mole["[mass]"], 1.0) + self.assertEqual(dims_cp_mole["[length]"], 2.0) + self.assertEqual(dims_cp_mole["[time]"], -2.0) + self.assertEqual(dims_cp_mole["[substance]"], -1.0) self.assertEqual(dims_cp_mole["[temperature]"], -1.0) - dims_cv_mole = self.phase.cv.dimensionality - self.assertIn("[energy/molar/temperature]", dims_cv_mole) - self.assertEqual(dims_cv_mole["[energy]"], 1.0) - self.assertEqual(dims_cv_mole["[molar]"], -1.0) + dims_cv_mole = self.phase.cv_mole.dimensionality + self.assertEqual(dims_cv_mole["[mass]"], 1.0) + self.assertEqual(dims_cv_mole["[length]"], 2.0) + self.assertEqual(dims_cv_mole["[time]"], -2.0) + self.assertEqual(dims_cv_mole["[substance]"], -1.0) self.assertEqual(dims_cv_mole["[temperature]"], -1.0) + def check_setters(self, T1, rho1, Y1): T0, rho0, Y0 = self.phase.TDY self.phase.TDY = T1, rho1, Y1 @@ -212,8 +215,8 @@ def check_setters(self, T1, rho1, Y1): v1 = self.phase.v def check_state(T, rho, Y): - self.assertNear(self.phase.T, T) - self.assertNear(self.phase.density, rho) + self.assertQuantityNear(self.phase.T, T) + self.assertQuantityNear(self.phase.density, rho) self.assertArrayNear(self.phase.Y, Y) self.phase.TDY = T0, rho0, Y0 @@ -265,212 +268,212 @@ def check_state(T, rho, Y): check_state(T1, rho1, Y1) def test_setState_mass(self): - self.check_setters(T1 = Q_(500.0, "K"), rho1 = Q_(1.5, 'self.basis+"/m**3"'), - Y1 = Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2], "dimensionless") + self.check_setters(T1 = Q_(500.0, "K"), rho1 = Q_(1.5, "kg/m**3"), + Y1 = Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless")) - def test_setState_mole(self): - self.phase.basis = 'molar' - self.check_setters(T1 = Q_(750.0, "K"), rho1 = Q_(0.02, 'self.basis+"/m**3"'), - Y1 = Q_([0.2, 0.1, 0.0, 0.3, 0.1, 0.0, 0.0, 0.2, 0.1], "dimensionless")) + # def test_setState_mole(self): + # self.phase.basis = 'molar' + # self.check_setters(T1 = Q_(750.0, "K"), rho1 = Q_(0.02, "kmol/m**3"), + # Y1 = Q_([0.2, 0.1, 0.0, 0.3, 0.1, 0.0, 0.0, 0.2, 0.1, 0.0], "dimensionless")) def test_setters_hold_constant(self): props = ('T','P','s','h','u','v','X','Y') pairs = [('TP', 'T', 'P'), ('SP', 's', 'P'), ('UV', 'u', 'v')] - self.phase.TDX = 1000, 1.5, 'H2O:0.1, O2:0.95, AR:3.0' + self.phase.TDX = Q_(1000, "K"), Q_(1.5, "kg/m**3"), 'H2O:0.1, O2:0.95, AR:3.0' values = {} for p in props: values[p] = getattr(self.phase, p) for pair, first, second in pairs: - self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' first_val = getattr(self.phase, first) second_val = getattr(self.phase, second) setattr(self.phase, pair, (values[first], None)) - self.assertNear(getattr(self.phase, first), values[first]) - self.assertNear(getattr(self.phase, second), second_val) + self.assertQuantityNear(getattr(self.phase, first), values[first]) + self.assertQuantityNear(getattr(self.phase, second), second_val) - self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' setattr(self.phase, pair, (None, values[second])) - self.assertNear(getattr(self.phase, first), first_val) - self.assertNear(getattr(self.phase, second), values[second]) + self.assertQuantityNear(getattr(self.phase, first), first_val) + self.assertQuantityNear(getattr(self.phase, second), values[second]) - self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' setattr(self.phase, pair + 'X', (None, None, values['X'])) - self.assertNear(getattr(self.phase, first), first_val) - self.assertNear(getattr(self.phase, second), second_val) + self.assertQuantityNear(getattr(self.phase, first), first_val) + self.assertQuantityNear(getattr(self.phase, second), second_val) - self.phase.TDX = 500, 2.5, 'H2:0.1, O2:1.0, AR:3.0' + self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' setattr(self.phase, pair + 'Y', (None, None, values['Y'])) - self.assertNear(getattr(self.phase, first), first_val) - self.assertNear(getattr(self.phase, second), second_val) + self.assertQuantityNear(getattr(self.phase, first), first_val) + self.assertQuantityNear(getattr(self.phase, second), second_val) def check_getters(self): T,D,X = self.phase.TDX - self.assertNear(T, self.phase.T) - self.assertNear(D, self.phase.density) + self.assertQuantityNear(T, self.phase.T) + self.assertQuantityNear(D, self.phase.density) self.assertArrayNear(X, self.phase.X) T,D,Y = self.phase.TDY - self.assertNear(T, self.phase.T) - self.assertNear(D, self.phase.density) + self.assertQuantityNear(T, self.phase.T) + self.assertQuantityNear(D, self.phase.density) self.assertArrayNear(Y, self.phase.Y) T,D = self.phase.TD - self.assertNear(T, self.phase.T) - self.assertNear(D, self.phase.density) + self.assertQuantityNear(T, self.phase.T) + self.assertQuantityNear(D, self.phase.density) T,P,X = self.phase.TPX - self.assertNear(T, self.phase.T) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(T, self.phase.T) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(X, self.phase.X) T,P,Y = self.phase.TPY - self.assertNear(T, self.phase.T) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(T, self.phase.T) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(Y, self.phase.Y) T,P = self.phase.TP - self.assertNear(T, self.phase.T) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(T, self.phase.T) + self.assertQuantityNear(P, self.phase.P) H,P,X = self.phase.HPX - self.assertNear(H, self.phase.h) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(H, self.phase.h) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(X, self.phase.X) H,P,Y = self.phase.HPY - self.assertNear(H, self.phase.h) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(H, self.phase.h) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(Y, self.phase.Y) H,P = self.phase.HP - self.assertNear(H, self.phase.h) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(H, self.phase.h) + self.assertQuantityNear(P, self.phase.P) U,V,X = self.phase.UVX - self.assertNear(U, self.phase.u) - self.assertNear(V, self.phase.v) + self.assertQuantityNear(U, self.phase.u) + self.assertQuantityNear(V, self.phase.v) self.assertArrayNear(X, self.phase.X) U,V,Y = self.phase.UVY - self.assertNear(U, self.phase.u) - self.assertNear(V, self.phase.v) + self.assertQuantityNear(U, self.phase.u) + self.assertQuantityNear(V, self.phase.v) self.assertArrayNear(Y, self.phase.Y) U,V = self.phase.UV - self.assertNear(U, self.phase.u) - self.assertNear(V, self.phase.v) + self.assertQuantityNear(U, self.phase.u) + self.assertQuantityNear(V, self.phase.v) S,P,X = self.phase.SPX - self.assertNear(S, self.phase.s) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(S, self.phase.s) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(X, self.phase.X) S,P,Y = self.phase.SPY - self.assertNear(S, self.phase.s) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(S, self.phase.s) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(Y, self.phase.Y) S,P = self.phase.SP - self.assertNear(S, self.phase.s) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(S, self.phase.s) + self.assertQuantityNear(P, self.phase.P) S,V,X = self.phase.SVX - self.assertNear(S, self.phase.s) - self.assertNear(V, self.phase.v) + self.assertQuantityNear(S, self.phase.s) + self.assertQuantityNear(V, self.phase.v) self.assertArrayNear(X, self.phase.X) S,V,Y = self.phase.SVY - self.assertNear(S, self.phase.s) - self.assertNear(V, self.phase.v) + self.assertQuantityNear(S, self.phase.s) + self.assertQuantityNear(V, self.phase.v) self.assertArrayNear(Y, self.phase.Y) S,V = self.phase.SV - self.assertNear(S, self.phase.s) - self.assertNear(V, self.phase.v) + self.assertQuantityNear(S, self.phase.s) + self.assertQuantityNear(V, self.phase.v) D,P,X = self.phase.DPX - self.assertNear(D, self.phase.density) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(D, self.phase.density) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(X, self.phase.X) D,P,Y = self.phase.DPY - self.assertNear(D, self.phase.density) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(D, self.phase.density) + self.assertQuantityNear(P, self.phase.P) self.assertArrayNear(Y, self.phase.Y) D,P = self.phase.DP - self.assertNear(D, self.phase.density) - self.assertNear(P, self.phase.P) + self.assertQuantityNear(D, self.phase.density) + self.assertQuantityNear(P, self.phase.P) def test_getState_mass(self): - self.phase.TDY = 350.0, 0.7, 'H2:0.1, H2O2:0.1, AR:0.8' + self.phase.TDY = Q_(350.0, "K"), Q_(0.7, "kg/m**3"), 'H2:0.1, H2O2:0.1, AR:0.8' self.check_getters() def test_getState_mole(self): self.phase.basis = 'molar' - self.phase.TDX = 350.0, 0.01, 'H2:0.1, O2:0.3, AR:0.6' + self.phase.TDX = Q_(350.0, "K"), Q_(0.01, "kg/m**3"), 'H2:0.1, O2:0.3, AR:0.6' self.check_getters() def test_isothermal_compressibility(self): - self.assertNear(self.phase.isothermal_compressibility, 1.0/self.phase.P) + self.assertQuantityNear(self.phase.isothermal_compressibility, 1.0/self.phase.P) -class TestPureFluidUnits(utilities.CanteraTest): +class TestPureFluidUnits(CanteraUnitsTest): def setUp(self): self.water = ct.Water() def test_critical_properties(self): - self.assertNear(self.water.critical_pressure, 22.089e6) - self.assertNear(self.water.critical_temperature, 647.286) - self.assertNear(self.water.critical_density, 317.0) + self.assertQuantityNear(self.water.critical_pressure, 22.089e6 * ct.units.Pa) + self.assertQuantityNear(self.water.critical_temperature, 647.286 * ct.units.K) + self.assertQuantityNear(self.water.critical_density, 317.0 * ct.units.dimensionless) def test_temperature_limits(self): co2 = ct.CarbonDioxide() - self.assertNear(co2.min_temp, 216.54) - self.assertNear(co2.max_temp, 1500.0) + self.assertQuantityNear(co2.min_temp, 216.54 * ct.units.K) + self.assertQuantityNear(co2.max_temp, 1500.0 * ct.units.K) def test_set_state(self): - self.water.PQ = 101325, 0.5 - self.assertNear(self.water.P, 101325) - self.assertNear(self.water.Q, 0.5) + self.water.PQ = Q_(101325, "Pa"), Q_(0.5, "dimensionless") + self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) + self.assertQuantityNear(self.water.Q, 0.5 * ct.units.dimensionless) - self.water.TQ = 500, 0.8 - self.assertNear(self.water.T, 500) - self.assertNear(self.water.Q, 0.8) + self.water.TQ = Q_(500, "K"), Q_(0.8, "dimensionless") + self.assertQuantityNear(self.water.T, 500 * ct.units.K) + self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) def test_substance_set(self): - self.water.TV = 400, 1.45 - self.assertNear(self.water.T, 400) - self.assertNear(self.water.v, 1.45) - with self.assertRaisesRegex(ct.CanteraError, 'Negative specific volume'): - self.water.TV = 300, -1. + self.water.TV = Q_(400, "K"), Q_(1.45, "m**3/kg") + self.assertQuantityNear(self.water.T, 400 * ct.units.K) + self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) + with self.assertRaisesRegex(cn.CanteraError, 'Negative specific volume'): + self.water.TV = Q_(300, "K"), Q_(-1, "m**3/kg") - self.water.PV = 101325, 1.45 - self.assertNear(self.water.P, 101325) - self.assertNear(self.water.v, 1.45) + self.water.PV = Q_(101325, "Pa"), Q_(1.45, "m**3/kg") + self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) + self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) - self.water.UP = -1.45e7, 101325 - self.assertNear(self.water.u, -1.45e7) - self.assertNear(self.water.P, 101325) + self.water.UP = Q_(-1.45e7, "J/kg"), Q_(101325, "Pa") + self.assertQuantityNear(self.water.u, -1.45e7 * ct.units.J / ct.units.kg) + self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) - self.water.VH = 1.45, -1.45e7 - self.assertNear(self.water.v, 1.45) - self.assertNear(self.water.h, -1.45e7) + self.water.VH = Q_(1.45, "m**3/kg"), Q_(-1.45e7, "J/kg") + self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) + self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - self.water.TH = 400, -1.45e7 - self.assertNear(self.water.T, 400) - self.assertNear(self.water.h, -1.45e7) + self.water.TH = Q_(400, "K"), Q_(-1.45e7, "J/kg") + self.assertQuantityNear(self.water.T, 400 * ct.units.K) + self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - self.water.SH = 5000, -1.45e7 - self.assertNear(self.water.s, 5000) - self.assertNear(self.water.h, -1.45e7) + self.water.SH = Q_(5000, "J/kg/K"), Q_(-1.45e7, "J/kg") + self.assertQuantityNear(self.water.s, 5000 * ct.units.J / ct.units.kg / ct.units.K) + self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - self.water.ST = 5000, 400 - self.assertNear(self.water.s, 5000) - self.assertNear(self.water.T, 400) + self.water.ST = Q_(5000, "J/kg/K"), Q_(400, "K") + self.assertQuantityNear(self.water.s, 5000 * ct.units.J / ct.units.kg / ct.units.K) + self.assertQuantityNear(self.water.T, 400 * ct.units.K) def test_states(self): self.assertEqual(self.water._native_state, ('T', 'D')) @@ -478,27 +481,27 @@ def test_states(self): self.assertIn('TQ', self.water._partial_states.values()) def test_set_Q(self): - self.water.TQ = 500, 0.0 + self.water.TQ = Q_(500, "K"), Q_(0.0, "dimensionless") p = self.water.P - self.water.Q = 0.8 - self.assertNear(self.water.P, p) - self.assertNear(self.water.T, 500) - self.assertNear(self.water.Q, 0.8) + self.water.Q = Q_(0.8, "dimensionless") + self.assertQuantityNear(self.water.P, p) + self.assertQuantityNear(self.water.T, 500 * ct.units.K) + self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) - self.water.TP = 650, 101325 - with self.assertRaises(ct.CanteraError): - self.water.Q = 0.1 + self.water.TP = Q_(650, "K"), Q_(101325, "Pa") + with self.assertRaises(cn.CanteraError): + self.water.Q = Q_(0.1, "dimensionless") - self.water.TP = 300, 101325 + self.water.TP = Q_(300, "K"), Q_(101325, "Pa") with self.assertRaises(ValueError): - self.water.Q = 0.3 + self.water.Q = Q_(0.3, "dimensionless") def test_set_minmax(self): - self.water.TP = self.water.min_temp, 101325 - self.assertNear(self.water.T, self.water.min_temp) + self.water.TP = self.water.min_temp, Q_(101325, "Pa") + self.assertQuantityNear(self.water.T, self.water.min_temp) - self.water.TP = self.water.max_temp, 101325 - self.assertNear(self.water.T, self.water.max_temp) + self.water.TP = self.water.max_temp, Q_(101325, "Pa") + self.assertQuantityNear(self.water.T, self.water.max_temp) def check_fd_properties(self, T1, P1, T2, P2, tol): # Properties which are computed as finite differences @@ -518,166 +521,166 @@ def check_fd_properties(self, T1, P1, T2, P2, tol): alpha2 = self.water.thermal_expansion_coeff h2b = self.water.enthalpy_mass - self.assertNear(cp1, cp2, tol) - self.assertNear(cv1, cv2, tol) - self.assertNear(k1, k2, tol) - self.assertNear(alpha1, alpha2, tol) + self.assertQuantityNear(cp1, cp2, tol, tol) + self.assertQuantityNear(cv1, cv2, tol, tol) + self.assertQuantityNear(k1, k2, tol, tol) + self.assertQuantityNear(alpha1, alpha2, tol, tol) # calculating these finite difference properties should not perturb the # state of the object (except for checks on edge cases) - self.assertNear(h1a, h1b, 1e-9) - self.assertNear(h2a, h2b, 1e-9) + self.assertQuantityNear(h1a, h1b, 1e-9) + self.assertQuantityNear(h2a, h2b, 1e-9) def test_properties_near_min(self): - self.check_fd_properties(self.water.min_temp*(1+1e-5), 101325, - self.water.min_temp*(1+1e-4), 101325, 1e-2) + self.check_fd_properties(self.water.min_temp*(1+1e-5), 101325 * ct.units.Pa, + self.water.min_temp*(1+1e-4), 101325 * ct.units.Pa, 1e-2) def test_properties_near_max(self): - self.check_fd_properties(self.water.max_temp*(1-1e-5), 101325, - self.water.max_temp*(1-1e-4), 101325, 1e-2) + self.check_fd_properties(self.water.max_temp*(1-1e-5), 101325 * ct.units.Pa, + self.water.max_temp*(1-1e-4), 101325 * ct.units.Pa, 1e-2) def test_properties_near_sat1(self): - for T in [340,390,420]: - self.water.TQ = T, 0.0 + for T in [340 * ct.units.K, 390 * ct.units.K, 420 * ct.units.K]: + self.water.TQ = T, Q_(0.0, "dimensionless") P = self.water.P - self.check_fd_properties(T, P+0.01, T, P+0.5, 1e-4) + self.check_fd_properties(T, P+0.01 * ct.units.Pa, T, P+0.5 * ct.units.Pa, 1e-4) def test_properties_near_sat2(self): - for T in [340,390,420]: - self.water.TQ = T, 0.0 + for T in [340 * ct.units.K, 390 * ct.units.K, 420 * ct.units.K]: + self.water.TQ = T, Q_(0.0, "dimensionless") P = self.water.P - self.check_fd_properties(T, P-0.01, T, P-0.5, 1e-4) + self.check_fd_properties(T, P-0.01 * ct.units.Pa, T, P-0.5 * ct.units.Pa, 1e-4) def test_isothermal_compressibility_lowP(self): # Low-pressure limit corresponds to ideal gas ref = ct.Solution('gri30.xml') - ref.TPX = 450, 12, 'H2O:1.0' - self.water.TP = 450, 12 - self.assertNear(ref.isothermal_compressibility, - self.water.isothermal_compressibility, 1e-5) + ref.TPX = Q_(450, "K"), Q_(12, "Pa"), 'H2O:1.0' + self.water.TP = Q_(450, "K"), Q_(12, "Pa") + self.assertQuantityNear(ref.isothermal_compressibility, + self.water.isothermal_compressibility, 1e-5 * ct.units.dimensionless) def test_thermal_expansion_coeff_lowP(self): # Low-pressure limit corresponds to ideal gas ref = ct.Solution('gri30.xml') - ref.TPX = 450, 12, 'H2O:1.0' - self.water.TP = 450, 12 - self.assertNear(ref.thermal_expansion_coeff, - self.water.thermal_expansion_coeff, 1e-5) + ref.TPX = Q_(450, "K"), Q_(12, "Pa"), 'H2O:1.0' + self.water.TP = Q_(450, "K"), Q_(12, "Pa") + self.assertQuantityNear(ref.thermal_expansion_coeff, + self.water.thermal_expansion_coeff, 1e-5 * ct.units.dimensionless) def test_thermal_expansion_coeff_TD(self): - for T in [440, 550, 660]: - self.water.TD = T, 0.1 - self.assertNear(T * self.water.thermal_expansion_coeff, 1.0, 1e-2) + for T in [440 * ct.units.K, 550 * ct.units.K, 660 * ct.units.K]: + self.water.TD = T, Q_(0.1, "kg/m**3") + self.assertQuantityNear(T * self.water.thermal_expansion_coeff, 1.0, 1e-2) def test_pq_setter_triple_check(self): - self.water.PQ = 101325, .2 + self.water.PQ = Q_(101325, "Pa"), Q_(.2, "dimensionless") T = self.water.T # change T such that it would result in a Psat larger than P - self.water.TP = 400, 101325 + self.water.TP = Q_(400, "K"), Q_(101325, "Pa") # ensure that correct triple point pressure is recalculated # (necessary as this value is not stored by the C++ base class) - self.water.PQ = 101325, .2 - self.assertNear(T, self.water.T, 1e-9) - with self.assertRaisesRegex(ct.CanteraError, 'below triple point'): + self.water.PQ = Q_(101325, "Pa"), Q_(.2, "dimensionless") + self.assertQuantityNear(T, self.water.T, 1e-9) + with self.assertRaisesRegex(cn.CanteraError, 'below triple point'): # min_temp is triple point temperature - self.water.TP = self.water.min_temp, 101325 + self.water.TP = self.water.min_temp, Q_(101325, "Pa") P = self.water.P_sat # triple-point pressure - self.water.PQ = .999*P, .2 + self.water.PQ = .999*P, Q_(.2, "dimensionless") def test_quality_exceptions(self): # Critical point - self.water.TP = 300, ct.one_atm - self.water.TQ = self.water.critical_temperature, .5 - self.assertNear(self.water.P, self.water.critical_pressure) - self.water.TP = 300, ct.one_atm - self.water.PQ = self.water.critical_pressure, .5 - self.assertNear(self.water.T, self.water.critical_temperature) + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") + self.water.TQ = self.water.critical_temperature, Q_(.5, "dimensionless") + self.assertQuantityNear(self.water.P, self.water.critical_pressure) + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") + self.water.PQ = self.water.critical_pressure, Q_(.5, "dimensionless") + self.assertQuantityNear(self.water.T, self.water.critical_temperature) # Supercritical - with self.assertRaisesRegex(ct.CanteraError, 'supercritical'): - self.water.TQ = 1.001 * self.water.critical_temperature, 0. - with self.assertRaisesRegex(ct.CanteraError, 'supercritical'): - self.water.PQ = 1.001 * self.water.critical_pressure, 0. + with self.assertRaisesRegex(cn.CanteraError, 'supercritical'): + self.water.TQ = 1.001 * self.water.critical_temperature, Q_(0, "dimensionless") + with self.assertRaisesRegex(cn.CanteraError, 'supercritical'): + self.water.PQ = 1.001 * self.water.critical_pressure, Q_(0, "dimensionless") # Q negative - with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): - self.water.TQ = 373.15, -.001 - with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): - self.water.PQ = ct.one_atm, -.001 + with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): + self.water.TQ = Q_(373.15, "K"), Q_(-.001, "dimensionless") + with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): + self.water.PQ = Q_(cn.one_atm, "Pa"), Q_(-.001, "dimensionless") # Q larger than one - with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): - self.water.TQ = 373.15, 1.001 - with self.assertRaisesRegex(ct.CanteraError, 'Invalid vapor fraction'): - self.water.PQ = ct.one_atm, 1.001 + with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): + self.water.TQ = Q_(373.15, "K"), Q_(1.001, "dimensionless") + with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): + self.water.PQ = Q_(cn.one_atm, "Pa"), Q_(1.001, "dimensionless") def test_saturated_mixture(self): - self.water.TP = 300, ct.one_atm - with self.assertRaisesRegex(ct.CanteraError, 'Saturated mixture detected'): - self.water.TP = 300, self.water.P_sat + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") + with self.assertRaisesRegex(cn.CanteraError, 'Saturated mixture detected'): + self.water.TP = Q_(300, "K"), self.water.P_sat w = ct.Water() # Saturated vapor - self.water.TQ = 373.15, 1. + self.water.TQ = Q_(373.15, "K"), Q_(1, "dimensionless") self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') w.TP = self.water.T, .999 * self.water.P_sat - self.assertNear(self.water.cp, w.cp, 1.e-3) - self.assertNear(self.water.cv, w.cv, 1.e-3) - self.assertNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-3) - self.assertNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-3) + self.assertQuantityNear(self.water.cp, w.cp, 1.e-1) + self.assertQuantityNear(self.water.cv, w.cv, 1.e-1) + self.assertQuantityNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-1) + self.assertQuantityNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-1) # Saturated mixture - self.water.TQ = 373.15, .5 + self.water.TQ = Q_(373.15, "K"), Q_(.5, "dimensionless") self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') - self.assertEqual(self.water.cp, np.inf) + self.assertEqual(self.water.cp, np.inf * ct.units.J / ct.units.kg / ct.units.K) self.assertTrue(np.isnan(self.water.cv)) - self.assertEqual(self.water.isothermal_compressibility, np.inf) - self.assertEqual(self.water.thermal_expansion_coeff, np.inf) + self.assertEqual(self.water.isothermal_compressibility, np.inf * 1 / ct.units.Pa) + self.assertEqual(self.water.thermal_expansion_coeff, np.inf * 1 / ct.units.K) # Saturated liquid - self.water.TQ = 373.15, 0. + self.water.TQ = Q_(373.15, "K"), Q_(0, "dimensionless") self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') w.TP = self.water.T, 1.001 * self.water.P_sat - self.assertNear(self.water.cp, w.cp, 1.e-3) - self.assertNear(self.water.cv, w.cv, 1.e-3) - self.assertNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-3) - self.assertNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-3) + self.assertQuantityNear(self.water.cp, w.cp, 9.e-1) + self.assertQuantityNear(self.water.cv, w.cv, 9.e-1) + self.assertQuantityNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-1) + self.assertQuantityNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-1) def test_saturation_near_limits(self): # Low temperature limit (triple point) - self.water.TP = 300, ct.one_atm + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") self.water.P_sat # ensure that solver buffers sufficiently different values - self.water.TP = self.water.min_temp, ct.one_atm + self.water.TP = self.water.min_temp, Q_(cn.one_atm, "Pa") psat = self.water.P_sat - self.water.TP = 300, ct.one_atm + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") self.water.P_sat # ensure that solver buffers sufficiently different values - self.water.TP = 300, psat - self.assertNear(self.water.T_sat, self.water.min_temp) + self.water.TP = Q_(300, "K"), psat + self.assertQuantityNear(self.water.T_sat, self.water.min_temp) # High temperature limit (critical point) - saturation temperature - self.water.TP = 300, ct.one_atm + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") self.water.P_sat # ensure that solver buffers sufficiently different values self.water.TP = self.water.critical_temperature, self.water.critical_pressure - self.assertNear(self.water.T_sat, self.water.critical_temperature) + self.assertQuantityNear(self.water.T_sat, self.water.critical_temperature) # High temperature limit (critical point) - saturation pressure - self.water.TP = 300, ct.one_atm + self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") self.water.P_sat # ensure that solver buffers sufficiently different values self.water.TP = self.water.critical_temperature, self.water.critical_pressure - self.assertNear(self.water.P_sat, self.water.critical_pressure) + self.assertQuantityNear(self.water.P_sat, self.water.critical_pressure) # Supercricital - with self.assertRaisesRegex(ct.CanteraError, 'Illegal temperature value'): + with self.assertRaisesRegex(cn.CanteraError, 'Illegal temperature value'): self.water.TP = 1.001 * self.water.critical_temperature, self.water.critical_pressure self.water.P_sat - with self.assertRaisesRegex(ct.CanteraError, 'Illegal pressure value'): + with self.assertRaisesRegex(cn.CanteraError, 'Illegal pressure value'): self.water.TP = self.water.critical_temperature, 1.001 * self.water.critical_pressure self.water.T_sat # Below triple point - with self.assertRaisesRegex(ct.CanteraError, 'Illegal temperature'): - self.water.TP = .999 * self.water.min_temp, ct.one_atm + with self.assertRaisesRegex(cn.CanteraError, 'Illegal temperature'): + self.water.TP = .999 * self.water.min_temp, Q_(cn.one_atm, "Pa") self.water.P_sat # @TODO: test disabled pending fix of GitHub issue #605 # with self.assertRaisesRegex(ct.CanteraError, 'Illegal pressure value'): @@ -685,86 +688,48 @@ def test_saturation_near_limits(self): # self.water.T_sat def test_TPQ(self): - self.water.TQ = 400, 0.8 + self.water.TQ = Q_(400, "K"), Q_(0.8, "dimensionless") T, P, Q = self.water.TPQ - self.assertNear(T, 400) - self.assertNear(Q, 0.8) + self.assertQuantityNear(T, 400 * ct.units.K) + self.assertQuantityNear(Q, 0.8 * ct.units.dimensionless) # a supercritical state - self.water.TPQ = 800, 3e7, 1 - self.assertNear(self.water.T, 800) - self.assertNear(self.water.P, 3e7) + self.water.TPQ = Q_(800, "K"), Q_(3e7, "Pa"), Q_(1, "dimensionless") + self.assertQuantityNear(self.water.T, 800 * ct.units.K) + self.assertQuantityNear(self.water.P, 3e7 * ct.units.Pa) self.water.TPQ = T, P, Q - self.assertNear(self.water.Q, 0.8) - with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): + self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) + with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): self.water.TPQ = T, .999*P, Q - with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): + with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): self.water.TPQ = T, 1.001*P, Q with self.assertRaises(TypeError): self.water.TPQ = T, P, 'spam' - self.water.TPQ = 500, 1e5, 1 # superheated steam - self.assertNear(self.water.P, 1e5) - with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): - self.water.TPQ = 500, 1e5, 0 # vapor fraction should be 1 (T < Tc) - with self.assertRaisesRegex(ct.CanteraError, 'inconsistent'): - self.water.TPQ = 700, 1e5, 0 # vapor fraction should be 1 (T > Tc) + self.water.TPQ = Q_(500, "K"), Q_(1e5, "Pa"), Q_(1, "dimensionless") # superheated steam + self.assertQuantityNear(self.water.P, 1e5 * ct.units.Pa) + with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): + self.water.TPQ = Q_(500, "K"), Q_(1e5, "Pa"), Q_(0, "dimensionless") # vapor fraction should be 1 (T < Tc) + with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): + self.water.TPQ = Q_(700, "K"), Q_(1e5, "Pa"), Q_(0, "dimensionless") # vapor fraction should be 1 (T > Tc) def test_phase_of_matter(self): - self.water.TP = 300, 101325 + self.water.TP = Q_(300, "K"), Q_(101325, "Pa") self.assertEqual(self.water.phase_of_matter, "liquid") - self.water.TP = 500, 101325 + self.water.TP = Q_(500, "K"), Q_(101325, "Pa") self.assertEqual(self.water.phase_of_matter, "gas") - self.water.TP = self.water.critical_temperature*2, 101325 + self.water.TP = self.water.critical_temperature*2, Q_(101325, "Pa") self.assertEqual(self.water.phase_of_matter, "supercritical") - self.water.TP = 300, self.water.critical_pressure*2 + self.water.TP = Q_(300, "K"), self.water.critical_pressure*2 self.assertEqual(self.water.phase_of_matter, "supercritical") - self.water.TQ = 300, 0.4 + self.water.TQ = Q_(300, "K"), Q_(0.4, "dimensionless") self.assertEqual(self.water.phase_of_matter, "liquid-gas-mix") # These cases work after fixing GH-786 n2 = ct.Nitrogen() - n2.TP = 100, 1000 + n2.TP = Q_(100, "K"), Q_(1000, "Pa") self.assertEqual(n2.phase_of_matter, "gas") co2 = ct.CarbonDioxide() self.assertEqual(co2.phase_of_matter, "gas") - - def test_water_backends(self): - w = ct.Water(backend='Reynolds') - self.assertEqual(w.thermo_model, 'PureFluid') - w = ct.Water(backend='IAPWS95') - self.assertEqual(w.thermo_model, 'liquid-water-IAPWS95') - with self.assertRaisesRegex(KeyError, 'Unknown backend'): - ct.Water('foobar') - - def test_water_iapws(self): - w = ct.Water(backend='IAPWS95') - self.assertNear(w.critical_density, 322.) - self.assertNear(w.critical_temperature, 647.096) - self.assertNear(w.critical_pressure, 22064000.0) - - # test internal TP setters (setters update temperature at constant - # density before updating pressure) - w.TP = 300, ct.one_atm - dens = w.density - w.TP = 2000, ct.one_atm # supercritical - self.assertEqual(w.phase_of_matter, "supercritical") - w.TP = 300, ct.one_atm # state goes from supercritical -> gas -> liquid - self.assertNear(w.density, dens) - self.assertEqual(w.phase_of_matter, "liquid") - - # test setters for critical conditions - w.TP = w.critical_temperature, w.critical_pressure - self.assertNear(w.density, 322.) - w.TP = 2000, ct.one_atm # uses current density as initial guess - w.TP = 273.16, ct.one_atm # uses fixed density as initial guess - self.assertNear(w.density, 999.84376) - self.assertEqual(w.phase_of_matter, "liquid") - w.TP = w.T, w.P_sat - self.assertEqual(w.phase_of_matter, "liquid") - with self.assertRaisesRegex(ct.CanteraError, "assumes liquid phase"): - w.TP = 273.1599999, ct.one_atm - with self.assertRaisesRegex(ct.CanteraError, "assumes liquid phase"): - w.TP = 500, ct.one_atm \ No newline at end of file diff --git a/interfaces/cython/cantera/units/solution.py.in b/interfaces/cython/cantera/units/solution.py.in index 296a163d74..34db9e24de 100644 --- a/interfaces/cython/cantera/units/solution.py.in +++ b/interfaces/cython/cantera/units/solution.py.in @@ -65,6 +65,22 @@ class PureFluid(Solution): def __init__(self, infile, phasename=""): self._phase = _PureFluid(infile, phasename) + @property + def Q(self): + Q = self._phase.Q + return Q_(Q, "dimensionless") + + @Q.setter + def Q(self, value): + if value is not None: + try: + Q = value.to("dimensionless").magnitude + except AttributeError: + Q = value + else: + Q = self.Q.magnitude + self._phase.Q = Q + @purefluid_properties@ diff --git a/samples/python/thermo/isentropic_units.py b/samples/python/thermo/isentropic_units.py index 724773356f..20c5d71d80 100644 --- a/samples/python/thermo/isentropic_units.py +++ b/samples/python/thermo/isentropic_units.py @@ -1,11 +1,10 @@ """ Isentropic, adiabatic flow example - calculate area ratio vs. Mach number curve -Requires: cantera >= 2.5.0, matplotlib >= 2.0 +Requires: cantera >= 2.6.0 """ import cantera.units as ct -import math import numpy as np ct.units.default_format = ".2F~P" label_string = "area ratio\tMach number\ttemperature\tpressure ratio" diff --git a/samples/python/thermo/rankine_units.py b/samples/python/thermo/rankine_units.py index eaabb02089..a4e0e33489 100644 --- a/samples/python/thermo/rankine_units.py +++ b/samples/python/thermo/rankine_units.py @@ -1,7 +1,7 @@ """ A Rankine vapor power cycle -Requires: Cantera >= 2.5.0 +Requires: Cantera >= 2.6.0 """ import cantera.units as ct diff --git a/samples/python/thermo/sound_speed_units.py b/samples/python/thermo/sound_speed_units.py index 9c58a324b8..fe7c4f2a27 100644 --- a/samples/python/thermo/sound_speed_units.py +++ b/samples/python/thermo/sound_speed_units.py @@ -1,12 +1,11 @@ """ Compute the "equilibrium" and "frozen" sound speeds for a gas -Requires: cantera >= 2.5.0 +Requires: cantera >= 2.6.0 """ import cantera.units as ct import numpy as np -import math ct.units.default_format = ".2F~P" From e7a59fa2baefeef9bd8c1014a9f2ebd4f6692414 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 30 Apr 2021 09:29:23 -0400 Subject: [PATCH 05/41] Refactor PureFluid derived classes as functions Since the only overridden method was __init__, it makes more sense for these to be functions than subclasses. --- .../cython/cantera/units/solution.py.in | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/interfaces/cython/cantera/units/solution.py.in b/interfaces/cython/cantera/units/solution.py.in index 34db9e24de..fec2e6aacf 100644 --- a/interfaces/cython/cantera/units/solution.py.in +++ b/interfaces/cython/cantera/units/solution.py.in @@ -84,41 +84,33 @@ class PureFluid(Solution): @purefluid_properties@ -class Heptane(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "heptane") +def Heptane(): + return PureFluid("liquidvapor.yaml", "heptane") -class CarbonDioxide(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "carbon-dioxide") +def CarbonDioxide(): + return PureFluid("liquidvapor.yaml", "carbon-dioxide") -class Hfc134a(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "hfc134a") +def Hfc134a(): + return PureFluid("liquidvapor.yaml", "hfc134a") -class Hydrogen(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "hydrogen") +def Hydrogen(): + return PureFluid("liquidvapor.yaml", "hydrogen") -class Methane(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "methane") +def Methane(): + return PureFluid("liquidvapor.yaml", "methane") -class Nitrogen(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "nitrogen") +def Nitrogen(): + return PureFluid("liquidvapor.yaml", "nitrogen") -class Oxygen(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "oxygen") +def Oxygen(): + return PureFluid("liquidvapor.yaml", "oxygen") -class Water(PureFluid): - def __init__(self): - super().__init__("liquidvapor.yaml", "water") +def Water(): + return PureFluid("liquidvapor.yaml", "water") From b1fbaf04b70f1c1c17893123173b10f6bdb0a518 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 30 Apr 2021 09:57:03 -0400 Subject: [PATCH 06/41] Refactor setter and getter templates for units * To allow setting properties on the upstream classes, __setattr__ is implemented and checks whether the attribute is defined on this class. This requires setting the phase instance directly into the __dict__ attribute to avoid a recursion error. * Class inheritance is not used here because it is not easy to set attributes on the super class. By design, super() does not allow setting properties, only getting. Furthermore, since the base PureFluid class is defined in Cython, the attributes of that class cannot be set by subclasses at all, again by design. * The PureFluid class implemented here does not inherit from the Solution class implemented here because eventually this Solution class will include methods and properties related to kinetics and transport, which are not implemented for PureFluid. The base PureFluid class is a subclass of ThermoPhase only, whereas the base Solution class is a subclass of ThermoPhase, Kinetics, and Transport. * To reflect the distinction between Solution and ThermoPhase, the template variables are renamed. * Several methods of the PureFluid are getters only, despite having three properties. These attributes are fixed here. TPQ is the only three-property attribute with a setter. * Setter methods now raise a CanteraError if the unit conversion to base units fails due to an AttributeError. --- interfaces/cython/SConscript | 182 +++++++++++++----- .../cython/cantera/units/solution.py.in | 90 ++++----- 2 files changed, 175 insertions(+), 97 deletions(-) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index c8fcb62ae2..7e757c1eec 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -135,30 +135,30 @@ UNITS = { "reference_pressure": '"Pa"', "thermal_expansion_coeff": '"1/K"' } +getter_properties = [ + "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", + "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", + "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "cv_mass", "cv_mole", "P", + "P_sat", "T", "T_sat", "atomic_weight", "chemical_potentials", "concentrations", + "critical_pressure", "critical_temperature", "electric_potential", + "electrochemical_potentials", "isothermal_compressibility", "max_temp", + "mean_molecular_weight", "min_temp", "molecular_weights", "partial_molar_cp", + "partial_molar_enthalpies", "partial_molar_entropies", "partial_molar_int_energies", + "partial_molar_volumes", "reference_pressure", "thermal_expansion_coeff", "cp", + "cv", "density", "h", "s", "g", "u", "v" +] + getter_template = Template(""" @property def ${name}(self): return Q_(self._phase.${name}, ${units}) """) -getter_properties = [ - "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", - "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", - "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "cv_mass", "cv_mole", "P", "P_sat", "T", - "T_sat", "atomic_weight", "chemical_potentials", "concentrations", "critical_pressure", - "critical_temperature", "electric_potential", "electrochemical_potentials", - "isothermal_compressibility", "max_temp", "mean_molecular_weight", "min_temp", - "molecular_weights", "partial_molar_cp", "partial_molar_enthalpies", - "partial_molar_entropies", "partial_molar_int_energies", "partial_molar_volumes", - "reference_pressure", "thermal_expansion_coeff", "cp", "cv", "density", - "h", "s", "g", "u", "v" -] - -getter_string = "".join( - getter_template.substitute(name=name, units=UNITS[name]) for name in getter_properties -) +thermophase_getters = [] +for name in getter_properties: + thermophase_getters.append(getter_template.substitute(name=name, units=UNITS[name])) -setter_template = Template(""" +setter_2_template = Template(""" @property def ${name}(self): ${n0}, ${n1} = self._phase.${name} @@ -166,22 +166,34 @@ setter_template = Template(""" @${name}.setter def ${name}(self, value): - ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} - ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} + try: + ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} + ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} + except AttributeError as e: + # The 'to' attribute missing means this isn't a pint Quantity + if "'to'" in str(e): + raise CanteraError( + f"Values ({value}) must be instances of pint.Quantity classes" + ) from None + else: + raise self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude """) -setter_properties = ["TP", "DP", "HP", "SP", "SV", "TD", "UV"] -pf_setter_properties = ["PQ", "TQ", "PV", "SH", "ST", "TH", "TV", "UP", "VH"] +tp_setter_2_properties = ["TP", "DP", "HP", "SP", "SV", "TD", "UV"] +pf_setter_2_properties = ["PQ", "TQ", "PV", "SH", "ST", "TH", "TV", "UP", "VH"] -setter_string = "".join( - setter_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) for name in setter_properties -) -pf_setter_string = "".join( - setter_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) for name in pf_setter_properties -) +thermophase_2_setters = [] +for name in tp_setter_2_properties: + d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) + thermophase_2_setters.append(setter_2_template.substitute(d)) -setter1_template = Template(""" +purefluid_2_setters = [] +for name in pf_setter_2_properties: + d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) + purefluid_2_setters.append(setter_2_template.substitute(d)) + +setter_3_template = Template(""" @property def ${name}(self): ${n0}, ${n1}, ${n2} = self._phase.${name} @@ -189,8 +201,17 @@ setter1_template = Template(""" @${name}.setter def ${name}(self, value): - ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} - ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} + try: + ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} + ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} + except AttributeError as e: + # The 'to' attribute missing means this isn't a pint Quantity + if "'to'" in str(e): + raise CanteraError( + f"Values ({value}) must be instances of pint.Quantity classes" + ) from None + else: + raise if value[2] is not None: try: ${n2} = value[2].to(${u2}).magnitude @@ -201,24 +222,97 @@ setter1_template = Template(""" self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude, ${n2} """) -setter1_properties = [ - "TPX", "TPY", "DPX", "DPY", "HPX", "HPY", "SPX", "SPY", "SVX", "SVY", - "TDX", "TDY", "UVX", "UVY" +tp_setter_3_properties = [ + "TPX", "TPY", "DPX", "DPY", "HPX", "HPY", "SPX", "SPY", "SVX", "SVY", "TDX", "TDY", + "UVX", "UVY" ] -pf_setter1_properties = ["TPQ", "DPQ", "HPQ", "SPQ", "SVQ", "TDQ", "UVQ"] -setter1_string = "".join( - setter1_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], n2=name[2], u2=UNITS[name[2]]) for name in setter1_properties -) -pf_setter1_string = "".join( - setter1_template.substitute(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], n2=name[2], u2=UNITS[name[2]]) for name in pf_setter1_properties -) +thermophase_3_setters = [] +for name in tp_setter_3_properties: + d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], + n2=name[2], u2=UNITS[name[2]]) + thermophase_3_setters.append(setter_3_template.substitute(d)) + +getter_3_template = Template(""" + @property + def ${name}(self): + ${n0}, ${n1}, ${n2} = self._phase.${name} + return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) +""") + +pf_getter_3_properties = ["DPQ", "HPQ", "SPQ", "SVQ", "TDQ", "UVQ"] + +purefluid_3_getters = [] +for name in pf_getter_3_properties: + d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], + n2=name[2], u2=UNITS[name[2]]) + purefluid_3_getters.append(getter_3_template.substitute(d)) + -solution_properties = "".join([getter_string, setter_string, setter1_string]) -pf_properties = "".join([pf_setter_string, pf_setter1_string]) +def recursive_join(*args, joiner=""): + result = "" + for arg in args: + result = result + joiner.join(arg) + return result + + +thermophase_properties = recursive_join(thermophase_getters, thermophase_2_setters, + thermophase_3_setters) +purefluid_properties = recursive_join(purefluid_2_setters, purefluid_3_getters) + +common_properties = """ + def __getattr__(self, name): + return getattr(self._phase, name) + + def __setattr__(self, name, value): + if name in dir(self): + object.__setattr__(self, name, value) + else: + setattr(self._phase, name, value) + + @property + def basis_units(self): + if self._phase.basis == "mass": + return "kg" + else: + return "kmol" + + @property + def X(self): + X = self._phase.X + return Q_(X, "dimensionless") + + @X.setter + def X(self, value): + if value is not None: + try: + X = value.to("dimensionless").magnitude + except AttributeError: + X = value + else: + X = self.X.magnitude + self._phase.X = X + + @property + def Y(self): + Y = self._phase.Y + return Q_(Y, "dimensionless") + + @Y.setter + def Y(self, value): + if value is not None: + try: + Y = value.to("dimensionless").magnitude + except AttributeError: + Y = value + else: + Y = self.Y.magnitude + self._phase.Y = Y +""" -localenv["solution_properties"] = solution_properties.strip() -localenv["purefluid_properties"] = pf_properties.strip() +localenv["common_properties"] = common_properties.strip() +localenv["thermophase_properties"] = thermophase_properties.strip() +localenv["purefluid_properties"] = purefluid_properties.strip() units = localenv.SubstFile("cantera/units/solution.py", "cantera/units/solution.py.in") localenv.Depends(mod, units) diff --git a/interfaces/cython/cantera/units/solution.py.in b/interfaces/cython/cantera/units/solution.py.in index fec2e6aacf..bf25963a54 100644 --- a/interfaces/cython/cantera/units/solution.py.in +++ b/interfaces/cython/cantera/units/solution.py.in @@ -1,69 +1,29 @@ -from .. import Solution as _Solution, PureFluid as _PureFluid +from .. import Solution as _Solution, PureFluid as _PureFluid, CanteraError from pint import UnitRegistry units = UnitRegistry() Q_ = units.Quantity -__all__ = ( - "units", "Q_", "Solution", "PureFluid", "Heptane", "CarbonDioxide", - "Hfc134a", "Hydrogen", "Methane", "Nitrogen", "Oxygen", "Water") +__all__ = ("units", "Q_", "Solution", "PureFluid", "Heptane", "CarbonDioxide", + "Hfc134a", "Hydrogen", "Methane", "Nitrogen", "Oxygen", "Water", + "CanteraError") class Solution: - def __init__(self, infile, phasename=""): - self._phase = _Solution(infile, phasename) + def __init__(self, infile, name=""): + self.__dict__["_phase"] = _Solution(infile, name) - @property - def basis_units(self): - if self._phase.basis == "mass": - return "kg" - else: - return "kmol" + @common_properties@ - def __getattr__(self, name): - return getattr(self._phase, name) + @thermophase_properties@ - def __setattr__(self, name, value): - return super(Solution, self).__setattr__(name, value) - @property - def X(self): - X = self._phase.X - return Q_(X, "dimensionless") - @X.setter - def X(self, value): - if value is not None: - try: - X = value.to("dimensionless").magnitude - except AttributeError: - X = value - else: - X = self.X.magnitude - self._phase.X = X +class PureFluid: + def __init__(self, infile, name=""): + self.__dict__["_phase"] = _PureFluid(infile, name) - @property - def Y(self): - Y = self._phase.Y - return Q_(Y, "dimensionless") - - @Y.setter - def Y(self, value): - if value is not None: - try: - Y = value.to("dimensionless").magnitude - except AttributeError: - Y = value - else: - Y = self.Y.magnitude - self._phase.Y = Y - - @solution_properties@ - - -class PureFluid(Solution): - def __init__(self, infile, phasename=""): - self._phase = _PureFluid(infile, phasename) + @common_properties@ @property def Q(self): @@ -76,11 +36,35 @@ class PureFluid(Solution): try: Q = value.to("dimensionless").magnitude except AttributeError: - Q = value + raise CanteraError( + "Values must be instances of pint.Quantity classes" + ) from None else: Q = self.Q.magnitude self._phase.Q = Q + @thermophase_properties@ + + @property + def TPQ(self): + T, P, Q = self._phase.TPQ + return Q_(T, "K"), Q_(P, "Pa"), Q_(Q, "dimensionless") + + @TPQ.setter + def TPQ(self, value): + T = value[0] if value[0] is not None else self.T + P = value[1] if value[1] is not None else self.P + Q = value[2] if value[2] is not None else self.Q + try: + T = T.to("K") + P = P.to("Pa") + Q = Q.to("dimensionless") + except AttributeError: + raise CanteraError( + "Values must be instances of pint.Quantity classes" + ) from None + self._phase.TPQ = T.magnitude, P.magnitude, Q.magnitude + @purefluid_properties@ From 8bf0fbb77c12dd41c0bbd598733c0172da6e7fad Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 30 Apr 2021 10:39:41 -0400 Subject: [PATCH 07/41] Use numpy.isclose to compare array Quantities The existing assertArrayNear method was causing units to be stripped from the array Quantities. Avoiding numpy.asarray conversions and using numpy.isclose prevents that from happening. --- interfaces/cython/cantera/test/test_units.py | 90 ++++++++++++-------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/interfaces/cython/cantera/test/test_units.py b/interfaces/cython/cantera/test/test_units.py index c079b473a3..cce89f7d8f 100644 --- a/interfaces/cython/cantera/test/test_units.py +++ b/interfaces/cython/cantera/test/test_units.py @@ -16,6 +16,23 @@ def assertQuantityNear(self, a, b, atol=1.0E-12, rtol=1.0E-8): f'rtol = {rtol:10e}; atol = {atol:10e}') self.fail(message) + def assertArrayQuantityNear(self, A, B, rtol=1e-8, atol=1e-12, msg=None): + if A.shape != B.shape: + self.fail(f"Arrays are of different lengths ({A.shape}, {B.shape})") + isclose = np.isclose(A, B, atol=atol, rtol=rtol) + if not isclose.all(): + bad_A = A[~isclose] + bad_B = B[~isclose] + message = ( + f"AssertNear: {bad_A:.14g} - {bad_B:.14g} = {bad_A - bad_B:.14g}\n" + + f"Error for {(~isclose).sum()} element(s) exceeds rtol = {rtol:10e}," + + f"atol = {atol:10e}." + ) + if msg is not None: + message = msg + "\n" + message + self.fail(message) + + class TestSolutionUnits(CanteraUnitsTest): def setUp(self): self.phase = ct.Solution("h2o2.yaml") @@ -203,7 +220,6 @@ def test_dimensions(self): self.assertEqual(dims_cv_mole["[substance]"], -1.0) self.assertEqual(dims_cv_mole["[temperature]"], -1.0) - def check_setters(self, T1, rho1, Y1): T0, rho0, Y0 = self.phase.TDY self.phase.TDY = T1, rho1, Y1 @@ -217,7 +233,7 @@ def check_setters(self, T1, rho1, Y1): def check_state(T, rho, Y): self.assertQuantityNear(self.phase.T, T) self.assertQuantityNear(self.phase.density, rho) - self.assertArrayNear(self.phase.Y, Y) + self.assertArrayQuantityNear(self.phase.Y, Y) self.phase.TDY = T0, rho0, Y0 self.phase.TPY = T1, P1, Y1 @@ -311,101 +327,101 @@ def test_setters_hold_constant(self): self.assertQuantityNear(getattr(self.phase, second), second_val) def check_getters(self): - T,D,X = self.phase.TDX + T, D, X = self.phase.TDX self.assertQuantityNear(T, self.phase.T) self.assertQuantityNear(D, self.phase.density) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - T,D,Y = self.phase.TDY + T, D, Y = self.phase.TDY self.assertQuantityNear(T, self.phase.T) self.assertQuantityNear(D, self.phase.density) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - T,D = self.phase.TD + T, D = self.phase.TD self.assertQuantityNear(T, self.phase.T) self.assertQuantityNear(D, self.phase.density) - T,P,X = self.phase.TPX + T, P, X = self.phase.TPX self.assertQuantityNear(T, self.phase.T) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - T,P,Y = self.phase.TPY + T, P, Y = self.phase.TPY self.assertQuantityNear(T, self.phase.T) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - T,P = self.phase.TP + T, P = self.phase.TP self.assertQuantityNear(T, self.phase.T) self.assertQuantityNear(P, self.phase.P) - H,P,X = self.phase.HPX + H, P, X = self.phase.HPX self.assertQuantityNear(H, self.phase.h) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - H,P,Y = self.phase.HPY + H, P, Y = self.phase.HPY self.assertQuantityNear(H, self.phase.h) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - H,P = self.phase.HP + H, P = self.phase.HP self.assertQuantityNear(H, self.phase.h) self.assertQuantityNear(P, self.phase.P) - U,V,X = self.phase.UVX + U, V, X = self.phase.UVX self.assertQuantityNear(U, self.phase.u) self.assertQuantityNear(V, self.phase.v) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - U,V,Y = self.phase.UVY + U, V, Y = self.phase.UVY self.assertQuantityNear(U, self.phase.u) self.assertQuantityNear(V, self.phase.v) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - U,V = self.phase.UV + U, V = self.phase.UV self.assertQuantityNear(U, self.phase.u) self.assertQuantityNear(V, self.phase.v) - S,P,X = self.phase.SPX + S, P, X = self.phase.SPX self.assertQuantityNear(S, self.phase.s) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - S,P,Y = self.phase.SPY + S, P, Y = self.phase.SPY self.assertQuantityNear(S, self.phase.s) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - S,P = self.phase.SP + S, P = self.phase.SP self.assertQuantityNear(S, self.phase.s) self.assertQuantityNear(P, self.phase.P) - S,V,X = self.phase.SVX + S, V, X = self.phase.SVX self.assertQuantityNear(S, self.phase.s) self.assertQuantityNear(V, self.phase.v) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - S,V,Y = self.phase.SVY + S, V, Y = self.phase.SVY self.assertQuantityNear(S, self.phase.s) self.assertQuantityNear(V, self.phase.v) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - S,V = self.phase.SV + S, V = self.phase.SV self.assertQuantityNear(S, self.phase.s) self.assertQuantityNear(V, self.phase.v) - D,P,X = self.phase.DPX + D, P, X = self.phase.DPX self.assertQuantityNear(D, self.phase.density) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(X, self.phase.X) + self.assertArrayQuantityNear(X, self.phase.X) - D,P,Y = self.phase.DPY + D, P, Y = self.phase.DPY self.assertQuantityNear(D, self.phase.density) self.assertQuantityNear(P, self.phase.P) - self.assertArrayNear(Y, self.phase.Y) + self.assertArrayQuantityNear(Y, self.phase.Y) - D,P = self.phase.DP + D, P = self.phase.DP self.assertQuantityNear(D, self.phase.density) self.assertQuantityNear(P, self.phase.P) From 94c5908e8d8861c1301079e32c7989fa4989cdb5 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 30 Apr 2021 11:11:20 -0400 Subject: [PATCH 08/41] Add critical_density units --- interfaces/cython/SConscript | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index 7e757c1eec..59c89c8c26 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -126,9 +126,10 @@ UNITS = { "U": '"J/" + self.basis_units', "P_sat": '"Pa"', "T_sat": '"K"', "atomic_weight": '"kg/kmol"', "chemical_potentials": '"J/kmol"', "concentrations": '"kmol/m**3"', "critical_pressure": '"Pa"', - "critical_temperature": '"K"', "electric_potential": '"V"', - "electrochemical_potentials": '"J/kmol"', "isothermal_compressibility": '"1/Pa"', - "max_temp": '"K"', "mean_molecular_weight": '"kg/kmol"', "min_temp": '"K"', + "critical_temperature": '"K"', "critical_density": 'self.basis_units + "/m**3"', + "electric_potential": '"V"', "electrochemical_potentials": '"J/kmol"', + "isothermal_compressibility": '"1/Pa"', "max_temp": '"K"', + "mean_molecular_weight": '"kg/kmol"', "min_temp": '"K"', "molecular_weights": '"kg/kmol"', "partial_molar_cp": '"J/kmol/K"', "partial_molar_enthalpies": '"J/kmol"', "partial_molar_entropies": '"J/kmol/K"', "partial_molar_int_energies": '"J/kmol"', "partial_molar_volumes": '"m**3/kmol"', @@ -140,12 +141,12 @@ getter_properties = [ "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "cv_mass", "cv_mole", "P", "P_sat", "T", "T_sat", "atomic_weight", "chemical_potentials", "concentrations", - "critical_pressure", "critical_temperature", "electric_potential", - "electrochemical_potentials", "isothermal_compressibility", "max_temp", - "mean_molecular_weight", "min_temp", "molecular_weights", "partial_molar_cp", - "partial_molar_enthalpies", "partial_molar_entropies", "partial_molar_int_energies", - "partial_molar_volumes", "reference_pressure", "thermal_expansion_coeff", "cp", - "cv", "density", "h", "s", "g", "u", "v" + "critical_pressure", "critical_temperature", "critical_density", + "electric_potential", "electrochemical_potentials", "isothermal_compressibility", + "max_temp", "mean_molecular_weight", "min_temp", "molecular_weights", + "partial_molar_cp", "partial_molar_enthalpies", "partial_molar_entropies", + "partial_molar_int_energies", "partial_molar_volumes", "reference_pressure", + "thermal_expansion_coeff", "cp", "cv", "density", "h", "s", "g", "u", "v", ] getter_template = Template(""" From 4952189a08736a0b25e78187b5e12c82aec083eb Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 30 Apr 2021 11:41:29 -0400 Subject: [PATCH 09/41] Fix up tests for units module * Remove unnecessary tests of the underlying implementation that duplicate functionality in the upstream tests. * Move some dimensionality tests into the PureFluid tests where the saturation and critical attributes exist. * Remove unused imports --- .../test => test/python}/test_units.py | 440 +++++------------- 1 file changed, 106 insertions(+), 334 deletions(-) rename {interfaces/cython/cantera/test => test/python}/test_units.py (50%) diff --git a/interfaces/cython/cantera/test/test_units.py b/test/python/test_units.py similarity index 50% rename from interfaces/cython/cantera/test/test_units.py rename to test/python/test_units.py index cce89f7d8f..3afaa7228a 100644 --- a/interfaces/cython/cantera/test/test_units.py +++ b/test/python/test_units.py @@ -1,19 +1,17 @@ -import itertools import numpy as np -import warnings -from pint import UnitRegistry -units = UnitRegistry() -Q_ = units.Quantity -import cantera.units as ct -import cantera as cn +import cantera.with_units as ct from . import utilities + class CanteraUnitsTest(utilities.CanteraTest): - def assertQuantityNear(self, a, b, atol=1.0E-12, rtol=1.0E-8): + def assertQuantityNear(self, a, b, atol=1.0E-12, rtol=1.0E-8, msg=None): if not np.isclose(a, b, atol=atol, rtol=rtol): - message = (f'AssertNear: {a:.14g} - {b:.14g} = {a-b:.14g}\n' + - f'rtol = {rtol:10e}; atol = {atol:10e}') + message = ( + f"AssertNear: {a:.14g} - {b:.14g} = {a-b:.14g}\n" + + f"rtol = {rtol:10e}; atol = {atol:10e}") + if msg is not None: + message = msg + "\n" + message self.fail(message) def assertArrayQuantityNear(self, A, B, rtol=1e-8, atol=1e-12, msg=None): @@ -38,7 +36,7 @@ def setUp(self): self.phase = ct.Solution("h2o2.yaml") def test_mass_basis(self): - self.assertEqual(self.phase.basis_units, 'kg') + self.assertEqual(self.phase.basis_units, "kg") self.assertQuantityNear(self.phase.density_mass, self.phase.density) self.assertQuantityNear(self.phase.enthalpy_mass, self.phase.h) self.assertQuantityNear(self.phase.entropy_mass, self.phase.s) @@ -49,8 +47,8 @@ def test_mass_basis(self): self.assertQuantityNear(self.phase.cv_mass, self.phase.cv) def test_molar_basis(self): - self.phase._phase.basis = "molar" - self.assertEqual(self.phase.basis_units, 'kmol') + self.phase.basis = "molar" + self.assertEqual(self.phase.basis_units, "kmol") self.assertQuantityNear(self.phase.density_mole, self.phase.density) self.assertQuantityNear(self.phase.enthalpy_mole, self.phase.h) self.assertQuantityNear(self.phase.entropy_mole, self.phase.s) @@ -60,8 +58,8 @@ def test_molar_basis(self): self.assertQuantityNear(self.phase.cp_mole, self.phase.cp) self.assertQuantityNear(self.phase.cv_mole, self.phase.cv) - def test_dimensions(self): - #basis-independent + def test_dimensions_solution(self): + # basis-independent dims_T = self.phase.T.dimensionality self.assertEqual(dims_T["[temperature]"], 1.0) dims_P = self.phase.P.dimensionality @@ -69,16 +67,10 @@ def test_dimensions(self): self.assertEqual(dims_P["[length]"], -1.0) self.assertEqual(dims_P["[time]"], -2.0) dims_X = self.phase.X.dimensionality - #units container for dimensionless is empty + # units container for dimensionless is empty self.assertEqual(len(dims_X), 0) dims_Y = self.phase.Y.dimensionality self.assertEqual(len(dims_Y), 0) - # dims_T_sat = self.phase.T_sat.dimensionality - # self.assertEqual(dims_T_sat["[temperature]"], 1.0) - # dims_P_sat = self.phase.P_sat.dimensionality - # self.assertEqual(dims_P_sat["[mass]"], 1.0) - # self.assertEqual(dims_P_sat["[length]"], -1.0) - # self.assertEqual(dims_P_sat["[time]"], -2.0) dims_atomic_weight = self.phase.atomic_weight.dimensionality self.assertEqual(dims_atomic_weight["[mass]"], 1.0) self.assertEqual(dims_atomic_weight["[substance]"], -1.0) @@ -90,26 +82,20 @@ def test_dimensions(self): dims_concentrations = self.phase.concentrations.dimensionality self.assertEqual(dims_concentrations["[substance]"], 1.0) self.assertEqual(dims_concentrations["[length]"], -3.0) - # dims_critical_temperature = self.phase.critical_temperature.dimensionality - # self.assertEqual(dims_critical_temperature["[temperature]"], 1.0) - # dims_critical_pressure = self.phase.critical_pressure.dimensionality - # self.assertEqual(dims_critical_pressure["[mass]"], 1.0) - # self.assertEqual(dims_critical_pressure["[length]"], -1.0) - # self.assertEqual(dims_critical_pressure["[time]"], -2.0) dims_electric_potential = self.phase.electric_potential.dimensionality self.assertEqual(dims_electric_potential["[mass]"], 1.0) self.assertEqual(dims_electric_potential["[length]"], 2.0) self.assertEqual(dims_electric_potential["[time]"], -3.0) self.assertEqual(dims_electric_potential["[current]"], -1.0) - dims_electrochemical_potentials = self.phase.electrochemical_potentials.dimensionality - self.assertEqual(dims_electrochemical_potentials["[mass]"], 1.0) - self.assertEqual(dims_electrochemical_potentials["[length]"], 2.0) - self.assertEqual(dims_electrochemical_potentials["[time]"], -2.0) - self.assertEqual(dims_electrochemical_potentials["[substance]"], -1.0) - dims_isothermal_compressibility = self.phase.isothermal_compressibility.dimensionality - self.assertEqual(dims_isothermal_compressibility["[mass]"], -1.0) - self.assertEqual(dims_isothermal_compressibility["[length]"], 1.0) - self.assertEqual(dims_isothermal_compressibility["[time]"], 2.0) + dims_echem_potential = self.phase.electrochemical_potentials.dimensionality + self.assertEqual(dims_echem_potential["[mass]"], 1.0) + self.assertEqual(dims_echem_potential["[length]"], 2.0) + self.assertEqual(dims_echem_potential["[time]"], -2.0) + self.assertEqual(dims_echem_potential["[substance]"], -1.0) + dims_isothermal_comp = self.phase.isothermal_compressibility.dimensionality + self.assertEqual(dims_isothermal_comp["[mass]"], -1.0) + self.assertEqual(dims_isothermal_comp["[length]"], 1.0) + self.assertEqual(dims_isothermal_comp["[time]"], 2.0) dims_max_temp = self.phase.max_temp.dimensionality self.assertEqual(dims_max_temp["[temperature]"], 1.0) dims_mean_molecular_weight = self.phase.mean_molecular_weight.dimensionality @@ -126,22 +112,22 @@ def test_dimensions(self): self.assertEqual(dims_partial_molar_cp["[time]"], -2.0) self.assertEqual(dims_partial_molar_cp["[substance]"], -1.0) self.assertEqual(dims_partial_molar_cp["[temperature]"], -1.0) - dims_partial_molar_enthalpies = self.phase.partial_molar_enthalpies.dimensionality - self.assertEqual(dims_partial_molar_enthalpies["[mass]"], 1.0) - self.assertEqual(dims_partial_molar_enthalpies["[length]"], 2.0) - self.assertEqual(dims_partial_molar_enthalpies["[time]"], -2.0) - self.assertEqual(dims_partial_molar_enthalpies["[substance]"], -1.0) + dims_partial_mol_enth = self.phase.partial_molar_enthalpies.dimensionality + self.assertEqual(dims_partial_mol_enth["[mass]"], 1.0) + self.assertEqual(dims_partial_mol_enth["[length]"], 2.0) + self.assertEqual(dims_partial_mol_enth["[time]"], -2.0) + self.assertEqual(dims_partial_mol_enth["[substance]"], -1.0) dims_partial_molar_entropies = self.phase.partial_molar_entropies.dimensionality self.assertEqual(dims_partial_molar_entropies["[mass]"], 1.0) self.assertEqual(dims_partial_molar_entropies["[length]"], 2.0) self.assertEqual(dims_partial_molar_entropies["[time]"], -2.0) self.assertEqual(dims_partial_molar_entropies["[substance]"], -1.0) self.assertEqual(dims_partial_molar_entropies["[temperature]"], -1.0) - dims_partial_molar_int_energies = self.phase.partial_molar_int_energies.dimensionality - self.assertEqual(dims_partial_molar_int_energies["[mass]"], 1.0) - self.assertEqual(dims_partial_molar_int_energies["[length]"], 2.0) - self.assertEqual(dims_partial_molar_int_energies["[time]"], -2.0) - self.assertEqual(dims_partial_molar_int_energies["[substance]"], -1.0) + dims_partial_mol_int_eng = self.phase.partial_molar_int_energies.dimensionality + self.assertEqual(dims_partial_mol_int_eng["[mass]"], 1.0) + self.assertEqual(dims_partial_mol_int_eng["[length]"], 2.0) + self.assertEqual(dims_partial_mol_int_eng["[time]"], -2.0) + self.assertEqual(dims_partial_mol_int_eng["[substance]"], -1.0) dims_partial_molar_volumes = self.phase.partial_molar_volumes.dimensionality self.assertEqual(dims_partial_molar_volumes["[length]"], 3.0) self.assertEqual(dims_partial_molar_volumes["[substance]"], -1.0) @@ -151,7 +137,7 @@ def test_dimensions(self): self.assertEqual(dims_reference_pressure["[time]"], -2.0) dims_thermal_expansion_coeff = self.phase.thermal_expansion_coeff.dimensionality self.assertEqual(dims_thermal_expansion_coeff["[temperature]"], -1.0) - #basis-dependent (mass) + # basis-dependent (mass) dims_density_mass = self.phase.density_mass.dimensionality self.assertEqual(dims_density_mass["[mass]"], 1.0) self.assertEqual(dims_density_mass["[length]"], -3.0) @@ -179,7 +165,7 @@ def test_dimensions(self): self.assertEqual(dims_cv_mass["[length]"], 2.0) self.assertEqual(dims_cv_mass["[time]"], -2.0) self.assertEqual(dims_cv_mass["[temperature]"], -1.0) - #basis-dependent (molar) + # basis-dependent (molar) dims_density_mole = self.phase.density_mole.dimensionality self.assertEqual(dims_density_mole["[substance]"], 1.0) self.assertEqual(dims_density_mole["[length]"], -3.0) @@ -284,26 +270,35 @@ def check_state(T, rho, Y): check_state(T1, rho1, Y1) def test_setState_mass(self): - self.check_setters(T1 = Q_(500.0, "K"), rho1 = Q_(1.5, "kg/m**3"), - Y1 = Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless")) - - # def test_setState_mole(self): - # self.phase.basis = 'molar' - # self.check_setters(T1 = Q_(750.0, "K"), rho1 = Q_(0.02, "kmol/m**3"), - # Y1 = Q_([0.2, 0.1, 0.0, 0.3, 0.1, 0.0, 0.0, 0.2, 0.1, 0.0], "dimensionless")) + Y1 = ct.Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless") + self.check_setters( + T1=ct.Q_(500.0, "K"), + rho1=ct.Q_(1.5, "kg/m**3"), + Y1=Y1, + ) + + def test_setState_mole(self): + self.phase.basis = "molar" + Y1 = ct.Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless") + self.check_setters( + T1=ct.Q_(750.0, "K"), + rho1=ct.Q_(0.02, "kmol/m**3"), + Y1=Y1, + ) def test_setters_hold_constant(self): - props = ('T','P','s','h','u','v','X','Y') - pairs = [('TP', 'T', 'P'), ('SP', 's', 'P'), - ('UV', 'u', 'v')] - - self.phase.TDX = Q_(1000, "K"), Q_(1.5, "kg/m**3"), 'H2O:0.1, O2:0.95, AR:3.0' + props = ("T", "P", "s", "h", "u", "v", "X", "Y") + pairs = [("TP", "T", "P"), ("SP", "s", "P"), + ("UV", "u", "v")] + self.phase.X = "H2O:0.1, O2:0.95, AR:3.0" + self.phase.TD = ct.Q_(1000, "K"), ct.Q_(1.5, "kg/m**3") values = {} for p in props: values[p] = getattr(self.phase, p) + composition = "H2:0.1, O2:1.0, AR:3.0" for pair, first, second in pairs: - self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' + self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition first_val = getattr(self.phase, first) second_val = getattr(self.phase, second) @@ -311,18 +306,18 @@ def test_setters_hold_constant(self): self.assertQuantityNear(getattr(self.phase, first), values[first]) self.assertQuantityNear(getattr(self.phase, second), second_val) - self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' + self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition setattr(self.phase, pair, (None, values[second])) self.assertQuantityNear(getattr(self.phase, first), first_val) self.assertQuantityNear(getattr(self.phase, second), values[second]) - self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' - setattr(self.phase, pair + 'X', (None, None, values['X'])) + self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition + setattr(self.phase, pair + "X", (None, None, values["X"])) self.assertQuantityNear(getattr(self.phase, first), first_val) self.assertQuantityNear(getattr(self.phase, second), second_val) - self.phase.TDX = Q_(500, "K"), Q_(2.5, "kg/m**3"), 'H2:0.1, O2:1.0, AR:3.0' - setattr(self.phase, pair + 'Y', (None, None, values['Y'])) + self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition + setattr(self.phase, pair + "Y", (None, None, values["Y"])) self.assertQuantityNear(getattr(self.phase, first), first_val) self.assertQuantityNear(getattr(self.phase, second), second_val) @@ -426,25 +421,45 @@ def check_getters(self): self.assertQuantityNear(P, self.phase.P) def test_getState_mass(self): - self.phase.TDY = Q_(350.0, "K"), Q_(0.7, "kg/m**3"), 'H2:0.1, H2O2:0.1, AR:0.8' + self.phase.Y = "H2:0.1, H2O2:0.1, AR:0.8" + self.phase.TD = ct.Q_(350.0, "K"), ct.Q_(0.7, "kg/m**3") self.check_getters() def test_getState_mole(self): - self.phase.basis = 'molar' - self.phase.TDX = Q_(350.0, "K"), Q_(0.01, "kg/m**3"), 'H2:0.1, O2:0.3, AR:0.6' + self.phase.basis = "molar" + self.phase.X = "H2:0.1, O2:0.3, AR:0.6" + self.phase.TD = ct.Q_(350.0, "K"), ct.Q_(0.01, "kmol/m**3") self.check_getters() def test_isothermal_compressibility(self): self.assertQuantityNear(self.phase.isothermal_compressibility, 1.0/self.phase.P) + class TestPureFluidUnits(CanteraUnitsTest): def setUp(self): self.water = ct.Water() + def test_dimensions_purefluid(self): + """These properties are not defined on the IdealGas phase class, + so they can"t be tested in the Solution for the h2o2.yaml input file. + """ + dims_T_sat = self.water.T_sat.dimensionality + self.assertEqual(dims_T_sat["[temperature]"], 1.0) + dims_P_sat = self.water.P_sat.dimensionality + self.assertEqual(dims_P_sat["[mass]"], 1.0) + self.assertEqual(dims_P_sat["[length]"], -1.0) + self.assertEqual(dims_P_sat["[time]"], -2.0) + dims_critical_temperature = self.water.critical_temperature.dimensionality + self.assertEqual(dims_critical_temperature["[temperature]"], 1.0) + dims_critical_pressure = self.water.critical_pressure.dimensionality + self.assertEqual(dims_critical_pressure["[mass]"], 1.0) + self.assertEqual(dims_critical_pressure["[length]"], -1.0) + self.assertEqual(dims_critical_pressure["[time]"], -2.0) + def test_critical_properties(self): self.assertQuantityNear(self.water.critical_pressure, 22.089e6 * ct.units.Pa) self.assertQuantityNear(self.water.critical_temperature, 647.286 * ct.units.K) - self.assertQuantityNear(self.water.critical_density, 317.0 * ct.units.dimensionless) + self.assertQuantityNear(self.water.critical_density, ct.Q_(317.0, "kg/m**3")) def test_temperature_limits(self): co2 = ct.CarbonDioxide() @@ -452,300 +467,57 @@ def test_temperature_limits(self): self.assertQuantityNear(co2.max_temp, 1500.0 * ct.units.K) def test_set_state(self): - self.water.PQ = Q_(101325, "Pa"), Q_(0.5, "dimensionless") + self.water.PQ = ct.Q_(101325, "Pa"), ct.Q_(0.5, "dimensionless") self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) self.assertQuantityNear(self.water.Q, 0.5 * ct.units.dimensionless) - self.water.TQ = Q_(500, "K"), Q_(0.8, "dimensionless") + self.water.TQ = ct.Q_(500, "K"), ct.Q_(0.8, "dimensionless") self.assertQuantityNear(self.water.T, 500 * ct.units.K) self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) def test_substance_set(self): - self.water.TV = Q_(400, "K"), Q_(1.45, "m**3/kg") + self.water.TV = ct.Q_(400, "K"), ct.Q_(1.45, "m**3/kg") self.assertQuantityNear(self.water.T, 400 * ct.units.K) self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) - with self.assertRaisesRegex(cn.CanteraError, 'Negative specific volume'): - self.water.TV = Q_(300, "K"), Q_(-1, "m**3/kg") + with self.assertRaisesRegex(ct.CanteraError, "Negative specific volume"): + self.water.TV = ct.Q_(300, "K"), ct.Q_(-1, "m**3/kg") - self.water.PV = Q_(101325, "Pa"), Q_(1.45, "m**3/kg") + self.water.PV = ct.Q_(101325, "Pa"), ct.Q_(1.45, "m**3/kg") self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) - self.water.UP = Q_(-1.45e7, "J/kg"), Q_(101325, "Pa") + self.water.UP = ct.Q_(-1.45e7, "J/kg"), ct.Q_(101325, "Pa") self.assertQuantityNear(self.water.u, -1.45e7 * ct.units.J / ct.units.kg) self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) - self.water.VH = Q_(1.45, "m**3/kg"), Q_(-1.45e7, "J/kg") + self.water.VH = ct.Q_(1.45, "m**3/kg"), ct.Q_(-1.45e7, "J/kg") self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - self.water.TH = Q_(400, "K"), Q_(-1.45e7, "J/kg") + self.water.TH = ct.Q_(400, "K"), ct.Q_(-1.45e7, "J/kg") self.assertQuantityNear(self.water.T, 400 * ct.units.K) self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - self.water.SH = Q_(5000, "J/kg/K"), Q_(-1.45e7, "J/kg") - self.assertQuantityNear(self.water.s, 5000 * ct.units.J / ct.units.kg / ct.units.K) + self.water.SH = ct.Q_(5000, "J/kg/K"), ct.Q_(-1.45e7, "J/kg") + self.assertQuantityNear(self.water.s, 5000 * ct.units("J/kg/K")) self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - self.water.ST = Q_(5000, "J/kg/K"), Q_(400, "K") - self.assertQuantityNear(self.water.s, 5000 * ct.units.J / ct.units.kg / ct.units.K) + self.water.ST = ct.Q_(5000, "J/kg/K"), ct.Q_(400, "K") + self.assertQuantityNear(self.water.s, 5000 * ct.units("J/kg/K")) self.assertQuantityNear(self.water.T, 400 * ct.units.K) - def test_states(self): - self.assertEqual(self.water._native_state, ('T', 'D')) - self.assertNotIn('TPY', self.water._full_states.values()) - self.assertIn('TQ', self.water._partial_states.values()) - def test_set_Q(self): - self.water.TQ = Q_(500, "K"), Q_(0.0, "dimensionless") + self.water.TQ = ct.Q_(500, "K"), ct.Q_(0.0, "dimensionless") p = self.water.P - self.water.Q = Q_(0.8, "dimensionless") + self.water.Q = ct.Q_(0.8, "dimensionless") self.assertQuantityNear(self.water.P, p) self.assertQuantityNear(self.water.T, 500 * ct.units.K) self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) - self.water.TP = Q_(650, "K"), Q_(101325, "Pa") - with self.assertRaises(cn.CanteraError): - self.water.Q = Q_(0.1, "dimensionless") + self.water.TP = ct.Q_(650, "K"), ct.Q_(101325, "Pa") + with self.assertRaises(ct.CanteraError): + self.water.Q = ct.Q_(0.1, "dimensionless") - self.water.TP = Q_(300, "K"), Q_(101325, "Pa") + self.water.TP = ct.Q_(300, "K"), ct.Q_(101325, "Pa") with self.assertRaises(ValueError): - self.water.Q = Q_(0.3, "dimensionless") - - def test_set_minmax(self): - self.water.TP = self.water.min_temp, Q_(101325, "Pa") - self.assertQuantityNear(self.water.T, self.water.min_temp) - - self.water.TP = self.water.max_temp, Q_(101325, "Pa") - self.assertQuantityNear(self.water.T, self.water.max_temp) - - def check_fd_properties(self, T1, P1, T2, P2, tol): - # Properties which are computed as finite differences - self.water.TP = T1, P1 - h1a = self.water.enthalpy_mass - cp1 = self.water.cp_mass - cv1 = self.water.cv_mass - k1 = self.water.isothermal_compressibility - alpha1 = self.water.thermal_expansion_coeff - h1b = self.water.enthalpy_mass - - self.water.TP = T2, P2 - h2a = self.water.enthalpy_mass - cp2 = self.water.cp_mass - cv2 = self.water.cv_mass - k2 = self.water.isothermal_compressibility - alpha2 = self.water.thermal_expansion_coeff - h2b = self.water.enthalpy_mass - - self.assertQuantityNear(cp1, cp2, tol, tol) - self.assertQuantityNear(cv1, cv2, tol, tol) - self.assertQuantityNear(k1, k2, tol, tol) - self.assertQuantityNear(alpha1, alpha2, tol, tol) - - # calculating these finite difference properties should not perturb the - # state of the object (except for checks on edge cases) - self.assertQuantityNear(h1a, h1b, 1e-9) - self.assertQuantityNear(h2a, h2b, 1e-9) - - def test_properties_near_min(self): - self.check_fd_properties(self.water.min_temp*(1+1e-5), 101325 * ct.units.Pa, - self.water.min_temp*(1+1e-4), 101325 * ct.units.Pa, 1e-2) - - def test_properties_near_max(self): - self.check_fd_properties(self.water.max_temp*(1-1e-5), 101325 * ct.units.Pa, - self.water.max_temp*(1-1e-4), 101325 * ct.units.Pa, 1e-2) - - def test_properties_near_sat1(self): - for T in [340 * ct.units.K, 390 * ct.units.K, 420 * ct.units.K]: - self.water.TQ = T, Q_(0.0, "dimensionless") - P = self.water.P - self.check_fd_properties(T, P+0.01 * ct.units.Pa, T, P+0.5 * ct.units.Pa, 1e-4) - - def test_properties_near_sat2(self): - for T in [340 * ct.units.K, 390 * ct.units.K, 420 * ct.units.K]: - self.water.TQ = T, Q_(0.0, "dimensionless") - P = self.water.P - self.check_fd_properties(T, P-0.01 * ct.units.Pa, T, P-0.5 * ct.units.Pa, 1e-4) - - def test_isothermal_compressibility_lowP(self): - # Low-pressure limit corresponds to ideal gas - ref = ct.Solution('gri30.xml') - ref.TPX = Q_(450, "K"), Q_(12, "Pa"), 'H2O:1.0' - self.water.TP = Q_(450, "K"), Q_(12, "Pa") - self.assertQuantityNear(ref.isothermal_compressibility, - self.water.isothermal_compressibility, 1e-5 * ct.units.dimensionless) - - def test_thermal_expansion_coeff_lowP(self): - # Low-pressure limit corresponds to ideal gas - ref = ct.Solution('gri30.xml') - ref.TPX = Q_(450, "K"), Q_(12, "Pa"), 'H2O:1.0' - self.water.TP = Q_(450, "K"), Q_(12, "Pa") - self.assertQuantityNear(ref.thermal_expansion_coeff, - self.water.thermal_expansion_coeff, 1e-5 * ct.units.dimensionless) - - def test_thermal_expansion_coeff_TD(self): - for T in [440 * ct.units.K, 550 * ct.units.K, 660 * ct.units.K]: - self.water.TD = T, Q_(0.1, "kg/m**3") - self.assertQuantityNear(T * self.water.thermal_expansion_coeff, 1.0, 1e-2) - - def test_pq_setter_triple_check(self): - self.water.PQ = Q_(101325, "Pa"), Q_(.2, "dimensionless") - T = self.water.T - # change T such that it would result in a Psat larger than P - self.water.TP = Q_(400, "K"), Q_(101325, "Pa") - # ensure that correct triple point pressure is recalculated - # (necessary as this value is not stored by the C++ base class) - self.water.PQ = Q_(101325, "Pa"), Q_(.2, "dimensionless") - self.assertQuantityNear(T, self.water.T, 1e-9) - with self.assertRaisesRegex(cn.CanteraError, 'below triple point'): - # min_temp is triple point temperature - self.water.TP = self.water.min_temp, Q_(101325, "Pa") - P = self.water.P_sat # triple-point pressure - self.water.PQ = .999*P, Q_(.2, "dimensionless") - - def test_quality_exceptions(self): - # Critical point - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - self.water.TQ = self.water.critical_temperature, Q_(.5, "dimensionless") - self.assertQuantityNear(self.water.P, self.water.critical_pressure) - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - self.water.PQ = self.water.critical_pressure, Q_(.5, "dimensionless") - self.assertQuantityNear(self.water.T, self.water.critical_temperature) - - # Supercritical - with self.assertRaisesRegex(cn.CanteraError, 'supercritical'): - self.water.TQ = 1.001 * self.water.critical_temperature, Q_(0, "dimensionless") - with self.assertRaisesRegex(cn.CanteraError, 'supercritical'): - self.water.PQ = 1.001 * self.water.critical_pressure, Q_(0, "dimensionless") - - # Q negative - with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): - self.water.TQ = Q_(373.15, "K"), Q_(-.001, "dimensionless") - with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): - self.water.PQ = Q_(cn.one_atm, "Pa"), Q_(-.001, "dimensionless") - - # Q larger than one - with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): - self.water.TQ = Q_(373.15, "K"), Q_(1.001, "dimensionless") - with self.assertRaisesRegex(cn.CanteraError, 'Invalid vapor fraction'): - self.water.PQ = Q_(cn.one_atm, "Pa"), Q_(1.001, "dimensionless") - - def test_saturated_mixture(self): - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - with self.assertRaisesRegex(cn.CanteraError, 'Saturated mixture detected'): - self.water.TP = Q_(300, "K"), self.water.P_sat - - w = ct.Water() - - # Saturated vapor - self.water.TQ = Q_(373.15, "K"), Q_(1, "dimensionless") - self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') - w.TP = self.water.T, .999 * self.water.P_sat - self.assertQuantityNear(self.water.cp, w.cp, 1.e-1) - self.assertQuantityNear(self.water.cv, w.cv, 1.e-1) - self.assertQuantityNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-1) - self.assertQuantityNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-1) - - # Saturated mixture - self.water.TQ = Q_(373.15, "K"), Q_(.5, "dimensionless") - self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') - self.assertEqual(self.water.cp, np.inf * ct.units.J / ct.units.kg / ct.units.K) - self.assertTrue(np.isnan(self.water.cv)) - self.assertEqual(self.water.isothermal_compressibility, np.inf * 1 / ct.units.Pa) - self.assertEqual(self.water.thermal_expansion_coeff, np.inf * 1 / ct.units.K) - - # Saturated liquid - self.water.TQ = Q_(373.15, "K"), Q_(0, "dimensionless") - self.assertEqual(self.water.phase_of_matter, 'liquid-gas-mix') - w.TP = self.water.T, 1.001 * self.water.P_sat - self.assertQuantityNear(self.water.cp, w.cp, 9.e-1) - self.assertQuantityNear(self.water.cv, w.cv, 9.e-1) - self.assertQuantityNear(self.water.thermal_expansion_coeff, w.thermal_expansion_coeff, 1.e-1) - self.assertQuantityNear(self.water.isothermal_compressibility, w.isothermal_compressibility, 1.e-1) - - def test_saturation_near_limits(self): - # Low temperature limit (triple point) - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - self.water.P_sat # ensure that solver buffers sufficiently different values - self.water.TP = self.water.min_temp, Q_(cn.one_atm, "Pa") - psat = self.water.P_sat - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - self.water.P_sat # ensure that solver buffers sufficiently different values - self.water.TP = Q_(300, "K"), psat - self.assertQuantityNear(self.water.T_sat, self.water.min_temp) - - # High temperature limit (critical point) - saturation temperature - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - self.water.P_sat # ensure that solver buffers sufficiently different values - self.water.TP = self.water.critical_temperature, self.water.critical_pressure - self.assertQuantityNear(self.water.T_sat, self.water.critical_temperature) - - # High temperature limit (critical point) - saturation pressure - self.water.TP = Q_(300, "K"), Q_(cn.one_atm, "Pa") - self.water.P_sat # ensure that solver buffers sufficiently different values - self.water.TP = self.water.critical_temperature, self.water.critical_pressure - self.assertQuantityNear(self.water.P_sat, self.water.critical_pressure) - - # Supercricital - with self.assertRaisesRegex(cn.CanteraError, 'Illegal temperature value'): - self.water.TP = 1.001 * self.water.critical_temperature, self.water.critical_pressure - self.water.P_sat - with self.assertRaisesRegex(cn.CanteraError, 'Illegal pressure value'): - self.water.TP = self.water.critical_temperature, 1.001 * self.water.critical_pressure - self.water.T_sat - - # Below triple point - with self.assertRaisesRegex(cn.CanteraError, 'Illegal temperature'): - self.water.TP = .999 * self.water.min_temp, Q_(cn.one_atm, "Pa") - self.water.P_sat - # @TODO: test disabled pending fix of GitHub issue #605 - # with self.assertRaisesRegex(ct.CanteraError, 'Illegal pressure value'): - # self.water.TP = 300, .999 * psat - # self.water.T_sat - - def test_TPQ(self): - self.water.TQ = Q_(400, "K"), Q_(0.8, "dimensionless") - T, P, Q = self.water.TPQ - self.assertQuantityNear(T, 400 * ct.units.K) - self.assertQuantityNear(Q, 0.8 * ct.units.dimensionless) - - # a supercritical state - self.water.TPQ = Q_(800, "K"), Q_(3e7, "Pa"), Q_(1, "dimensionless") - self.assertQuantityNear(self.water.T, 800 * ct.units.K) - self.assertQuantityNear(self.water.P, 3e7 * ct.units.Pa) - - self.water.TPQ = T, P, Q - self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) - with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): - self.water.TPQ = T, .999*P, Q - with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): - self.water.TPQ = T, 1.001*P, Q - with self.assertRaises(TypeError): - self.water.TPQ = T, P, 'spam' - - self.water.TPQ = Q_(500, "K"), Q_(1e5, "Pa"), Q_(1, "dimensionless") # superheated steam - self.assertQuantityNear(self.water.P, 1e5 * ct.units.Pa) - with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): - self.water.TPQ = Q_(500, "K"), Q_(1e5, "Pa"), Q_(0, "dimensionless") # vapor fraction should be 1 (T < Tc) - with self.assertRaisesRegex(cn.CanteraError, 'inconsistent'): - self.water.TPQ = Q_(700, "K"), Q_(1e5, "Pa"), Q_(0, "dimensionless") # vapor fraction should be 1 (T > Tc) - - def test_phase_of_matter(self): - self.water.TP = Q_(300, "K"), Q_(101325, "Pa") - self.assertEqual(self.water.phase_of_matter, "liquid") - self.water.TP = Q_(500, "K"), Q_(101325, "Pa") - self.assertEqual(self.water.phase_of_matter, "gas") - self.water.TP = self.water.critical_temperature*2, Q_(101325, "Pa") - self.assertEqual(self.water.phase_of_matter, "supercritical") - self.water.TP = Q_(300, "K"), self.water.critical_pressure*2 - self.assertEqual(self.water.phase_of_matter, "supercritical") - self.water.TQ = Q_(300, "K"), Q_(0.4, "dimensionless") - self.assertEqual(self.water.phase_of_matter, "liquid-gas-mix") - - # These cases work after fixing GH-786 - n2 = ct.Nitrogen() - n2.TP = Q_(100, "K"), Q_(1000, "Pa") - self.assertEqual(n2.phase_of_matter, "gas") - - co2 = ct.CarbonDioxide() - self.assertEqual(co2.phase_of_matter, "gas") + self.water.Q = ct.Q_(0.3, "dimensionless") From b347d0ac34a18c7c8b9019df0f79f7b5df5f0d83 Mon Sep 17 00:00:00 2001 From: hallaali <77864659+hallaali@users.noreply.github.com> Date: Mon, 3 May 2021 11:32:42 -0400 Subject: [PATCH 10/41] fix capitalization error --- interfaces/cython/SConscript | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index 59c89c8c26..6632265faf 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -136,6 +136,11 @@ UNITS = { "reference_pressure": '"Pa"', "thermal_expansion_coeff": '"1/K"' } +SYMBOL = { + "T": "T", "P": "P", "D": "density", "H": "h", "S": "s", + "V": "v", "U": "u", "Q": "Q", "X": "X", "Y": "Y" +} + getter_properties = [ "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", @@ -186,12 +191,14 @@ pf_setter_2_properties = ["PQ", "TQ", "PV", "SH", "ST", "TH", "TV", "UP", "VH"] thermophase_2_setters = [] for name in tp_setter_2_properties: - d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) + d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], + u1=UNITS[name[1]]) thermophase_2_setters.append(setter_2_template.substitute(d)) purefluid_2_setters = [] for name in pf_setter_2_properties: - d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]]) + d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], + u1=UNITS[name[1]]) purefluid_2_setters.append(setter_2_template.substitute(d)) setter_3_template = Template(""" @@ -230,8 +237,8 @@ tp_setter_3_properties = [ thermophase_3_setters = [] for name in tp_setter_3_properties: - d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], - n2=name[2], u2=UNITS[name[2]]) + d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], + u1=UNITS[name[1]], n2=SYMBOL[name[2]], u2=UNITS[name[2]]) thermophase_3_setters.append(setter_3_template.substitute(d)) getter_3_template = Template(""" From 07555134a96646cce7bf61d8545a83791fa9f17e Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 28 May 2022 21:16:05 -0400 Subject: [PATCH 11/41] add pint dependency to CI --- .github/workflows/main.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 955c70b142..f917982a71 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,7 +53,7 @@ jobs: # h5py is optional; some versions don't have binaries (yet) run: | python3 -m pip install ruamel.yaml scons==3.1.2 numpy cython pandas pytest \ - pytest-github-actions-annotate-failures + pytest-github-actions-annotate-failures pint python3 -m pip install h5py - name: Build Cantera run: | @@ -96,7 +96,7 @@ jobs: run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies run: | - python3 -m pip install ruamel.yaml scons numpy cython pandas h5py pytest pytest-github-actions-annotate-failures + python3 -m pip install ruamel.yaml scons numpy cython pandas h5py pytest pytest-github-actions-annotate-failures pint - name: Build Cantera run: python3 `which scons` build env_vars=all CXX=clang++-12 CC=clang-12 f90_interface=n extra_lib_dirs=/usr/lib/llvm/lib @@ -147,7 +147,7 @@ jobs: - name: Install Python dependencies # h5py is optional; may fail if no wheel is present for a given OS/Python version run: | - $PYTHON_CMD -m pip install ruamel.yaml numpy cython pandas pytest pytest-github-actions-annotate-failures + $PYTHON_CMD -m pip install ruamel.yaml numpy cython pandas pytest pytest-github-actions-annotate-failures pint $PYTHON_CMD -m pip install h5py || true - name: Install Python dependencies for GH Python if: matrix.python-version == '3.11' @@ -197,7 +197,7 @@ jobs: - name: Install Python dependencies run: | python3 -m pip install ruamel.yaml scons numpy cython pandas scipy pytest h5py \ - pytest-github-actions-annotate-failures pytest-cov gcovr + pytest-github-actions-annotate-failures pytest-cov gcovr pint - name: Setup .NET Core SDK uses: actions/setup-dotnet@v2 with: @@ -367,7 +367,7 @@ jobs: run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies run: | - python3 -m pip install ruamel.yaml scons numpy cython pandas matplotlib scipy h5py + python3 -m pip install ruamel.yaml scons numpy cython pandas matplotlib scipy pint h5py - name: Build Cantera # compile with GCC 9.4.0 on ubuntu-20.04 as an alternative to the default # (GCC 7.5.0 is both default and oldest supported version) @@ -434,7 +434,7 @@ jobs: run: | conda install -q sundials=${{ matrix.sundials-ver }} scons numpy ruamel.yaml \ cython boost-cpp fmt eigen yaml-cpp h5py pandas libgomp openblas pytest \ - highfive + highfive pint - name: Build Cantera run: | scons build system_fmt=y system_eigen=y system_yamlcpp=y system_sundials=y \ @@ -495,7 +495,7 @@ jobs: # use boost-cpp rather than boost from conda-forge # Install SCons >=4.4.0 to make sure that MSVC_TOOLSET_VERSION variable is present run: | - mamba install -q '"scons>=4.4.0"' numpy cython ruamel.yaml boost-cpp eigen yaml-cpp h5py pandas pytest highfive + mamba install -q '"scons>=4.4.0"' numpy cython ruamel.yaml boost-cpp eigen yaml-cpp h5py pandas pytest highfive pint shell: pwsh - name: Build Cantera run: scons build system_eigen=y system_yamlcpp=y system_highfive=y logging=debug @@ -558,7 +558,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install -U pip setuptools wheel - python -m pip install '"scons<4.4.0"' pypiwin32 numpy ruamel.yaml cython pandas pytest pytest-github-actions-annotate-failures + python -m pip install '"scons<4.4.0"' pypiwin32 numpy ruamel.yaml cython pandas pytest pytest-github-actions-annotate-failures pint - name: Restore Boost cache uses: actions/cache@v3 id: cache-boost @@ -622,7 +622,7 @@ jobs: - name: Install Python dependencies run: | python3 -m pip install ruamel.yaml scons numpy cython pandas h5py pytest \ - pytest-github-actions-annotate-failures + pytest-github-actions-annotate-failures pint - name: Setup Intel oneAPI environment run: | source /opt/intel/oneapi/setvars.sh @@ -669,7 +669,7 @@ jobs: run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies run: python3 -m pip install ruamel.yaml scons numpy cython h5py pandas pytest - pytest-github-actions-annotate-failures + pytest-github-actions-annotate-failures pint - name: Setup Intel oneAPI environment run: | source /opt/intel/oneapi/setvars.sh @@ -701,7 +701,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install -U pip setuptools wheel - python -m pip install scons pypiwin32 numpy ruamel.yaml cython h5py pandas pytest pytest-github-actions-annotate-failures + python -m pip install scons pypiwin32 numpy ruamel.yaml cython h5py pandas pytest pytest-github-actions-annotate-failures pint - name: Restore Boost cache uses: actions/cache@v3 id: cache-boost From 4a033136eb5b58db5103c9cf780b50d6e1b001ba Mon Sep 17 00:00:00 2001 From: hallaali <77864659+hallaali@users.noreply.github.com> Date: Tue, 4 May 2021 23:34:55 -0400 Subject: [PATCH 12/41] Update rankine_units.py --- samples/python/thermo/rankine_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/python/thermo/rankine_units.py b/samples/python/thermo/rankine_units.py index a4e0e33489..32967eb017 100644 --- a/samples/python/thermo/rankine_units.py +++ b/samples/python/thermo/rankine_units.py @@ -49,7 +49,7 @@ def printState(n, fluid): # create an object representing water w = ct.Water() - # start with saturated liquid water at 80.33 degrees Fahrenheit + # start with saturated liquid water at 80.33 degrees Rankine w.TQ = 540 * ct.units.degR, 0.0 * ct.units.dimensionless h1 = w.h p1 = w.P From 3944c32507dcd5a4fbbbfb10fd67434953fad912 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 28 May 2022 13:49:02 -0400 Subject: [PATCH 13/41] [Doc] Add default python_module key into env This prevents KeyError failures if sphinx_docs=y and python_package=n --- SConstruct | 1 + 1 file changed, 1 insertion(+) diff --git a/SConstruct b/SConstruct index 69c770f1d1..540564c0c2 100644 --- a/SConstruct +++ b/SConstruct @@ -1737,6 +1737,7 @@ elif env['python_package'] == 'n': env['install_python_action'] = '' env['python_module_loc'] = '' +env["python_module"] = None env["ct_pyscriptdir"] = "" def check_module(name): From f3667ab3a86d1259cf45465a6a110a0e814af6f5 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 28 May 2022 21:44:08 -0400 Subject: [PATCH 14/41] [Units] Add application-level registry This supports pickling and unpickling of quantities carrying Pint units. --- interfaces/cython/cantera/units/__init__.py | 4 ++++ interfaces/cython/cantera/units/solution.py.in | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/interfaces/cython/cantera/units/__init__.py b/interfaces/cython/cantera/units/__init__.py index 7a493060d7..f7496908d9 100644 --- a/interfaces/cython/cantera/units/__init__.py +++ b/interfaces/cython/cantera/units/__init__.py @@ -1 +1,5 @@ from .solution import * + +from pint import UnitRegistry, set_application_registry +units = UnitRegistry() +set_application_registry(units) diff --git a/interfaces/cython/cantera/units/solution.py.in b/interfaces/cython/cantera/units/solution.py.in index bf25963a54..c0298ca89d 100644 --- a/interfaces/cython/cantera/units/solution.py.in +++ b/interfaces/cython/cantera/units/solution.py.in @@ -1,7 +1,7 @@ from .. import Solution as _Solution, PureFluid as _PureFluid, CanteraError +from pint import get_application_registry -from pint import UnitRegistry -units = UnitRegistry() +units = get_application_registry() Q_ = units.Quantity __all__ = ("units", "Q_", "Solution", "PureFluid", "Heptane", "CarbonDioxide", From 7ae626e67b1b6b3f030ee3e498769b983bdb7c5c Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 24 Sep 2022 17:42:07 -0400 Subject: [PATCH 15/41] [Cython] Fix setuptools warning about data The include_package_data config option is redundant with explicitly listing the package data, so setuptools was generating warnings. Since we explicitly list everything, this can be removed. --- interfaces/cython/setup.cfg.in | 1 - 1 file changed, 1 deletion(-) diff --git a/interfaces/cython/setup.cfg.in b/interfaces/cython/setup.cfg.in index 81ec1d409a..5eee3c35af 100644 --- a/interfaces/cython/setup.cfg.in +++ b/interfaces/cython/setup.cfg.in @@ -38,7 +38,6 @@ project_urls = [options] zip_safe = False -include_package_data = True install_requires = numpy >= 1.12.0 ruamel.yaml >= 0.15.34 From 590ebdecc71ba6e3227ce2db984b56f8e53de41c Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 24 Sep 2022 17:43:20 -0400 Subject: [PATCH 16/41] [Cython] Move units package to with_units The units package shadowed the units Cython module. This made Cython very unhappy and we couldn't import anything from Cantera. --- interfaces/cython/SConscript | 5 ++++- interfaces/cython/cantera/units/__init__.py | 5 ----- interfaces/cython/cantera/with_units/__init__.py | 9 +++++++++ .../cython/cantera/{units => with_units}/solution.py.in | 3 +++ interfaces/cython/setup.cfg.in | 5 +++-- 5 files changed, 19 insertions(+), 8 deletions(-) delete mode 100644 interfaces/cython/cantera/units/__init__.py create mode 100644 interfaces/cython/cantera/with_units/__init__.py rename interfaces/cython/cantera/{units => with_units}/solution.py.in (94%) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index 6632265faf..a5df1e54fa 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -321,7 +321,10 @@ common_properties = """ localenv["common_properties"] = common_properties.strip() localenv["thermophase_properties"] = thermophase_properties.strip() localenv["purefluid_properties"] = purefluid_properties.strip() -units = localenv.SubstFile("cantera/units/solution.py", "cantera/units/solution.py.in") +units = localenv.SubstFile( + "cantera/with_units/solution.py", + "cantera/with_units/solution.py.in", +) localenv.Depends(mod, units) # Determine installation path and install the Python module diff --git a/interfaces/cython/cantera/units/__init__.py b/interfaces/cython/cantera/units/__init__.py deleted file mode 100644 index f7496908d9..0000000000 --- a/interfaces/cython/cantera/units/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .solution import * - -from pint import UnitRegistry, set_application_registry -units = UnitRegistry() -set_application_registry(units) diff --git a/interfaces/cython/cantera/with_units/__init__.py b/interfaces/cython/cantera/with_units/__init__.py new file mode 100644 index 0000000000..42a41116bf --- /dev/null +++ b/interfaces/cython/cantera/with_units/__init__.py @@ -0,0 +1,9 @@ +# This code to set the application registry has to come before any code that wants to +# use the registry is imported. In particular, it has to come before any of our code +# that uses units! +from pint import UnitRegistry, set_application_registry +cantera_units_registry = UnitRegistry() +set_application_registry(cantera_units_registry) + +# Now we can import our code +from .solution import * diff --git a/interfaces/cython/cantera/units/solution.py.in b/interfaces/cython/cantera/with_units/solution.py.in similarity index 94% rename from interfaces/cython/cantera/units/solution.py.in rename to interfaces/cython/cantera/with_units/solution.py.in index c0298ca89d..8a9b5860f0 100644 --- a/interfaces/cython/cantera/units/solution.py.in +++ b/interfaces/cython/cantera/with_units/solution.py.in @@ -1,3 +1,6 @@ +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + from .. import Solution as _Solution, PureFluid as _PureFluid, CanteraError from pint import get_application_registry diff --git a/interfaces/cython/setup.cfg.in b/interfaces/cython/setup.cfg.in index 5eee3c35af..1a0358a77d 100644 --- a/interfaces/cython/setup.cfg.in +++ b/interfaces/cython/setup.cfg.in @@ -48,7 +48,7 @@ packages = cantera.data cantera.test cantera.examples - cantera.units + cantera.with_units [options.package_data] # The module extension needs to be here since we don't want setuptools to compile @@ -61,10 +61,11 @@ cantera.examples = *.txt [options.extras_require] pandas = pandas +units = pint [options.entry_points] console_scripts = ck2yaml = cantera.ck2yaml:script_entry_point cti2yaml = cantera.cti2yaml:main ctml2yaml = cantera.ctml2yaml:main - yaml2ck = cantera.yaml2ck:main + yaml2ck = cantera.yaml2ck:main From 443ad26b0db54cc2dc6bb6b6cccf8027a3b09f41 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 24 Sep 2022 17:44:26 -0400 Subject: [PATCH 17/41] [Examples] Add example keywords for units --- doc/example-keywords.txt | 1 + interfaces/cython/SConscript | 2 +- samples/python/thermo/isentropic_units.py | 5 +++-- samples/python/thermo/rankine_units.py | 20 +++++++++++--------- samples/python/thermo/sound_speed_units.py | 8 +++++--- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/doc/example-keywords.txt b/doc/example-keywords.txt index 22a82c264d..8cc74559f0 100644 --- a/doc/example-keywords.txt +++ b/doc/example-keywords.txt @@ -41,5 +41,6 @@ thermodynamic cycle thermodynamics transport tutorial +units user-defined model well-stirred reactor diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index a5df1e54fa..74f83d01e5 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -106,7 +106,7 @@ else: localenv.Depends(ext, localenv['cantera_shlib']) for f in (multi_glob(localenv, 'cantera', 'py') + - multi_glob(localenv, 'cantera/*', 'py')): + multi_glob(localenv, 'cantera/*', 'py', 'in')): localenv.Depends(mod, f) UNITS = { diff --git a/samples/python/thermo/isentropic_units.py b/samples/python/thermo/isentropic_units.py index 20c5d71d80..2220dea1e1 100644 --- a/samples/python/thermo/isentropic_units.py +++ b/samples/python/thermo/isentropic_units.py @@ -1,10 +1,11 @@ """ Isentropic, adiabatic flow example - calculate area ratio vs. Mach number curve -Requires: cantera >= 2.6.0 +Requires: cantera >= 3.0.0 +Keywords: thermodynamics, compressible flow, units """ -import cantera.units as ct +import cantera.with_units as ct import numpy as np ct.units.default_format = ".2F~P" label_string = "area ratio\tMach number\ttemperature\tpressure ratio" diff --git a/samples/python/thermo/rankine_units.py b/samples/python/thermo/rankine_units.py index 32967eb017..a4e3855bfc 100644 --- a/samples/python/thermo/rankine_units.py +++ b/samples/python/thermo/rankine_units.py @@ -1,15 +1,17 @@ """ -A Rankine vapor power cycle +Calculate the efficiency of a Rankine vapor power cycle using a pure fluid model +for water. Includes the units of quantities in the calculations. Requires: Cantera >= 2.6.0 +Keywords: thermodynamics, thermodynamic cycle, non-ideal fluid, units """ -import cantera.units as ct +import cantera.with_units as ct # parameters -eta_pump = 0.6 * ct.units.dimensionless # pump isentropic efficiency +eta_pump = 0.6 * ct.units.dimensionless # pump isentropic efficiency eta_turbine = 0.8 * ct.units.dimensionless # turbine isentropic efficiency -p_max = 116.03 * ct.units.psi # maximum pressure +p_max = 116.03 * ct.units.psi # maximum pressure def pump(fluid, p_final, eta): @@ -40,7 +42,7 @@ def expand(fluid, p_final, eta): return actual_work -def printState(n, fluid): +def print_state(n, fluid): print('\n***************** State {0} ******************'.format(n)) print(fluid.report()) @@ -53,23 +55,23 @@ def printState(n, fluid): w.TQ = 540 * ct.units.degR, 0.0 * ct.units.dimensionless h1 = w.h p1 = w.P - printState(1, w) + print_state(1, w) # pump it adiabatically to p_max pump_work = pump(w, p_max, eta_pump) h2 = w.h - printState(2, w) + print_state(2, w) # heat it at constant pressure until it reaches the saturated vapor state # at this pressure w.PQ = p_max, 1.0 * ct.units.dimensionless h3 = w.h heat_added = h3 - h2 - printState(3, w) + print_state(3, w) # expand back to p1 turbine_work = expand(w, p1, eta_turbine) - printState(4, w) + print_state(4, w) # efficiency eff = (turbine_work - pump_work)/heat_added diff --git a/samples/python/thermo/sound_speed_units.py b/samples/python/thermo/sound_speed_units.py index fe7c4f2a27..c0abca7ab5 100644 --- a/samples/python/thermo/sound_speed_units.py +++ b/samples/python/thermo/sound_speed_units.py @@ -2,14 +2,15 @@ Compute the "equilibrium" and "frozen" sound speeds for a gas Requires: cantera >= 2.6.0 +Keywords: thermodynamics, equilibrium """ -import cantera.units as ct +import cantera.with_units as ct import numpy as np ct.units.default_format = ".2F~P" -def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): +def equilibrium_sound_speeds(gas, rtol=1.0e-6, max_iter=5000): """ Returns a tuple containing the equilibrium and frozen sound speeds for a gas with an equilibrium composition. The gas is first set to an @@ -43,6 +44,7 @@ def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): # compute the frozen sound speed using the ideal gas expression as a check gamma = gas.cp/gas.cv + gamma * ct.units.molar_gas_constant afrozen2 = np.sqrt(gamma * ct.units.molar_gas_constant * gas.T / gas.mean_molecular_weight).to("ft/s") @@ -56,4 +58,4 @@ def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): print("Temperature Equilibrium Sound Speed Frozen Sound Speed Frozen Sound Speed Check") for T in T_range: gas.TP = T, 1.0 * ct.units.atm - print(T, *equilSoundSpeeds(gas), sep = " ") + print(T, *equilibrium_sound_speeds(gas), sep = " ") From 0f4346311fee9c636612ad2b902d7c5ac05f8d99 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 25 Sep 2022 20:37:00 -0400 Subject: [PATCH 18/41] [CI] Run examples from installed wheel This lets us check whether the wheel is built properly and includes all the files necessary for the package to function. --- .github/workflows/main.yml | 41 ++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f917982a71..a7f6027cab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -70,6 +70,13 @@ jobs: - name: Test Cantera run: python3 `which scons` test show_long_tests=yes verbose_tests=yes --debug=time + - name: Save the wheel file to install Cantera + uses: actions/upload-artifact@v3 + with: + path: build/python/dist/Cantera*.whl + retention-days: 2 + name: cantera-wheel-${{ matrix.python-version }}-${{ matrix.os }} + if-no-files-found: error clang-compiler: name: LLVM/Clang with Python 3.8 @@ -339,8 +346,9 @@ jobs: run-examples: name: Run the Python examples using bash - runs-on: ubuntu-20.04 + runs-on: ${{ matrix.os }} timeout-minutes: 60 + needs: ["ubuntu-multiple-pythons"] strategy: matrix: python-version: ['3.8', '3.10', '3.11'] @@ -349,39 +357,24 @@ jobs: HDF5_LIBDIR: /usr/lib/x86_64-linux-gnu/hdf5/serial HDF5_INCLUDEDIR: /usr/include/hdf5/serial steps: + # We're not building Cantera here, we only need the checkout for the samples + # folder, so no need to do a recursive checkout - uses: actions/checkout@v3 name: Checkout the repository - with: - submodules: recursive - name: Setup Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: x64 - - name: Install Apt dependencies - run: | - sudo apt update - sudo apt install libboost-dev gfortran graphviz liblapack-dev libblas-dev \ - gcc-9 g++-9 libhdf5-dev + - name: Download the wheel artifact + uses: actions/download-artifact@v3 + with: + name: cantera-wheel-${{ matrix.python-version }}-${{ matrix.os }} + path: dist - name: Upgrade pip run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies - run: | - python3 -m pip install ruamel.yaml scons numpy cython pandas matplotlib scipy pint h5py - - name: Build Cantera - # compile with GCC 9.4.0 on ubuntu-20.04 as an alternative to the default - # (GCC 7.5.0 is both default and oldest supported version) - # compile without native HDF5 support - run: python3 `which scons` build -j2 debug=n CC=gcc-9 CXX=g++-9 - if: matrix.python-version != '3.10' - - name: Build Cantera (Python 3.10 with HDF) - # compile with GCC 9.4.0 on ubuntu-20.04 as an alternative to the default - # (GCC 7.5.0 is both default and oldest supported version) - # compile with native HDF5 support - run: | - python3 `which scons` build -j2 debug=n CC=gcc-9 CXX=g++-9 \ - hdf_libdir=$HDF5_LIBDIR hdf_include=$HDF5_INCLUDEDIR - if: matrix.python-version == '3.10' + run: python3 -m pip install --find-links dist cantera h5py pandas matplotlib scipy pint - name: Run the examples # See https://unix.stackexchange.com/a/392973 for an explanation of the -exec part run: | From cde23e3def73f867487e0745677eef636944a274 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 11 Oct 2022 22:24:33 -0400 Subject: [PATCH 19/41] [Cython] Fix a typo in an error message --- interfaces/cython/cantera/thermo.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interfaces/cython/cantera/thermo.pyx b/interfaces/cython/cantera/thermo.pyx index 0354b101aa..a56eaeaa59 100644 --- a/interfaces/cython/cantera/thermo.pyx +++ b/interfaces/cython/cantera/thermo.pyx @@ -1960,7 +1960,7 @@ cdef class PureFluid(ThermoPhase): def __set__(self, Q): if (self.P >= self.critical_pressure or abs(self.P-self.P_sat)/self.P > 1e-4): - raise ValueError('Cannot set vapor quality outside the' + raise ValueError('Cannot set vapor quality outside the ' 'two-phase region') self.thermo.setState_Psat(self.P, Q) From 2effd6d94628702f20a6615271262d027b9800ef Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 11 Oct 2022 22:24:57 -0400 Subject: [PATCH 20/41] [Test] Add branch coverage to Python --- test/python/coverage.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/test/python/coverage.ini b/test/python/coverage.ini index 14a8e48bdd..80e95de18d 100644 --- a/test/python/coverage.ini +++ b/test/python/coverage.ini @@ -4,3 +4,4 @@ omit = *.cti stringsource source = ../../build/python/cantera +branch = true From abd8b4bd969265905c720f0d1c66dc7b3d09feeb Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 11 Oct 2022 22:26:08 -0400 Subject: [PATCH 21/41] [Units] Pass through kwargs in constructor Allow setting the backed of the Water instance. Fix a typo in the R134a instance. --- interfaces/cython/cantera/with_units/solution.py.in | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/interfaces/cython/cantera/with_units/solution.py.in b/interfaces/cython/cantera/with_units/solution.py.in index 8a9b5860f0..c12cc04d13 100644 --- a/interfaces/cython/cantera/with_units/solution.py.in +++ b/interfaces/cython/cantera/with_units/solution.py.in @@ -23,8 +23,8 @@ class Solution: class PureFluid: - def __init__(self, infile, name=""): - self.__dict__["_phase"] = _PureFluid(infile, name) + def __init__(self, infile, name="", **kwargs): + self.__dict__["_phase"] = _PureFluid(infile, name, **kwargs) @common_properties@ @@ -80,7 +80,7 @@ def CarbonDioxide(): def Hfc134a(): - return PureFluid("liquidvapor.yaml", "hfc134a") + return PureFluid("liquidvapor.yaml", "HFC-134a") def Hydrogen(): @@ -99,5 +99,5 @@ def Oxygen(): return PureFluid("liquidvapor.yaml", "oxygen") -def Water(): - return PureFluid("liquidvapor.yaml", "water") +def Water(backend="Reynolds"): + return PureFluid("liquidvapor.yaml", "water", backend=backend) From cf4c1341dddca9c093cb4e5dbb23f4b77ecb7658 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 11 Oct 2022 22:27:32 -0400 Subject: [PATCH 22/41] [Units] Refactor setter to use a loop Rather than checking each value individually with try/except, use the ito method and try to convert within a loop. This simplifies testing for the error condition and looks a little cleaner. --- interfaces/cython/SConscript | 46 ++++++++++--------- .../cython/cantera/with_units/solution.py.in | 28 ++++++----- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index 74f83d01e5..5bdb6ff945 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -172,17 +172,18 @@ setter_2_template = Template(""" @${name}.setter def ${name}(self, value): - try: - ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} - ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} - except AttributeError as e: - # The 'to' attribute missing means this isn't a pint Quantity - if "'to'" in str(e): - raise CanteraError( - f"Values ({value}) must be instances of pint.Quantity classes" - ) from None - else: - raise + ${n0} = value[0] if value[0] is not None else self.${n0} + ${n1} = value[1] if value[1] is not None else self.${n1} + for val, unit in ((${n0}, ${u0}), (${n1}, ${u1})): + try: + val.ito(unit) + except AttributeError as e: + if "'ito'" in str(e): + raise CanteraError( + f"Value {val!r} must be an instance of a pint.Quantity class" + ) from None + else: + raise # pragma: no cover self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude """) @@ -209,17 +210,18 @@ setter_3_template = Template(""" @${name}.setter def ${name}(self, value): - try: - ${n0} = value[0].to(${u0}) if value[0] is not None else self.${n0} - ${n1} = value[1].to(${u1}) if value[1] is not None else self.${n1} - except AttributeError as e: - # The 'to' attribute missing means this isn't a pint Quantity - if "'to'" in str(e): - raise CanteraError( - f"Values ({value}) must be instances of pint.Quantity classes" - ) from None - else: - raise + ${n0} = value[0] if value[0] is not None else self.${n0} + ${n1} = value[1] if value[1] is not None else self.${n1} + for val, unit in ((${n0}, ${u0}), (${n1}, ${u1})): + try: + val.ito(unit) + except AttributeError as e: + if "'ito'" in str(e): + raise CanteraError( + f"Value {val!r} must be an instance of a pint.Quantity class" + ) from None + else: + raise # pragma: no cover if value[2] is not None: try: ${n2} = value[2].to(${u2}).magnitude diff --git a/interfaces/cython/cantera/with_units/solution.py.in b/interfaces/cython/cantera/with_units/solution.py.in index c12cc04d13..ce7ae0ed8c 100644 --- a/interfaces/cython/cantera/with_units/solution.py.in +++ b/interfaces/cython/cantera/with_units/solution.py.in @@ -38,10 +38,13 @@ class PureFluid: if value is not None: try: Q = value.to("dimensionless").magnitude - except AttributeError: - raise CanteraError( - "Values must be instances of pint.Quantity classes" - ) from None + except AttributeError as e: + if "'to'" in str(e): + raise CanteraError( + f"Value {value!r} must be an instance of a pint.Quantity class" + ) from None + else: + raise # pragma: no cover else: Q = self.Q.magnitude self._phase.Q = Q @@ -55,17 +58,18 @@ class PureFluid: @TPQ.setter def TPQ(self, value): + msg = "Value {value!r} must be an instance of a pint.Quantity class" T = value[0] if value[0] is not None else self.T P = value[1] if value[1] is not None else self.P Q = value[2] if value[2] is not None else self.Q - try: - T = T.to("K") - P = P.to("Pa") - Q = Q.to("dimensionless") - except AttributeError: - raise CanteraError( - "Values must be instances of pint.Quantity classes" - ) from None + for val, unit in ((T, "K"), (P, "Pa"), (Q, "dimensionless")): + try: + val.ito(unit) + except AttributeError as e: + if "'ito'" in str(e): + raise CanteraError(msg.format(value=val)) from None + else: + raise # pragma: no cover self._phase.TPQ = T.magnitude, P.magnitude, Q.magnitude @purefluid_properties@ From dfec96f2214bdc3266af7cb9b81b56ccbe7fd6b2 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 11 Oct 2022 22:27:41 -0400 Subject: [PATCH 23/41] Missing a license header --- interfaces/cython/cantera/with_units/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/interfaces/cython/cantera/with_units/__init__.py b/interfaces/cython/cantera/with_units/__init__.py index 42a41116bf..d3d6aa7a47 100644 --- a/interfaces/cython/cantera/with_units/__init__.py +++ b/interfaces/cython/cantera/with_units/__init__.py @@ -1,3 +1,6 @@ +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + # This code to set the application registry has to come before any code that wants to # use the registry is imported. In particular, it has to come before any of our code # that uses units! From db403b453c6bfa1dc4c3620f3770e025576271fb Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 11 Oct 2022 22:29:28 -0400 Subject: [PATCH 24/41] [Units/Test] Refactor test suite to use pytest This increases the line coverage to near 100%, uses pint's testing assertions rather than rolling our own, and uses parameterize extensively to reduce boilerplate in testing as many properties as possible. --- test/python/conftest.py | 3 + test/python/test_units.py | 1046 +++++++++++++++++++------------------ 2 files changed, 532 insertions(+), 517 deletions(-) create mode 100644 test/python/conftest.py diff --git a/test/python/conftest.py b/test/python/conftest.py new file mode 100644 index 0000000000..debb72318b --- /dev/null +++ b/test/python/conftest.py @@ -0,0 +1,3 @@ +import pytest + +pytest.register_assert_rewrite("pint.testing") diff --git a/test/python/test_units.py b/test/python/test_units.py index 3afaa7228a..3af2804828 100644 --- a/test/python/test_units.py +++ b/test/python/test_units.py @@ -1,523 +1,535 @@ -import numpy as np - -import cantera.with_units as ct -from . import utilities - - -class CanteraUnitsTest(utilities.CanteraTest): - def assertQuantityNear(self, a, b, atol=1.0E-12, rtol=1.0E-8, msg=None): - if not np.isclose(a, b, atol=atol, rtol=rtol): - message = ( - f"AssertNear: {a:.14g} - {b:.14g} = {a-b:.14g}\n" + - f"rtol = {rtol:10e}; atol = {atol:10e}") - if msg is not None: - message = msg + "\n" + message - self.fail(message) - - def assertArrayQuantityNear(self, A, B, rtol=1e-8, atol=1e-12, msg=None): - if A.shape != B.shape: - self.fail(f"Arrays are of different lengths ({A.shape}, {B.shape})") - isclose = np.isclose(A, B, atol=atol, rtol=rtol) - if not isclose.all(): - bad_A = A[~isclose] - bad_B = B[~isclose] - message = ( - f"AssertNear: {bad_A:.14g} - {bad_B:.14g} = {bad_A - bad_B:.14g}\n" + - f"Error for {(~isclose).sum()} element(s) exceeds rtol = {rtol:10e}," + - f"atol = {atol:10e}." - ) - if msg is not None: - message = msg + "\n" + message - self.fail(message) - - -class TestSolutionUnits(CanteraUnitsTest): - def setUp(self): - self.phase = ct.Solution("h2o2.yaml") - - def test_mass_basis(self): - self.assertEqual(self.phase.basis_units, "kg") - self.assertQuantityNear(self.phase.density_mass, self.phase.density) - self.assertQuantityNear(self.phase.enthalpy_mass, self.phase.h) - self.assertQuantityNear(self.phase.entropy_mass, self.phase.s) - self.assertQuantityNear(self.phase.int_energy_mass, self.phase.u) - self.assertQuantityNear(self.phase.volume_mass, self.phase.v) - self.assertQuantityNear(self.phase.gibbs_mass, self.phase.g) - self.assertQuantityNear(self.phase.cp_mass, self.phase.cp) - self.assertQuantityNear(self.phase.cv_mass, self.phase.cv) - - def test_molar_basis(self): - self.phase.basis = "molar" - self.assertEqual(self.phase.basis_units, "kmol") - self.assertQuantityNear(self.phase.density_mole, self.phase.density) - self.assertQuantityNear(self.phase.enthalpy_mole, self.phase.h) - self.assertQuantityNear(self.phase.entropy_mole, self.phase.s) - self.assertQuantityNear(self.phase.int_energy_mole, self.phase.u) - self.assertQuantityNear(self.phase.volume_mole, self.phase.v) - self.assertQuantityNear(self.phase.gibbs_mole, self.phase.g) - self.assertQuantityNear(self.phase.cp_mole, self.phase.cp) - self.assertQuantityNear(self.phase.cv_mole, self.phase.cv) - - def test_dimensions_solution(self): - # basis-independent - dims_T = self.phase.T.dimensionality - self.assertEqual(dims_T["[temperature]"], 1.0) - dims_P = self.phase.P.dimensionality - self.assertEqual(dims_P["[mass]"], 1.0) - self.assertEqual(dims_P["[length]"], -1.0) - self.assertEqual(dims_P["[time]"], -2.0) - dims_X = self.phase.X.dimensionality - # units container for dimensionless is empty - self.assertEqual(len(dims_X), 0) - dims_Y = self.phase.Y.dimensionality - self.assertEqual(len(dims_Y), 0) - dims_atomic_weight = self.phase.atomic_weight.dimensionality - self.assertEqual(dims_atomic_weight["[mass]"], 1.0) - self.assertEqual(dims_atomic_weight["[substance]"], -1.0) - dims_chemical_potentials = self.phase.chemical_potentials.dimensionality - self.assertEqual(dims_chemical_potentials["[mass]"], 1.0) - self.assertEqual(dims_chemical_potentials["[length]"], 2.0) - self.assertEqual(dims_chemical_potentials["[time]"], -2.0) - self.assertEqual(dims_chemical_potentials["[substance]"], -1.0) - dims_concentrations = self.phase.concentrations.dimensionality - self.assertEqual(dims_concentrations["[substance]"], 1.0) - self.assertEqual(dims_concentrations["[length]"], -3.0) - dims_electric_potential = self.phase.electric_potential.dimensionality - self.assertEqual(dims_electric_potential["[mass]"], 1.0) - self.assertEqual(dims_electric_potential["[length]"], 2.0) - self.assertEqual(dims_electric_potential["[time]"], -3.0) - self.assertEqual(dims_electric_potential["[current]"], -1.0) - dims_echem_potential = self.phase.electrochemical_potentials.dimensionality - self.assertEqual(dims_echem_potential["[mass]"], 1.0) - self.assertEqual(dims_echem_potential["[length]"], 2.0) - self.assertEqual(dims_echem_potential["[time]"], -2.0) - self.assertEqual(dims_echem_potential["[substance]"], -1.0) - dims_isothermal_comp = self.phase.isothermal_compressibility.dimensionality - self.assertEqual(dims_isothermal_comp["[mass]"], -1.0) - self.assertEqual(dims_isothermal_comp["[length]"], 1.0) - self.assertEqual(dims_isothermal_comp["[time]"], 2.0) - dims_max_temp = self.phase.max_temp.dimensionality - self.assertEqual(dims_max_temp["[temperature]"], 1.0) - dims_mean_molecular_weight = self.phase.mean_molecular_weight.dimensionality - self.assertEqual(dims_mean_molecular_weight["[mass]"], 1.0) - self.assertEqual(dims_mean_molecular_weight["[substance]"], -1.0) - dims_min_temp = self.phase.min_temp.dimensionality - self.assertEqual(dims_min_temp["[temperature]"], 1.0) - dims_molecular_weights = self.phase.molecular_weights.dimensionality - self.assertEqual(dims_molecular_weights["[mass]"], 1.0) - self.assertEqual(dims_molecular_weights["[substance]"], -1.0) - dims_partial_molar_cp = self.phase.partial_molar_cp.dimensionality - self.assertEqual(dims_partial_molar_cp["[mass]"], 1.0) - self.assertEqual(dims_partial_molar_cp["[length]"], 2.0) - self.assertEqual(dims_partial_molar_cp["[time]"], -2.0) - self.assertEqual(dims_partial_molar_cp["[substance]"], -1.0) - self.assertEqual(dims_partial_molar_cp["[temperature]"], -1.0) - dims_partial_mol_enth = self.phase.partial_molar_enthalpies.dimensionality - self.assertEqual(dims_partial_mol_enth["[mass]"], 1.0) - self.assertEqual(dims_partial_mol_enth["[length]"], 2.0) - self.assertEqual(dims_partial_mol_enth["[time]"], -2.0) - self.assertEqual(dims_partial_mol_enth["[substance]"], -1.0) - dims_partial_molar_entropies = self.phase.partial_molar_entropies.dimensionality - self.assertEqual(dims_partial_molar_entropies["[mass]"], 1.0) - self.assertEqual(dims_partial_molar_entropies["[length]"], 2.0) - self.assertEqual(dims_partial_molar_entropies["[time]"], -2.0) - self.assertEqual(dims_partial_molar_entropies["[substance]"], -1.0) - self.assertEqual(dims_partial_molar_entropies["[temperature]"], -1.0) - dims_partial_mol_int_eng = self.phase.partial_molar_int_energies.dimensionality - self.assertEqual(dims_partial_mol_int_eng["[mass]"], 1.0) - self.assertEqual(dims_partial_mol_int_eng["[length]"], 2.0) - self.assertEqual(dims_partial_mol_int_eng["[time]"], -2.0) - self.assertEqual(dims_partial_mol_int_eng["[substance]"], -1.0) - dims_partial_molar_volumes = self.phase.partial_molar_volumes.dimensionality - self.assertEqual(dims_partial_molar_volumes["[length]"], 3.0) - self.assertEqual(dims_partial_molar_volumes["[substance]"], -1.0) - dims_reference_pressure = self.phase.reference_pressure.dimensionality - self.assertEqual(dims_reference_pressure["[mass]"], 1.0) - self.assertEqual(dims_reference_pressure["[length]"], -1.0) - self.assertEqual(dims_reference_pressure["[time]"], -2.0) - dims_thermal_expansion_coeff = self.phase.thermal_expansion_coeff.dimensionality - self.assertEqual(dims_thermal_expansion_coeff["[temperature]"], -1.0) - # basis-dependent (mass) - dims_density_mass = self.phase.density_mass.dimensionality - self.assertEqual(dims_density_mass["[mass]"], 1.0) - self.assertEqual(dims_density_mass["[length]"], -3.0) - dims_enthalpy_mass = self.phase.enthalpy_mass.dimensionality - self.assertEqual(dims_enthalpy_mass["[length]"], 2.0) - self.assertEqual(dims_enthalpy_mass["[time]"], -2.0) - dims_entropy_mass = self.phase.entropy_mass.dimensionality - self.assertEqual(dims_entropy_mass["[length]"], 2.0) - self.assertEqual(dims_entropy_mass["[time]"], -2.0) - self.assertEqual(dims_entropy_mass["[temperature]"], -1.0) - dims_int_energy_mass = self.phase.int_energy_mass.dimensionality - self.assertEqual(dims_int_energy_mass["[length]"], 2.0) - self.assertEqual(dims_int_energy_mass["[time]"], -2.0) - dims_volume_mass = self.phase.volume_mass.dimensionality - self.assertEqual(dims_volume_mass["[length]"], 3.0) - self.assertEqual(dims_volume_mass["[mass]"], -1.0) - dims_gibbs_mass = self.phase.gibbs_mass.dimensionality - self.assertEqual(dims_gibbs_mass["[length]"], 2.0) - self.assertEqual(dims_gibbs_mass["[time]"], -2.0) - dims_cp_mass = self.phase.cp_mass.dimensionality - self.assertEqual(dims_cp_mass["[length]"], 2.0) - self.assertEqual(dims_cp_mass["[time]"], -2.0) - self.assertEqual(dims_cp_mass["[temperature]"], -1.0) - dims_cv_mass = self.phase.cv.dimensionality - self.assertEqual(dims_cv_mass["[length]"], 2.0) - self.assertEqual(dims_cv_mass["[time]"], -2.0) - self.assertEqual(dims_cv_mass["[temperature]"], -1.0) - # basis-dependent (molar) - dims_density_mole = self.phase.density_mole.dimensionality - self.assertEqual(dims_density_mole["[substance]"], 1.0) - self.assertEqual(dims_density_mole["[length]"], -3.0) - dims_enthalpy_mole = self.phase.enthalpy_mole.dimensionality - self.assertEqual(dims_enthalpy_mole["[mass]"], 1.0) - self.assertEqual(dims_enthalpy_mole["[length]"], 2.0) - self.assertEqual(dims_enthalpy_mole["[time]"], -2.0) - self.assertEqual(dims_enthalpy_mole["[substance]"], -1.0) - dims_entropy_mole = self.phase.entropy_mole.dimensionality - self.assertEqual(dims_entropy_mole["[mass]"], 1.0) - self.assertEqual(dims_entropy_mole["[length]"], 2.0) - self.assertEqual(dims_entropy_mole["[time]"], -2.0) - self.assertEqual(dims_entropy_mole["[substance]"], -1.0) - self.assertEqual(dims_entropy_mole["[temperature]"], -1.0) - dims_int_energy_mole = self.phase.int_energy_mole.dimensionality - self.assertEqual(dims_int_energy_mole["[mass]"], 1.0) - self.assertEqual(dims_int_energy_mole["[length]"], 2.0) - self.assertEqual(dims_int_energy_mole["[time]"], -2.0) - self.assertEqual(dims_int_energy_mole["[substance]"], -1.0) - dims_volume_mole = self.phase.volume_mole.dimensionality - self.assertEqual(dims_volume_mole["[length]"], 3.0) - self.assertEqual(dims_volume_mole["[substance]"], -1.0) - dims_gibbs_mole = self.phase.gibbs_mole.dimensionality - self.assertEqual(dims_gibbs_mole["[mass]"], 1.0) - self.assertEqual(dims_gibbs_mole["[length]"], 2.0) - self.assertEqual(dims_gibbs_mole["[time]"], -2.0) - self.assertEqual(dims_gibbs_mole["[substance]"], -1.0) - dims_cp_mole = self.phase.cp_mole.dimensionality - self.assertEqual(dims_cp_mole["[mass]"], 1.0) - self.assertEqual(dims_cp_mole["[length]"], 2.0) - self.assertEqual(dims_cp_mole["[time]"], -2.0) - self.assertEqual(dims_cp_mole["[substance]"], -1.0) - self.assertEqual(dims_cp_mole["[temperature]"], -1.0) - dims_cv_mole = self.phase.cv_mole.dimensionality - self.assertEqual(dims_cv_mole["[mass]"], 1.0) - self.assertEqual(dims_cv_mole["[length]"], 2.0) - self.assertEqual(dims_cv_mole["[time]"], -2.0) - self.assertEqual(dims_cv_mole["[substance]"], -1.0) - self.assertEqual(dims_cv_mole["[temperature]"], -1.0) - - def check_setters(self, T1, rho1, Y1): - T0, rho0, Y0 = self.phase.TDY - self.phase.TDY = T1, rho1, Y1 - X1 = self.phase.X - P1 = self.phase.P - h1 = self.phase.h - s1 = self.phase.s - u1 = self.phase.u - v1 = self.phase.v - - def check_state(T, rho, Y): - self.assertQuantityNear(self.phase.T, T) - self.assertQuantityNear(self.phase.density, rho) - self.assertArrayQuantityNear(self.phase.Y, Y) - - self.phase.TDY = T0, rho0, Y0 - self.phase.TPY = T1, P1, Y1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.UVY = u1, v1, Y1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.HPY = h1, P1, Y1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.SPY = s1, P1, Y1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.TPX = T1, P1, X1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.UVX = u1, v1, X1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.HPX = h1, P1, X1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.SPX = s1, P1, X1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.SVX = s1, v1, X1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.SVY = s1, v1, Y1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.DPX = rho1, P1, X1 - check_state(T1, rho1, Y1) - - self.phase.TDY = T0, rho0, Y0 - self.phase.DPY = rho1, P1, Y1 - check_state(T1, rho1, Y1) - - def test_setState_mass(self): - Y1 = ct.Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless") - self.check_setters( - T1=ct.Q_(500.0, "K"), - rho1=ct.Q_(1.5, "kg/m**3"), - Y1=Y1, +from contextlib import nullcontext +import pytest +from dataclasses import dataclass +from typing import Optional, Tuple, Dict + +import cantera.with_units as ctu +from pint.testing import assert_allclose + + +@pytest.fixture(scope="function") +def ideal_gas(): + return ctu.Solution("h2o2.yaml") + + +@pytest.fixture( + scope="function", + params=( + pytest.param(("Water-Reynolds", None), id="Water-Reynolds"), + pytest.param(("Water-IAPWS95", None), id="Water-IAPWS95"), + pytest.param(("CarbonDioxide", None), id="CarbonDioxide"), + pytest.param(("Nitrogen", ctu.Q_(-160, "degC")), id="Nitrogen"), + pytest.param(("Methane", ctu.Q_(175, "K")), id="Methane"), + pytest.param(("Hydrogen", ctu.Q_(-250, "degC")), id="Hydrogen"), + pytest.param(("Oxygen", ctu.Q_(-150, "degC")), id="Oxygen"), + pytest.param(("Hfc134a", ctu.Q_(90, "degC")), id="Hfc134a"), + pytest.param(("Heptane", None), id="Heptane"), + ), +) +def pure_fluid(request): + if "Water" in request.param[0]: + _, backend = request.param[0].split("-") + fluid = ctu.Water(backend=backend) + else: + fluid = getattr(ctu, request.param[0])() + fluid.TQ = request.param[1], 0.5 * ctu.units.dimensionless + return fluid + + +@pytest.fixture(params=["ideal_gas", "pure_fluid"]) +def generic_phase(request): + if request.param == "ideal_gas": + return request.getfixturevalue(request.param) + elif request.param == "pure_fluid": + return ctu.Water() + + +def test_setting_basis_units_fails(generic_phase): + with pytest.raises(AttributeError, match="basis_units"): + generic_phase.basis_units = "some random string" + + +def test_mass_basis(generic_phase): + """Check that mass basis units have kg and the generic getter returns the same + value as the mass-specific getter.""" + generic_phase.basis = "mass" + assert generic_phase.basis_units == "kg" + assert_allclose(generic_phase.density_mass, generic_phase.density) + assert_allclose(generic_phase.enthalpy_mass, generic_phase.h) + assert_allclose(generic_phase.entropy_mass, generic_phase.s) + assert_allclose(generic_phase.int_energy_mass, generic_phase.u) + assert_allclose(generic_phase.volume_mass, generic_phase.v) + assert_allclose(generic_phase.gibbs_mass, generic_phase.g) + assert_allclose(generic_phase.cp_mass, generic_phase.cp) + assert_allclose(generic_phase.cv_mass, generic_phase.cv) + + +def test_molar_basis(generic_phase): + """Check that molar basis units have kmol and the generic getter returns the + same value as the molar-specific getter.""" + generic_phase.basis = "molar" + assert generic_phase.basis_units == "kmol" + assert_allclose(generic_phase.density_mole, generic_phase.density) + assert_allclose(generic_phase.enthalpy_mole, generic_phase.h) + assert_allclose(generic_phase.entropy_mole, generic_phase.s) + assert_allclose(generic_phase.int_energy_mole, generic_phase.u) + assert_allclose(generic_phase.volume_mole, generic_phase.v) + assert_allclose(generic_phase.gibbs_mole, generic_phase.g) + assert_allclose(generic_phase.cp_mole, generic_phase.cp) + assert_allclose(generic_phase.cv_mole, generic_phase.cv) + + +@dataclass(frozen=True) +class Dimensions: + mass: Optional[float] = None + length: Optional[float] = None + time: Optional[float] = None + substance: Optional[float] = None + temperature: Optional[float] = None + current: Optional[float] = None + dimensions: Tuple[str, ...] = ("mass", "length", "time", "substance", "temperature", "current") + + def get_dict(self) -> Dict[str, float]: + """Add the square brackets around the dimension for comparison with pint""" + dimensionality = {} + for dimension in self.dimensions: + value = getattr(self, dimension) + if value is not None: + dimensionality[f"[{dimension}]"] = value + return dimensionality + + +def check_dimensions(phase, property, dimensions): + """Check that the dimensionality of the given property is correct""" + assert dict(getattr(phase, property).dimensionality) == dimensions.get_dict() + + +def test_base_independent_dimensions(generic_phase): + """Test that the dimensions of base-independent quantities match expectations.""" + temperature = Dimensions(temperature=1) + check_dimensions(generic_phase, "T", temperature) + check_dimensions(generic_phase, "max_temp", temperature) + check_dimensions(generic_phase, "min_temp", temperature) + + check_dimensions(generic_phase, "thermal_expansion_coeff", Dimensions(temperature=-1)) + + pressure = Dimensions(mass=1, length=-1, time=-2) + check_dimensions(generic_phase, "P", pressure) + check_dimensions(generic_phase, "reference_pressure", pressure) + + atomic_molecular_weights = Dimensions(mass=1, substance=-1) + check_dimensions(generic_phase, "atomic_weight", atomic_molecular_weights) + check_dimensions(generic_phase, "molecular_weights", atomic_molecular_weights) + check_dimensions(generic_phase, "mean_molecular_weight", atomic_molecular_weights) + assert not generic_phase.X.dimensionality + assert not generic_phase.Y.dimensionality + + +def test_dimensions(generic_phase): + chemical_potential = Dimensions(mass=1, length=2, time=-2, substance=-1) + check_dimensions(generic_phase, "chemical_potentials", chemical_potential) + check_dimensions(generic_phase, "electrochemical_potentials", chemical_potential) + + electric_potential = Dimensions(mass=1, length=2, time=-3, current=-1) + check_dimensions(generic_phase, "electric_potential", electric_potential) + + concentrations_like = Dimensions(substance=1, length=-3) + check_dimensions(generic_phase, "concentrations", concentrations_like) + check_dimensions(generic_phase, "density_mole", concentrations_like) + + check_dimensions(generic_phase, "volume_mole", Dimensions(substance=-1, length=3)) + + check_dimensions(generic_phase, "density_mass", Dimensions(mass=1, length=-3)) + check_dimensions(generic_phase, "volume_mass", Dimensions(length=3, mass=-1)) + + isothermal_compressibility = Dimensions(mass=-1, length=1, time=2) + check_dimensions( + generic_phase, "isothermal_compressibility", isothermal_compressibility + ) + + partial_molar_inv_T = Dimensions(mass=1, length=2, time=-2, substance=-1, + temperature=-1) + check_dimensions(generic_phase, "partial_molar_cp", partial_molar_inv_T) + check_dimensions(generic_phase, "partial_molar_entropies", partial_molar_inv_T) + + partial_molar_energy_like = Dimensions(mass=1, length=2, time=-2, substance=-1) + check_dimensions(generic_phase, "partial_molar_enthalpies", partial_molar_energy_like) + check_dimensions(generic_phase, "partial_molar_int_energies", partial_molar_energy_like) + + partial_molar_volume = Dimensions(length=3, substance=-1) + check_dimensions(generic_phase, "partial_molar_volumes", partial_molar_volume) + + mass_basis_energy_like = Dimensions(length=2, time=-2) + check_dimensions(generic_phase, "enthalpy_mass", mass_basis_energy_like) + check_dimensions(generic_phase, "int_energy_mass", mass_basis_energy_like) + check_dimensions(generic_phase, "gibbs_mass", mass_basis_energy_like) + + mass_basis_entropy_like = Dimensions(length=2, time=-2, temperature=-1) + check_dimensions(generic_phase, "entropy_mass", mass_basis_entropy_like) + check_dimensions(generic_phase, "cp_mass", mass_basis_entropy_like) + check_dimensions(generic_phase, "cv_mass", mass_basis_entropy_like) + + molar_basis_energy_like = Dimensions(mass=1, length=2, time=-2, substance=-1) + check_dimensions(generic_phase, "enthalpy_mole", molar_basis_energy_like) + check_dimensions(generic_phase, "int_energy_mole", molar_basis_energy_like) + check_dimensions(generic_phase, "gibbs_mole", molar_basis_energy_like) + + molar_basis_entropy_like = Dimensions(mass=1, length=2, time=-2, substance=-1, + temperature=-1) + check_dimensions(generic_phase, "entropy_mole", molar_basis_entropy_like) + check_dimensions(generic_phase, "cp_mole", molar_basis_entropy_like) + check_dimensions(generic_phase, "cv_mole", molar_basis_entropy_like) + + +def test_purefluid_dimensions(): + # Test some dimensions that weren't tested as part of the Solution tests + # Create and test a liquidvapor phase in a Solution object, since an ideal gas phase + # doesn't implement saturation or critical properties. + heptane_solution = ctu.Solution("liquidvapor.yaml", "heptane") + water_purefluid = ctu.Water() + temperature = Dimensions(temperature=1) + check_dimensions(water_purefluid, "T_sat", temperature) + check_dimensions(water_purefluid, "critical_temperature", temperature) + check_dimensions(heptane_solution, "T_sat", temperature) + check_dimensions(heptane_solution, "critical_temperature", temperature) + + pressure = Dimensions(mass=1, length=-1, time=-2) + check_dimensions(water_purefluid, "P_sat", pressure) + check_dimensions(water_purefluid, "critical_pressure", pressure) + check_dimensions(heptane_solution, "P_sat", pressure) + check_dimensions(heptane_solution, "critical_pressure", pressure) + + density = Dimensions(mass=1, length=-3) + check_dimensions(water_purefluid, "critical_density", density) + check_dimensions(heptane_solution, "critical_density", density) + + +def yield_prop_pairs(): + pairs = [ + pytest.param(("TP", "T", "P"), id="TP"), + pytest.param(("SP", "s", "P"), id="SP"), + pytest.param(("UV", "u", "v"), id="UV"), + pytest.param(("DP", "density", "P"), id="DP"), + pytest.param(("HP", "h", "P"), id="HP"), + pytest.param(("SV", "s", "v"), id="SV"), + pytest.param(("TD", "T", "density"), id="TD"), + ] + yield from pairs + + +def yield_prop_triples(): + for pair in yield_prop_pairs(): + values = pair.values[0] + yield pytest.param( + (values[0] + "X", *values[1:], "X"), + id=pair.id + "X", + ) + yield pytest.param( + (values[0] + "Y", *values[1:], "Y"), + id=pair.id + "Y", ) - def test_setState_mole(self): - self.phase.basis = "molar" - Y1 = ct.Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless") - self.check_setters( - T1=ct.Q_(750.0, "K"), - rho1=ct.Q_(0.02, "kmol/m**3"), - Y1=Y1, + +def yield_prop_pairs_and_triples(): + yield from yield_prop_pairs() + yield from yield_prop_triples() + + +@pytest.fixture +def test_properties(request): + if request.param == "mass": + T = ctu.Q_(500, "K") + rho = ctu.Q_(1.5, "kg/m**3") + elif request.param == "molar": + T = ctu.Q_(750, "K") + rho = ctu.Q_(0.02, "kmol/m**3") + return (T, rho) + + +@pytest.fixture +def initial_TDY(request, generic_phase): + generic_phase.basis = request.param + return generic_phase.TDY + + +@pytest.fixture +def some_setters_arent_implemented_for_purefluid(request): + pair_or_triple = request.getfixturevalue("props")[0] + is_pure_fluid = isinstance(request.getfixturevalue("generic_phase"), ctu.PureFluid) + if is_pure_fluid and pair_or_triple.startswith("DP"): + request.applymarker( + pytest.mark.xfail( + raises=ctu.CanteraError, + reason=f"The {pair_or_triple} method isn't implemented" + ) ) - def test_setters_hold_constant(self): - props = ("T", "P", "s", "h", "u", "v", "X", "Y") - pairs = [("TP", "T", "P"), ("SP", "s", "P"), - ("UV", "u", "v")] - self.phase.X = "H2O:0.1, O2:0.95, AR:3.0" - self.phase.TD = ct.Q_(1000, "K"), ct.Q_(1.5, "kg/m**3") - values = {} - for p in props: - values[p] = getattr(self.phase, p) +# The parameterization is done here with the indirect kwarg to make sure that the same +# basis is passed to both fixtures. The alternative is to use the params kwarg to the +# fixture decorator, which would give us (mass, molar) basis pairs, and that doesn't +# make sense. +@pytest.mark.parametrize( + "test_properties,initial_TDY", + [ + pytest.param("mass", "mass", id="mass"), + pytest.param("molar", "molar", id="molar"), + ], + indirect=True, +) +@pytest.mark.parametrize("props", yield_prop_pairs_and_triples()) +@pytest.mark.usefixtures("some_setters_arent_implemented_for_purefluid") +def test_setters(generic_phase, test_properties, initial_TDY, props): + pair_or_triple = props[0] + if isinstance(generic_phase, ctu.PureFluid): + Y_1 = ctu.Q_([1.0], "dimensionless") + generic_phase.TD = test_properties + else: + Y_1 = ctu.Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], + "dimensionless") + generic_phase.TDY = *test_properties, Y_1 + print(generic_phase.Y) + + # Use TDY setting to get the properties at the modified state + new_props = getattr(generic_phase, pair_or_triple) + + # Reset to the initial state so that the next state setting actually has to do + # something. + generic_phase.TDY = initial_TDY + + # If we're only setting a pair of properties, reset the mass fractions to the + # expected state before using the pair to set. + if len(pair_or_triple) == 2: + generic_phase.Y = Y_1 + + # Use the test pair or triple to set the state and assert that the + # natural properties are equal to the modified state + setattr(generic_phase, pair_or_triple, new_props) + T_1, rho_1 = test_properties + assert_allclose(generic_phase.T, T_1) + assert_allclose(generic_phase.density, rho_1) + assert_allclose(generic_phase.Y, Y_1) + + +@pytest.mark.parametrize("props", yield_prop_triples()) +@pytest.mark.usefixtures("some_setters_arent_implemented_for_purefluid") +def test_setters_hold_constant(generic_phase, props): + triple, first, second, third = props + + # Set an arbitrary initial state + if generic_phase.n_species == 1: + generic_phase.X = ctu.Q_([1.0], "dimensionless") + composition = ctu.Q_([1.0], "dimensionless") + else: + generic_phase.X = "H2O:0.1, O2:0.95, AR:3.0" composition = "H2:0.1, O2:1.0, AR:3.0" - for pair, first, second in pairs: - self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition - first_val = getattr(self.phase, first) - second_val = getattr(self.phase, second) - - setattr(self.phase, pair, (values[first], None)) - self.assertQuantityNear(getattr(self.phase, first), values[first]) - self.assertQuantityNear(getattr(self.phase, second), second_val) - - self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition - setattr(self.phase, pair, (None, values[second])) - self.assertQuantityNear(getattr(self.phase, first), first_val) - self.assertQuantityNear(getattr(self.phase, second), values[second]) - - self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition - setattr(self.phase, pair + "X", (None, None, values["X"])) - self.assertQuantityNear(getattr(self.phase, first), first_val) - self.assertQuantityNear(getattr(self.phase, second), second_val) - - self.phase.TDX = ct.Q_(500, "K"), ct.Q_(2.5, "kg/m**3"), composition - setattr(self.phase, pair + "Y", (None, None, values["Y"])) - self.assertQuantityNear(getattr(self.phase, first), first_val) - self.assertQuantityNear(getattr(self.phase, second), second_val) - - def check_getters(self): - T, D, X = self.phase.TDX - self.assertQuantityNear(T, self.phase.T) - self.assertQuantityNear(D, self.phase.density) - self.assertArrayQuantityNear(X, self.phase.X) - - T, D, Y = self.phase.TDY - self.assertQuantityNear(T, self.phase.T) - self.assertQuantityNear(D, self.phase.density) - self.assertArrayQuantityNear(Y, self.phase.Y) - - T, D = self.phase.TD - self.assertQuantityNear(T, self.phase.T) - self.assertQuantityNear(D, self.phase.density) - - T, P, X = self.phase.TPX - self.assertQuantityNear(T, self.phase.T) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(X, self.phase.X) - - T, P, Y = self.phase.TPY - self.assertQuantityNear(T, self.phase.T) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(Y, self.phase.Y) - - T, P = self.phase.TP - self.assertQuantityNear(T, self.phase.T) - self.assertQuantityNear(P, self.phase.P) - - H, P, X = self.phase.HPX - self.assertQuantityNear(H, self.phase.h) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(X, self.phase.X) - - H, P, Y = self.phase.HPY - self.assertQuantityNear(H, self.phase.h) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(Y, self.phase.Y) - - H, P = self.phase.HP - self.assertQuantityNear(H, self.phase.h) - self.assertQuantityNear(P, self.phase.P) - - U, V, X = self.phase.UVX - self.assertQuantityNear(U, self.phase.u) - self.assertQuantityNear(V, self.phase.v) - self.assertArrayQuantityNear(X, self.phase.X) - - U, V, Y = self.phase.UVY - self.assertQuantityNear(U, self.phase.u) - self.assertQuantityNear(V, self.phase.v) - self.assertArrayQuantityNear(Y, self.phase.Y) - - U, V = self.phase.UV - self.assertQuantityNear(U, self.phase.u) - self.assertQuantityNear(V, self.phase.v) - - S, P, X = self.phase.SPX - self.assertQuantityNear(S, self.phase.s) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(X, self.phase.X) - - S, P, Y = self.phase.SPY - self.assertQuantityNear(S, self.phase.s) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(Y, self.phase.Y) - - S, P = self.phase.SP - self.assertQuantityNear(S, self.phase.s) - self.assertQuantityNear(P, self.phase.P) - - S, V, X = self.phase.SVX - self.assertQuantityNear(S, self.phase.s) - self.assertQuantityNear(V, self.phase.v) - self.assertArrayQuantityNear(X, self.phase.X) - - S, V, Y = self.phase.SVY - self.assertQuantityNear(S, self.phase.s) - self.assertQuantityNear(V, self.phase.v) - self.assertArrayQuantityNear(Y, self.phase.Y) - - S, V = self.phase.SV - self.assertQuantityNear(S, self.phase.s) - self.assertQuantityNear(V, self.phase.v) - - D, P, X = self.phase.DPX - self.assertQuantityNear(D, self.phase.density) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(X, self.phase.X) - - D, P, Y = self.phase.DPY - self.assertQuantityNear(D, self.phase.density) - self.assertQuantityNear(P, self.phase.P) - self.assertArrayQuantityNear(Y, self.phase.Y) - - D, P = self.phase.DP - self.assertQuantityNear(D, self.phase.density) - self.assertQuantityNear(P, self.phase.P) - - def test_getState_mass(self): - self.phase.Y = "H2:0.1, H2O2:0.1, AR:0.8" - self.phase.TD = ct.Q_(350.0, "K"), ct.Q_(0.7, "kg/m**3") - self.check_getters() - - def test_getState_mole(self): - self.phase.basis = "molar" - self.phase.X = "H2:0.1, O2:0.3, AR:0.6" - self.phase.TD = ct.Q_(350.0, "K"), ct.Q_(0.01, "kmol/m**3") - self.check_getters() - - def test_isothermal_compressibility(self): - self.assertQuantityNear(self.phase.isothermal_compressibility, 1.0/self.phase.P) - - -class TestPureFluidUnits(CanteraUnitsTest): - def setUp(self): - self.water = ct.Water() - - def test_dimensions_purefluid(self): - """These properties are not defined on the IdealGas phase class, - so they can"t be tested in the Solution for the h2o2.yaml input file. - """ - dims_T_sat = self.water.T_sat.dimensionality - self.assertEqual(dims_T_sat["[temperature]"], 1.0) - dims_P_sat = self.water.P_sat.dimensionality - self.assertEqual(dims_P_sat["[mass]"], 1.0) - self.assertEqual(dims_P_sat["[length]"], -1.0) - self.assertEqual(dims_P_sat["[time]"], -2.0) - dims_critical_temperature = self.water.critical_temperature.dimensionality - self.assertEqual(dims_critical_temperature["[temperature]"], 1.0) - dims_critical_pressure = self.water.critical_pressure.dimensionality - self.assertEqual(dims_critical_pressure["[mass]"], 1.0) - self.assertEqual(dims_critical_pressure["[length]"], -1.0) - self.assertEqual(dims_critical_pressure["[time]"], -2.0) - - def test_critical_properties(self): - self.assertQuantityNear(self.water.critical_pressure, 22.089e6 * ct.units.Pa) - self.assertQuantityNear(self.water.critical_temperature, 647.286 * ct.units.K) - self.assertQuantityNear(self.water.critical_density, ct.Q_(317.0, "kg/m**3")) - - def test_temperature_limits(self): - co2 = ct.CarbonDioxide() - self.assertQuantityNear(co2.min_temp, 216.54 * ct.units.K) - self.assertQuantityNear(co2.max_temp, 1500.0 * ct.units.K) - - def test_set_state(self): - self.water.PQ = ct.Q_(101325, "Pa"), ct.Q_(0.5, "dimensionless") - self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) - self.assertQuantityNear(self.water.Q, 0.5 * ct.units.dimensionless) - - self.water.TQ = ct.Q_(500, "K"), ct.Q_(0.8, "dimensionless") - self.assertQuantityNear(self.water.T, 500 * ct.units.K) - self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) - - def test_substance_set(self): - self.water.TV = ct.Q_(400, "K"), ct.Q_(1.45, "m**3/kg") - self.assertQuantityNear(self.water.T, 400 * ct.units.K) - self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) - with self.assertRaisesRegex(ct.CanteraError, "Negative specific volume"): - self.water.TV = ct.Q_(300, "K"), ct.Q_(-1, "m**3/kg") - - self.water.PV = ct.Q_(101325, "Pa"), ct.Q_(1.45, "m**3/kg") - self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) - self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) - - self.water.UP = ct.Q_(-1.45e7, "J/kg"), ct.Q_(101325, "Pa") - self.assertQuantityNear(self.water.u, -1.45e7 * ct.units.J / ct.units.kg) - self.assertQuantityNear(self.water.P, 101325 * ct.units.Pa) - - self.water.VH = ct.Q_(1.45, "m**3/kg"), ct.Q_(-1.45e7, "J/kg") - self.assertQuantityNear(self.water.v, 1.45 * ct.units.m**3 / ct.units.kg) - self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - - self.water.TH = ct.Q_(400, "K"), ct.Q_(-1.45e7, "J/kg") - self.assertQuantityNear(self.water.T, 400 * ct.units.K) - self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - - self.water.SH = ct.Q_(5000, "J/kg/K"), ct.Q_(-1.45e7, "J/kg") - self.assertQuantityNear(self.water.s, 5000 * ct.units("J/kg/K")) - self.assertQuantityNear(self.water.h, -1.45e7 * ct.units.J / ct.units.kg) - - self.water.ST = ct.Q_(5000, "J/kg/K"), ct.Q_(400, "K") - self.assertQuantityNear(self.water.s, 5000 * ct.units("J/kg/K")) - self.assertQuantityNear(self.water.T, 400 * ct.units.K) - - def test_set_Q(self): - self.water.TQ = ct.Q_(500, "K"), ct.Q_(0.0, "dimensionless") - p = self.water.P - self.water.Q = ct.Q_(0.8, "dimensionless") - self.assertQuantityNear(self.water.P, p) - self.assertQuantityNear(self.water.T, 500 * ct.units.K) - self.assertQuantityNear(self.water.Q, 0.8 * ct.units.dimensionless) - - self.water.TP = ct.Q_(650, "K"), ct.Q_(101325, "Pa") - with self.assertRaises(ct.CanteraError): - self.water.Q = ct.Q_(0.1, "dimensionless") - - self.water.TP = ct.Q_(300, "K"), ct.Q_(101325, "Pa") - with self.assertRaises(ValueError): - self.water.Q = ct.Q_(0.3, "dimensionless") + generic_phase.TD = ctu.Q_(1000, "K"), ctu.Q_(1.5, "kg/m**3") + property_3 = getattr(generic_phase, third) + + # Change to another arbitrary state and store values to compare when that spot + # isn't changed + reset_state = (ctu.Q_(500, "K"), ctu.Q_(2.5, "kg/m**3"), composition) + generic_phase.TDX = reset_state + first_val, second_val, third_val = getattr(generic_phase, triple) + + setattr(generic_phase, triple, (None, None, property_3)) + assert_allclose(getattr(generic_phase, first), first_val) + assert_allclose(getattr(generic_phase, second), second_val) + assert_allclose(getattr(generic_phase, third), property_3) + + generic_phase.TDX = reset_state + setattr(generic_phase, triple, (None, None, None)) + assert_allclose(getattr(generic_phase, first), first_val) + assert_allclose(getattr(generic_phase, second), second_val) + assert_allclose(getattr(generic_phase, third), third_val) + + +@pytest.mark.parametrize("props", yield_prop_pairs_and_triples()) +@pytest.mark.parametrize( + "basis,rho_0", + [ + pytest.param("mass", ctu.Q_(0.7, "kg/m**3"), id="mass"), + pytest.param("molar", ctu.Q_(0.01, "kmol/m**3"), id="molar"), + ], +) +def test_multi_prop_getters_are_equal_to_single(generic_phase, props, basis, rho_0): + pair_or_triple, first, second, *third = props + generic_phase.basis = basis + if generic_phase.n_species != 1: + generic_phase.Y = "H2:0.1, H2O2:0.1, AR:0.8" + generic_phase.TD = ctu.Q_(350.0, "K"), rho_0 + first_value, second_value, *third_value = getattr(generic_phase, pair_or_triple) + assert_allclose(getattr(generic_phase, first), first_value) + assert_allclose(getattr(generic_phase, second), second_value) + if third: + assert_allclose(getattr(generic_phase, third[0]), third_value[0]) + + +@pytest.mark.parametrize("pair", yield_prop_pairs()) +def test_set_pair_without_units_is_an_error(generic_phase, pair): + value_1 = [300, None] + with pytest.raises(ctu.CanteraError, match="an instance of a pint"): + setattr(generic_phase, pair[0], value_1) + + +@pytest.fixture +def third_prop_sometimes_fails(request): + # Setting these property triples with None, None, [all equal fractions] results in + # a convergence error in setting the state. We don't care that it fails because + # this is just testing what happens to the value that gets passed in to the + # with_units setters in terms of conversion to/from values that don't have units + # already attached to them. + prop_triple = request.getfixturevalue("triple")[0] + is_purefluid = isinstance(request.getfixturevalue("generic_phase"), ctu.PureFluid) + if prop_triple in ("SPX", "UVX", "UVY", "HPX", "HPY", "SVX") and not is_purefluid: + return pytest.raises(ctu.CanteraError, match="ThermoPhase::setState") + elif prop_triple in ("DPX", "DPY") and is_purefluid: + return pytest.raises(ctu.CanteraError, match="setState_RP") + else: + return nullcontext() + +@pytest.mark.parametrize("triple", yield_prop_triples()) +def test_set_triple_without_units_is_an_error( + generic_phase, + triple, + third_prop_sometimes_fails, +): + value_1 = [300, None, [1]*generic_phase.n_species] + with pytest.raises(ctu.CanteraError, match="an instance of a pint"): + setattr(generic_phase, triple[0], value_1) + + value_3 = [None, None, [1]*generic_phase.n_species] + with third_prop_sometimes_fails: + setattr(generic_phase, triple[0], value_3) + + +@pytest.fixture +def xfail_heptane(request): + if request.getfixturevalue("pure_fluid").name == "heptane": + request.applymarker( + pytest.mark.xfail( + raises=ctu.CanteraError, + reason="Convergence failure for P_sat/Q solver used by Q-only setter", + strict=True, + ) + ) + + +@pytest.mark.usefixtures("xfail_heptane") +def test_set_Q(pure_fluid): + p = pure_fluid.P + T = pure_fluid.T + pure_fluid.Q = ctu.Q_(0.6, "dimensionless") + assert_allclose(pure_fluid.Q, 0.6 * ctu.units.dimensionless) + assert_allclose(pure_fluid.T, T) + assert_allclose(pure_fluid.P, p) + + pure_fluid.Q = None + assert_allclose(pure_fluid.Q, 0.6 * ctu.units.dimensionless) + assert_allclose(pure_fluid.T, T) + assert_allclose(pure_fluid.P, p) + + with pytest.raises(ctu.CanteraError, match="an instance of a pint"): + pure_fluid.Q = 0.5 + + +def yield_purefluid_only_setters(): + props = [ + pytest.param(("TPQ", "T", "P", "Q"), id="TPQ"), + pytest.param(("TQ", "T", "Q"), id="TQ"), + pytest.param(("PQ", "P", "Q"), id="PQ"), + pytest.param(("PV", "P", "v"), id="PV"), + pytest.param(("SH", "s", "h"), id="SH"), + pytest.param(("ST", "s", "T"), id="ST"), + pytest.param(("TH", "T", "h"), id="TH"), + pytest.param(("TV", "T", "v"), id="TV"), + pytest.param(("VH", "v", "h"), id="VH"), + pytest.param(("UP", "u", "P"), id="UP"), + ] + yield from props + + +def yield_purefluid_only_getters(): + props = [ + pytest.param(("DPQ", "density", "P", "Q"), id="DPQ"), + pytest.param(("HPQ", "h", "P", "Q"), id="HPQ"), + pytest.param(("SPQ", "s", "P", "Q"), id="SPQ"), + pytest.param(("SVQ", "s", "v", "Q"), id="SVQ"), + pytest.param(("TDQ", "T", "density", "Q"), id="TDQ"), + pytest.param(("UVQ", "u", "v", "Q"), id="UVQ"), + ] + yield from props + + +def yield_all_purefluid_only_props(): + yield from yield_purefluid_only_setters() + yield from yield_purefluid_only_getters() + + +@pytest.mark.parametrize("prop", yield_purefluid_only_setters()) +def test_set_without_units_is_error_purefluid(prop): + # We only need to run this test for one PureFluid, not all of them + water = ctu.Water() + value = [None] * (len(prop[0]) - 1) + [0.5] + with pytest.raises(ctu.CanteraError, match="an instance of a pint"): + # Don't use append here, because append returns None which would be + # passed to setattr + setattr(water, prop[0], value) + + +@pytest.mark.parametrize("props", yield_all_purefluid_only_props()) +def test_multi_prop_getters_purefluid(pure_fluid, props): + pair_or_triple, first, second, *third = props + first_value, second_value, *third_value = getattr(pure_fluid, pair_or_triple) + assert_allclose(getattr(pure_fluid, first), first_value) + assert_allclose(getattr(pure_fluid, second), second_value) + if third: + assert_allclose(getattr(pure_fluid, third[0]), third_value[0]) + + +@pytest.mark.parametrize("props", yield_purefluid_only_setters()) +def test_setters_purefluid(props): + # Only need to run this for a single pure fluid + pure_fluid = ctu.Water() + initial_TD = pure_fluid.TD + pair_or_triple = props[0] + + T_1 = ctu.Q_(500, "K") + if pair_or_triple in ("SH", "TH"): + # This state is able to converge for these setters, whereas the state below + # does not converge + rho_1 = ctu.Q_(1000, "kg/m**3") + else: + # This state is located inside the vapor dome to be able to test the + # TQ and PQ setters + rho_1 = ctu.Q_(25.93245092697775, "kg/m**3") + + # Use TD setting to get the properties at the modified state + pure_fluid.TD = T_1, rho_1 + new_props = getattr(pure_fluid, pair_or_triple) + print(new_props) + + # Reset to the initial state so that the next state setting actually has to do + # something. + pure_fluid.TD = initial_TD + + # Use the test pair or triple to set the state and assert that the + # natural properties are equal to the modified state + setattr(pure_fluid, pair_or_triple, new_props) + assert_allclose(pure_fluid.T, T_1) + assert_allclose(pure_fluid.density, rho_1) + + +@pytest.mark.parametrize("prop", ("X", "Y")) +def test_X_Y_setters_with_none(generic_phase, prop): + comparison = getattr(generic_phase, prop) + setattr(generic_phase, prop, None) + # Assert that the value hasn't changed + assert_allclose(comparison, getattr(generic_phase, prop)) + + +@pytest.mark.parametrize("prop", ("X", "Y")) +def test_X_Y_setters_without_units_works(generic_phase, prop): + composition = f"{generic_phase.species_names[0]}:1" + setattr(generic_phase, prop, composition) + assert_allclose(getattr(generic_phase, prop)[0], ctu.Q_([1], "dimensionless")) From 7e89b03e4069e1711af0d9d911b898e629a39830 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 16 Oct 2022 22:02:51 -0400 Subject: [PATCH 25/41] Refactor dimensions tests --- test/python/test_units.py | 315 ++++++++++++++++++-------------------- 1 file changed, 148 insertions(+), 167 deletions(-) diff --git a/test/python/test_units.py b/test/python/test_units.py index 3af2804828..3e6d67ed20 100644 --- a/test/python/test_units.py +++ b/test/python/test_units.py @@ -12,36 +12,14 @@ def ideal_gas(): return ctu.Solution("h2o2.yaml") -@pytest.fixture( - scope="function", - params=( - pytest.param(("Water-Reynolds", None), id="Water-Reynolds"), - pytest.param(("Water-IAPWS95", None), id="Water-IAPWS95"), - pytest.param(("CarbonDioxide", None), id="CarbonDioxide"), - pytest.param(("Nitrogen", ctu.Q_(-160, "degC")), id="Nitrogen"), - pytest.param(("Methane", ctu.Q_(175, "K")), id="Methane"), - pytest.param(("Hydrogen", ctu.Q_(-250, "degC")), id="Hydrogen"), - pytest.param(("Oxygen", ctu.Q_(-150, "degC")), id="Oxygen"), - pytest.param(("Hfc134a", ctu.Q_(90, "degC")), id="Hfc134a"), - pytest.param(("Heptane", None), id="Heptane"), - ), -) -def pure_fluid(request): - if "Water" in request.param[0]: - _, backend = request.param[0].split("-") - fluid = ctu.Water(backend=backend) - else: - fluid = getattr(ctu, request.param[0])() - fluid.TQ = request.param[1], 0.5 * ctu.units.dimensionless - return fluid +@pytest.fixture(scope="function") +def pure_fluid(): + return ctu.Water() @pytest.fixture(params=["ideal_gas", "pure_fluid"]) def generic_phase(request): - if request.param == "ideal_gas": - return request.getfixturevalue(request.param) - elif request.param == "pure_fluid": - return ctu.Water() + return request.getfixturevalue(request.param) def test_setting_basis_units_fails(generic_phase): @@ -81,15 +59,35 @@ def test_molar_basis(generic_phase): @dataclass(frozen=True) class Dimensions: + name: str mass: Optional[float] = None length: Optional[float] = None time: Optional[float] = None substance: Optional[float] = None temperature: Optional[float] = None current: Optional[float] = None - dimensions: Tuple[str, ...] = ("mass", "length", "time", "substance", "temperature", "current") + dimensions: Tuple[str, ...] = ( + "mass", + "length", + "time", + "substance", + "temperature", + "current", + ) + + def __str__(self): + return self.name - def get_dict(self) -> Dict[str, float]: + def inverse(self, name: Optional[str] = None) -> "Dimensions": + dimensionality = {} + for dimension in self.dimensions: + value = getattr(self, dimension) + if value is not None: + dimensionality[dimension] = -1 * value + new_name = name if name is not None else f"inverse_{self.name}" + return Dimensions(name=new_name, **dimensionality) + + def as_dict(self) -> Dict[str, float]: """Add the square brackets around the dimension for comparison with pint""" dimensionality = {} for dimension in self.dimensions: @@ -99,109 +97,103 @@ def get_dict(self) -> Dict[str, float]: return dimensionality -def check_dimensions(phase, property, dimensions): - """Check that the dimensionality of the given property is correct""" - assert dict(getattr(phase, property).dimensionality) == dimensions.get_dict() - - -def test_base_independent_dimensions(generic_phase): - """Test that the dimensions of base-independent quantities match expectations.""" - temperature = Dimensions(temperature=1) - check_dimensions(generic_phase, "T", temperature) - check_dimensions(generic_phase, "max_temp", temperature) - check_dimensions(generic_phase, "min_temp", temperature) - - check_dimensions(generic_phase, "thermal_expansion_coeff", Dimensions(temperature=-1)) - - pressure = Dimensions(mass=1, length=-1, time=-2) - check_dimensions(generic_phase, "P", pressure) - check_dimensions(generic_phase, "reference_pressure", pressure) - - atomic_molecular_weights = Dimensions(mass=1, substance=-1) - check_dimensions(generic_phase, "atomic_weight", atomic_molecular_weights) - check_dimensions(generic_phase, "molecular_weights", atomic_molecular_weights) - check_dimensions(generic_phase, "mean_molecular_weight", atomic_molecular_weights) - assert not generic_phase.X.dimensionality - assert not generic_phase.Y.dimensionality - - -def test_dimensions(generic_phase): - chemical_potential = Dimensions(mass=1, length=2, time=-2, substance=-1) - check_dimensions(generic_phase, "chemical_potentials", chemical_potential) - check_dimensions(generic_phase, "electrochemical_potentials", chemical_potential) - - electric_potential = Dimensions(mass=1, length=2, time=-3, current=-1) - check_dimensions(generic_phase, "electric_potential", electric_potential) - - concentrations_like = Dimensions(substance=1, length=-3) - check_dimensions(generic_phase, "concentrations", concentrations_like) - check_dimensions(generic_phase, "density_mole", concentrations_like) - - check_dimensions(generic_phase, "volume_mole", Dimensions(substance=-1, length=3)) - - check_dimensions(generic_phase, "density_mass", Dimensions(mass=1, length=-3)) - check_dimensions(generic_phase, "volume_mass", Dimensions(length=3, mass=-1)) - - isothermal_compressibility = Dimensions(mass=-1, length=1, time=2) - check_dimensions( - generic_phase, "isothermal_compressibility", isothermal_compressibility - ) - - partial_molar_inv_T = Dimensions(mass=1, length=2, time=-2, substance=-1, - temperature=-1) - check_dimensions(generic_phase, "partial_molar_cp", partial_molar_inv_T) - check_dimensions(generic_phase, "partial_molar_entropies", partial_molar_inv_T) - - partial_molar_energy_like = Dimensions(mass=1, length=2, time=-2, substance=-1) - check_dimensions(generic_phase, "partial_molar_enthalpies", partial_molar_energy_like) - check_dimensions(generic_phase, "partial_molar_int_energies", partial_molar_energy_like) - - partial_molar_volume = Dimensions(length=3, substance=-1) - check_dimensions(generic_phase, "partial_molar_volumes", partial_molar_volume) - - mass_basis_energy_like = Dimensions(length=2, time=-2) - check_dimensions(generic_phase, "enthalpy_mass", mass_basis_energy_like) - check_dimensions(generic_phase, "int_energy_mass", mass_basis_energy_like) - check_dimensions(generic_phase, "gibbs_mass", mass_basis_energy_like) - - mass_basis_entropy_like = Dimensions(length=2, time=-2, temperature=-1) - check_dimensions(generic_phase, "entropy_mass", mass_basis_entropy_like) - check_dimensions(generic_phase, "cp_mass", mass_basis_entropy_like) - check_dimensions(generic_phase, "cv_mass", mass_basis_entropy_like) +temperature = Dimensions(temperature=1, name="temperature") +pressure = Dimensions(mass=1, length=-1, time=-2, name="pressure") +isothermal_compressiblity = pressure.inverse() +inverse_temperature = temperature.inverse() +atomic_molecular_weights = Dimensions("atomic_molecular_weights", mass=1, substance=-1) +mole_mass_fractions = Dimensions(name="mole_mass_fractions") +chemical_potential = Dimensions( + name="chemical_potential", mass=1, length=2, time=-2, substance=-1 +) +electric_potential = Dimensions( + name="electric_potential", mass=1, length=2, time=-3, current=-1 +) +concentrations_like = Dimensions(name="concentrations_like", substance=1, length=-3) +molar_volume = Dimensions(name="volume_mole", substance=-1, length=3) +volume_mass = Dimensions(name="volume_mass", mass=-1, length=3) +density_mass = volume_mass.inverse(name="density_mass") +mass_basis_energy_like = Dimensions(name="mass_basis_energy_like", length=2, time=-2) +mass_basis_entropy_like = Dimensions( + name="mass_basis_entropy_like", length=2, time=-2, temperature=-1 +) +molar_basis_energy_like = Dimensions( + name="molar_basis_energy_like", mass=1, length=2, time=-2, substance=-1 +) +molar_basis_entropy_like = Dimensions( + name="molar_basis_entropy_like", + mass=1, + length=2, + time=-2, + substance=-1, + temperature=-1, +) - molar_basis_energy_like = Dimensions(mass=1, length=2, time=-2, substance=-1) - check_dimensions(generic_phase, "enthalpy_mole", molar_basis_energy_like) - check_dimensions(generic_phase, "int_energy_mole", molar_basis_energy_like) - check_dimensions(generic_phase, "gibbs_mole", molar_basis_energy_like) - molar_basis_entropy_like = Dimensions(mass=1, length=2, time=-2, substance=-1, - temperature=-1) - check_dimensions(generic_phase, "entropy_mole", molar_basis_entropy_like) - check_dimensions(generic_phase, "cp_mole", molar_basis_entropy_like) - check_dimensions(generic_phase, "cv_mole", molar_basis_entropy_like) +def yield_dimensions(): + """Yield pytest.param instances with the dimensions""" + # This dictionary maps the dimensions to the relevant property names + dims: Dict[Dimensions, Tuple[str, ...]] = { + temperature: ("T", "max_temp", "min_temp"), + inverse_temperature: ("thermal_expansion_coeff",), + pressure: ("P", "reference_pressure"), + isothermal_compressiblity: ("isothermal_compressibility",), + atomic_molecular_weights: ( + "atomic_weight", + "molecular_weights", + "mean_molecular_weight", + ), + mole_mass_fractions: ("X", "Y"), + chemical_potential: ("chemical_potentials", "electrochemical_potentials"), + electric_potential: ("electric_potential",), + concentrations_like: ("concentrations", "density_mole"), + molar_volume: ("volume_mole", "partial_molar_volumes"), + volume_mass: ("volume_mass",), + density_mass: ("density_mass",), + mass_basis_energy_like: ("enthalpy_mass", "int_energy_mass", "gibbs_mass"), + mass_basis_entropy_like: ("entropy_mass", "cp_mass", "cv_mass"), + molar_basis_energy_like: ( + "enthalpy_mole", + "int_energy_mole", + "gibbs_mole", + "partial_molar_enthalpies", + "partial_molar_int_energies", + ), + molar_basis_entropy_like: ( + "entropy_mole", + "cp_mole", + "cv_mole", + "partial_molar_cp", + "partial_molar_entropies", + ), + } + for dimension, props in dims.items(): + for prop in props: + yield pytest.param(prop, dimension.as_dict(), id=f"{dimension}-{prop}") + + +@pytest.mark.parametrize("prop,dimensions", yield_dimensions()) +def test_dimensions(generic_phase, prop, dimensions): + pint_dim = dict(getattr(generic_phase, prop).dimensionality) + assert pint_dim == dimensions -def test_purefluid_dimensions(): +@pytest.mark.parametrize( + "phase", + ( + pytest.param(ctu.Solution("liquidvapor.yaml", "heptane"), id="Solution"), + pytest.param(ctu.Water(), id="PureFluid"), + ), +) +def test_purefluid_dimensions(phase): # Test some dimensions that weren't tested as part of the Solution tests # Create and test a liquidvapor phase in a Solution object, since an ideal gas phase # doesn't implement saturation or critical properties. - heptane_solution = ctu.Solution("liquidvapor.yaml", "heptane") - water_purefluid = ctu.Water() - temperature = Dimensions(temperature=1) - check_dimensions(water_purefluid, "T_sat", temperature) - check_dimensions(water_purefluid, "critical_temperature", temperature) - check_dimensions(heptane_solution, "T_sat", temperature) - check_dimensions(heptane_solution, "critical_temperature", temperature) - - pressure = Dimensions(mass=1, length=-1, time=-2) - check_dimensions(water_purefluid, "P_sat", pressure) - check_dimensions(water_purefluid, "critical_pressure", pressure) - check_dimensions(heptane_solution, "P_sat", pressure) - check_dimensions(heptane_solution, "critical_pressure", pressure) - - density = Dimensions(mass=1, length=-3) - check_dimensions(water_purefluid, "critical_density", density) - check_dimensions(heptane_solution, "critical_density", density) + assert dict(phase.T_sat.dimensionality) == temperature.as_dict() + assert dict(phase.critical_temperature.dimensionality) == temperature.as_dict() + assert dict(phase.P_sat.dimensionality) == pressure.as_dict() + assert dict(phase.critical_pressure.dimensionality) == pressure.as_dict() + assert dict(phase.critical_density.dimensionality) == density_mass.as_dict() def yield_prop_pairs(): @@ -236,7 +228,7 @@ def yield_prop_pairs_and_triples(): @pytest.fixture -def test_properties(request): +def TD_in_the_right_basis(request): if request.param == "mass": T = ctu.Q_(500, "K") rho = ctu.Q_(1.5, "kg/m**3") @@ -260,7 +252,7 @@ def some_setters_arent_implemented_for_purefluid(request): request.applymarker( pytest.mark.xfail( raises=ctu.CanteraError, - reason=f"The {pair_or_triple} method isn't implemented" + reason=f"The {pair_or_triple} method isn't implemented", ) ) @@ -270,7 +262,7 @@ def some_setters_arent_implemented_for_purefluid(request): # fixture decorator, which would give us (mass, molar) basis pairs, and that doesn't # make sense. @pytest.mark.parametrize( - "test_properties,initial_TDY", + "TD_in_the_right_basis,initial_TDY", [ pytest.param("mass", "mass", id="mass"), pytest.param("molar", "molar", id="molar"), @@ -279,16 +271,15 @@ def some_setters_arent_implemented_for_purefluid(request): ) @pytest.mark.parametrize("props", yield_prop_pairs_and_triples()) @pytest.mark.usefixtures("some_setters_arent_implemented_for_purefluid") -def test_setters(generic_phase, test_properties, initial_TDY, props): +def test_setters(generic_phase, TD_in_the_right_basis, initial_TDY, props): pair_or_triple = props[0] if isinstance(generic_phase, ctu.PureFluid): Y_1 = ctu.Q_([1.0], "dimensionless") - generic_phase.TD = test_properties else: - Y_1 = ctu.Q_([0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], - "dimensionless") - generic_phase.TDY = *test_properties, Y_1 - print(generic_phase.Y) + Y_1 = ctu.Q_( + [0.1, 0.0, 0.0, 0.1, 0.4, 0.2, 0.0, 0.0, 0.2, 0.0], "dimensionless" + ) + generic_phase.TDY = *TD_in_the_right_basis, Y_1 # Use TDY setting to get the properties at the modified state new_props = getattr(generic_phase, pair_or_triple) @@ -305,7 +296,7 @@ def test_setters(generic_phase, test_properties, initial_TDY, props): # Use the test pair or triple to set the state and assert that the # natural properties are equal to the modified state setattr(generic_phase, pair_or_triple, new_props) - T_1, rho_1 = test_properties + T_1, rho_1 = TD_in_the_right_basis assert_allclose(generic_phase.T, T_1) assert_allclose(generic_phase.density, rho_1) assert_allclose(generic_phase.Y, Y_1) @@ -326,7 +317,7 @@ def test_setters_hold_constant(generic_phase, props): generic_phase.TD = ctu.Q_(1000, "K"), ctu.Q_(1.5, "kg/m**3") property_3 = getattr(generic_phase, third) - # Change to another arbitrary state and store values to compare when that spot + # Change to another arbitrary state and store values to compare when a property # isn't changed reset_state = (ctu.Q_(500, "K"), ctu.Q_(2.5, "kg/m**3"), composition) generic_phase.TDX = reset_state @@ -367,9 +358,8 @@ def test_multi_prop_getters_are_equal_to_single(generic_phase, props, basis, rho @pytest.mark.parametrize("pair", yield_prop_pairs()) def test_set_pair_without_units_is_an_error(generic_phase, pair): - value_1 = [300, None] with pytest.raises(ctu.CanteraError, match="an instance of a pint"): - setattr(generic_phase, pair[0], value_1) + setattr(generic_phase, pair[0], [300, None]) @pytest.fixture @@ -388,49 +378,43 @@ def third_prop_sometimes_fails(request): else: return nullcontext() + @pytest.mark.parametrize("triple", yield_prop_triples()) def test_set_triple_without_units_is_an_error( generic_phase, triple, third_prop_sometimes_fails, ): - value_1 = [300, None, [1]*generic_phase.n_species] + value_1 = [300, None, [1] * generic_phase.n_species] with pytest.raises(ctu.CanteraError, match="an instance of a pint"): setattr(generic_phase, triple[0], value_1) - value_3 = [None, None, [1]*generic_phase.n_species] + value_3 = [None, None, [1] * generic_phase.n_species] with third_prop_sometimes_fails: setattr(generic_phase, triple[0], value_3) @pytest.fixture -def xfail_heptane(request): - if request.getfixturevalue("pure_fluid").name == "heptane": - request.applymarker( - pytest.mark.xfail( - raises=ctu.CanteraError, - reason="Convergence failure for P_sat/Q solver used by Q-only setter", - strict=True, - ) - ) +def pure_fluid_in_vapordome(pure_fluid): + pure_fluid.TQ = None, ctu.Q_(0.5, "dimensionless") + return pure_fluid -@pytest.mark.usefixtures("xfail_heptane") -def test_set_Q(pure_fluid): - p = pure_fluid.P - T = pure_fluid.T - pure_fluid.Q = ctu.Q_(0.6, "dimensionless") - assert_allclose(pure_fluid.Q, 0.6 * ctu.units.dimensionless) - assert_allclose(pure_fluid.T, T) - assert_allclose(pure_fluid.P, p) +def test_set_Q(pure_fluid_in_vapordome): + P = pure_fluid_in_vapordome.P + T = pure_fluid_in_vapordome.T + pure_fluid_in_vapordome.Q = ctu.Q_(0.6, "dimensionless") + assert_allclose(pure_fluid_in_vapordome.Q, 0.6 * ctu.units.dimensionless) + assert_allclose(pure_fluid_in_vapordome.T, T) + assert_allclose(pure_fluid_in_vapordome.P, P) - pure_fluid.Q = None - assert_allclose(pure_fluid.Q, 0.6 * ctu.units.dimensionless) - assert_allclose(pure_fluid.T, T) - assert_allclose(pure_fluid.P, p) + pure_fluid_in_vapordome.Q = None + assert_allclose(pure_fluid_in_vapordome.Q, 0.6 * ctu.units.dimensionless) + assert_allclose(pure_fluid_in_vapordome.T, T) + assert_allclose(pure_fluid_in_vapordome.P, P) with pytest.raises(ctu.CanteraError, match="an instance of a pint"): - pure_fluid.Q = 0.5 + pure_fluid_in_vapordome.Q = 0.5 def yield_purefluid_only_setters(): @@ -467,14 +451,12 @@ def yield_all_purefluid_only_props(): @pytest.mark.parametrize("prop", yield_purefluid_only_setters()) -def test_set_without_units_is_error_purefluid(prop): - # We only need to run this test for one PureFluid, not all of them - water = ctu.Water() +def test_set_without_units_is_error_purefluid(prop, pure_fluid): value = [None] * (len(prop[0]) - 1) + [0.5] with pytest.raises(ctu.CanteraError, match="an instance of a pint"): # Don't use append here, because append returns None which would be # passed to setattr - setattr(water, prop[0], value) + setattr(pure_fluid, prop[0], value) @pytest.mark.parametrize("props", yield_all_purefluid_only_props()) @@ -488,9 +470,8 @@ def test_multi_prop_getters_purefluid(pure_fluid, props): @pytest.mark.parametrize("props", yield_purefluid_only_setters()) -def test_setters_purefluid(props): +def test_setters_purefluid(props, pure_fluid): # Only need to run this for a single pure fluid - pure_fluid = ctu.Water() initial_TD = pure_fluid.TD pair_or_triple = props[0] From 966a2498423dd02f54533f10a3f2319f5fde1f46 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 23 Oct 2022 13:13:12 -0400 Subject: [PATCH 26/41] Address review line comments --- .gitignore | 1 - samples/python/thermo/isentropic_units.py | 24 +++++++++++++--------- samples/python/thermo/rankine_units.py | 18 ++++++++-------- samples/python/thermo/sound_speed.py | 2 +- samples/python/thermo/sound_speed_units.py | 23 ++++++++++++--------- 5 files changed, 37 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index d81ef0c235..e0b14d0596 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,3 @@ coverage.info .coverage doc/sphinx/matlab/*.rst !doc/sphinx/matlab/index.rst -interfaces/cython/cantera/units/solution.py diff --git a/samples/python/thermo/isentropic_units.py b/samples/python/thermo/isentropic_units.py index 2220dea1e1..d0f3523d2a 100644 --- a/samples/python/thermo/isentropic_units.py +++ b/samples/python/thermo/isentropic_units.py @@ -1,22 +1,24 @@ """ Isentropic, adiabatic flow example - calculate area ratio vs. Mach number curve -Requires: cantera >= 3.0.0 +Requires: Cantera >= 3.0.0, pint Keywords: thermodynamics, compressible flow, units """ -import cantera.with_units as ct +import cantera.with_units as ctu import numpy as np -ct.units.default_format = ".2F~P" -label_string = "area ratio\tMach number\ttemperature\tpressure ratio" -output_string = "{0:.2E~P}\t{1} {2}\t{3:.2E~P}" + +# This sets the default output format of the units to have 2 significant digits +# and the units are printed with a Unicode font. See: +# https://pint.readthedocs.io/en/stable/formatting.html#unit-format-types +ctu.units.default_format = ".2F~P" def soundspeed(gas): """The speed of sound. Assumes an ideal gas.""" gamma = gas.cp / gas.cv - return np.sqrt(gamma * ct.units.molar_gas_constant + return np.sqrt(gamma * ctu.units.molar_gas_constant * gas.T / gas.mean_molecular_weight).to("m/s") @@ -29,16 +31,16 @@ def isentropic(gas=None): degrees Fahrenheit, P0 = 10 atm. """ if gas is None: - gas = ct.Solution('gri30.yaml') - gas.TPX = 2160 * ct.units.degR, 10.0 * ct.units.atm, 'H2:1,N2:0.1' + gas = ctu.Solution('gri30.yaml') + gas.TPX = 2160 * ctu.units.degR, 10.0 * ctu.units.atm, 'H2:1,N2:0.1' # get the stagnation state parameters s0 = gas.s h0 = gas.h p0 = gas.P - mdot = 1 * ct.units.kg / ct.units.s # arbitrary - amin = 1.e14 * ct.units.m**2 + mdot = 1 * ctu.units.kg / ctu.units.s # arbitrary + amin = 1.e14 * ctu.units.m**2 data = [] @@ -60,6 +62,8 @@ def isentropic(gas=None): if __name__ == "__main__": print(__doc__) data, amin = isentropic() + label_string = "area ratio\tMach number\ttemperature\tpressure ratio" + output_string = "{0:.2E~P}\t{1} {2}\t{3:.2E~P}" print(label_string) for row in data: print(output_string.format(row[0] / amin, row[1], row[2], row[3])) diff --git a/samples/python/thermo/rankine_units.py b/samples/python/thermo/rankine_units.py index a4e3855bfc..cae7d93bc9 100644 --- a/samples/python/thermo/rankine_units.py +++ b/samples/python/thermo/rankine_units.py @@ -2,16 +2,16 @@ Calculate the efficiency of a Rankine vapor power cycle using a pure fluid model for water. Includes the units of quantities in the calculations. -Requires: Cantera >= 2.6.0 +Requires: Cantera >= 3.0.0, pint Keywords: thermodynamics, thermodynamic cycle, non-ideal fluid, units """ -import cantera.with_units as ct +import cantera.with_units as ctu # parameters -eta_pump = 0.6 * ct.units.dimensionless # pump isentropic efficiency -eta_turbine = 0.8 * ct.units.dimensionless # turbine isentropic efficiency -p_max = 116.03 * ct.units.psi # maximum pressure +eta_pump = 0.6 * ctu.units.dimensionless # pump isentropic efficiency +eta_turbine = 0.8 * ctu.units.dimensionless # turbine isentropic efficiency +p_max = 116.03 * ctu.units.psi # maximum pressure def pump(fluid, p_final, eta): @@ -49,10 +49,10 @@ def print_state(n, fluid): if __name__ == '__main__': # create an object representing water - w = ct.Water() + w = ctu.Water() - # start with saturated liquid water at 80.33 degrees Rankine - w.TQ = 540 * ct.units.degR, 0.0 * ct.units.dimensionless + # start with saturated liquid water at 80.33 degrees Fahrenheit + w.TQ = ctu.Q_(80.33, "degF"), 0.0 * ctu.units.dimensionless h1 = w.h p1 = w.P print_state(1, w) @@ -64,7 +64,7 @@ def print_state(n, fluid): # heat it at constant pressure until it reaches the saturated vapor state # at this pressure - w.PQ = p_max, 1.0 * ct.units.dimensionless + w.PQ = p_max, 1.0 * ctu.units.dimensionless h3 = w.h heat_added = h3 - h2 print_state(3, w) diff --git a/samples/python/thermo/sound_speed.py b/samples/python/thermo/sound_speed.py index db39dc9ab9..5a93ba726f 100644 --- a/samples/python/thermo/sound_speed.py +++ b/samples/python/thermo/sound_speed.py @@ -54,7 +54,7 @@ def equilSoundSpeeds(gas, rtol=1.0e-6, max_iter=5000): if __name__ == "__main__": gas = ct.Solution('gri30.yaml') gas.X = 'CH4:1.00, O2:2.0, N2:7.52' - T_range = np.linspace(300, 2900, 27) + T_range = np.arange(300, 2901, 100) for T in T_range: gas.TP = T, ct.one_atm print(T, equilSoundSpeeds(gas)) diff --git a/samples/python/thermo/sound_speed_units.py b/samples/python/thermo/sound_speed_units.py index c0abca7ab5..aedf3bac5d 100644 --- a/samples/python/thermo/sound_speed_units.py +++ b/samples/python/thermo/sound_speed_units.py @@ -1,14 +1,17 @@ """ Compute the "equilibrium" and "frozen" sound speeds for a gas -Requires: cantera >= 2.6.0 -Keywords: thermodynamics, equilibrium +Requires: Cantera >= 3.0.0, pint +Keywords: thermodynamics, equilibrium, units """ -import cantera.with_units as ct +import cantera.with_units as ctu import numpy as np -ct.units.default_format = ".2F~P" +# This sets the default output format of the units to have 2 significant digits +# and the units are printed with a Unicode font. See: +# https://pint.readthedocs.io/en/stable/formatting.html#unit-format-types +ctu.units.default_format = ".2F~P" def equilibrium_sound_speeds(gas, rtol=1.0e-6, max_iter=5000): """ @@ -44,18 +47,18 @@ def equilibrium_sound_speeds(gas, rtol=1.0e-6, max_iter=5000): # compute the frozen sound speed using the ideal gas expression as a check gamma = gas.cp/gas.cv - gamma * ct.units.molar_gas_constant - afrozen2 = np.sqrt(gamma * ct.units.molar_gas_constant * gas.T / + gamma * ctu.units.molar_gas_constant + afrozen2 = np.sqrt(gamma * ctu.units.molar_gas_constant * gas.T / gas.mean_molecular_weight).to("ft/s") return aequil, afrozen, afrozen2 # test program if __name__ == "__main__": - gas = ct.Solution('gri30.yaml') + gas = ctu.Solution('gri30.yaml') gas.X = 'CH4:1.00, O2:2.0, N2:7.52' - T_range = np.linspace(80.33, 4760.33, 50) * ct.units.degF + T_range = np.linspace(80.33, 4760.33, 50) * ctu.units.degF print("Temperature Equilibrium Sound Speed Frozen Sound Speed Frozen Sound Speed Check") for T in T_range: - gas.TP = T, 1.0 * ct.units.atm - print(T, *equilibrium_sound_speeds(gas), sep = " ") + gas.TP = T, 1.0 * ctu.units.atm + print(T.to("degF"), *equilibrium_sound_speeds(gas), sep = " ") From 9d1b53326f7c3d89497125c5be25e035e31f0539 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 29 Oct 2022 19:34:35 -0400 Subject: [PATCH 27/41] [Units] Add documentation Most of the docs are copied from the upstream object. Some updates are included for units. --- doc/sphinx/conf.py | 30 ++++ doc/sphinx/cython/index.rst | 1 + doc/sphinx/cython/units.rst | 25 ++++ interfaces/cython/SConscript | 9 ++ .../cython/cantera/with_units/solution.py.in | 131 +++++++++++++++++- 5 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 doc/sphinx/cython/units.rst diff --git a/doc/sphinx/conf.py b/doc/sphinx/conf.py index 05eeb6923d..0186b1c1fa 100644 --- a/doc/sphinx/conf.py +++ b/doc/sphinx/conf.py @@ -84,6 +84,36 @@ def escape_splats(app, what, name, obj, options, lines): lines[i] = l.replace("*", r"\*") app.connect('autodoc-process-docstring', escape_splats) + # NAMES = [] + # DIRECTIVES = {} + + # def get_rst(app, what, name, obj, options, signature, + # return_annotation): + # if "with_units" not in name: + # return + # doc_indent = ' ' + # directive_indent = '' + # if what in ['method', 'attribute']: + # doc_indent += ' ' + # directive_indent += ' ' + # directive = '%s.. py:%s:: %s' % (directive_indent, what, name) + # if signature: # modules, attributes, ... don't have a signature + # directive += signature + # NAMES.append(name) + # rst = directive + '\n\n' + doc_indent + obj.__doc__ + '\n' + # DIRECTIVES[name] = rst + + # def write_new_docs(app, exception): + # txt = ['My module documentation'] + # txt.append('-----------------------\n') + # for name in NAMES: + # txt.append(DIRECTIVES[name]) + # # print('\n'.join(txt)) + # with open('../doc_new/generated.rst', 'w') as outfile: + # outfile.write('\n'.join(txt)) + + # app.connect('autodoc-process-signature', get_rst) + # app.connect('build-finished', write_new_docs) autoclass_content = 'both' diff --git a/doc/sphinx/cython/index.rst b/doc/sphinx/cython/index.rst index e3ddbf046a..25490f21ea 100644 --- a/doc/sphinx/cython/index.rst +++ b/doc/sphinx/cython/index.rst @@ -15,4 +15,5 @@ Contents: zerodim onedim constants + units utilities diff --git a/doc/sphinx/cython/units.rst b/doc/sphinx/cython/units.rst new file mode 100644 index 0000000000..8c8b28d287 --- /dev/null +++ b/doc/sphinx/cython/units.rst @@ -0,0 +1,25 @@ +.. py:currentmodule:: cantera.with_units + +Python Interface With Units +=========================== + +.. autoclass:: Solution + +PureFluid Phases With Units +=========================== + +The following convenience classes are available to create `PureFluid ` +objects with the indicated equation of state: + +.. autoclass:: CarbonDioxide +.. autoclass:: Heptane +.. autoclass:: Hfc134a +.. autoclass:: Hydrogen +.. autoclass:: Methane +.. autoclass:: Nitrogen +.. autoclass:: Oxygen +.. autoclass:: Water + +The full documentation for the `PureFluid ` class and its properties is here: + +.. autoclass:: PureFluid diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index 5bdb6ff945..958c90b5cb 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -156,6 +156,7 @@ getter_properties = [ getter_template = Template(""" @property + @copy_doc def ${name}(self): return Q_(self._phase.${name}, ${units}) """) @@ -166,6 +167,7 @@ for name in getter_properties: setter_2_template = Template(""" @property + @copy_doc def ${name}(self): ${n0}, ${n1} = self._phase.${name} return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}) @@ -204,6 +206,7 @@ for name in pf_setter_2_properties: setter_3_template = Template(""" @property + @copy_doc def ${name}(self): ${n0}, ${n1}, ${n2} = self._phase.${name} return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) @@ -245,6 +248,7 @@ for name in tp_setter_3_properties: getter_3_template = Template(""" @property + @copy_doc def ${name}(self): ${n0}, ${n1}, ${n2} = self._phase.${name} return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) @@ -282,13 +286,16 @@ common_properties = """ @property def basis_units(self): + \"\"\"The units associated with the mass/molar basis of this phase.\"\"\" if self._phase.basis == "mass": return "kg" else: return "kmol" @property + @copy_doc def X(self): + \"\"\"If an array is used for setting, the units must be dimensionless.\"\"\" X = self._phase.X return Q_(X, "dimensionless") @@ -304,7 +311,9 @@ common_properties = """ self._phase.X = X @property + @copy_doc def Y(self): + \"\"\"If an array is used for setting, the units must be dimensionless.\"\"\" Y = self._phase.Y return Q_(Y, "dimensionless") diff --git a/interfaces/cython/cantera/with_units/solution.py.in b/interfaces/cython/cantera/with_units/solution.py.in index ce7ae0ed8c..0384bc462b 100644 --- a/interfaces/cython/cantera/with_units/solution.py.in +++ b/interfaces/cython/cantera/with_units/solution.py.in @@ -1,35 +1,126 @@ # This file is part of Cantera. See License.txt in the top-level directory or # at https://cantera.org/license.txt for license and copyright information. +from textwrap import dedent from .. import Solution as _Solution, PureFluid as _PureFluid, CanteraError +from .. import (Heptane as _Heptane, Water as _Water, Hfc134a as _Hfc134a, + CarbonDioxide as _CarbonDioxide, Hydrogen as _Hydrogen, + Methane as _Methane, Nitrogen as _Nitrogen, Oxygen as _Oxygen) from pint import get_application_registry -units = get_application_registry() -Q_ = units.Quantity - __all__ = ("units", "Q_", "Solution", "PureFluid", "Heptane", "CarbonDioxide", "Hfc134a", "Hydrogen", "Methane", "Nitrogen", "Oxygen", "Water", "CanteraError") +units = get_application_registry() +Q_ = units.Quantity + + +def copy_doc(method): + doc = getattr(method, "__doc__", None) or "" + if isinstance(method, property): + method = method.fget + if not doc: + doc = getattr(method, "__doc__", None) or "" + original_klass = method.__qualname__.split(".")[0] + klass = {"Solution": _Solution, "PureFluid": _PureFluid}[original_klass] + original_method = getattr(klass, method.__name__) + original_doc = dedent(getattr(original_method, "__doc__", "")) + method.__doc__ = f"{original_doc}\n{doc}" + return method + class Solution: - def __init__(self, infile, name=""): - self.__dict__["_phase"] = _Solution(infile, name) + """ + A class for chemically-reacting solutions. Instances can be created to + represent any type of solution -- a mixture of gases, a liquid solution, or + a solid solution, for example. + + This implementation of `Solution ` operates with + units by using the `pint` library to convert between unit systems. All properties + are asssigned units in the standard MKS system that Cantera uses, substituting kmol + instead of mol. Each property is an instance of the `pint.Quantity` class. + + Similarly, properties must be instances of `pint.Quantity` classes when they are + used for assignment to set the state. The properties may have any units, so long + as the dimensions for the quantity are consistent. For example, temperatures can + be provided in K, degC, degF, or degR; conversion will be done internally to + Cantera's consistent unit system. + + See the `pint documentation `__ for more information + about using pint's ``Quantity`` classes. + + The most common way to instantiate `Solution` objects is by using a phase + definition, species and reactions defined in an input file:: + + gas = ct.Solution('gri30.yaml') + + If an input file defines multiple phases, the corresponding key in the + ``phases`` map can be used to specify the desired phase via the ``name`` keyword + argument of the constructor:: + + gas = ct.Solution('diamond.yaml', name='gas') + diamond = ct.Solution('diamond.yaml', name='diamond') + + The name of the `Solution` object defaults to the *phase* identifier + specified in the input file. Upon initialization of a `Solution` object, + a custom name can assigned via:: + + gas.name = 'my_custom_name' + + In addition, `Solution` objects can be constructed by passing the text of + the YAML phase definition in directly, using the ``yaml`` keyword + argument:: + + yaml_def = ''' + phases: + - name: gas + thermo: ideal-gas + kinetics: gas + elements: [O, H, Ar] + species: + - gri30.yaml/species: all + reactions: + - gri30.yaml/reactions: declared-species + skip-undeclared-elements: true + skip-undeclared-third-bodies: true + state: {T: 300, P: 1 atm} + ''' + gas = ct.Solution(yaml=yaml_def) + """ + def __init__(self, infile="", name="", *, yaml=None): + self.__dict__["_phase"] = _Solution(infile, name, yaml=yaml) @common_properties@ @thermophase_properties@ - class PureFluid: - def __init__(self, infile, name="", **kwargs): + """ + This implementation of `PureFluid ` operates with + units by using the `pint` library to convert between unit systems. All properties + are asssigned units in the standard MKS system that Cantera uses, substituting kmol + instead of mol. Each property is an instance of the `pint.Quantity` class. + + Similarly, properties must be instances of `pint.Quantity` classes when they are + used for assignment to set the state. The properties may have any units, so long + as the dimensions for the quantity are consistent. For example, temperatures can + be provided in K, degC, degF, or degR; conversion will be done internally to + Cantera's consistent unit system. + + See the `pint documentation `__ for more information + about using pint's ``Quantity`` classes. + """ + def __init__(self, infile, name="", *, yaml=None, **kwargs): self.__dict__["_phase"] = _PureFluid(infile, name, **kwargs) @common_properties@ @property + @copy_doc def Q(self): + """Must be set using a quantity with dimensionless units.""" Q = self._phase.Q return Q_(Q, "dimensionless") @@ -52,6 +143,7 @@ class PureFluid: @thermophase_properties@ @property + @copy_doc def TPQ(self): T, P, Q = self._phase.TPQ return Q_(T, "K"), Q_(P, "Pa"), Q_(Q, "dimensionless") @@ -75,33 +167,58 @@ class PureFluid: @purefluid_properties@ +PureFluid.__doc__ = f"{PureFluid.__doc__}\n{_PureFluid.__doc__}" + + def Heptane(): return PureFluid("liquidvapor.yaml", "heptane") +Heptane.__doc__ = _Heptane.__doc__ + def CarbonDioxide(): return PureFluid("liquidvapor.yaml", "carbon-dioxide") +CarbonDioxide.__doc__ = _CarbonDioxide.__doc__ + + def Hfc134a(): return PureFluid("liquidvapor.yaml", "HFC-134a") +Hfc134a.__doc__ = _Hfc134a.__doc__ + + def Hydrogen(): return PureFluid("liquidvapor.yaml", "hydrogen") +Hydrogen.__doc__ = _Hydrogen.__doc__ + + def Methane(): return PureFluid("liquidvapor.yaml", "methane") +Methane.__doc__ = _Methane.__doc__ + + def Nitrogen(): return PureFluid("liquidvapor.yaml", "nitrogen") +Nitrogen.__doc__ = _Nitrogen.__doc__ + + def Oxygen(): return PureFluid("liquidvapor.yaml", "oxygen") +Oxygen.__doc__ = _Oxygen.__doc__ + + def Water(backend="Reynolds"): return PureFluid("liquidvapor.yaml", "water", backend=backend) + +Water.__doc__ = _Water.__doc__ From 12a6cb4c5cc37aead1682073e59ee3a63176b051 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 29 Oct 2022 20:17:05 -0400 Subject: [PATCH 28/41] [Units] Add tests that all properties are implemented --- test/python/test_units.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/python/test_units.py b/test/python/test_units.py index 3e6d67ed20..0b71bfe719 100644 --- a/test/python/test_units.py +++ b/test/python/test_units.py @@ -4,6 +4,8 @@ from typing import Optional, Tuple, Dict import cantera.with_units as ctu +import cantera as ct +import numpy as np from pint.testing import assert_allclose @@ -514,3 +516,36 @@ def test_X_Y_setters_without_units_works(generic_phase, prop): composition = f"{generic_phase.species_names[0]}:1" setattr(generic_phase, prop, composition) assert_allclose(getattr(generic_phase, prop)[0], ctu.Q_([1], "dimensionless")) + + +def test_thermophase_properties_exist(ideal_gas): + # Since the Solution class in the with_units subpackage only implements + # the ThermoPhase interface for now, instantiate a regular ThermoPhase + # to compare the attributes and make sure all of them exist on the with_units + # object + tp = ct.ThermoPhase("h2o2.yaml") + + for attr in dir(tp): + if attr.startswith("_"): # or attr in skip: + continue + + try: + getattr(tp, attr) + except (ct.CanteraError, ct.ThermoModelMethodError): + continue + + assert hasattr(ideal_gas, attr) + + +def test_purefluid_properties_exist(pure_fluid): + pf = ct.PureFluid("liquidvapor.yaml", "water") + for attr in dir(pf): + if attr.startswith("_"): + continue + + try: + getattr(pf, attr) + except (ct.CanteraError, ct.ThermoModelMethodError): + continue + + assert hasattr(pure_fluid, attr) From 1ae45560139ed6598363577876f940e4e12739d7 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 29 Oct 2022 21:36:55 -0400 Subject: [PATCH 29/41] Fix testing and docs build failures --- .github/workflows/main.yml | 2 +- test/python/test_units.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a7f6027cab..6c631ff26e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -300,7 +300,7 @@ jobs: - name: Install Python dependencies run: | python3 -m pip install ruamel.yaml scons numpy cython sphinx\<4.0 jinja2\<3.1.0 \ - sphinxcontrib-katex sphinxcontrib-matlabdomain sphinxcontrib-doxylink + sphinxcontrib-katex sphinxcontrib-matlabdomain sphinxcontrib-doxylink pint - name: Build Cantera with documentation run: python3 `which scons` build -j2 doxygen_docs=y sphinx_docs=y debug=n optimize=n use_pch=n - name: Ensure 'scons help' options work diff --git a/test/python/test_units.py b/test/python/test_units.py index 0b71bfe719..ca80e705c2 100644 --- a/test/python/test_units.py +++ b/test/python/test_units.py @@ -1,12 +1,16 @@ from contextlib import nullcontext -import pytest from dataclasses import dataclass from typing import Optional, Tuple, Dict +import sys +import pytest import cantera.with_units as ctu import cantera as ct -import numpy as np -from pint.testing import assert_allclose +try: + from pint.testing import assert_allclose +except ModuleNotFoundError: + # pint.testing was introduced in pint 0.20 + from pint.testsuite.helpers import assert_quantity_almost_equal as assert_allclose @pytest.fixture(scope="function") @@ -25,7 +29,13 @@ def generic_phase(request): def test_setting_basis_units_fails(generic_phase): - with pytest.raises(AttributeError, match="basis_units"): + # Python 3.10 includes the name of the attribute which was improperly used as a + # setter. Earlier versions have just a generic error message. + if sys.version_info.minor < 10: + match = "set attribute" + else: + match = "basis_units" + with pytest.raises(AttributeError, match=match): generic_phase.basis_units = "some random string" From e6941bff8cc393ac28afa044ca3c63b0e6bb6f20 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 30 Oct 2022 20:02:28 -0400 Subject: [PATCH 30/41] [CI] Add --pre option to sample installation step This is required to find the version of Cantera in the dist folder. By default, pip only finds stable versions, and in this case, was going out to PyPI to find Cantera rather than use the local artifact. However, we can't set --no-index because we also need to install other dependencies from PyPI. --- .github/workflows/main.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6c631ff26e..b4c91d0b0f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -366,6 +366,10 @@ jobs: with: python-version: ${{ matrix.python-version }} architecture: x64 + - name: Install Apt dependencies + run: | + sudo apt update + sudo apt install graphviz - name: Download the wheel artifact uses: actions/download-artifact@v3 with: @@ -374,7 +378,8 @@ jobs: - name: Upgrade pip run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies - run: python3 -m pip install --find-links dist cantera h5py pandas matplotlib scipy pint + run: python3 -m pip install --pre --find-links dist cantera h5py pandas \ + matplotlib scipy pint - name: Run the examples # See https://unix.stackexchange.com/a/392973 for an explanation of the -exec part run: | @@ -382,7 +387,6 @@ jobs: find samples/python -type f -iname "*.py" \ -exec sh -c 'for n; do echo "$n" | tee -a results.txt && python3 "$n" >> results.txt || exit 1; done' sh {} + env: - PYTHONPATH: build/python # The ignore setting here is due to a new warning introduced in Matplotlib==3.6.0 PYTHONWARNINGS: "error,ignore:warn_name_set_on_empty_Forward::pyparsing" MPLBACKEND: Agg From 6b554c7eb8690ea9bb4af3c77319b0620d72fe8e Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Mon, 31 Oct 2022 21:14:04 -0400 Subject: [PATCH 31/41] [CI] Don't use PyPI index when installing Cantera Ensure that we only install the wheel from the archive and don't accidentally reach out to PyPI. --- .github/workflows/main.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b4c91d0b0f..eea822337f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -378,8 +378,9 @@ jobs: - name: Upgrade pip run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies - run: python3 -m pip install --pre --find-links dist cantera h5py pandas \ - matplotlib scipy pint + run: | + python3 -m pip install --pre --no-index --find-links dist cantera + python3 -m pip install numpy ruamel.yaml h5py pandas matplotlib scipy pint - name: Run the examples # See https://unix.stackexchange.com/a/392973 for an explanation of the -exec part run: | From 582ef721ffb9a14c5a53318c4d78400b98b86916 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 6 Nov 2022 21:00:56 -0500 Subject: [PATCH 32/41] [CI] Fix example installation --- .github/workflows/main.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eea822337f..e405dfa2af 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -377,10 +377,13 @@ jobs: path: dist - name: Upgrade pip run: python3 -m pip install -U pip setuptools wheel + - name: Install typing_extensions for Python 3.7 + if: matrix.python-version == '3.7' + run: python3 -m pip install typing-extensions - name: Install Python dependencies run: | - python3 -m pip install --pre --no-index --find-links dist cantera python3 -m pip install numpy ruamel.yaml h5py pandas matplotlib scipy pint + python3 -m pip install --pre --no-index --find-links dist cantera - name: Run the examples # See https://unix.stackexchange.com/a/392973 for an explanation of the -exec part run: | From 085480cb33703dfe9651aa654fe320cbd7ca63ff Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 24 Mar 2023 21:05:13 -0400 Subject: [PATCH 33/41] Remove some tabs --- interfaces/python_minimal/setup.cfg.in | 2 +- interfaces/python_sdist/setup.cfg.in | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/interfaces/python_minimal/setup.cfg.in b/interfaces/python_minimal/setup.cfg.in index 96e0c6995c..2d96c75eda 100644 --- a/interfaces/python_minimal/setup.cfg.in +++ b/interfaces/python_minimal/setup.cfg.in @@ -45,4 +45,4 @@ console_scripts = ck2yaml = cantera.ck2yaml:script_entry_point cti2yaml = cantera.cti2yaml:main ctml2yaml = cantera.ctml2yaml:main - yaml2ck = cantera.yaml2ck:main + yaml2ck = cantera.yaml2ck:main diff --git a/interfaces/python_sdist/setup.cfg.in b/interfaces/python_sdist/setup.cfg.in index 41d122a6db..66e5605913 100644 --- a/interfaces/python_sdist/setup.cfg.in +++ b/interfaces/python_sdist/setup.cfg.in @@ -67,4 +67,4 @@ console_scripts = ck2yaml = cantera.ck2yaml:script_entry_point cti2yaml = cantera.cti2yaml:main ctml2yaml = cantera.ctml2yaml:main - yaml2ck = cantera.yaml2ck:main + yaml2ck = cantera.yaml2ck:main From e382c2a2964413e7ce836bcca4becc5f055ff5ca Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sat, 22 Apr 2023 14:36:18 -0600 Subject: [PATCH 34/41] Apply suggestions from code review Co-authored-by: Ray Speth --- interfaces/cython/cantera/with_units/solution.py.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interfaces/cython/cantera/with_units/solution.py.in b/interfaces/cython/cantera/with_units/solution.py.in index 0384bc462b..b8d391eb99 100644 --- a/interfaces/cython/cantera/with_units/solution.py.in +++ b/interfaces/cython/cantera/with_units/solution.py.in @@ -38,7 +38,7 @@ class Solution: This implementation of `Solution ` operates with units by using the `pint` library to convert between unit systems. All properties - are asssigned units in the standard MKS system that Cantera uses, substituting kmol + are assigned units in the standard MKS system that Cantera uses, substituting kmol instead of mol. Each property is an instance of the `pint.Quantity` class. Similarly, properties must be instances of `pint.Quantity` classes when they are @@ -100,7 +100,7 @@ class PureFluid: """ This implementation of `PureFluid ` operates with units by using the `pint` library to convert between unit systems. All properties - are asssigned units in the standard MKS system that Cantera uses, substituting kmol + are assigned units in the standard MKS system that Cantera uses, substituting kmol instead of mol. Each property is an instance of the `pint.Quantity` class. Similarly, properties must be instances of `pint.Quantity` classes when they are From 094d5faf866a0a826c99e00433c14e84191bb010 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 23 Apr 2023 10:36:47 -0600 Subject: [PATCH 35/41] Move building the units interface to a Scons extension --- SConstruct | 2 +- interfaces/cython/SConscript | 229 +--------------- interfaces/python_sdist/SConscript | 4 + interfaces/python_sdist/setup.cfg.in | 1 + .../site_tools/UnitsInterfaceBuilder.py | 248 ++++++++++++++++++ 5 files changed, 257 insertions(+), 227 deletions(-) create mode 100644 site_scons/site_tools/UnitsInterfaceBuilder.py diff --git a/SConstruct b/SConstruct index 540564c0c2..11ed47d564 100644 --- a/SConstruct +++ b/SConstruct @@ -813,7 +813,7 @@ else: config.add(config_options) toolchain = ["default"] -env = Environment(tools=toolchain+["textfile", "subst", "recursiveInstall", "wix", "gch"], +env = Environment(tools=toolchain+["textfile", "subst", "recursiveInstall", "UnitsInterfaceBuilder", "wix", "gch"], ENV={"PATH": os.environ["PATH"]}, toolchain=toolchain, **extraEnvArgs) diff --git a/interfaces/cython/SConscript b/interfaces/cython/SConscript index 958c90b5cb..528b7d86bb 100644 --- a/interfaces/cython/SConscript +++ b/interfaces/cython/SConscript @@ -58,7 +58,7 @@ for pyxfile in multi_glob(localenv, "cantera", "pyx"): cython_obj.append(obj) copy_header = localenv.Command('#src/extensions/delegator.h', '#build/python/cantera/delegator.h', - Copy('$TARGET', '$SOURCE')) + Copy('$TARGET', '$SOURCE')) ext_manager = localenv.SharedObject("#src/extensions/PythonExtensionManager.cpp") cython_obj.extend(ext_manager) @@ -106,233 +106,10 @@ else: localenv.Depends(ext, localenv['cantera_shlib']) for f in (multi_glob(localenv, 'cantera', 'py') + - multi_glob(localenv, 'cantera/*', 'py', 'in')): + multi_glob(localenv, 'cantera/*', 'py')): localenv.Depends(mod, f) -UNITS = { - "cp_mass": '"J/kg/K"', "cp_mole": '"J/kmol/K"', "cv_mass": '"J/kg/K"', - "cv_mole": '"J/kmol/K"', "density_mass": '"kg/m**3"', "density_mole": '"kmol/m**3"', - "enthalpy_mass": '"J/kg"', "enthalpy_mole": '"J/kmol"', "entropy_mass": '"J/kg/K"', - "entropy_mole": '"J/kmol/K"', "gibbs_mass": '"J/kg"', "gibbs_mole": '"J/kmol"', - "int_energy_mass": '"J/kg"', "int_energy_mole": '"J/kmol"', - "volume_mass": '"m**3/kg"', "volume_mole": '"m**3/kmol"', "T": '"K"', "P": '"Pa"', - "X": '"dimensionless"', "Y": '"dimensionless"', "Q": '"dimensionless"', - "cp": '"J/K/" + self.basis_units', "cv": '"J/K/" + self.basis_units', - "density": 'self.basis_units + "/m**3"', "h": '"J/" + self.basis_units', - "s": '"J/K/" + self.basis_units', "g": '"J/" + self.basis_units', - "u": '"J/" + self.basis_units', "v": '"m**3/" + self.basis_units', - "H": '"J/" + self.basis_units', "V": '"m**3/" + self.basis_units', - "S": '"J/K/" + self.basis_units', "D": 'self.basis_units + "/m**3"', - "U": '"J/" + self.basis_units', "P_sat": '"Pa"', "T_sat": '"K"', - "atomic_weight": '"kg/kmol"', "chemical_potentials": '"J/kmol"', - "concentrations": '"kmol/m**3"', "critical_pressure": '"Pa"', - "critical_temperature": '"K"', "critical_density": 'self.basis_units + "/m**3"', - "electric_potential": '"V"', "electrochemical_potentials": '"J/kmol"', - "isothermal_compressibility": '"1/Pa"', "max_temp": '"K"', - "mean_molecular_weight": '"kg/kmol"', "min_temp": '"K"', - "molecular_weights": '"kg/kmol"', "partial_molar_cp": '"J/kmol/K"', - "partial_molar_enthalpies": '"J/kmol"', "partial_molar_entropies": '"J/kmol/K"', - "partial_molar_int_energies": '"J/kmol"', "partial_molar_volumes": '"m**3/kmol"', - "reference_pressure": '"Pa"', "thermal_expansion_coeff": '"1/K"' -} - -SYMBOL = { - "T": "T", "P": "P", "D": "density", "H": "h", "S": "s", - "V": "v", "U": "u", "Q": "Q", "X": "X", "Y": "Y" -} - -getter_properties = [ - "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", - "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", - "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "cv_mass", "cv_mole", "P", - "P_sat", "T", "T_sat", "atomic_weight", "chemical_potentials", "concentrations", - "critical_pressure", "critical_temperature", "critical_density", - "electric_potential", "electrochemical_potentials", "isothermal_compressibility", - "max_temp", "mean_molecular_weight", "min_temp", "molecular_weights", - "partial_molar_cp", "partial_molar_enthalpies", "partial_molar_entropies", - "partial_molar_int_energies", "partial_molar_volumes", "reference_pressure", - "thermal_expansion_coeff", "cp", "cv", "density", "h", "s", "g", "u", "v", -] - -getter_template = Template(""" - @property - @copy_doc - def ${name}(self): - return Q_(self._phase.${name}, ${units}) -""") - -thermophase_getters = [] -for name in getter_properties: - thermophase_getters.append(getter_template.substitute(name=name, units=UNITS[name])) - -setter_2_template = Template(""" - @property - @copy_doc - def ${name}(self): - ${n0}, ${n1} = self._phase.${name} - return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}) - - @${name}.setter - def ${name}(self, value): - ${n0} = value[0] if value[0] is not None else self.${n0} - ${n1} = value[1] if value[1] is not None else self.${n1} - for val, unit in ((${n0}, ${u0}), (${n1}, ${u1})): - try: - val.ito(unit) - except AttributeError as e: - if "'ito'" in str(e): - raise CanteraError( - f"Value {val!r} must be an instance of a pint.Quantity class" - ) from None - else: - raise # pragma: no cover - self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude -""") - -tp_setter_2_properties = ["TP", "DP", "HP", "SP", "SV", "TD", "UV"] -pf_setter_2_properties = ["PQ", "TQ", "PV", "SH", "ST", "TH", "TV", "UP", "VH"] - -thermophase_2_setters = [] -for name in tp_setter_2_properties: - d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], - u1=UNITS[name[1]]) - thermophase_2_setters.append(setter_2_template.substitute(d)) - -purefluid_2_setters = [] -for name in pf_setter_2_properties: - d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], - u1=UNITS[name[1]]) - purefluid_2_setters.append(setter_2_template.substitute(d)) - -setter_3_template = Template(""" - @property - @copy_doc - def ${name}(self): - ${n0}, ${n1}, ${n2} = self._phase.${name} - return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) - - @${name}.setter - def ${name}(self, value): - ${n0} = value[0] if value[0] is not None else self.${n0} - ${n1} = value[1] if value[1] is not None else self.${n1} - for val, unit in ((${n0}, ${u0}), (${n1}, ${u1})): - try: - val.ito(unit) - except AttributeError as e: - if "'ito'" in str(e): - raise CanteraError( - f"Value {val!r} must be an instance of a pint.Quantity class" - ) from None - else: - raise # pragma: no cover - if value[2] is not None: - try: - ${n2} = value[2].to(${u2}).magnitude - except AttributeError: - ${n2} = value[2] - else: - ${n2} = self.${n2}.magnitude - self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude, ${n2} -""") - -tp_setter_3_properties = [ - "TPX", "TPY", "DPX", "DPY", "HPX", "HPY", "SPX", "SPY", "SVX", "SVY", "TDX", "TDY", - "UVX", "UVY" -] - -thermophase_3_setters = [] -for name in tp_setter_3_properties: - d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], - u1=UNITS[name[1]], n2=SYMBOL[name[2]], u2=UNITS[name[2]]) - thermophase_3_setters.append(setter_3_template.substitute(d)) - -getter_3_template = Template(""" - @property - @copy_doc - def ${name}(self): - ${n0}, ${n1}, ${n2} = self._phase.${name} - return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) -""") - -pf_getter_3_properties = ["DPQ", "HPQ", "SPQ", "SVQ", "TDQ", "UVQ"] - -purefluid_3_getters = [] -for name in pf_getter_3_properties: - d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], - n2=name[2], u2=UNITS[name[2]]) - purefluid_3_getters.append(getter_3_template.substitute(d)) - - -def recursive_join(*args, joiner=""): - result = "" - for arg in args: - result = result + joiner.join(arg) - return result - - -thermophase_properties = recursive_join(thermophase_getters, thermophase_2_setters, - thermophase_3_setters) -purefluid_properties = recursive_join(purefluid_2_setters, purefluid_3_getters) - -common_properties = """ - def __getattr__(self, name): - return getattr(self._phase, name) - - def __setattr__(self, name, value): - if name in dir(self): - object.__setattr__(self, name, value) - else: - setattr(self._phase, name, value) - - @property - def basis_units(self): - \"\"\"The units associated with the mass/molar basis of this phase.\"\"\" - if self._phase.basis == "mass": - return "kg" - else: - return "kmol" - - @property - @copy_doc - def X(self): - \"\"\"If an array is used for setting, the units must be dimensionless.\"\"\" - X = self._phase.X - return Q_(X, "dimensionless") - - @X.setter - def X(self, value): - if value is not None: - try: - X = value.to("dimensionless").magnitude - except AttributeError: - X = value - else: - X = self.X.magnitude - self._phase.X = X - - @property - @copy_doc - def Y(self): - \"\"\"If an array is used for setting, the units must be dimensionless.\"\"\" - Y = self._phase.Y - return Q_(Y, "dimensionless") - - @Y.setter - def Y(self, value): - if value is not None: - try: - Y = value.to("dimensionless").magnitude - except AttributeError: - Y = value - else: - Y = self.Y.magnitude - self._phase.Y = Y -""" - -localenv["common_properties"] = common_properties.strip() -localenv["thermophase_properties"] = thermophase_properties.strip() -localenv["purefluid_properties"] = purefluid_properties.strip() -units = localenv.SubstFile( +units = localenv.UnitsInterfaceBuilder( "cantera/with_units/solution.py", "cantera/with_units/solution.py.in", ) diff --git a/interfaces/python_sdist/SConscript b/interfaces/python_sdist/SConscript index 1bf55332df..0c673ed87a 100644 --- a/interfaces/python_sdist/SConscript +++ b/interfaces/python_sdist/SConscript @@ -159,6 +159,10 @@ config_h_in_target = sdist(localenv.Command( config_builder, )) +sdist(localenv.UnitsInterfaceBuilder( + "cantera/with_units/solution.py", + "#interfaces/cython/cantera/with_units/solution.py.in", +)) # Use RecursiveInstall to make sure that files are not overwritten during the copy. # A normal Copy Action would fail because of the existing directories. sdist(localenv.RecursiveInstall("cantera", diff --git a/interfaces/python_sdist/setup.cfg.in b/interfaces/python_sdist/setup.cfg.in index 66e5605913..89ec6775a7 100644 --- a/interfaces/python_sdist/setup.cfg.in +++ b/interfaces/python_sdist/setup.cfg.in @@ -61,6 +61,7 @@ cantera = *.cpp, *.h, *.pyx [options.extras_require] pandas = pandas +units = pint [options.entry_points] console_scripts = diff --git a/site_scons/site_tools/UnitsInterfaceBuilder.py b/site_scons/site_tools/UnitsInterfaceBuilder.py new file mode 100644 index 0000000000..0c9af9a51b --- /dev/null +++ b/site_scons/site_tools/UnitsInterfaceBuilder.py @@ -0,0 +1,248 @@ +from textwrap import dedent, indent +from string import Template + +def UnitsInterfaceBuilder(env, target, source): + """This builder creates the cantera.with_units interface for the Python package. + + This builder is meant to be called like + ``env.UnitsInterfaceBuilder(target_file, template_source_file)`` + + The builder will create string templates for all the ``ThermoPhase`` methods and + fill them in with the appropriate SI + kmol units to be converted with pint. + + The return value is an item of type ``env.SubstFile`` which can be put into a + ``Depends`` call, so that this builder will be called for a particular module. + """ + UNITS = { + "cp_mass": '"J/kg/K"', "cp_mole": '"J/kmol/K"', "cv_mass": '"J/kg/K"', + "cv_mole": '"J/kmol/K"', "density_mass": '"kg/m**3"', "density_mole": '"kmol/m**3"', + "enthalpy_mass": '"J/kg"', "enthalpy_mole": '"J/kmol"', "entropy_mass": '"J/kg/K"', + "entropy_mole": '"J/kmol/K"', "gibbs_mass": '"J/kg"', "gibbs_mole": '"J/kmol"', + "int_energy_mass": '"J/kg"', "int_energy_mole": '"J/kmol"', + "volume_mass": '"m**3/kg"', "volume_mole": '"m**3/kmol"', "T": '"K"', "P": '"Pa"', + "X": '"dimensionless"', "Y": '"dimensionless"', "Q": '"dimensionless"', + "cp": '"J/K/" + self.basis_units', "cv": '"J/K/" + self.basis_units', + "density": 'self.basis_units + "/m**3"', "h": '"J/" + self.basis_units', + "s": '"J/K/" + self.basis_units', "g": '"J/" + self.basis_units', + "u": '"J/" + self.basis_units', "v": '"m**3/" + self.basis_units', + "H": '"J/" + self.basis_units', "V": '"m**3/" + self.basis_units', + "S": '"J/K/" + self.basis_units', "D": 'self.basis_units + "/m**3"', + "U": '"J/" + self.basis_units', "P_sat": '"Pa"', "T_sat": '"K"', + "atomic_weight": '"kg/kmol"', "chemical_potentials": '"J/kmol"', + "concentrations": '"kmol/m**3"', "critical_pressure": '"Pa"', + "critical_temperature": '"K"', "critical_density": 'self.basis_units + "/m**3"', + "electric_potential": '"V"', "electrochemical_potentials": '"J/kmol"', + "isothermal_compressibility": '"1/Pa"', "max_temp": '"K"', + "mean_molecular_weight": '"kg/kmol"', "min_temp": '"K"', + "molecular_weights": '"kg/kmol"', "partial_molar_cp": '"J/kmol/K"', + "partial_molar_enthalpies": '"J/kmol"', "partial_molar_entropies": '"J/kmol/K"', + "partial_molar_int_energies": '"J/kmol"', "partial_molar_volumes": '"m**3/kmol"', + "reference_pressure": '"Pa"', "thermal_expansion_coeff": '"1/K"' + } + + SYMBOL = { + "T": "T", "P": "P", "D": "density", "H": "h", "S": "s", + "V": "v", "U": "u", "Q": "Q", "X": "X", "Y": "Y" + } + + getter_properties = [ + "density_mass", "density_mole", "enthalpy_mass", "enthalpy_mole", "entropy_mass", + "entropy_mole", "int_energy_mass", "int_energy_mole", "volume_mass", "volume_mole", + "gibbs_mass", "gibbs_mole", "cp_mass", "cp_mole", "cv_mass", "cv_mole", "P", + "P_sat", "T", "T_sat", "atomic_weight", "chemical_potentials", "concentrations", + "critical_pressure", "critical_temperature", "critical_density", + "electric_potential", "electrochemical_potentials", "isothermal_compressibility", + "max_temp", "mean_molecular_weight", "min_temp", "molecular_weights", + "partial_molar_cp", "partial_molar_enthalpies", "partial_molar_entropies", + "partial_molar_int_energies", "partial_molar_volumes", "reference_pressure", + "thermal_expansion_coeff", "cp", "cv", "density", "h", "s", "g", "u", "v", + ] + + getter_template = Template(dedent(""" + @property + @copy_doc + def ${name}(self): + return Q_(self._phase.${name}, ${units}) + """)) + + thermophase_getters = [] + for name in getter_properties: + thermophase_getters.append(getter_template.substitute(name=name, units=UNITS[name])) + + setter_2_template = Template(dedent(""" + @property + @copy_doc + def ${name}(self): + ${n0}, ${n1} = self._phase.${name} + return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}) + + @${name}.setter + def ${name}(self, value): + ${n0} = value[0] if value[0] is not None else self.${n0} + ${n1} = value[1] if value[1] is not None else self.${n1} + for val, unit in ((${n0}, ${u0}), (${n1}, ${u1})): + try: + val.ito(unit) + except AttributeError as e: + if "'ito'" in str(e): + raise CanteraError( + f"Value {val!r} must be an instance of a pint.Quantity class" + ) from None + else: + raise + self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude + """)) + + tp_setter_2_properties = ["TP", "DP", "HP", "SP", "SV", "TD", "UV"] + pf_setter_2_properties = ["PQ", "TQ", "PV", "SH", "ST", "TH", "TV", "UP", "VH"] + + thermophase_2_setters = [] + for name in tp_setter_2_properties: + d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], + u1=UNITS[name[1]]) + thermophase_2_setters.append(setter_2_template.substitute(d)) + + purefluid_2_setters = [] + for name in pf_setter_2_properties: + d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], + u1=UNITS[name[1]]) + purefluid_2_setters.append(setter_2_template.substitute(d)) + + setter_3_template = Template(dedent(""" + @property + @copy_doc + def ${name}(self): + ${n0}, ${n1}, ${n2} = self._phase.${name} + return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) + + @${name}.setter + def ${name}(self, value): + ${n0} = value[0] if value[0] is not None else self.${n0} + ${n1} = value[1] if value[1] is not None else self.${n1} + for val, unit in ((${n0}, ${u0}), (${n1}, ${u1})): + try: + val.ito(unit) + except AttributeError as e: + if "'ito'" in str(e): + raise CanteraError( + f"Value {val!r} must be an instance of a pint.Quantity class" + ) from None + else: + raise + if value[2] is not None: + try: + ${n2} = value[2].to(${u2}).magnitude + except AttributeError: + ${n2} = value[2] + else: + ${n2} = self.${n2}.magnitude + self._phase.${name} = ${n0}.magnitude, ${n1}.magnitude, ${n2} + """)) + + tp_setter_3_properties = [ + "TPX", "TPY", "DPX", "DPY", "HPX", "HPY", "SPX", "SPY", "SVX", "SVY", "TDX", "TDY", + "UVX", "UVY" + ] + + thermophase_3_setters = [] + for name in tp_setter_3_properties: + d = dict(name=name, n0=SYMBOL[name[0]], u0=UNITS[name[0]], n1=SYMBOL[name[1]], + u1=UNITS[name[1]], n2=SYMBOL[name[2]], u2=UNITS[name[2]]) + thermophase_3_setters.append(setter_3_template.substitute(d)) + + getter_3_template = Template(dedent(""" + @property + @copy_doc + def ${name}(self): + ${n0}, ${n1}, ${n2} = self._phase.${name} + return Q_(${n0}, ${u0}), Q_(${n1}, ${u1}), Q_(${n2}, ${u2}) + """)) + + pf_getter_3_properties = ["DPQ", "HPQ", "SPQ", "SVQ", "TDQ", "UVQ"] + + purefluid_3_getters = [] + for name in pf_getter_3_properties: + d = dict(name=name, n0=name[0], u0=UNITS[name[0]], n1=name[1], u1=UNITS[name[1]], + n2=name[2], u2=UNITS[name[2]]) + purefluid_3_getters.append(getter_3_template.substitute(d)) + + + def recursive_join(*args, joiner=""): + result = "" + for arg in args: + result = result + joiner.join(arg) + return result + + + thermophase_properties = recursive_join(thermophase_getters, thermophase_2_setters, + thermophase_3_setters) + purefluid_properties = recursive_join(purefluid_2_setters, purefluid_3_getters) + + common_properties = dedent(""" + def __getattr__(self, name): + return getattr(self._phase, name) + + def __setattr__(self, name, value): + if name in dir(self): + object.__setattr__(self, name, value) + else: + setattr(self._phase, name, value) + + @property + def basis_units(self): + \"\"\"The units associated with the mass/molar basis of this phase.\"\"\" + if self._phase.basis == "mass": + return "kg" + else: + return "kmol" + + @property + @copy_doc + def X(self): + \"\"\"If an array is used for setting, the units must be dimensionless.\"\"\" + X = self._phase.X + return Q_(X, "dimensionless") + + @X.setter + def X(self, value): + if value is not None: + try: + X = value.to("dimensionless").magnitude + except AttributeError: + X = value + else: + X = self.X.magnitude + self._phase.X = X + + @property + @copy_doc + def Y(self): + \"\"\"If an array is used for setting, the units must be dimensionless.\"\"\" + Y = self._phase.Y + return Q_(Y, "dimensionless") + + @Y.setter + def Y(self, value): + if value is not None: + try: + Y = value.to("dimensionless").magnitude + except AttributeError: + Y = value + else: + Y = self.Y.magnitude + self._phase.Y = Y + """) + + env["common_properties"] = indent(common_properties.strip(), " "*4) + env["thermophase_properties"] = indent(thermophase_properties.strip(), " "*4) + env["purefluid_properties"] = indent(purefluid_properties.strip(), " "*4) + units = env.SubstFile(target, source) + return units + + +def generate(env, **kw): + env.AddMethod(UnitsInterfaceBuilder) + + +def exists(env): + return True From 0f9167b713faf315ed782485dd15f4d49868c656 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 23 Apr 2023 10:40:04 -0600 Subject: [PATCH 36/41] General cleanups --- .github/workflows/main.yml | 3 --- interfaces/python_sdist/MANIFEST.in | 1 - interfaces/python_sdist/SConscript | 2 -- interfaces/python_sdist/pyproject.toml | 9 ++++++++- interfaces/python_sdist/setup.cfg.in | 10 +++++++--- interfaces/python_sdist/setup.py | 22 +++++++++------------- site_scons/buildutils.py | 1 - 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e405dfa2af..f6a0bf6ff2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -377,9 +377,6 @@ jobs: path: dist - name: Upgrade pip run: python3 -m pip install -U pip setuptools wheel - - name: Install typing_extensions for Python 3.7 - if: matrix.python-version == '3.7' - run: python3 -m pip install typing-extensions - name: Install Python dependencies run: | python3 -m pip install numpy ruamel.yaml h5py pandas matplotlib scipy pint diff --git a/interfaces/python_sdist/MANIFEST.in b/interfaces/python_sdist/MANIFEST.in index dc998757e1..9b1ae04f48 100644 --- a/interfaces/python_sdist/MANIFEST.in +++ b/interfaces/python_sdist/MANIFEST.in @@ -3,7 +3,6 @@ # listed in setup.cfg:options.exclude_package_data. include cantera/_cantera.cpp -include cantera/_cantera.h recursive-include cantera *.pyx include sundials_config.h.in include config.h.in diff --git a/interfaces/python_sdist/SConscript b/interfaces/python_sdist/SConscript index 0c673ed87a..85ac5644cb 100644 --- a/interfaces/python_sdist/SConscript +++ b/interfaces/python_sdist/SConscript @@ -170,8 +170,6 @@ sdist(localenv.RecursiveInstall("cantera", exclude=["__pycache__"])) sdist(localenv.RecursiveInstall("cantera/data", "#build/data")) -sdist(localenv.RecursiveInstall("cantera/test/data", - "#test/data")) # Copy the minimal Sundials configuration template into the sdist so that # it can be filled in at compile time on the user's machine diff --git a/interfaces/python_sdist/pyproject.toml b/interfaces/python_sdist/pyproject.toml index a272bb9561..1bb9493c62 100644 --- a/interfaces/python_sdist/pyproject.toml +++ b/interfaces/python_sdist/pyproject.toml @@ -1,3 +1,10 @@ [build-system] -requires = ["setuptools>=43.0.0", "wheel", "oldest-supported-numpy", "Cython>=0.29.12"] +# These versions are pinned to the latest versions at the time of this commit. +# Feel free to update as required. +requires = [ + "setuptools==67.7.1", + "wheel", + "oldest-supported-numpy", + "Cython==0.29.34", +] build-backend = "setuptools.build_meta" diff --git a/interfaces/python_sdist/setup.cfg.in b/interfaces/python_sdist/setup.cfg.in index 89ec6775a7..77aa5557b7 100644 --- a/interfaces/python_sdist/setup.cfg.in +++ b/interfaces/python_sdist/setup.cfg.in @@ -45,15 +45,19 @@ install_requires = packaging python_requires @py_requires_ver_str@ packages = - cantera cantera.data + cantera.examples + cantera.test + cantera.with_units # These options include data in the sdist and wheel if the files are also listed in # MANIFEST.in. Note that only files that are inside the "cantera" packages will be # included in the wheel. [options.package_data] -cantera.data = *.*, */*.* -cantera = *.pxd, test/*.txt, examples/*.txt +cantera = *.pxd +cantera.data = *.* +cantera.examples = *.txt +cantera.test = *.txt # These options exclude data from the wheel file but not from the sdist [options.exclude_package_data] diff --git a/interfaces/python_sdist/setup.py b/interfaces/python_sdist/setup.py index 705d2608f0..127297a178 100644 --- a/interfaces/python_sdist/setup.py +++ b/interfaces/python_sdist/setup.py @@ -8,14 +8,13 @@ import numpy import shutil -HERE = Path(__file__).parent -CT_SRC = HERE / "src" -EXT_SRC = HERE / "ext" -CT_INCLUDE = HERE / "include" +CT_SRC = Path("src") +EXT_SRC = Path("ext") +CT_INCLUDE = Path("include") BOOST_INCLUDE = None FORCE_CYTHON_COMPILE = False -CYTHON_BUILT_FILES = [HERE / "cantera" / f"_cantera.{ext}" for ext in ("cpp", "h")] +CYTHON_BUILT_FILES = [Path("cantera") / f"_cantera.{ext}" for ext in ("cpp", "h")] class CanteraOptionsMixin: @@ -114,9 +113,6 @@ def configure_build(): config_h = {} - def create_config(key, value): - config_h[key] = f"#define {key} {value}" - if not boost_version: raise ValueError( "Could not find Boost headers. Please set an environment variable called " @@ -134,7 +130,7 @@ def create_config(key, value): ) if sys.platform != "win32": - extra_compile_flags = ["-std=c++14", "-g0"] + extra_compile_flags = ["-std=c++17", "-g0"] sundials_configh = { "SUNDIALS_USE_GENERIC_MATH": "#define SUNDIALS_USE_GENERIC_MATH 1", "SUNDIALS_BLAS_LAPACK": "/* #undef SUNDIALS_BLAS_LAPACK */" @@ -150,14 +146,14 @@ def create_config(key, value): } sundials_cflags = [] - sun_config_h_in = (HERE / "sundials_config.h.in").read_text() - sun_config_h = HERE / "sundials_config.h" + sun_config_h_in = Path("sundials_config.h.in").read_text() + sun_config_h = Path("sundials_config.h") sun_config_h.write_text(sun_config_h_in.format_map(sundials_configh)) shutil.copy2(sun_config_h, EXT_SRC / "sundials" / "sundials") shutil.copy2(sun_config_h, CT_INCLUDE / "cantera" / "ext" / "sundials") - config_h_in = (HERE / "config.h.in").read_text() - ct_config_h = HERE / "include" / "cantera" / "base" / "config.h" + config_h_in = Path("config.h.in").read_text() + ct_config_h = Path("include") / "cantera" / "base" / "config.h" ct_config_h.write_text(config_h_in.format_map(config_h)) return extra_compile_flags, sundials_cflags, sundials_macros diff --git a/site_scons/buildutils.py b/site_scons/buildutils.py index 1b560e8334..601653338a 100644 --- a/site_scons/buildutils.py +++ b/site_scons/buildutils.py @@ -2,7 +2,6 @@ import json import os import sys -import platform import textwrap import re import subprocess From 5396e79006315bb34fab71d4524d7098aefdc7d4 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 23 Apr 2023 10:40:45 -0600 Subject: [PATCH 37/41] Fix one problem with building the sdist --- interfaces/python_sdist/SConscript | 2 ++ 1 file changed, 2 insertions(+) diff --git a/interfaces/python_sdist/SConscript b/interfaces/python_sdist/SConscript index 85ac5644cb..0aaf71b7e3 100644 --- a/interfaces/python_sdist/SConscript +++ b/interfaces/python_sdist/SConscript @@ -38,6 +38,8 @@ def copy_ext_src(target, source, env): if not FMT_SOURCE.is_dir(): raise ValueError("Missing fmt submodule. " + error_message) for cc_file in (FMT_SOURCE / "src").glob("*.cc"): + if cc_file.name == "fmt.cc": + continue shutil.copy2(cc_file, FMT_ROOT) shutil.copytree(FMT_SOURCE / "include" / "fmt", FMT_ROOT / "fmt") From 7459ec3c28409d0b11b555b7d47dac541a2345c4 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 23 Apr 2023 10:47:34 -0600 Subject: [PATCH 38/41] Address a bunch of review comments --- doc/sphinx/conf.py | 36 +-------- doc/sphinx/cython/units.rst | 9 ++- .../cython/cantera/with_units/solution.py.in | 73 ++++++------------- samples/python/thermo/isentropic_units.py | 12 +-- samples/python/thermo/sound_speed_units.py | 5 +- 5 files changed, 43 insertions(+), 92 deletions(-) diff --git a/doc/sphinx/conf.py b/doc/sphinx/conf.py index 0186b1c1fa..cf1625b3a1 100644 --- a/doc/sphinx/conf.py +++ b/doc/sphinx/conf.py @@ -84,48 +84,18 @@ def escape_splats(app, what, name, obj, options, lines): lines[i] = l.replace("*", r"\*") app.connect('autodoc-process-docstring', escape_splats) - # NAMES = [] - # DIRECTIVES = {} - - # def get_rst(app, what, name, obj, options, signature, - # return_annotation): - # if "with_units" not in name: - # return - # doc_indent = ' ' - # directive_indent = '' - # if what in ['method', 'attribute']: - # doc_indent += ' ' - # directive_indent += ' ' - # directive = '%s.. py:%s:: %s' % (directive_indent, what, name) - # if signature: # modules, attributes, ... don't have a signature - # directive += signature - # NAMES.append(name) - # rst = directive + '\n\n' + doc_indent + obj.__doc__ + '\n' - # DIRECTIVES[name] = rst - - # def write_new_docs(app, exception): - # txt = ['My module documentation'] - # txt.append('-----------------------\n') - # for name in NAMES: - # txt.append(DIRECTIVES[name]) - # # print('\n'.join(txt)) - # with open('../doc_new/generated.rst', 'w') as outfile: - # outfile.write('\n'.join(txt)) - - # app.connect('autodoc-process-signature', get_rst) - # app.connect('build-finished', write_new_docs) - autoclass_content = 'both' doxylink = { - 'ct': (os.path.abspath('../../build/docs/Cantera.tag'), - '../../doxygen/html/') + 'ct': (os.path.abspath('../../build/docs/Cantera.tag'), + '../../doxygen/html/') } intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), 'numpy': ('https://numpy.org/doc/stable/', None), + 'pint': ('https://pint.readthedocs.io/en/stable/', None), } # Ensure that the primary domain is the Python domain, since we've added the diff --git a/doc/sphinx/cython/units.rst b/doc/sphinx/cython/units.rst index 8c8b28d287..ae70390faf 100644 --- a/doc/sphinx/cython/units.rst +++ b/doc/sphinx/cython/units.rst @@ -3,10 +3,17 @@ Python Interface With Units =========================== +This interface allows users to specify physical units associated with quantities. +To do so, this interface leverages the `pint `__ +library to provide consistent unit conversion. + +Solution with Units +------------------- + .. autoclass:: Solution PureFluid Phases With Units -=========================== +--------------------------- The following convenience classes are available to create `PureFluid ` objects with the indicated equation of state: diff --git a/interfaces/cython/cantera/with_units/solution.py.in b/interfaces/cython/cantera/with_units/solution.py.in index b8d391eb99..d6aa2993ce 100644 --- a/interfaces/cython/cantera/with_units/solution.py.in +++ b/interfaces/cython/cantera/with_units/solution.py.in @@ -17,6 +17,12 @@ Q_ = units.Quantity def copy_doc(method): + """Decorator to copy docstrings from related methods in upstream classes. + + This decorator will copy the docstring from the same named method in the upstream + class, either `Solution` or `PureFluid`. The docstring in the method being + decorated is appended to the upstream documentation. + """ doc = getattr(method, "__doc__", None) or "" if isinstance(method, property): method = method.fget @@ -32,10 +38,6 @@ def copy_doc(method): class Solution: """ - A class for chemically-reacting solutions. Instances can be created to - represent any type of solution -- a mixture of gases, a liquid solution, or - a solid solution, for example. - This implementation of `Solution ` operates with units by using the `pint` library to convert between unit systems. All properties are assigned units in the standard MKS system that Cantera uses, substituting kmol @@ -50,51 +52,19 @@ class Solution: See the `pint documentation `__ for more information about using pint's ``Quantity`` classes. - The most common way to instantiate `Solution` objects is by using a phase - definition, species and reactions defined in an input file:: - - gas = ct.Solution('gri30.yaml') - - If an input file defines multiple phases, the corresponding key in the - ``phases`` map can be used to specify the desired phase via the ``name`` keyword - argument of the constructor:: - - gas = ct.Solution('diamond.yaml', name='gas') - diamond = ct.Solution('diamond.yaml', name='diamond') - - The name of the `Solution` object defaults to the *phase* identifier - specified in the input file. Upon initialization of a `Solution` object, - a custom name can assigned via:: - - gas.name = 'my_custom_name' - - In addition, `Solution` objects can be constructed by passing the text of - the YAML phase definition in directly, using the ``yaml`` keyword - argument:: - - yaml_def = ''' - phases: - - name: gas - thermo: ideal-gas - kinetics: gas - elements: [O, H, Ar] - species: - - gri30.yaml/species: all - reactions: - - gri30.yaml/reactions: declared-species - skip-undeclared-elements: true - skip-undeclared-third-bodies: true - state: {T: 300, P: 1 atm} - ''' - gas = ct.Solution(yaml=yaml_def) + **Note:** This class is experimental. It only implements methods from `ThermoPhase`. + Methods from other classes are not yet supported. If you are interested in contributing + to this feature, please chime in on our enhancements issue: + ``__. """ def __init__(self, infile="", name="", *, yaml=None): self.__dict__["_phase"] = _Solution(infile, name, yaml=yaml) - @common_properties@ +@common_properties@ - @thermophase_properties@ +@thermophase_properties@ +Solution.__doc__ = f"{Solution.__doc__}\n{_Solution.__doc__}" class PureFluid: """ @@ -113,9 +83,9 @@ class PureFluid: about using pint's ``Quantity`` classes. """ def __init__(self, infile, name="", *, yaml=None, **kwargs): - self.__dict__["_phase"] = _PureFluid(infile, name, **kwargs) + self.__dict__["_phase"] = _PureFluid(infile, name, yaml=yaml, **kwargs) - @common_properties@ +@common_properties@ @property @copy_doc @@ -135,12 +105,12 @@ class PureFluid: f"Value {value!r} must be an instance of a pint.Quantity class" ) from None else: - raise # pragma: no cover + raise else: Q = self.Q.magnitude self._phase.Q = Q - @thermophase_properties@ +@thermophase_properties@ @property @copy_doc @@ -150,7 +120,6 @@ class PureFluid: @TPQ.setter def TPQ(self, value): - msg = "Value {value!r} must be an instance of a pint.Quantity class" T = value[0] if value[0] is not None else self.T P = value[1] if value[1] is not None else self.P Q = value[2] if value[2] is not None else self.Q @@ -159,12 +128,14 @@ class PureFluid: val.ito(unit) except AttributeError as e: if "'ito'" in str(e): - raise CanteraError(msg.format(value=val)) from None + raise CanteraError( + f"Value {val!r} must be an instance of a pint.Quantity class" + ) from None else: - raise # pragma: no cover + raise self._phase.TPQ = T.magnitude, P.magnitude, Q.magnitude - @purefluid_properties@ +@purefluid_properties@ PureFluid.__doc__ = f"{PureFluid.__doc__}\n{_PureFluid.__doc__}" diff --git a/samples/python/thermo/isentropic_units.py b/samples/python/thermo/isentropic_units.py index d0f3523d2a..a5a1a4f655 100644 --- a/samples/python/thermo/isentropic_units.py +++ b/samples/python/thermo/isentropic_units.py @@ -1,5 +1,7 @@ """ -Isentropic, adiabatic flow example - calculate area ratio vs. Mach number curve +Isentropic, adiabatic flow example - calculate area ratio vs. Mach number curve. +Uses the pint library to include customized units in the calculation. + Requires: Cantera >= 3.0.0, pint Keywords: thermodynamics, compressible flow, units @@ -10,7 +12,7 @@ # This sets the default output format of the units to have 2 significant digits # and the units are printed with a Unicode font. See: -# https://pint.readthedocs.io/en/stable/formatting.html#unit-format-types +# https://pint.readthedocs.io/en/stable/user/formatting.html ctu.units.default_format = ".2F~P" @@ -18,8 +20,8 @@ def soundspeed(gas): """The speed of sound. Assumes an ideal gas.""" gamma = gas.cp / gas.cv - return np.sqrt(gamma * ctu.units.molar_gas_constant - * gas.T / gas.mean_molecular_weight).to("m/s") + specific_gas_constant = ctu.units.molar_gas_constant / gas.mean_molecular_weight + return np.sqrt(gamma * specific_gas_constant * gas.T).to("m/s") def isentropic(gas=None): @@ -45,7 +47,7 @@ def isentropic(gas=None): data = [] # compute values for a range of pressure ratios - p_range = np.logspace(-3, 0, 200) * p0 + p_range = np.logspace(-3, 0, 10) * p0 for p in p_range: # set the state using (p,s0) diff --git a/samples/python/thermo/sound_speed_units.py b/samples/python/thermo/sound_speed_units.py index aedf3bac5d..7500ae6a3b 100644 --- a/samples/python/thermo/sound_speed_units.py +++ b/samples/python/thermo/sound_speed_units.py @@ -1,5 +1,6 @@ """ -Compute the "equilibrium" and "frozen" sound speeds for a gas +Compute the "equilibrium" and "frozen" sound speeds for a gas. Uses the pint library to +include customized units in the calculation. Requires: Cantera >= 3.0.0, pint Keywords: thermodynamics, equilibrium, units @@ -57,7 +58,7 @@ def equilibrium_sound_speeds(gas, rtol=1.0e-6, max_iter=5000): if __name__ == "__main__": gas = ctu.Solution('gri30.yaml') gas.X = 'CH4:1.00, O2:2.0, N2:7.52' - T_range = np.linspace(80.33, 4760.33, 50) * ctu.units.degF + T_range = np.linspace(80, 4880, 25) * ctu.units.degF print("Temperature Equilibrium Sound Speed Frozen Sound Speed Frozen Sound Speed Check") for T in T_range: gas.TP = T, 1.0 * ctu.units.atm From ee8bdb6b3260725b610f89c1cdd7cfa60ec4f2b9 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Sun, 23 Apr 2023 11:17:36 -0600 Subject: [PATCH 39/41] Address comments about tests --- test/python/test_units.py | 90 ++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/test/python/test_units.py b/test/python/test_units.py index ca80e705c2..67a8db610f 100644 --- a/test/python/test_units.py +++ b/test/python/test_units.py @@ -29,13 +29,7 @@ def generic_phase(request): def test_setting_basis_units_fails(generic_phase): - # Python 3.10 includes the name of the attribute which was improperly used as a - # setter. Earlier versions have just a generic error message. - if sys.version_info.minor < 10: - match = "set attribute" - else: - match = "basis_units" - with pytest.raises(AttributeError, match=match): + with pytest.raises(AttributeError): generic_phase.basis_units = "some random string" @@ -109,6 +103,8 @@ def as_dict(self) -> Dict[str, float]: return dimensionality +# Create instances of Dimensions that correspond to all the combinations of dimensions +# that are implemented in the with_units interface. temperature = Dimensions(temperature=1, name="temperature") pressure = Dimensions(mass=1, length=-1, time=-2, name="pressure") isothermal_compressiblity = pressure.inverse() @@ -186,6 +182,24 @@ def yield_dimensions(): @pytest.mark.parametrize("prop,dimensions", yield_dimensions()) def test_dimensions(generic_phase, prop, dimensions): + """Test that the dimensions returned for a property are correct. + + Arguments + ========= + + generic_phase: A phase definition created from cantera.with_units objects. + Currently, one of `Solution` or `PureFluid`. Created by the generic_phase + fixture. + prop: A string of the property name + dimensions: The known dimensions for this property + + The latter two arguments are supplied by the parametrize on this test. That + parametrize is effectively a loop over all the implemented properties on the + classes. The loop is implemented in the `yield_dimensions()` function. The + parametrize call is kinda like calling ``list(generator_function())`` to + discover all the values in the generator, except pytest does that automatically + for us and fills in the arguments. + """ pint_dim = dict(getattr(generic_phase, prop).dimensionality) assert pint_dim == dimensions @@ -263,7 +277,7 @@ def some_setters_arent_implemented_for_purefluid(request): if is_pure_fluid and pair_or_triple.startswith("DP"): request.applymarker( pytest.mark.xfail( - raises=ctu.CanteraError, + raises=NotImplementedError, reason=f"The {pair_or_triple} method isn't implemented", ) ) @@ -361,6 +375,13 @@ def test_multi_prop_getters_are_equal_to_single(generic_phase, props, basis, rho if generic_phase.n_species != 1: generic_phase.Y = "H2:0.1, H2O2:0.1, AR:0.8" generic_phase.TD = ctu.Q_(350.0, "K"), rho_0 + # This test is equivalent to + # T, P, X = solution.TPX + # assert isclose(T, solution.T) + # assert isclose(P, solution.P) + # assert all(isclose(X, solution.X)) + # Where T, P, and X loop through all the valid property pairs and triples, for + # both mass and molar basis units. first_value, second_value, *third_value = getattr(generic_phase, pair_or_triple) assert_allclose(getattr(generic_phase, first), first_value) assert_allclose(getattr(generic_phase, second), second_value) @@ -374,36 +395,22 @@ def test_set_pair_without_units_is_an_error(generic_phase, pair): setattr(generic_phase, pair[0], [300, None]) -@pytest.fixture -def third_prop_sometimes_fails(request): - # Setting these property triples with None, None, [all equal fractions] results in - # a convergence error in setting the state. We don't care that it fails because - # this is just testing what happens to the value that gets passed in to the - # with_units setters in terms of conversion to/from values that don't have units - # already attached to them. - prop_triple = request.getfixturevalue("triple")[0] - is_purefluid = isinstance(request.getfixturevalue("generic_phase"), ctu.PureFluid) - if prop_triple in ("SPX", "UVX", "UVY", "HPX", "HPY", "SVX") and not is_purefluid: - return pytest.raises(ctu.CanteraError, match="ThermoPhase::setState") - elif prop_triple in ("DPX", "DPY") and is_purefluid: - return pytest.raises(ctu.CanteraError, match="setState_RP") - else: - return nullcontext() - @pytest.mark.parametrize("triple", yield_prop_triples()) -def test_set_triple_without_units_is_an_error( - generic_phase, - triple, - third_prop_sometimes_fails, -): - value_1 = [300, None, [1] * generic_phase.n_species] +def test_set_triple_without_units_is_an_error(generic_phase, triple): + value_1 = [300, None, [1] + [0] * (generic_phase.n_species - 1)] with pytest.raises(ctu.CanteraError, match="an instance of a pint"): setattr(generic_phase, triple[0], value_1) - value_3 = [None, None, [1] * generic_phase.n_species] - with third_prop_sometimes_fails: - setattr(generic_phase, triple[0], value_3) +@pytest.mark.parametrize("props", yield_prop_triples()) +@pytest.mark.usefixtures("some_setters_arent_implemented_for_purefluid") +def test_set_triple_with_no_units_on_composition_succeeds( + generic_phase, + props, +): + value_3 = [None, None, [1] + [0] * (generic_phase.n_species - 1)] + setattr(generic_phase, props[0], value_3) + assert_allclose(getattr(generic_phase, props[0][2]), value_3[2]) @pytest.fixture @@ -466,13 +473,17 @@ def yield_all_purefluid_only_props(): def test_set_without_units_is_error_purefluid(prop, pure_fluid): value = [None] * (len(prop[0]) - 1) + [0.5] with pytest.raises(ctu.CanteraError, match="an instance of a pint"): - # Don't use append here, because append returns None which would be - # passed to setattr setattr(pure_fluid, prop[0], value) @pytest.mark.parametrize("props", yield_all_purefluid_only_props()) def test_multi_prop_getters_purefluid(pure_fluid, props): + # This test is equivalent to + # T, P, X = pure_fluid.TPX + # assert isclose(T, pure_fluid.T) + # assert isclose(P, pure_fluid.P) + # assert all(isclose(X, pure_fluid.X)) + # Where T, P, and X loop through all the valid property pairs and triples. pair_or_triple, first, second, *third = props first_value, second_value, *third_value = getattr(pure_fluid, pair_or_triple) assert_allclose(getattr(pure_fluid, first), first_value) @@ -500,7 +511,6 @@ def test_setters_purefluid(props, pure_fluid): # Use TD setting to get the properties at the modified state pure_fluid.TD = T_1, rho_1 new_props = getattr(pure_fluid, pair_or_triple) - print(new_props) # Reset to the initial state so that the next state setting actually has to do # something. @@ -536,18 +546,20 @@ def test_thermophase_properties_exist(ideal_gas): tp = ct.ThermoPhase("h2o2.yaml") for attr in dir(tp): - if attr.startswith("_"): # or attr in skip: + if attr.startswith("_"): continue try: getattr(tp, attr) - except (ct.CanteraError, ct.ThermoModelMethodError): + except (NotImplementedError, ct.ThermoModelMethodError): continue assert hasattr(ideal_gas, attr) def test_purefluid_properties_exist(pure_fluid): + # Test that all the properties implemented on the "upstream" PureFluid class + # are also implemented for the "with_units" variety. pf = ct.PureFluid("liquidvapor.yaml", "water") for attr in dir(pf): if attr.startswith("_"): @@ -555,7 +567,7 @@ def test_purefluid_properties_exist(pure_fluid): try: getattr(pf, attr) - except (ct.CanteraError, ct.ThermoModelMethodError): + except (NotImplementedError, ct.ThermoModelMethodError): continue assert hasattr(pure_fluid, attr) From 3ba8aec75a7734329c56bd722280705782d51ae7 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Mon, 24 Apr 2023 07:27:53 -0600 Subject: [PATCH 40/41] Docs and AUTHORS --- AUTHORS | 1 + doc/sphinx/cython/units.rst | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/AUTHORS b/AUTHORS index 52ee1adfb5..83362a284c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,7 @@ tracker. Rounak Agarwal (@agarwalrounak) David Akinpelu (@DavidAkinpelu), Louisiana State University +Halla Ali (@hallaali) Emil Atz (@EmilAtz) Jongyoon Bae (@jongyoonbae), Brown University Philip Berndt diff --git a/doc/sphinx/cython/units.rst b/doc/sphinx/cython/units.rst index ae70390faf..28178f67f8 100644 --- a/doc/sphinx/cython/units.rst +++ b/doc/sphinx/cython/units.rst @@ -11,6 +11,7 @@ Solution with Units ------------------- .. autoclass:: Solution + :no-members: PureFluid Phases With Units --------------------------- @@ -19,14 +20,23 @@ The following convenience classes are available to create `PureFluid objects with the indicated equation of state: .. autoclass:: CarbonDioxide + :no-members: .. autoclass:: Heptane + :no-members: .. autoclass:: Hfc134a + :no-members: .. autoclass:: Hydrogen + :no-members: .. autoclass:: Methane + :no-members: .. autoclass:: Nitrogen + :no-members: .. autoclass:: Oxygen + :no-members: .. autoclass:: Water + :no-members: The full documentation for the `PureFluid ` class and its properties is here: .. autoclass:: PureFluid + :no-members: From 644bc79f8b302a370628027531c3cd376d05d36a Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Tue, 25 Apr 2023 14:07:10 -0400 Subject: [PATCH 41/41] Add a bit more docs --- doc/sphinx/cython/units.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/doc/sphinx/cython/units.rst b/doc/sphinx/cython/units.rst index 28178f67f8..f5036671a1 100644 --- a/doc/sphinx/cython/units.rst +++ b/doc/sphinx/cython/units.rst @@ -5,7 +5,14 @@ Python Interface With Units This interface allows users to specify physical units associated with quantities. To do so, this interface leverages the `pint `__ -library to provide consistent unit conversion. +library to provide consistent unit conversion. Several examples of this interface can +be found in the ``samples/python`` folder in the +`root of the repository `__. +Examples that use this interface are suffixed with `_units`. + +The overall goal is to provide a compatible implementation of the `cantera.Solution` and +`cantera.PureFluid` interfaces. Please see those pages for further documentation of the +available properties. Solution with Units -------------------