diff --git a/src/geometry/PyramidTransformation.hpp b/src/geometry/PyramidTransformation.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..19e7822ba3bc3e01da7be2840522e24707b68007
--- /dev/null
+++ b/src/geometry/PyramidTransformation.hpp
@@ -0,0 +1,72 @@
+#ifndef PYRAMID_TRANSFORMATION_HPP
+#define PYRAMID_TRANSFORMATION_HPP
+
+#include <algebra/TinyMatrix.hpp>
+#include <algebra/TinyVector.hpp>
+
+#include <array>
+
+class PyramidTransformation
+{
+ private:
+  constexpr static size_t Dimension      = 3;
+  constexpr static size_t NumberOfPoints = 5;
+
+  TinyMatrix<Dimension, NumberOfPoints - 1> m_coefficients;
+  TinyVector<Dimension> m_shift;
+
+ public:
+  PUGS_INLINE
+  TinyVector<Dimension>
+  operator()(const TinyVector<Dimension>& x) const
+  {
+    const TinyVector<NumberOfPoints - 1> X = {x[0], x[1], x[2], x[0] * x[1]};
+    return m_coefficients * X + m_shift;
+  }
+
+  double
+  jacobianDeterminant(const TinyVector<Dimension>& X) const
+  {
+    const auto& T   = m_coefficients;
+    const double& x = X[0];
+    const double& y = X[1];
+
+    const TinyMatrix<Dimension, Dimension> J = {T(0, 0) + T(0, 3) * y,   //
+                                                T(0, 1) + T(0, 3) * x,   //
+                                                T(0, 2),
+                                                //
+                                                T(1, 0) + T(1, 3) * y,   //
+                                                T(1, 1) + T(1, 3) * x,   //
+                                                T(1, 2),
+                                                //
+                                                T(2, 0) + T(2, 3) * y,   //
+                                                T(2, 1) + T(2, 3) * x,   //
+                                                T(2, 2)};
+    return det(J);
+  }
+
+  PUGS_INLINE
+  PyramidTransformation(const std::array<TinyVector<Dimension>, NumberOfPoints>& image_nodes)
+  {
+    static_assert(Dimension == 3, "invalid dimension");
+
+    const TinyVector<Dimension>& a = image_nodes[0];
+    const TinyVector<Dimension>& b = image_nodes[1];
+    const TinyVector<Dimension>& c = image_nodes[2];
+    const TinyVector<Dimension>& d = image_nodes[3];
+    const TinyVector<Dimension>& e = image_nodes[4];
+
+    m_shift = 0.25 * (a + b + c + d);
+
+    for (size_t i = 0; i < Dimension; ++i) {
+      m_coefficients(i, 0) = 0.5 * (b[i] + c[i]) - m_shift[i];
+      m_coefficients(i, 1) = 0.5 * (c[i] + d[i]) - m_shift[i];
+      m_coefficients(i, 2) = e[i] - m_shift[i];
+      m_coefficients(i, 3) = 0.5 * (a[i] + c[i]) - m_shift[i];
+    }
+  }
+
+  ~PyramidTransformation() = default;
+};
+
+#endif   // PYRAMID_TRANSFORMATION_HPP
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index fadd4ec5d1138d1423c4e758e9cc59249684c8e7..ba8ceb6c279f71dddff992aa4aebac11a629f79c 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -101,6 +101,7 @@ add_executable (unit_tests
   test_PugsFunctionAdapter.cpp
   test_PugsUtils.cpp
   test_PyramidGaussQuadrature.cpp
+  test_PyramidTransformation.cpp
   test_Q1Transformation.cpp
   test_RevisionInfo.cpp
   test_SmallArray.cpp
diff --git a/tests/test_PyramidTransformation.cpp b/tests/test_PyramidTransformation.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..bfdcee769d97558a2ff8037752b8e060fb3a8c8b
--- /dev/null
+++ b/tests/test_PyramidTransformation.cpp
@@ -0,0 +1,95 @@
+#include <catch2/catch_approx.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include <analysis/PyramidGaussQuadrature.hpp>
+#include <geometry/PyramidTransformation.hpp>
+
+// clazy:excludeall=non-pod-global-static
+
+TEST_CASE("PyramidTransformation", "[geometry]")
+{
+  using R3 = TinyVector<3>;
+
+  const R3 a_hat = {-1, -1, +0};
+  const R3 b_hat = {+1, -1, +0};
+  const R3 c_hat = {+1, +1, +0};
+  const R3 d_hat = {-1, +1, +0};
+  const R3 e_hat = {+0, +0, +1};
+
+  const R3 m_hat = {0, 0, 1. / 5};
+
+  const R3 a = {1, 2, 0};
+  const R3 b = {3, 1, 3};
+  const R3 c = {2, 5, 2};
+  const R3 d = {0, 3, 1};
+  const R3 e = {1, 2, 5};
+
+  const std::array points = {a, b, c, d, e};
+  const PyramidTransformation t(points);
+
+  SECTION("points")
+  {
+    REQUIRE(l2Norm(t(a_hat) - a) == Catch::Approx(0));
+    REQUIRE(l2Norm(t(b_hat) - b) == Catch::Approx(0));
+    REQUIRE(l2Norm(t(c_hat) - c) == Catch::Approx(0));
+    REQUIRE(l2Norm(t(d_hat) - d) == Catch::Approx(0));
+    REQUIRE(l2Norm(t(e_hat) - e) == Catch::Approx(0));
+
+    R3 m = (1. / 5) * (a + b + c + d + e);
+    REQUIRE(l2Norm(t(m_hat) - m) == Catch::Approx(0).margin(1E-14));
+  }
+
+  SECTION("Jacobian determinant")
+  {
+    SECTION("at points")
+    {
+      auto detJ = [](const R3 X) {
+        const double& x = X[0];
+        const double& y = X[1];
+
+        return (43 * x + 13 * y + 93) / 16;
+      };
+
+      REQUIRE(t.jacobianDeterminant(a_hat) == Catch::Approx(detJ(a_hat)));
+      REQUIRE(t.jacobianDeterminant(b_hat) == Catch::Approx(detJ(b_hat)));
+      REQUIRE(t.jacobianDeterminant(c_hat) == Catch::Approx(detJ(c_hat)));
+      REQUIRE(t.jacobianDeterminant(d_hat) == Catch::Approx(detJ(d_hat)));
+      REQUIRE(t.jacobianDeterminant(e_hat) == Catch::Approx(detJ(e_hat)));
+
+      REQUIRE(t.jacobianDeterminant(m_hat) == Catch::Approx(detJ(m_hat)));
+    }
+
+    SECTION("volume calculation")
+    {
+      // The jacobian determinant is a degree 1 polynomial
+      PyramidGaussQuadrature gauss(1);
+      double volume = 0;
+      for (size_t i = 0; i < gauss.numberOfPoints(); ++i) {
+        volume += gauss.weight(i) * t.jacobianDeterminant(gauss.point(i));
+      }
+
+      // 31 / 4 is actually the volume of the pyramid
+      REQUIRE(volume == Catch::Approx(31. / 4));
+    }
+
+    SECTION("exact polynomial integration")
+    {
+      auto p = [](const R3& X) {
+        const double x = X[0];
+        const double y = X[1];
+        const double z = X[2];
+
+        return 3 * x * x + 2 * y * y + 3 * z * z + 4 * x + 3 * y + 2 * z + 1;
+      };
+
+      // 3 is the minimum quadrature rule to integrate the polynomial on the pyramid
+      PyramidGaussQuadrature gauss(3);
+      double integral = 0;
+      for (size_t i = 0; i < gauss.numberOfPoints(); ++i) {
+        integral += gauss.weight(i) * t.jacobianDeterminant(gauss.point(i)) * p(gauss.point(i));
+      }
+
+      REQUIRE(integral == Catch::Approx(1513. / 60));
+    }
+  }
+}