diff --git a/include/cantera/kinetics/Kinetics.h b/include/cantera/kinetics/Kinetics.h index 8975dd1eb05..7647509e369 100644 --- a/include/cantera/kinetics/Kinetics.h +++ b/include/cantera/kinetics/Kinetics.h @@ -130,7 +130,7 @@ class Kinetics //! Each class derived from Kinetics should override this method to return //! a meaningful identifier. virtual std::string kineticsType() const { - return "Kinetics"; + return "None"; } //! Finalize Kinetics object and associated objects diff --git a/include/cantera/thermo/Phase.h b/include/cantera/thermo/Phase.h index f80bbccc3db..7457fac1f6f 100644 --- a/include/cantera/thermo/Phase.h +++ b/include/cantera/thermo/Phase.h @@ -664,7 +664,8 @@ class Phase * \rho, Y_1, \dots, Y_K) \f$. Alternatively, it returns a stored value. */ virtual double pressure() const { - throw NotImplementedError("Phase::pressure"); + throw NotImplementedError("Phase::pressure", + "Not implemented for thermo model '{}'", type()); } //! Density (kg/m^3). @@ -703,7 +704,8 @@ class Phase * @param p input Pressure (Pa) */ virtual void setPressure(double p) { - throw NotImplementedError("Phase::setPressure"); + throw NotImplementedError("Phase::setPressure", + "Not implemented for thermo model '{}'", type()); } //! Set the internally stored temperature of the phase (K). diff --git a/include/cantera/thermo/ThermoPhase.h b/include/cantera/thermo/ThermoPhase.h index 1c5762a9462..52d15a4dcb5 100644 --- a/include/cantera/thermo/ThermoPhase.h +++ b/include/cantera/thermo/ThermoPhase.h @@ -111,7 +111,7 @@ class ThermoPhase : public Phase //! @{ virtual std::string type() const { - return "ThermoPhase"; + return "None"; } //! String indicating the mechanical phase of the matter in this Phase. diff --git a/interfaces/cython/cantera/_cantera.pxd b/interfaces/cython/cantera/_cantera.pxd index 30223c9798d..1e624510666 100644 --- a/interfaces/cython/cantera/_cantera.pxd +++ b/interfaces/cython/cantera/_cantera.pxd @@ -1246,6 +1246,7 @@ cdef class _SolutionBase: cdef int thermo_basis cdef np.ndarray _selected_species cdef object parent + cdef public object _references cdef class Species: cdef shared_ptr[CxxSpecies] _species @@ -1261,7 +1262,6 @@ cdef class ThermoPhase(_SolutionBase): cpdef int species_index(self, species) except * cdef np.ndarray _getArray1(self, thermoMethod1d method) cdef void _setArray1(self, thermoMethod1d method, values) except * - cdef public object _references cdef class InterfacePhase(ThermoPhase): cdef CxxSurfPhase* surf diff --git a/interfaces/cython/cantera/base.pyx b/interfaces/cython/cantera/base.pyx index 4ff9337ef38..563f546dfee 100644 --- a/interfaces/cython/cantera/base.pyx +++ b/interfaces/cython/cantera/base.pyx @@ -9,6 +9,35 @@ cdef class _SolutionBase: source=None, yaml=None, thermo=None, species=(), kinetics=None, reactions=(), **kwargs): + # run instantiation only if valid sources are specified + self._references = None + if origin or infile or source or yaml or (thermo and species): + + self._cinit(infile=infile, name=name, adjacent=adjacent, + origin=origin, source=source, yaml=yaml, + thermo=thermo, species=species, kinetics=kinetics, + reactions=reactions, **kwargs) + return + elif any([infile, source, adjacent, origin, source, yaml, + thermo, species, kinetics, reactions, kwargs]): + raise ValueError("Arguments are insufficient to define a phase") + + self._base = CxxNewSolution() + self.base = self._base.get() + self.base.setSource(stringify("none")) + + self.base.setThermo(newThermo(stringify("none"))) + self.thermo = self.base.thermo().get() + self.base.setKinetics(newKinetics(stringify("none"))) + self.kinetics = self.base.kinetics().get() + self.base.setTransport(newTransport(NULL, stringify("none"))) + self.transport = self.base.transport().get() + self._selected_species = np.ndarray(0, dtype=np.uint64) + + def _cinit(self, infile='', name='', adjacent=(), origin=None, + source=None, yaml=None, thermo=None, species=(), + kinetics=None, reactions=(), **kwargs): + if 'phaseid' in kwargs: if name is not '': raise AttributeError('duplicate specification of phase name') @@ -63,10 +92,8 @@ cdef class _SolutionBase: # Parse inputs if infile or source: self._init_cti_xml(infile, name, adjacent, source) - elif thermo and species: - self._init_parts(thermo, species, kinetics, adjacent, reactions) else: - raise ValueError("Arguments are insufficient to define a phase") + self._init_parts(thermo, species, kinetics, adjacent, reactions) self._selected_species = np.ndarray(0, dtype=np.uint64) @@ -152,6 +179,9 @@ cdef class _SolutionBase: self.base.setKinetics(newKinetics(stringify("none"))) self.kinetics = self.base.kinetics().get() + # Transport + self.transport = self.base.transport().get() + def _init_cti_xml(self, infile, name, adjacent, source): """ Instantiate a set of new Cantera C++ objects from a CTI or XML @@ -283,14 +313,14 @@ cdef class _SolutionBase: """ self.base.header().clear() - def write_yaml(self, filename, phases=None, units=None, precision=None, + def write_yaml(self, filename=None, phases=None, units=None, precision=None, skip_user_defined=None, header=True): """ Write the definition for this phase, any additional phases specified, and their species and reactions to the specified file. :param filename: - The name of the output file + The name of the output file; if ``None``, a YAML string is returned :param phases: Additional ThermoPhase / Solution objects to be included in the output file @@ -328,6 +358,8 @@ cdef class _SolutionBase: Y.precision = precision if skip_user_defined is not None: Y.skip_user_defined = skip_user_defined + if filename is None: + return Y.to_string() Y.to_file(filename) def __getitem__(self, selection): @@ -349,8 +381,17 @@ cdef class _SolutionBase: for i,spec in enumerate(species): self._selected_species[i] = self.species_index(spec) - def __reduce__(self): - raise NotImplementedError('Solution object is not picklable') + def __getstate__(self): + """Save complete information of the current phase for pickling.""" + if self.kinetics.nTotalSpecies() > self.thermo.nSpecies(): + raise NotImplementedError( + "Pickling of Interface objects is not implemented.") + return self.write_yaml() + + def __setstate__(self, pkl): + """Restore Solution from pickled information.""" + yml = pkl + self._cinit(yaml=yml) def __copy__(self): raise NotImplementedError('Solution object is not copyable') diff --git a/interfaces/cython/cantera/composite.py b/interfaces/cython/cantera/composite.py index 1d0caa0861c..4fa8ff51592 100644 --- a/interfaces/cython/cantera/composite.py +++ b/interfaces/cython/cantera/composite.py @@ -25,7 +25,7 @@ import pandas as _pandas -class Solution(ThermoPhase, Kinetics, Transport): +class Solution(Transport, Kinetics, ThermoPhase): """ A class for chemically-reacting solutions. Instances can be created to represent any type of solution -- a mixture of gases, a liquid solution, or @@ -101,7 +101,7 @@ class Solution(ThermoPhase, Kinetics, Transport): __slots__ = () -class Interface(InterfacePhase, InterfaceKinetics): +class Interface(InterfaceKinetics, InterfacePhase): """ Two-dimensional interfaces. @@ -121,7 +121,7 @@ class Interface(InterfacePhase, InterfaceKinetics): __slots__ = ('_phase_indices',) -class DustyGas(ThermoPhase, Kinetics, DustyGasTransport): +class DustyGas(DustyGasTransport, Kinetics, ThermoPhase): """ A composite class which models a gas in a stationary, solid, porous medium. @@ -1375,6 +1375,12 @@ def strip_ext(source): return root_attrs + def __reduce__(self): + raise NotImplementedError('SolutionArray object is not picklable') + + def __copy__(self): + raise NotImplementedError('SolutionArray object is not copyable') + def _state2_prop(name, doc_source): # Factory for creating properties which consist of a tuple of two variables, diff --git a/interfaces/cython/cantera/kinetics.pyx b/interfaces/cython/cantera/kinetics.pyx index 94b24f01f38..d213f56e32f 100644 --- a/interfaces/cython/cantera/kinetics.pyx +++ b/interfaces/cython/cantera/kinetics.pyx @@ -8,6 +8,8 @@ from ctypes import c_int # e.g. class Solution. [Cython 0.16] cdef np.ndarray get_species_array(Kinetics kin, kineticsMethod1d method): cdef np.ndarray[np.double_t, ndim=1] data = np.empty(kin.n_total_species) + if kin.n_total_species == 0: + return data method(kin.kinetics, &data[0]) # @todo: Fix _selected_species to work with interface kinetics if kin._selected_species.size: @@ -17,6 +19,8 @@ cdef np.ndarray get_species_array(Kinetics kin, kineticsMethod1d method): cdef np.ndarray get_reaction_array(Kinetics kin, kineticsMethod1d method): cdef np.ndarray[np.double_t, ndim=1] data = np.empty(kin.n_reactions) + if kin.n_reactions == 0: + return data method(kin.kinetics, &data[0]) return data @@ -60,6 +64,13 @@ cdef class Kinetics(_SolutionBase): of progress, species production rates, and other quantities pertaining to a reaction mechanism. """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self._references is None: + raise ValueError( + "Cannot instantiate stand-alone 'Kinetics' object as it requires an " + "associated thermo phase.\nAll 'Kinetics' methods should be accessed " + "from a 'Solution' object.") property kinetics_model: """ diff --git a/interfaces/cython/cantera/liquidvapor.py b/interfaces/cython/cantera/liquidvapor.py index 8bf6a17d834..48a5837263c 100644 --- a/interfaces/cython/cantera/liquidvapor.py +++ b/interfaces/cython/cantera/liquidvapor.py @@ -41,7 +41,7 @@ def Water(backend='Reynolds'): :ct:WaterSSTP and :ct:WaterTransport in the Cantera C++ source code documentation. """ - class WaterWithTransport(PureFluid, _cantera.Transport): + class WaterWithTransport(_cantera.Transport, PureFluid): __slots__ = () if backend == 'Reynolds': diff --git a/interfaces/cython/cantera/test/test_composite.py b/interfaces/cython/cantera/test/test_composite.py index 49de22a99ef..04a52782b56 100644 --- a/interfaces/cython/cantera/test/test_composite.py +++ b/interfaces/cython/cantera/test/test_composite.py @@ -2,6 +2,7 @@ import numpy as np from collections import OrderedDict +import pickle import cantera as ct from cantera.composite import _h5py, _pandas @@ -116,6 +117,94 @@ def check(a, b): raise TypeError(msg) from inst +class TestPickle(utilities.CanteraTest): + + def test_pickle_gas(self): + gas = ct.Solution("h2o2.yaml", transport_model=None) + gas.TPX = 500, 500000, "H2:.75,O2:.25" + with open("gas.pkl", "wb") as pkl: + pickle.dump(gas, pkl) + + with open("gas.pkl", "rb") as pkl: + gas2 = pickle.load(pkl) + self.assertNear(gas.T, gas2.T) + self.assertNear(gas.P, gas2.P) + self.assertArrayNear(gas.X, gas2.X) + + self.assertEqual(gas2.transport_model, "None") + + def test_pickle_gas_with_transport(self): + gas = ct.Solution("h2o2.yaml") + gas.TPX = 500, 500000, "H2:.75,O2:.25" + gas.transport_model = "Multi" + with open("gas.pkl", "wb") as pkl: + pickle.dump(gas, pkl) + + with open("gas.pkl", "rb") as pkl: + gas2 = pickle.load(pkl) + self.assertNear(gas.T, gas2.T) + self.assertNear(gas.P, gas2.P) + self.assertArrayNear(gas.X, gas2.X) + + self.assertEqual(gas2.transport_model, "Multi") + + def test_pickle_interface(self): + gas = ct.Solution("diamond.yaml", "gas") + solid = ct.Solution("diamond.yaml", "diamond") + interface = ct.Interface("diamond.yaml", "diamond_100", (gas, solid)) + + with self.assertRaises(NotImplementedError): + with open("interface.pkl", "wb") as pkl: + pickle.dump(interface, pkl) + + +class TestEmptyThermoPhase(utilities.CanteraTest): + """ Test empty Solution object """ + @classmethod + def setUpClass(cls): + utilities.CanteraTest.setUpClass() + cls.gas = ct.ThermoPhase() + + def test_empty_report(self): + with self.assertRaisesRegex(ct.CanteraError, "NotImplementedError"): + self.gas() + + def test_empty_TP(self): + with self.assertRaisesRegex(ct.CanteraError, "NotImplementedError"): + self.gas.TP = 300, ct.one_atm + + def test_empty_equilibrate(self): + with self.assertRaisesRegex(ct.CanteraError, "NotImplementedError"): + self.gas.equilibrate("TP") + + +class TestEmptySolution(TestEmptyThermoPhase): + """ Test empty Solution object """ + @classmethod + def setUpClass(cls): + utilities.CanteraTest.setUpClass() + cls.gas = ct.Solution() + + def test_empty_composite(self): + self.assertEqual(self.gas.thermo_model, "None") + self.assertEqual(self.gas.composite, ("None", "None", "None")) + + +class TestEmptyEdgeCases(utilities.CanteraTest): + """ Test for edge cases where constructors are not allowed """ + def test_empty_phase(self): + with self.assertRaisesRegex(ValueError, "Arguments are insufficient to define a phase"): + ct.ThermoPhase(thermo="ideal-gas") + + def test_empty_kinetics(self): + with self.assertRaisesRegex(ValueError, "Cannot instantiate"): + ct.Kinetics() + + def test_empty_transport(self): + with self.assertRaisesRegex(ValueError, "Cannot instantiate"): + ct.Transport() + + class TestSolutionArrayIO(utilities.CanteraTest): """ Test SolutionArray file IO """ @classmethod diff --git a/interfaces/cython/cantera/test/test_thermo.py b/interfaces/cython/cantera/test/test_thermo.py index 43f32bf55f8..a2189bf241c 100644 --- a/interfaces/cython/cantera/test/test_thermo.py +++ b/interfaces/cython/cantera/test/test_thermo.py @@ -740,11 +740,6 @@ def test_ref_info(self): self.assertNear(self.phase.min_temp, 300.0) self.assertNear(self.phase.max_temp, 3500.0) - def test_unpicklable(self): - import pickle - with self.assertRaises(NotImplementedError): - pickle.dumps(self.phase) - def test_uncopyable(self): import copy with self.assertRaises(NotImplementedError): diff --git a/interfaces/cython/cantera/thermo.pyx b/interfaces/cython/cantera/thermo.pyx index 9499bbd7ee0..9a4e5de2f55 100644 --- a/interfaces/cython/cantera/thermo.pyx +++ b/interfaces/cython/cantera/thermo.pyx @@ -330,6 +330,9 @@ cdef class ThermoPhase(_SolutionBase): super().__init__(*args, **kwargs) if 'source' not in kwargs: self.thermo_basis = mass_basis + # In composite objects, the ThermoPhase constructor needs to be called first + # to prevent instantiation of stand-alone 'Kinetics' or 'Transport' objects. + # The following is used as a sentinel. self._references = weakref.WeakKeyDictionary() property thermo_model: diff --git a/interfaces/cython/cantera/transport.pyx b/interfaces/cython/cantera/transport.pyx index d87a452803e..3f1874b1a98 100644 --- a/interfaces/cython/cantera/transport.pyx +++ b/interfaces/cython/cantera/transport.pyx @@ -168,6 +168,7 @@ cdef class Transport(_SolutionBase): # The signature of this function causes warnings for Sphinx documentation def __init__(self, *args, **kwargs): if self.transport == NULL: + # @todo ... after removal of CTI/XML, this should be handled by base.pyx if 'transport_model' not in kwargs: self.base.setTransport(newTransport(self.thermo, stringify("default"))) else: @@ -178,6 +179,11 @@ cdef class Transport(_SolutionBase): self.transport = self.base.transport().get() super().__init__(*args, **kwargs) + if self._references is None: + raise ValueError( + "Cannot instantiate stand-alone 'Transport' object as it requires an " + "associated thermo phase.\nAll 'Transport' methods should be accessed " + "from a 'Solution' object.") property transport_model: """ diff --git a/src/kinetics/KineticsFactory.cpp b/src/kinetics/KineticsFactory.cpp index 72e61b3fc43..c551b427820 100644 --- a/src/kinetics/KineticsFactory.cpp +++ b/src/kinetics/KineticsFactory.cpp @@ -42,6 +42,7 @@ Kinetics* KineticsFactory::newKinetics(XML_Node& phaseData, KineticsFactory::KineticsFactory() { reg("none", []() { return new Kinetics(); }); addAlias("none", "Kinetics"); + addAlias("none", "None"); reg("gas", []() { return new GasKinetics(); }); addAlias("gas", "gaskinetics"); addAlias("gas", "Gas"); @@ -106,7 +107,7 @@ void addReactions(Kinetics& kin, const AnyMap& phaseNode, const AnyMap& rootNode vector sections, rules; if (phaseNode.hasKey("reactions")) { - if (kin.kineticsType() == "Kinetics") { + if (kin.kineticsType() == "None") { throw InputFileError("addReactions", phaseNode["reactions"], "Phase entry includes a 'reactions' field but does not " "specify a kinetics model."); @@ -137,7 +138,7 @@ void addReactions(Kinetics& kin, const AnyMap& phaseNode, const AnyMap& rootNode rules.push_back(item.begin()->second.asString()); } } - } else if (kin.kineticsType() != "Kinetics") { + } else if (kin.kineticsType() != "None") { if (rootNode.hasKey("reactions")) { // Default behavior is to add all reactions from the 'reactions' // section, if a 'kinetics' model has been specified diff --git a/src/thermo/ThermoFactory.cpp b/src/thermo/ThermoFactory.cpp index 6275397e923..b1828f3de74 100644 --- a/src/thermo/ThermoFactory.cpp +++ b/src/thermo/ThermoFactory.cpp @@ -48,6 +48,9 @@ std::mutex ThermoFactory::thermo_mutex; ThermoFactory::ThermoFactory() { + reg("none", []() { return new ThermoPhase(); }); + addAlias("none", "ThermoPhase"); + addAlias("none", "None"); reg("ideal-gas", []() { return new IdealGasPhase(); }); addAlias("ideal-gas", "IdealGas"); reg("ideal-surface", []() { return new SurfPhase(); }); diff --git a/src/thermo/ThermoPhase.cpp b/src/thermo/ThermoPhase.cpp index 778f30ae66c..e46c47c934e 100644 --- a/src/thermo/ThermoPhase.cpp +++ b/src/thermo/ThermoPhase.cpp @@ -1403,6 +1403,11 @@ void ThermoPhase::getdlnActCoeffdlnN_numderiv(const size_t ld, doublereal* const std::string ThermoPhase::report(bool show_thermo, doublereal threshold) const { + if (type() == "None") { + throw NotImplementedError("ThermoPhase::report", + "Not implemented for thermo model 'None'"); + } + fmt::memory_buffer b; // This is the width of the first column of names in the report. int name_width = 18; diff --git a/src/transport/TransportFactory.cpp b/src/transport/TransportFactory.cpp index 6fe71260c48..f5a19bb9c91 100644 --- a/src/transport/TransportFactory.cpp +++ b/src/transport/TransportFactory.cpp @@ -29,10 +29,10 @@ std::mutex TransportFactory::transport_mutex; TransportFactory::TransportFactory() { - reg("", []() { return new Transport(); }); - addAlias("", "Transport"); - addAlias("", "None"); - addAlias("", "none"); + reg("none", []() { return new Transport(); }); + addAlias("none", "Transport"); + addAlias("none", "None"); + addAlias("none", ""); reg("unity-Lewis-number", []() { return new UnityLewisTransport(); }); addAlias("unity-Lewis-number", "UnityLewis"); reg("mixture-averaged", []() { return new MixTransport(); }); @@ -63,7 +63,7 @@ void TransportFactory::deleteFactory() Transport* TransportFactory::newTransport(const std::string& transportModel, ThermoPhase* phase, int log_level) { - if (transportModel != "DustyGas" && canonicalize(transportModel) == "") { + if (transportModel != "DustyGas" && canonicalize(transportModel) == "none") { return create(transportModel); } if (!phase) { diff --git a/test/kinetics/kineticsFromYaml.cpp b/test/kinetics/kineticsFromYaml.cpp index a3d0440a08a..97de849cf60 100644 --- a/test/kinetics/kineticsFromYaml.cpp +++ b/test/kinetics/kineticsFromYaml.cpp @@ -404,7 +404,7 @@ TEST(KineticsFromYaml, NoKineticsModelOrReactionsField1) { auto soln = newSolution("phase-reaction-spec1.yaml", "nokinetics-noreactions"); - EXPECT_EQ(soln->kinetics()->kineticsType(), "Kinetics"); + EXPECT_EQ(soln->kinetics()->kineticsType(), "None"); EXPECT_EQ(soln->kinetics()->nReactions(), (size_t) 0); } @@ -412,7 +412,7 @@ TEST(KineticsFromYaml, NoKineticsModelOrReactionsField2) { auto soln = newSolution("phase-reaction-spec2.yaml", "nokinetics-noreactions"); - EXPECT_EQ(soln->kinetics()->kineticsType(), "Kinetics"); + EXPECT_EQ(soln->kinetics()->kineticsType(), "None"); EXPECT_EQ(soln->kinetics()->nReactions(), (size_t) 0); }