Repository: ModellingWebLab/cellmlmanip

Issue:
Add model property to VariableDummy
- A variable dummy is tied to a model: it's not possible to re-use a variable dummy with a different model (e.g. a model stores all variable dummies it has when creating them, and they have a model-specific order_added property)
- I've got some use cases where we pass dummies around, but not necessarily the model object https://github.com/ModellingWebLab/weblab-fc/pull/158#discussion_r413871468

Thoughts @jonc125 @MauriceHendrix ?

Discussion:

Patch:
diff --git a/cellmlmanip/main.py b/cellmlmanip/main.py
index a9fefb7..42764f1 100644
--- a/cellmlmanip/main.py
+++ b/cellmlmanip/main.py
@@ -9,4 +9,3 @@ def load_model(path, unit_store=None):
         underlying registry so that conversions between model units and those from the provided store work.
     """
     return Parser(path).parse(unit_store=unit_store)
-
diff --git a/cellmlmanip/model.py b/cellmlmanip/model.py
index e768403..8a3c651 100644
--- a/cellmlmanip/model.py
+++ b/cellmlmanip/model.py
@@ -194,6 +194,7 @@ class Model(object):
         self._name_to_variable[name] = var = VariableDummy(
             name=name,
             units=units,
+            model=self,
             initial_value=initial_value,
             public_interface=public_interface,
             private_interface=private_interface,
@@ -681,6 +682,15 @@ class Model(object):
         """ Checks if the given ``variable`` is a state variable (i.e. if it's defined by an ODE). """
         return variable in self._ode_definition_map
 
+    def is_constant(self, variable):
+        """Determine whether the given ``variable`` is a constant.
+
+        This is calculated by looking at the RHS of the defining equation and checking it has no
+        variable references.
+        """
+        defn = self._var_definition_map.get(variable)
+        return defn is not None and len(defn.rhs.atoms(VariableDummy)) == 0
+
     def find_variables_and_derivatives(self, expressions):
         """ Returns a set containing all variables and derivatives referenced in a list of expressions.
 
@@ -741,6 +751,7 @@ class Model(object):
         del self._name_to_variable[variable.name]
         if variable._cmeta_id is not None:
             del self._cmeta_id_to_variable[variable._cmeta_id]
+        variable._model = None  # Just in case!
         self._invalidate_cache()
 
     def transfer_cmeta_id(self, source, target):
@@ -1070,11 +1081,13 @@ class VariableDummy(sympy.Dummy):
     def __init__(self,
                  name,
                  units,
+                 model=None,
                  initial_value=None,
                  public_interface=None,
                  private_interface=None,
                  order_added=None,
                  cmeta_id=None):
+        self._model = model
         self.name = name
         self.units = units
         self.initial_value = None if initial_value is None else float(initial_value)
@@ -1102,24 +1115,28 @@ class VariableDummy(sympy.Dummy):
         # TODO: Define allowed types via enum
         self.type = None
 
+    def __str__(self):
+        return self.name
+
+    @property
+    def model(self):
+        """The :class:`Model` this variable is part of."""
+        return self._model
+
+    @property
+    def rdf_identity(self):
+        """The RDF identity for this variable (will be ``None`` unless the variable has a cmeta id)."""
+        return self._rdf_identity
+
     @property
     def cmeta_id(self):
-        """Provides read-only access the the cmeta id."""
+        """Provides read-only access to the cmeta id."""
         return self._cmeta_id
 
-    def __str__(self):
-        return self.name
-
     def _set_cmeta_id(self, cmeta_id):
-        """ Sets this variable's cmeta id. Should only be called by Model, which can verify cmeta id uniqueness. """
+        """Sets this variable's cmeta id. Should only be called by Model, which can verify cmeta id uniqueness."""
         self._cmeta_id = cmeta_id
         if cmeta_id is None:
             self._rdf_identity = None
         else:
             self._rdf_identity = create_rdf_node('#' + self._cmeta_id)
-
-    @property
-    def rdf_identity(self):
-        """The RDF identity for this variable (will be ``None`` unless the variable has a cmeta id)."""
-        return self._rdf_identity
-
diff --git a/cellmlmanip/parser.py b/cellmlmanip/parser.py
index 4a6c818..3699aeb 100644
--- a/cellmlmanip/parser.py
+++ b/cellmlmanip/parser.py
@@ -501,4 +501,3 @@ class Parser(object):
         if not rnc.validate(tree):
             msg = '. '.join([str(x) for x in rnc.error_log])
             raise ValueError('Invalid or unsupported CellML file. ' + msg)
-
diff --git a/cellmlmanip/printer.py b/cellmlmanip/printer.py
index cfa22a9..76fc38d 100644
--- a/cellmlmanip/printer.py
+++ b/cellmlmanip/printer.py
@@ -403,4 +403,3 @@ class Printer(sympy.printing.printer.Printer):
     def _print_Symbol(self, expr):
         """ Handles Sympy Symbol objects. """
         return self._symbol_function(expr)
-
diff --git a/docs/source/conf.py b/docs/source/conf.py
index d5d034b..6607a9e 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -22,6 +22,7 @@
 # import cellmlmanip
 import sphinx
 
+
 # -- General configuration ------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
@@ -185,4 +186,3 @@ texinfo_documents = [
      author, 'Cellmlmanip', 'One line description of project.',
      'Miscellaneous'),
 ]
-


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 8abdeda..8eb2b6f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -65,5 +65,3 @@ def bad_units_model():
     """
     model = shared.load_model('simple_model_invalid_units')
     return model
-
-
diff --git a/tests/shared.py b/tests/shared.py
index 8b94fcb..f100e2d 100644
--- a/tests/shared.py
+++ b/tests/shared.py
@@ -1,5 +1,6 @@
 # Module with cellmlmanip tests
 import os
+
 import cellmlmanip
 
 
diff --git a/tests/test_model_functions.py b/tests/test_model_functions.py
index bbbe05a..62e6f53 100644
--- a/tests/test_model_functions.py
+++ b/tests/test_model_functions.py
@@ -4,8 +4,7 @@ import pytest
 import sympy as sp
 
 from cellmlmanip import parser, units
-from cellmlmanip.model import Model, VariableDummy
-from cellmlmanip.model import FLOAT_PRECISION
+from cellmlmanip.model import FLOAT_PRECISION, Model, VariableDummy
 
 from . import shared
 
@@ -462,6 +461,7 @@ class TestModelFunctions():
             model.get_variable_by_name('newvar') is None
 
         newvar = model.add_variable('newvar', 'mV')
+        assert newvar.model is model
         assert newvar.is_real
         assert len(model.variables()) == 4
         assert model.get_variable_by_name('newvar')
@@ -490,6 +490,7 @@ class TestModelFunctions():
 
         model.remove_variable(i_stim)
 
+        assert i_stim.model is None
         with pytest.raises(KeyError):
             model.get_variable_by_name(i_stim.name)
         assert model.get_definition(i_stim) is None
@@ -506,6 +507,7 @@ class TestModelFunctions():
 
         model.remove_variable(v)
 
+        assert v.model is None
         with pytest.raises(KeyError):
             model.get_variable_by_name(v.name)
         assert model.get_definition(v) is None
@@ -519,6 +521,7 @@ class TestModelFunctions():
 
         model.remove_variable(t)
 
+        assert t.model is None
         with pytest.raises(KeyError):
             model.get_variable_by_name(t.name)
 
@@ -526,6 +529,7 @@ class TestModelFunctions():
         var = model.get_variable_by_name('membrane$E_R')
         model.remove_variable(var)
 
+        assert var.model is None
         with pytest.raises(KeyError):
             model.get_variable_by_name(var.name)
 
@@ -646,24 +650,28 @@ class TestModelFunctions():
         assert new_eqn.rhs.args[0].value == 0.001
         assert new_eqn.rhs.args[1] == source
 
-    def test_is_state(self, aslanidi_model):
-        """ Tests Model.is_state(). """
+    def test_variable_classification(self, aslanidi_model):
+        """ Tests Model.is_state() and Model.is_constant(). """
 
         # State variable
         v = aslanidi_model.get_variable_by_ontology_term((shared.OXMETA, 'membrane_voltage'))
         assert aslanidi_model.is_state(v)
+        assert not aslanidi_model.is_constant(v)
 
         # Intermediary variable
         i = aslanidi_model.get_variable_by_ontology_term((shared.OXMETA, 'membrane_fast_sodium_current'))
         assert not aslanidi_model.is_state(i)
+        assert not aslanidi_model.is_constant(i)
 
         # Free variable
         t = aslanidi_model.get_variable_by_ontology_term((shared.OXMETA, 'time'))
         assert not aslanidi_model.is_state(t)
+        assert not aslanidi_model.is_constant(t)
 
         # Constant
         c = aslanidi_model.get_variable_by_ontology_term((shared.OXMETA, 'membrane_capacitance'))
         assert not aslanidi_model.is_state(c)
+        assert aslanidi_model.is_constant(c)
 
     def test_transform_constants(self):
         """ Tests Model.transform_constants(). """
@@ -681,4 +689,3 @@ class TestModelFunctions():
         # And the equations should have matching units
         for eq in model.equations:
             model.check_left_right_units_equal(eq)
-
diff --git a/tests/test_parser.py b/tests/test_parser.py
index fc142fe..ee1d22d 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -317,4 +317,3 @@ class TestParser(object):
         p = parser.Parser(path)
         with pytest.raises(ValueError, match='Element model has extra content'):
             p.parse()
-
diff --git a/tests/test_printer.py b/tests/test_printer.py
index 79a954e..e8a32ee 100644
--- a/tests/test_printer.py
+++ b/tests/test_printer.py
@@ -3,11 +3,13 @@
 #
 import logging
 import math
+
 import pytest
 import sympy as sp
 
 import cellmlmanip.printer
 
+
 # Show more logging output
 logging.getLogger().setLevel(logging.INFO)
 
diff --git a/tests/test_rdf.py b/tests/test_rdf.py
index d716d83..94d15bd 100644
--- a/tests/test_rdf.py
+++ b/tests/test_rdf.py
@@ -4,8 +4,10 @@ import sympy
 
 import cellmlmanip
 import cellmlmanip.rdf
+
 from . import shared
 
+
 PYCMLMETA = 'https://chaste.comlab.ox.ac.uk/cellml/ns/pycml#'
 
 
@@ -284,4 +286,3 @@ def test_transfer_cmeta_id():
     assert v4.rdf_identity is not None
     with pytest.raises(ValueError):
         model.transfer_cmeta_id(v2, v4)
-
diff --git a/tests/test_unit_conversion.py b/tests/test_unit_conversion.py
index 23b9867..92ca867 100644
--- a/tests/test_unit_conversion.py
+++ b/tests/test_unit_conversion.py
@@ -3,6 +3,7 @@ from pint import DimensionalityError
 
 from cellmlmanip import units
 from cellmlmanip.model import DataDirectionFlow
+
 from . import shared