diff --git a/pybamm/models/base_model.py b/pybamm/models/base_model.py
index 95eefdba..b7488a6d 100644
--- a/pybamm/models/base_model.py
+++ b/pybamm/models/base_model.py
@@ -195,32 +195,57 @@ class BaseModel(object):
     def check_well_posedness(self):
         """
         Check that the model is well-posed by executing the following tests:
+        - All the variables that appear in the rhs and algebraic equations appear in
+        the rhs keys, with leeway for exactly n variables where n is the number of
+        algebraic equations. Overdetermined if more equations than variables,
+        underdetermined if more variables than equations.
         - There is an initial condition in self.initial_conditions for each
         variable/equation pair in self.rhs
         - There are appropriate boundary conditions in self.boundary_conditions for each
         variable/equation pair in self.rhs
         """
+        # Equations (differential and algebraic)
+        # Get all the variables from differential and algebraic equations
+        variable_ids_in_eqns = set()
+        for eqn in list(self.rhs.values()) + self.algebraic:
+            variable_ids_in_eqns.update(
+                [x.id for x in eqn.pre_order() if isinstance(x, pybamm.Variable)]
+            )
+        # Get all variables ids from rhs keys
+        variable_ids_in_keys = set([var.id for var in self.rhs.keys()])
+        # Compare eqns and keys
+        unaccounted_variables = variable_ids_in_eqns.difference(variable_ids_in_keys)
+        # Count how many variables are not accounted for by algebraic equations
+        n_extra_variables = len(unaccounted_variables) - len(self.algebraic)
+        # Make sure n_extra_variables == 0
+        if n_extra_variables > 0:
+            raise pybamm.ModelError("model is underdetermined")
+        elif n_extra_variables < 0:
+            raise pybamm.ModelError("model is overdetermined")
+
         # Initial conditions
         for var in self.rhs.keys():
-            assert var in self.initial_conditions.keys(), pybamm.ModelError(
-                """no initial condition given for variable '{}'""".format(var)
-            )
+            if var not in self.initial_conditions.keys():
+                raise pybamm.ModelError(
+                    """no initial condition given for variable '{}'""".format(var)
+                )
 
         # Boundary conditions
         for var, eqn in self.rhs.items():
             if eqn.has_spatial_derivatives():
                 # Variable must be in at least one expression in the boundary condition
                 # keys (to account for both Dirichlet and Neumann boundary conditions)
-                assert any(
+                if not any(
                     [
                         any([var.id == symbol.id for symbol in key.pre_order()])
                         for key in self.boundary_conditions.keys()
                     ]
-                ), pybamm.ModelError(
-                    """
-                    no boundary condition given for variable '{}'
-                    with equation '{}'
-                    """.format(
-                        var, eqn
+                ):
+                    raise pybamm.ModelError(
+                        """
+                        no boundary condition given for variable '{}'
+                        with equation '{}'
+                        """.format(
+                            var, eqn
+                        )
                     )
-                )

Model well-posedness
When checking that a PDE model is well-posed, make sure that all variables that appear in the rhs dict values expression trees (i.e. the model equations) also appear in the rhs keys. 
For DAE models, give some "free passes" corresponding to the number of algebraic equations (for which we don't explicitly define a variable)

Most submodels won't be well-posed, but they don't need to be
diff --git a/tests/test_models/test_base_model.py b/tests/test_models/test_base_model.py
index e7aa7555..c175476f 100644
--- a/tests/test_models/test_base_model.py
+++ b/tests/test_models/test_base_model.py
@@ -157,7 +157,45 @@ class TestBaseModel(unittest.TestCase):
         self.assertEqual(model.initial_conditions[e], submodel2.initial_conditions[e])
         self.assertEqual(model.boundary_conditions[e], submodel2.boundary_conditions[e])
 
-    def test_check_well_posedness(self):
+    def test_check_well_posedness_variables(self):
+        # Well-posed ODE model
+        model = pybamm.BaseModel()
+        whole_cell = ["negative electrode", "separator", "positive electrode"]
+        c = pybamm.Variable("c", domain=whole_cell)
+        d = pybamm.Variable("d", domain=whole_cell)
+        model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1, d: -c}
+        model.initial_conditions = {c: 1, d: 2}
+        model.boundary_conditions = {
+            c: {"left": 0, "right": 0},
+            d: {"left": 0, "right": 0},
+        }
+        model.check_well_posedness()
+
+        # Well-posed DAE model
+        e = pybamm.Variable("e", domain=whole_cell)
+        model.algebraic = [e - c - d]
+        model.check_well_posedness()
+
+        # Underdetermined model - not enough differential equations
+        model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1}
+        model.algebraic = [e - c - d]
+        with self.assertRaisesRegex(pybamm.ModelError, "underdetermined"):
+            model.check_well_posedness()
+
+        # Underdetermined model - not enough algebraic equations
+        model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1, d: -c - e}
+        model.algebraic = []
+        with self.assertRaisesRegex(pybamm.ModelError, "underdetermined"):
+            model.check_well_posedness()
+
+        # Overdetermined model - too many algebraic equations
+        # Model cannot be overdetermined by too many differential equations, as rhs
+        # keys are all unique variables
+        model.algebraic = [c - d, e + d]
+        with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"):
+            model.check_well_posedness()
+
+    def test_check_well_posedness_initial_boundary_conditions(self):
         # Well-posed model - Dirichlet
         whole_cell = ["negative electrode", "separator", "positive electrode"]
         model = pybamm.BaseModel()
@@ -174,28 +212,22 @@ class TestBaseModel(unittest.TestCase):
         # Model with bad initial conditions (expect assertion error)
         d = pybamm.Variable("d", domain=whole_cell)
         model.initial_conditions = {d: 3}
-        with self.assertRaises(AssertionError) as error:
+        with self.assertRaisesRegex(pybamm.ModelError, "initial condition"):
             model.check_well_posedness()
-        self.assertIsInstance(error.exception.args[0], pybamm.ModelError)
-        self.assertIn("initial condition", error.exception.args[0].args[0])
 
         # Model with bad boundary conditions - Dirichlet (expect assertion error)
         d = pybamm.Variable("d", domain=whole_cell)
         model.initial_conditions = {c: 3}
         model.boundary_conditions = {d: {"left": 0, "right": 0}}
-        with self.assertRaises(AssertionError) as error:
+        with self.assertRaisesRegex(pybamm.ModelError, "boundary condition"):
             model.check_well_posedness()
-        self.assertIsInstance(error.exception.args[0], pybamm.ModelError)
-        self.assertIn("boundary condition", error.exception.args[0].args[0])
 
         # Model with bad boundary conditions - Neumann (expect assertion error)
         d = pybamm.Variable("d", domain=whole_cell)
         model.initial_conditions = {c: 3}
         model.boundary_conditions = {4 * pybamm.grad(d): {"left": 0, "right": 0}}
-        with self.assertRaises(AssertionError) as error:
+        with self.assertRaisesRegex(pybamm.ModelError, "boundary condition"):
             model.check_well_posedness()
-        self.assertIsInstance(error.exception.args[0], pybamm.ModelError)
-        self.assertIn("boundary condition", error.exception.args[0].args[0])
 
 
 if __name__ == "__main__":

pip install -e .[dev,docs]
parse_log_pytest
requirements.txt
flake8 pytest
apt-get update apt-get install -y python3-dev gfortran gcc libopenblas-dev liblapack-dev
3.6
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
anyio==3.6.2
anytree==2.8.0
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
async-generator==1.10
attrs==22.2.0
Babel==2.11.0
backcall==0.2.0
bleach==4.1.0
certifi==2021.5.30
cffi==1.15.1
charset-normalizer==2.0.12
comm==0.1.4
contextvars==2.4
cycler==0.11.0
dataclasses==0.8
decorator==5.1.1
defusedxml==0.7.1
docutils==0.17.1
entrypoints==0.4
flake8==5.0.4
guzzle-sphinx-theme==0.7.11
idna==3.10
imagesize==1.4.1
immutables==0.19
importlib-metadata==4.2.0
iniconfig==1.1.1
ipykernel==5.5.6
ipython==7.16.3
ipython-genutils==0.2.0
ipywidgets==7.8.5
jedi==0.17.2
Jinja2==3.0.3
json5==0.9.16
jsonschema==3.2.0
jupyter==1.1.1
jupyter-client==7.1.2
jupyter-console==6.4.3
jupyter-core==4.9.2
jupyter-server==1.13.1
jupyterlab==3.2.9
jupyterlab-pygments==0.1.2
jupyterlab-server==2.10.3
jupyterlab_widgets==1.1.11
kiwisolver==1.3.1
MarkupSafe==2.0.1
matplotlib==3.3.4
mccabe==0.7.0
mistune==0.8.4
nbclassic==0.3.5
nbclient==0.5.9
nbconvert==6.0.7
nbformat==5.1.3
nest-asyncio==1.6.0
notebook==6.4.10
numpy==1.19.5
packaging==21.3
pandas==1.1.5
pandocfilters==1.5.1
parso==0.7.1
pexpect==4.9.0
pickleshare==0.7.5
Pillow==8.4.0
pluggy==1.0.0
prometheus-client==0.17.1
prompt-toolkit==3.0.36
ptyprocess==0.7.0
py==1.11.0
-e git+https://github.com/tinosulzer/PyBaMM.git@f0121256faff8a78328418809e677b84b1474c47#egg=pybamm
pycodestyle==2.9.1
pycparser==2.21
pyflakes==2.5.0
Pygments==2.14.0
pyparsing==3.1.4
pyrsistent==0.18.0
pytest==7.0.1
python-dateutil==2.9.0.post0
pytz==2025.2
pyzmq==25.1.2
requests==2.27.1
scipy==1.5.4
Send2Trash==1.8.3
six==1.17.0
sniffio==1.2.0
snowballstemmer==2.2.0
Sphinx==4.3.2
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
terminado==0.12.1
testpath==0.6.0
tomli==1.2.3
tornado==6.1
traitlets==4.3.3
typing_extensions==4.1.1
urllib3==1.26.20
wcwidth==0.2.13
webencodings==0.5.1
websocket-client==1.3.1
widgetsnbextension==3.6.10
zipp==3.6.0

name: PyBaMM
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:
      - alabaster==0.7.13
      - anyio==3.6.2
      - anytree==2.8.0
      - argon2-cffi==21.3.0
      - argon2-cffi-bindings==21.2.0
      - async-generator==1.10
      - attrs==22.2.0
      - babel==2.11.0
      - backcall==0.2.0
      - bleach==4.1.0
      - cffi==1.15.1
      - charset-normalizer==2.0.12
      - comm==0.1.4
      - contextvars==2.4
      - cycler==0.11.0
      - dataclasses==0.8
      - decorator==5.1.1
      - defusedxml==0.7.1
      - docutils==0.17.1
      - entrypoints==0.4
      - flake8==5.0.4
      - guzzle-sphinx-theme==0.7.11
      - idna==3.10
      - imagesize==1.4.1
      - immutables==0.19
      - importlib-metadata==4.2.0
      - iniconfig==1.1.1
      - ipykernel==5.5.6
      - ipython==7.16.3
      - ipython-genutils==0.2.0
      - ipywidgets==7.8.5
      - jedi==0.17.2
      - jinja2==3.0.3
      - json5==0.9.16
      - jsonschema==3.2.0
      - jupyter==1.1.1
      - jupyter-client==7.1.2
      - jupyter-console==6.4.3
      - jupyter-core==4.9.2
      - jupyter-server==1.13.1
      - jupyterlab==3.2.9
      - jupyterlab-pygments==0.1.2
      - jupyterlab-server==2.10.3
      - jupyterlab-widgets==1.1.11
      - kiwisolver==1.3.1
      - markupsafe==2.0.1
      - matplotlib==3.3.4
      - mccabe==0.7.0
      - mistune==0.8.4
      - nbclassic==0.3.5
      - nbclient==0.5.9
      - nbconvert==6.0.7
      - nbformat==5.1.3
      - nest-asyncio==1.6.0
      - notebook==6.4.10
      - numpy==1.19.5
      - packaging==21.3
      - pandas==1.1.5
      - pandocfilters==1.5.1
      - parso==0.7.1
      - pexpect==4.9.0
      - pickleshare==0.7.5
      - pillow==8.4.0
      - pluggy==1.0.0
      - prometheus-client==0.17.1
      - prompt-toolkit==3.0.36
      - ptyprocess==0.7.0
      - py==1.11.0
      - pycodestyle==2.9.1
      - pycparser==2.21
      - pyflakes==2.5.0
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pyrsistent==0.18.0
      - pytest==7.0.1
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyzmq==25.1.2
      - requests==2.27.1
      - scipy==1.5.4
      - send2trash==1.8.3
      - six==1.17.0
      - sniffio==1.2.0
      - snowballstemmer==2.2.0
      - sphinx==4.3.2
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - terminado==0.12.1
      - testpath==0.6.0
      - tomli==1.2.3
      - tornado==6.1
      - traitlets==4.3.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - wcwidth==0.2.13
      - webencodings==0.5.1
      - websocket-client==1.3.1
      - widgetsnbextension==3.6.10
      - zipp==3.6.0
prefix: /opt/conda/envs/PyBaMM

tests/test_models/test_base_model.py::TestBaseModel::test_check_well_posedness_initial_boundary_conditions tests/test_models/test_base_model.py::TestBaseModel::test_check_well_posedness_variables
tests/test_models/test_base_model.py::TestBaseModel::test_algebraic_set_get tests/test_models/test_base_model.py::TestBaseModel::test_boundary_conditions_set_get tests/test_models/test_base_model.py::TestBaseModel::test_initial_conditions_set_get tests/test_models/test_base_model.py::TestBaseModel::test_model_dict_behaviour tests/test_models/test_base_model.py::TestBaseModel::test_rhs_set_get tests/test_models/test_base_model.py::TestBaseModel::test_update tests/test_models/test_base_model.py::TestBaseModel::test_variables_set_get
pybamm/models/base_model.py
pybamm/models/base_model.py