Repository: tBuLi/kingdon (language: python)

Issue:
Make OperatorDict as a seperate class
* kingdon version:
* Python version:
* Operating System:

### Description

For every operator we need we currently have a structure like this:
```python
from dataclasses import dataclass, field
from functool import partial

operation_field = partial(field, default_factory=dict, init=False, repr=False, compare=False)

@dataclass
class Algebra:
    p: int = 0
    q: int = 0
    r: int = 0
    d: int = field(init=False, repr=False, compare=False)  # Total number of dimensions
    signature: list = field(init=False, repr=False, compare=False)

    # Dictionaries that cache previously symbolically optimized lambda functions between elements.
    _gp: dict = operation_field()  # geometric product dict
    _inv: dict = operation_field()  # inverse dict

    def gp(self, other):
        return self._binary_operation(other, func_dictionary=self.algebra._gp, codegen=codegen_gp)

    __mul__ = __rmul__ = gp

    def inv(self):
        """ Inverse of this multivector. """
        return self._unary_operation(func_dictionary=self.algebra._inv, codegen=codegen_inv)
```
where the functions `_binary_operation` and `_unary_operation` do the actual work of tying it all together. This is not very easy to maintain, as adding a new operation requires multiple lines of code that have to work together.

My proposal is to add a new `OperatorDict` class, that is used as follows:
```python
@dataclass
class Algebra:
    p: int = 0
    q: int = 0
    r: int = 0
    d: int = field(init=False, repr=False, compare=False)  # Total number of dimensions
    signature: list = field(init=False, repr=False, compare=False)

    # Dictionaries that cache previously symbolically optimized lambda functions between elements.
    gp: OperatorDict = field(init=False, repr=False, compare=False, metadata={'codegen': codegen_gp})
    inv: OperatorDict = field(init=False, repr=False, compare=False, metadata={'codegen': codegen_inv))

    __mul__ = __rmul__ = gp
```
The `__post_init__` method of `Algebra` can then prepare all the `OperatorDict` fields on the class. The `OperatorDict` should meet the following requirements:
- It should be callable, such that `alg.gp(x, y)` still works the same way it does now.
- It should behave like a dict, such that `alg.gp[x.keys(), y.keys()]` returns the `keys_out, func` tuple that it also returns currently.

This would reduce the lines of code needed to introduce a new operator, and removes the distinction between unary and binary operators.

So roughly, `OperatorDict` will look like this:
```python
from collections.abc import Mapping

@dataclass
class OperatorDict(Mapping):
    name: str
    codegen: callable
    _cached_operations: dict
    
    def __call__(self, *mvs):
        """ Call for the mv's in mvs """

    def __getitem__(self, keys_in):
        """ Return (keys_out, func) """
        if keys_in not in self._cached_operations:
            # precompute and add to _cached_operation
        return self._cached_operations[keys_in]

    def __len__(self, keys_in):
        return len(self._cached_operations)
```
Github workflows
Add github workflows for automatic testing upon commit and pull request, and for coverage testing.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/generate_docs.yml b/.github/workflows/generate_docs.yml
new file mode 100644
index 0000000..e47f1f0
--- /dev/null
+++ b/.github/workflows/generate_docs.yml
@@ -0,0 +1,23 @@
+name: docs
+on: [push, pull_request]
+
+
+jobs:
+  build-docs:
+    name: Build and generate docs
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v2
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.10'
+    - name: Build docs
+      run: |
+        sudo apt-get install pandoc
+        pip install --upgrade -r requirements_docs.txt
+        pip install --upgrade .[all]
+        mkdir docs_build
+        sphinx-build -nW -b html docs docs_build
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 0ad7b9c..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# Config file for automatic testing at travis-ci.com
-
-language: python
-python:
-  - 3.8
-  - 3.7
-  - 3.6
-
-# Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
-install: pip install -U tox-travis
-
-# Command to run tests, e.g. python setup.py test
-script: tox
-
-# Assuming you have installed the travis-ci CLI tool, after you
-# create the Github repo and add it to Travis, run the
-# following command to finish PyPI deployment setup:
-# $ travis encrypt --add deploy.password
-deploy:
-  provider: pypi
-  distributions: sdist bdist_wheel
-  user: tbuli
-  password:
-    secure: PLEASE_REPLACE_ME
-  on:
-    tags: true
-    repo: tbuli/kingdon
-    python: 3.8
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 27ed03b..fde3cf7 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -102,7 +102,7 @@ Before you submit a pull request, check that it meets these guidelines:
 2. If the pull request adds functionality, the docs should be updated. Put
    your new functionality into a function with a docstring, and add the
    feature to the list in README.rst.
-3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check
+3. The pull request should work for Python 3.8, 3.9, and 3.10, and for PyPy. Check
    https://travis-ci.com/tbuli/kingdon/pull_requests
    and make sure that the tests pass for all supported Python versions.
 
diff --git a/README.rst b/README.rst
index a3512f5..a2ff759 100644
--- a/README.rst
+++ b/README.rst
@@ -13,6 +13,8 @@ Kingdon
         :target: https://kingdon.readthedocs.io/en/latest/?version=latest
         :alt: Documentation Status
 
+.. image:: https://coveralls.io/repos/github/tBuLi/kingdon/badge.svg?branch=master
+        :target: https://coveralls.io/github/tBuLi/kingdon?branch=master
 
 
 
diff --git a/docs/conf.py b/docs/conf.py
index 0898bc3..738d86b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -70,7 +70,7 @@
 #
 # This is also used if you do content translation via gettext catalogs.
 # Usually you set "language" from the command line for these cases.
-language = None
+language = 'en'
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
@@ -100,7 +100,7 @@
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = []  # '_static'
 
 
 # -- Options for HTMLHelp output ---------------------------------------
diff --git a/docs/examples/ex_pointcloud.ipynb b/docs/examples/ex_pointcloud.ipynb
new file mode 100644
index 0000000..5101965
--- /dev/null
+++ b/docs/examples/ex_pointcloud.ipynb
@@ -0,0 +1,487 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "59eb2809",
+   "metadata": {},
+   "source": [
+    "# Poincloud rotor estimation\n",
+    "\n",
+    "Consider the following challenge. We are presented with an input pointcloud $p_i$, and an output pointcloud $q_i = R[p_i] + \\eta_i$, where $R$ is an unknown tranformation, and $\\eta_i$ is Gaussian noise. The challenge is to reconstruct the transformation $R$.\n",
+    "\n",
+    "In order to do this, we construct a symbolic tranformation $R$, whose entries are `symfit.Parameter` objects. We can then use `symfit` to find the rotor $R$."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "5c08628c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from kingdon import Algebra\n",
+    "from symfit import Fit, Model, CallableModel, Variable, Parameter, Eq, Mul\n",
+    "from symfit.core.minimizers import *\n",
+    "import numpy as np\n",
+    "import matplotlib.pyplot as plt"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "73865566",
+   "metadata": {},
+   "source": [
+    "We set up the number of points `n_points` in the pointcloud, the number of (Euclidean) dimensions of the modeling space `d`, and the standard deviation `sig` of the Gaussian distribution."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "07bf4671",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "n_points = 10\n",
+    "d = 2\n",
+    "sig = 0.02"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "091f5c8d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "point_vals = np.zeros((d+1, n_points))\n",
+    "noise_vals = np.zeros((d+1, n_points))\n",
+    "point_vals[0, :] = np.ones(n_points)\n",
+    "point_vals[1:d+1, :] = np.random.random((d, n_points))\n",
+    "noise_vals[1:d+1, :] = np.random.normal(0.0, sig, (d, n_points))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "d742e36d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "alg = Algebra(d, 0, 1)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4cec591d",
+   "metadata": {},
+   "source": [
+    "Create the points and noise as pseudovector of grade `d`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "1dea6b61",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "MultiVector(algebra=Algebra(p=2, q=0, r=1, cse=True, precompute='none', numba=False, graded=False, simplify=True), _values=array([[1.        , 1.        , 1.        , 1.        , 1.        ,\n",
+       "        1.        , 1.        , 1.        , 1.        , 1.        ],\n",
+       "       [0.54249339, 0.00187754, 0.88934931, 0.14371307, 0.90024069,\n",
+       "        0.16091466, 0.79154578, 0.8069197 , 0.08935312, 0.71837907],\n",
+       "       [0.46277455, 0.50690105, 0.93836961, 0.33780138, 0.21474948,\n",
+       "        0.09130718, 0.67021727, 0.37623112, 0.16948978, 0.31433891]]), _keys=(3, 5, 6))"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "noise = alg.purevector(noise_vals, grade=d)\n",
+    "p = alg.purevector(point_vals, grade=d)\n",
+    "p"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c75f5df2",
+   "metadata": {},
+   "source": [
+    "As the input rotor $R$, we use a translation by $2$ in the $\\mathbb{e}_{13}$ direction, followed by a rotation around $\\mathbb{e}_{12}$."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "5e975b48",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "(1) * 1 + (1) * e13\n",
+      "(0.5000000000000001) * 1 + (0.8660254037844386) * e12 + (0.5000000000000001) * e13 + (-0.8660254037844386) * e23\n",
+      "(1.0) * 1 + (0.0) * e12 + (0.0) * e13 + (0.0) * e23\n"
+     ]
+    }
+   ],
+   "source": [
+    "t = np.pi/3\n",
+    "T = alg.multivector({0: 1, 5: 1})\n",
+    "print(T)\n",
+    "R = alg.multivector({0: np.cos(t), 3: np.sin(t)})\n",
+    "R = R*T\n",
+    "print(R)\n",
+    "print(R.normsq())"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "88942d1a",
+   "metadata": {},
+   "source": [
+    "We can now create the transformed pointcloud $q$."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "7bfb9aa6",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "([1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]) * e12 + ([1.88101687 2.16664797 2.12831565 1.95533762 1.48204906 1.73949724\n",
+      " 1.91198636 1.66356202 1.80505835 1.67581711]) * e13 + ([-1.70253249 -1.25262509 -2.21694347 -1.28787096 -1.87394245 -1.21644782\n",
+      " -2.02445395 -1.89602809 -1.10909076 -1.78311012]) * e23\n"
+     ]
+    }
+   ],
+   "source": [
+    "q = R.conj(p) + noise\n",
+    "print(q)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "d1f304ab",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "<matplotlib.legend.Legend at 0x24a29099c90>"
+      ]
+     },
+     "execution_count": 11,
+     "metadata": {},
+     "output_type": "execute_result"
+    },
+    {
+     "data": {
+      "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/av/WaAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAwCUlEQVR4nO3de3jU1YH/8c8kQAImGQxILjZAFAVivEAiEipeH7kuXtZWWxXjFu1CqywiFRH7ILprxKWKikKxFGpprT4NUFlYFFtulVhEgkIDohiBH05EQCcBJIHk/P6ImWXI5DKTmTkzmffreeahc+Z8Z858+db5cL7n4jDGGAEAAFgSZ7sBAAAgthFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFjVwXYDWqOurk5ffPGFkpOT5XA4bDcHAAC0gjFGVVVVyszMVFxc0/0fURFGvvjiC2VlZdluBgAACMD+/fv1ve99r8nXoyKMJCcnS6r/MikpKZZbAwAAWqOyslJZWVme3/GmREUYabg1k5KSQhgBACDKtDTEggGsAADAKsIIAACwijACAACsiooxIwCAyGaM0alTp1RbW2u7KQij+Ph4dejQoc3LbhBGAABtUlNTI5fLpePHj9tuCizo0qWLMjIy1KlTp4DfgzACAAhYXV2dysvLFR8fr8zMTHXq1InFKWOEMUY1NTX66quvVF5ergsuuKDZhc2aQxgBAASspqZGdXV1ysrKUpcuXWw3B2HWuXNndezYUXv37lVNTY0SExMDeh8GsAIA2izQfxEj+gXj756eEaA5dbXS3k3S0S+lpDSp1xApLt52qwCgXSHKAk0pe1Oakyv97l+k4nH1f87JrS8HELMef/xxXXbZZX4dc80112jSpEnW2xGp6BkBfCl7U3rjbknGu7zSVV9+26tSzo1WmgbArilTpuiBBx7w65ilS5eqY8eOIWpR9KNnBDhTXa20eqoaBRHp/8pWP1JfD0DMaFhLJSkpSd26dfPr2NTU1BY3i4tlhBHgTHs3SZVfNFPBSJUH6usBCJraOqOSPYf1l20HVLLnsGrrfP2DILiqq6s1ceJE9ejRQ4mJibryyiv1/vvvS5LWrVsnh8Oht956S/n5+UpISNDGjRsb3R45deqUJk6cqK5du6pbt26aOnWqCgsLdfPNN3vqnHmbpnfv3nrqqaf0k5/8RMnJyerZs6cWLFjg1bapU6fqwgsvVJcuXXTeeefpl7/8pU6ePBnK02ENYQQ409Evg1sPQItW73Dpyll/049feU//8adt+vEr7+nKWX/T6h2ukH7uww8/rOLiYv3ud7/T1q1b1adPHw0fPlxHjhzxqlNUVKSdO3fqkksuafQes2bN0h/+8ActWrRI7777riorK7V8+fIWP/tXv/qV8vPzVVpaqp/97GeaMGGCdu3a5Xk9OTlZixcvVllZmZ5//nm98soreu6554LyvSNN7IaRulqpfKO0/c/1f9LljgZJacGtB6BZq3e4NGHJVrncJ7zKK9wnNGHJ1pAFkmPHjmnevHn67//+b40cOVI5OTl65ZVX1LlzZy1cuNBT74knntANN9yg888/3+ftmRdffFHTpk3TLbfcon79+mnu3Lnq2rVri58/atQo/exnP1OfPn00depUde/eXevWrfO8/thjj2nIkCHq3bu3xowZo4ceekhvvPFGML56xInNAaxlb9aPCTi9Kz4lUxoxi0GJqJ++m5JZP1jV57gRR/3rvYaEu2VAu1NbZzRzRVmTI7QckmauKNMNOemKjwvuyq579uzRyZMn9f3vf99T1rFjRw0aNEg7d+7U5ZdfLknKz89v8j3cbre+/PJLDRo0yFMWHx+vvLw81dXVNfv5p/eyOBwOpaen6+DBg56yP//5z5ozZ44+/fRTHT16VKdOnVJKSorf3zMaxF7PSMMsiTPHBDTMkmDaJuLi64OppPr/FJ7uu+cjnma9ESAINpcfadQjcjojyeU+oc3lR5qsEyhj6iPQmcvXG2O8ys4666wW38vXe7TkzNk1DofDE2Dee+89/ehHP9LIkSP1P//zPyotLdX06dNVU1PT4vtGo9gKI8ySQGvl3Fg/fTclw7s8JZNpvUAQHaxqOogEUs8fffr0UadOnfT3v//dU3by5Elt2bJF/fv3b9V7OJ1OpaWlafPmzZ6y2tpalZaWtqlt7777rnr16qXp06crPz9fF1xwgfbu3dum94xksXWbxp9ZEtlDw9YsRKicG6V+o1mBFQihHsmt28uktfX8cdZZZ2nChAn6xS9+odTUVPXs2VPPPPOMjh8/rnHjxunDDz9s1fs88MADKioqUp8+fdSvXz+9+OKL+vrrr9u0YWCfPn20b98+/elPf9Lll1+ulStXatmyZQG/X6SLrTDCLAn4Ky6eYAqE0KDsVGU4E1XhPtHUCC2lOxM1KDs1JJ//9NNPq66uTmPHjlVVVZXy8/P11ltv6eyzz271e0ydOlUVFRW6++67FR8fr5/+9KcaPny44uMD/4fLTTfdpAcffFD333+/qqurNXr0aP3yl7/U448/HvB7RjKHac2NLcsqKyvldDrldrvbNninfGP9kt4tKfwffoAAoBVOnDih8vJyZWdnB7xja8NsGsn7JnpDv8K8uwZqRG5Go+MiVV1dnfr376/bbrtNTz75pO3mhFxz10Brf79ja8xIwyyJRoMSGziklHOZJQEAYTQiN0Pz7hqodKf3D1m6MzEqgsjevXv1yiuvaPfu3dq+fbsmTJig8vJy3XHHHbabFjVi6zZNwyyJN+5WfSDxkcGZJQEAYTciN0M35KRrc/kRHaw6oR7J9bdmgj2dNxTi4uK0ePFiTZkyRcYY5ebm6p133mn1IFjEWhiR/m+WhM91Rp5mlgQAWBIf51DB+f7t+RIJsrKy9O6779puRlSLvTAiMUsCAIAIEpthRGKWBAAAESK2BrACAICIQxgBAABWEUYAAIBVhBEAAGAVYQQAgDDYtWuXBg8erMTERF122WW2m9Mqn3/+uRwOh7Zt2xbSz4nd2TQAgJh2zTXX6LLLLtOcOXPC8nkzZszQWWedpY8//lhJSUlh+cxoQRgBAESGutqIWv/JGKPa2lp16BCcn8o9e/Zo9OjR6tWrV8DvUVNTo06dOgWlPZGE2zQAAPvK3pTm5NZvZlo8rv7PObn15SFwzz33aP369Xr++eflcDjkcDi0ePFiORwOvfXWW8rPz1dCQoI2btyoPXv26KabblJaWpqSkpJ0+eWX65133vF6v969e+upp57ST37yEyUnJ6tnz55asGCB53WHw6EPPvhATzzxhBwOh2f33e3bt+u6665T586d1a1bN/30pz/V0aNHvdp58803q6ioSJmZmbrwwgs9t07eeOMNDR06VJ07d9bll1+u3bt36/3331d+fr6SkpI0YsQIffXVV17tXLRokfr376/ExET169dPL7/8stfrmzdv1oABA5SYmKj8/HyVlpYG+cw3wUQBt9ttJBm32227KQCA03z77bemrKzMfPvtt4G/yT//YswMpzEzUs54OOsf//xLkFr7f7755htTUFBg7rvvPuNyuYzL5TLvvPOOkWQuueQS8/bbb5tPP/3UHDp0yGzbts3Mnz/ffPTRR2b37t1m+vTpJjEx0ezdu9fzfr169TKpqanmpZdeMp988okpKioycXFxZufOncYYY1wul7nooovMQw89ZFwul6mqqjLHjh0zmZmZ5l//9V/N9u3bzV//+leTnZ1tCgsLPe9bWFhokpKSzNixY82OHTvM9u3bTXl5uZFk+vXrZ1avXm3KysrM4MGDzcCBA80111xj/v73v5utW7eaPn36mPHjx3vea8GCBSYjI8MUFxebzz77zBQXF5vU1FSzePFiY4wxR48eNeecc465/fbbzY4dO8yKFSvMeeedZySZ0tLSJs9lc9dAa3+//QojTz31lMnPzzdJSUnmnHPOMTfddJPZtWtXi8etW7fODBw40CQkJJjs7Gwzb948fz6WMAIAEarNYaT2lDG/6ucjiJwWSH7Vv75ekF199dXmP/7jPzzP165daySZ5cuXt3hsTk6OefHFFz3Pe/XqZe666y7P87q6OtOjRw+v37tLL73UzJgxw/N8wYIF5uyzzzZHjx71lK1cudLExcWZiooKY0x9GElLSzPV1dWeOg1h5De/+Y2n7LXXXjOSzF//+ldPWVFRkenbt6/neVZWlvnjH//o9T2efPJJU1BQYIwx5te//rVJTU01x44d87w+b968sIQRv27TrF+/Xj//+c/13nvvac2aNTp16pSGDRumY8eONXlMeXm5Ro0apaFDh6q0tFSPPvqoJk6cqOLi4sC6cgAA7cfeTd6bljZipMoD9fXCJD8/3+v5sWPH9PDDDysnJ0ddu3ZVUlKSdu3apX379nnVu+SSSzz/2+FwKD09XQcPHmzyc3bu3KlLL71UZ511lqfs+9//vurq6vTxxx97yi6++GKf40RO/7y0tDRP3dPLGj7/q6++0v79+zVu3DglJSV5Hv/5n/+pPXv2eLWnS5cunvcoKChosv3B5NeonNWrV3s9X7RokXr06KEPPvhAV111lc9j5s+fr549e3pGK/fv319btmzR7NmzdeuttwbWagBA+3D0y+DWC4LTw4Ek/eIXv9Bbb72l2bNnq0+fPurcubN+8IMfqKamxqtex44dvZ47HA7V1dU1+TnGGDkcDp+vnV5+Znt8fV5D/TPLGj6/4c9XXnlFV1xxhdf7xMfHe9pjS5uGCLvdbklSampqk3VKSko0bNgw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guHw4cPav3+/149vJAvl+YmVa6egoEBut1ubN2/WoEGDJEn/+Mc/5Ha7NWTIkFZ/XrRdO7506tRJeXl5WrNmjW655RZP+Zo1a3TTTTf5PKagoEArVqzwKnv77beVn5+vjh07hrS94RTIufGltLQ0qq+RYAr6tRPQsFf47cCBA6ZPnz7muuuuM//v//0/43K5PI/T9e3b1yxdutTz/OmnnzZOp9MsXbrUbN++3fz4xz82GRkZprKyMtxfIaT27t1rSktLzcyZM01SUpIpLS01paWlpqqqylPn9HNTVVVlHnroIbNp0yZTXl5u1q5dawoKCsy5557b7s6NMf6fH2Ni59oZMWKEueSSS0xJSYkpKSkxF198sfmXf/kXrzqxcu386U9/Mh07djQLFy40ZWVlZtKkSeass84yn3/+uTHGmEceecSMHTvWU/+zzz4zXbp0MQ8++KApKyszCxcuNB07djR//vOfbX2FkPH33Dz33HNm2bJlZvfu3WbHjh3mkUceMZJMcXGxra8QUlVVVZ7/rkgyzz77rCktLTV79+41xoT+2iGMhMmiRYuMJJ+P00kyixYt8jyvq6szM2bMMOnp6SYhIcFcddVVZvv27WFufegVFhb6PDdr16711Dn93Bw/ftwMGzbMnHPOOaZjx46mZ8+eprCw0Ozbt8/OFwgxf8+PMbFz7Rw+fNjceeedJjk52SQnJ5s777yz0XTMWLp2XnrpJdOrVy/TqVMnM3DgQLN+/XrPa4WFhebqq6/2qr9u3TozYMAA06lTJ9O7d28zb968MLc4fPw5N7NmzTLnn3++SUxMNGeffba58sorzcqVKy20OjwapjKf+SgsLDTGhP7acRjz3YgTAAAAC5hNAwAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsOr/A3lFil65iAWoAAAAAElFTkSuQmCC\n",
+      "text/plain": [
+       "<Figure size 640x480 with 1 Axes>"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "plt.scatter(p[6], p[5], label='original')\n",
+    "plt.scatter(q[6], q[5], label='tranformed')\n",
+    "plt.legend()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d1ad552b",
+   "metadata": {},
+   "source": [
+    "We will now setup a symfit model to describe this transformation, where the rotor $R$ consists of `Parameter`'s, and the pointclouds $p$ and $q$ are symfit `Variable`'s."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "id": "aefb3cbd",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "(R) * 1 + (R12) * e12 + (R13) * e13 + (R23) * e23\n",
+      "(p12) * e12 + (p13) * e13 + (p23) * e23\n",
+      "(q12) * e12 + (q13) * e13 + (q23) * e23\n"
+     ]
+    }
+   ],
+   "source": [
+    "R_par = alg.evenmv(name='R', symbolcls=Parameter)\n",
+    "p_var = alg.purevector(name='p', symbolcls=Variable, grade=d)\n",
+    "q_var = alg.purevector(name='q', symbolcls=Variable, grade=d)\n",
+    "print(R_par)\n",
+    "print(p_var)\n",
+    "print(q_var)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "id": "7e72e068",
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/latex": [
+       "\\begin{align}q_{12}(p_{12}; R, R_{12}) & = p_{12} \\left(R^{2} + R_{12}^{2}\\right)\\\\q_{13}(p_{12}, p_{13}, p_{23}; R, R_{12}, R_{13}, R_{23}) & = R^{2} p_{13} + 2 R R_{12} p_{23} - 2 R R_{23} p_{12} - R_{12}^{2} p_{13} + 2 R_{12} R_{13} p_{12}\\\\q_{23}(p_{12}, p_{13}, p_{23}; R, R_{12}, R_{13}, R_{23}) & = R^{2} p_{23} - 2 R R_{12} p_{13} + 2 R R_{13} p_{12} - R_{12}^{2} p_{23} + 2 R_{12} R_{23} p_{12}\\end{align}"
+      ],
+      "text/plain": [
+       "<symfit.core.models.Model at 0x24a2994aec0>"
+      ]
+     },
+     "execution_count": 14,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "p_var_trans = R_par.conj(p_var)\n",
+    "model = Model({q_var[k]: expr for k, expr in p_var_trans.items()})\n",
+    "model"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "30378c77",
+   "metadata": {},
+   "source": [
+    "Prepare the data for `symfit`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "id": "c8d31d48",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'p12': array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]),\n",
+       " 'p13': array([0.54249339, 0.00187754, 0.88934931, 0.14371307, 0.90024069,\n",
+       "        0.16091466, 0.79154578, 0.8069197 , 0.08935312, 0.71837907]),\n",
+       " 'p23': array([0.46277455, 0.50690105, 0.93836961, 0.33780138, 0.21474948,\n",
+       "        0.09130718, 0.67021727, 0.37623112, 0.16948978, 0.31433891]),\n",
+       " 'q12': array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]),\n",
+       " 'q13': array([1.88101687, 2.16664797, 2.12831565, 1.95533762, 1.48204906,\n",
+       "        1.73949724, 1.91198636, 1.66356202, 1.80505835, 1.67581711]),\n",
+       " 'q23': array([-1.70253249, -1.25262509, -2.21694347, -1.28787096, -1.87394245,\n",
+       "        -1.21644782, -2.02445395, -1.89602809, -1.10909076, -1.78311012])}"
+      ]
+     },
+     "execution_count": 15,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "p_data_dict = {p_var[k].name: p[k] for k in p_var.keys()}\n",
+    "q_data_dict = {q_var[k].name: q[k] for k in q_var.keys()}\n",
+    "datadict = p_data_dict | q_data_dict\n",
+    "datadict"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "668c9d93",
+   "metadata": {},
+   "source": [
+    "Initiate a `symfit.Fit` object with the model and data. We additionally supply the demand $R \\widetilde{R} = 1$, since rotors should be normalized (i.e., othonormal transformations)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 16,
+   "id": "97427a69",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "constraints = [\n",
+    "    Eq(R_par.normsq()[0], 1)\n",
+    "]\n",
+    "fit = Fit(model, **datadict, constraints=constraints)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "id": "e9b9e56e",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "\n",
+      "Parameter Value        Standard Deviation\n",
+      "R         5.108861e-01 1.055367e-02\n",
+      "R12       8.596484e-01 7.296990e-03\n",
+      "R13       4.858532e-01 1.842247e-02\n",
+      "R23       -8.692481e-01 9.055921e-03\n",
+      "Status message         Optimization terminated successfully\n",
+      "Number of iterations   24\n",
+      "Objective              <symfit.core.objectives.LeastSquares object at 0x0000024A2994BD00>\n",
+      "Minimizer              <symfit.core.minimizers.SLSQP object at 0x0000024A29993790>\n",
+      "\n",
+      "Goodness of fit qualifiers:\n",
+      "chi_squared            0.006075606510390592\n",
+      "objective_value        0.003037803255195296\n",
+      "r_squared              0.9965636446731153\n",
+      "\n",
+      "Constraints:\n",
+      "--------------------\n",
+      "Question: R**2 + R12**2 - 1 == 0?\n",
+      "Answer:   4.747247039915692e-11\n",
+      "\n",
+      "\n"
+     ]
+    }
+   ],
+   "source": [
+    "results = fit.execute()\n",
+    "print(results)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b029b3e0",
+   "metadata": {},
+   "source": [
+    "`symfit` has used SLSQP because of the constraint, and we see that we have high accuracy on this constraint. Let us print the reconstructed rotor and it's norm. Furthermore, we can now apply $\\widetilde{R}$ to $q$ to transform it back to the location of $p$ so we can visually inspect the quality of the reconstruction."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "id": "2f072bec",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "(0.5108861009186112) * 1 + (0.8596484118147669) * e12 + (0.48585315153371916) * e13 + (-0.8692480906253773) * e23\n",
+      "(1.0000000000474725) * 1 + (0.0) * e12 + (0.0) * e13 + (5.551115123125783e-17) * e23\n"
+     ]
+    }
+   ],
+   "source": [
+    "R_re = R_par(**results.params)\n",
+    "print(R_re)\n",
+    "print(R_re.normsq())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 19,
+   "id": "8d0e25ed",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p_reconstructed = (~R_re).conj(q)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 20,
+   "id": "d8059b46",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "<matplotlib.legend.Legend at 0x24a2994bdc0>"
+      ]
+     },
+     "execution_count": 20,
+     "metadata": {},
+     "output_type": "execute_result"
+    },
+    {
+     "data": {
+      "image/png": "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/fffN//85z/N4cOHzbZt28zixYvN559/bnbv3m2mT59uwsLCTGFhoef9EhMTTdeuXc0LL7xgvvrqKzNr1iwTFBRkdu7caYwxpqioyPTt29c8/PDDpqioyJSVlZljx46Z+Ph485Of/MRs377dfPDBByYpKcmMHz/e877jx483nTt3NuPGjTM7duww27dvNwUFBUaS6d27t1m7dq3Jz883gwYNMqmpqWbIkCHm73//u8nNzTU9e/Y0EydO9LzXkiVLTFxcnMnMzDRff/21yczMNF27djXLly83xhhz9OhRExUVZW655RazY8cOs3r1anPuuecaSSYvL6/Z/wxO1dCx1Njfb5/CyFNPPWXS0tJM586dTVRUlLnuuuvMrl27Trtddna2SU1NNaGhoSYpKcksWrTIl48ljABAMzvjMOI6YcwzvesIIicFkmf6VPdrZldddZX51a9+5Xm+YcMGI8m88847p902OTnZPP/8857niYmJ5vbbb/c8d7vdJjo62ut36uKLLzYzZszwPF+yZIk566yzzNGjRz1t7733ngkKCjLFxcXGmOowEhMTYyorKz19asLIH/7wB0/bG2+8YSSZDz74wNM2a9Ys06tXL8/zhIQE8/rrr3t9jyeffNKkp6cbY4x56aWXTNeuXc2xY8c8ry9atKhVhRGfTtNs3LhR9957rz755BOtX79eJ06c0IgRI3Ts2LF6tykoKNCYMWM0ePBg5eXl6dFHH9UDDzygzMzMpg3lAACsV/ix981GazFS6YHqfgGSlpbm9fzYsWN65JFHlJycrC5duqhz587atWuX9u3b59Xvoosu8vy/zWZTbGysSkpK6v2cnTt36uKLL1anTp08bZdffrncbre+/PJLT9uFF15Y5zyRkz8vJibG0/fktprPP3TokPbv368JEyaoc+fOnsfvfvc77dmzx6ue8PBwz3ukp6fXW39L5NPsnrVr13o9X7ZsmaKjo7V161ZdeeWVdW6zePFide/e3TPruU+fPtqyZYvmzp2rG2+8sWlVAwCsdfRg8/ZrBieHA0n69a9/rXXr1mnu3Lnq2bOnOnbsqJtuuklVVVVe/UJCQrye22w2ud3uej/HGCObzVbnaye3n1pPXZ9X0//UtprPr/nvyy+/rEsvvdTrfex2u6ee1u6Mpho7nU5JUteuXevts3nzZo0YMcKrbeTIkVq6dKmOHz9e6yCQpMrKSlVWVnqel5aWnkmZAIDm1jmmefv5oEOHDnK5XKftt2nTJt1555264YYbJElHjx7V3r17z/jzk5OT9eqrr+rYsWOewPHRRx8pKChIF1xwwRm//8liYmJ0zjnn6Ouvv9Ztt91Wbz1//OMf9f3336tjx46SpE8++aRZ6/C3Jl9NY4xRRkaGrrjiCqWkpNTbr7i42DMMVSMmJkYnTpzQ4cOH69xm1qxZcjgcnkdCQkJTywQA+EPiZVJkvKS6RwgkmxR5TnW/ZtajRw99+umn2rt3rw4fPlzvKEbPnj21atUqbdu2Tf/4xz906623Njji0Vi33XabwsLCNH78eO3YsUMbNmzQ/fffr3HjxtX6vWsOM2fO1KxZs7RgwQLt3r1b27dv17JlyzRv3jxJ0q233qqgoCBNmDBB+fn5WrNmjebOndvsdfhTk8PIfffdp88//1xvvPHGafueOpxVM6RU3zDXtGnT5HQ6PY/9+/c3tUwAgD8E2aVRc354curf5T88HzXbL+uNTJ48WXa7XcnJyYqKiqo1B6TGs88+q7POOkuXXXaZxo4dq5EjRyo1NfWMPz88PFzr1q3TkSNHNGDAAN1000265pprtHDhwjN+77rcfffd+sMf/qDly5frwgsv1FVXXaXly5crKSlJktS5c2etXr1a+fn56tevn6ZPn645c+ac5l1bFptpwsmm+++/X++8844+/PBDz86oz5VXXql+/fppwYIFnra3335bN998s8rLy+s8TXOq0tJSORwOOZ1ORUZG+lou0DgtbOEmwJ8qKipUUFCgpKQkhYWFNf2N8t+V1k7xnswaeU51EEn+8ZkXihavoWOpsb/fPs0ZMcbo/vvv19tvv63s7OzTBhGpekbv6tWrvdref/99paWlNSqIAAFR51+o8dX/8uMvVKB+yT+Wel9LkMcZ8ek0zb333quVK1fq9ddfV0REhIqLi1VcXKzvv//e02fatGm64447PM8nTpyowsJCZWRkaOfOnXrllVe0dOlSTZ48ufm+BXAm8t+V3rqj9mWKpUXV7X5aSRJoM4LsUtJg6cKbqv9LEIGPfAojixYtktPp1JAhQxQXF+d5vPnmm54+RUVFXufvkpKStGbNGmVnZ+uSSy7Rk08+qeeee47LetEyuF3VIyKq62zlD21rp1b3AwD4hc+naU5n+fLltdquuuoq5ebm+vJRQGD4snBT0uCAlQUA7Qk3ykP71gIXbgKA9oYwgvbNwoWbAADVzmgFVviAy0ZbppqFm0qLVPe8EVv1635YuAkAUI0wEghcNtpy1Szc9NYdql6o6eRA4t+FmwAA1ThN429cNtryJf9YunmFFBnn3R4ZX91OYAQAv2JkxJ9Oe9morfqy0d7X8i9vq7FwEwDUsnfvXiUlJSkvL0+XXHKJ3z6HkRF/8uWyUViPhZsA+MmQIUP04IMPBuSz9u7dK5vNpm3btgXk85oDIyP+1BYuG2XiLYDTcLldyi3J1aHyQ4oKj1JqdKrsAfx7oqqqSh06dAjY5/mLMUYul0vBwe3vp5mREX9q7ZeN5r8rzU+RXv2RlDmh+r/zU5jnAsAjqzBLIzNH6q51d2nKpim6a91dGpk5UlmFWX77zCFDhui+++5TRkaGzj77bA0fPlz5+fkaM2aMOnfurJiYGI0bN06HDx/2bON2uzVnzhz17NlToaGh6t69u37/+997Xt++fbuuvvpqdezYUd26ddMvfvELHT161PP6nXfeqeuvv15z585VXFycunXrpnvvvVfHjx/39HnxxRd1/vnnKywsTDExMbrppps8227cuFELFiyQzWaTzWbT3r17lZ2dLZvNpnXr1iktLU2hoaHatGmT57NO9uCDD2rIkCGN+j41943r16+fbDab13bLli1Tnz59FBYWpt69e+vFF1/0+pycnBz169dPYWFhSktLU15eXtP+kHxEGPGnmstGa91eu4at+u6WLfGyUSbeAjiNrMIsZWRn6GC59+huSXmJMrIz/BpIXn31VQUHB+ujjz7S7NmzddVVV+mSSy7Rli1btHbtWh08eFA333yzp/+0adM0Z84cPfbYY8rPz9frr7+umJjqfwiWl5dr1KhROuuss/TZZ5/pf//3f5WVlaX77rvP6zM3bNigPXv2aMOGDXr11Ve1fPlyz6rjW7Zs0QMPPKAnnnhCX375pdauXasrr7xSkrRgwQKlp6frnnvuUVFRkYqKipSQkOB530ceeUSzZs3Szp07ddFFFzXq+zf0fXJyciRJWVlZKioq0qpVqyRJL7/8sqZPn67f//732rlzp5566ik99thjevXVVyVJx44d049+9CP16tVLW7du1cyZMwN2H7n2NxYUSK31slEm3gI4DZfbpdk5s2Xq+HvCyMgmm+bkzNHQhKF+OWXTs2dPPf3005Kk3/72t0pNTdVTTz3lef2VV15RQkKCdu/erbi4OC1YsEALFy7U+PHjJUnnnXeerrjiCknSa6+9pu+//14rVqxQp06dJEkLFy7U2LFjNWfOHM+P/FlnnaWFCxfKbrerd+/euvbaa/XBBx/onnvu0b59+9SpUyf96Ec/UkREhBITE9WvXz9JksPhUIcOHRQeHq7Y2Nha3+WJJ57Q8OHDG/3dy8rKGvw+UVFRkqRu3bp5fd6TTz6pZ555Rj/5yU8kVY+g5Ofn66WXXtL48eP12muvyeVy6ZVXXlF4eLj69u2rf/3rX5o0aVKja2sqRkb8rTVeNsrEWwCnkVuSW2tE5GRGRsXlxcot8c99ydLS0jz/v3XrVm3YsEGdO3f2PHr37i1J2rNnj3bu3KnKykpdc801db7Xzp07dfHFF3uCiCRdfvnlcrvd+vLLLz1tffv2ld3+n2AVFxenkpISSdLw4cOVmJioc889V+PGjdNrr72m8vJyn79LY5zu+9Tl0KFD2r9/vyZMmOC1n373u99pz549nve9+OKLFR4e7tkuPT3dp9qaipGRQGhtl422hYm3APzqUPmhZu3nq5ODg9vt9oxinCouLk5ff/11g+9ljJHNVvfp9JPbQ0JCar3mdrslSREREcrNzVV2drbef/99/fa3v9XMmTP12WefqUuXLo3+LpIUFBRU68a0J89N6dixY4PvV5eaOl9++WVdeumlXq/VBKzG3AzXXxgZCZTWdNloa594C8DvosKjmrXfmUhNTdUXX3yhHj16qGfPnl6PTp066fzzz1fHjh31wQcf1Ll9cnKytm3bpmPHjnnaPvroIwUFBemCCy5odB3BwcEaNmyYnn76aX3++efau3ev/va3v0mSOnToIJfL1aj3iYqKUlFRkVfbyZfpnu771FxZdPLnxcTE6JxzztHXX39dax/VTHhNTk7WP/7xD33//fee7T755JNG1XymCCOorTVPvAUQEKnRqYoJj5Gtnr8nbLIpNjxWqdGpfq/l3nvv1ZEjR/Tzn/9cOTk5+vrrr/X+++/rrrvuksvlUlhYmKZMmaJHHnlEK1as0J49e/TJJ59o6dKlkqTbbrtNYWFhGj9+vHbs2KENGzbo/vvv17hx4zzzRU7nr3/9q5577jlt27ZNhYWFWrFihdxut3r16iVJ6tGjhz799FPt3btXhw8f9oxU1OXqq6/Wli1btGLFCn311VeaMWOGduzY4Xn9dN8nOjpaHTt29EzkdTqdkqSZM2dq1qxZWrBggXbv3q3t27dr2bJlmjdvniTp1ltvVVBQkCZMmKD8/HytWbNGc+fO9f0PpAkII6itZuKtpNqBpAVPvAUQMPYgu6YOnCpJtQJJzfMpA6cEZL2R+Ph4ffTRR3K5XBo5cqRSUlL0q1/9Sg6HQ0FB1T9zjz32mB5++GH99re/VZ8+fXTLLbd45nuEh4dr3bp1OnLkiAYMGKCbbrpJ11xzjRYuXNjoGrp06aJVq1bp6quvVp8+fbR48WK98cYb6tu3ryRp8uTJstvtSk5OVlRUlPbt21fve40cOVKPPfaYHnnkEQ0YMEBlZWW64447vPo09H2Cg4P13HPP6aWXXlJ8fLyuu+46SdLdd9+tP/zhD1q+fLkuvPBCXXXVVVq+fLlnZKRz585avXq18vPz1a9fP02fPr3OU1/+YDNWniRqpNLSUjkcDjmdTkVGRlpdTvtR5w3+zqkOIi1x4i2ARquoqFBBQYGSkpIUFhbW5PfJKszS7JzZXpNZY8NjNWXgFA1LHNYcpaKFa+hYauzvNxNYUb/WNvEWQMANSxymoQlDLV2BFa0fYQQNq5l4CwD1sAfZNSB2gNVloBUjjACBxv1+AMALYQQIpDrn4cRXTxhmHg6AdoqraYBA4X4/AFAnwggQCKe934+q7/fjbtyiSEBzaQUXVKKFa45jiDACBAL3+0ELU7O0eWPvnwLUp+YYOnW5fF8wZwQIBO73gxbGbrerS5cuXgt/1Xd/FqAuxhiVl5erpKREXbp08bqJoK8II0AgcL8ftEA1t5evCSRAU3Tp0sVzLDUVYQQIhJr7/ZQWqe55I7bq17nfDwLIZrMpLi5O0dHRXneFBRorJCTkjEZEahBGgECoud/PW3eo+v4+JwcS7vcDa9nt9mb5QQGaigmsQKAk/1i6eYUUGefdHhlf3c46IwDaKUZGAF+dyQqq3O8HAGohjAC+aI4VVLnfDwB44TQN0FisoAoAfkEYARqDFVQBwG8II0BjsIIqAPgNYQRoDFZQBQC/IYwAjcEKqgDgN4QRoDFqVlBVfffusEmR57CCKgA0AWEEaIyaFVQl1Q4krKAKAGeCMAI0FiuoAoBfsOhZe3Mmq4eCFVQBwA8II+1Jc6weClZQBYBmxmma1sbtkgo2Sdv/XP3fxi6yxeqhAIAWipGR1qSpIxunXT3UVr16aO9rOd0AAAg4RkZaizMZ2WD1UABAC0YYaQ3O9L4orB4KAGjBCCOtwZmObLB6KACgBSOMtAZnOrLB6qEAgBaMMNIanOnIBquHAgBaMMJIa9AcIxusHgoAaKG4tLc1qBnZeOsOVQeSkyey+jCyweqhAIAWiDDSWtSMbNS5zsjsxo9ssHooAKCFIYy0JoxsAADaIMJIa8PIBgCgjWECKwAAsBRhBAAAWIowAgAALEUYAQAAliKMAAAASxFGAACApQgjAADAUoQRAABgKcIIAACwFGEEAABYijACAAAsRRgBAACWIowAAABLEUYAAIClfA4jH374ocaOHav4+HjZbDa98847DfbPzs6WzWar9di1a1dTawYAAG1IsK8bHDt2TBdffLH++7//WzfeeGOjt/vyyy8VGRnpeR4VFeXrRwMAgDbI5zAyevRojR492ucPio6OVpcuXXzeDg1wu6TCj6WjB6XOMVLiZVKQ3eqqAADwic9hpKn69euniooKJScn6ze/+Y2GDh1ab9/KykpVVlZ6npeWlgaixNYl/11p7RSp9Jv/tEXGS6PmSMk/tq4uAAB85PcJrHFxcVqyZIkyMzO1atUq9erVS9dcc40+/PDDereZNWuWHA6H55GQkODvMluX/Helt+7wDiKSVFpU3Z7/rjV1AQDQBDZjjGnyxjab3n77bV1//fU+bTd27FjZbDa9+27dP5p1jYwkJCTI6XR6zTtpl9wuaX5K7SDiYaseIXlwO6dsAACWKi0tlcPhOO3vtyWX9g4aNEhfffVVva+HhoYqMjLS64EfFH7cQBCRJCOVHqjuBwBAK2BJGMnLy1NcXJwVH936HT3YvP0AALCYzxNYjx49qn/+85+e5wUFBdq2bZu6du2q7t27a9q0aTpw4IBWrFghSZo/f7569Oihvn37qqqqSitXrlRmZqYyMzOb71u0J51jmrcfAAAW8zmMbNmyxetKmIyMDEnS+PHjtXz5chUVFWnfvn2e16uqqjR58mQdOHBAHTt2VN++ffXee+9pzJgxzVB+O5R4WfWckNIiSXVN9/lhzkjiZYGuDACAJjmjCayB0tgJMO1GzdU0krwDia36Pzev4PJeAIDlWvQEVpyh5B9XB47IU+bdRMYTRAAArU7AFj1DM0v+sdT7WlZgBQC0eoSR1izILiUNtroKAADOCKdpAACApQgjAADAUoQRAABgKcIIAACwFGEEAABYijACAAAsRRgBAACWIowAAABLEUYAAIClCCMAAMBShBEAAGApwggAALAUYQQAAFiKMAIAACxFGAEAAJYijAAAAEsRRgAAgKUIIwAAwFKEEQAAYCnCCAAAsBRhBAAAWIowAgAALEUYAQAAliKMAAAASxFGAACApQgjAADAUoQRAABgKcIIAACwFGEEAABYijACAAAsRRgBAACWIowAAABLEUYAAIClCCMAAMBShBEAAGApwggAALAUYQQAAFiKMAIAACxFGAEAAJYijAAAAEsRRgAAgKUIIwAAwFKEEQAAYCnCCAAAsBRhBAAAWIowAgAALEUYAQAAliKMAAAASxFGAACApQgjAADAUoQRAABgKcIIAACwFGEEAABYijACAAAsRRgBAACWIowAAABLEUYAAIClCCMAAMBShBEAAGApwggAALAUYQQAAFiKMAIAACxFGAEAAJbyOYx8+OGHGjt2rOLj42Wz2fTOO++cdpuNGzeqf//+CgsL07nnnqvFixc3pVYAANAG+RxGjh07posvvlgLFy5sVP+CggKNGTNGgwcPVl5enh599FE98MADyszM9LlYAADQ9gT7usHo0aM1evToRvdfvHixunfvrvnz50uS+vTpoy1btmju3Lm68cYbff14AADQxvh9zsjmzZs1YsQIr7aRI0dqy5YtOn78uL8/HgAAtHA+j4z4qri4WDExMV5tMTExOnHihA4fPqy4uLha21RWVqqystLzvLS01N9lAgAAiwTkahqbzeb13BhTZ3uNWbNmyeFweB4JCQl+rxEAAFjD72EkNjZWxcXFXm0lJSUKDg5Wt27d6txm2rRpcjqdnsf+/fv9XSYAALCI30/TpKena/Xq1V5t77//vtLS0hQSElLnNqGhoQoNDfV3aQAAoAXweWTk6NGj2rZtm7Zt2yap+tLdbdu2ad++fZKqRzXuuOMOT/+JEyeqsLBQGRkZ2rlzp1555RUtXbpUkydPbp5vAAAAWjWfR0a2bNmioUOHep5nZGRIksaPH6/ly5erqKjIE0wkKSkpSWvWrNFDDz2kF154QfHx8Xruuee4rBcAAEiSbKZmNmkLVlpaKofDIafTqcjISKvLAQAAjdDY32/uTQMAACxF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\n",
+      "text/plain": [
+       "<Figure size 640x480 with 1 Axes>"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "plt.scatter(p[6], p[5], label='original')\n",
+    "plt.scatter(q[6], q[5], label='tranformed')\n",
+    "plt.scatter(p_reconstructed[6], p_reconstructed[5], label='reconstructed')\n",
+    "plt.legend()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "910a5aea",
+   "metadata": {},
+   "source": [
+    "We see that we have excellent agreement between the original and reconstructed pointclouds."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "df7ba23a",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8cfb042f",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/examples/ex_symbolic.ipynb b/docs/examples/ex_symbolic.ipynb
index 4fa22c6..9b8cc41 100644
--- a/docs/examples/ex_symbolic.ipynb
+++ b/docs/examples/ex_symbolic.ipynb
@@ -11,10 +11,22 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 2,
+   "execution_count": 1,
    "id": "be2ca2a3",
    "metadata": {},
-   "outputs": [],
+   "outputs": [
+    {
+     "ename": "ModuleNotFoundError",
+     "evalue": "No module named 'kingdon'",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m",
+      "\u001b[1;31mModuleNotFoundError\u001b[0m                       Traceback (most recent call last)",
+      "Input \u001b[1;32mIn [1]\u001b[0m, in \u001b[0;36m<cell line: 1>\u001b[1;34m()\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mkingdon\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m Algebra\n",
+      "\u001b[1;31mModuleNotFoundError\u001b[0m: No module named 'kingdon'"
+     ]
+    }
+   ],
    "source": [
     "from kingdon import Algebra"
    ]
@@ -29,7 +41,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 9,
+   "execution_count": null,
    "id": "354a36cb",
    "metadata": {},
    "outputs": [],
@@ -49,16 +61,19 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 15,
+   "execution_count": 2,
    "id": "73fbc348",
    "metadata": {},
    "outputs": [
     {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "R = (u1*v1 + u2*v2 + u3*v3) * 1 + (u1*v2 - u2*v1) * e12 + (u1*v3 - u3*v1) * e13 + (u1*v4 - u4*v1) * e14 + (u2*v3 - u3*v2) * e23 + (u2*v4 - u4*v2) * e24 + (u3*v4 - u4*v3) * e34\n",
-      "grades: (0, 2)\n"
+     "ename": "NameError",
+     "evalue": "name 'u' is not defined",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m",
+      "\u001b[1;31mNameError\u001b[0m                                 Traceback (most recent call last)",
+      "Input \u001b[1;32mIn [2]\u001b[0m, in \u001b[0;36m<cell line: 1>\u001b[1;34m()\u001b[0m\n\u001b[1;32m----> 1\u001b[0m R \u001b[38;5;241m=\u001b[39m \u001b[43mu\u001b[49m \u001b[38;5;241m*\u001b[39m v\n\u001b[0;32m      2\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mR =\u001b[39m\u001b[38;5;124m'\u001b[39m, R)\n\u001b[0;32m      3\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mgrades:\u001b[39m\u001b[38;5;124m'\u001b[39m, R\u001b[38;5;241m.\u001b[39mgrades)\n",
+      "\u001b[1;31mNameError\u001b[0m: name 'u' is not defined"
      ]
     }
    ],
@@ -164,7 +179,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.10.6"
+   "version": "3.9.12"
   }
  },
  "nbformat": 4,
diff --git a/docs/examples/index.rst b/docs/examples/index.rst
index f6d91e8..fd28eba 100644
--- a/docs/examples/index.rst
+++ b/docs/examples/index.rst
@@ -7,4 +7,4 @@ Examples
     :maxdepth: 1
 
     ex_symbolic.ipynb
-    ex_perfomance.ipynb
+    ex_pointcloud.ipynb
diff --git a/docs/installation.rst b/docs/installation.rst
index 760a21f..d32bde2 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -8,7 +8,7 @@ Installation
 Stable release
 --------------
 
-:mod:`Kingdon` is currently still under development, and thus no stable release is available yet.
+:code:`Kingdon` is currently still under development, and thus no stable release is available yet.
 
 .. only:: comment
 
diff --git a/docs/module_docs.rst b/docs/module_docs.rst
index d255adc..1ed64ac 100644
--- a/docs/module_docs.rst
+++ b/docs/module_docs.rst
@@ -7,14 +7,20 @@ Algebra
 .. automodule:: kingdon.algebra
    :members:
 
+MultiVector
+-----------
+
+.. automodule:: kingdon.multivector
+   :members:
+
 Codegen
 -------
 
 The codegen module generates python functions from operations
-between/on purely symbolic :class:`~kingdon.algebra.MultiVector` objects.
+between/on purely symbolic :class:`~kingdon.multivector.MultiVector` objects.
 
 As a general rule, these functions take in pure symbolic
-:class:`~kingdon.algebra.MultiVector` objects and return a tuple of keys
+:class:`~kingdon.multivector.MultiVector` objects and return a tuple of keys
 present in the output, and a pure python function which represents the
 respective operation.
 
diff --git a/docs/examples/ex_performance.ipynb b/examples/ex_performance.ipynb
similarity index 100%
rename from docs/examples/ex_performance.ipynb
rename to examples/ex_performance.ipynb
diff --git a/kingdon/__init__.py b/kingdon/__init__.py
index 2292492..a61cff6 100644
--- a/kingdon/__init__.py
+++ b/kingdon/__init__.py
@@ -6,4 +6,5 @@
 
 from sympy import symbols
 
-from kingdon.algebra import Algebra, MultiVector
+from kingdon.algebra import Algebra
+from kingdon.multivector import MultiVector
diff --git a/kingdon/algebra.py b/kingdon/algebra.py
index 607b4df..c0672f9 100644
--- a/kingdon/algebra.py
+++ b/kingdon/algebra.py
@@ -1,27 +1,30 @@
 from itertools import combinations, product, chain, groupby
-from functools import partial, cached_property, reduce
-from collections.abc import Mapping
-from collections import defaultdict, Counter
-from dataclasses import dataclass, field, replace, fields
-from contextlib import contextmanager
+from functools import partial
+from collections import Counter
+from dataclasses import dataclass, field, fields
+try:
+    from functools import cached_property
+except ImportError:
+    from functools import lru_cache
+
+    def cached_property(func):
+        return property(lru_cache()(func))
 
 import numpy as np
-from sympy import Symbol, Expr, simplify, sympify
 
 from kingdon.codegen import (
     codegen_gp, codegen_conj, codegen_cp, codegen_ip, codegen_op, codegen_div,
-    codegen_rp, codegen_acp, codegen_proj, codegen_sp, codegen_lc, codegen_inv, codegen_rc, _lambdify_mv
+    codegen_rp, codegen_acp, codegen_proj, codegen_sp, codegen_lc, codegen_inv,
+    codegen_rc, codegen_normsq, codegen_outerexp
 )
+from kingdon.operator_dict import OperatorDict
 from kingdon.matrixreps import matrix_rep
+from kingdon.multivector import MultiVector
 # from kingdon.module_builder import predefined_modules
 
 operation_field = partial(field, default_factory=dict, init=False, repr=False, compare=False)
 
 
-class AlgebraError(Exception):
-    pass
-
-
 @dataclass
 class Algebra:
     """
@@ -44,20 +47,22 @@ class Algebra:
     d: int = field(init=False, repr=False, compare=False)  # Total number of dimensions
     signature: list = field(init=False, repr=False, compare=False)
 
-    # Dictionaries that cache previously symbolically optimized lambda functions between elements.
-    _gp: dict = operation_field(metadata={'codegen': codegen_gp, 'syntax': '__mul__'})  # geometric product dict
-    _conj: dict = operation_field(metadata={'codegen': codegen_conj})  # conjugation dict
-    _cp: dict = operation_field(metadata={'codegen': codegen_cp})  # commutator product dict
-    _acp: dict = operation_field(metadata={'codegen': codegen_acp})  # anti-commutator product dict
-    _ip: dict = operation_field(metadata={'codegen': codegen_ip})  # inner product dict
-    _sp: dict = operation_field(metadata={'codegen': codegen_sp})  # Scalar product dict
-    _lc: dict = operation_field(metadata={'codegen': codegen_lc})  # left-contraction
-    _rc: dict = operation_field(metadata={'codegen': codegen_rc})  # right-contraction
-    _op: dict = operation_field(metadata={'codegen': codegen_op})  # exterior product dict
-    _rp: dict = operation_field(metadata={'codegen': codegen_rp})  # regressive product dict
-    _proj: dict = operation_field(metadata={'codegen': codegen_proj})  # projection dict
-    _div: dict = operation_field(metadata={'codegen': codegen_div})  # division dict
-    _inv: dict = operation_field(metadata={'codegen': codegen_inv})  # projection dict
+    # Clever dictionaries that cache previously symbolically optimized lambda functions between elements.
+    gp: OperatorDict = operation_field(metadata={'codegen': codegen_gp})  # geometric product dict
+    conj: OperatorDict = operation_field(metadata={'codegen': codegen_conj})  # conjugation dict
+    cp: OperatorDict = operation_field(metadata={'codegen': codegen_cp})  # commutator product dict
+    acp: OperatorDict = operation_field(metadata={'codegen': codegen_acp})  # anti-commutator product dict
+    ip: OperatorDict = operation_field(metadata={'codegen': codegen_ip})  # inner product dict
+    sp: OperatorDict = operation_field(metadata={'codegen': codegen_sp})  # Scalar product dict
+    lc: OperatorDict = operation_field(metadata={'codegen': codegen_lc})  # left-contraction
+    rc: OperatorDict = operation_field(metadata={'codegen': codegen_rc})  # right-contraction
+    op: OperatorDict = operation_field(metadata={'codegen': codegen_op})  # exterior product dict
+    rp: OperatorDict = operation_field(metadata={'codegen': codegen_rp})  # regressive product dict
+    proj: OperatorDict = operation_field(metadata={'codegen': codegen_proj})  # projection dict
+    div: OperatorDict = operation_field(metadata={'codegen': codegen_div})  # division dict
+    inv: OperatorDict = operation_field(metadata={'codegen': codegen_inv})  # inverse dict
+    normsq: OperatorDict = operation_field(metadata={'codegen': codegen_normsq})  # norm squared dict
+    outerexp: OperatorDict = operation_field(metadata={'codegen': codegen_outerexp})
 
     # Mappings from binary to canonical reps. e.g. 0b01 = 1 <-> 'e1', 0b11 = 3 <-> 'e12'.
     canon2bin: dict = field(init=False, repr=False, compare=False)
@@ -96,6 +101,11 @@ def __post_init__(self):
                        for k, v in self.canon2bin.items()}
         self.pss = self.blades[self.bin2canon[2 ** self.d - 1]]
 
+        # Prepare OperatorDict's
+        operators = (f for f in fields(self) if 'codegen' in f.metadata)
+        for f in operators:
+            setattr(self, f.name, OperatorDict(name=f.name, codegen=f.metadata['codegen'], algebra=self))
+
     def __len__(self):
         return 2 ** self.d
 
@@ -106,13 +116,15 @@ def indices_for_grade(self):
         {0: (0,), 1: (1, 2), 2: (3,)}
         """
         key = lambda i: bin(i).count('1')
-        return {grade: tuple(inds)
-                for grade, inds in groupby(sorted(range(len(self)), key=key), key=key)}
+        sorted_inds = sorted(range(len(self)), key=key)
+        return {grade: tuple(inds) for grade, inds in groupby(sorted_inds, key=key)}
 
     @cached_property
     def indices_for_grades(self):
         """
-        Mapping from a sequence of grades to the corresponding indices. E.g. in 2D VGA, this returns
+        Mapping from a sequence of grades to the corresponding indices.
+        E.g. in 2D VGA, this returns
+
         {(0,): (0,), (1,): (1, 2), (2,): (3,), (0, 1): (0, 1, 2),
          (0, 2): (0, 3), (1, 2): (1, 2, 3), (0, 1, 2): (0, 1, 2, 3)}
         """
@@ -124,10 +136,6 @@ def indices_for_grades(self):
     def matrix_basis(self):
         return matrix_rep(self.p, self.q, self.r)
 
-    @cached_property
-    def _binary_operations(self):
-        return {f.name: dict(f.metadata) for f in fields(self) if f.metadata}
-
     def _prepare_signs_and_cayley(self):
         """
         Prepares two dicts whose keys are two basis-blades in (binary rep) and the result is either
@@ -176,17 +184,17 @@ def _prepare_signs_and_cayley(self):
         return swaps_arr, signs, cayley
 
     def multivector(self, *args, **kwargs):
-        """ Create a new :class:`~kingdon.algebra.MultiVector`. """
+        """ Create a new :class:`~kingdon.multivector.MultiVector`. """
         return MultiVector(self, *args, **kwargs)
 
     def evenmv(self, *args, **kwargs):
-        """ Create a new :class:`~kingdon.algebra.MultiVector` in the even subalgebra. """
+        """ Create a new :class:`~kingdon.multivector.MultiVector` in the even subalgebra. """
         grades = tuple(filter(lambda x: x % 2 == 0, range(self.d + 1)))
         return MultiVector(self, *args, grades=grades, **kwargs)
 
     def oddmv(self, *args, **kwargs):
         """
-        Create a new :class:`~kingdon.algebra.MultiVector` of odd grades.
+        Create a new :class:`~kingdon.multivector.MultiVector` of odd grades.
         (There is technically no such thing as an odd subalgebra, but
         otherwise this is similar to :class:`~kingdon.algebra.Algebra.evenmv`.)
         """
@@ -195,359 +203,8 @@ def oddmv(self, *args, **kwargs):
 
     def purevector(self, *args, grade, **kwargs):
         """
-        Create a new :class:`~kingdon.algebra.MultiVector` of a specific grade.
+        Create a new :class:`~kingdon.multivector.MultiVector` of a specific grade.
 
         :param grade: Grade of the mutivector to create.
         """
         return MultiVector(self, *args, grades=(grade,), **kwargs)
-
-
-@dataclass(init=False)
-class MultiVector:
-    algebra: Algebra
-    _values: tuple = field(default_factory=tuple)
-    _keys: tuple = field(default_factory=tuple)
-
-    def __new__(cls, algebra, values=None, keys=None, name=None, grades=None):
-        # Sanitize input
-        values = values if values is not None else tuple()
-        keys = keys if keys is not None else tuple()
-        name = name if name is not None else ''
-        if grades is not None:
-            if not all(0 <= grade <= algebra.d for grade in grades):
-                raise ValueError(f'Each grade in `grades` needs to be a value between 0 and {algebra.d}.')
-        else:
-            grades = tuple(range(algebra.d + 1))
-
-        # Construct a new MV on the basis of the kind of input we received.
-        if isinstance(values, Mapping):
-            keys, values = zip(*values.items())
-        elif len(values) == len(algebra) and not keys:
-            keys = tuple(range(len(values)))
-        elif len(values) == len(algebra.indices_for_grades[grades]) and not keys:
-            keys = algebra.indices_for_grades[grades]
-        elif name and not values:
-            # values was not given, but we do have a name. So we are in symbolic mode.
-            keys = algebra.indices_for_grades[grades]
-            values = tuple(Symbol(f'{name}{algebra.bin2canon[k][1:]}') for k in keys)
-        elif len(keys) != len(values):
-            raise TypeError(f'Length of `keys` and `values` have to match.')
-
-        if not all(isinstance(k, int) for k in keys):
-            keys = tuple(key if key in algebra.bin2canon else algebra.canon2bin[key]
-                         for key in keys)
-        if any(isinstance(v, str) for v in values):
-            values = tuple(val if not isinstance(val, str) else sympify(val)
-                           for val in values)
-
-        if not set(keys) <= set(algebra.indices_for_grades[grades]):
-            raise ValueError(f"All keys should be of grades {grades}.")
-
-        return cls.fromkeysvalues(algebra, keys, values)
-
-    @classmethod
-    def fromkeysvalues(cls, algebra, keys, values):
-        """
-        Initiate a multivector from a sequence of keys and a sequence of values.
-        """
-        obj = object.__new__(cls)
-        obj.algebra = algebra
-        obj._values = values
-        obj._keys = keys
-        return obj
-
-    def keys(self):
-        return self._keys
-
-    def values(self):
-        return self._values
-
-    def items(self):
-        return zip(self._keys, self._values)
-
-    @classmethod
-    def frommatrix(cls, algebra, matrix):
-        """
-        Initiate a multivector from a matrix. This matrix is assumed to be
-        generated by :class:`~kingdon.algebra.MultiVector.asmatrix`, and
-        thus we only read the first column of the input matrix.
-        """
-        obj = cls(algebra=algebra, values=matrix[:, 0])
-        return obj
-
-    def __len__(self):
-        return len(self._values)
-
-    @cached_property
-    def grades(self):
-        """ Tuple of the grades present in `self`. """
-        return tuple(sorted({bin(ind).count('1') for ind in self.keys()}))
-
-    def grade(self, grades):
-        """
-        Returns a new  :class:`~kingdon.algebra.MultiVector` instance with
-        only the selected `grades` from `self`.
-
-        :param grades: tuple or int, grades to select.
-        """
-        if isinstance(grades, int):
-            grades = (grades,)
-        elif not isinstance(grades, tuple):
-            grades = tuple(grades)
-
-        vals = {k: self.values()[k]
-                for k in self.algebra.indices_for_grades[grades] if k in self.keys()}
-        return self.fromkeysvalues(self.algebra, tuple(vals.keys()), tuple(vals.values()))
-
-    @cached_property
-    def issymbolic(self):
-        """ True if this mv contains Symbols, False otherwise. """
-        return any(isinstance(v, Expr) for v in self.values())
-
-    def __neg__(self):
-        try:
-            values = - self.values()
-        except TypeError:
-            values = tuple(-v for v in self.values())
-        return self.fromkeysvalues(self.algebra, self.keys(), values)
-
-    def __invert__(self):  # reversion
-        values = tuple((-1)**(bin(k).count("1") // 2) * v for k, v in self.items())
-        return self.fromkeysvalues(self.algebra, self.keys(), values)
-
-    def normsq(self):
-        return self * ~self
-
-    def inv(self):
-        """ Inverse of this multivector. """
-        return self._unary_operation(func_dictionary=self.algebra._inv, codegen=codegen_inv)
-
-    def __add__(self, other):
-        if not isinstance(other, MultiVector):
-            other = self.fromkeysvalues(self.algebra, (0,), (other,))
-        vals = dict(self.items())
-        for k, v in other.items():
-            if k in vals:
-                vals[k] += v
-            else:
-                vals[k] = v
-        return self.fromkeysvalues(self.algebra, tuple(vals.keys()), tuple(vals.values()))
-
-    def __sub__(self, other):
-        return self + (-other)
-
-    def __truediv__(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._div, codegen=codegen_div)
-
-    def __str__(self):
-        if len(self.values()):
-            canon_sorted_vals = sorted(self.items(), key=lambda x: (len(self.algebra.bin2canon[x[0]]), self.algebra.bin2canon[x[0]]))
-            return ' + '.join([f'({val}) * {self.algebra.bin2canon[key]}' for key, val in canon_sorted_vals])
-        else:
-            return '0'
-
-    def __getitem__(self, item):
-        item = item if item in self.algebra.bin2canon else self.algebra.canon2bin[item]
-        try:
-            index = self.keys().index(item)
-        except ValueError:
-            return 0
-        else:
-            return self.values()[index]
-
-    def __contains__(self, item):
-        item = item if item in self.algebra.bin2canon else self.algebra.canon2bin[item]
-        return item in self._keys
-
-    @cached_property
-    def free_symbols(self):
-        return reduce(lambda tot, x: tot | x, (v.free_symbols for v in self.values()))
-
-    @cached_property
-    def _callable(self):
-        """ Return the callable function for this MV. """
-        return _lambdify_mv(sorted(self.free_symbols, key=lambda x: x.name), self)
-
-    def __call__(self, *args, **kwargs):
-        if not self.free_symbols:
-            return self
-        keys_out, func = self._callable
-        values = func(*args, **kwargs)
-        return self.fromkeysvalues(self.algebra, keys_out, values)
-
-    def asmatrix(self):
-        """ Returns a matrix representation of this multivector. """
-        return sum(v * self.algebra.matrix_basis[k] for k, v in self.items())
-
-    def _unary_operation(self, func_dictionary, codegen):
-        """ Helper function for all unary operations such as inv, dual, pow etc. """
-        keys_in = self.keys()
-        if keys_in not in func_dictionary:
-            xvals = tuple(Symbol(f'a{self.algebra.bin2canon[ek][1:]}') for ek in keys_in)
-            x = self.fromkeysvalues(self.algebra, keys_in, xvals)
-            keys_out, func = func_dictionary[keys_in] = codegen(x)
-        else:
-            keys_out, func = func_dictionary[keys_in]
-
-        values = tuple(func(self.values()))
-        if self.algebra.simplify and self.issymbolic:
-            # Keep only symbolically non-zero elements.
-            keysvalues = filter(lambda kv: True if not isinstance(kv[1], Expr) else simplify(kv[1]),
-                                zip(keys_out, values))
-            keys_out, values = zip(*keysvalues)
-
-        return self.fromkeysvalues(self.algebra, keys_out, values)
-
-    def _binary_operation(self, other, func_dictionary, codegen):
-        """ Helper function for all multiplication types such as gp, sp, cp etc. """
-        if not isinstance(other, MultiVector):
-            # Assume scalar multiplication, turn into a mv.
-            other = self.fromkeysvalues(self.algebra, (0,), (other,))
-        elif self.algebra != other.algebra:
-            raise AlgebraError("Cannot multiply elements of different algebra's.")
-
-        keys_in = (self.keys(), other.keys())
-        if keys_in not in func_dictionary:
-            xvals = tuple(Symbol(f'a{self.algebra.bin2canon[ek][1:]}') for ek in keys_in[0])
-            yvals = tuple(Symbol(f'b{self.algebra.bin2canon[ek][1:]}') for ek in keys_in[1])
-            x = self.fromkeysvalues(self.algebra, keys_in[0], xvals)
-            y = self.fromkeysvalues(self.algebra, keys_in[1], yvals)
-            keys_out, func = func_dictionary[keys_in] = codegen(x, y)
-        else:
-            keys_out, func = func_dictionary[keys_in]
-
-        values = func(self.values(), other.values())
-        if self.algebra.simplify and self.issymbolic or other.issymbolic:
-            keysvalues = tuple(filter(lambda kv: True if not isinstance(kv[1], Expr) else simplify(kv[1]),
-                                      zip(keys_out, values)))
-            keys_out, values = zip(*keysvalues) if keysvalues else (tuple(), tuple())
-
-        return self.fromkeysvalues(self.algebra, keys_out, values)
-
-    def gp(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._gp, codegen=codegen_gp)
-
-    __mul__ = __rmul__ = gp
-
-    def conj(self, other):
-        """ Apply `x := self` to `y := other` under conjugation: `x*y*~x`. """
-        return self._binary_operation(other, func_dictionary=self.algebra._conj, codegen=codegen_conj)
-
-    def proj(self, other):
-        """
-        Project :code:`x := self` onto :code:`y := other`: :code:`x @ y = (x | y) * ~y`.
-        For correct behavior, :code:`x` and :code:`y` should be normalized (k-reflections).
-        """
-        return self._binary_operation(other, func_dictionary=self.algebra._proj, codegen=codegen_proj)
-
-    __matmul__ = proj
-
-    def cp(self, other):
-        """
-        Calculate the commutator product of :code:`x := self` and :code:`y := other`:
-        :code:`x.cp(y) = 0.5*(x*y-y*x)`.
-        """
-        return self._binary_operation(other, func_dictionary=self.algebra._cp, codegen=codegen_cp)
-
-    def acp(self, other):
-        """
-        Calculate the anti-commutator product of :code:`x := self` and :code:`y := other`:
-        :code:`x.cp(y) = 0.5*(x*y+y*x)`.
-        """
-        return self._binary_operation(other, func_dictionary=self.algebra._acp, codegen=codegen_acp)
-
-    def ip(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._ip, codegen=codegen_ip)
-
-    __or__ = ip
-
-    def op(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._op, codegen=codegen_op)
-
-    __xor__ = op
-
-    def lc(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._lc, codegen=codegen_lc)
-
-    __lshift__ = lc
-
-    def rc(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._rc, codegen=codegen_rc)
-
-    __rshift__ = rc
-
-    def sp(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._sp, codegen=codegen_sp)
-
-    def rp(self, other):
-        return self._binary_operation(other, func_dictionary=self.algebra._rp, codegen=codegen_rp)
-
-    __and__ = rp
-
-    def dual(self, kind='auto'):
-        """
-        Compute the dual of `self`. There are three different kinds of duality in common usage.
-        The first is polarity, which is simply multiplying by the inverse PSS. This is the only game in town for
-        non-degenerate metrics (Algebra.r = 0). However, for degenerate spaces this no longer works, and we have
-        two popular options: Poincaré and Hodge duality.
-
-        By default, `kingdon` will use polarity in non-degenerate spaces, and Hodge duality for spaces with
-        `Algebra.r = 1`. For spaces with `r > 2`, little to no literature exists, and you are on your own.
-
-        :param kind: if 'auto' (default), :mod:`kingdon` will try to determine the best dual on the
-            basis of the signature of the space. See explenation above.
-            To ensure polarity, use :code:`kind='polarity'`, and to ensure Hodge duality,
-            use :code:`kind='hodge'`.
-        """
-        if kind == 'polarity' or kind == 'auto' and self.algebra.r == 0:
-            return self / self.algebra.pss
-        elif kind == 'hodge' or kind == 'auto' and self.algebra.r == 1:
-            return self.algebra.multivector(
-                {len(self.algebra) - 1 - eI: self.algebra.signs[eI, len(self.algebra) - 1 - eI] * val
-                 for eI, val in self.items()}
-            )
-        elif kind == 'auto':
-            raise Exception('Cannot select a suitable dual in auto mode for this algebra.')
-        else:
-            raise ValueError(f'No dual found for kind={kind}.')
-
-    def undual(self, kind='auto'):
-        """
-        Compute the undual of `self`. See :class:`~kingdon.algebra.MultiVector.dual` for more information.
-        """
-        if kind == 'polarity' or kind == 'auto' and self.algebra.r == 0:
-            return self * self.algebra.pss
-        elif kind == 'hodge' or kind == 'auto' and self.algebra.r == 1:
-            return self.algebra.multivector(
-                {len(self.algebra) - 1 - eI: self.algebra.signs[len(self.algebra) - 1 - eI, eI] * val
-                 for eI, val in self.items()}
-            )
-        elif kind == 'auto':
-            raise Exception('Cannot select a suitable undual in auto mode for this algebra.')
-        else:
-            raise ValueError(f'No undual found for kind={kind}.')
-
-
-class GradedMultiplication:
-    def _binary_operation(self, other, func_dictionary, codegen):
-        """ Helper function for all multiplication types such as gp, sp, cp etc. """
-        if self.algebra != other.algebra:
-            raise AlgebraError("Cannot multiply elements of different algebra's.")
-
-        keys_in = (self.algebra.indices_for_grades[self.grades],
-                   self.algebra.indices_for_grades[other.grades])
-        if keys_in not in func_dictionary:
-            x = self.algebra.multivector(vals={ek: Symbol(f'a{self.algebra.bin2canon[ek][1:]}')
-                                               for ek in keys_in[0]})
-            y = self.algebra.multivector(vals={ek: Symbol(f'b{self.algebra.bin2canon[ek][1:]}')
-                                               for ek in keys_in[1]})
-            keys_out, func = func_dictionary[keys_in] = codegen(x, y)
-        else:
-            keys_out, func = func_dictionary[keys_in]
-
-        args = chain((self.vals.get(i, 0) for i in keys_in[0]),
-                     (other.vals.get(i, 0) for i in keys_in[1]))
-        res_vals = defaultdict(int, {k: v for k, v in zip(keys_out, func(*args))
-                                     if (True if v.__class__ is not Expr else simplify(v))})
-
-        return self.algebra.mvfromtrusted(vals=res_vals)
diff --git a/kingdon/codegen.py b/kingdon/codegen.py
index bd30fcb..0b5d438 100644
--- a/kingdon/codegen.py
+++ b/kingdon/codegen.py
@@ -3,6 +3,7 @@
 from functools import reduce
 import operator
 import linecache
+import warnings
 
 from sympy import simplify, sympify, Add, Mul
 from sympy.utilities.lambdify import lambdify
@@ -45,7 +46,7 @@ def codegen_product(*mvs, name_base, filter_func=lambda tt: tt.sign, sign_func=N
     """
     Helper function for the codegen of all product-type functions.
 
-    :param *mvs: Positional-argument :class:`~kingdon.algebra.MultiVector`
+    :param mvs: Positional-argument :class:`~kingdon.multivector.MultiVector`.
     :param filter_func: A condition which should be true in the preprocessing of terms.
         For example, for the geometric product we filter out all values for which
         ei*ej = 0 since these do not have to be considered anyway.
@@ -84,9 +85,9 @@ def codegen_gp(x, y, symbolic=False):
     """
     Generate the geometric product between :code:`x` and :code:`y`.
 
-    :param x: Fully symbolic :class:`~kingdon.algebra.MultiVector`.
-    :param y: Fully symbolic :class:`~kingdon.algebra.MultiVector`.
-    :param symbolic: If true, return a symbolic :class:`~kingdon.algebra.MultiVector` instead of a lambda function.
+    :param x: Fully symbolic :class:`~kingdon.multivector.MultiVector`.
+    :param y: Fully symbolic :class:`~kingdon.multivector.MultiVector`.
+    :param symbolic: If true, return a symbolic :class:`~kingdon.multivector.MultiVector` instead of a lambda function.
     :return: tuple with integers indicating the basis blades present in the
         product in binary convention, and a lambda function that perform the product.
     """
@@ -213,17 +214,11 @@ def codegen_op(x, y, symbolic=False):
 
 def codegen_rp(x, y):
     """
-    Generate the commutator product of `x := self` and `y := other`: `x.cp(y) = 0.5*(x*y-y*x)`.
-
-    :return: tuple of keys in binary representation and a lambda function.
-    """
-    x_regr_y = (x.dual().op(y.dual())).undual()
-    return _lambdify_binary(x, y, x_regr_y)
-
-def codegen_rp(x, y):
-    """
-    Generate the regressive product of :code:`x` and :code:`y`: :math:`x \vee y`.
+    Generate the regressive product of :code:`x` and :code:`y`:,
+    :math:`x \\vee y`.
 
+    :param x:
+    :param y:
     :return: tuple of keys in binary representation and a lambda function.
     """
     algebra = x.algebra
@@ -301,6 +296,37 @@ def codegen_div(x, y):
         xdivy = x.algebra.multivector({k: v / normsqy for k, v in x_adjy.items()})
     return _lambdify_binary(x, y, xdivy)
 
+def codegen_normsq(x):
+    if x.algebra.simplify:
+        res = codegen_product(x, ~x, name_base='gp', asdict=True, sympy=True)
+        res = {k: str(simp_expr) for k, expr in res.items() if (simp_expr := simplify(expr))}
+    else:
+        res = codegen_product(x, ~x, name_base='gp', asdict=True)
+    return _func_builder(res, x, name_base="normsq")
+
+
+def codegen_outerexp(x, asterms=False):
+    if len(x.grades) != 1:
+        warnings.warn('Outer exponential might not converge for mixed-grade multivectors.', RuntimeWarning)
+    k = x.algebra.d
+
+    Ws = [x.algebra.scalar([1]), x]
+    j = 2
+    while j <= k:
+        Wj = Ws[-1] ^ x
+        # Dividing like this avoids floating point numbers, which is excellent.
+        Wj._values = tuple(v / j for v in Wj._values)
+        if Wj:
+            Ws.append(Wj)
+            j += 1
+        else:
+            break
+
+    if asterms:
+        return Ws
+    L = reduce(operator.add, Ws)
+    return _func_builder(dict(L.items()), x, name_base='outerexp')
+
 
 def _lambdify_binary(x, y, x_bin_y):
     xy_symbols = [list(x.values()), list(y.values())]
@@ -335,7 +361,7 @@ def _func_builder(res_vals: dict, *mvs, name_base: str):
     header = f'def {func_name}({", ".join(args)}):'
     if res_vals:
         body = "\n".join(f'    {",".join(v.name for v in mv.values())}, = {arg}' for mv, arg in zip(mvs, args))
-        return_val = f'    return ({", ".join(res_vals.values())},)'
+        return_val = f'    return ({", ".join(v if isinstance(v, str) else str(v) for v in res_vals.values())},)'
     else:
         body = ''
         return_val = f'    return tuple()'
diff --git a/kingdon/matrixreps.py b/kingdon/matrixreps.py
index c09bc95..762e824 100644
--- a/kingdon/matrixreps.py
+++ b/kingdon/matrixreps.py
@@ -1,6 +1,6 @@
 """
 This module contains support functions to turn
-:class:`~kingdon.algebra.MultiVector`'s into matrices.
+:class:`~kingdon.multivector.MultiVector`'s into matrices.
 
 This follows the approach outlined in
 Graded Symmetry Groups: Plane and Simple, section 10.
diff --git a/kingdon/multivector.py b/kingdon/multivector.py
new file mode 100644
index 0000000..9488233
--- /dev/null
+++ b/kingdon/multivector.py
@@ -0,0 +1,361 @@
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from functools import reduce, cached_property
+
+from sympy import Symbol, Expr, sympify
+
+from kingdon.codegen import _lambdify_mv
+
+
+@dataclass(init=False)
+class MultiVector:
+    algebra: "Algebra"
+    _values: tuple = field(default_factory=tuple)
+    _keys: tuple = field(default_factory=tuple)
+
+    def __new__(cls, algebra, values=None, keys=None, *, name=None, grades=None, symbolcls=Symbol):
+        """
+        :param algebra: Instance of :class:`~kingdon.algebra.Algebra`.
+        :param keys: Keys corresponding to the basis blades in binary rep.
+        :param values: Values of the multivector. If keys are provided, then keys and values should
+            satisfy :code:`len(keys) == len(values)`. If no keys nor grades are provided, :code:`len(values)`
+            should equal :code:`len(algebra)`, i.e. a full multivector. If grades is provided,
+            then :code:`len(values)` should be identical to the number of values in a multivector
+            of that grade.
+        :param name: Base string to be used as the name for symbolic values.
+        :param grades: Optional, :class:`tuple` of grades in this multivector.
+            If present, :code:`keys` is checked against these grades.
+        :param symbolcls: Optional, class to be used for symbol creation. This is a :class:`sympy.Symbol` by default,
+            but could be e.g. :class:`symfit.Variable` or :class:`symfit.Parameter` when the goal is to use this
+            multivector in a fitting problem.
+        """
+        # Sanitize input
+        values = values if values is not None else tuple()
+        name = name if name is not None else ''
+        if grades is not None:
+            if not all(0 <= grade <= algebra.d for grade in grades):
+                raise ValueError(f'Each grade in `grades` needs to be a value between 0 and {algebra.d}.')
+        else:
+            grades = tuple(range(algebra.d + 1))
+
+        # Construct a new MV on the basis of the kind of input we received.
+        if isinstance(values, Mapping):
+            keys, values = zip(*values.items())
+        elif len(values) == len(algebra) and not keys:
+            keys = tuple(range(len(values)))
+        elif len(values) == len(algebra.indices_for_grades[grades]) and not keys:
+            keys = algebra.indices_for_grades[grades]
+        elif name and not values:
+            # values was not given, but we do have a name. So we are in symbolic mode.
+            keys = algebra.indices_for_grades[grades] if not keys else keys
+            values = tuple(symbolcls(f'{name}{algebra.bin2canon[k][1:]}') for k in keys)
+        elif len(keys) != len(values):
+            raise TypeError(f'Length of `keys` and `values` have to match.')
+
+        if not all(isinstance(k, int) for k in keys):
+            keys = tuple(key if key in algebra.bin2canon else algebra.canon2bin[key]
+                         for key in keys)
+        if any(isinstance(v, str) for v in values):
+            values = tuple(val if not isinstance(val, str) else sympify(val)
+                           for val in values)
+
+        if not set(keys) <= set(algebra.indices_for_grades[grades]):
+            raise ValueError(f"All keys should be of grades {grades}.")
+
+        return cls.fromkeysvalues(algebra, keys, values)
+
+    @classmethod
+    def fromkeysvalues(cls, algebra, keys, values):
+        """
+        Initiate a multivector from a sequence of keys and a sequence of values.
+        """
+        obj = object.__new__(cls)
+        obj.algebra = algebra
+        obj._values = values
+        obj._keys = keys
+        return obj
+
+    @classmethod
+    def frommatrix(cls, algebra, matrix):
+        """
+        Initiate a multivector from a matrix. This matrix is assumed to be
+        generated by :class:`~kingdon.multivector.MultiVector.asmatrix`, and
+        thus we only read the first column of the input matrix.
+        """
+        obj = cls(algebra=algebra, values=matrix[:, 0])
+        return obj
+
+    def keys(self):
+        return self._keys
+
+    def values(self):
+        return self._values
+
+    def items(self):
+        return zip(self._keys, self._values)
+
+    def __len__(self):
+        return len(self._values)
+
+    @cached_property
+    def grades(self):
+        """ Tuple of the grades present in `self`. """
+        return tuple(sorted({bin(ind).count('1') for ind in self.keys()}))
+
+    def grade(self, grades):
+        """
+        Returns a new  :class:`~kingdon.multivector.MultiVector` instance with
+        only the selected `grades` from `self`.
+
+        :param grades: tuple or int, grades to select.
+        """
+        if isinstance(grades, int):
+            grades = (grades,)
+        elif not isinstance(grades, tuple):
+            grades = tuple(grades)
+
+        vals = {k: self[k]
+                for k in self.algebra.indices_for_grades[grades] if k in self.keys()}
+        return self.fromkeysvalues(self.algebra, tuple(vals.keys()), tuple(vals.values()))
+
+    @cached_property
+    def issymbolic(self):
+        """ True if this mv contains Symbols, False otherwise. """
+        return any(isinstance(v, Expr) for v in self.values())
+
+    def __neg__(self):
+        try:
+            values = - self.values()
+        except TypeError:
+            values = tuple(-v for v in self.values())
+        return self.fromkeysvalues(self.algebra, self.keys(), values)
+
+    def __invert__(self):  # reversion
+        values = tuple((-1)**(bin(k).count("1") // 2) * v for k, v in self.items())
+        return self.fromkeysvalues(self.algebra, self.keys(), values)
+
+    def normsq(self):
+        return self.algebra.normsq(self)
+
+    def normalized(self):
+        """ Normalized version of this multivector. """
+        normsq = self.normsq()
+        if normsq.grades == (0,):
+            return self / normsq[0] ** 0.5
+        else:
+            raise NotImplementedError
+
+    def inv(self):
+        """ Inverse of this multivector. """
+        return self.algebra.inv(self)
+
+    def __add__(self, other):
+        if not isinstance(other, MultiVector):
+            other = self.fromkeysvalues(self.algebra, (0,), (other,))
+        vals = dict(self.items())
+        for k, v in other.items():
+            if k in vals:
+                vals[k] += v
+            else:
+                vals[k] = v
+        return self.fromkeysvalues(self.algebra, tuple(vals.keys()), tuple(vals.values()))
+
+    def __sub__(self, other):
+        return self + (-other)
+
+    def __truediv__(self, other):
+        return self.algebra.div(self, other)
+
+    def __str__(self):
+        if len(self.values()):
+            canon_sorted_vals = sorted(self.items(), key=lambda x: (len(self.algebra.bin2canon[x[0]]), self.algebra.bin2canon[x[0]]))
+            return ' + '.join([f'({val}) * {self.algebra.bin2canon[key]}' for key, val in canon_sorted_vals])
+        else:
+            return '0'
+
+    def __getitem__(self, item):
+        if isinstance(item, tuple):
+            key, slicing = item
+        else:
+            key, slicing = item, None
+
+        if key == slice(None, None, None):
+            values = self.values()
+        else:
+            # Convert key from a basis-blade in binary rep to a valid index in values.
+            key = key if key in self.algebra.bin2canon else self.algebra.canon2bin[key]
+            try:
+                key = self.keys().index(key)
+            except ValueError:
+                return 0
+            else:
+                values = self.values()
+
+        if slicing is None:
+            return values[key]
+        else:
+            return values[key][slicing] if isinstance(values, (tuple, list)) else values[key, slicing]
+
+    def __contains__(self, item):
+        item = item if item in self.algebra.bin2canon else self.algebra.canon2bin[item]
+        return item in self._keys
+
+    @cached_property
+    def free_symbols(self):
+        return reduce(lambda tot, x: tot | x, (v.free_symbols for v in self.values()))
+
+    @cached_property
+    def _callable(self):
+        """ Return the callable function for this MV. """
+        return _lambdify_mv(sorted(self.free_symbols, key=lambda x: x.name), self)
+
+    def __call__(self, *args, **kwargs):
+        if not self.free_symbols:
+            return self
+        keys_out, func = self._callable
+        values = func(*args, **kwargs)
+        return self.fromkeysvalues(self.algebra, keys_out, values)
+
+    def asmatrix(self):
+        """ Returns a matrix representation of this multivector. """
+        return sum(v * self.algebra.matrix_basis[k] for k, v in self.items())
+
+    def gp(self, other):
+        return self.algebra.gp(self, other)
+
+    __mul__ = __rmul__ = gp
+
+    def conj(self, other):
+        """ Apply `x := self` to `y := other` under conjugation: `x*y*~x`. """
+        return self.algebra.conj(self, other)
+
+    def proj(self, other):
+        """
+        Project :code:`x := self` onto :code:`y := other`: :code:`x @ y = (x | y) * ~y`.
+        For correct behavior, :code:`x` and :code:`y` should be normalized (k-reflections).
+        """
+        return self.algebra.proj(self, other)
+
+    __matmul__ = proj
+
+    def cp(self, other):
+        """
+        Calculate the commutator product of :code:`x := self` and :code:`y := other`:
+        :code:`x.cp(y) = 0.5*(x*y-y*x)`.
+        """
+        return self.algebra.cp(self, other)
+
+    def acp(self, other):
+        """
+        Calculate the anti-commutator product of :code:`x := self` and :code:`y := other`:
+        :code:`x.cp(y) = 0.5*(x*y+y*x)`.
+        """
+        return self.algebra.acp(self, other)
+
+    def ip(self, other):
+        return self.algebra.ip(self, other)
+
+    __or__ = ip
+
+    def op(self, other):
+        return self.algebra.op(self, other)
+
+    __xor__ = __rxor__ = op
+
+    def lc(self, other):
+        return self.algebra.lc(self, other)
+
+    __lshift__ = lc
+
+    def rc(self, other):
+        return self.algebra.rc(self, other)
+
+    __rshift__ = rc
+
+    def sp(self, other):
+        """ Scalar product: :math:`\langle x \cdot y \rangle`. """
+        return self.algebra.sp(self, other)
+
+    def rp(self, other):
+        return self.algebra.rp(self, other)
+
+    __and__ = rp
+
+    def __pow__(self, power, modulo=None):
+        # TODO: this should also be taken care of via codegen, but for now this workaround is ok.
+        if power == 2:
+            return self.algebra.gp(self, self)
+        else:
+            raise NotImplementedError
+
+    def outerexp(self):
+        return self.algebra.outerexp(self)
+
+
+    def dual(self, kind='auto'):
+        """
+        Compute the dual of `self`. There are three different kinds of duality in common usage.
+        The first is polarity, which is simply multiplying by the inverse PSS. This is the only game in town for
+        non-degenerate metrics (Algebra.r = 0). However, for degenerate spaces this no longer works, and we have
+        two popular options: Poincaré and Hodge duality.
+
+        By default, :code:`kingdon` will use polarity in non-degenerate spaces, and Hodge duality for spaces with
+        `Algebra.r = 1`. For spaces with `r > 2`, little to no literature exists, and you are on your own.
+
+        :param kind: if 'auto' (default), :code:`kingdon` will try to determine the best dual on the
+            basis of the signature of the space. See explenation above.
+            To ensure polarity, use :code:`kind='polarity'`, and to ensure Hodge duality,
+            use :code:`kind='hodge'`.
+        """
+        if kind == 'polarity' or kind == 'auto' and self.algebra.r == 0:
+            return self / self.algebra.pss
+        elif kind == 'hodge' or kind == 'auto' and self.algebra.r == 1:
+            return self.algebra.multivector(
+                {len(self.algebra) - 1 - eI: self.algebra.signs[eI, len(self.algebra) - 1 - eI] * val
+                 for eI, val in self.items()}
+            )
+        elif kind == 'auto':
+            raise Exception('Cannot select a suitable dual in auto mode for this algebra.')
+        else:
+            raise ValueError(f'No dual found for kind={kind}.')
+
+    def undual(self, kind='auto'):
+        """
+        Compute the undual of `self`. See :class:`~kingdon.multivector.MultiVector.dual` for more information.
+        """
+        if kind == 'polarity' or kind == 'auto' and self.algebra.r == 0:
+            return self * self.algebra.pss
+        elif kind == 'hodge' or kind == 'auto' and self.algebra.r == 1:
+            return self.algebra.multivector(
+                {len(self.algebra) - 1 - eI: self.algebra.signs[len(self.algebra) - 1 - eI, eI] * val
+                 for eI, val in self.items()}
+            )
+        elif kind == 'auto':
+            raise Exception('Cannot select a suitable undual in auto mode for this algebra.')
+        else:
+            raise ValueError(f'No undual found for kind={kind}.')
+
+
+# class GradedMultiplication:
+#     def _binary_operation(self, other, func_dictionary, codegen):
+#         """ Helper function for all multiplication types such as gp, sp, cp etc. """
+#         if self.algebra != other.algebra:
+#             raise AlgebraError("Cannot multiply elements of different algebra's.")
+#
+#         keys_in = (self.algebra.indices_for_grades[self.grades],
+#                    self.algebra.indices_for_grades[other.grades])
+#         if keys_in not in func_dictionary:
+#             x = self.algebra.multivector(vals={ek: Symbol(f'a{self.algebra.bin2canon[ek][1:]}')
+#                                                for ek in keys_in[0]})
+#             y = self.algebra.multivector(vals={ek: Symbol(f'b{self.algebra.bin2canon[ek][1:]}')
+#                                                for ek in keys_in[1]})
+#             keys_out, func = func_dictionary[keys_in] = codegen(x, y)
+#         else:
+#             keys_out, func = func_dictionary[keys_in]
+#
+#         args = chain((self.vals.get(i, 0) for i in keys_in[0]),
+#                      (other.vals.get(i, 0) for i in keys_in[1]))
+#         res_vals = defaultdict(int, {k: v for k, v in zip(keys_out, func(*args))
+#                                      if (True if v.__class__ is not Expr else simplify(v))})
+#
+#         return self.algebra.mvfromtrusted(vals=res_vals)
diff --git a/kingdon/operator_dict.py b/kingdon/operator_dict.py
new file mode 100644
index 0000000..3cac065
--- /dev/null
+++ b/kingdon/operator_dict.py
@@ -0,0 +1,56 @@
+from dataclasses import dataclass, field
+from collections.abc import Mapping
+from typing import Callable
+import string
+
+from sympy import Symbol, Expr, simplify
+
+from kingdon.multivector import MultiVector
+
+
+class AlgebraError(Exception):
+    pass
+
+
+@dataclass
+class OperatorDict(Mapping):
+    name: str
+    codegen: Callable
+    algebra: "Algebra"
+    operator_dict: dict = field(default_factory=dict, init=False)
+
+    def __len__(self):
+        return len(self.operator_dict)
+
+    def __getitem__(self, keys_in):
+        if keys_in not in self.operator_dict:
+            mvs = []
+            for name, keys in zip(string.ascii_lowercase, keys_in):
+                vals = tuple(Symbol(f'{name}{self.algebra.bin2canon[ek][1:]}') for ek in keys)
+                mvs.append(MultiVector.fromkeysvalues(self.algebra, keys=keys, values=vals))
+            self.operator_dict[keys_in] = self.codegen(*mvs)
+        return self.operator_dict[keys_in]
+
+    def __iter__(self):
+        return iter(self.operator_dict)
+
+    def __call__(self, *mvs):
+        # Make sure all inputs are multivectors. If an input is not, assume its scalar.
+        mvs = [mv if isinstance(mv, MultiVector) else MultiVector.fromkeysvalues(self.algebra, (0,), (mv,))
+               for mv in mvs]
+        if any((mvs[0].algebra != mv.algebra) for mv in mvs[1:]):
+            raise AlgebraError("Cannot multiply elements of different algebra's.")
+        keys_in = tuple(mv.keys() for mv in mvs)
+        values_in = tuple(mv.values() for mv in mvs)
+        keys_out, func = self[keys_in]
+        values = func(*values_in)
+
+        if self.algebra.simplify and any(mv.issymbolic for mv in mvs):
+            # Keep only symbolically non-zero elements.
+            keysvalues = tuple(filter(
+                lambda kv: True if not isinstance(kv[1], Expr) else simplify(kv[1]),
+                zip(keys_out, values)
+            ))
+            keys_out, values = zip(*keysvalues) if keysvalues else (tuple(), tuple())
+
+        return MultiVector.fromkeysvalues(self.algebra, keys=keys_out, values=values)
diff --git a/requirements.txt b/requirements.txt
index 05bdf19..0c29ef9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
 numpy
 sympy
+numba
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 5d10382..a0bc847 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,4 +1,4 @@
-pip==19.2.3
+pip>=21.1
 bump2version==0.5.11
 wheel==0.33.6
 watchdog==0.9.0
diff --git a/requirements_docs.txt b/requirements_docs.txt
new file mode 100644
index 0000000..2f516b8
--- /dev/null
+++ b/requirements_docs.txt
@@ -0,0 +1,7 @@
+-r requirements.txt
+sphinx
+nbsphinx
+IPython
+ipykernel
+symfit
+matplotlib
diff --git a/setup.py b/setup.py
index 9f5be00..04ed877 100644
--- a/setup.py
+++ b/setup.py
@@ -17,16 +17,16 @@
 setup(
     author="Martin Roelfs",
     author_email='martinroelfs@yahoo.com',
-    python_requires='>=3.6',
+    python_requires='>=3.8',
     classifiers=[
         'Development Status :: 2 - Pre-Alpha',
         'Intended Audience :: Developers',
         'License :: OSI Approved :: MIT License',
         'Natural Language :: English',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
     ],
     description="Pythonic Geometric Algebra Package",
     install_requires=requirements,
diff --git a/tox.ini b/tox.ini
index 0e5958b..258312f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,11 +1,12 @@
 [tox]
-envlist = py36, py37, py38, flake8
+envlist = py38, py39, py310, flake8
 
 [travis]
 python =
+    3.10: py310
+    3.9: py39
     3.8: py38
-    3.7: py37
-    3.6: py36
+
 
 [testenv:flake8]
 basepython = python


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..759baf2
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,27 @@
+name: pytest
+on: [push, pull_request]
+
+jobs:
+  build:
+
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+        python-version: [3.8, 3.9, '3.10']
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install wheel
+        pip install pytest
+        pip install -r requirements.txt
+        pip install -e .
+    - name: Test with coverage
+      run:
+        pytest
diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml
new file mode 100644
index 0000000..049aeda
--- /dev/null
+++ b/.github/workflows/test_coverage.yml
@@ -0,0 +1,32 @@
+name: pytest_and_coveralls
+on: [push, pull_request]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.10'
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install wheel
+        pip install coveralls
+        pip install pytest
+        pip install pytest-cov
+        pip install -r requirements.txt
+        pip install -e .
+    - name: Test with coverage
+      run: |
+        pytest --cov=kingdon
+    - name: Upload to Coveralls
+      env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      run: |
+          pip install --upgrade coveralls
+          coveralls --service=github
diff --git a/tests/test_kingdon.py b/tests/test_kingdon.py
index 95fbb7b..8cbc01b 100644
--- a/tests/test_kingdon.py
+++ b/tests/test_kingdon.py
@@ -440,3 +440,49 @@ def test_projection():
     xconjy = x.proj(y)
     for i in range(len(alg)):
         assert expand(xconjy[i]) == expand(xconjy_expected[i])
+
+
+def test_outerexp(R6):
+    B = R6.bivector(name='B')
+    LB = B.outerexp()
+    LB_exact = B + (B ^ B) / 2 + (B ^ B ^ B) / 6 + 1
+
+    diff = LB - LB_exact
+    for val in diff.values():
+        assert simplify(val) == 0
+
+    v = R6.vector(name='v')
+    Lv = v.outerexp()
+    Lv_exact = v + 1
+    diff = Lv - Lv_exact
+    for val in diff.values():
+        assert val == 0
+
+
+def test_indexing():
+    alg = Algebra(4)
+    nrows = 3
+    bvals = np.random.random((len(alg.indices_for_grade[2]), nrows))
+    B = alg.bivector(bvals)
+    np.testing.assert_allclose(B[3, 2:4], bvals[0, 2:4])
+    np.testing.assert_allclose(B[3, 2], bvals[0, 2])
+    np.testing.assert_allclose(B[3, :], bvals[0, :])
+    np.testing.assert_allclose(B[:, 0], bvals[:, 0])
+
+    #TODO: same tests but without using a numpy array
+    bvals = tuple(np.random.random(nrows) for _ in range(len(alg.indices_for_grade[2])))
+    B = alg.bivector(bvals)
+    np.testing.assert_allclose(B[3, 2:4], bvals[0][2:4])
+    np.testing.assert_allclose(B[3, 2], bvals[0][2])
+    np.testing.assert_allclose(B[3, :], bvals[0][:])
+    np.testing.assert_allclose(B[:, 0], bvals[:][0])
+
+def test_normalization(pga3d):
+    vvals = np.random.random(len(pga3d.indices_for_grade[1]))
+    v = pga3d.vector(vvals).normalized()
+    assert (v*v)[0] == pytest.approx(1.0)
+    np.testing.assert_allclose((v*v)[0], 1.0)
+
+    bvals = np.random.random(len(pga3d.indices_for_grade[2]))
+    with pytest.raises(NotImplementedError):
+        B = pga3d.bivector(bvals).normalized()
diff --git a/tests/test_operator_dict.py b/tests/test_operator_dict.py
new file mode 100644
index 0000000..0258310
--- /dev/null
+++ b/tests/test_operator_dict.py
@@ -0,0 +1,27 @@
+from dataclasses import fields
+
+import pytest
+
+from kingdon.operator_dict import OperatorDict
+from kingdon.codegen import codegen_gp, codegen_inv
+from kingdon import Algebra
+
+
+def test_operator_dict():
+    alg = Algebra(2)
+    x = alg.multivector(name='x')
+    y = alg.multivector(name='y')
+
+    gp = OperatorDict('gp', codegen=codegen_gp, algebra=alg)
+    assert len(gp) == 0
+    with pytest.raises(TypeError):
+        gp[(x.keys(), y.keys())] = 2
+    xy = gp(x, y)
+    assert len(gp) == 1  # size of gp has grown by one
+    assert (x.keys(), y.keys()) in gp
+
+    inv = OperatorDict('inv', codegen=codegen_inv, algebra=alg)
+    assert len(inv) == 0
+    xinv = inv(x)
+    assert len(inv) == 1
+    assert (x.keys(),) in inv