diff --git a/cellmlmanip/parser.py b/cellmlmanip/parser.py
index ba8e681..66b3e90 100644
--- a/cellmlmanip/parser.py
+++ b/cellmlmanip/parser.py
@@ -12,7 +12,6 @@ import sympy
 from lxml import etree
 
 from cellmlmanip.model import SYMPY_SYMBOL_DELIMITER, Model
-from cellmlmanip.transpiler import Transpiler
 
 
 UNIT_PREFIXES = {
@@ -48,6 +47,16 @@ class XmlNs(Enum):
     RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
 
 
+def with_ns(ns_enum, name):
+    """Returns an ElementTree-friendly name with namespace in brackets"""
+    return '{%s}%s' % (ns_enum.value, name)
+
+
+def _dump_node(node):
+    """Pretty-print an XML node."""
+    return etree.tostring(node, pretty_print=True).decode()
+
+
 class _Component:
     """This hold information about a CellML component. It's for internal-use only. Once the parser
     has created the flattened cellmlmanip.Model instance, components are no longer used"""
@@ -81,11 +90,6 @@ class _Component:
 class Parser(object):
     """Handles parsing of CellML files"""
 
-    @staticmethod
-    def with_ns(ns_enum, name):
-        """Returns an ElementTree-friendly name with namespace in brackets"""
-        return '{%s}%s' % (ns_enum.value, name)
-
     def __init__(self, filepath):
         """Initialise an instance of Parser
 
@@ -121,7 +125,7 @@ class Parser(object):
         # <model> root node - initialise the model object
         model_xml = tree.getroot()
         self.model = Model(model_xml.get('name'),
-                           model_xml.get(Parser.with_ns(XmlNs.CMETA, 'id')),
+                           model_xml.get(with_ns(XmlNs.CMETA, 'id')),
                            unit_store=unit_store)
 
         # handle the child elements of <model>
@@ -147,7 +151,7 @@ class Parser(object):
 
         :param element: the CellML parent element to search for children RDF tags
         """
-        for rdf in element.iter(Parser.with_ns(XmlNs.RDF, 'RDF')):
+        for rdf in element.iter(with_ns(XmlNs.RDF, 'RDF')):
             self.model.add_rdf(etree.tostring(rdf, encoding=str))
 
     def _add_units(self, model):
@@ -155,7 +159,7 @@ class Parser(object):
         <model> <units> <unit /> </units> </model>
         :param model: an etree.Element
         """
-        units_elements = model.findall(Parser.with_ns(XmlNs.CELLML, 'units'))
+        units_elements = model.findall(with_ns(XmlNs.CELLML, 'units'))
 
         # get list of built-in cellml units
         from cellmlmanip.units import _CELLML_UNITS
@@ -250,7 +254,7 @@ class Parser(object):
         <model> <component> </model>
         :param model: an etree.Element
         """
-        component_elements = model.findall(Parser.with_ns(XmlNs.CELLML, 'component'))
+        component_elements = model.findall(with_ns(XmlNs.CELLML, 'component'))
 
         # for each component defined in the model
         for element in component_elements:
@@ -265,13 +269,13 @@ class Parser(object):
             self._add_maths(element, variable_to_symbol)
 
             # Raise error if component units are defined
-            component_units = element.findall(Parser.with_ns(XmlNs.CELLML, 'units'))
+            component_units = element.findall(with_ns(XmlNs.CELLML, 'units'))
             if component_units:
                 raise ValueError(
                     'Defining units inside components is not supported (found in component ' + name + ').')
 
             # Raise error if reactions are defined
-            reactions = element.findall(Parser.with_ns(XmlNs.CELLML, 'reaction'))
+            reactions = element.findall(with_ns(XmlNs.CELLML, 'reaction'))
             if reactions:
                 raise ValueError(
                     'Reactions are not supported (found in component ' + name + ').')
@@ -281,7 +285,7 @@ class Parser(object):
         <model> <component> <variable> </component> </model>
         :param component_element: an etree.Element
         """
-        variable_elements = component_element.findall(Parser.with_ns(XmlNs.CELLML, 'variable'))
+        variable_elements = component_element.findall(with_ns(XmlNs.CELLML, 'variable'))
 
         # we keep a {variable name: sympy symbol} lookup that we pass to the transpiler
         variable_lookup_symbol = dict()
@@ -290,7 +294,7 @@ class Parser(object):
             attributes = dict(variable_element.attrib)
 
             # Rename key for cmeta_id (remove namespace from attribute)
-            cmeta_id_attribute = Parser.with_ns(XmlNs.CMETA, 'id')
+            cmeta_id_attribute = with_ns(XmlNs.CMETA, 'id')
             if cmeta_id_attribute in attributes:
                 attributes['cmeta_id'] = attributes.pop(cmeta_id_attribute)
 
@@ -314,7 +318,7 @@ class Parser(object):
         :param variable_to_symbol: a ``Dict[str, sympy.Dummy]``
         """
         # get all <math> elements in the component
-        math_elements = component_element.findall(Parser.with_ns(XmlNs.MATHML, 'math'))
+        math_elements = component_element.findall(with_ns(XmlNs.MATHML, 'math'))
 
         # nothing to do if we don't have any <math> elements
         if not math_elements:
@@ -336,21 +340,20 @@ class Parser(object):
 
         # for each math element
         for math_element in math_elements:
-            sympy_exprs = transpiler.parse_string(etree.tostring(math_element, encoding=str))
+            sympy_exprs = transpiler.parse_tree(math_element)
 
             # add each equation from <math> to the model
             for expr in sympy_exprs:
                 self.model.add_equation(expr)
 
     def _add_relationships(self, model: etree.Element):
-        group_elements = model.findall(Parser.with_ns(XmlNs.CELLML, 'group'))
+        group_elements = model.findall(with_ns(XmlNs.CELLML, 'group'))
 
         # find all the <group> elements
         for group_element in group_elements:
 
             # find the relationship for this <group>
-            relationship_ref = group_element.findall(Parser.with_ns(XmlNs.CELLML,
-                                                                    'relationship_ref'))
+            relationship_ref = group_element.findall(with_ns(XmlNs.CELLML, 'relationship_ref'))
             assert len(relationship_ref) == 1
             relationship = relationship_ref[0].attrib.get('relationship')
 
@@ -363,8 +366,7 @@ class Parser(object):
         siblings = []
 
         # for each of the child <component_ref> elements in the parent tag
-        for component_ref_element in parent_tag.findall(Parser.with_ns(XmlNs.CELLML,
-                                                                       'component_ref')):
+        for component_ref_element in parent_tag.findall(with_ns(XmlNs.CELLML, 'component_ref')):
 
             # get the name of the child component
             child_component = component_ref_element.attrib.get('component')
@@ -392,7 +394,7 @@ class Parser(object):
         """
         :param model: an etree.Element
         """
-        connection_elements = model.findall(Parser.with_ns(XmlNs.CELLML, 'connection'))
+        connection_elements = model.findall(with_ns(XmlNs.CELLML, 'connection'))
 
         # a list to collect the (source, target) connection tuples
         connect_from_to = []
@@ -401,13 +403,13 @@ class Parser(object):
         for connection in connection_elements:
             # first child is <map_component>
             child_0 = connection[0]
-            assert child_0.tag == Parser.with_ns(XmlNs.CELLML, 'map_components')
+            assert child_0.tag == with_ns(XmlNs.CELLML, 'map_components')
             comp_1, comp_2 = (child_0.attrib.get('component_1'),
                               child_0.attrib.get('component_2'))
 
             # the remaining children are <map_variables> tags
             for child in connection[1:]:
-                assert child.tag == Parser.with_ns(XmlNs.CELLML, 'map_variables')
+                assert child.tag == with_ns(XmlNs.CELLML, 'map_variables')
                 connect_from_to.append(
                     self._determine_connection_direction(comp_1, child.attrib.get('variable_1'),
                                                          comp_2, child.attrib.get('variable_2'))
@@ -521,3 +523,409 @@ 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)
+
+
+class Transpiler(object):
+    """
+    Handles conversion of MathmL to Sympy exprerssions.
+
+    :param symbol_generator: An optional method to create expressions for symbols.
+        Must have signature ``f(name) -> sympy.Basic``.
+    :param number_generator: An optional method to create expressions for numbers with units.
+        Must have signature ``f(value, unit) -> sympy.Basic``.
+    """
+    def __init__(self, symbol_generator=None, number_generator=None):
+
+        # Create simple lambdas for symbol and number generators
+        if symbol_generator is None:
+            symbol_generator = lambda x: sympy.Symbol(x)        # noqa: E731
+        if number_generator is None:
+            number_generator = lambda x, y: sympy.Float(x)      # noqa: E731
+
+        # Store symbol and number generators
+        self.symbol_generator = symbol_generator
+        self.number_generator = number_generator
+
+        # Mapping MathML tag element names (keys) to appropriate handler for SymPy output (values)
+        # These tags require explicit handling because they have children or context etc.
+        self.handlers = {
+            'apply': self._apply_handler,
+            'bvar': self._bvar_handler,
+            'ci': self._ci_handler,
+            'cn': self._cn_handler,
+            'degree': self._degree_handler,
+            'diff': self._diff_handler,
+            'divide': self._divide_handler,
+            'log': self._log_handler,
+            'logbase': self._logbase_handler,
+            'math': self.transpile,
+            'minus': self._minus_handler,
+            'otherwise': self._otherwise_handler,
+            'piece': self._piece_handler,
+            'piecewise': self._piecewise_handler,
+            'power': self._power_handler,
+            'root': self._root_handler
+        }
+
+        # Add tags that can be handled by simple_operator_handler
+        for tag_name in SIMPLE_MATHML_TO_SYMPY_CLASSES:
+            self.handlers[tag_name] = self._simple_operator_handler
+
+    @staticmethod
+    def set_mathml_handler(mathml_operator, operator_class):
+        """Change how the transpiler handles a given mathml_operator.
+
+        :param mathml_operator: The name of a MathML operator e.g. 'exp', 'true' etc.
+        :param operator_class: A class that can handle the given operator e.g. ``sympy.exp``, or a function that creates
+            and returns a sympy object given the operands as arguments.
+        """
+        SIMPLE_MATHML_TO_SYMPY_CLASSES[mathml_operator] = operator_class
+
+    def parse_string(self, xml_string):
+        """
+        Reads MathML content from a string and returns equivalent SymPy expressions.
+        :return: A list of SymPy expressions.
+        """
+        parser = etree.XMLParser(no_network=True)
+        tree = etree.fromstring(xml_string, parser)
+        return self.parse_tree(tree)
+
+    def parse_tree(self, math_element):
+        """Accepts a <math> element and returns equivalent SymPy expressions.
+
+        Note: math_element must be the <math> ``Element``, not the root ``ElementTree``.
+
+        :param math_element: <math> ``etree.Element`` object
+        :return: A list of SymPy expressions.
+        """
+        return self.transpile(math_element)
+
+    def transpile(self, element):
+        """Convert MathML to Sympy expressions.
+
+        Descends the given MathML element node, calling the corresponding handler for child
+        elements, and returns the appropriate SymPy expression.
+
+        :param element: an etree ``Element`` of parsed MathML
+        :return: a list of SymPy expressions
+        """
+        # Collect the parsed expression(s) (i.e. SymPy output) into list
+        sympy_expressions = []
+
+        # For each child element of this element
+        for child_element in element.iterchildren(tag='*'):
+            # Call the appropriate MathML handler function for this tag
+            tag_name = etree.QName(child_element.tag).localname
+            if tag_name in self.handlers:
+                sympy_expressions.append(self.handlers[tag_name](child_element))
+            else:
+                # MathML handler function not found for this tag!
+                raise ValueError('No handler for element <%s>' % tag_name)
+
+        return sympy_expressions
+
+    # TOKEN ELEMENTS ###############################################################################
+
+    def _ci_handler(self, node):
+        """MathML:  https://www.w3.org/TR/MathML2/chapter4.html#contm.ci
+        SymPy: http://docs.sympy.org/latest/modules/core.html#id17
+        """
+        identifier = node.text.strip()
+        return self.symbol_generator(identifier)
+
+    def _cn_handler(self, node):
+        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.cn
+        SymPy: http://docs.sympy.org/latest/modules/core.html#number
+        """
+
+        # If this number is using scientific notation
+        if 'type' in node.attrib:
+            if node.attrib['type'] == 'e-notation':
+                # A real number may also be presented in scientific notation. Such numbers have two
+                # parts (a mantissa and an exponent) separated by sep. The first part is a real
+                # number, while the second part is an integer exponent indicating a power of the
+                # base.. For example, 12.3<sep/>5 represents 12.3 times 10^5. The default
+                # presentation of this example is 12.3e5.
+                if len(node) == 1 and node[0].tag == with_ns(XmlNs.MATHML, 'sep'):
+                    mantissa = node.text.strip()
+                    exponent = int(node[0].tail.strip())
+                    number = float('%se%d' % (mantissa, exponent))
+                else:
+                    raise ValueError('Expecting '
+                                     '<cn type="e-notation">significand<sep/>exponent</cn>.'
+                                     'Got: ' + _dump_node(node))
+            else:
+                raise ValueError('Unimplemented type attribute for <cn>: ' + node.attrib['type'])
+        else:
+            number = float(node.text.strip())
+
+        # Get units, if given
+        # TODO: We're allowing these to _not_ be set for testing only. Maybe remove this option?
+        units = node.get(with_ns(XmlNs.CELLML, 'units'))
+
+        return self.number_generator(number, units)
+
+    # BASIC CONTENT ELEMENTS #######################################################################
+
+    def _apply_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.apply
+        """
+        result = self.transpile(node)
+
+        if len(result) > 1:
+            expression = result[0](*(result[1:]))
+        else:
+            expression = result[0]
+        return expression
+
+    def _piecewise_handler(self, node):
+        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.piecewise
+        SymPy: http://docs.sympy.org/latest/modules/functions/elementary.html#piecewise
+
+        constructor, zero or more <piece>, zero or one <otherwise>
+        """
+        result = self.transpile(node)
+        return sympy.Piecewise(*result)
+
+    def _piece_handler(self, node):
+        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.piecewise
+        Returns a 2-tuple defining an expression and condition
+        <piece> element contains exactly two children
+        """
+        result = self.transpile(node)
+        if len(result) != 2:
+            raise ValueError('Need exactly 2 children for <piece>')
+        return result[0], result[1]
+
+    def _otherwise_handler(self, node):
+        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.piecewise
+        Returns a 2-tuple defining an expression and condition
+        """
+        result = self.transpile(node)
+        if len(result) != 1:
+            raise ValueError('More than 1 child for <otherwise>')
+        return result[0], True
+
+    # ARITHMETIC, ALGEBRA AND LOGIC ################################################################
+
+    def _minus_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.minus
+        unary arithmetic operator OR binary arithmetic operator
+
+        From http://docs.sympy.org/latest/tutorial/manipulation.html:
+        "There is no subtraction class in SymPy. x - y is represented as x + -y, or, more
+        completely, x + -1*y, i.e., Add(x, Mul(-1, y))."
+
+        * Negation (-a) is equivalent to sympy.Mul(sympy.S.NegativeOne, a)
+        * Subtraction (a - b) is equivalent to sympy.Add(a, sympy.Mul(sympy.S.NegativeOne, b))
+        """
+        def _wrapped_minus(left_operand, right_operand=None):
+            if right_operand is None:
+                # unary arithmetic operator => negation
+                return -left_operand
+            # otherwise, binary arithmetic operator => subtraction
+            return left_operand - right_operand
+        return _wrapped_minus
+
+    def _divide_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.divide
+        binary arithmetic operator
+        There is no class in SymPy for division. Rather, division is represented by a power of -1.
+
+        Equivalent to sympy.Mul(a, sympy.Pow(b, sympy.S.NegativeOne))
+        """
+        def _wrapped_divide(dividend, divisor):
+            return dividend / divisor
+        return _wrapped_divide
+
+    def _power_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.power
+        binary arithmetic operator
+        equivalent to sympy.Pow(a, b)
+        """
+        def _wrapped_power(base, exponent):
+            return base ** exponent
+        return _wrapped_power
+
+    def _root_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.root
+        operator taking qualifiers
+
+        Nasty:
+        The root element is used to construct roots. The kind of root to be taken is specified by a
+        degree element, which should be given as the second child of the apply element enclosing the
+        root element. Thus, square roots correspond to the case where degree contains the value 2,
+        cube roots correspond to 3, and so on. If no degree is present, a default value of 2 is used
+        """
+        def _wrapped_root(first_argument, second_argument=None):
+            # if no <degree> given, it's sqrt
+            if second_argument is None:
+                return sympy.root(first_argument, 2)
+            return sympy.root(second_argument, first_argument)
+        return _wrapped_root
+
+    def _degree_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.degree
+        Meaning of <degree> depends on context! We implement it for order of <bvar> in <diff> and
+        the kind of root in <root>
+        """
+        result = self.transpile(node)
+        if len(result) != 1:
+            raise ValueError('Expected single value in <degree> tag.'
+                             'Got: ' + _dump_node(node))
+        return result[0]
+
+    # CALCULUS AND VECTOR CALCULUS #################################################################
+
+    def _diff_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.diff
+        operator taking qualifiers
+        """
+        def _wrapped_diff(x_symbol, y_symbol, evaluate=False):
+            # if bound variable element <bvar> contains <degree>, argument x_symbol is a list,
+            # otherwise, it is a symbol
+            if isinstance(x_symbol, list) and len(x_symbol) == 2:
+                bound_variable = x_symbol[0]
+                order = int(x_symbol[1])
+                deriv = sympy.Derivative(y_symbol, bound_variable, order, evaluate=evaluate)
+            # Otherwise, first degree derivative
+            else:
+                deriv = sympy.Derivative(y_symbol, x_symbol, evaluate=evaluate)
+
+            return deriv
+
+        return _wrapped_diff
+
+    def _bvar_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.bvar
+        NASTY: bvar element depends on the context it is being used
+        In a derivative, it indicates the variable with respect to which a function is being
+        differentiated.
+
+        The bound variable <bvar> can also specify degree. In this case, we'll have two elements
+        """
+        result = self.transpile(node)
+        if len(result) == 1:
+            # Bound variable without specifying degree
+            return result[0]
+        elif len(result) == 2:
+            return result
+        else:
+            raise ValueError('Do not know how to handle <bvar> ' + _dump_node(node))
+
+    # ELEMENTARY CLASSICAL FUNCTIONS ###############################################################
+
+    def _log_handler(self, node):
+        """https://www.w3.org/TR/MathML2/chapter4.html#contm.log
+        operator taking qualifiers or a unary calculus operator
+        """
+        def _wrapped_log(first_element, second_element=None):
+            if second_element is None:
+                # if no <logbase> element is present, the base is assumed to be 10
+                return sympy.log(first_element, 10)
+
+            # Has <logbase> element, which is the first_element after <log/>
+            return sympy.log(second_element, first_element)
+        return _wrapped_log
+
+    def _logbase_handler(self, node):
+        """Qualifier for <log>
+
+        The log function accepts only the logbase schema. If present, the logbase schema denotes the
+        base with respect to which the logarithm is being taken. Otherwise, the log is assumed to be
+        base 10. When used with log, the logbase schema is expected to contain a single child schema
+        otherwise an error is generated.
+
+        Should be the first element following log, i.e. the second child of the containing apply
+        element.
+        """
+        return self.transpile(node)[0]
+
+    def _get_nary_relation_callback(self, sympy_relation):
+        """Wraps the Sympy binary relation to handle n-ary MathML relations
+
+        :param sympy_relation: handle for binary Sympy relation (Eq, Le, Lt, Ge, Gt)
+        :return: callback used by the apply_handler to handle n-ary relations
+        """
+        def _wrapper_relational(*expressions):
+            # If the MathML relation is chaining more than 2 expressions
+            if len(expressions) > 2:
+                # Convert to multiple Sympy binary relations bundled in an 'And' boolean
+                relations = []
+                for first, second in zip(expressions[:-1], expressions[1:]):
+                    relations.append(sympy_relation(first, second))
+                return sympy.And(*relations)
+            return sympy_relation(*expressions)
+        return _wrapper_relational
+
+    def _simple_operator_handler(self, node):
+        """This function handles simple MathML <tagName> to sympy.Class operators, where no unique
+        handling of tag children etc. is required.
+        """
+        tag_name = etree.QName(node.tag).localname
+
+        handler = SIMPLE_MATHML_TO_SYMPY_CLASSES[tag_name]
+
+        # Some MathML relations allow chaining but Sympy relations are binary operations
+        if tag_name in MATHML_NARY_RELATIONS:
+            return self._get_nary_relation_callback(handler)
+
+        return handler
+
+
+# These MathML tags map directly to Sympy classes and don't require any extra handling
+SIMPLE_MATHML_TO_SYMPY_CLASSES = {
+    'abs': sympy.Abs,
+    'and': sympy.And,
+    'arccos': sympy.acos,
+    'arccosh': sympy.acosh,
+    'arccot': sympy.acot,
+    'arccoth': sympy.acoth,
+    'arccsc': sympy.acsc,
+    'arccsch': sympy.acsch,
+    'arcsec': sympy.asec,
+    'arcsech': sympy.asech,
+    'arcsin': sympy.asin,
+    'arcsinh': sympy.asinh,
+    'arctan': sympy.atan,
+    'arctanh': sympy.atanh,
+    'ceiling': sympy.ceiling,
+    'cos': sympy.cos,
+    'cosh': sympy.cosh,
+    'cot': sympy.cot,
+    'coth': sympy.coth,
+    'csc': sympy.csc,
+    'csch': sympy.csch,
+    'eq': sympy.Eq,
+    'exp': sympy.exp,
+    'exponentiale': sympy.E,
+    'false': sympy.false,
+    'floor': sympy.floor,
+    'geq': sympy.Ge,
+    'gt': sympy.Gt,
+    'infinity': sympy.oo,
+    'leq': sympy.Le,
+    'ln': sympy.ln,
+    'lt': sympy.Lt,
+    'max': sympy.Max,
+    'min': sympy.Min,
+    'neq': sympy.Ne,
+    'not': sympy.Not,
+    'notanumber': sympy.nan,
+    'or': sympy.Or,
+    'pi': sympy.pi,
+    'plus': sympy.Add,
+    'rem': sympy.Mod,
+    'sec': sympy.sec,
+    'sech': sympy.sech,
+    'sin': sympy.sin,
+    'sinh': sympy.sinh,
+    'tan': sympy.tan,
+    'tanh': sympy.tanh,
+    'times': sympy.Mul,
+    'true': sympy.true,
+    'xor': sympy.Xor,
+}
+
+# MathML relation elements that are n-ary operators
+MATHML_NARY_RELATIONS = {'eq', 'leq', 'lt', 'geq', 'gt'}
diff --git a/cellmlmanip/transpiler.py b/cellmlmanip/transpiler.py
deleted file mode 100644
index 64165a6..0000000
--- a/cellmlmanip/transpiler.py
+++ /dev/null
@@ -1,439 +0,0 @@
-"""
-The :mod:`cellmlmanip.transpiler` module parses Content MathML and returns equivalent SymPy expressions.
-
-Content Markup specification: https://www.w3.org/TR/MathML2/chapter4.html
-"""
-import logging
-from xml.dom import Node, minidom
-
-import sympy
-
-
-logger = logging.getLogger(__name__)
-
-
-class Transpiler(object):
-    """
-    Handles conversion of MathmL to Sympy exprerssions.
-
-    :param symbol_generator: An optional method to create expressions for symbols.
-        Must have signature ``f(name) -> sympy.Basic``.
-    :param number_generator: An optional method to create expressions for numbers with units.
-        Must have signature ``f(value, unit) -> sympy.Basic``.
-    """
-    def __init__(self, symbol_generator=None, number_generator=None):
-
-        # Create simple lambdas for symbol and number generators
-        if symbol_generator is None:
-            symbol_generator = lambda x: sympy.Symbol(x)        # noqa: E731
-        if number_generator is None:
-            number_generator = lambda x, y: sympy.Float(x)      # noqa: E731
-
-        # Store symbol and number generators
-        self.symbol_generator = symbol_generator
-        self.number_generator = number_generator
-
-        # Mapping MathML tag element names (keys) to appropriate handler for SymPy output (values)
-        # These tags require explicit handling because they have children or context etc.
-        self.handlers = {
-            'apply': self._apply_handler,
-            'bvar': self._bvar_handler,
-            'ci': self._ci_handler,
-            'cn': self._cn_handler,
-            'degree': self._degree_handler,
-            'diff': self._diff_handler,
-            'divide': self._divide_handler,
-            'log': self._log_handler,
-            'logbase': self._logbase_handler,
-            'math': self._math_handler,
-            'minus': self._minus_handler,
-            'otherwise': self._otherwise_handler,
-            'piece': self._piece_handler,
-            'piecewise': self._piecewise_handler,
-            'power': self._power_handler,
-            'root': self._root_handler
-        }
-
-        # Add tags that can be handled by simple_operator_handler
-        for tag_name in SIMPLE_MATHML_TO_SYMPY_CLASSES:
-            self.handlers[tag_name] = self._simple_operator_handler
-
-    @staticmethod
-    def set_mathml_handler(mathml_operator, operator_class):
-        """Change how the transpiler handles a given mathml_operator.
-
-        :param mathml_operator: The name of a MathML operator e.g. 'exp', 'true' etc.
-        :param operator_class: A class that can handle the given operator e.g. ``sympy.exp``, or a function that creates
-            and returns a sympy object given the operands as arguments.
-        """
-        SIMPLE_MATHML_TO_SYMPY_CLASSES[mathml_operator] = operator_class
-
-    def parse_string(self, xml_string):
-        """
-        Reads MathML content from a string and returns equivalent SymPy expressions.
-        :return: A list of SymPy expressions.
-        """
-        dom = minidom.parseString(xml_string)
-        return self.parse_dom(dom.childNodes[0])
-
-    def parse_dom(self, math_dom_element):
-        """Accepts a <math> node of DOM structure and returns equivalent SymPy expressions.
-
-        Note: math_dom_element must point the <math> XmlNode, not the root XmlDocument
-
-        :param math_dom_element: <math> XmlNode object of a MathML DOM structure
-        :return: A list of SymPy expressions.
-        """
-        return self.transpile(math_dom_element)
-
-    def transpile(self, xml_node):
-        """Descends the given MathML element node and calls the corresponding handler for child
-        elements and returns the SymPy expression of node.
-        :param xml_node: a DOM element of parsed MathML
-        :return: a list of SymPy expressions
-        """
-        # Collect the parsed expression(s) (i.e. SymPy output) into list
-        sympy_expressions = []
-
-        # For each child element of this DOM node
-        for child_node in xml_node.childNodes:
-            # If this is a Text node (no child nodes)
-            if child_node.nodeType == Node.TEXT_NODE:
-                # We should be handling text between tags explicitly in the handler
-                # (see cn_handler for an example), show a message
-                text = child_node.data.strip()
-                if text:
-                    logger.warning('Unhandled text node in <%s>: "%s"', child_node.tagName, text)
-            elif child_node.nodeType == child_node.ELEMENT_NODE:
-                # Call the appropriate MathML handler function for this tag
-                tag_name = child_node.tagName
-                if tag_name in self.handlers:
-                    sympy_expressions.append(self.handlers[tag_name](child_node))
-                    logger.debug('Transpiled node %s ⟶ %s',
-                                 child_node.toxml(), sympy_expressions[-1])
-                else:
-                    # MathML handler function not found for this tag!
-                    raise NotImplementedError('No handler for element <%s>' % tag_name)
-            elif child_node.nodeType not in [Node.COMMENT_NODE, Node.PROCESSING_INSTRUCTION_NODE]:
-                raise NotImplementedError('Unknown node type %d' % child_node.nodeType)
-
-        return sympy_expressions
-
-    # MATHML ELEMENT HANDLERS ######################################################################
-
-    def _math_handler(self, node):
-        """Descend XML node <math>...</math>
-        """
-        result = self.transpile(node)
-        return result
-
-    # TOKEN ELEMENTS ###############################################################################
-
-    def _ci_handler(self, node):
-        """MathML:  https://www.w3.org/TR/MathML2/chapter4.html#contm.ci
-        SymPy: http://docs.sympy.org/latest/modules/core.html#id17
-        """
-        identifier = node.childNodes[0].data.strip()
-        return self.symbol_generator(identifier)
-
-    def _cn_handler(self, node):
-        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.cn
-        SymPy: http://docs.sympy.org/latest/modules/core.html#number
-        """
-
-        # If this number is using scientific notation
-        if 'type' in node.attributes:
-            if node.attributes['type'].value == 'e-notation':
-                # A real number may also be presented in scientific notation. Such numbers have two
-                # parts (a mantissa and an exponent) separated by sep. The first part is a real
-                # number, while the second part is an integer exponent indicating a power of the
-                # base.. For example, 12.3<sep/>5 represents 12.3 times 10^5. The default
-                # presentation of this example is 12.3e5.
-                if len(node.childNodes) == 3 and node.childNodes[1].tagName == 'sep':
-                    mantissa = node.childNodes[0].data.strip()
-                    exponent = int(node.childNodes[2].data.strip())
-                    number = float('%se%d' % (mantissa, exponent))
-                else:
-                    raise SyntaxError('Expecting '
-                                      '<cn type="e-notation">significand<sep/>exponent</cn>.'
-                                      'Got: ' + node.toxml())
-            else:
-                raise NotImplementedError('Unimplemented type attribute for <cn>: '
-                                          + node.attributes['type'].value)
-        else:
-            number = float(node.childNodes[0].data.strip())
-
-        # Get units, if given
-        # To-do: We're allowing these to _not_ be set for testing only. Maybe remove this option?
-        units = node.attributes.get('cellml:units', None)
-        if units is not None:
-            units = units.value
-
-        return self.number_generator(number, units)
-
-    # BASIC CONTENT ELEMENTS #######################################################################
-
-    def _apply_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.apply
-        """
-        result = self.transpile(node)
-
-        logger.debug('Result of <apply>:\n\t%s\t⟶\t%s', node.toxml(), result)
-
-        if len(result) > 1:
-            expression = result[0](*(result[1:]))
-        else:
-            expression = result[0]
-        return expression
-
-    def _piecewise_handler(self, node):
-        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.piecewise
-        SymPy: http://docs.sympy.org/latest/modules/functions/elementary.html#piecewise
-
-        constructor, zero or more <piece>, zero or one <otherwise>
-        """
-        result = self.transpile(node)
-        return sympy.Piecewise(*result)
-
-    def _piece_handler(self, node):
-        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.piecewise
-        Returns a 2-tuple defining an expression and condition
-        <piece> element contains exactly two children
-        """
-        result = self.transpile(node)
-        if len(result) != 2:
-            raise ValueError('Need exactly 2 children for <piece>')
-        return result[0], result[1]
-
-    def _otherwise_handler(self, node):
-        """MathML: https://www.w3.org/TR/MathML2/chapter4.html#contm.piecewise
-        Returns a 2-tuple defining an expression and condition
-        """
-        result = self.transpile(node)
-        if len(result) != 1:
-            raise ValueError('More than 1 child for <otherwise>')
-        return result[0], True
-
-    # ARITHMETIC, ALGEBRA AND LOGIC ################################################################
-
-    def _minus_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.minus
-        unary arithmetic operator OR binary arithmetic operator
-
-        From http://docs.sympy.org/latest/tutorial/manipulation.html:
-        "There is no subtraction class in SymPy. x - y is represented as x + -y, or, more
-        completely, x + -1*y, i.e., Add(x, Mul(-1, y))."
-
-        * Negation (-a) is equivalent to sympy.Mul(sympy.S.NegativeOne, a)
-        * Subtraction (a - b) is equivalent to sympy.Add(a, sympy.Mul(sympy.S.NegativeOne, b))
-        """
-        def _wrapped_minus(left_operand, right_operand=None):
-            if right_operand is None:
-                # unary arithmetic operator => negation
-                return -left_operand
-            # otherwise, binary arithmetic operator => subtraction
-            return left_operand - right_operand
-        return _wrapped_minus
-
-    def _divide_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.divide
-        binary arithmetic operator
-        There is no class in SymPy for division. Rather, division is represented by a power of -1.
-
-        Equivalent to sympy.Mul(a, sympy.Pow(b, sympy.S.NegativeOne))
-        """
-        def _wrapped_divide(dividend, divisor):
-            return dividend / divisor
-        return _wrapped_divide
-
-    def _power_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.power
-        binary arithmetic operator
-        equivalent to sympy.Pow(a, b)
-        """
-        def _wrapped_power(base, exponent):
-            return base ** exponent
-        return _wrapped_power
-
-    def _root_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.root
-        operator taking qualifiers
-
-        Nasty:
-        The root element is used to construct roots. The kind of root to be taken is specified by a
-        degree element, which should be given as the second child of the apply element enclosing the
-        root element. Thus, square roots correspond to the case where degree contains the value 2,
-        cube roots correspond to 3, and so on. If no degree is present, a default value of 2 is used
-        """
-        def _wrapped_root(first_argument, second_argument=None):
-            # if no <degree> given, it's sqrt
-            if second_argument is None:
-                return sympy.root(first_argument, 2)
-            return sympy.root(second_argument, first_argument)
-        return _wrapped_root
-
-    def _degree_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.degree
-        Meaning of <degree> depends on context! We implement it for order of <bvar> in <diff> and
-        the kind of root in <root>
-        """
-        result = self.transpile(node)
-        if len(result) != 1:
-            raise ValueError('Expected single value in <degree> tag.'
-                             'Got: ' + node.toxml())
-        return result[0]
-
-    # CALCULUS AND VECTOR CALCULUS #################################################################
-
-    def _diff_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.diff
-        operator taking qualifiers
-        """
-        def _wrapped_diff(x_symbol, y_symbol, evaluate=False):
-            # if bound variable element <bvar> contains <degree>, argument x_symbol is a list,
-            # otherwise, it is a symbol
-            if isinstance(x_symbol, list) and len(x_symbol) == 2:
-                bound_variable = x_symbol[0]
-                order = int(x_symbol[1])
-                deriv = sympy.Derivative(y_symbol, bound_variable, order, evaluate=evaluate)
-            # Otherwise, first degree derivative
-            else:
-                deriv = sympy.Derivative(y_symbol, x_symbol, evaluate=evaluate)
-
-            return deriv
-
-        return _wrapped_diff
-
-    def _bvar_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.bvar
-        NASTY: bvar element depends on the context it is being used
-        In a derivative, it indicates the variable with respect to which a function is being
-        differentiated.
-
-        The bound variable <bvar> can also specify degree. In this case, we'll have two elements
-        """
-        result = self.transpile(node)
-        if len(result) == 1:
-            # Bound variable without specifying degree
-            return result[0]
-        elif len(result) == 2:
-            return result
-        else:
-            raise SyntaxError('Do not know how to handle <bvar> ' + node.toxml())
-
-    # ELEMENTARY CLASSICAL FUNCTIONS ###############################################################
-
-    def _log_handler(self, node):
-        """https://www.w3.org/TR/MathML2/chapter4.html#contm.log
-        operator taking qualifiers or a unary calculus operator
-        """
-        def _wrapped_log(first_element, second_element=None):
-            if second_element is None:
-                # if no <logbase> element is present, the base is assumed to be 10
-                return sympy.log(first_element, 10)
-
-            # Has <logbase> element, which is the first_element after <log/>
-            return sympy.log(second_element, first_element)
-        return _wrapped_log
-
-    def _logbase_handler(self, node):
-        """Qualifier for <log>
-
-        The log function accepts only the logbase schema. If present, the logbase schema denotes the
-        base with respect to which the logarithm is being taken. Otherwise, the log is assumed to be
-        base 10. When used with log, the logbase schema is expected to contain a single child schema
-        otherwise an error is generated.
-
-        Should be the first element following log, i.e. the second child of the containing apply
-        element.
-        """
-        return self.transpile(node)[0]
-
-    def _get_nary_relation_callback(self, sympy_relation):
-        """Wraps the Sympy binary relation to handle n-ary MathML relations
-
-        :param sympy_relation: handle for binary Sympy relation (Eq, Le, Lt, Ge, Gt)
-        :return: callback used by the apply_handler to handle n-ary relations
-        """
-        def _wrapper_relational(*expressions):
-            # If the MathML relation is chaining more than 2 expressions
-            if len(expressions) > 2:
-                # Convert to multiple Sympy binary relations bundled in an 'And' boolean
-                relations = []
-                for first, second in zip(expressions[:-1], expressions[1:]):
-                    relations.append(sympy_relation(first, second))
-                return sympy.And(*relations)
-            return sympy_relation(*expressions)
-        return _wrapper_relational
-
-    def _simple_operator_handler(self, node):
-        """This function handles simple MathML <tagName> to sympy.Class operators, where no unique
-        handling of tag children etc. is required.
-        """
-        tag_name = node.tagName
-
-        handler = SIMPLE_MATHML_TO_SYMPY_CLASSES[tag_name]
-
-        # Some MathML relations allow chaining but Sympy relations are binary operations
-        if tag_name in MATHML_NARY_RELATIONS:
-            return self._get_nary_relation_callback(handler)
-
-        return handler
-
-
-# These MathML tags map directly to Sympy classes and don't require any extra handling
-SIMPLE_MATHML_TO_SYMPY_CLASSES = {
-    'abs': sympy.Abs,
-    'and': sympy.And,
-    'arccos': sympy.acos,
-    'arccosh': sympy.acosh,
-    'arccot': sympy.acot,
-    'arccoth': sympy.acoth,
-    'arccsc': sympy.acsc,
-    'arccsch': sympy.acsch,
-    'arcsec': sympy.asec,
-    'arcsech': sympy.asech,
-    'arcsin': sympy.asin,
-    'arcsinh': sympy.asinh,
-    'arctan': sympy.atan,
-    'arctanh': sympy.atanh,
-    'ceiling': sympy.ceiling,
-    'cos': sympy.cos,
-    'cosh': sympy.cosh,
-    'cot': sympy.cot,
-    'coth': sympy.coth,
-    'csc': sympy.csc,
-    'csch': sympy.csch,
-    'eq': sympy.Eq,
-    'exp': sympy.exp,
-    'exponentiale': sympy.E,
-    'false': sympy.false,
-    'floor': sympy.floor,
-    'geq': sympy.Ge,
-    'gt': sympy.Gt,
-    'infinity': sympy.oo,
-    'leq': sympy.Le,
-    'ln': sympy.ln,
-    'lt': sympy.Lt,
-    'max': sympy.Max,
-    'min': sympy.Min,
-    'neq': sympy.Ne,
-    'not': sympy.Not,
-    'notanumber': sympy.nan,
-    'or': sympy.Or,
-    'pi': sympy.pi,
-    'plus': sympy.Add,
-    'rem': sympy.Mod,
-    'sec': sympy.sec,
-    'sech': sympy.sech,
-    'sin': sympy.sin,
-    'sinh': sympy.sinh,
-    'tan': sympy.tan,
-    'tanh': sympy.tanh,
-    'times': sympy.Mul,
-    'true': sympy.true,
-    'xor': sympy.Xor,
-}
-
-# MathML relation elements that are n-ary operators
-MATHML_NARY_RELATIONS = {'eq', 'leq', 'lt', 'geq', 'gt'}
diff --git a/docs/source/reading_cellml.rst b/docs/source/reading_cellml.rst
index 4f4819c..3cf9bee 100644
--- a/docs/source/reading_cellml.rst
+++ b/docs/source/reading_cellml.rst
@@ -12,5 +12,3 @@ This uses the :class:`cellmlmanip.parser.Parser` class.
 Most users won't need to worry about this, but its documentation is included below for completeness.
 
 .. automodule:: cellmlmanip.parser
-
-.. automodule:: cellmlmanip.transpiler

Rewrite transpiler to use etree instead of dom
Investigate why the mathml2sympy transpiler doesn't like a reference to the MathML DOM element. Currently passing string representation of the MathML.

See `_add_maths` in `parser.Parser`
diff --git a/tests/test_model_evaluation.py b/tests/test_model_evaluation.py
index 9b33ffa..b55c7ee 100644
--- a/tests/test_model_evaluation.py
+++ b/tests/test_model_evaluation.py
@@ -1,90 +1,76 @@
-import os
-
 import networkx
 import pytest
 import sympy
 
-from cellmlmanip import load_model
-
 
-class TestModelEvaluation():
+def test_model_evaluation(hh_model):
     """
     Tests the calculated derivatives of a single model match a known reference.
     """
-
-    def test_evaluation(self):
-
-        # Load Hodgkin-Huxley model
-        model = load_model(os.path.join(
-            os.path.dirname(__file__),
-            "cellml_files",
-            "hodgkin_huxley_squid_axon_model_1952_modified.cellml"
-        ))
-
-        graph = model.graph_with_sympy_numbers
-
-        # Initial values for the free and state variables
-        initial_values = {
-            'environment$time': 0.0,
-            'membrane$V': -75,
-            'sodium_channel_m_gate$m': 0.05,
-            'sodium_channel_h_gate$h': 0.6,
-            'potassium_channel_n_gate$n': 0.325,
-        }
-
-        # Evaluated derivatives at the initial conditions
-        evaluated_derivatives = {
-            'membrane$V': -6.00768750000000740e-01,
-            'sodium_channel_m_gate$m': 1.23855383553985177e-02,
-            'sodium_channel_h_gate$h': -4.55523906540064583e-04,
-            'potassium_channel_n_gate$n': -1.34157228632045961e-03,
-        }
-
-        # collects all the evaluated lh-sides of the equations
-        evaluated_symbols = dict()
-
-        # saves the evaluated lh-side for the state variables
-        state_derivatives = dict()
-
-        # loop over each of the equations in the model (topologically sorted)
-        sorted_symbols = networkx.lexicographical_topological_sort(graph, key=str)
-        for symbol in sorted_symbols:
-            equation = graph.nodes[symbol]['equation']
-
-            # if this symbol is calculated using an equation
-            if equation is not None:
-
-                # substitute all symbols in the rhs of the equation
-                eq_substituted = equation.rhs.subs(evaluated_symbols)
-
-                # if any symbols remain, we have a problem
-                remaining_symbols = eq_substituted.atoms(sympy.Symbol)
-                if remaining_symbols:
-                    for remaining_symbol in remaining_symbols:
-                        pytest.fail("Unresolved symbol %s in %s" % (remaining_symbol, equation))
-
-                # calculate the result
-                eq_evaluated = eq_substituted.evalf()
-
-                # save the calculation for this symbol (to be used in the next equations)
-                evaluated_symbols[equation.lhs] = eq_evaluated
-
-                # if the symbol on the lhs is a derivative
-                if equation.lhs.is_Derivative:
-                    # save the calculation for testing
-                    state_derivatives[equation.lhs.free_symbols.pop()] = eq_evaluated
-
-            # otherwise the symbol doesn't have an equation
-            # if the symbol has an initial value
-            elif symbol.name in initial_values:
-                # add the symbol's initial value to the substitution dictionary
-                evaluated_symbols[symbol] = sympy.Number(initial_values[symbol.name])
-            else:
-                # something has gone wrong - no equation or initial value
-                pytest.fail("Symbol " + str(symbol) + " does not have equation or initial value.")
-
-        # Check evaluations against expected values
-        for state_symbol, evaluated_deriv in state_derivatives.items():
-            expected = evaluated_derivatives[state_symbol.name]
-            actual = evaluated_deriv
-            assert float(actual) == pytest.approx(expected)
+    graph = hh_model.graph_with_sympy_numbers
+
+    # Initial values for the free and state variables
+    initial_values = {
+        'environment$time': 0.0,
+        'membrane$V': -75,
+        'sodium_channel_m_gate$m': 0.05,
+        'sodium_channel_h_gate$h': 0.6,
+        'potassium_channel_n_gate$n': 0.325,
+    }
+
+    # Evaluated derivatives at the initial conditions
+    evaluated_derivatives = {
+        'membrane$V': -6.00768750000000740e-01,
+        'sodium_channel_m_gate$m': 1.23855383553985177e-02,
+        'sodium_channel_h_gate$h': -4.55523906540064583e-04,
+        'potassium_channel_n_gate$n': -1.34157228632045961e-03,
+    }
+
+    # collects all the evaluated lh-sides of the equations
+    evaluated_symbols = dict()
+
+    # saves the evaluated lh-side for the state variables
+    state_derivatives = dict()
+
+    # loop over each of the equations in the model (topologically sorted)
+    sorted_symbols = networkx.lexicographical_topological_sort(graph, key=str)
+    for symbol in sorted_symbols:
+        equation = graph.nodes[symbol]['equation']
+
+        # if this symbol is calculated using an equation
+        if equation is not None:
+
+            # substitute all symbols in the rhs of the equation
+            eq_substituted = equation.rhs.subs(evaluated_symbols)
+
+            # if any symbols remain, we have a problem
+            remaining_symbols = eq_substituted.atoms(sympy.Symbol)
+            if remaining_symbols:
+                for remaining_symbol in remaining_symbols:
+                    pytest.fail("Unresolved symbol %s in %s" % (remaining_symbol, equation))
+
+            # calculate the result
+            eq_evaluated = eq_substituted.evalf()
+
+            # save the calculation for this symbol (to be used in the next equations)
+            evaluated_symbols[equation.lhs] = eq_evaluated
+
+            # if the symbol on the lhs is a derivative
+            if equation.lhs.is_Derivative:
+                # save the calculation for testing
+                state_derivatives[equation.lhs.free_symbols.pop()] = eq_evaluated
+
+        # otherwise the symbol doesn't have an equation
+        # if the symbol has an initial value
+        elif symbol.name in initial_values:
+            # add the symbol's initial value to the substitution dictionary
+            evaluated_symbols[symbol] = sympy.Number(initial_values[symbol.name])
+        else:
+            # something has gone wrong - no equation or initial value
+            pytest.fail("Symbol " + str(symbol) + " does not have equation or initial value.")
+
+    # Check evaluations against expected values
+    for state_symbol, evaluated_deriv in state_derivatives.items():
+        expected = evaluated_derivatives[state_symbol.name]
+        actual = evaluated_deriv
+        assert float(actual) == pytest.approx(expected)
diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py
index 6e51db6..a55ae47 100644
--- a/tests/test_transpiler.py
+++ b/tests/test_transpiler.py
@@ -1,10 +1,7 @@
-import os
-from xml.dom import pulldom
-
 import pytest
 import sympy
 
-from cellmlmanip.transpiler import Transpiler
+from cellmlmanip.parser import Transpiler
 
 
 class TestTranspiler(object):
@@ -25,6 +22,12 @@ class TestTranspiler(object):
         transpiled_sympy = transpiler.parse_string(mathml_string)
         assert transpiled_sympy == sympy_expression
 
+    def assert_raises(self, content_xml, exception_class, match=None):
+        transpiler = Transpiler()
+        mathml_string = self.make_mathml(content_xml)
+        with pytest.raises(exception_class, match=match):
+            transpiler.parse_string(mathml_string)
+
     def test_symbol(self):
         self.assert_equal('<ci>x</ci>',
                           [sympy.Symbol('x')])
@@ -129,6 +132,9 @@ class TestTranspiler(object):
         self.assert_equal('<apply><root/><degree><ci>n</ci></degree><ci>a</ci></apply>',
                           [sympy.root(sympy.Symbol('a'), sympy.Symbol('n'))])
 
+    def test_degree_error(self):
+        self.assert_raises('<degree></degree>', ValueError, match='Expected single value')
+
     def test_pi(self):
         self.assert_equal('<pi/>',
                           [sympy.pi])
@@ -165,6 +171,9 @@ class TestTranspiler(object):
                           '</apply>',
                           [sympy.Derivative(V, time, 2)])
 
+    def test_bvar_error(self):
+        self.assert_raises('<bvar/>', ValueError, match='Do not know how to handle')
+
     def test_piecewise(self):
         x = sympy.Symbol('x')
         self.assert_equal('<piecewise>'
@@ -181,6 +190,12 @@ class TestTranspiler(object):
                           '</piecewise>',
                           [sympy.Piecewise((10, x > 0.), (20, x > 1.), (30, x > 2.), (0, True))])
 
+    def test_piece_error(self):
+        self.assert_raises('<piece><cn>0</cn></piece>', ValueError, match='Need exactly 2 children')
+
+    def test_otherwise_error(self):
+        self.assert_raises('<otherwise><cn>0</cn><cn>1</cn></otherwise>', ValueError, match='More than 1 child')
+
     def test_multiple_relations(self):
         from sympy.abc import a, b, c, x, y, z
         eq_xml = '<apply><eq/><ci>x</ci><ci>y</ci><ci>z</ci><cn>2.0</cn></apply>'
@@ -216,6 +231,10 @@ class TestTranspiler(object):
     def test_scientific_notation(self):
         self.assert_equal('<cn type="e-notation">1.234<sep/>5</cn>', [sympy.Number(1.234e5)])
 
+    def test_bad_cn_element(self):
+        self.assert_raises('<cn type="e-notation">1.23<sep/>5<sep/>6</cn>', ValueError, match='significand')
+        self.assert_raises('<cn type="complex-cartesian"/>', ValueError, match='Unimplemented type attribute')
+
     def test_xor(self):
         self.assert_equal('<apply><xor/><ci>a</ci><ci>b</ci></apply>',
                           [sympy.Xor(sympy.Symbol('a'), sympy.Symbol('b'))])
@@ -267,28 +286,12 @@ class TestTranspiler(object):
         assert numbers[0][0] == 2
         assert numbers[1][0] == 2000
 
-    def test_hodgkin_1952(self):
-        cellml_path = os.path.join(
-            os.path.dirname(__file__),
-            "cellml_files",
-            "hodgkin_huxley_squid_axon_model_1952_modified.cellml"
-        )
-
-        document = pulldom.parse(cellml_path)
-        components = []
-        for event, node in document:
-            if event == pulldom.START_ELEMENT and node.tagName == 'math':
-                document.expandNode(node)
-                components.append(node)
-        for component in components:
-            eqs = Transpiler().parse_dom(component)
-            for eq in eqs:
-                pass
-                # print(eq)
-
     def test_change_handler(self):
         class _exp(sympy.Function):
             pass
         Transpiler.set_mathml_handler('exp', _exp)
         self.assert_equal('<apply><exp/><ci>x</ci></apply>',
                           [_exp(sympy.Symbol('x'))])
+
+    def test_unhandled_tag(self):
+        self.assert_raises('<matrix/>', ValueError, match='No handler for element')

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov flake8 isort codecov
3.6
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==22.2.0
-e git+https://github.com/ModellingWebLab/cellmlmanip.git@f7d43a7e895532d1db966fa257a8a6ae32039610#egg=cellmlmanip
certifi==2021.5.30
charset-normalizer==2.0.12
codecov==2.1.13
coverage==6.2
decorator==4.4.0
flake8==5.0.4
idna==3.10
importlib-metadata==4.2.0
iniconfig==1.1.1
isodate==0.6.0
isort==5.10.1
lxml==4.4.1
mccabe==0.7.0
mpmath==1.1.0
networkx==2.3
packaging==21.3
Pint==0.9
pluggy==1.0.0
py==1.11.0
pycodestyle==2.9.1
pyflakes==2.5.0
pyparsing==2.4.2
pytest==7.0.1
pytest-cov==4.0.0
rdflib==4.2.2
requests==2.27.1
six==1.12.0
sympy==1.4
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.26.20
zipp==3.6.0

name: cellmlmanip
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==22.2.0
      - charset-normalizer==2.0.12
      - codecov==2.1.13
      - coverage==6.2
      - decorator==4.4.0
      - flake8==5.0.4
      - idna==3.10
      - importlib-metadata==4.2.0
      - iniconfig==1.1.1
      - isodate==0.6.0
      - isort==5.10.1
      - lxml==4.4.1
      - mccabe==0.7.0
      - mpmath==1.1.0
      - networkx==2.3
      - packaging==21.3
      - pint==0.9
      - pluggy==1.0.0
      - py==1.11.0
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
      - pyparsing==2.4.2
      - pytest==7.0.1
      - pytest-cov==4.0.0
      - rdflib==4.2.2
      - requests==2.27.1
      - six==1.12.0
      - sympy==1.4
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - zipp==3.6.0
prefix: /opt/conda/envs/cellmlmanip

tests/test_transpiler.py::TestTranspiler::test_bvar_error tests/test_transpiler.py::TestTranspiler::test_bad_cn_element tests/test_transpiler.py::TestTranspiler::test_unhandled_tag
tests/test_model_evaluation.py::test_model_evaluation tests/test_transpiler.py::TestTranspiler::test_symbol tests/test_transpiler.py::TestTranspiler::test_number tests/test_transpiler.py::TestTranspiler::test_ignore_comment tests/test_transpiler.py::TestTranspiler::test_ignore_processing tests/test_transpiler.py::TestTranspiler::test_plus tests/test_transpiler.py::TestTranspiler::test_mul tests/test_transpiler.py::TestTranspiler::test_minus tests/test_transpiler.py::TestTranspiler::test_minus_nary tests/test_transpiler.py::TestTranspiler::test_negative tests/test_transpiler.py::TestTranspiler::test_divide tests/test_transpiler.py::TestTranspiler::test_divide_nary tests/test_transpiler.py::TestTranspiler::test_eq tests/test_transpiler.py::TestTranspiler::test_neq tests/test_transpiler.py::TestTranspiler::test_leq tests/test_transpiler.py::TestTranspiler::test_lt tests/test_transpiler.py::TestTranspiler::test_floor tests/test_transpiler.py::TestTranspiler::test_geq tests/test_transpiler.py::TestTranspiler::test_gt tests/test_transpiler.py::TestTranspiler::test_and tests/test_transpiler.py::TestTranspiler::test_or tests/test_transpiler.py::TestTranspiler::test_exp tests/test_transpiler.py::TestTranspiler::test_power tests/test_transpiler.py::TestTranspiler::test_ln tests/test_transpiler.py::TestTranspiler::test_abs tests/test_transpiler.py::TestTranspiler::test_root tests/test_transpiler.py::TestTranspiler::test_root_degree tests/test_transpiler.py::TestTranspiler::test_degree_error tests/test_transpiler.py::TestTranspiler::test_pi tests/test_transpiler.py::TestTranspiler::test_mod tests/test_transpiler.py::TestTranspiler::test_log tests/test_transpiler.py::TestTranspiler::test_log_with_base tests/test_transpiler.py::TestTranspiler::test_diff tests/test_transpiler.py::TestTranspiler::test_diff_with_order tests/test_transpiler.py::TestTranspiler::test_piecewise tests/test_transpiler.py::TestTranspiler::test_piece_error tests/test_transpiler.py::TestTranspiler::test_otherwise_error tests/test_transpiler.py::TestTranspiler::test_multiple_relations tests/test_transpiler.py::TestTranspiler::test_cellml_namespace tests/test_transpiler.py::TestTranspiler::test_diff_eq tests/test_transpiler.py::TestTranspiler::test_scientific_notation tests/test_transpiler.py::TestTranspiler::test_xor tests/test_transpiler.py::TestTranspiler::test_not tests/test_transpiler.py::TestTranspiler::test_ceiling tests/test_transpiler.py::TestTranspiler::test_min tests/test_transpiler.py::TestTranspiler::test_max tests/test_transpiler.py::TestTranspiler::test_trig tests/test_transpiler.py::TestTranspiler::test_cn_units tests/test_transpiler.py::TestTranspiler::test_change_handler
docs/source/reading_cellml.rst cellmlmanip/parser.py cellmlmanip/transpiler.py
docs/source/reading_cellml.rst cellmlmanip/parser.py cellmlmanip/transpiler.py