Repository: mechmotum/symbrim (language: python)

Issue:
Initial design
# Introduction
This issue discusses the initial design of `brim` (this is mainly just a scratchpad). The image below shows a general design, this however needs to be specified a lot further.
![modular_bicycle_model_explanation](https://user-images.githubusercontent.com/97806294/224776385-e21a6606-4e50-4341-b650-d6192099b82e.png)

## Overview
When defining a model using `sympy.physics.mechanics` one can in general split up the process in the following steps within which the order is less important:
1. Imports;
2. Define `symbols` and `dynamicsymbols`;
3. Define objects/nodes, such as `ReferenceFrame`, `Point`, `RigidBody`, `Particle`;
4. Define kinematics/relations, such as the orientation from one frame w.r.t. another;
5. Define loads, i.e. fores, torques (before constraints as they may alter velocities);
6. Define constraints, i.e. holonomic and non-holonomic constraints;
7. Form equations of motion.

An important aspect in this modular programming is how the responsibilities of these steps are shared among the extensions. An important part to keep in mind here is encapsulation, which in this case will mean that a child will not know anything about the parent, but the parent does know about the child.

Best way is to look to study a case, such as the definition of the front wheel in the `WhippleBicycle`.
1. Both modules can import sympy objects, but only the `WhippleBicycle` may import a `WheelBase`. Specific implementation of a wheel like `KnifeEdgeWheel` should not be imported.
2. Symbols like the radius, mass, inertia can be defined by the implementation of a `WheelBase`. The exact use of a wheel is not known to a wheel. Therefore it will be the `WhippleBicycle`, which has to define the `dynamicsymbol` for the rotation angle of the wheel.
3. The instances of a `RigidBody` a `Point` for the contact point shall be initiated by the `KnifeEdgeWheel`, where the `WheelBase` describes how they can be accessed by for example the `WhippleBicycle`.
4. The rotation angle of the wheel is defined with respect to another object, a subclass of `FrontFrameBase`. The `WhippleBicycle` will know about both, so it is the responsibility of the `WhippleBicycle` to create the relation based on the axis supplied by both the front frame and the wheel. Determining the location of the ground contact point is more complex. The computation is based on information, which is known by the wheel and by the ground. In this case a choice is made to use add an abstract method `WheelBase.set_pos_contact_point(self, ground: Type[GroundBase])`.
5. Loads generally act between objects and need to know more about the general kinematics and objects created by different extensions. In case of noncontributing forces it should be noticed that they do actually change velocities by introducing auxilary speeds. `TyreModelBase` ...
6. If there is even a holonomic constraint is the question, which can only be answered by the bicycle, e.g. `WhippleBicycle`. So the creation of the constraint is a responsibility of `WhippleBicycle`.
7. The equations of motion are in the end formed by `KanesMethod`, the parsing to `System` is handled by handled by `WhippleBicycle`, which gathers all necessary objects.

As step 1 and 2 are the responsibility of the extensions themselves, which can be ran in the `__init__` and later be overwritten by the user if preferred, I'll merge the together in one function, such that one can also use the automatic generation of symbols in for example `RigidBody`. A user should be overwriting such a symbol directly in the body and not on the wheel. A good function name is `define_objects` (other option would be `initialize_objects`).

# User stories
Probably best to write some user stories first.

### Simple Whipple bicycle
```py
from brim import *
bike = WhippleBicycle('bike', formulation='moore')
bike.rear_wheel = KnifeEdgeWheel('rear_wheel')
bike.rear_frame = RigidRearFrame('rear_frame')
bike.front_frame = RigidFrontFrame('front_frame')
bike.front_wheel = KnifeEdgeWheel('front_wheel')
# Would in a way like to not have names, but it makes unification easier
assert bike.front_wheel.radius != bike.rear_wheel.radius
# bike.rider = ...
system = bike.to_system()
system.form_eoms()
```

# Core
It is desirable to use a base class  for each object, to simply walk through the composition of each gathering all details about for example the constants etc. This abstract class will be called `ModelBase`. One of the problems is that we need to handle the submodels appropriately. An option is of course to hard code a lot. I've even started a module called templates, in which some instructions on how a class looks are written to make it easier with copy pasting. This would lead to something like the following (simplified and excluding the docstrings and typing for now):
```py
class MyModel(ModelBase):
    def __init__(self, name):
        super().__init__(name)
        self._submodel1 = None

    @property
    def submodels(self):
        return frozenset((submodel for submodel in (
            self.submodel1, ...) if submodel is not None))

    @property
    def submodel1(self):
        return self._submodel1

    @submodel1.setter
    def submodel1(self, submodel1):
        if not (submodel1 is None or isinstance(submodel1, SubModelBase)):
            raise TypeError
        self._submodel1 - submodel1
```
As visible this is not something I would really like. Therefore it would nice if you can just specify what are the submodels with their datatypes etc and it should just create them. An option would be something like:
```py
class MyModel(ModelBase):
    requirements = (
        Requirement('submodel1', (SubModelBase,), 'Some description', ...),
    )
```
This already looks a lot nicer, but there are of course some difficulties. It would first of all be best if the properties were to be defined on definition of the class. Secondly it would be quite nice if an IDE would actually see that those attributes would exist and if it would be possible to get the typing annotations in. There are two approaches to get the desired behaviour:
1. Let `ModelBase` inherit from a different meta class, which fixes the problem in its `__new__` method.
2. Put a decorator before every class, which would do something similar as `dataclass` does.
I'm gonna try to do the first. For a few reasons. First of it has to be applied to every subclass, so it is quite nice if it works via inheritance and not via a decorator before each subclass. Besides that it is probably not exactly possible to get the desired behaviour with something like [`attrs`](www.attrs.org), which would be an extra dependency (though a quite nice one).

# Base classes
## ModelBase
It is desirable to use a base class  for each object, to simply walk through the composition of each gathering all details about for example the constants etc. This abstract class will be called `ModelBase`. There exist the following requirements for this class:
- It needs to handle the symbols, associated with itself, while also being able to retrieve the ones from its child's. Some things to keep in mind are:
  - What types of symbols are there?
    - Constants
    - Dynamicsymbols
      - Coordinates
      - Speeds
      - Time-dependent loads
  - Symbols should be unique, as multiple instances of one object may exist.
    - Give each `ModelBase` a `name` property that is used as a prefix for each symbol.
    - Allow the user to specify each symbol.
    - `Dummy`'s can be used, but will result in equations that are difficult to read.
  - There should be an easy way to retrieve a description of a symbol.
- There should be a part on propagation to the child extensions.
  - There should be a property with all the child extensions, i.e. `extensions: set`.
- An option would be to have abstract methods for building the extension with the child's. It could be split in a few parts:
  - `define_objects`: only initiates all the objects, e.g. bodies and points. This should already be ran in the `__init__`.
  - `define_kinematics`: defines the kinematic relationships between the different objects, while also adding them to `System`.
  - `define_loads`: defines and adds the loads to `System`.

Some important questions to answer are:
- Should it be possible to overwrite a certain symbol? For example `wheel.radius = symbols('r')`
- Should it be possible to overwrite a certain object? For example `wheel.body = RigidBody('wheel')`
- Would quite like using the `WhippleBicycle` as a class, but there are multiple formulations, which would lead to different implementations.
  - An option might be to have different classes for each formulation, but have a `__new__` in `WhippleBicycle` which just selects the right formulation for you.




Discussion / hints:

Fix patch:
diff --git a/poetry.lock b/poetry.lock
index 4eb57c37..355cc1f2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -31,6 +31,81 @@ files = [
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 ]
 
+[[package]]
+name = "contourpy"
+version = "1.0.7"
+description = "Python library for calculating contours of 2D quadrilateral grids"
+category = "main"
+optional = true
+python-versions = ">=3.8"
+files = [
+    {file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d"},
+    {file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab"},
+    {file = "contourpy-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8acf74b5d383414401926c1598ed77825cd530ac7b463ebc2e4f46638f56cce6"},
+    {file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c71fdd8f1c0f84ffd58fca37d00ca4ebaa9e502fb49825484da075ac0b0b803"},
+    {file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e9486bf1bb979d95d5cffed40689cb595abb2b841f2991fc894b3452290e8"},
+    {file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f4d8941a9564cda3f7fa6a6cd9b32ec575830780677932abdec7bcb61717b0"},
+    {file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e20e5a1908e18aaa60d9077a6d8753090e3f85ca25da6e25d30dc0a9e84c2c6"},
+    {file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a877ada905f7d69b2a31796c4b66e31a8068b37aa9b78832d41c82fc3e056ddd"},
+    {file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6381fa66866b0ea35e15d197fc06ac3840a9b2643a6475c8fff267db8b9f1e69"},
+    {file = "contourpy-1.0.7-cp310-cp310-win32.whl", hash = "sha256:3c184ad2433635f216645fdf0493011a4667e8d46b34082f5a3de702b6ec42e3"},
+    {file = "contourpy-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:3caea6365b13119626ee996711ab63e0c9d7496f65641f4459c60a009a1f3e80"},
+    {file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed33433fc3820263a6368e532f19ddb4c5990855e4886088ad84fd7c4e561c71"},
+    {file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38e2e577f0f092b8e6774459317c05a69935a1755ecfb621c0a98f0e3c09c9a5"},
+    {file = "contourpy-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ae90d5a8590e5310c32a7630b4b8618cef7563cebf649011da80874d0aa8f414"},
+    {file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130230b7e49825c98edf0b428b7aa1125503d91732735ef897786fe5452b1ec2"},
+    {file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58569c491e7f7e874f11519ef46737cea1d6eda1b514e4eb5ac7dab6aa864d02"},
+    {file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d43960d809c4c12508a60b66cb936e7ed57d51fb5e30b513934a4a23874fae"},
+    {file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:152fd8f730c31fd67fe0ffebe1df38ab6a669403da93df218801a893645c6ccc"},
+    {file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9056c5310eb1daa33fc234ef39ebfb8c8e2533f088bbf0bc7350f70a29bde1ac"},
+    {file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a9d7587d2fdc820cc9177139b56795c39fb8560f540bba9ceea215f1f66e1566"},
+    {file = "contourpy-1.0.7-cp311-cp311-win32.whl", hash = "sha256:4ee3ee247f795a69e53cd91d927146fb16c4e803c7ac86c84104940c7d2cabf0"},
+    {file = "contourpy-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:5caeacc68642e5f19d707471890f037a13007feba8427eb7f2a60811a1fc1350"},
+    {file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad"},
+    {file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0f9d350b639db6c2c233d92c7f213d94d2e444d8e8fc5ca44c9706cf72193772"},
+    {file = "contourpy-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e96a08b62bb8de960d3a6afbc5ed8421bf1a2d9c85cc4ea73f4bc81b4910500f"},
+    {file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:031154ed61f7328ad7f97662e48660a150ef84ee1bc8876b6472af88bf5a9b98"},
+    {file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e9ebb4425fc1b658e13bace354c48a933b842d53c458f02c86f371cecbedecc"},
+    {file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb8f6d08ca7998cf59eaf50c9d60717f29a1a0a09caa46460d33b2924839dbd"},
+    {file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6c180d89a28787e4b73b07e9b0e2dac7741261dbdca95f2b489c4f8f887dd810"},
+    {file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b8d587cc39057d0afd4166083d289bdeff221ac6d3ee5046aef2d480dc4b503c"},
+    {file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:769eef00437edf115e24d87f8926955f00f7704bede656ce605097584f9966dc"},
+    {file = "contourpy-1.0.7-cp38-cp38-win32.whl", hash = "sha256:62398c80ef57589bdbe1eb8537127321c1abcfdf8c5f14f479dbbe27d0322e66"},
+    {file = "contourpy-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:57119b0116e3f408acbdccf9eb6ef19d7fe7baf0d1e9aaa5381489bc1aa56556"},
+    {file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30676ca45084ee61e9c3da589042c24a57592e375d4b138bd84d8709893a1ba4"},
+    {file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e927b3868bd1e12acee7cc8f3747d815b4ab3e445a28d2e5373a7f4a6e76ba1"},
+    {file = "contourpy-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:366a0cf0fc079af5204801786ad7a1c007714ee3909e364dbac1729f5b0849e5"},
+    {file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ba9bb365446a22411f0673abf6ee1fea3b2cf47b37533b970904880ceb72f3"},
+    {file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b0bf0c30d432278793d2141362ac853859e87de0a7dee24a1cea35231f0d50"},
+    {file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7281244c99fd7c6f27c1c6bfafba878517b0b62925a09b586d88ce750a016d2"},
+    {file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b6d0f9e1d39dbfb3977f9dd79f156c86eb03e57a7face96f199e02b18e58d32a"},
+    {file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f6979d20ee5693a1057ab53e043adffa1e7418d734c1532e2d9e915b08d8ec2"},
+    {file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5dd34c1ae752515318224cba7fc62b53130c45ac6a1040c8b7c1a223c46e8967"},
+    {file = "contourpy-1.0.7-cp39-cp39-win32.whl", hash = "sha256:c5210e5d5117e9aec8c47d9156d1d3835570dd909a899171b9535cb4a3f32693"},
+    {file = "contourpy-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:60835badb5ed5f4e194a6f21c09283dd6e007664a86101431bf870d9e86266c4"},
+    {file = "contourpy-1.0.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ce41676b3d0dd16dbcfabcc1dc46090aaf4688fd6e819ef343dbda5a57ef0161"},
+    {file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a011cf354107b47c58ea932d13b04d93c6d1d69b8b6dce885e642531f847566"},
+    {file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31a55dccc8426e71817e3fe09b37d6d48ae40aae4ecbc8c7ad59d6893569c436"},
+    {file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69f8ff4db108815addd900a74df665e135dbbd6547a8a69333a68e1f6e368ac2"},
+    {file = "contourpy-1.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efe99298ba37e37787f6a2ea868265465410822f7bea163edcc1bd3903354ea9"},
+    {file = "contourpy-1.0.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a1e97b86f73715e8670ef45292d7cc033548266f07d54e2183ecb3c87598888f"},
+    {file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc331c13902d0f50845099434cd936d49d7a2ca76cb654b39691974cb1e4812d"},
+    {file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24847601071f740837aefb730e01bd169fbcaa610209779a78db7ebb6e6a7051"},
+    {file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abf298af1e7ad44eeb93501e40eb5a67abbf93b5d90e468d01fc0c4451971afa"},
+    {file = "contourpy-1.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:64757f6460fc55d7e16ed4f1de193f362104285c667c112b50a804d482777edd"},
+    {file = "contourpy-1.0.7.tar.gz", hash = "sha256:d8165a088d31798b59e91117d1f5fc3df8168d8b48c4acc10fc0df0d0bdbcc5e"},
+]
+
+[package.dependencies]
+numpy = ">=1.16"
+
+[package.extras]
+bokeh = ["bokeh", "chromedriver", "selenium"]
+docs = ["furo", "sphinx-copybutton"]
+mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"]
+test = ["Pillow", "matplotlib", "pytest"]
+test-no-images = ["pytest"]
+
 [[package]]
 name = "coverage"
 version = "7.2.1"
@@ -98,6 +173,18 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
 [package.extras]
 toml = ["tomli"]
 
+[[package]]
+name = "cycler"
+version = "0.11.0"
+description = "Composable style cycles"
+category = "main"
+optional = true
+python-versions = ">=3.6"
+files = [
+    {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"},
+    {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"},
+]
+
 [[package]]
 name = "exceptiongroup"
 version = "1.1.1"
@@ -113,6 +200,51 @@ files = [
 [package.extras]
 test = ["pytest (>=6)"]
 
+[[package]]
+name = "fonttools"
+version = "4.39.2"
+description = "Tools to manipulate font files"
+category = "main"
+optional = true
+python-versions = ">=3.8"
+files = [
+    {file = "fonttools-4.39.2-py3-none-any.whl", hash = "sha256:85245aa2fd4cf502a643c9a9a2b5a393703e150a6eaacc3e0e84bb448053f061"},
+    {file = "fonttools-4.39.2.zip", hash = "sha256:e2d9f10337c9e3b17f9bce17a60a16a885a7d23b59b7f45ce07ea643e5580439"},
+]
+
+[package.extras]
+all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.0.0)", "xattr", "zopfli (>=0.1.4)"]
+graphite = ["lz4 (>=1.7.4.2)"]
+interpolatable = ["munkres", "scipy"]
+lxml = ["lxml (>=4.0,<5)"]
+pathops = ["skia-pathops (>=0.5.0)"]
+plot = ["matplotlib"]
+repacker = ["uharfbuzz (>=0.23.0)"]
+symfont = ["sympy"]
+type1 = ["xattr"]
+ufo = ["fs (>=2.2.0,<3)"]
+unicode = ["unicodedata2 (>=15.0.0)"]
+woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
+
+[[package]]
+name = "importlib-resources"
+version = "5.12.0"
+description = "Read resources from Python packages"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+    {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"},
+    {file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"},
+]
+
+[package.dependencies]
+zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -125,6 +257,147 @@ files = [
     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
 ]
 
+[[package]]
+name = "kiwisolver"
+version = "1.4.4"
+description = "A fast implementation of the Cassowary constraint solver"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+    {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-win32.whl", hash = "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408"},
+    {file = "kiwisolver-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-win32.whl", hash = "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e"},
+    {file = "kiwisolver-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-win32.whl", hash = "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3"},
+    {file = "kiwisolver-1.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-win32.whl", hash = "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191"},
+    {file = "kiwisolver-1.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-win32.whl", hash = "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea"},
+    {file = "kiwisolver-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b"},
+    {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a"},
+    {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d"},
+    {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a"},
+    {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871"},
+    {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9"},
+    {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8"},
+    {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286"},
+    {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb"},
+    {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f"},
+    {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008"},
+    {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767"},
+    {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9"},
+    {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2"},
+    {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b"},
+    {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"},
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.7.1"
+description = "Python plotting package"
+category = "main"
+optional = true
+python-versions = ">=3.8"
+files = [
+    {file = "matplotlib-3.7.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:95cbc13c1fc6844ab8812a525bbc237fa1470863ff3dace7352e910519e194b1"},
+    {file = "matplotlib-3.7.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:08308bae9e91aca1ec6fd6dda66237eef9f6294ddb17f0d0b3c863169bf82353"},
+    {file = "matplotlib-3.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:544764ba51900da4639c0f983b323d288f94f65f4024dc40ecb1542d74dc0500"},
+    {file = "matplotlib-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56d94989191de3fcc4e002f93f7f1be5da476385dde410ddafbb70686acf00ea"},
+    {file = "matplotlib-3.7.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99bc9e65901bb9a7ce5e7bb24af03675cbd7c70b30ac670aa263240635999a4"},
+    {file = "matplotlib-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb7d248c34a341cd4c31a06fd34d64306624c8cd8d0def7abb08792a5abfd556"},
+    {file = "matplotlib-3.7.1-cp310-cp310-win32.whl", hash = "sha256:ce463ce590f3825b52e9fe5c19a3c6a69fd7675a39d589e8b5fbe772272b3a24"},
+    {file = "matplotlib-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3d7bc90727351fb841e4d8ae620d2d86d8ed92b50473cd2b42ce9186104ecbba"},
+    {file = "matplotlib-3.7.1-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:770a205966d641627fd5cf9d3cb4b6280a716522cd36b8b284a8eb1581310f61"},
+    {file = "matplotlib-3.7.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f67bfdb83a8232cb7a92b869f9355d677bce24485c460b19d01970b64b2ed476"},
+    {file = "matplotlib-3.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2bf092f9210e105f414a043b92af583c98f50050559616930d884387d0772aba"},
+    {file = "matplotlib-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89768d84187f31717349c6bfadc0e0d8c321e8eb34522acec8a67b1236a66332"},
+    {file = "matplotlib-3.7.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83111e6388dec67822e2534e13b243cc644c7494a4bb60584edbff91585a83c6"},
+    {file = "matplotlib-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a867bf73a7eb808ef2afbca03bcdb785dae09595fbe550e1bab0cd023eba3de0"},
+    {file = "matplotlib-3.7.1-cp311-cp311-win32.whl", hash = "sha256:fbdeeb58c0cf0595efe89c05c224e0a502d1aa6a8696e68a73c3efc6bc354304"},
+    {file = "matplotlib-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0bd19c72ae53e6ab979f0ac6a3fafceb02d2ecafa023c5cca47acd934d10be7"},
+    {file = "matplotlib-3.7.1-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:6eb88d87cb2c49af00d3bbc33a003f89fd9f78d318848da029383bfc08ecfbfb"},
+    {file = "matplotlib-3.7.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:cf0e4f727534b7b1457898c4f4ae838af1ef87c359b76dcd5330fa31893a3ac7"},
+    {file = "matplotlib-3.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:46a561d23b91f30bccfd25429c3c706afe7d73a5cc64ef2dfaf2b2ac47c1a5dc"},
+    {file = "matplotlib-3.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8704726d33e9aa8a6d5215044b8d00804561971163563e6e6591f9dcf64340cc"},
+    {file = "matplotlib-3.7.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4cf327e98ecf08fcbb82685acaf1939d3338548620ab8dfa02828706402c34de"},
+    {file = "matplotlib-3.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:617f14ae9d53292ece33f45cba8503494ee199a75b44de7717964f70637a36aa"},
+    {file = "matplotlib-3.7.1-cp38-cp38-win32.whl", hash = "sha256:7c9a4b2da6fac77bcc41b1ea95fadb314e92508bf5493ceff058e727e7ecf5b0"},
+    {file = "matplotlib-3.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:14645aad967684e92fc349493fa10c08a6da514b3d03a5931a1bac26e6792bd1"},
+    {file = "matplotlib-3.7.1-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:81a6b377ea444336538638d31fdb39af6be1a043ca5e343fe18d0f17e098770b"},
+    {file = "matplotlib-3.7.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:28506a03bd7f3fe59cd3cd4ceb2a8d8a2b1db41afede01f66c42561b9be7b4b7"},
+    {file = "matplotlib-3.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8c587963b85ce41e0a8af53b9b2de8dddbf5ece4c34553f7bd9d066148dc719c"},
+    {file = "matplotlib-3.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bf26ade3ff0f27668989d98c8435ce9327d24cffb7f07d24ef609e33d582439"},
+    {file = "matplotlib-3.7.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:def58098f96a05f90af7e92fd127d21a287068202aa43b2a93476170ebd99e87"},
+    {file = "matplotlib-3.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f883a22a56a84dba3b588696a2b8a1ab0d2c3d41be53264115c71b0a942d8fdb"},
+    {file = "matplotlib-3.7.1-cp39-cp39-win32.whl", hash = "sha256:4f99e1b234c30c1e9714610eb0c6d2f11809c9c78c984a613ae539ea2ad2eb4b"},
+    {file = "matplotlib-3.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:3ba2af245e36990facf67fde840a760128ddd71210b2ab6406e640188d69d136"},
+    {file = "matplotlib-3.7.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3032884084f541163f295db8a6536e0abb0db464008fadca6c98aaf84ccf4717"},
+    {file = "matplotlib-3.7.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a2cb34336110e0ed8bb4f650e817eed61fa064acbefeb3591f1b33e3a84fd96"},
+    {file = "matplotlib-3.7.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b867e2f952ed592237a1828f027d332d8ee219ad722345b79a001f49df0936eb"},
+    {file = "matplotlib-3.7.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:57bfb8c8ea253be947ccb2bc2d1bb3862c2bccc662ad1b4626e1f5e004557042"},
+    {file = "matplotlib-3.7.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:438196cdf5dc8d39b50a45cb6e3f6274edbcf2254f85fa9b895bf85851c3a613"},
+    {file = "matplotlib-3.7.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:21e9cff1a58d42e74d01153360de92b326708fb205250150018a52c70f43c290"},
+    {file = "matplotlib-3.7.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75d4725d70b7c03e082bbb8a34639ede17f333d7247f56caceb3801cb6ff703d"},
+    {file = "matplotlib-3.7.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:97cc368a7268141afb5690760921765ed34867ffb9655dd325ed207af85c7529"},
+    {file = "matplotlib-3.7.1.tar.gz", hash = "sha256:7b73305f25eab4541bd7ee0b96d87e53ae9c9f1823be5659b806cd85786fe882"},
+]
+
+[package.dependencies]
+contourpy = ">=1.0.1"
+cycler = ">=0.10"
+fonttools = ">=4.22.0"
+importlib-resources = {version = ">=3.2.0", markers = "python_version < \"3.10\""}
+kiwisolver = ">=1.0.1"
+numpy = ">=1.20"
+packaging = ">=20.0"
+pillow = ">=6.2.0"
+pyparsing = ">=2.3.1"
+python-dateutil = ">=2.7"
+
 [[package]]
 name = "mpmath"
 version = "1.3.0"
@@ -144,69 +417,48 @@ gmpy = ["gmpy2 (>=2.1.0a4)"]
 tests = ["pytest (>=4.6)"]
 
 [[package]]
-name = "mypy"
-version = "1.1.1"
-description = "Optional static typing for Python"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "mypy-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39c7119335be05630611ee798cc982623b9e8f0cff04a0b48dfc26100e0b97af"},
-    {file = "mypy-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61bf08362e93b6b12fad3eab68c4ea903a077b87c90ac06c11e3d7a09b56b9c1"},
-    {file = "mypy-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbb19c9f662e41e474e0cff502b7064a7edc6764f5262b6cd91d698163196799"},
-    {file = "mypy-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:315ac73cc1cce4771c27d426b7ea558fb4e2836f89cb0296cbe056894e3a1f78"},
-    {file = "mypy-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5cb14ff9919b7df3538590fc4d4c49a0f84392237cbf5f7a816b4161c061829e"},
-    {file = "mypy-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26cdd6a22b9b40b2fd71881a8a4f34b4d7914c679f154f43385ca878a8297389"},
-    {file = "mypy-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b5f81b40d94c785f288948c16e1f2da37203c6006546c5d947aab6f90aefef2"},
-    {file = "mypy-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b437be1c02712a605591e1ed1d858aba681757a1e55fe678a15c2244cd68a5"},
-    {file = "mypy-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d809f88734f44a0d44959d795b1e6f64b2bbe0ea4d9cc4776aa588bb4229fc1c"},
-    {file = "mypy-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:a380c041db500e1410bb5b16b3c1c35e61e773a5c3517926b81dfdab7582be54"},
-    {file = "mypy-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b7c7b708fe9a871a96626d61912e3f4ddd365bf7f39128362bc50cbd74a634d5"},
-    {file = "mypy-1.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c10fa12df1232c936830839e2e935d090fc9ee315744ac33b8a32216b93707"},
-    {file = "mypy-1.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0a28a76785bf57655a8ea5eb0540a15b0e781c807b5aa798bd463779988fa1d5"},
-    {file = "mypy-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef6a01e563ec6a4940784c574d33f6ac1943864634517984471642908b30b6f7"},
-    {file = "mypy-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d64c28e03ce40d5303450f547e07418c64c241669ab20610f273c9e6290b4b0b"},
-    {file = "mypy-1.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64cc3afb3e9e71a79d06e3ed24bb508a6d66f782aff7e56f628bf35ba2e0ba51"},
-    {file = "mypy-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce61663faf7a8e5ec6f456857bfbcec2901fbdb3ad958b778403f63b9e606a1b"},
-    {file = "mypy-1.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b0c373d071593deefbcdd87ec8db91ea13bd8f1328d44947e88beae21e8d5e9"},
-    {file = "mypy-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:2888ce4fe5aae5a673386fa232473014056967f3904f5abfcf6367b5af1f612a"},
-    {file = "mypy-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:19ba15f9627a5723e522d007fe708007bae52b93faab00f95d72f03e1afa9598"},
-    {file = "mypy-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:59bbd71e5c58eed2e992ce6523180e03c221dcd92b52f0e792f291d67b15a71c"},
-    {file = "mypy-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9401e33814cec6aec8c03a9548e9385e0e228fc1b8b0a37b9ea21038e64cdd8a"},
-    {file = "mypy-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b398d8b1f4fba0e3c6463e02f8ad3346f71956b92287af22c9b12c3ec965a9f"},
-    {file = "mypy-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:69b35d1dcb5707382810765ed34da9db47e7f95b3528334a3c999b0c90fe523f"},
-    {file = "mypy-1.1.1-py3-none-any.whl", hash = "sha256:4e4e8b362cdf99ba00c2b218036002bdcdf1e0de085cdb296a49df03fb31dfc4"},
-    {file = "mypy-1.1.1.tar.gz", hash = "sha256:ae9ceae0f5b9059f33dbc62dea087e942c0ccab4b7a003719cb70f9b8abfa32f"},
-]
-
-[package.dependencies]
-mypy-extensions = ">=1.0.0"
-tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
-typing-extensions = ">=3.10"
-
-[package.extras]
-dmypy = ["psutil (>=4.0)"]
-install-types = ["pip"]
-python2 = ["typed-ast (>=1.4.0,<2)"]
-reports = ["lxml"]
-
-[[package]]
-name = "mypy-extensions"
-version = "1.0.0"
-description = "Type system extensions for programs checked with the mypy type checker."
-category = "dev"
+name = "numpy"
+version = "1.24.2"
+description = "Fundamental package for array computing in Python"
+category = "main"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.8"
 files = [
-    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
-    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+    {file = "numpy-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d"},
+    {file = "numpy-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5"},
+    {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253"},
+    {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978"},
+    {file = "numpy-1.24.2-cp310-cp310-win32.whl", hash = "sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9"},
+    {file = "numpy-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0"},
+    {file = "numpy-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a"},
+    {file = "numpy-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0"},
+    {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281"},
+    {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910"},
+    {file = "numpy-1.24.2-cp311-cp311-win32.whl", hash = "sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95"},
+    {file = "numpy-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04"},
+    {file = "numpy-1.24.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2"},
+    {file = "numpy-1.24.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5"},
+    {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a"},
+    {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96"},
+    {file = "numpy-1.24.2-cp38-cp38-win32.whl", hash = "sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d"},
+    {file = "numpy-1.24.2-cp38-cp38-win_amd64.whl", hash = "sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756"},
+    {file = "numpy-1.24.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a"},
+    {file = "numpy-1.24.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f"},
+    {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb"},
+    {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780"},
+    {file = "numpy-1.24.2-cp39-cp39-win32.whl", hash = "sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468"},
+    {file = "numpy-1.24.2-cp39-cp39-win_amd64.whl", hash = "sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5"},
+    {file = "numpy-1.24.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d"},
+    {file = "numpy-1.24.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa"},
+    {file = "numpy-1.24.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f"},
+    {file = "numpy-1.24.2.tar.gz", hash = "sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22"},
 ]
 
 [[package]]
 name = "packaging"
 version = "23.0"
 description = "Core utilities for Python packages"
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -214,6 +466,97 @@ files = [
     {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
 ]
 
+[[package]]
+name = "pillow"
+version = "9.4.0"
+description = "Python Imaging Library (Fork)"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+    {file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
+    {file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
+    {file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
+    {file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
+    {file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
+    {file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
+    {file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
+    {file = "Pillow-9.4.0-2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:9d9a62576b68cd90f7075876f4e8444487db5eeea0e4df3ba298ee38a8d067b0"},
+    {file = "Pillow-9.4.0-2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:87708d78a14d56a990fbf4f9cb350b7d89ee8988705e58e39bdf4d82c149210f"},
+    {file = "Pillow-9.4.0-2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8a2b5874d17e72dfb80d917213abd55d7e1ed2479f38f001f264f7ce7bae757c"},
+    {file = "Pillow-9.4.0-2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:83125753a60cfc8c412de5896d10a0a405e0bd88d0470ad82e0869ddf0cb3848"},
+    {file = "Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:9e5f94742033898bfe84c93c831a6f552bb629448d4072dd312306bab3bd96f1"},
+    {file = "Pillow-9.4.0-2-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:013016af6b3a12a2f40b704677f8b51f72cb007dac785a9933d5c86a72a7fe33"},
+    {file = "Pillow-9.4.0-2-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:99d92d148dd03fd19d16175b6d355cc1b01faf80dae93c6c3eb4163709edc0a9"},
+    {file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
+    {file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
+    {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
+    {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3049a10261d7f2b6514d35bbb7a4dfc3ece4c4de14ef5876c4b7a23a0e566d"},
+    {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16a8df99701f9095bea8a6c4b3197da105df6f74e6176c5b410bc2df2fd29a57"},
+    {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:94cdff45173b1919350601f82d61365e792895e3c3a3443cf99819e6fbf717a5"},
+    {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed3e4b4e1e6de75fdc16d3259098de7c6571b1a6cc863b1a49e7d3d53e036070"},
+    {file = "Pillow-9.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5b2f8a31bd43e0f18172d8ac82347c8f37ef3e0b414431157718aa234991b28"},
+    {file = "Pillow-9.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:09b89ddc95c248ee788328528e6a2996e09eaccddeeb82a5356e92645733be35"},
+    {file = "Pillow-9.4.0-cp310-cp310-win32.whl", hash = "sha256:f09598b416ba39a8f489c124447b007fe865f786a89dbfa48bb5cf395693132a"},
+    {file = "Pillow-9.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6e78171be3fb7941f9910ea15b4b14ec27725865a73c15277bc39f5ca4f8391"},
+    {file = "Pillow-9.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:3fa1284762aacca6dc97474ee9c16f83990b8eeb6697f2ba17140d54b453e133"},
+    {file = "Pillow-9.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eaef5d2de3c7e9b21f1e762f289d17b726c2239a42b11e25446abf82b26ac132"},
+    {file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4dfdae195335abb4e89cc9762b2edc524f3c6e80d647a9a81bf81e17e3fb6f0"},
+    {file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6abfb51a82e919e3933eb137e17c4ae9c0475a25508ea88993bb59faf82f3b35"},
+    {file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451f10ef963918e65b8869e17d67db5e2f4ab40e716ee6ce7129b0cde2876eab"},
+    {file = "Pillow-9.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6663977496d616b618b6cfa43ec86e479ee62b942e1da76a2c3daa1c75933ef4"},
+    {file = "Pillow-9.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60e7da3a3ad1812c128750fc1bc14a7ceeb8d29f77e0a2356a8fb2aa8925287d"},
+    {file = "Pillow-9.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:19005a8e58b7c1796bc0167862b1f54a64d3b44ee5d48152b06bb861458bc0f8"},
+    {file = "Pillow-9.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f715c32e774a60a337b2bb8ad9839b4abf75b267a0f18806f6f4f5f1688c4b5a"},
+    {file = "Pillow-9.4.0-cp311-cp311-win32.whl", hash = "sha256:b222090c455d6d1a64e6b7bb5f4035c4dff479e22455c9eaa1bdd4c75b52c80c"},
+    {file = "Pillow-9.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba6612b6548220ff5e9df85261bddc811a057b0b465a1226b39bfb8550616aee"},
+    {file = "Pillow-9.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5f532a2ad4d174eb73494e7397988e22bf427f91acc8e6ebf5bb10597b49c493"},
+    {file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dd5a9c3091a0f414a963d427f920368e2b6a4c2f7527fdd82cde8ef0bc7a327"},
+    {file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef21af928e807f10bf4141cad4746eee692a0dd3ff56cfb25fce076ec3cc8abe"},
+    {file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:847b114580c5cc9ebaf216dd8c8dbc6b00a3b7ab0131e173d7120e6deade1f57"},
+    {file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:653d7fb2df65efefbcbf81ef5fe5e5be931f1ee4332c2893ca638c9b11a409c4"},
+    {file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:46f39cab8bbf4a384ba7cb0bc8bae7b7062b6a11cfac1ca4bc144dea90d4a9f5"},
+    {file = "Pillow-9.4.0-cp37-cp37m-win32.whl", hash = "sha256:7ac7594397698f77bce84382929747130765f66406dc2cd8b4ab4da68ade4c6e"},
+    {file = "Pillow-9.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:46c259e87199041583658457372a183636ae8cd56dbf3f0755e0f376a7f9d0e6"},
+    {file = "Pillow-9.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:0e51f608da093e5d9038c592b5b575cadc12fd748af1479b5e858045fff955a9"},
+    {file = "Pillow-9.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:765cb54c0b8724a7c12c55146ae4647e0274a839fb6de7bcba841e04298e1011"},
+    {file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:519e14e2c49fcf7616d6d2cfc5c70adae95682ae20f0395e9280db85e8d6c4df"},
+    {file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d197df5489004db87d90b918033edbeee0bd6df3848a204bca3ff0a903bef837"},
+    {file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0845adc64fe9886db00f5ab68c4a8cd933ab749a87747555cec1c95acea64b0b"},
+    {file = "Pillow-9.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:e1339790c083c5a4de48f688b4841f18df839eb3c9584a770cbd818b33e26d5d"},
+    {file = "Pillow-9.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:a96e6e23f2b79433390273eaf8cc94fec9c6370842e577ab10dabdcc7ea0a66b"},
+    {file = "Pillow-9.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7cfc287da09f9d2a7ec146ee4d72d6ea1342e770d975e49a8621bf54eaa8f30f"},
+    {file = "Pillow-9.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d7081c084ceb58278dd3cf81f836bc818978c0ccc770cbbb202125ddabec6628"},
+    {file = "Pillow-9.4.0-cp38-cp38-win32.whl", hash = "sha256:df41112ccce5d47770a0c13651479fbcd8793f34232a2dd9faeccb75eb5d0d0d"},
+    {file = "Pillow-9.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7a21222644ab69ddd9967cfe6f2bb420b460dae4289c9d40ff9a4896e7c35c9a"},
+    {file = "Pillow-9.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0f3269304c1a7ce82f1759c12ce731ef9b6e95b6df829dccd9fe42912cc48569"},
+    {file = "Pillow-9.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cb362e3b0976dc994857391b776ddaa8c13c28a16f80ac6522c23d5257156bed"},
+    {file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2e0f87144fcbbe54297cae708c5e7f9da21a4646523456b00cc956bd4c65815"},
+    {file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28676836c7796805914b76b1837a40f76827ee0d5398f72f7dcc634bae7c6264"},
+    {file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0884ba7b515163a1a05440a138adeb722b8a6ae2c2b33aea93ea3118dd3a899e"},
+    {file = "Pillow-9.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:53dcb50fbdc3fb2c55431a9b30caeb2f7027fcd2aeb501459464f0214200a503"},
+    {file = "Pillow-9.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:e8c5cf126889a4de385c02a2c3d3aba4b00f70234bfddae82a5eaa3ee6d5e3e6"},
+    {file = "Pillow-9.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c6b1389ed66cdd174d040105123a5a1bc91d0aa7059c7261d20e583b6d8cbd2"},
+    {file = "Pillow-9.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0dd4c681b82214b36273c18ca7ee87065a50e013112eea7d78c7a1b89a739153"},
+    {file = "Pillow-9.4.0-cp39-cp39-win32.whl", hash = "sha256:6d9dfb9959a3b0039ee06c1a1a90dc23bac3b430842dcb97908ddde05870601c"},
+    {file = "Pillow-9.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:54614444887e0d3043557d9dbc697dbb16cfb5a35d672b7a0fcc1ed0cf1c600b"},
+    {file = "Pillow-9.4.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b9b752ab91e78234941e44abdecc07f1f0d8f51fb62941d32995b8161f68cfe5"},
+    {file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3b56206244dc8711f7e8b7d6cad4663917cd5b2d950799425076681e8766286"},
+    {file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aabdab8ec1e7ca7f1434d042bf8b1e92056245fb179790dc97ed040361f16bfd"},
+    {file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:db74f5562c09953b2c5f8ec4b7dfd3f5421f31811e97d1dbc0a7c93d6e3a24df"},
+    {file = "Pillow-9.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e9d7747847c53a16a729b6ee5e737cf170f7a16611c143d95aa60a109a59c336"},
+    {file = "Pillow-9.4.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b52ff4f4e002f828ea6483faf4c4e8deea8d743cf801b74910243c58acc6eda3"},
+    {file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:575d8912dca808edd9acd6f7795199332696d3469665ef26163cd090fa1f8bfa"},
+    {file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c4ed2ff6760e98d262e0cc9c9a7f7b8a9f61aa4d47c58835cdaf7b0b8811bb"},
+    {file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e621b0246192d3b9cb1dc62c78cfa4c6f6d2ddc0ec207d43c0dedecb914f152a"},
+    {file = "Pillow-9.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8f127e7b028900421cad64f51f75c051b628db17fb00e099eb148761eed598c9"},
+    {file = "Pillow-9.4.0.tar.gz", hash = "sha256:a1c2d7780448eb93fbcc3789bf3916aa5720d942e37945f4056680317f1cd23e"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+
 [[package]]
 name = "pluggy"
 version = "1.0.0"
@@ -230,6 +573,21 @@ files = [
 dev = ["pre-commit", "tox"]
 testing = ["pytest", "pytest-benchmark"]
 
+[[package]]
+name = "pyparsing"
+version = "3.0.9"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "main"
+optional = true
+python-versions = ">=3.6.8"
+files = [
+    {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
+    {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
+]
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
 [[package]]
 name = "pytest"
 version = "7.2.2"
@@ -273,6 +631,21 @@ pytest = ">=4.6"
 [package.extras]
 testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
 
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = true
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
 [[package]]
 name = "ruff"
 version = "0.0.254"
@@ -300,9 +673,42 @@ files = [
     {file = "ruff-0.0.254.tar.gz", hash = "sha256:0eb66c9520151d3bd950ea43b3a088618a8e4e10a5014a72687881e6f3606312"},
 ]
 
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = true
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "symmeplot"
+version = "0.0.1"
+description = "SymPy Mechanics Plotter"
+category = "main"
+optional = true
+python-versions = "^3.8"
+files = []
+develop = false
+
+[package.dependencies]
+matplotlib = "^3.7.1"
+numpy = "^1.24.2"
+sympy = "^1.11.1"
+
+[package.source]
+type = "git"
+url = "https://github.com/TJStienstra/symmeplot.git"
+reference = "HEAD"
+resolved_reference = "79478315408ea5ae115777619ec1bb50d8b8f3df"
+
 [[package]]
 name = "sympy"
-version = "1.12.dev0"
+version = "1.13.dev0"
 description = "Computer algebra system (CAS) in Python"
 category = "main"
 optional = false
@@ -315,9 +721,9 @@ mpmath = ">=0.19"
 
 [package.source]
 type = "git"
-url = "https://github.com/sympy/sympy.git"
-reference = "physics-mechanics-devel"
-resolved_reference = "06b3ea3435ee4808a205340ef5dfba9e39d9fa92"
+url = "https://github.com/TJStienstra/sympy.git"
+reference = "sympy-for-brim"
+resolved_reference = "fc421eb45cc45833378c885e1a23af785382f240"
 
 [[package]]
 name = "tomli"
@@ -332,18 +738,22 @@ files = [
 ]
 
 [[package]]
-name = "typing-extensions"
-version = "4.5.0"
-description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "dev"
-optional = false
+name = "zipp"
+version = "3.15.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = true
 python-versions = ">=3.7"
 files = [
-    {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"},
-    {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
+    {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
+    {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"},
 ]
 
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "40e93dc0e3caba27dae4730de5c1b3121152ce27ecfcfe59828baa32b297c3e7"
+content-hash = "b67b0ea0daee9cab357f34da7c04280f9a331e01f984c3b2b62b21d7745885d1"
diff --git a/pyproject.toml b/pyproject.toml
index 342c6714..583f556b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,16 +9,17 @@ repository = "https://github.com/TJStienstra/brim"
 
 [tool.poetry.dependencies]
 python = "^3.8"
-sympy = {git = "https://github.com/sympy/sympy.git", rev = "physics-mechanics-devel"}
+sympy = {git = "https://github.com/TJStienstra/sympy.git", rev = "sympy-for-brim"}
+symmeplot = {git = "https://github.com/TJStienstra/symmeplot.git", optional = true}
 
 [tool.poetry.group.lint.dependencies]
 ruff = "^0.0.254"
-mypy = "^1.1.1"
 
 [tool.poetry.group.test.dependencies]
 pytest = "^7.2.2"
 coverage = {extras = ["toml"], version = "^7.2.1"}
 pytest-cov = "^4.0.0"
+numpy = "^1.24.2"
 
 [build-system]
 requires = ["poetry-core"]
@@ -56,3 +57,11 @@ source = ["brim"]
 [tool.coverage.report]
 show_missing = true
 fail_under = 100
+exclude_lines = [
+    "pragma: no cover",
+    "if __name__ == .__main__.:",
+    "if TYPE_CHECKING:",
+]
+omit = [
+    "src/brim/utilities/plotting.py",
+]
diff --git a/src/brim/__init__.py b/src/brim/__init__.py
index 57c50b57..70a4ebf5 100644
--- a/src/brim/__init__.py
+++ b/src/brim/__init__.py
@@ -1,4 +1,17 @@
-__all__ = ["ModelBase", "BicycleBase"]
+"""BRiM.
 
-from brim.bicycle_base import BicycleBase
-from brim.model_base import ModelBase
+A Modular and Extensible Open-Source Framework for Creating Bicycle-Rider Models.
+"""
+__all__ = [
+    "WhippleBicycle", "FlatGround", "RigidRearFrame", "RigidFrontFrame",
+    "KnifeEdgeWheel", "NonHolonomicTyreModel",
+]
+
+from brim.bicycle import (
+    FlatGround,
+    KnifeEdgeWheel,
+    NonHolonomicTyreModel,
+    RigidFrontFrame,
+    RigidRearFrame,
+    WhippleBicycle,
+)
diff --git a/src/brim/bicycle/__init__.py b/src/brim/bicycle/__init__.py
new file mode 100644
index 00000000..daf82687
--- /dev/null
+++ b/src/brim/bicycle/__init__.py
@@ -0,0 +1,22 @@
+"""Bicycle module."""
+__all__ = [
+    "BicycleBase",
+    "WhippleBicycle", "WhippleBicycleMoore",
+    "GroundBase", "FlatGround",
+    "RearFrameBase", "RigidRearFrame", "RigidRearFrameMoore",
+    "FrontFrameBase", "RigidFrontFrame", "RigidFrontFrameMoore",
+    "WheelBase", "KnifeEdgeWheel",
+    "TyreModelBase", "NonHolonomicTyreModel",
+]
+
+from brim.bicycle.bicycle_base import BicycleBase
+from brim.bicycle.front_frames import (
+    FrontFrameBase,
+    RigidFrontFrame,
+    RigidFrontFrameMoore,
+)
+from brim.bicycle.grounds import FlatGround, GroundBase
+from brim.bicycle.rear_frames import RearFrameBase, RigidRearFrame, RigidRearFrameMoore
+from brim.bicycle.tyre_models import NonHolonomicTyreModel, TyreModelBase
+from brim.bicycle.wheels import KnifeEdgeWheel, WheelBase
+from brim.bicycle.whipple_bicycle import WhippleBicycle, WhippleBicycleMoore
diff --git a/src/brim/bicycle/bicycle_base.py b/src/brim/bicycle/bicycle_base.py
new file mode 100644
index 00000000..38c23e5f
--- /dev/null
+++ b/src/brim/bicycle/bicycle_base.py
@@ -0,0 +1,10 @@
+"""Module containing the base class for bicycles."""
+from __future__ import annotations
+
+from brim.core import ModelBase
+
+__all__ = ["BicycleBase"]
+
+
+class BicycleBase(ModelBase):
+    """Base class for the bicycles."""
diff --git a/src/brim/bicycle/front_frames.py b/src/brim/bicycle/front_frames.py
new file mode 100644
index 00000000..196c8c8a
--- /dev/null
+++ b/src/brim/bicycle/front_frames.py
@@ -0,0 +1,136 @@
+"""Module containing the models of the front frame of a bicycle."""
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import Any
+
+from sympy import Symbol, symbols
+from sympy.physics.mechanics import Point, Vector, inertia
+
+from brim.core import ModelBase, NewtonianBodyMixin
+
+__all__ = ["FrontFrameBase", "RigidFrontFrame", "RigidFrontFrameMoore"]
+
+
+class FrontFrameBase(NewtonianBodyMixin, ModelBase):
+    """Base class for the front frame of a bicycle."""
+
+    @property
+    @abstractmethod
+    def steer_axis(self) -> Vector:
+        """Steer axis expressed in the front frame."""
+
+    @property
+    @abstractmethod
+    def wheel_axis(self) -> Vector:
+        """Wheel axis expressed in the front frame."""
+
+
+class RigidFrontFrame(FrontFrameBase):
+    """Rigid front frame."""
+
+    def __new__(cls, name: str, *args, formulation: str = "moore", **kwargs
+                ) -> RigidFrontFrame:
+        """Create a new instance of the front frame.
+
+        Parameters
+        ----------
+        name : str
+            Name of the front frame.
+        formulation : str, optional
+            Formulation of the front frame, by default "moore".
+
+        """
+        if formulation == "moore":
+            cls = RigidFrontFrameMoore
+        else:
+            raise NotImplementedError(f"Formulation '{formulation}' has not been "
+                                      f"implemented.")
+        return super().__new__(cls)
+
+    def define_objects(self):
+        """Define the objects of the front frame."""
+        super().define_objects()
+        self.body.central_inertia = inertia(self.body.frame,
+                                            *symbols(self.add_prefix("ixx iyy izz")),
+                                            izx=Symbol(self.add_prefix("izx")))
+        self._wheel_attachment = Point("wheel_attachment")
+
+    def define_kinematics(self):
+        """Define the kinematics of the front frame."""
+        super().define_kinematics()
+        self.wheel_attachment.set_vel(self.frame, 0)
+
+    def define_loads(self):
+        """Define the loads acting upon the front frame."""
+        super().define_loads()
+
+    @property
+    @abstractmethod
+    def wheel_axis(self) -> Vector:
+        """Wheel axis expressed in the front frame."""
+
+    @property
+    def wheel_attachment(self) -> Point:
+        """Point representing the attachment of the front wheel."""
+        return self._wheel_attachment
+
+
+class RigidFrontFrameMoore(RigidFrontFrame):
+    """Rigid front frame model based on Moore's formulation."""
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Descriptions of the attributes of the front frame."""
+        return {
+            **super().descriptions,
+            self.lengths[0]: "Distance between wheels along the steer axis.",
+            self.lengths[1]: "Perpendicular distance from the steer axis to the center "
+                             "of the front wheel (fork offset).",
+            self.lengths[2]: "Distance in the front frame x drection from the front "
+                             "wheel center to the center of mass of the front frame.",
+            self.lengths[3]: "Distance in the front frame z drection from the front "
+                             "wheel center to the center of mass of the front frame.",
+        }
+
+    def define_objects(self):
+        """Define the objects of the front frame."""
+        super().define_objects()
+        self.lengths = list(symbols(self.add_prefix("d2 d3 l3 l4")))
+        self._steer_attachment = Point("steer_attachment")
+
+    def define_kinematics(self):
+        """Define the kinematics of the front frame."""
+        super().define_kinematics()
+        self.wheel_attachment.set_pos(
+            self.steer_attachment,
+            self.lengths[1] * self.body.x + self.lengths[0] * self.body.z)
+        self.body.masscenter.set_pos(self.wheel_attachment,
+                                     self.lengths[2] * self.body.x + self.lengths[
+                                         3] * self.body.z)
+        self.body.masscenter.set_vel(self.frame, 0)
+        self.steer_attachment.set_vel(self.frame, 0)
+        self.wheel_attachment.set_vel(self.frame, 0)
+
+    @property
+    def steer_axis(self) -> Vector:
+        """Steer axis expressed in the front frame."""
+        return self.body.z
+
+    @property
+    def wheel_axis(self) -> Vector:
+        """Wheel axis expressed in the front frame."""
+        return self.body.y
+
+    @property
+    def steer_attachment(self) -> Point:
+        """Attachment point between the rear frame and the front frame.
+
+        Explanation
+        -----------
+        In Moore's formulation an attachment point between the rear and the front frame
+        is defined. This point is defined as the intersection of the steer axis a
+        perpendicular line, which passes through the attachment of the rear wheel to the
+        rear frame.
+        """
+        return self._steer_attachment
diff --git a/src/brim/bicycle/grounds.py b/src/brim/bicycle/grounds.py
new file mode 100644
index 00000000..38c9a6c0
--- /dev/null
+++ b/src/brim/bicycle/grounds.py
@@ -0,0 +1,110 @@
+"""Module containing the models of the ground."""
+from __future__ import annotations
+
+from abc import abstractmethod
+
+from sympy.physics.mechanics import (
+    Point,
+    ReferenceFrame,
+    RigidBody,
+    System,
+    Vector,
+    cross,
+)
+
+from brim.core import ModelBase
+
+__all__ = ["GroundBase", "FlatGround"]
+
+
+class GroundBase(ModelBase):
+    """Base class for the ground."""
+
+    def define_objects(self) -> None:
+        """Define the objects of the ground."""
+        super().define_objects()
+        self._body = RigidBody(self.name)
+        self._body.masscenter = Point(self.add_prefix("origin"))
+        self._system = System.from_newtonian(self.body)
+
+    def define_kinematics(self) -> None:
+        """Define the kinematics of the ground."""
+        super().define_kinematics()
+        self.origin.set_vel(self.frame, 0)
+
+    def define_loads(self) -> None:
+        """Define the loads acting upon the ground."""
+        super().define_loads()
+
+    @property
+    def body(self) -> RigidBody:
+        """The body representing the ground."""
+        return self._body
+
+    @property
+    @abstractmethod
+    def frame(self) -> ReferenceFrame:
+        """Frame fixed to the ground."""
+
+    @property
+    @abstractmethod
+    def normal(self) -> Vector:
+        """Normal vector of the ground."""
+
+    @property
+    @abstractmethod
+    def planar_vectors(self) -> tuple[Vector, Vector]:
+        """Planar vectors of the ground."""
+
+    @property
+    def origin(self) -> Point:
+        """Origin of the ground."""
+        return self.body.masscenter
+
+
+class FlatGround(GroundBase):
+    """Flat ground."""
+
+    def __init__(self, name: str, normal: str = "-z") -> None:
+        """Initialize the flat ground.
+
+        Parameters
+        ----------
+        name : str
+            Name of the ground.
+        normal : str, optional
+            Normal vector of the ground. Options are:
+            - "+x", "-x", "+y", "-y", "+z", "-z"
+            The default is "-z".
+
+        """
+        super().__init__(name)
+        if normal[0] == "-":
+            normal = normal[1:]
+            times = -1
+        else:
+            times = 1
+            if normal[0] == "+":
+                normal = normal[1:]
+        self._normal = times * self.frame[normal]
+        if cross(self._normal, self.frame.x) == 0:
+            self._planar_vectors = (self.frame.y, self.frame.z)
+        elif cross(self._normal, self.frame.y) == 0:
+            self._planar_vectors = (self.frame.x, self.frame.z)
+        else:
+            self._planar_vectors = (self.frame.x, self.frame.y)
+
+    @property
+    def frame(self) -> ReferenceFrame:
+        """Frame fixed to the ground."""
+        return self.body.frame
+
+    @property
+    def normal(self) -> Vector:
+        """Normal vector of the ground."""
+        return self._normal
+
+    @property
+    def planar_vectors(self) -> tuple[Vector, Vector]:
+        """Planar vectors of the ground."""
+        return self._planar_vectors
diff --git a/src/brim/bicycle/rear_frames.py b/src/brim/bicycle/rear_frames.py
new file mode 100644
index 00000000..db744a59
--- /dev/null
+++ b/src/brim/bicycle/rear_frames.py
@@ -0,0 +1,133 @@
+"""Module containing the models of the rear frame of a bicycle."""
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import Any
+
+from sympy import Symbol, symbols
+from sympy.physics.mechanics import Point, Vector, inertia
+
+from brim.core import ModelBase, NewtonianBodyMixin
+
+__all__ = ["RearFrameBase", "RigidRearFrame", "RigidRearFrameMoore"]
+
+
+class RearFrameBase(NewtonianBodyMixin, ModelBase):
+    """Base class for the rear frame of a bicycle."""
+
+    @property
+    @abstractmethod
+    def steer_axis(self) -> Vector:
+        """Steer axis expressed in the rear frame."""
+
+    @property
+    @abstractmethod
+    def wheel_axis(self) -> Vector:
+        """Wheel axis expressed in the rear frame."""
+
+
+class RigidRearFrame(RearFrameBase):
+    """Rigid rear frame."""
+
+    def __new__(cls, name: str, *args, formulation="moore", **kwargs):
+        """Create a new instance of the rear frame.
+
+        Parameters
+        ----------
+        name : str
+            Name of the rear frame.
+        formulation : str, optional
+            Formulation of the rear frame, by default "moore".
+
+        """
+        if formulation == "moore":
+            cls = RigidRearFrameMoore
+        else:
+            raise NotImplementedError(f"Formulation '{formulation}' has not been "
+                                      f"implemented.")
+        return super().__new__(cls)
+
+    def define_objects(self):
+        """Define the objects of the rear frame."""
+        super().define_objects()
+        self.body.central_inertia = inertia(self.body.frame,
+                                            *symbols(self.add_prefix("ixx iyy izz")),
+                                            izx=Symbol(self.add_prefix("izx")))
+        self._wheel_attachment = Point("wheel_attachment")
+
+    def define_kinematics(self):
+        """Define the kinematics of the rear frame."""
+        super().define_kinematics()
+        self.wheel_attachment.set_vel(self.frame, 0)
+
+    def define_loads(self):
+        """Define the loads acting upon the rear frame."""
+        super().define_loads()
+
+    @property
+    @abstractmethod
+    def steer_axis(self) -> Vector:
+        """Steer axis expressed in the rear frame."""
+
+    @property
+    def wheel_axis(self) -> Vector:
+        """Wheel axis expressed in the rear frame."""
+        return self.body.y
+
+    @property
+    def wheel_attachment(self) -> Point:
+        """Point representing attachment of the rear wheel."""
+        return self._wheel_attachment
+
+
+class RigidRearFrameMoore(RigidRearFrame):
+    """Rigid rear frame model based on Moore's formulation."""
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Descriptions of the attributes of the rear frame."""
+        return {
+            **super().descriptions,
+            self.lengths[0]: "Perpendicular distance from the steer axis to the center "
+                             "of the rear wheel (rear offset).",
+            self.lengths[1]: "Distance in the rear frame x drection from the rear "
+                             "wheel center to the center of mass of the rear frame.",
+            self.lengths[2]: "Distance in the rear frame z drection from the rear "
+                             "wheel center to the center of mass of the rear frame.",
+        }
+
+    def define_objects(self):
+        """Define the objects of the rear frame."""
+        super().define_objects()
+        self.lengths = list(symbols(self.add_prefix("d1 l1 l2")))
+        self._steer_attachment = Point("steer_attachment")
+
+    def define_kinematics(self):
+        """Define the kinematics of the rear frame."""
+        super().define_kinematics()
+        self.steer_attachment.set_pos(self.wheel_attachment,
+                                      self.lengths[0] * self.body.x)
+        self.body.masscenter.set_pos(self.wheel_attachment,
+                                     self.lengths[1] * self.body.x + self.lengths[
+                                         2] * self.body.z)
+        self.body.masscenter.set_vel(self.frame, 0)
+        self.steer_attachment.set_vel(self.frame, 0)
+        self.wheel_attachment.set_vel(self.frame, 0)
+
+    @property
+    def steer_axis(self) -> Vector:
+        """Steer axis expressed in the rear frame."""
+        return self.body.z
+
+    @property
+    def steer_attachment(self) -> Point:
+        """Attachment point between the rear frame and the front frame.
+
+        Explanation
+        -----------
+        In Moore's formulation an attachment point between the rear and the front frame
+        is defined. This point is defined as the intersection of the steer axis a
+        perpendicular line, which passes through the attachment of the rear wheel to the
+        rear frame.
+        """
+        return self._steer_attachment
diff --git a/src/brim/bicycle/tyre_models.py b/src/brim/bicycle/tyre_models.py
new file mode 100644
index 00000000..5bdc8719
--- /dev/null
+++ b/src/brim/bicycle/tyre_models.py
@@ -0,0 +1,82 @@
+"""Module containing tyre models for bicycles."""
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import TYPE_CHECKING
+
+from sympy.physics.mechanics import System, cross
+
+from brim.core import ModelBase
+
+if TYPE_CHECKING:
+
+    from brim.bicycle.grounds import GroundBase
+    from brim.bicycle.wheels import WheelBase
+
+__all__ = ["TyreModelBase", "NonHolonomicTyreModel"]
+
+
+class TyreModelBase(ModelBase):
+    """Base class for the tyre models."""
+
+    @property
+    def system(self) -> System:
+        """System of the tyre model."""
+        return self._system
+
+    def define_objects(self) -> None:
+        """Define the objects of the tyre model."""
+        super().define_objects()
+        self._system = System()
+
+    def define_kinematics(self) -> None:
+        """Define the kinematics of the tyre model."""
+        super().define_kinematics()
+
+    def define_loads(self) -> None:
+        """Define the loads of the tyre model."""
+        super().define_loads()
+
+    @abstractmethod
+    def compute(self, ground: GroundBase, wheel: WheelBase, on_ground: bool
+                ) -> None:
+        """Compute the tyre model.
+
+        Parameters
+        ----------
+        ground : GroundBase
+            Ground model.
+        wheel : WheelBase
+            Wheel model.
+        on_ground : bool
+            Boolean whether the wheel is already defined as touching the ground.
+
+        """
+
+
+class NonHolonomicTyreModel(TyreModelBase):
+    """Tyre model based on non-holonomic constraints."""
+
+    def compute(self, ground: GroundBase, wheel: WheelBase, on_ground: bool
+                ) -> None:
+        """Compute the tyre model.
+
+        Parameters
+        ----------
+        ground : GroundBase
+            Ground model.
+        wheel : WheelBase
+            Wheel model.
+        on_ground : bool
+            Boolean whether the wheel is already defined as touching the ground.
+
+        """
+        v0 = wheel.center.vel(ground.frame) + cross(
+            wheel.frame.ang_vel_in(ground.frame),
+            wheel.contact_point.pos_from(wheel.center)
+        )
+        self.system.add_nonholonomic_constraints(
+            v0.dot(ground.planar_vectors[0]), v0.dot(ground.planar_vectors[1]))
+        if not on_ground:
+            self.system.add_holonomic_constraints(
+                wheel.contact_point.pos_from(ground.origin).dot(ground.normal))
diff --git a/src/brim/bicycle/wheels.py b/src/brim/bicycle/wheels.py
new file mode 100644
index 00000000..db1fea18
--- /dev/null
+++ b/src/brim/bicycle/wheels.py
@@ -0,0 +1,112 @@
+"""Module containing the wheel models."""
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import TYPE_CHECKING, Any
+
+from sympy import Symbol, symbols
+from sympy.physics.mechanics import Point, cross, inertia
+
+from brim.bicycle.tyre_models import TyreModelBase
+from brim.core import ModelBase, NewtonianBodyMixin, Requirement
+
+if TYPE_CHECKING:
+    from sympy.physics.mechanics import Vector
+
+    from brim.bicycle.grounds import GroundBase
+
+__all__ = ["WheelBase", "KnifeEdgeWheel"]
+
+
+class WheelBase(NewtonianBodyMixin, ModelBase):
+    """Wheel base class."""
+
+    requirements: tuple[Requirement, ...] = (
+        Requirement("tyre_model", TyreModelBase, "Tyre model of the wheel."),
+    )
+    tyre_model: TyreModelBase
+
+    def define_objects(self) -> None:
+        """Define the objects of the wheel."""
+        super().define_objects()
+        self.body.central_inertia = inertia(self.body.frame,
+                                            *symbols(self.add_prefix("ixx iyy ixx")))
+        self._contact_point = Point(self.add_prefix("contact_point"))
+
+    @property
+    @abstractmethod
+    def center(self) -> Point:
+        """Point representing the center of the wheel."""
+
+    @property
+    @abstractmethod
+    def rotation_axis(self) -> Vector:
+        """Rotation axis of the wheel."""
+
+    @property
+    def contact_point(self) -> Point:
+        """Point representing the contact point of the wheel with the ground."""
+        return self._contact_point
+
+    @abstractmethod
+    def compute_contact_point(self, ground: GroundBase) -> None:
+        """Set the position of the contact point.
+
+        Explanation
+        -----------
+        This method should be part of the parent model to set the position of the wheel
+        contact point with the ground.
+        """
+
+    def compute_tyre_model(self, ground: GroundBase, on_ground: bool) -> None:
+        """Compute the tyre model."""
+        self.tyre_model.compute(ground, self, on_ground)
+
+
+class KnifeEdgeWheel(WheelBase):
+    """Knife edge wheel."""
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Descriptions of the attributes of the wheel."""
+        return {
+            self.radius: "Radius of the wheel",
+        }
+
+    def define_objects(self) -> None:
+        """Define the objects of the wheel."""
+        super().define_objects()
+        self.radius: Symbol = Symbol(self.add_prefix("radius"))
+
+    def define_kinematics(self) -> None:
+        """Define the kinematics of the wheel."""
+        super().define_kinematics()
+
+    def define_loads(self) -> None:
+        """Define the loads acting upon the wheel."""
+        super().define_loads()
+
+    @property
+    def center(self) -> Point:
+        """Point representing the center of the wheel."""
+        return self.body.masscenter
+
+    @property
+    def rotation_axis(self) -> Vector:
+        """Rotation axis of the wheel."""
+        return self.body.y
+
+    def compute_contact_point(self, ground: GroundBase) -> None:
+        """Set the position of the contact point.
+
+        Explanation
+        -----------
+        This method should be part of the parent model to set the position of the wheel
+        contact point with the ground.
+        """
+        self.center.set_pos(self.contact_point, self.radius * cross(
+            self.body.y, cross(ground.normal, self.body.y)).normalize())
+
+
+class ToroidalWheel(KnifeEdgeWheel):
+    """Toroidal shaped wheel."""
diff --git a/src/brim/bicycle/whipple_bicycle.py b/src/brim/bicycle/whipple_bicycle.py
new file mode 100644
index 00000000..cf6b0825
--- /dev/null
+++ b/src/brim/bicycle/whipple_bicycle.py
@@ -0,0 +1,133 @@
+"""Module containing the Whipple bicycle model."""
+from __future__ import annotations
+
+from typing import Any
+
+from sympy import Matrix
+from sympy.physics.mechanics import PinJoint, System, dynamicsymbols
+
+from brim.bicycle.bicycle_base import BicycleBase
+from brim.bicycle.front_frames import FrontFrameBase
+from brim.bicycle.grounds import GroundBase
+from brim.bicycle.rear_frames import RearFrameBase
+from brim.bicycle.wheels import WheelBase
+from brim.core import Requirement
+
+__all__ = ["WhippleBicycle", "WhippleBicycleMoore"]
+
+
+class WhippleBicycle(BicycleBase):
+    """Base class for the Whipple bicycle model."""
+
+    requirements: tuple[Requirement, ...] = (
+        Requirement("ground", GroundBase, "Submodel of the ground.", True),
+        Requirement("rear_frame", RearFrameBase, "Submodel of the rear frame.", True),
+        Requirement("front_frame", FrontFrameBase, "Submodel of the front frame.",
+                    True),
+        Requirement("rear_wheel", WheelBase, "Submodel of the rear wheel.", True),
+        Requirement("front_wheel", WheelBase, "Submodel of the front wheel.", True),
+    )
+    ground: GroundBase
+    rear_frame: RearFrameBase
+    front_frame: FrontFrameBase
+    rear_wheel: WheelBase
+    front_wheel: WheelBase
+
+    def __new__(cls, name: str, *args, formulation: str = "moore", **kwargs
+                ) -> WhippleBicycle:
+        """Create a new instance of the Whipple bicycle.
+
+        Parameters
+        ----------
+        name : str
+            Name of the Whipple bicycle.
+        formulation : str, optional
+            Formulation of the Whipple bicycle, by default "moore".
+
+        """
+        if formulation == "moore":
+            cls = WhippleBicycleMoore
+        else:
+            raise NotImplementedError(f"The formulation '{formulation}' has not "
+                                      f"been implemented in {cls}.")
+        return super().__new__(cls)
+
+
+class WhippleBicycleMoore(WhippleBicycle):
+    """Whipple bicycle model based on Moore's formulation."""
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Dictionary of descriptions of the Whipple bicycle's attributes."""
+        desc = {
+            **super().descriptions,
+            self.q[0]: f"Perpendicular distance along ground.x to the rear contact "
+                       f"point in the ground plane of {self.name}.",
+            self.q[1]: f"Perpendicular distance along ground.y to the rear contact "
+                       f"point in the ground plane of {self.name}.",
+            self.q[2]: f"Yaw angle of the rear frame of {self.name}.",
+            self.q[3]: f"Roll angle of the rear frame of {self.name}.",
+            self.q[4]: f"Pitch angle of the rear frame of {self.name}.",
+            self.q[5]: f"Front wheel rotation angle of {self.name}.",
+            self.q[6]: f"Steering rotation angle of {self.name}.",
+            self.q[7]: f"Rear wheel rotation angle of {self.name}.",
+        }
+        desc.update({ui: f"Generalized speed of the {desc[qi].lower()}"
+                     for qi, ui in zip(self.q, self.u)})
+        return desc
+
+    def define_objects(self) -> None:
+        """Define the objects of the Whipple bicycle."""
+        super().define_objects()
+        self.q = Matrix(dynamicsymbols(self.add_prefix("q1:9")))
+        self.u = Matrix(dynamicsymbols(self.add_prefix("u1:9")))
+
+    def define_kinematics(self) -> None:
+        """Define the kinematics of the Whipple bicycle."""
+        super().define_kinematics()
+        self._system = System.from_newtonian(self.ground.body)
+        # Define the location of the rear wheel contact point in the ground frame.
+        self.rear_wheel.contact_point.set_pos(
+            self.ground.origin,
+            self.q[0] * self.ground.planar_vectors[0] +
+            self.q[1] * self.ground.planar_vectors[1])
+        self.rear_wheel.contact_point.set_vel(
+            self.ground.frame,
+            self.u[0] * self.ground.planar_vectors[0] +
+            self.u[1] * self.ground.planar_vectors[1])
+        # Define the orientation of the rear frame.
+        self.rear_frame.frame.orient_body_fixed(self.ground.frame, self.q[2:5], "zxy")
+        self.rear_frame.frame.set_ang_vel(
+            self.ground.frame,
+            self.rear_frame.frame.ang_vel_in(self.ground.frame).xreplace(
+                {qi.diff(dynamicsymbols._t): ui for qi, ui in zip(self.q, self.u)}))
+        # Define the joints
+        self.system.add_joints(
+            PinJoint(self.add_prefix("rear_wheel_joint"), self.rear_frame.body,
+                     self.rear_wheel.body, self.q[5], self.u[5],
+                     self.rear_frame.wheel_attachment, self.rear_wheel.center,
+                     self.rear_frame.wheel_axis, self.rear_wheel.rotation_axis),
+            PinJoint(self.add_prefix("steer_joint"), self.rear_frame.body,
+                     self.front_frame.body, self.q[6], self.u[6],
+                     self.rear_frame.steer_attachment,
+                     self.front_frame.steer_attachment, self.rear_frame.steer_axis,
+                     self.front_frame.steer_axis),
+            PinJoint(self.add_prefix("front_wheel_joint"), self.front_frame.body,
+                     self.front_wheel.body, self.q[7], self.u[7],
+                     self.front_frame.wheel_attachment, self.front_wheel.center,
+                     self.front_frame.wheel_axis, self.front_wheel.rotation_axis),
+        )
+        # Define contact points.
+        self.rear_wheel.compute_contact_point(self.ground)
+        self.front_wheel.compute_contact_point(self.ground)
+        # Add the coordinates and speeds to the system.
+        self.system.add_coordinates(*self.q[:5])
+        self.system.add_speeds(*self.u[:5])
+        self.system.add_kdes(*(
+            ui - qi.diff(dynamicsymbols._t) for qi, ui in zip(self.q[:5], self.u[:5])))
+
+    def define_loads(self) -> None:
+        """Define the loads of the Whipple bicycle."""
+        super().define_loads()
+        self.rear_wheel.compute_tyre_model(self.ground, True)
+        self.front_wheel.compute_tyre_model(self.ground, False)
diff --git a/src/brim/bicycle_base.py b/src/brim/bicycle_base.py
deleted file mode 100644
index b059283c..00000000
--- a/src/brim/bicycle_base.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from brim.model_base import ModelBase
-
-
-class BicycleBase(ModelBase):
-    """Base class for the bicycles."""
diff --git a/src/brim/core/__init__.py b/src/brim/core/__init__.py
new file mode 100644
index 00000000..00e67a20
--- /dev/null
+++ b/src/brim/core/__init__.py
@@ -0,0 +1,7 @@
+"""Module containing the core elements of BRiM."""
+
+__all__ = ["ModelMeta", "ModelBase", "Requirement", "NewtonianBodyMixin"]
+
+from brim.core.mixins import NewtonianBodyMixin
+from brim.core.model_base import ModelBase, ModelMeta
+from brim.core.requirement import Requirement
diff --git a/src/brim/core/mixins.py b/src/brim/core/mixins.py
new file mode 100644
index 00000000..571e4c10
--- /dev/null
+++ b/src/brim/core/mixins.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sympy.physics.mechanics import RigidBody, System
+
+if TYPE_CHECKING:
+    from sympy.physics.mechanics import ReferenceFrame, Vector
+
+
+class NewtonianBodyMixin:
+    """Mixin class adding a Newtonian body to a model."""
+
+    def define_objects(self) -> None:
+        """Define the objects of the model."""
+        body = RigidBody(self.name)
+        self._system = System.from_newtonian(body)
+        super().define_objects()
+
+    @property
+    def body(self) -> RigidBody:
+        """The body representing the model."""
+        return self.system.bodies[0]
+
+    @property
+    def frame(self) -> ReferenceFrame:
+        """Reference frame of the model."""
+        return self.body.frame
+
+    @property
+    def x(self) -> Vector:
+        """X-axis of model."""
+        return self.frame.x
+
+    @property
+    def y(self) -> Vector:
+        """Y-axis of model."""
+        return self.frame.y
+
+    @property
+    def z(self) -> Vector:
+        """Z-axis of model."""
+        return self.frame.z
diff --git a/src/brim/core/model_base.py b/src/brim/core/model_base.py
new file mode 100644
index 00000000..60978782
--- /dev/null
+++ b/src/brim/core/model_base.py
@@ -0,0 +1,161 @@
+"""Module containing the base class for all models in BRiM."""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from functools import wraps
+from typing import TYPE_CHECKING, Any
+
+from sympy import symbols
+
+if TYPE_CHECKING:
+    from sympy.physics.mechanics import System
+
+    from brim.core.requirement import Requirement
+
+
+class ModelMeta(ABCMeta):
+    """Metaclass for the :class:`brim.core.model_base.ModelBase`."""
+
+    def __new__(mcs, name, bases, namespace, **kwargs):  # noqa: N804
+        """Create a new class."""
+
+        def traverse_submodels(func):
+            @wraps(func)
+            def wrapper(self, *args, **kwargs):
+                for submodel in self.submodels:
+                    getattr(submodel, func.__name__)(*args, **kwargs)
+                func(self, *args, **kwargs)
+
+            return wrapper
+
+        def create_submodel_property(requirement: Requirement) -> property:
+            def getter(self):
+                return getattr(self, f"_{requirement.attribute_name}")
+
+            def setter(self, model):
+                if not (model is None or isinstance(model, requirement.types)):
+                    raise TypeError(
+                        f"{requirement.full_name} should be an instance of an subclass "
+                        f"of {requirement.type_name}, but {model} is an instance of "
+                        f"{type(model)}.")
+                setattr(self, f"_{requirement.attribute_name}", model)
+
+            return property(getter, setter, None, requirement.description)
+
+        # Create properties for each of the requirements
+        requirements = []
+        for base_cls in bases:
+            base_reqs = getattr(base_cls, "requirements", None)
+            if base_reqs is not None:
+                requirements.extend(base_reqs)
+        if "requirements" in namespace:
+            requirements.extend(namespace["requirements"])
+        for req in requirements:
+            namespace[req.attribute_name] = create_submodel_property(req)
+        # Overwrite the define methods such that it is first called in the submodels
+        for method in ("define_objects", "define_kinematics", "define_loads",
+                       "define_constraints"):
+            if method in namespace:
+                namespace[method] = traverse_submodels(namespace[method])
+        return super().__new__(mcs, name, bases, namespace, **kwargs)
+
+    def __call__(cls, *args, **kwargs):
+        """Create a new instance of the class.
+
+        Notes
+        -----
+        The formulation is removed from the keyword arguments before the instance is
+        created. This is done to prevent the formulation from being passed to the
+        ``__init__`` method.
+
+        """
+        obj = cls.__new__(cls, *args, **kwargs)
+        if "formulation" in kwargs:
+            del kwargs["formulation"]
+        obj.__init__(*args, **kwargs)
+        return obj
+
+
+class ModelBase(metaclass=ModelMeta):
+    """Base class for all objects in brim."""
+
+    requirements: tuple[Requirement, ...] = ()
+
+    def __init__(self, name: str):
+        """Create a new instance of the model.
+
+        Parameters
+        ----------
+        name : str
+            Name of the model.
+
+        """
+        if any(char in name for char in (" ", ",", ":")):
+            raise ValueError("The name of an object may not contain: ' ', ',' or ':'.")
+        if not name:
+            raise ValueError("The name of an object may not be empty.")
+        self._name = str(name)
+        for req in self.requirements:
+            setattr(self, f"_{req.attribute_name}", None)
+        self._system = None
+        self.define_objects()
+
+    def __str__(self) -> str:
+        return self.name
+
+    @property
+    def name(self) -> str:
+        """Name of the part of the model."""
+        return self._name
+
+    def add_prefix(self, names: str) -> str:
+        """Add the name of the model as a prefix to a set of names..
+
+        Examples
+        --------
+        ...
+
+        """
+        syms= symbols(names)
+        if isinstance(syms, tuple):
+            return ", ".join(f"{self.name}_{sym.name}" for sym in syms)
+        return f"{self.name}_{syms.name}"
+
+    @property
+    def submodels(self) -> frozenset[ModelBase]:
+        """Submodels out of which this model exists."""
+        submodels = []
+        for req in self.requirements:
+            submodels.append(getattr(self, req.attribute_name))
+        return frozenset(submodel for submodel in submodels if submodel is not None)
+
+    def get_description(self, obj: Any) -> str:
+        """Get description of a given object."""
+        if obj in self.descriptions:
+            return self.descriptions[obj]
+        for submodel in self.submodels:
+            desc = submodel.get_description(obj)
+            if desc is not None:
+                return desc
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Descriptions of the attributes of the model."""
+        return {}
+
+    @property
+    def system(self) -> System:
+        """System object representing the model."""
+        return self._system
+
+    @abstractmethod
+    def define_objects(self) -> None:
+        """Initialize the objects belonging to the model."""
+
+    @abstractmethod
+    def define_kinematics(self) -> None:
+        """Establish the kinematics of the objects belonging to the model."""
+
+    @abstractmethod
+    def define_loads(self) -> None:
+        """Define the loads that are part of the model."""
diff --git a/src/brim/core/requirement.py b/src/brim/core/requirement.py
new file mode 100644
index 00000000..963a0b34
--- /dev/null
+++ b/src/brim/core/requirement.py
@@ -0,0 +1,86 @@
+"""Module containing the requirement class."""
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+
+class Requirement:
+    """Simple class containing the requirement properties."""
+
+    def __init__(self, attribute_name: str,
+                 submodel_types: type | tuple[type, ...],
+                 description: str | None = None,
+                 hard_requirement: bool = False,
+                 full_name: str | None = None,
+                 type_name: str | None = None) -> None:
+        """Initialize a new instance of the requirement.
+
+        Parameters
+        ----------
+        attribute_name : str
+            Name of the attribute that is used to store the submodel in the parent.
+        submodel_types : type | tuple[type, ...]
+            Supported types of the submodel.
+        description : str, optional
+            Description of the submodel, by default None.
+        hard_requirement : bool, optional
+            Whether the submodel is a hard requirement, by default False.
+        full_name : str, optional
+            Full name of the submodel, by default capitalized version of the attribute
+            name, where the underscores are replaced by spaces.
+        type_name : str, optional
+            Names of the supported types of the submodel. The names of the type classes
+            are used by default.
+
+        """
+        attribute_name = str(attribute_name)
+        if not attribute_name.isidentifier():
+            raise ValueError(f"'{attribute_name}' is not a valid attribute name, "
+                             f"because it cannot be used as a variable name.")
+        self._attribute_name = attribute_name
+        if not isinstance(submodel_types, Iterable):
+            submodel_types = (submodel_types,)
+        self._types = tuple(submodel_types)
+        if description is None:
+            description = self.types[0].__doc__.split("\n", 1)[0]
+        self._description = str(description)
+        self._hard = bool(hard_requirement)
+        if full_name is None:
+            full_name = self.attribute_name.replace("_", " ").capitalize()
+        self._full_name = str(full_name)
+        if type_name is None:
+            type_name = " or ".join(tp.__name__ for tp in self.types)
+        self._type_name = str(type_name)
+
+    @property
+    def attribute_name(self) -> str:
+        """Name of the attribute that is used to store the submodel in the parent."""
+        return self._attribute_name
+
+    @property
+    def types(self) -> tuple[type, ...]:
+        """Supported types of the submodel."""
+        return self._types
+
+    @property
+    def description(self) -> str:
+        """Description of the submodel."""
+        return self._description
+
+    @property
+    def hard(self) -> bool:
+        """Whether the submodel is a hard requirement."""
+        return self._hard
+
+    @property
+    def full_name(self) -> str:
+        """Full name of the submodel."""
+        return self._full_name
+
+    @property
+    def type_name(self) -> str:
+        """Names of the supported types of the submodel."""
+        return self._type_name
+
+    def __str__(self):
+        return self.attribute_name
diff --git a/src/brim/model_base.py b/src/brim/model_base.py
deleted file mode 100644
index d04dd3a5..00000000
--- a/src/brim/model_base.py
+++ /dev/null
@@ -1,2 +0,0 @@
-class ModelBase:
-    """Base class for all objects in brim."""
diff --git a/src/brim/other/__init__.py b/src/brim/other/__init__.py
new file mode 100644
index 00000000..f2385abb
--- /dev/null
+++ b/src/brim/other/__init__.py
@@ -0,0 +1,5 @@
+"""Module containing other models, which are neither bicycles nor riders."""
+
+__all__ = ["RollingDisc"]
+
+from brim.other.rolling_disc import RollingDisc
diff --git a/src/brim/other/rolling_disc.py b/src/brim/other/rolling_disc.py
new file mode 100644
index 00000000..a865f2fa
--- /dev/null
+++ b/src/brim/other/rolling_disc.py
@@ -0,0 +1,135 @@
+"""Module containing the rolling disc model."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from sympy import Matrix, Symbol, symbols
+from sympy.physics.mechanics import (
+    ReferenceFrame,
+    RigidBody,
+    System,
+    cross,
+    dynamicsymbols,
+    inertia,
+)
+
+from brim.bicycle.grounds import GroundBase
+from brim.bicycle.wheels import WheelBase
+from brim.core import ModelBase, Requirement
+
+if TYPE_CHECKING:
+    pass
+
+
+class RollingDisc(ModelBase):
+    """Rolling disc model."""
+
+    requirements: tuple[Requirement, ...] = (
+        Requirement("ground", GroundBase, "Ground model."),
+        Requirement("disc", WheelBase, "Disc model."),
+    )
+    ground: GroundBase
+    disc: WheelBase
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Dictionary of descriptions of the rolling disc's attributes."""
+        desc = {
+            **super().descriptions,
+            self.q[0]: "Perpendicular distance along ground.x to the contact point in "
+                       "the ground plane.",
+            self.q[1]: "Perpendicular distance along ground.y to the contact point in "
+                       "the ground plane.",
+            self.q[2]: "Yaw angle of the disc.",
+            self.q[3]: "Roll angle of the disc.",
+            self.q[4]: "Pitch angle of the disc.",
+        }
+        desc.update({ui: f"Generalized speed of the {desc[qi].lower()}"
+                     for qi, ui in zip(self.q, self.u)})
+        return desc
+
+    def define_objects(self) -> None:
+        """Define the objects of the rolling disc."""
+        super().define_objects()
+        self.q = Matrix([dynamicsymbols(self.add_prefix("q1:6"))])
+        self.u = Matrix([dynamicsymbols(self.add_prefix("u1:6"))])
+
+    def define_kinematics(self) -> None:
+        """Define the kinematics of the rolling disc."""
+        super().define_kinematics()
+        self._system = System.from_newtonian(self.ground.body)
+        self.disc.frame.orient_body_fixed(self.ground.frame, self.q[2:], "zxy")
+        self.disc.frame.set_ang_vel(
+            self.ground.frame, self.disc.frame.ang_vel_in(self.ground.frame).xreplace(
+                {qi.diff(dynamicsymbols._t): ui for qi, ui in zip(self.q, self.u)}
+            ))
+        self.disc.contact_point.set_pos(
+            self.ground.origin,
+            self.q[0] * self.ground.planar_vectors[0] +
+            self.q[1] * self.ground.planar_vectors[1]
+        )
+        self.disc.contact_point.set_vel(
+            self.ground.frame,
+            self.u[0] * self.ground.planar_vectors[0] +
+            self.u[1] * self.ground.planar_vectors[1]
+        )
+        self.disc.compute_contact_point(self.ground)
+        self.disc.compute_tyre_model(self.ground, True)
+        self.system.q_ind = self.q
+        self.system.u_ind = self.u
+        self.system.kdes = [
+            qdi - ui for qdi, ui in zip(self.q.diff(dynamicsymbols._t), self.u)]
+
+    def define_loads(self) -> None:
+        """Define the loads of the rolling disc."""
+        super().define_loads()
+
+
+def rolling_disc_manual() -> System:
+    """Create a rolling disc model manually.
+
+    Notes
+    -----
+    This function is used to verify and benchmark the rolling disc model. It is mostly
+    copied from _create_rolling_disc in test_kane5.py from SymPy.
+    """
+    # Define symbols and coordinates
+    t = dynamicsymbols._t
+    q = dynamicsymbols("q1:6")
+    u = dynamicsymbols("u1:6")
+    qd_repl = {qi.diff(t): ui for qi, ui in zip(q, u)}
+    # Define bodies and frames
+    ground = RigidBody("ground")
+    disc = RigidBody("disc", mass=Symbol("m"))
+    disc.inertia = (inertia(disc.frame, *symbols("ixx iyy ixx")), disc.masscenter)
+    ground.masscenter.set_vel(ground.frame, 0)
+    disc.masscenter.set_vel(disc.frame, 0)
+    int_frame = ReferenceFrame("int_frame")
+
+    # Orient frames
+    int_frame.orient_body_fixed(ground.frame, (q[2], q[3], 0), "zxy")
+    disc.frame.orient_axis(int_frame, int_frame.y, q[4])
+    g_w_d = disc.frame.ang_vel_in(ground.frame)
+    disc.frame.set_ang_vel(ground.frame, g_w_d.xreplace(qd_repl))
+    # Define points
+    cp = ground.masscenter.locatenew("contact_point",
+                                     q[0] * ground.x + q[1] * ground.y)
+    cp.set_vel(ground.frame, u[0] * ground.x + u[1] * ground.y)
+    disc.masscenter.set_pos(cp, -Symbol("r") * int_frame.z)
+
+    # Define kinematic differential equations
+    kdes = [qdi - ui for qdi, ui in qd_repl.items()]
+    # Define nonholonomic constraints
+    v0 = disc.masscenter.vel(ground.frame) + cross(
+        disc.frame.ang_vel_in(ground.frame), cp.pos_from(disc.masscenter))
+    fnh = [v0.dot(ground.x), v0.dot(ground.y)]
+    # Create system
+    system = System.from_newtonian(ground)
+    system.q_ind = q
+    system.u_ind = u[2:]
+    system.u_dep = u[:2]
+    system.kdes = kdes
+    system.nonholonomic_constraints = fnh
+    system.bodies = disc
+    system.loads = [(disc.masscenter, disc.mass * Symbol("g") * ground.z)]
+    return system
diff --git a/src/brim/rider/__init__.py b/src/brim/rider/__init__.py
new file mode 100644
index 00000000..c09f3e1a
--- /dev/null
+++ b/src/brim/rider/__init__.py
@@ -0,0 +1,1 @@
+"""Rider module."""
diff --git a/src/brim/utilities/__init__.py b/src/brim/utilities/__init__.py
new file mode 100644
index 00000000..d1deba74
--- /dev/null
+++ b/src/brim/utilities/__init__.py
@@ -0,0 +1,5 @@
+"""Utilities for brim."""
+
+__all__ = ["merge_systems"]
+
+from brim.utilities.utilities import merge_systems
diff --git a/src/brim/utilities/plotting.py b/src/brim/utilities/plotting.py
new file mode 100644
index 00000000..c5d73f85
--- /dev/null
+++ b/src/brim/utilities/plotting.py
@@ -0,0 +1,77 @@
+"""Module containing utilities to easily plot the model in SymMePlot.
+
+Notes
+-----
+The plotter is based on SymMePlot, which is an experimental plotting library for SymPy
+mechanics. Therefore, there is no guarantee that the plotter will work in the future.
+
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from symmeplot import SymMePlotter
+
+from brim.bicycle.front_frames import RigidFrontFrameMoore
+from brim.bicycle.grounds import GroundBase
+from brim.bicycle.rear_frames import RigidRearFrameMoore
+from brim.bicycle.wheels import KnifeEdgeWheel
+from brim.core import ModelBase, NewtonianBodyMixin
+
+if TYPE_CHECKING:
+    from mpl_toolkits.mplot3d.axes3d import Axes3D
+
+__all__ = ["Plotter"]
+
+
+class Plotter(SymMePlotter):
+    """Plotter for models created by BRiM using symmeplot."""
+
+    def __init__(self, ax: Axes3D, model: ModelBase, **inertial_frame_properties):
+        """Initialize the plotter.
+
+        Parameters
+        ----------
+        ax : mpl_toolkits.mplot3d.axes3d.Axes3D
+            Axes to plot on.
+        model : ModelBase
+            Model to plot.
+        **inertial_frame_properties
+            Keyword arguments are parsed to
+            :class:`symmeplot.plot_objects.PlotFrame` representing the inertial
+            reference frame.
+
+        """
+        self._model = model
+        super().__init__(ax, model.system.frame, model.system.origin,
+                         **inertial_frame_properties)
+        self.add_model(model)
+
+    def add_model(self, model: ModelBase, add_submodels: bool = True):
+        """Add a model to the plotter.
+
+        Parameters
+        ----------
+        model : ModelBase
+            Model to add.
+        add_submodels : bool, optional
+            Whether to add the submodels of the model to the plot. Default is
+            True.
+
+        """
+        if isinstance(model, NewtonianBodyMixin):
+            body = self.add_body(model.body)
+        if isinstance(model, RigidRearFrameMoore):
+            self.add_line([model.wheel_attachment, model.steer_attachment],
+                          "rear frame")
+        elif isinstance(model, RigidFrontFrameMoore):
+            self.add_line([model.wheel_attachment, model.steer_attachment],
+                          "front frame")
+        elif isinstance(model, KnifeEdgeWheel):
+            body.attach_circle(model.center, model.radius, model.rotation_axis,
+                               facecolor="none", edgecolor="k")
+        elif isinstance(model, GroundBase):
+            self.add_frame(model.frame, model.origin)
+        if add_submodels:
+            for submodel in model.submodels:
+                self.add_model(submodel)
diff --git a/src/brim/utilities/templates.py b/src/brim/utilities/templates.py
new file mode 100644
index 00000000..ef2d3bcb
--- /dev/null
+++ b/src/brim/utilities/templates.py
@@ -0,0 +1,204 @@
+"""Module containing templates for creating new models.
+
+Explanation
+-----------
+In brim a concept is being used that a model consists out of other models, between which
+there are certain interactions. A bicycle consists out of a rear wheel, a front wheel,
+a rear frame, a front frame and a ground. Each of these components, called submodels,
+are a system on its own. The bicycle takes these different submodels and connects them.
+The rear frame is connected to the rear wheel, the front frame to the front wheel, etc.
+
+To make sure that every relation is defined on time, i.e. before it is used, a model
+breaks up the definition of its system in multiple parts:
+- define_objects: define the objects of the model, like the points, the frames, the
+  bodies, etc. The system is often also initiated in this phase. This method is called
+  when a model is initiated.
+- define_kinematics: define the kinematics of the model, like the position of the
+  points, the velocity of the bodies, etc.
+- define_loads: define the loads on the model, like the forces and torques acting within
+  the system. It has been decided to currently also define the constraints in this
+  phase. This may however change in the future.
+
+When calling one of those methods, the model will automatically first call the same
+method for all the submodels, before actually defining the system of the current model.
+This way, the system of the current model can be defined in terms of the submodels.
+
+Notes
+-----
+These templates are not meant to be used directly, but rather as a guideline for
+creating new models. They are however used in the tests to test the functionality of
+the base classes.
+
+"""
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import Any
+
+from sympy import Matrix
+from sympy.physics.mechanics import dynamicsymbols
+from sympy.physics.mechanics.system import System
+
+from brim.core import ModelBase, NewtonianBodyMixin, Requirement
+
+__all__ = ["MySubModelBase", "MySubModel", "MyModel"]
+
+
+class MySubModelBase(NewtonianBodyMixin, ModelBase):
+    """Template of a submodel base class, like :class:`brim.wheels.WheelBase`.
+
+    Explanation
+    -----------
+    A base class of a model is generally used to define how a certain category of models
+    should look like. In case of :class:`brim.wheels.WheelBase` it is known that a wheel
+    should have a ``body``, a ``rotation_axis`` and so on. Therefore, the
+    :class:`brim.wheels.WheelBase` creates the abstract methods for those, such that
+    classes which can use a wheel know that it has those properties. The use of a
+    prescibed method like ``rotation_axis`` is that the parent model can use it to
+    orient the wheel with respect to the frame using a rotation about ``rotation_axis``.
+
+    In this template class we prescribe that is has a body and that subclasses should
+    also implement a method called ``my_submodel_method``, which can be used by the
+    parent model. As many models will actually use a Newtonian body with respect to
+    which the model is defined, a mixin :class:`brim.core.NewtonianBodyMixin` has been
+    implemented. This mixin will create some useful properties associated with a body,
+    such as ``body`` and ``frame``.
+
+    As this is is a base class, it does not have to implement all abstract methods from
+    :class:`brim.ModelBase`.
+
+    See Also
+    --------
+    :class:`brim.templates.MySubModel` is a subclass of this base class.
+
+    """
+
+    @abstractmethod
+    def my_submodel_method(self, variable: ModelBase) -> None:
+        """Abstract method that should be implemented by subclasses.
+
+        Explanation
+        -----------
+        A method like this can be useful for creating an interface for a parent model to
+        define an interaction with a different submodel. An example in brim is
+        :meth:`brim.bicycle.wheels.compute_contact_point` which needs the ground model
+        to compute the contact point of the wheel. As the ground model is not known by
+        the wheel model, the parent model,
+        :class:`brim.bicycle.whipple_bicycle.WhippleBicycle`, calls this method
+        providing the ground to define the location of the contact point.
+
+        Parameters
+        ----------
+        variable : ModelBase
+            Explanation about what this variable is.
+
+        """
+
+
+class MySubModel(MySubModelBase):
+    """Template of a submodel, like :class:`brim.wheels.KnifeEdgeWheel`.
+
+    Explanation
+    -----------
+    As this sub model inherits from :class:`brim.templates.MySubModelBase`, it has to
+    implement the abstract methods ``my_submodel_method`` and ``define_kinematics``,
+    ``define_loads``. The latter two are defined as abstract classes in
+    :class:`brim.core.model_base.ModelBase`. Of course we can also add additional
+    methods and properties. In this case we add a symbolic variable ``my_symbol``,
+    including a description of what it is in the the ``descriptions`` property.
+
+    """
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Descriptions of the attributes of the model."""
+        return {self.my_symbol: f"Symbolic variable of {self.name}"}
+
+    def define_objects(self) -> None:
+        """Define the objects of the model."""
+        super().define_objects()
+        # add_prefix adds the name of the model to the variable, such that it is unique
+        # in the system.
+        self.my_symbol = dynamicsymbols(self.add_prefix("my_symbol"))
+
+    def define_kinematics(self) -> None:
+        """Define the kinematics of the model."""
+
+    def define_loads(self) -> None:
+        """Define the loads of the model."""
+
+    def my_submodel_method(self, variable: ModelBase) -> None:
+        """Implementat the prescribed abstract method."""
+
+
+class MyModel(ModelBase):
+    """Template of a model.
+
+    Explanation
+    -----------
+    A normal model can also directly inherit from
+    :class:`brim.core.model_base.ModelBase` and implement all abstract methods required
+    for initialization:
+
+    - ``system``: a :class:`sympy.physics.mechanics.system.System` instance, which does
+      the book-keeping of the objects and relations from this models perspective. This
+      means that it should not contain all objects known to the submodels. Just the ones
+      necessary to describe the relationships governed by this model.
+    - ``define_objects``: initializes the objects belonging to the model and also puts
+      them in ``system`` if possible.
+    - ``define_kinematics``: defines the kinematics of the model and mainly between the
+      different submodels, as the kinematics within the submodels gets defined by the
+      submodels themselves.
+    - ``define_loads``: defines the loads act upon the model and again mainly the ones
+      between the submodels.
+
+    Additional methods that can be useful to specify are:
+    - ``requirements`` which is a property, which mentions the required submodels. This
+      property is parsed on initialization, such that the class automatically gets a
+      property for each of these requirements.
+    - ``descriptions`` is a dictionary with descriptions of all the model associated
+      objects, which don't have a docstring. This is mainly useful for explaining the
+      meaning of symbols.
+
+    In this implementation we create two hard requirements, namely ``submodel1`` and
+    ``submodel2``. These submodels are required to be subclasses of
+    :class:`brim.utilities.templates.MySubModelBase`. In this example we orient
+    ``submodel2`` with a body-fixed rotation with respect to ``submodel1``.
+
+    """
+
+    requirements: tuple[Requirement, ...] = (
+        Requirement("submodel1", MySubModelBase, "Description of submodel1", True),
+        Requirement("submodel2", MySubModelBase, "Description of submodel2", True),
+    )
+    # Adding annotations for IDEs
+    submodel1: MySubModelBase
+    submodel2: MySubModelBase
+
+    @property
+    def system(self) -> System:
+        """System object representing the model."""
+        return self._system
+
+    @property
+    def descriptions(self) -> dict[Any, str]:
+        """Descriptions of the attributes of the model."""
+        return {
+            self.q[0]: f"Yaw angle between {self.submodel1} and {self.submodel2}.",
+            self.q[1]: f"Roll angle between {self.submodel1} and {self.submodel2}.",
+            self.q[2]: f"Pitch angle between {self.submodel1} and {self.submodel2}.",
+        }
+
+    def define_objects(self) -> None:
+        """Initialize the objects belonging to the model."""
+        self.q = Matrix(dynamicsymbols("q1:4"))
+        self._system = None
+
+    def define_kinematics(self) -> None:
+        """Establish the kinematics of the objects belonging to the model."""
+        self._system = System(self.submodel1.body.masscenter, self.submodel1.frame)
+        self.submodel2.frame.orient_body_fixed(self.submodel1.frame, self.q[:], "zxy")
+
+    def define_loads(self) -> None:
+        """Define the loads that are part of the model."""
+        # One can for example use self.submodel1.my_submodel_method here
diff --git a/src/brim/utilities/utilities.py b/src/brim/utilities/utilities.py
new file mode 100644
index 00000000..847f33c3
--- /dev/null
+++ b/src/brim/utilities/utilities.py
@@ -0,0 +1,89 @@
+"""Utilities for brim."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sympy import ImmutableMatrix, zeros
+from sympy.core.cache import cacheit
+from sympy.physics.mechanics import System
+
+if TYPE_CHECKING:
+    from brim.core import ModelBase
+
+__all__ = ["merge_systems", "to_system", "cramer_solve"]
+
+
+def to_system(model: ModelBase) -> System:  # pragma: no cover
+    """Export the Whipple bicycle to a Sympy system."""
+
+    def get_systems(model):
+        """Get the systems of the submodels."""
+        return ([model.system] +
+                [s for submodel in model.submodels for s in get_systems(submodel)])
+
+    return merge_systems(model.ground.system, *get_systems(model))
+
+
+def merge_systems(*systems: System) -> System:  # pragma: no cover
+    """Combine multiple system instance into one."""
+    system = System(systems[0].origin, systems[0].frame)
+    for s in systems:
+        for qi in s.q_ind:
+            if qi not in system.q:
+                system.add_coordinates(qi, independent=True)
+        for qi in s.q_dep:
+            if qi not in system.q:
+                system.add_coordinates(qi, independent=False)
+        for ui in s.u_ind:
+            if ui not in system.u:
+                system.add_speeds(ui, independent=True)
+        for ui in s.u_dep:
+            if ui not in system.u:
+                system.add_speeds(ui, independent=False)
+        for body in s.bodies:
+            if body not in system.bodies:
+                system.add_bodies(body)
+        for joint in s.joints:
+            if joint not in system.joints:
+                system.add_joints(joint)
+        for load in s.loads:
+            if load not in system.loads:
+                system.add_loads(load)
+        for kde in s.kdes:
+            if kde not in system.kdes:
+                system.add_kdes(kde)
+        for fh in s.holonomic_constraints:
+            if fh not in system.holonomic_constraints:
+                system.add_holonomic_constraints(fh)
+        for fnh in s.nonholonomic_constraints:
+            if fnh not in system.nonholonomic_constraints:
+                system.add_nonholonomic_constraints(fnh)
+    return system
+
+
+@cacheit
+def det_laplace(matrix):  # pragma: no cover
+    """Compute the determinant of a matrix using Laplace's formula."""
+    n = matrix.shape[0]
+    if n == 1:
+        return matrix[0]
+    elif n == 2:
+        return matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]
+    else:
+        return sum((-1) ** i * matrix[0, i] *
+                   det_laplace(matrix.minor_submatrix(0, i)) for i in range(n))
+
+
+def cramer_solve(mat, rhs, det_method=det_laplace):  # pragma: no cover
+    """Solve a system of linear equations using Cramer's rule."""
+
+    def entry(i, j):
+        return rhs[i, sol] if j == col else mat[i, j]
+
+    mat_im = ImmutableMatrix(mat)  # Convert to immutable for cache purposes
+    det_mat = det_method(mat_im)
+    x = zeros(*rhs.shape)
+    for sol in range(rhs.shape[1]):
+        for col in range(rhs.shape[0]):
+            x[col, sol] = det_method(ImmutableMatrix(*mat_im.shape, entry)) / det_mat
+    return x


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index f22ffcc8..dde07609 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -24,8 +24,7 @@ jobs:
         run: poetry install --with lint
       - name: Run lint
         run: |
-          poetry run ruff . --verbose
-          poetry run mypy ./src --verbose
+          poetry run ruff .
 
   #-------------------------------------- tests ---------------------------------------#
 
diff --git a/tests/test_model_base.py b/tests/bicycle/__init__.py
similarity index 100%
rename from tests/test_model_base.py
rename to tests/bicycle/__init__.py
diff --git a/tests/bicycle/test_front_frames.py b/tests/bicycle/test_front_frames.py
new file mode 100644
index 00000000..c4727fea
--- /dev/null
+++ b/tests/bicycle/test_front_frames.py
@@ -0,0 +1,46 @@
+import pytest
+from brim.bicycle.front_frames import RigidFrontFrame, RigidFrontFrameMoore
+from sympy.physics.mechanics import Point
+
+
+class TestRigidFrontFrame:
+    def test_default(self) -> None:
+        front = RigidFrontFrame("front")
+        assert isinstance(front, RigidFrontFrameMoore)
+
+    @pytest.mark.parametrize("formulation_name, expected_class", [
+        ("moore", RigidFrontFrameMoore),
+    ])
+    def test_init(self, formulation_name, expected_class) -> None:
+        front = RigidFrontFrame("front", formulation=formulation_name)
+        assert isinstance(front, expected_class)
+
+    def test_init_error(self) -> None:
+        with pytest.raises(NotImplementedError):
+            RigidFrontFrame("front", formulation="not_implemented")
+
+
+class TestRigidFrontFrameMoore:
+    def test_default(self) -> None:
+        front = RigidFrontFrameMoore("front")
+        assert front.name == "front"
+        assert front.frame == front.body.frame
+        assert isinstance(front.steer_attachment, Point)
+        assert isinstance(front.wheel_attachment, Point)
+        assert front.wheel_axis == front.y
+
+    def test_kinematics(self) -> None:
+        front = RigidFrontFrameMoore("front")
+        front.define_kinematics()
+        # Test if kinematics is defined
+        front.steer_attachment.pos_from(front.body.masscenter)
+        front.wheel_attachment.pos_from(front.body.masscenter)
+        # Test velocities
+        assert front.body.masscenter.vel(front.frame) == 0
+        assert front.steer_attachment.vel(front.frame) == 0
+        assert front.wheel_attachment.vel(front.frame) == 0
+
+    def test_descriptions(self) -> None:
+        front = RigidFrontFrameMoore("front")
+        for length in front.lengths:
+            assert front.descriptions[length] is not None
diff --git a/tests/bicycle/test_grounds.py b/tests/bicycle/test_grounds.py
new file mode 100644
index 00000000..f76c7d96
--- /dev/null
+++ b/tests/bicycle/test_grounds.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from brim.bicycle.grounds import FlatGround
+from sympy.physics.mechanics.system import System
+
+if TYPE_CHECKING:
+    pass
+
+
+class TestFlatGround:
+    def test_default(self) -> None:
+        ground = FlatGround("ground")
+        assert ground.name == "ground"
+        assert ground.frame == ground.body.frame
+        assert ground.normal == -ground.frame.z
+        assert ground.planar_vectors == (ground.frame.x, ground.frame.y)
+        assert ground.origin == ground.body.masscenter
+        assert ground.origin.vel(ground.frame) == 0
+        assert isinstance(ground.system, System)
+
+    @pytest.mark.parametrize("normal, n_idx, pl_idx1, pl_idx2", [
+        ("+x", 0, 1, 2),
+        ("-x", 0, 1, 2),
+        ("+y", 1, 0, 2),
+        ("-y", 1, 0, 2),
+        ("+z", 2, 0, 1),
+        ("-z", 2, 0, 1),
+        ("x", 0, 1, 2),
+        ("y", 1, 0, 2),
+        ("z", 2, 0, 1),
+    ])
+    def test_normal(self, normal: str, n_idx: int, pl_idx1: int, pl_idx2: int) -> None:
+        ground = FlatGround("ground", normal)
+        vectors = (ground.frame.x, ground.frame.y, ground.frame.z)
+        times = -1 if normal[0] == "-" else 1
+        assert ground.normal == times * vectors[n_idx]
+        assert ground.planar_vectors == (vectors[pl_idx1], vectors[pl_idx2])
diff --git a/tests/bicycle/test_rear_frames.py b/tests/bicycle/test_rear_frames.py
new file mode 100644
index 00000000..5cad9216
--- /dev/null
+++ b/tests/bicycle/test_rear_frames.py
@@ -0,0 +1,46 @@
+import pytest
+from brim.bicycle.rear_frames import RigidRearFrame, RigidRearFrameMoore
+from sympy.physics.mechanics import Point
+
+
+class TestRigidRearFrame:
+    def test_default(self) -> None:
+        front = RigidRearFrame("rear")
+        assert isinstance(front, RigidRearFrameMoore)
+
+    @pytest.mark.parametrize("formulation_name, expected_class", [
+        ("moore", RigidRearFrameMoore),
+    ])
+    def test_init(self, formulation_name, expected_class) -> None:
+        front = RigidRearFrame("rear", formulation=formulation_name)
+        assert isinstance(front, expected_class)
+
+    def test_init_error(self) -> None:
+        with pytest.raises(NotImplementedError):
+            RigidRearFrame("rear", formulation="not_implemented")
+
+
+class TestRigidRearFrameMoore:
+    def test_default(self):
+        rear = RigidRearFrameMoore("rear")
+        assert rear.name == "rear"
+        assert rear.frame == rear.body.frame
+        assert isinstance(rear.steer_attachment, Point)
+        assert isinstance(rear.wheel_attachment, Point)
+        assert rear.wheel_axis == rear.y
+
+    def test_kinematics(self):
+        rear = RigidRearFrameMoore("rear")
+        rear.define_kinematics()
+        # Test if kinematics is defined
+        rear.steer_attachment.pos_from(rear.body.masscenter)
+        rear.wheel_attachment.pos_from(rear.body.masscenter)
+        # Test velocities
+        assert rear.body.masscenter.vel(rear.frame) == 0
+        assert rear.steer_attachment.vel(rear.frame) == 0
+        assert rear.wheel_attachment.vel(rear.frame) == 0
+
+    def test_descriptions(self):
+        rear = RigidRearFrameMoore("rear")
+        for length in rear.lengths:
+            assert rear.descriptions[length] is not None
diff --git a/tests/bicycle/test_tyre_models.py b/tests/bicycle/test_tyre_models.py
new file mode 100644
index 00000000..a7a5b549
--- /dev/null
+++ b/tests/bicycle/test_tyre_models.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import pytest
+from brim.bicycle.grounds import FlatGround
+from brim.bicycle.tyre_models import NonHolonomicTyreModel
+from brim.bicycle.wheels import KnifeEdgeWheel
+from sympy import cos, sin
+from sympy.physics.mechanics import dynamicsymbols
+from sympy.physics.mechanics.system import System
+
+
+class TestNonHolonomicTyreModel:
+    def test_default(self) -> None:
+        tyre_model = NonHolonomicTyreModel("tyre_model")
+        assert tyre_model.name == "tyre_model"
+        assert isinstance(tyre_model.system, System)
+
+    @pytest.mark.parametrize("on_ground", [True, False])
+    def test_compute_on_ground(self, on_ground: bool) -> None:
+        t = dynamicsymbols._t
+        q1, q2, x, y, z = dynamicsymbols("q1 q2 x y z")
+        ground = FlatGround("ground")
+        wheel = KnifeEdgeWheel("wheel")
+        wheel.tyre_model = NonHolonomicTyreModel("tyre_model")
+        wheel.frame.orient_body_fixed(ground.frame, (q1, q2, 0), "zyx")
+        wheel.contact_point.set_pos(
+            ground.origin, (x * ground.planar_vectors[0] + y * ground.planar_vectors[1]
+                            + int(not on_ground) * z * ground.normal))
+        wheel.compute_contact_point(ground)
+        wheel.compute_tyre_model(ground, on_ground)
+        fnh = [
+            wheel.radius * cos(q1) * q2.diff(t) + x.diff(t),
+            wheel.radius * sin(q1) * q2.diff(t) + y.diff(t),
+        ]
+        assert len(wheel.tyre_model.system.holonomic_constraints) == int(not on_ground)
+        assert len(wheel.tyre_model.system.nonholonomic_constraints) == 2
+        if not on_ground:
+            assert (wheel.tyre_model.system.holonomic_constraints[0] - z
+                    ).simplify() == 0
+        for fnhi in wheel.tyre_model.system.nonholonomic_constraints:
+            assert (fnhi - fnh[0]).simplify() == 0 or (fnhi - fnh[1]).simplify() == 0
diff --git a/tests/bicycle/test_wheels.py b/tests/bicycle/test_wheels.py
new file mode 100644
index 00000000..9ac19dfc
--- /dev/null
+++ b/tests/bicycle/test_wheels.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import pytest
+from brim.bicycle.grounds import FlatGround
+from brim.bicycle.tyre_models import NonHolonomicTyreModel
+from brim.bicycle.wheels import KnifeEdgeWheel
+from sympy import Symbol
+from sympy.physics.mechanics import ReferenceFrame, dynamicsymbols
+
+
+class TestKnifeEdgeWheel:
+    def test_default(self) -> None:
+        wheel = KnifeEdgeWheel("wheel")
+        assert wheel.name == "wheel"
+        assert wheel.frame == wheel.body.frame
+        assert wheel.center == wheel.body.masscenter
+        assert wheel.rotation_axis == wheel.y
+        assert wheel.tyre_model is None
+        assert isinstance(wheel.radius, Symbol)
+
+    def test_descriptions(self) -> None:
+        wheel = KnifeEdgeWheel("wheel")
+        assert wheel.descriptions[wheel.radius] is not None
+
+    def test_tyre_model(self) -> None:
+        wheel = KnifeEdgeWheel("wheel")
+        wheel.tyre_model = NonHolonomicTyreModel("tyre_model")
+        assert wheel.tyre_model is not None
+        with pytest.raises(TypeError):
+            wheel.tyre_model = KnifeEdgeWheel("not_a_tyre_model")
+
+    def test_contact_point(self) -> None:
+        q1, q2, q3 = dynamicsymbols("q1:4")
+        wheel = KnifeEdgeWheel("wheel")
+        ground = FlatGround("ground")
+        int_frame = ReferenceFrame("int_frame")
+        int_frame.orient_body_fixed(ground.frame, (q1, q2, 0), "zxy")
+        wheel.frame.orient_axis(int_frame, q3, int_frame.y)
+        wheel.compute_contact_point(ground)
+        assert (wheel.contact_point.pos_from(wheel.center) -
+                wheel.radius * int_frame.z).express(wheel.frame).simplify().xreplace(
+            {q2: 0.123, q3: 1.234}) == 0  # sqrt(cos(q2)**2) is not simplified
diff --git a/tests/bicycle/test_whipple_bicycle.py b/tests/bicycle/test_whipple_bicycle.py
new file mode 100644
index 00000000..c92fca9f
--- /dev/null
+++ b/tests/bicycle/test_whipple_bicycle.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+import pytest
+from brim import (
+    FlatGround,
+    KnifeEdgeWheel,
+    NonHolonomicTyreModel,
+    RigidFrontFrame,
+    RigidRearFrame,
+)
+from brim.bicycle import WhippleBicycle, WhippleBicycleMoore
+from brim.utilities.utilities import cramer_solve, to_system
+from sympy import Symbol, lambdify
+from sympy.physics.mechanics import KanesMethod, dynamicsymbols
+
+if TYPE_CHECKING:
+    from sympy import Basic
+
+
+class TestWhippleBicycle:
+    def test_default(self) -> None:
+        front = WhippleBicycle("bike")
+        assert isinstance(front, WhippleBicycleMoore)
+
+    @pytest.mark.parametrize("formulation_name, expected_class", [
+        ("moore", WhippleBicycleMoore),
+    ])
+    def test_init(self, formulation_name, expected_class) -> None:
+        front = WhippleBicycle("bike", formulation=formulation_name)
+        assert isinstance(front, expected_class)
+
+    def test_init_error(self) -> None:
+        with pytest.raises(NotImplementedError):
+            WhippleBicycle("bike", formulation="not_implemented")
+
+
+class TestWhippleBicycleMoore:
+    @staticmethod
+    def _get_basu_mandal_values(bike: WhippleBicycleMoore
+                                ) -> tuple[dict[Basic, float], dict[Basic, float]]:
+        def get_inertia_matrix(model):
+            return model.body.central_inertia.to_matrix(model.body.frame)
+
+        constants = {
+            bike.front_wheel.radius: 0.35,
+            bike.rear_wheel.radius: 0.3,
+            bike.rear_frame.lengths[0]: 0.9534570696121849,
+            bike.front_frame.lengths[0]: 0.2676445084476887,
+            bike.front_frame.lengths[1]: 0.03207142672761929,
+            bike.rear_frame.lengths[1]: 0.4707271515135145,
+            bike.rear_frame.lengths[2]: -0.47792881146460797,
+            bike.front_frame.lengths[2]: -0.00597083392418685,
+            bike.front_frame.lengths[3]: -0.3699518200282974,
+            bike.rear_frame.body.mass: 85.0,
+            bike.rear_wheel.body.mass: 2.0,
+            bike.front_frame.body.mass: 4.0,
+            bike.front_wheel.body.mass: 3.0,
+            get_inertia_matrix(bike.rear_wheel)[0, 0]: 0.0603,
+            get_inertia_matrix(bike.rear_wheel)[1, 1]: 0.12,
+            get_inertia_matrix(bike.front_wheel)[0, 0]: 0.1405,
+            get_inertia_matrix(bike.front_wheel)[1, 1]: 0.28,
+            get_inertia_matrix(bike.rear_frame)[0, 0]: 7.178169776497895,
+            get_inertia_matrix(bike.rear_frame)[1, 1]: 11.0,
+            get_inertia_matrix(bike.rear_frame)[0, 2]: 3.8225535938357873,
+            get_inertia_matrix(bike.rear_frame)[2, 2]: 4.821830223502103,
+            get_inertia_matrix(bike.front_frame)[0, 0]: 0.05841337700152972,
+            get_inertia_matrix(bike.front_frame)[1, 1]: 0.06,
+            get_inertia_matrix(bike.front_frame)[0, 2]: 0.009119225261946298,
+            get_inertia_matrix(bike.front_frame)[2, 2]: 0.007586622998470264,
+            Symbol("g"): 9.81}
+        initial_state = {
+            **{qi: val for qi, val in zip(bike.q, (
+                -0.0, -0.17447337661787718, -0.0, 0.6206670416476966,
+                0.3300446174593725, -0.0, -0.2311385135743, -0.0))},
+            **{ui: val for ui, val in zip(bike.u, (
+                2.6703213326046784, -2.453592884421596e-14, -0.7830033527065,
+                -0.6068425835418, 0.0119185528069, -8.912989661489, -0.4859824687093,
+                -8.0133620584155))}}
+        return constants, initial_state
+
+    def test_basu_mandal(self) -> None:
+        t = dynamicsymbols._t
+        bike = WhippleBicycleMoore("bike")
+        bike.ground = FlatGround("ground")
+        bike.rear_frame = RigidRearFrame("rear_frame")
+        bike.front_frame = RigidFrontFrame("front_frame")
+        bike.rear_wheel = KnifeEdgeWheel("rear_wheel")
+        bike.front_wheel = KnifeEdgeWheel("front_wheel")
+        bike.rear_wheel.tyre_model = NonHolonomicTyreModel("rear_tyre")
+        bike.front_wheel.tyre_model = NonHolonomicTyreModel("front_tyre")
+        bike.define_kinematics()
+        bike.define_loads()
+        system = to_system(bike)
+        system.apply_gravity(-Symbol("g") * bike.ground.normal)
+        system.q_ind = [*bike.q[:4], *bike.q[5:]]
+        system.q_dep = [bike.q[4]]
+        system.u_ind = [bike.u[3], *bike.u[5:7]]
+        system.u_dep = [*bike.u[:3], bike.u[4], bike.u[7]]
+        system._eom_method = KanesMethod(
+            system.frame, system.q_ind, system.u_ind, kd_eqs=system.kdes,
+            q_dependent=system.q_dep, u_dependent=system.u_dep,
+            configuration_constraints=system.holonomic_constraints,
+            velocity_constraints=system.holonomic_constraints.diff(t).col_join(
+                system.nonholonomic_constraints),
+            forcelist=system.loads, bodies=system.bodies,
+            explicit_kinematics=False, constraint_solver=cramer_solve)
+        system.eom_method.kanes_equations()
+
+        constants, initial_state = self._get_basu_mandal_values(bike)
+        p, p_vals = zip(*constants.items())
+        q0 = [initial_state[qi] for qi in system.q]
+        u0 = [initial_state[ui] for ui in system.u]
+        eval_sys = lambdify((system.q, system.u, p),
+                            (system.mass_matrix, system.forcing), cse=True)
+        eval_fnh = lambdify((system.q, system.u, p),
+                            system.nonholonomic_constraints.xreplace(
+                                system.eom_method.kindiffdict()), cse=True)
+        assert np.allclose(eval_fnh(q0, u0, p_vals).ravel(), np.zeros(4))
+        md, gd = eval_sys(q0, u0, p_vals)
+        ud0 = np.linalg.solve(md.astype(np.float64), gd.astype(np.float64)).ravel()
+        expected_state = {
+            udi: val for udi, val in zip(bike.u.diff(t), (
+                0.5903429412631302, -2.090870556233152, -0.8353281706376822,
+                7.855528112824374, -0.12055438978863461, -1.8472554144218631,
+                4.6198904039391895, -2.4548072904552343))
+        }
+        ud0_expected = [expected_state[udi] for udi in system.u.diff(t)]
+        assert np.allclose(ud0, ud0_expected)
+
+    def test_descriptions(self) -> None:
+        bike = WhippleBicycleMoore("bike")
+        for qi in bike.q:
+            assert bike.descriptions[qi]
+        for ui in bike.u:
+            assert bike.descriptions[ui]
diff --git a/tests/core/__init__.py b/tests/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/core/test_mixins.py b/tests/core/test_mixins.py
new file mode 100644
index 00000000..6b1aad4e
--- /dev/null
+++ b/tests/core/test_mixins.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from brim.core.mixins import NewtonianBodyMixin
+from brim.core.model_base import ModelBase
+from sympy.physics.mechanics import RigidBody
+
+
+class MyModel(NewtonianBodyMixin, ModelBase):
+    def define_kinematics(self) -> None:
+        pass
+
+    def define_loads(self) -> None:
+        pass
+
+
+class TestNewtonianBodyMixin:
+    def test_mixin(self) -> None:
+        model = MyModel("name")
+        assert isinstance(model.body, RigidBody)
+        assert model.system.bodies[0] is model.body
+        assert model.frame is model.body.frame
+        assert model.x is model.frame.x
+        assert model.y is model.frame.y
+        assert model.z is model.frame.z
+        assert model.system.origin is model.body.masscenter
diff --git a/tests/core/test_model_base.py b/tests/core/test_model_base.py
new file mode 100644
index 00000000..707df0b7
--- /dev/null
+++ b/tests/core/test_model_base.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import pytest
+from brim.utilities.templates import MyModel, MySubModel
+from sympy.physics.mechanics.system import System
+
+
+class TestModelBase:
+    """Test the ModelBase class.
+
+    Explanation
+    -----------
+    As ModelBase is an abstract class, this test actually uses the WhippleBicycleMoore
+    to test certain characteristics of the ModelBase class.
+    """
+
+    @pytest.fixture()
+    def _create_model(self) -> None:
+        self.model = MyModel("model")
+        self.model.submodel1 = MySubModel("submodel1")
+        self.model.submodel2 = MySubModel("submodel2")
+
+    def test_init(self) -> None:
+        bike = MyModel("model")
+        assert bike.name == "model"
+        assert bike.submodel1 is None
+        assert bike.submodel2 is None
+
+    @pytest.mark.parametrize("name", ["", " ", "my model", "my,model", "my:model"])
+    def test_invalid_name(self, name) -> None:
+        with pytest.raises(ValueError):
+            MyModel(name)
+
+    @pytest.mark.parametrize("meth", ["define_kinematics", "define_loads"])
+    def test_call_traversal(self, _create_model, meth) -> None:
+        # Test should ideally also test for the correct order
+        def register_call(meth):
+            def wrapper():
+                call_order.append(meth.__self__.name)
+                return meth()
+
+            return wrapper
+
+        call_order = []
+        for m in [self.model, self.model.submodel1, self.model.submodel2]:
+            setattr(m, meth, register_call(getattr(m, meth)))
+        getattr(self.model, meth)()
+        assert set(call_order) == {"model", "submodel1", "submodel2"}
+
+    def test_get_description_own_description(self, _create_model) -> None:
+        assert (self.model.get_description(self.model.q[0]) ==
+                self.model.descriptions[self.model.q[0]])
+
+    def test_get_description_of_submodel(self, _create_model) -> None:
+        assert self.model.get_description(self.model.submodel1.my_symbol) is not None
+
+    def test_get_description_of_not_existing_symbol(self, _create_model) -> None:
+        assert self.model.get_description("not existing symbol") is None
+
+    def test_call_system(self, _create_model):
+        self.model.define_kinematics()
+        self.model.define_loads()
+        assert isinstance(self.model.system, System)
diff --git a/tests/core/test_requirement.py b/tests/core/test_requirement.py
new file mode 100644
index 00000000..969bd381
--- /dev/null
+++ b/tests/core/test_requirement.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import pytest
+from brim.core.model_base import ModelBase
+from brim.core.requirement import Requirement
+
+
+class MyModel(ModelBase):
+    """My model description.
+
+    Explanation
+    -----------
+    Explanation of this submodel.
+    """
+
+
+class MyOtherSubModel(ModelBase):
+    """My other sub model description."""
+
+
+class TestRequirement:
+    def test_default(self) -> None:
+        req = Requirement("my_sub_model_attr", MyModel)
+        assert req.attribute_name == "my_sub_model_attr"
+        assert req.types == (MyModel,)
+        assert req.description == "My model description."
+        assert req.hard is False
+        assert req.full_name == "My sub model attr"
+        assert req.type_name == "MyModel"
+        assert str(req) == "my_sub_model_attr"
+
+    @pytest.mark.parametrize("args, kwargs, attribute, expected", [
+        (("my_sub", MyModel), {"description": "New desc."}, "description", "New desc."),
+        (("my_sub", MyModel), {"hard_requirement": True}, "hard", True),
+        (("my_sub", MyModel), {"full_name": "New name"}, "full_name", "New name"),
+        (("my_sub", MyModel), {"type_name": "New type"}, "type_name", "New type"),
+    ])
+    def test_specify_args(self, args, kwargs, attribute, expected) -> None:
+        req = Requirement(*args, **kwargs)
+        assert getattr(req, attribute) == expected
+
+    def test_multiple_types(self) -> None:
+        req = Requirement("my_sub", (MyModel, MyOtherSubModel))
+        assert req.types == (MyModel, MyOtherSubModel)
+        assert req.type_name == "MyModel or MyOtherSubModel"
+
+    @pytest.mark.parametrize("attribute_name", ["my sub model", "my,sub_model"])
+    def test_invalid_attribute_name(self, attribute_name) -> None:
+        with pytest.raises(ValueError):
+            Requirement(attribute_name, MyModel)
diff --git a/tests/other/__init__.py b/tests/other/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/other/test_rolling_disc.py b/tests/other/test_rolling_disc.py
new file mode 100644
index 00000000..8603b1cf
--- /dev/null
+++ b/tests/other/test_rolling_disc.py
@@ -0,0 +1,114 @@
+from typing import TYPE_CHECKING
+
+import pytest
+from brim.bicycle import FlatGround, KnifeEdgeWheel, NonHolonomicTyreModel
+from brim.other.rolling_disc import RollingDisc, rolling_disc_manual
+from brim.utilities.utilities import to_system
+from sympy import Symbol, lambdify
+from sympy.physics.mechanics import dynamicsymbols
+
+if TYPE_CHECKING:
+    pass
+
+
+class TestRollingDisc:
+    @staticmethod
+    def _arbitrary_values() -> dict[str, float]:
+        vals = {
+            "m": 1.23, "r": 0.45, "g": 9.81, "q1": 0.1, "q2": 0.3, "q3": 0.8,
+            "q4": -0.4, "q5": 2.5, "u1": -0.3135180192062244,
+            "u2": -0.3228102409047853, "u3": 0.4, "u4": 0.9, "u5": 1.0}
+        vals["ixx"] = vals["izz"] = vals["m"] * vals["r"] ** 2 / 4
+        vals["iyy"] = vals["m"] * vals["r"] ** 2 / 2
+        return vals
+
+    @pytest.fixture()
+    def _rolling_disc_brim(self) -> None:
+        self.rolling_disc = RollingDisc("rolling_disc")
+        self.rolling_disc.disc = KnifeEdgeWheel("disc")
+        self.rolling_disc.disc.tyre_model = NonHolonomicTyreModel("tyre_model")
+        self.rolling_disc.ground = FlatGround("ground")
+        self.rolling_disc.define_kinematics()
+        self.rolling_disc.define_loads()
+        self.system = to_system(self.rolling_disc)
+        str_vals = self._arbitrary_values()
+        inertia = self.rolling_disc.disc.body.central_inertia.to_matrix(
+            self.rolling_disc.disc.frame)
+        self.val_dict = {
+            self.rolling_disc.disc.body.mass: str_vals["m"],
+            inertia[0, 0]: str_vals["ixx"],
+            inertia[1, 1]: str_vals["iyy"],
+            self.rolling_disc.disc.radius: str_vals["r"],
+            Symbol("g"): str_vals["g"],
+            **{qi: str_vals[f"q{i}"] for i, qi in enumerate(self.rolling_disc.q, 1)},
+            **{ui: str_vals[f"u{i}"] for i, ui in enumerate(self.rolling_disc.u, 1)}
+        }
+        self.system.u_ind = self.rolling_disc.u[2:]
+        self.system.u_dep = self.rolling_disc.u[:2]
+        self._set_values()
+
+    @pytest.fixture()
+    def _rolling_disc_manual(self) -> None:
+        self.system = rolling_disc_manual()
+        str_vals = self._arbitrary_values()
+        self.val_dict = {
+            **{Symbol(name): str_vals[name] for name in ("m", "r", "g", "ixx", "iyy")},
+            **{dynamicsymbols(name): str_vals[name] for name in (
+                "q1", "q2", "q3", "q4", "q5", "u1", "u2", "u3", "u4", "u5")}
+        }
+        self._set_values()
+
+    def _set_values(self) -> None:
+        self.p = tuple(
+            pi for pi in self.val_dict if isinstance(pi, Symbol))
+        self.p_vals = tuple(self.val_dict[pi] for pi in self.p)
+        self.q0 = tuple(self.val_dict[qi] for qi in self.system.q)
+        self.u0 = tuple(self.val_dict[ui] for ui in self.system.u)
+
+    def _plot_rolling_disc_manual(self, _rolling_disc_manual) -> None:
+        """Test that is not actually ran, but is useful for debugging."""
+        # These are not official dependencies
+        import matplotlib.pyplot as plt
+        from symmeplot import SymMePlotter
+        fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
+        plotter = SymMePlotter(
+            ax, self.system.frame, self.system.origin)
+        disc = self.system.get_body("disc")
+        disc_plt = plotter.add_body(disc)
+        disc_plt.attach_circle(disc.masscenter, Symbol("r"), disc.y,
+                               facecolor="none", edgecolor="k")
+        plotter.lambdify_system((self.system.q, self.system.u, self.p))
+        plotter.evaluate_system(self.q0, self.u0, self.p_vals)
+        plotter.plot()
+        ax.invert_zaxis()
+        ax.invert_yaxis()
+        plt.show()
+
+    def _plot_rolling_disc_brim(self, _rolling_disc_brim) -> None:
+        """Test that is not actually ran, but is useful for debugging."""
+        # These are not official dependencies
+        import matplotlib.pyplot as plt
+        from brim.utilities.plotting import Plotter
+        fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
+        plotter = Plotter(ax, self.rolling_disc)
+        plotter.lambdify_system((self.system.q, self.system.u, self.p))
+        plotter.evaluate_system(self.q0, self.u0, self.p_vals)
+        plotter.plot()
+        ax.invert_zaxis()
+        ax.invert_yaxis()
+        plt.show()
+
+    @pytest.mark.parametrize("method", ("_rolling_disc_manual", "_rolling_disc_brim"))
+    def test_rolling_disc_nonholonomic(self, method, request) -> None:
+        request.getfixturevalue(method)
+        self.system.form_eoms()
+        fnh = self.system.nonholonomic_constraints.xreplace(
+            self.system.eom_method.kindiffdict())[:]
+        eval_fnh = lambdify((self.system.q, self.system.u, self.p), fnh, cse=True)
+        assert all(abs(val) < 1E-8 for val in eval_fnh(self.q0, self.u0, self.p_vals))
+
+    def test_rolling_disc_description(self, _rolling_disc_brim) -> None:
+        for qi in self.rolling_disc.q:
+            assert self.rolling_disc.descriptions[qi]
+        for ui in self.rolling_disc.u:
+            assert self.rolling_disc.descriptions[ui]
diff --git a/tests/rider/__init__.py b/tests/rider/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_bicycle_base.py b/tests/test_bicycle_base.py
deleted file mode 100644
index 827fc930..00000000
--- a/tests/test_bicycle_base.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from brim import BicycleBase, ModelBase
-
-
-def test_bicycle_base_default():
-    assert issubclass(BicycleBase, ModelBase)
diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py
new file mode 100644
index 00000000..e69de29b