diff --git a/Core/include/Acts/Geometry/GeometryID.hpp b/Core/include/Acts/Geometry/GeometryID.hpp
index 62ff52245b42e51dc2b0ff28c61bcaf23897585e..878293081c77988f782e94d4b49d727a40df426b 100644
--- a/Core/include/Acts/Geometry/GeometryID.hpp
+++ b/Core/include/Acts/Geometry/GeometryID.hpp
@@ -9,6 +9,7 @@
 #pragma once
 
 #include <cstdint>
+#include <functional>
 #include <iosfwd>
 
 namespace Acts {
@@ -113,3 +114,13 @@ class GeometryID {
 std::ostream& operator<<(std::ostream& os, GeometryID id);
 
 }  // namespace Acts
+
+// specialize std::hash so GeometryId can be used e.g. in an unordered_map
+namespace std {
+template <>
+struct hash<Acts::GeometryID> {
+  auto operator()(Acts::GeometryID gid) const noexcept {
+    return std::hash<Acts::GeometryID::Value>()(gid.value());
+  }
+};
+}  // namespace std
diff --git a/Core/include/Acts/Utilities/MultiIndex.hpp b/Core/include/Acts/Utilities/MultiIndex.hpp
index 6520123d9feb47b755db2764aa07339e07581d0e..fbcca4e51b955b049a93f204ac936cc96a18ff90 100644
--- a/Core/include/Acts/Utilities/MultiIndex.hpp
+++ b/Core/include/Acts/Utilities/MultiIndex.hpp
@@ -148,15 +148,13 @@ class MultiIndex {
 
 }  // namespace Acts
 
+// specialize std::hash so MultiIndex can be used e.g. in an unordered_map
 namespace std {
-
-// specialize std::hash so the MultiIndex can be used e.g. in an unordered_map
 template <typename Storage, std::size_t... BitsPerLevel>
 struct hash<Acts::MultiIndex<Storage, BitsPerLevel...>> {
-  constexpr auto operator()(
-      Acts::MultiIndex<Storage, BitsPerLevel...> idx) const noexcept {
+  auto operator()(Acts::MultiIndex<Storage, BitsPerLevel...> idx) const
+      noexcept {
     return std::hash<Storage>()(idx.value());
   }
 };
-
 }  // namespace std
diff --git a/Core/include/Acts/Utilities/PdgParticle.hpp b/Core/include/Acts/Utilities/PdgParticle.hpp
index 3ad4722ca26f91a1e38f8316be049eec5587058a..56a60c7f06a2511a2b1b76d45be57ec61b4676dd 100644
--- a/Core/include/Acts/Utilities/PdgParticle.hpp
+++ b/Core/include/Acts/Utilities/PdgParticle.hpp
@@ -32,4 +32,10 @@ enum PdgParticle : int32_t {
   eAntiProton = -eProton,
 };
 
+/// Convert an anti-particle to its particle and leave particles as-is.
+constexpr inline PdgParticle makeAbsolutePdgParticle(PdgParticle pdg) {
+  const auto value = static_cast<int32_t>(pdg);
+  return static_cast<PdgParticle>((0 <= value) ? value : -value);
+}
+
 }  // namespace Acts
diff --git a/Core/include/Acts/Utilities/CurvilinearUnitVectors.hpp b/Core/include/Acts/Utilities/UnitVectors.hpp
similarity index 66%
rename from Core/include/Acts/Utilities/CurvilinearUnitVectors.hpp
rename to Core/include/Acts/Utilities/UnitVectors.hpp
index fe5338226306b29ca75e9da3d348148c47b8ab4a..bc502bc18783853e8b90b677bb549757f663b609 100644
--- a/Core/include/Acts/Utilities/CurvilinearUnitVectors.hpp
+++ b/Core/include/Acts/Utilities/UnitVectors.hpp
@@ -8,6 +8,7 @@
 
 #pragma once
 
+#include <cmath>
 #include <limits>
 #include <utility>
 
@@ -15,6 +16,46 @@
 
 namespace Acts {
 
+/// Construct a normalized direction vector from phi angle and pseudorapidity.
+///
+/// @param phi is the direction angle in the x-y plane.
+/// @param eta is the pseudorapidity towards the z-axis.
+///
+/// @note The input arguments intentionally use the same template type so that
+///       a compile error occurs if inconsistent input types are used. Avoids
+///       unexpected implicit type conversions and forces the user to
+///       explicitely cast missmatched input types.
+template <typename T>
+inline ActsVector<T, 3> makeDirectionUnitFromPhiEta(T phi, T eta) {
+  const auto coshEta = std::cosh(eta);
+  const auto tanhEta = std::tanh(eta);
+  return {
+      std::cos(phi) / coshEta,
+      std::sin(phi) / coshEta,
+      std::tanh(eta),
+  };
+}
+
+/// Construct a normalized direction vector from phi and theta angle.
+///
+/// @param phi is the direction angle in radian in the x-y plane.
+/// @param theta is the polar angle in radian towards the z-axis.
+///
+/// @note The input arguments intentionally use the same template type so that
+///       a compile error occurs if inconsistent input types are used. Avoids
+///       unexpected implicit type conversions and forces the user to
+///       explicitely cast missmatched input types.
+template <typename T>
+inline ActsVector<T, 3> makeDirectionUnitFromPhiTheta(T phi, T theta) {
+  const auto cosTheta = std::cos(theta);
+  const auto sinTheta = std::sin(theta);
+  return {
+      std::cos(phi) * sinTheta,
+      std::sin(phi) * sinTheta,
+      cosTheta,
+  };
+}
+
 /// Construct the first curvilinear unit vector `U` for the given direction.
 ///
 /// @param direction is the input direction vector
diff --git a/Fatras/CMakeLists.txt b/Fatras/CMakeLists.txt
index 8939d92b10fb2af7859c1d52af8ae682086ba92b..1390603518bc373a8467524aa28526936f842411 100644
--- a/Fatras/CMakeLists.txt
+++ b/Fatras/CMakeLists.txt
@@ -3,7 +3,8 @@ add_library(
   src/LandauDistribution.cpp
   src/Particle.cpp
   src/ParticleData.cpp
-  src/ProcessType.cpp)
+  src/ProcessType.cpp
+  src/StandardPhysicsLists.cpp)
 target_compile_features(
   ActsFatras
   PUBLIC cxx_std_17)
diff --git a/Fatras/include/ActsFatras/EventData/Barcode.hpp b/Fatras/include/ActsFatras/EventData/Barcode.hpp
index f19e11be3e52901fd499d545537e5c0b0790590a..5a523b72650df7fcfdc16d5dc67b92b5f7e314f6 100644
--- a/Fatras/include/ActsFatras/EventData/Barcode.hpp
+++ b/Fatras/include/ActsFatras/EventData/Barcode.hpp
@@ -40,7 +40,7 @@ namespace ActsFatras {
 /// a consequence is that only non-zero generation can have non-zero
 /// sub-particle numbers. A non-zero generation indicates that the particle
 /// is a descendant of the original particle, e.g. from interactions or decay,
-/// while the sub-particle number identifies the the descendat particle.
+/// while the sub-particle number identifies the descendant particle.
 ///
 /// With this encoding, non-primary particles and their primary parent can
 /// be easily identified at the expense of not storing the exact decay history.
@@ -92,7 +92,12 @@ namespace ActsFatras {
 /// generation to contain unique values. However, this can only be done when all
 /// particles are known.
 class Barcode : public Acts::MultiIndex<uint64_t, 12, 12, 16, 8, 16> {
+  using Base = Acts::MultiIndex<uint64_t, 12, 12, 16, 8, 16>;
+
  public:
+  using Base::Base;
+  using Base::Value;
+
   /// Return the primary vertex identifier.
   constexpr Value vertexPrimary() const { return level(0); }
   /// Return the secondary vertex identifier.
@@ -124,3 +129,13 @@ class Barcode : public Acts::MultiIndex<uint64_t, 12, 12, 16, 8, 16> {
 };
 
 }  // namespace ActsFatras
+
+// specialize std::hash so Barcode can be used e.g. in an unordered_map
+namespace std {
+template <>
+struct hash<ActsFatras::Barcode> {
+  auto operator()(ActsFatras::Barcode barcode) const noexcept {
+    return std::hash<ActsFatras::Barcode::Value>()(barcode.value());
+  }
+};
+}  // namespace std
diff --git a/Fatras/include/ActsFatras/EventData/Hit.hpp b/Fatras/include/ActsFatras/EventData/Hit.hpp
index a4cad6e2bd1b26a72b86f7eec01fc8838bad97fc..95591338e8ac9bd7cb9501bb900c5975659b78b0 100644
--- a/Fatras/include/ActsFatras/EventData/Hit.hpp
+++ b/Fatras/include/ActsFatras/EventData/Hit.hpp
@@ -44,12 +44,12 @@ class Hit {
   /// users responsibility to ensure that the position correspond to a
   /// position on the given surface. The four-vector component order must be
   /// [x,y,z,t] and [px,py,pz,E].
-  template <typename Position4, typename Momentum4>
-  Hit(Acts::GeometryID geoId, Barcode particleId,
+  template <typename Position4, typename Momentum40, typename Momentum41>
+  Hit(Acts::GeometryID geometryId, Barcode particleId,
       const Eigen::MatrixBase<Position4>& pos4,
-      const Eigen::MatrixBase<Momentum4>& before4,
-      const Eigen::MatrixBase<Momentum4>& after4, int32_t index_ = -1)
-      : m_geoId(geoId),
+      const Eigen::MatrixBase<Momentum40>& before4,
+      const Eigen::MatrixBase<Momentum41>& after4, int32_t index_ = -1)
+      : m_geometryId(geometryId),
         m_particleId(particleId),
         m_index(index_),
         m_pos4(pos4),
@@ -61,13 +61,13 @@ class Hit {
   Hit& operator=(Hit&&) = default;
 
   /// Geometry identifier of the hit surface.
-  Acts::GeometryID geoId() const { return m_geoId; }
+  constexpr Acts::GeometryID geometryId() const { return m_geometryId; }
   /// Particle identifier of the particle that generated the hit.
-  Barcode particleId() const { return m_particleId; }
+  constexpr Barcode particleId() const { return m_particleId; }
   /// Hit index along the particle trajectory.
   ///
   /// @retval negative if the hit index is undefined.
-  int32_t index() const { return m_index; }
+  constexpr int32_t index() const { return m_index; }
 
   /// Space-time position four-vector.
   ///
@@ -86,12 +86,14 @@ class Hit {
   ///
   /// The component order is [px,py,pz,E].
   const Vector4& momentum4After() const { return m_after4; }
-  /// Particle direction before the hit.
-  Vector3 directionBefore() const { return m_before4.head<3>().normalized(); }
-  /// Particle direction after the hit.
-  Vector3 directionAfter() const { return m_after4.head<3>().normalized(); }
-  /// Average particle direction while passing through the surface.
-  Vector3 direction() const {
+  /// Normalized particle direction vector before the hit.
+  Vector3 unitDirectionBefore() const {
+    return m_before4.head<3>().normalized();
+  }
+  /// Normalized particle direction vector the hit.
+  Vector3 unitDirectionAfter() const { return m_after4.head<3>().normalized(); }
+  /// Average normalized particle direction vector through the surface.
+  Vector3 unitDirection() const {
     auto dir0 = m_before4 / (2 * m_before4.head<3>().norm());
     auto dir1 = m_after4 / (2 * m_after4.head<3>().norm());
     return (dir0 + dir1).head<3>().normalized();
@@ -100,11 +102,11 @@ class Hit {
   ///
   /// @retval positive if the particle lost energy when it passed the surface
   /// @retval negative if magic was involved
-  Scalar depositedEnergy() const { return m_before4.w() - m_after4.w(); }
+  Scalar depositedEnergy() const { return m_before4[3] - m_after4[3]; }
 
  private:
   /// Identifier of the surface.
-  Acts::GeometryID m_geoId;
+  Acts::GeometryID m_geometryId;
   /// Identifier of the generating particle.
   Barcode m_particleId;
   /// Index of the hit along the particle trajectory.
diff --git a/Fatras/include/ActsFatras/EventData/Particle.hpp b/Fatras/include/ActsFatras/EventData/Particle.hpp
index 498d0e96022a141e21a0973eaec464f1f08e7933..4225500a0535cf771f5450cff8873f2ca53690bd 100644
--- a/Fatras/include/ActsFatras/EventData/Particle.hpp
+++ b/Fatras/include/ActsFatras/EventData/Particle.hpp
@@ -9,6 +9,7 @@
 #pragma once
 
 #include <cmath>
+#include <iosfwd>
 #include <limits>
 
 #include "Acts/Utilities/Definitions.hpp"
@@ -29,27 +30,39 @@ class Particle {
   Particle() = default;
   /// Construct a particle at rest with explicit mass and charge.
   ///
-  /// @param id     Particle identifier within an event
-  /// @param pdg    PDG particle number
+  /// @param particleId Particle identifier within an event
+  /// @param pdg PDG id
   /// @param charge Particle charge in native units
-  /// @param mass   Particle mass in native units
+  /// @param mass Particle mass in native units
   ///
   /// @warning It is the users responsibility that charge and mass match
   ///          the PDG particle number.
-  Particle(Barcode id, Acts::PdgParticle pdg, Scalar charge, Scalar mass)
-      : m_id(id), m_pdg(pdg), m_charge(charge), m_mass(mass) {}
+  Particle(Barcode particleId, Acts::PdgParticle pdg, Scalar charge,
+           Scalar mass)
+      : m_particleId(particleId), m_pdg(pdg), m_charge(charge), m_mass(mass) {}
   /// Construct a particle at rest from a PDG particle number.
   ///
-  /// @param id  Particle identifier within an event
+  /// @param particleId Particle identifier within an event
   /// @param pdg PDG particle number
   ///
   /// Charge and mass are retrieved from the particle data table.
-  Particle(Barcode id, Acts::PdgParticle pdg);
+  Particle(Barcode particleId, Acts::PdgParticle pdg);
   Particle(const Particle &) = default;
   Particle(Particle &&) = default;
   Particle &operator=(const Particle &) = default;
   Particle &operator=(Particle &&) = default;
 
+  /// Construct a new particle with a new identifier but same kinematics.
+  ///
+  /// @note This is intentionally not a regular setter. The particle id
+  ///       is used to identify the whole particle. Setting it on an existing
+  ///       particle is usually a mistake.
+  Particle withParticleId(Barcode particleId) const {
+    Particle p = *this;
+    p.m_particleId = particleId;
+    return p;
+  }
+
   /// Set the process type that generated this particle.
   Particle &setProcess(ProcessType proc) { return m_process = proc, *this; }
   /// Set the space-time position four-vector.
@@ -75,21 +88,21 @@ class Particle {
   }
   /// Set the direction three-vector
   Particle &setDirection(const Vector3 &direction) {
-    m_direction = direction;
-    m_direction.normalize();
+    m_unitDirection = direction;
+    m_unitDirection.normalize();
     return *this;
   }
   /// Set the direction three-vector from scalar components.
   Particle &setDirection(Scalar dx, Scalar dy, Scalar dz) {
-    m_direction[0] = dx;
-    m_direction[1] = dy;
-    m_direction[2] = dz;
-    m_direction.normalize();
+    m_unitDirection[0] = dx;
+    m_unitDirection[1] = dy;
+    m_unitDirection[2] = dz;
+    m_unitDirection.normalize();
     return *this;
   }
   /// Set the absolute momentum.
-  Particle &setMomentum(Scalar momentum) {
-    m_momentum = momentum;
+  Particle &setAbsMomentum(Scalar absMomentum) {
+    m_absMomentum = absMomentum;
     return *this;
   }
   /// Change the energy by the given amount.
@@ -98,30 +111,30 @@ class Particle {
   /// would result in an unphysical value, the particle is put to rest, i.e.
   /// its absolute momentum is set to zero.
   Particle &correctEnergy(Scalar delta) {
-    const auto newEnergy = std::hypot(m_mass, m_momentum) + delta;
+    const auto newEnergy = std::hypot(m_mass, m_absMomentum) + delta;
     if (newEnergy <= m_mass) {
-      m_momentum = Scalar(0);
+      m_absMomentum = Scalar(0);
     } else {
-      m_momentum = std::sqrt(newEnergy * newEnergy - m_mass * m_mass);
+      m_absMomentum = std::sqrt(newEnergy * newEnergy - m_mass * m_mass);
     }
     return *this;
   }
 
   /// Particle identifier within an event.
-  Barcode id() const { return m_id; }
+  constexpr Barcode particleId() const { return m_particleId; }
   /// Which type of process generated this particle.
-  ProcessType process() const { return m_process; }
+  constexpr ProcessType process() const { return m_process; }
   /// PDG particle number that identifies the type.
-  Acts::PdgParticle pdg() const { return m_pdg; }
+  constexpr Acts::PdgParticle pdg() const { return m_pdg; }
   /// Particle charge.
-  Scalar charge() const { return m_charge; }
+  constexpr Scalar charge() const { return m_charge; }
   /// Particle mass.
-  Scalar mass() const { return m_mass; }
+  constexpr Scalar mass() const { return m_mass; }
 
   /// Space-time position four-vector.
   ///
   /// The component order is [x,y,z,t].
-  const Vector4 &position4() const { return m_position4; }
+  constexpr const Vector4 &position4() const { return m_position4; }
   /// Three-position, i.e. spatial coordinates without the time.
   auto position() const { return m_position4.head<3>(); }
   /// Time coordinate.
@@ -132,36 +145,33 @@ class Particle {
   Vector4 momentum4() const {
     Vector4 mom4;
     // stored direction is always normalized
-    mom4[0] = m_momentum * m_direction[0];
-    mom4[1] = m_momentum * m_direction[1];
-    mom4[2] = m_momentum * m_direction[2];
+    mom4[0] = m_absMomentum * m_unitDirection[0];
+    mom4[1] = m_absMomentum * m_unitDirection[1];
+    mom4[2] = m_absMomentum * m_unitDirection[2];
     mom4[3] = energy();
     return mom4;
   }
-  /// Three-direction, i.e. the normalized momentum three-vector.
-  const Vector3 &direction() const { return m_direction; }
+  /// Unit three-direction, i.e. the normalized momentum three-vector.
+  const Vector3 &unitDirection() const { return m_unitDirection; }
+  /// Absolute momentum in the x-y plane.
+  Scalar transverseMomentum() const {
+    return m_absMomentum * m_unitDirection.head<2>().norm();
+  }
   /// Absolute momentum.
-  Scalar momentum() const { return m_momentum; }
+  constexpr Scalar absMomentum() const { return m_absMomentum; }
   /// Total energy, i.e. norm of the four-momentum.
-  Scalar energy() const { return std::hypot(m_mass, m_momentum); }
-
-  /// Charge over absolute momentum.
-  Scalar chargeOverMomentum() const { return m_charge / m_momentum; }
-  /// Relativistic velocity.
-  Scalar beta() const { return m_momentum / energy(); }
-  /// Relativistic gamma factor.
-  Scalar gamma() const { return std::hypot(1, m_momentum / m_mass); }
+  Scalar energy() const { return std::hypot(m_mass, m_absMomentum); }
 
   /// Check if the particle is alive, i.e. is not at rest.
-  operator bool() const { return Scalar(0) < m_momentum; }
+  constexpr operator bool() const { return Scalar(0) < m_absMomentum; }
   /// Check if the particle is dead, i.e is at rest.
-  bool operator!() const { return m_momentum <= Scalar(0); }
+  constexpr bool operator!() const { return m_absMomentum <= Scalar(0); }
 
   /// Set the material that the particle has passed.
   ///
   /// @param pathX0 passed material measured in radiation lengths
   /// @param pathL0 passed thickness measured in interaction lengths
-  Particle &setMaterialPassed(Scalar pathX0, Scalar pathL0) {
+  constexpr Particle &setMaterialPassed(Scalar pathX0, Scalar pathL0) {
     m_pathX0 = pathX0;
     m_pathL0 = pathL0;
     return *this;
@@ -170,24 +180,24 @@ class Particle {
   ///
   /// @param limitX0 maximum radiation lengths the particle can pass
   /// @param limitL0 maximum interaction lengths the particle can pass
-  Particle &setMaterialLimits(Scalar limitX0, Scalar limitL0) {
+  constexpr Particle &setMaterialLimits(Scalar limitX0, Scalar limitL0) {
     m_limitX0 = limitX0;
     m_limitL0 = limitL0;
     return *this;
   }
   /// The passed material measured in radiation lengths.
-  Scalar pathInX0() const { return m_pathX0; }
+  constexpr Scalar pathInX0() const { return m_pathX0; }
   /// The passed material measured in interaction lengths.
-  Scalar pathInL0() const { return m_pathL0; }
+  constexpr Scalar pathInL0() const { return m_pathL0; }
   /// The maximum radation length the particle is allowed to pass.
-  Scalar pathLimitX0() const { return m_limitX0; }
+  constexpr Scalar pathLimitX0() const { return m_limitX0; }
   /// The maximum interaction length the particle is allowed to pass.
-  Scalar pathLimitL0() const { return m_limitL0; }
+  constexpr Scalar pathLimitL0() const { return m_limitL0; }
 
  private:
   // identity, i.e. things that do not change over the particle lifetime.
   /// Particle identifier within the event.
-  Barcode m_id;
+  Barcode m_particleId;
   /// Process type specifier.
   ProcessType m_process = ProcessType::eUndefined;
   /// PDG particle number.
@@ -196,8 +206,8 @@ class Particle {
   Scalar m_charge = Scalar(0);
   Scalar m_mass = Scalar(0);
   // kinematics, i.e. things that change over the particle lifetime.
-  Vector3 m_direction = Vector3::UnitZ();
-  Scalar m_momentum = Scalar(0);
+  Vector3 m_unitDirection = Vector3::UnitZ();
+  Scalar m_absMomentum = Scalar(0);
   Vector4 m_position4 = Vector4::Zero();
   // simulation-specific X0/L0 information and limits
   // these values are here to simplify the simulation of (nuclear) interactions.
@@ -213,4 +223,6 @@ class Particle {
   Scalar m_limitL0 = std::numeric_limits<Scalar>::max();
 };
 
+std::ostream &operator<<(std::ostream &os, const Particle &particle);
+
 }  // namespace ActsFatras
diff --git a/Fatras/include/ActsFatras/Kernel/Interactor.hpp b/Fatras/include/ActsFatras/Kernel/Interactor.hpp
index 5b77374ff5188e00f7d9cc920bcb546bc8f39a93..2d53d75e2aa62f4fa85c2aa4d4d5ef51602caaa8 100644
--- a/Fatras/include/ActsFatras/Kernel/Interactor.hpp
+++ b/Fatras/include/ActsFatras/Kernel/Interactor.hpp
@@ -100,7 +100,7 @@ struct Interactor {
             .setPosition4(stepper.position(state.stepping),
                           stepper.time(state.stepping))
             .setDirection(stepper.direction(state.stepping))
-            .setMomentum(stepper.momentum(state.stepping));
+            .setAbsMomentum(stepper.momentum(state.stepping));
     // we want to keep the particle state before and after the interaction.
     // since the particle is modified in-place we need a copy.
     auto after = before;
@@ -110,7 +110,7 @@ struct Interactor {
       Acts::Vector2D local;
       // TODO what to do in case of invalid return value?
       surface.globalToLocal(state.geoContext, before.position(),
-                            before.direction(), local);
+                            before.unitDirection(), local);
       Acts::MaterialProperties slab =
           surface.surfaceMaterial()->materialProperties(local);
 
@@ -120,7 +120,8 @@ struct Interactor {
         auto normal = surface.normal(state.geoContext, local);
         // dot-product(unit normal, direction) = cos(incidence angle)
         // particle direction is normalized, not sure about surface normal
-        auto cosIncidenceInv = normal.norm() / normal.dot(before.direction());
+        auto cosIncidenceInv =
+            normal.norm() / normal.dot(before.unitDirection());
         slab.scaleThickness(cosIncidenceInv);
         // TODO how to communicate the break condition to the propagator?
         physics(*generator, slab, after, result.generatedParticles);
@@ -143,15 +144,15 @@ struct Interactor {
     result.particle = after;
     if (selectHitSurface(surface)) {
       result.hits.emplace_back(
-          surface.geoID(), before.id(),
+          surface.geoID(), before.particleId(),
           // the interaction could potentially modify the particle position
           Hit::Scalar(0.5) * (before.position4() + after.position4()),
           before.momentum4(), after.momentum4(), result.hits.size());
     }
 
     // continue the propagation with the modified parameters
-    stepper.update(state.stepping, after.position(), after.direction(),
-                   after.momentum(), after.time());
+    stepper.update(state.stepping, after.position(), after.unitDirection(),
+                   after.absMomentum(), after.time());
   }
 
   /// Pure observer interface. Does not apply to the Fatras simulator.
diff --git a/Fatras/include/ActsFatras/Kernel/Simulator.hpp b/Fatras/include/ActsFatras/Kernel/Simulator.hpp
index 5a99b04c26868158cf737097e95aac24ff7481aa..c21849164488fd3b31c6ea7005c5c78ced6129c1 100644
--- a/Fatras/include/ActsFatras/Kernel/Simulator.hpp
+++ b/Fatras/include/ActsFatras/Kernel/Simulator.hpp
@@ -8,173 +8,319 @@
 
 #pragma once
 
-#include <optional>
+#include <algorithm>
+#include <cassert>
+#include <iterator>
+#include <memory>
 
-#include "Acts/EventData/NeutralParameters.hpp"
-#include "Acts/EventData/TrackParameters.hpp"
+#include "Acts/EventData/ChargePolicy.hpp"
+#include "Acts/EventData/SingleCurvilinearTrackParameters.hpp"
+#include "Acts/Geometry/GeometryContext.hpp"
+#include "Acts/MagneticField/MagneticFieldContext.hpp"
 #include "Acts/Propagator/AbortList.hpp"
 #include "Acts/Propagator/ActionList.hpp"
 #include "Acts/Propagator/Propagator.hpp"
 #include "Acts/Propagator/detail/DebugOutputActor.hpp"
 #include "Acts/Propagator/detail/StandardAborters.hpp"
 #include "Acts/Utilities/Logger.hpp"
+#include "Acts/Utilities/Result.hpp"
+#include "ActsFatras/EventData/Hit.hpp"
+#include "ActsFatras/EventData/Particle.hpp"
+#include "ActsFatras/Kernel/Interactor.hpp"
 
 namespace ActsFatras {
 
-struct VoidDetector {};
-
-/// @brief Fatras simulator
-///
-/// This is called from a Fatras steering algorithm
-/// Per call, the generator is provided which
-/// is then used to create a Acts propagator plugin.
+/// Single particle simulator with a fixed propagator and physics list.
 ///
-/// @tparam charged_propagator_t Type of the propagator for charged particles
-/// @tparam charged_selector_t Type of the slector (list) for charged particles
-/// @tparam charged_interactor_t Type of the dresser for chargerd particles
-///
-/// @tparam neutral_propagator_t Type of the propagator for neutral particles
-/// @tparam neutral_selector_t Type of the slector (list) for neutral particles
-/// @tparam neutral_interactor_t Type of the dresser for neutral particles
-template <typename charged_propagator_t, typename charged_selector_t,
-          typename charged_interactor_t, typename neutral_propagator_t,
-          typename neutral_selector_t, typename neutral_interactor_t>
-struct Simulator {
-  Simulator(charged_propagator_t chpropagator, neutral_propagator_t npropagator)
-      : chargedPropagator(std::move(chpropagator)),
-        neutralPropagator(std::move(npropagator)),
-        mlogger(Acts::getDefaultLogger("Simulator", Acts::Logging::INFO)) {}
+/// @tparam propagator_t is the type of the underlying propagator
+/// @tparam physics_list_t is the type of the simulated physics list
+/// @tparam hit_surface_selector_t is the type that selects hit surfaces
+template <typename propagator_t, typename physics_list_t,
+          typename hit_surface_selector_t>
+struct ParticleSimulator {
+  /// How and within which geometry to propagate the particle.
+  propagator_t propagator;
+  /// What should be simulated. Will be copied to the per-call interactor.
+  physics_list_t physics;
+  /// Where hits are registiered. Will be copied to the per-call interactor.
+  hit_surface_selector_t selectHitSurface;
+  /// Local logger for debug output.
+  std::shared_ptr<const Acts::Logger> localLogger = nullptr;
 
-  using PhysicsList_t = typename charged_interactor_t::PhysicsList_t;
-  charged_propagator_t chargedPropagator;
-  charged_selector_t chargedSelector;
-  PhysicsList_t physicsList;
+  /// Construct the simulator with the underlying propagator.
+  ParticleSimulator(propagator_t &&propagator_, Acts::Logging::Level lvl)
+      : propagator(propagator_),
+        localLogger(Acts::getDefaultLogger("Simulator", lvl)) {}
+
+  /// Provide access to the local logger instance, e.g. for logging macros.
+  const Acts::Logger &logger() const { return *localLogger; }
+
+  /// Simulate a single particle without secondaries.
+  ///
+  /// @param geoCtx is the geometry context to access surface geometries
+  /// @param magCtx is the magnetic field context to access field values
+  /// @param generator is the random number generator
+  /// @param particle is the initial particle state
+  /// @returns the result of the corresponding Interactor propagator action.
+  ///
+  /// @tparam generator_t is the type of the random number generator
+  template <typename generator_t>
+  Acts::Result<typename Interactor<generator_t, physics_list_t,
+                                   hit_surface_selector_t>::result_type>
+  simulate(const Acts::GeometryContext &geoCtx,
+           const Acts::MagneticFieldContext &magCtx, generator_t &generator,
+           const Particle &particle) const {
+    assert(localLogger and "Missing local logger");
 
-  neutral_propagator_t neutralPropagator;
-  neutral_selector_t neutralSelector;
+    // propagator-related additional types
+    using Interact =
+        Interactor<generator_t, physics_list_t, hit_surface_selector_t>;
+    using InteractResult = typename Interact::result_type;
+    using Actions = Acts::ActionList<Interact, Acts::detail::DebugOutputActor>;
+    using Abort = Acts::AbortList<Acts::detail::EndOfWorldReached>;
+    using PropagatorOptions = Acts::PropagatorOptions<Actions, Abort>;
 
-  VoidDetector detector;
+    // Construct per-call options.
+    PropagatorOptions options(geoCtx, magCtx);
+    options.absPdgCode = particle.pdg();
+    options.mass = particle.mass();
+    options.debug = localLogger->doPrint(Acts::Logging::Level::DEBUG);
+    // setup the interactor as part of the propagator options
+    auto &interactor = options.actionList.template get<Interact>();
+    interactor.generator = &generator;
+    interactor.physics = physics;
+    interactor.selectHitSurface = selectHitSurface;
+    interactor.particle = particle;
 
-  std::shared_ptr<const Acts::Logger> mlogger = nullptr;
+    // run with a start parameter type depending on the particle charge.
+    // TODO make track parameters consistently constructible regardless
+    //      of the charge policy and template the class on the parameter.
+    if (particle.charge() != 0) {
+      Acts::SingleCurvilinearTrackParameters<Acts::ChargedPolicy> start(
+          std::nullopt, particle.position(),
+          particle.absMomentum() * particle.unitDirection(), particle.charge(),
+          particle.time());
+      auto result = propagator.propagate(start, options);
+      if (result.ok()) {
+        return result.value().template get<InteractResult>();
+      } else {
+        return result.error();
+      }
+    } else {
+      Acts::SingleCurvilinearTrackParameters<Acts::NeutralPolicy> start(
+          std::nullopt, particle.position(),
+          particle.absMomentum() * particle.unitDirection(), particle.time());
+      auto result = propagator.propagate(start, options);
+      if (result.ok()) {
+        return result.value().template get<InteractResult>();
+      } else {
+        return result.error();
+      }
+    }
+  }
+};
 
-  bool debug = false;
+/// Fatras multi-particle simulator.
+///
+/// @tparam charged_selector_t Callable selector type for charged particles
+/// @tparam charged_simulator_t Single particle simulator for charged particles
+/// @tparam neutral_selector_t Callable selector type for neutral particles
+/// @tparam neutral_simulator_t Single particle simulator for neutral particles
+template <typename charged_selector_t, typename charged_simulator_t,
+          typename neutral_selector_t, typename neutral_simulator_t>
+struct Simulator {
+  charged_selector_t selectCharged;
+  neutral_selector_t selectNeutral;
+  charged_simulator_t charged;
+  neutral_simulator_t neutral;
 
-  /// Private access to the logging instance
-  const Acts::Logger &logger() const { return *mlogger; }
+  /// Construct from the single charged/neutral particle simulators.
+  Simulator(charged_simulator_t &&charged_, neutral_simulator_t &&neutral_)
+      : charged(std::move(charged_)), neutral(std::move(neutral_)) {}
 
-  /// @brief call operator to the simulator
+  /// Simulate multiple particles and generated secondaries.
   ///
-  /// @tparam context_t Type pf the context object
-  /// @tparam generator_t Type of the generator object
-  /// @tparam event_collection_t Type of the event collection
-  /// @tparam hit_collection_t Type of the hit collection, needs insert()
+  /// @param geoCtx is the geometry context to access surface geometries
+  /// @param magCtx is the magnetic field context to access field values
+  /// @param inputParticles contains all particles that should be simulated
+  /// @param simulatedParticlesInitial contains initial particle states
+  /// @param simulatedParticlesFinal contains final particle states
+  /// @param hits contains all generated hits
   ///
-  /// @param fatrasContext is the event-bound context
-  /// @param fatrasGenerator is the event-bound random generator
-  /// @param fatrasEvent is the truth event collection
-  /// @param fatrasHits is the hit collection
-  template <typename context_t, typename generator_t,
-            typename event_collection_t, typename hit_collection_t>
-  void operator()(context_t &fatrasContext, generator_t &fatrasGenerator,
-                  event_collection_t &fatrasEvent,
-                  hit_collection_t &fatrasHits) const {
-    // if screen output is required
-    typedef Acts::detail::DebugOutputActor DebugOutput;
-
-    // Action list, abort list and options
-    typedef Acts::ActionList<charged_interactor_t, DebugOutput>
-        ChargedActionList;
-    typedef Acts::AbortList<Acts::detail::EndOfWorldReached> ChargedAbortList;
-    typedef Acts::PropagatorOptions<ChargedActionList, ChargedAbortList>
-        ChargedOptions;
-
-    // Action list, abort list and
-    typedef Acts::ActionList<neutral_interactor_t, DebugOutput>
-        NeutralActionList;
-    typedef Acts::AbortList<Acts::detail::EndOfWorldReached> NeutralAbortList;
-    typedef Acts::PropagatorOptions<NeutralActionList, NeutralAbortList>
-        NeutralOptions;
-
-    // loop over the input events
-    // -> new secondaries will just be attached to that
-    for (auto &vertex : fatrasEvent) {
-      // take care here, the simulation can change the
-      // particle collection
-      for (std::size_t i = 0; i < vertex.outgoing.size(); i++) {
-        // create a local copy since the collection can reallocate and
-        // invalidate any reference.
-        auto particle = vertex.outgoing[i];
-        // charged particle detected and selected
-        if (chargedSelector(detector, particle)) {
-          // Need to construct them per call to set the particle
-          // Options and configuration
-          ChargedOptions chargedOptions(fatrasContext.geoContext,
-                                        fatrasContext.magFieldContext);
-          chargedOptions.debug = debug;
-          // Get the charged interactor
-          auto &chargedInteractor =
-              chargedOptions.actionList.template get<charged_interactor_t>();
-          // Result type typedef
-          typedef typename charged_interactor_t::result_type ChargedResult;
-          // Set the generator to guarantee event consistent entires
-          chargedInteractor.generator = &fatrasGenerator;
-          // Put all the additional information into the interactor
-          chargedInteractor.initialParticle = particle;
-          // Set the physics list
-          chargedInteractor.physicsList = physicsList;
-          // Create the kinematic start parameters
-          Acts::CurvilinearParameters start(std::nullopt, particle.position(),
-                                            particle.momentum(), particle.q(),
-                                            particle.time());
-          // Run the simulation
-          const auto &result =
-              chargedPropagator.propagate(start, chargedOptions).value();
-          const auto &fatrasResult = result.template get<ChargedResult>();
-          // a) Handle the hits
-          // hits go to the hit collection, particle go to the particle
-          // collection
-          for (const auto &fHit : fatrasResult.simulatedHits) {
-            fatrasHits.insert(fHit);
-          }
-          // b) deal with the particles
-          const auto &simparticles = fatrasResult.outgoing;
-          vertex.outgoing_insert(simparticles);
-          // c) screen output if requested
-          if (debug) {
-            auto &fatrasDebug = result.template get<DebugOutput::result_type>();
-            ACTS_INFO(fatrasDebug.debugString);
+  /// This takes all input particles and simulates those passing the selection
+  /// using the appropriate simulator. All selected particle states including
+  /// additional ones generated from interactions are stored in separate output
+  /// containers; both the initial state at the production vertex and the final
+  /// state after propagation are stored. Hits generated from selected input and
+  /// generated particles are stored in the hit container.
+  ///
+  /// @warning Particle-hit association is based on particle ids generated
+  ///          during the simulation. This requires that all input particles
+  ///          **must** have generation and sub-particle number set to zero.
+  ///
+  /// @tparam generator_t is the type of the random number generator
+  /// @tparam input_particles_t is a Container for particles
+  /// @tparam output_particles_t is a SequenceContainer for particles
+  /// @tparam hits_t is a SequenceContainer for hits
+  template <typename generator_t, typename input_particles_t,
+            typename output_particles_t, typename hits_t>
+  Acts::Result<void> simulate(const Acts::GeometryContext &geoCtx,
+                              const Acts::MagneticFieldContext &magCtx,
+                              generator_t &generator,
+                              const input_particles_t &inputParticles,
+                              output_particles_t &simulatedParticlesInitial,
+                              output_particles_t &simulatedParticlesFinal,
+                              hits_t &hits) const {
+    assert(
+        (simulatedParticlesInitial.size() == simulatedParticlesFinal.size()) and
+        "Inconsistent initial sizes of the simulated particle containers");
+
+    for (const Particle &inputParticle : inputParticles) {
+      if (not selectParticle(inputParticle)) {
+        continue;
+      }
+      // required to allow correct particle id numbering for secondaries later
+      if ((inputParticle.particleId().generation() != 0u) or
+          (inputParticle.particleId().subParticle() != 0u)) {
+        // TODO add meaningfull error code
+        return std::error_code(-1, std::generic_category());
+      }
+
+      // Do a *depth-first* simulation of the particle and its secondaries,
+      // i.e. we simulate all secondaries, tertiaries, ... before simulating
+      // the next primary particle. Use the end of the output container as
+      // a queue to store particles that should be simulated.
+      //
+      // WARNING the initial particle output container will be modified during
+      //         iteration as new secondaries are added directly to it. to avoid
+      //         issues, access must always occur via indices.
+      auto iinitial = simulatedParticlesInitial.size();
+      simulatedParticlesInitial.push_back(inputParticle);
+      for (; iinitial < simulatedParticlesInitial.size(); ++iinitial) {
+        // only simulatable particles are pushed to the container
+        // no additional check is necessary here.
+        const auto &initialParticle = simulatedParticlesInitial[iinitial];
+
+        if (selectCharged(initialParticle)) {
+          auto result =
+              charged.simulate(geoCtx, magCtx, generator, initialParticle);
+          if (not result.ok()) {
+            // do not keep unsimulated/ failed particles in the output
+            simulatedParticlesInitial.resize(iinitial);
+            return result.error();
           }
-        } else if (neutralSelector(detector, particle)) {
-          // Options and configuration
-          NeutralOptions neutralOptions(fatrasContext.geoContext,
-                                        fatrasContext.magFieldContext);
-          neutralOptions.debug = debug;
-          // Get the charged interactor
-          auto &neutralInteractor =
-              neutralOptions.actionList.template get<neutral_interactor_t>();
-          // Result type typedef
-          typedef typename neutral_interactor_t::result_type NeutralResult;
-          // Set the generator to guarantee event consistent entires
-          neutralInteractor.generator = &fatrasGenerator;
-          // Put all the additional information into the interactor
-          neutralInteractor.initialParticle = particle;
-          // Create the kinematic start parameters
-          Acts::NeutralCurvilinearParameters start(
-              std::nullopt, particle.position(), particle.momentum(), 0.);
-          const auto &result =
-              neutralPropagator.propagate(start, neutralOptions).value();
-          auto &fatrasResult = result.template get<NeutralResult>();
-          // a) deal with the particles
-          const auto &simparticles = fatrasResult.outgoing;
-          vertex.outgoing_insert(simparticles);
-          // b) screen output if requested
-          if (debug) {
-            auto &fatrasDebug = result.template get<DebugOutput::result_type>();
-            ACTS_INFO(fatrasDebug.debugString);
+          copyOutputs(result.value(), simulatedParticlesInitial,
+                      simulatedParticlesFinal, hits);
+        } else if (selectNeutral(initialParticle)) {
+          auto result =
+              neutral.simulate(geoCtx, magCtx, generator, initialParticle);
+          if (not result.ok()) {
+            // do not keep unsimulated/ failed particles in the output
+            simulatedParticlesInitial.resize(iinitial);
+            return result.error();
           }
-        }  // neutral processing
-      }    // loop over particles
-    }      // loop over events
+          copyOutputs(result.value(), simulatedParticlesInitial,
+                      simulatedParticlesFinal, hits);
+        }
+
+        // since physics processes are independent, there can be particle id
+        // collisions within the generated secondaries. they can be resolved by
+        // renumbering within each sub-particle generation. this must happen
+        // before the particle is simulated since the particle id is used to
+        // associate generated hits back to the particle.
+        renumberTailParticleIds(simulatedParticlesInitial, iinitial);
+      }
+    }
+
+    return Acts::Result<void>::success();
+  }
+
+ private:
+  // Select if the particle should be simulated at all.
+  //
+  // This also enforces mutual-exclusivity of the two charge selections. If both
+  // charge selections evaluate true, they are probably not setup correctly and
+  // not simulating them at all is a reasonable fall-back.
+  bool selectParticle(const Particle &particle) const {
+    const bool isValidCharged = selectCharged(particle);
+    const bool isValidNeutral = selectNeutral(particle);
+    assert(not(isValidCharged and isValidNeutral) and
+           "Charge selectors are not mutually exclusive");
+    return isValidCharged xor isValidNeutral;
+  }
+
+  /// Copy Interactor results to output containers.
+  ///
+  /// @tparam interactor_result_t is an Interactor result struct
+  /// @tparam particles_t is a SequenceContainer for particles
+  /// @tparam hits_t is a SequenceContainer for hits
+  template <typename interactor_result_t, typename particles_t, typename hits_t>
+  void copyOutputs(const interactor_result_t &result,
+                   particles_t &particlesInitial, particles_t &particlesFinal,
+                   hits_t &hits) const {
+    // initial particle state was already pushed to the container before
+    // store final particle state at the end of the simulation
+    particlesFinal.push_back(result.particle);
+    // move generated secondaries that should be simulated to the output
+    std::copy_if(
+        result.generatedParticles.begin(), result.generatedParticles.end(),
+        std::back_inserter(particlesInitial),
+        [this](const Particle &particle) { return selectParticle(particle); });
+    std::copy(result.hits.begin(), result.hits.end(), std::back_inserter(hits));
+  }
+
+  /// Renumber particle ids in the tail of the container.
+  ///
+  /// Ensures particle ids are unique by modifying the sub-particle number
+  /// within each generation.
+  ///
+  /// @param particles particle container in which particles are renumbered
+  /// @param lastValid index of the last particle with a valid particle id
+  ///
+  /// @tparam particles_t is a SequenceContainer for particles
+  ///
+  /// @note This function assumes that all particles in the tail have the same
+  ///       vertex numbers (primary/secondary) and particle number and are
+  ///       ordered according to their generation number.
+  ///
+  template <typename particles_t>
+  static void renumberTailParticleIds(particles_t &particles,
+                                      std::size_t lastValid) {
+    // iterate over adjacent pairs; potentially modify the second element.
+    // assume e.g. a primary particle 2 with generation=subparticle=0 that
+    // generates two secondaries during simulation. we have the following
+    // ids (decoded as vertex|particle|generation|subparticle)
+    //
+    //     v|2|0|0, v|2|1|0, v|2|1|0
+    //
+    // where v represents the vertex numbers. this will be renumbered to
+    //
+    //     v|2|0|0, v|2|1|0, v|2|1|1
+    //
+    // if each secondary then generates two tertiaries we could have e.g.
+    //
+    //     v|2|0|0, v|2|1|0, v|2|1|1, v|2|2|0, v|2|2|1, v|2|2|0, v|2|2|1
+    //
+    // which would then be renumbered to
+    //
+    //     v|2|0|0, v|2|1|0, v|2|1|1, v|2|2|0, v|2|2|1, v|2|2|2, v|2|2|3
+    //
+    for (auto j = lastValid; (j + 1u) < particles.size(); ++j) {
+      const auto prevId = particles[j].particleId();
+      auto currId = particles[j + 1u].particleId();
+      // NOTE primary/secondary vertex and particle are assumed to be equal
+      // only renumber within one generation
+      if (prevId.generation() != currId.generation()) {
+        continue;
+      }
+      // ensure the sub-particle is strictly monotonic within a generation
+      if (prevId.subParticle() < currId.subParticle()) {
+        continue;
+      }
+      // sub-particle numbering must be non-zero
+      currId.setSubParticle(prevId.subParticle() + 1u);
+      particles[j + 1u] = particles[j + 1u].withParticleId(currId);
+    }
   }
 };
 
diff --git a/Fatras/include/ActsFatras/Physics/EnergyLoss/BetheBloch.hpp b/Fatras/include/ActsFatras/Physics/EnergyLoss/BetheBloch.hpp
index 8424547bdc115dbbb48870f600d59750f8622a32..07966800826c11ad8692b4c6d1b286197e41d721 100644
--- a/Fatras/include/ActsFatras/Physics/EnergyLoss/BetheBloch.hpp
+++ b/Fatras/include/ActsFatras/Physics/EnergyLoss/BetheBloch.hpp
@@ -43,7 +43,7 @@ struct BetheBloch {
     // compute energy loss distribution parameters
     const auto pdg = particle.pdg();
     const auto m = particle.mass();
-    const auto qOverP = particle.chargeOverMomentum();
+    const auto qOverP = particle.charge() / particle.absMomentum();
     const auto q = particle.charge();
     // most probable value
     const auto energyLoss =
diff --git a/Fatras/include/ActsFatras/Physics/Scattering/GaussianMixture.hpp b/Fatras/include/ActsFatras/Physics/Scattering/GaussianMixture.hpp
index ceed5c179c5840f57104e2dbc76c8c4a303390da..a8078fbeeeb169a4c412bbe6cd836e229c2c3a7d 100644
--- a/Fatras/include/ActsFatras/Physics/Scattering/GaussianMixture.hpp
+++ b/Fatras/include/ActsFatras/Physics/Scattering/GaussianMixture.hpp
@@ -11,10 +11,10 @@
 #include <random>
 
 #include "Acts/Material/Interactions.hpp"
-#include "Acts/Material/MaterialProperties.hpp"
-#include "ActsFatras/EventData/Particle.hpp"
+#include "ActsFatras/Physics/Scattering/detail/Scattering.hpp"
 
 namespace ActsFatras {
+namespace detail {
 
 /// Generate scattering angles using a Gaussian mixture model.
 struct GaussianMixture {
@@ -44,8 +44,8 @@ struct GaussianMixture {
                     Particle &particle) const {
     /// Calculate the highland formula first
     double sigma = Acts::computeMultipleScatteringTheta0(
-        slab, particle.pdg(), particle.mass(), particle.chargeOverMomentum(),
-        particle.charge());
+        slab, particle.pdg(), particle.mass(),
+        particle.charge() / particle.absMomentum(), particle.charge());
     double sigma2 = sigma * sigma;
 
     // Gauss distribution, will be sampled with generator
@@ -55,8 +55,11 @@ struct GaussianMixture {
 
     // Now correct for the tail fraction
     // d_0'
-    double beta2 = particle.beta() * particle.beta();
-    double dprime = slab.thickness() / (slab.material().X0() * beta2);
+    // beta² = (p/E)² = p²/(p² + m²) = 1/(1 + (m/p)²)
+    // 1/beta² = 1 + (m/p)²
+    double mOverP = particle.mass() / particle.absMomentum();
+    double beta2inv = 1 + mOverP * mOverP;
+    double dprime = slab.thicknessInX0() * beta2inv;
     double log_dprime = std::log(dprime);
     // d_0''
     double log_dprimeprime =
@@ -76,7 +79,8 @@ struct GaussianMixture {
 
     // G4 optimised / native double Gaussian model
     if (optGaussianMixtureG4) {
-      sigma2 = 225. * dprime / (particle.momentum() * particle.momentum());
+      sigma2 =
+          225. * dprime / (particle.absMomentum() * particle.absMomentum());
     }
     // throw the random number core/tail
     if (uniformDist(generator) < epsilon) {
@@ -87,4 +91,8 @@ struct GaussianMixture {
   }
 };
 
+}  // namespace detail
+
+using GaussianMixtureScattering = detail::Scattering<detail::GaussianMixture>;
+
 }  // namespace ActsFatras
diff --git a/Fatras/include/ActsFatras/Physics/Scattering/GeneralMixture.hpp b/Fatras/include/ActsFatras/Physics/Scattering/GeneralMixture.hpp
index 022b897a463554bcd536c1fe8463680a5ed859dd..a3df2f254c4931cfde30384cb2e3f760e9e8b11d 100644
--- a/Fatras/include/ActsFatras/Physics/Scattering/GeneralMixture.hpp
+++ b/Fatras/include/ActsFatras/Physics/Scattering/GeneralMixture.hpp
@@ -11,11 +11,11 @@
 #include <random>
 
 #include "Acts/Material/Interactions.hpp"
-#include "Acts/Material/MaterialProperties.hpp"
 #include "Acts/Utilities/PdgParticle.hpp"
-#include "ActsFatras/EventData/Particle.hpp"
+#include "ActsFatras/Physics/Scattering/detail/Scattering.hpp"
 
 namespace ActsFatras {
+namespace detail {
 
 /// Generate scattering angles using a general mixture model.
 ///
@@ -53,17 +53,22 @@ struct GeneralMixture {
       //----------------------------------------------------------------------------
       std::array<double, 4> scattering_params;
       // Decide which mixture is best
-      double beta2 = (particle.beta() * particle.beta());
+      //   beta² = (p/E)² = p²/(p² + m²) = 1/(1 + (m/p)²)
+      // 1/beta² = 1 + (m/p)²
+      //    beta = 1/sqrt(1 + (m/p)²)
+      double mOverP = particle.mass() / particle.absMomentum();
+      double beta2Inv = 1 + mOverP * mOverP;
+      double beta = 1 / std::sqrt(beta2Inv);
       double tInX0 = slab.thicknessInX0();
-      double tob2 = slab.thicknessInX0() / beta2;
+      double tob2 = tInX0 * beta2Inv;
       if (tob2 > 0.6 / std::pow(slab.material().Z(), 0.6)) {
         // Gaussian mixture or pure Gaussian
         if (tob2 > 10) {
-          scattering_params = getGaussian(particle.beta(), particle.momentum(),
-                                          tInX0, genMixtureScalor);
+          scattering_params = getGaussian(beta, particle.absMomentum(), tInX0,
+                                          genMixtureScalor);
         } else {
           scattering_params =
-              getGaussmix(particle.beta(), particle.momentum(), tInX0,
+              getGaussmix(beta, particle.absMomentum(), tInX0,
                           slab.material().Z(), genMixtureScalor);
         }
         // Simulate
@@ -71,7 +76,7 @@ struct GeneralMixture {
       } else {
         // Semigaussian mixture - get parameters
         auto scattering_params_sg =
-            getSemigauss(particle.beta(), particle.momentum(), tInX0,
+            getSemigauss(beta, particle.absMomentum(), tInX0,
                          slab.material().Z(), genMixtureScalor);
         // Simulate
         theta = semigauss(generator, scattering_params_sg);
@@ -80,8 +85,8 @@ struct GeneralMixture {
       // for electrons we fall back to the Highland (extension)
       // return projection factor times sigma times gauss random
       const auto theta0 = Acts::computeMultipleScatteringTheta0(
-          slab, particle.pdg(), particle.mass(), particle.chargeOverMomentum(),
-          particle.charge());
+          slab, particle.pdg(), particle.mass(),
+          particle.charge() / particle.absMomentum(), particle.charge());
       theta = std::normal_distribution<double>(0.0, theta0)(generator);
     }
     // scale from planar to 3d angle
@@ -195,4 +200,8 @@ struct GeneralMixture {
   }
 };
 
+}  // namespace detail
+
+using GeneralMixtureScattering = detail::Scattering<detail::GeneralMixture>;
+
 }  // namespace ActsFatras
diff --git a/Fatras/include/ActsFatras/Physics/Scattering/Highland.hpp b/Fatras/include/ActsFatras/Physics/Scattering/Highland.hpp
index 211f2045c8ad3c680fb197e514cada4af3cf4aa2..b593327466327e8a6e23da4d4e5cb7cd5f6ecc46 100644
--- a/Fatras/include/ActsFatras/Physics/Scattering/Highland.hpp
+++ b/Fatras/include/ActsFatras/Physics/Scattering/Highland.hpp
@@ -11,10 +11,10 @@
 #include <random>
 
 #include "Acts/Material/Interactions.hpp"
-#include "Acts/Material/MaterialProperties.hpp"
-#include "ActsFatras/EventData/Particle.hpp"
+#include "ActsFatras/Physics/Scattering/detail/Scattering.hpp"
 
 namespace ActsFatras {
+namespace detail {
 
 /// Generate scattering angles using the Highland/PDG parametrization.
 ///
@@ -35,11 +35,15 @@ struct Highland {
                     Particle &particle) const {
     // compute the planar scattering angle
     const auto theta0 = Acts::computeMultipleScatteringTheta0(
-        slab, particle.pdg(), particle.mass(), particle.chargeOverMomentum(),
-        particle.charge());
+        slab, particle.pdg(), particle.mass(),
+        particle.charge() / particle.absMomentum(), particle.charge());
     // draw from the normal distribution representing the 3d angle distribution
     return std::normal_distribution<double>(0.0, M_SQRT2 * theta0)(generator);
   }
 };
 
+}  // namespace detail
+
+using HighlandScattering = detail::Scattering<detail::Highland>;
+
 }  // namespace ActsFatras
diff --git a/Fatras/include/ActsFatras/Physics/Scattering/Scattering.hpp b/Fatras/include/ActsFatras/Physics/Scattering/detail/Scattering.hpp
similarity index 95%
rename from Fatras/include/ActsFatras/Physics/Scattering/Scattering.hpp
rename to Fatras/include/ActsFatras/Physics/Scattering/detail/Scattering.hpp
index b5fc3bd4fb19a512844c2b5600f3e339beea3005..04d1e51cb69c2a1c2eaf70ceabfeee36d70f4806 100644
--- a/Fatras/include/ActsFatras/Physics/Scattering/Scattering.hpp
+++ b/Fatras/include/ActsFatras/Physics/Scattering/detail/Scattering.hpp
@@ -12,11 +12,12 @@
 #include <random>
 
 #include "Acts/Material/MaterialProperties.hpp"
-#include "Acts/Utilities/CurvilinearUnitVectors.hpp"
 #include "Acts/Utilities/Definitions.hpp"
+#include "Acts/Utilities/UnitVectors.hpp"
 #include "ActsFatras/EventData/Particle.hpp"
 
 namespace ActsFatras {
+namespace detail {
 
 /// Simulate (multiple) scattering using a configurable scattering model.
 ///
@@ -54,7 +55,7 @@ struct Scattering {
     // draw the scattering angle
     const auto theta = angle(generator, slab, particle);
 
-    Acts::Vector3D direction = particle.direction();
+    Acts::Vector3D direction = particle.unitDirection();
     // construct the combined rotation to the scattered direction
     Acts::RotationMatrix3D rotation(
         // rotation of the scattering deflector axis relative to the reference
@@ -69,4 +70,5 @@ struct Scattering {
   }
 };
 
+}  // namespace detail
 }  // namespace ActsFatras
diff --git a/Fatras/include/ActsFatras/Physics/StandardPhysicsLists.hpp b/Fatras/include/ActsFatras/Physics/StandardPhysicsLists.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..ce8867bd7fb0b0f18d5fad66481a05bcfab5951a
--- /dev/null
+++ b/Fatras/include/ActsFatras/Physics/StandardPhysicsLists.hpp
@@ -0,0 +1,66 @@
+// This file is part of the Acts project.
+//
+// Copyright (C) 2020 CERN for the benefit of the Acts project
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+#pragma once
+
+#include "ActsFatras/Kernel/PhysicsList.hpp"
+#include "ActsFatras/Kernel/Process.hpp"
+#include "ActsFatras/Physics/EnergyLoss/BetheBloch.hpp"
+#include "ActsFatras/Physics/EnergyLoss/BetheHeitler.hpp"
+#include "ActsFatras/Physics/Scattering/Highland.hpp"
+#include "ActsFatras/Selectors/KinematicCasts.hpp"
+#include "ActsFatras/Selectors/PdgSelectors.hpp"
+#include "ActsFatras/Selectors/SelectorHelpers.hpp"
+
+namespace ActsFatras {
+namespace detail {
+/// Select electrons and positrons only.
+using SelectElectronLike = AbsPdgSelector<Acts::PdgParticle::eElectron>;
+/// Select particles above a minimum absolute momentum.
+using SelectPMin = Min<Casts::P>;
+
+/// Highland multiple scattering that applies everywhere.
+using StandardScattering =
+    Process<HighlandScattering, EveryInput, EveryParticle, EveryParticle>;
+/// Ionisation/excitation energy loss with a lower p cut on output particles.
+///
+/// Bethe-Bloch generates no particles and the child selector has no effect.
+using StandardBetheBloch =
+    Process<BetheBloch, EveryInput, SelectPMin, EveryParticle>;
+/// Electron Bremsstrahlung energy loss with a lower p cut on output particles.
+///
+/// Only applies to electrons and positions. Bethe-Heitler generates no
+/// particles and the child selector has no effect.
+using StandardBetheHeitler =
+    Process<BetheHeitler, AsInputSelector<SelectElectronLike>, SelectPMin,
+            EveryParticle>;
+}  // namespace detail
+
+/// Electro-magnetic interactions for charged particles.
+///
+/// Scattering must come first so it is computed with the unmodified initial
+/// energy before energy loss is applied.
+///
+/// @warning The list has no cuts on input particle charge or kinematics, i.e.
+///          it relies on the simulator to preselect relevant charged particles
+///          before application.
+/// @todo Bethe-Bloch does not describe electrons; add correct ionisation loss
+///       descriptions for electrons.
+/// @todo Bethe-Heitler is applied after energy loss and thus sees the wrong
+///       input energy.
+using ChargedElectroMagneticPhysicsList =
+    PhysicsList<detail::StandardScattering, detail::StandardBetheBloch,
+                detail::StandardBetheHeitler>;
+
+/// Construct the standard electro-magnetic physics list for charged particles.
+///
+/// @param minimumAbsMomentum lower p cut on output particles
+ChargedElectroMagneticPhysicsList makeChargedElectroMagneticPhysicsList(
+    double minimumAbsMomentum);
+
+}  // namespace ActsFatras
diff --git a/Fatras/include/ActsFatras/Selectors/KinematicCasts.hpp b/Fatras/include/ActsFatras/Selectors/KinematicCasts.hpp
index 6229f607600821b6b1e2ae36528b44ab217877ca..67b3dd7051b387180f2de44c28fbd0bd76beda17 100644
--- a/Fatras/include/ActsFatras/Selectors/KinematicCasts.hpp
+++ b/Fatras/include/ActsFatras/Selectors/KinematicCasts.hpp
@@ -40,7 +40,7 @@ struct AbsVz {
 struct Eta {
   double operator()(const Particle& particle) const {
     // particle direction is always normalized, i.e. dz = pz / p
-    return std::atanh(particle.direction().z());
+    return std::atanh(particle.unitDirection().z());
   }
 };
 
@@ -48,7 +48,7 @@ struct Eta {
 struct AbsEta {
   double operator()(const Particle& particle) const {
     // particle direction is always normalized, i.e. dz = pz / p
-    return std::atanh(std::abs(particle.direction().z()));
+    return std::atanh(std::abs(particle.unitDirection().z()));
   }
 };
 
@@ -56,15 +56,15 @@ struct AbsEta {
 struct Pt {
   double operator()(const Particle& particle) const {
     // particle direction is always normalized, i.e. dt²+dz²=1 w/ dt²=dx²+dy²
-    return particle.momentum() *
-           std::hypot(particle.direction().x(), particle.direction().y());
+    return particle.absMomentum() * std::hypot(particle.unitDirection().x(),
+                                               particle.unitDirection().y());
   }
 };
 
 /// Retrieve the absolute momentum.
 struct P {
   double operator()(const Particle& particle) const {
-    return particle.momentum();
+    return particle.absMomentum();
   }
 };
 
diff --git a/Fatras/include/ActsFatras/Selectors/PdgSelectors.hpp b/Fatras/include/ActsFatras/Selectors/PdgSelectors.hpp
index 2c120c2d3054a91a41406bb481a526a83a4d4943..dbe045be358830864cbb3b0351512d23e172343f 100644
--- a/Fatras/include/ActsFatras/Selectors/PdgSelectors.hpp
+++ b/Fatras/include/ActsFatras/Selectors/PdgSelectors.hpp
@@ -8,8 +8,7 @@
 
 #pragma once
 
-#include <cstdlib>
-
+#include "Acts/Utilities/PdgParticle.hpp"
 #include "ActsFatras/EventData/Particle.hpp"
 
 namespace ActsFatras {
@@ -17,36 +16,38 @@ namespace ActsFatras {
 /// Select particles of one specific type.
 ///
 /// Particle and Antiparticle are treated as two separate types.
-template <int Pdg>
+template <Acts::PdgParticle Pdg>
 struct PdgSelector {
   bool operator()(const Particle &particle) const {
-    return (static_cast<int>(particle.pdg()) == Pdg);
+    return (particle.pdg() == Pdg);
   }
 };
 
 /// Select particles and antiparticles of one specific type.
-template <int Pdg>
+template <Acts::PdgParticle Pdg>
 struct AbsPdgSelector {
   bool operator()(const Particle &particle) const {
-    return (std::abs(static_cast<int>(particle.pdg())) == std::abs(Pdg));
+    return (makeAbsolutePdgParticle(particle.pdg()) ==
+            makeAbsolutePdgParticle(Pdg));
   }
 };
 
 /// Select all particles except one specific type.
 ///
 /// Particle and Antiparticle are treated as two separate types.
-template <int Pdg>
+template <Acts::PdgParticle Pdg>
 struct PdgExcluder {
   bool operator()(const Particle &particle) const {
-    return (static_cast<int>(particle.pdg()) != Pdg);
+    return (particle.pdg() != Pdg);
   }
 };
 
 /// Select all particles except for (anti-)particles of one specific type.
-template <int Pdg>
+template <Acts::PdgParticle Pdg>
 struct AbsPdgExcluder {
   bool operator()(const Particle &particle) const {
-    return (std::abs(static_cast<int>(particle.pdg())) != std::abs(Pdg));
+    return (makeAbsolutePdgParticle(particle.pdg()) !=
+            makeAbsolutePdgParticle(Pdg));
   }
 };
 
diff --git a/Fatras/src/Particle.cpp b/Fatras/src/Particle.cpp
index 58f91adf5a5b912691d388572927b58db01eada0..34c3283cc8109a4ffc7deb9008b993d6e00d10c6 100644
--- a/Fatras/src/Particle.cpp
+++ b/Fatras/src/Particle.cpp
@@ -10,5 +10,15 @@
 
 #include "ActsFatras/Utilities/ParticleData.hpp"
 
-ActsFatras::Particle::Particle(Barcode id, Acts::PdgParticle pdg)
-    : Particle(id, pdg, findCharge(pdg), findMass(pdg)) {}
+ActsFatras::Particle::Particle(Barcode particleId, Acts::PdgParticle pdg)
+    : Particle(particleId, pdg, findCharge(pdg), findMass(pdg)) {}
+
+std::ostream& ActsFatras::operator<<(std::ostream& os,
+                                     const ActsFatras::Particle& particle) {
+  os << "particle_id=" << particle.particleId();
+  os << " process=" << particle.process();
+  os << " pdg=" << particle.pdg();
+  os << " q=" << particle.charge();
+  os << " m=" << particle.mass();
+  return os;
+}
diff --git a/Fatras/src/StandardPhysicsLists.cpp b/Fatras/src/StandardPhysicsLists.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..044f1008bf157cf15282df975919fa21310ac9b4
--- /dev/null
+++ b/Fatras/src/StandardPhysicsLists.cpp
@@ -0,0 +1,19 @@
+// This file is part of the Acts project.
+//
+// Copyright (C) 2020 CERN for the benefit of the Acts project
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+#include "ActsFatras/Physics/StandardPhysicsLists.hpp"
+
+ActsFatras::ChargedElectroMagneticPhysicsList
+ActsFatras::makeChargedElectroMagneticPhysicsList(double minimumAbsMomentum) {
+  ChargedElectroMagneticPhysicsList pl;
+  pl.get<detail::StandardBetheBloch>().selectOutputParticle.valMin =
+      minimumAbsMomentum;
+  pl.get<detail::StandardBetheHeitler>().selectOutputParticle.valMin =
+      minimumAbsMomentum;
+  return pl;
+}
diff --git a/Tests/IntegrationTests/CMakeLists.txt b/Tests/IntegrationTests/CMakeLists.txt
index 8e8b0f55a42b001c8aa543f45d15ddae27bcf035..3198633b9a9a1258e08603ba0ca12e1ca81113b4 100644
--- a/Tests/IntegrationTests/CMakeLists.txt
+++ b/Tests/IntegrationTests/CMakeLists.txt
@@ -34,4 +34,5 @@ add_integrationtest(BVHNavigation BVHNavigationTest.cpp)
 add_integrationtest(InterpolatedSolenoidBField InterpolatedSolenoidBFieldTest.cpp)
 add_integrationtest(Propagation PropagationTests.cpp)
 
+add_subdirectory_if(Fatras ACTS_BUILD_FATRAS)
 add_subdirectory_if(Legacy ACTS_BUILD_LEGACY)
diff --git a/Tests/IntegrationTests/Fatras/CMakeLists.txt b/Tests/IntegrationTests/Fatras/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..584bb46836e2364ee5a339e12a389d5c53bf8559
--- /dev/null
+++ b/Tests/IntegrationTests/Fatras/CMakeLists.txt
@@ -0,0 +1,3 @@
+set(integrationtest_extra_libraries ActsFatras)
+
+add_integrationtest(FatrasSimulation FatrasSimulationTests.cpp)
diff --git a/Tests/IntegrationTests/Fatras/FatrasSimulationTests.cpp b/Tests/IntegrationTests/Fatras/FatrasSimulationTests.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0b7c41a299b442855c4c5a2f538a3bf6f773b0c1
--- /dev/null
+++ b/Tests/IntegrationTests/Fatras/FatrasSimulationTests.cpp
@@ -0,0 +1,238 @@
+// This file is part of the Acts project.
+//
+// Copyright (C) 2020 CERN for the benefit of the Acts project
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+#include <algorithm>
+#include <boost/test/data/test_case.hpp>
+#include <boost/test/unit_test.hpp>
+#include <random>
+
+#include "Acts/EventData/NeutralParameters.hpp"
+#include "Acts/EventData/TrackParameters.hpp"
+#include "Acts/MagneticField/ConstantBField.hpp"
+#include "Acts/Propagator/EigenStepper.hpp"
+#include "Acts/Propagator/Navigator.hpp"
+#include "Acts/Propagator/StraightLineStepper.hpp"
+#include "Acts/Tests/CommonHelpers/CylindricalTrackingGeometry.hpp"
+#include "Acts/Utilities/UnitVectors.hpp"
+#include "ActsFatras/Kernel/PhysicsList.hpp"
+#include "ActsFatras/Kernel/Simulator.hpp"
+#include "ActsFatras/Physics/StandardPhysicsLists.hpp"
+#include "ActsFatras/Selectors/ChargeSelectors.hpp"
+#include "ActsFatras/Utilities/ParticleData.hpp"
+
+using namespace Acts::UnitLiterals;
+
+namespace {
+
+/// Mock-up process that splits a particle into two above a momentum threshold.
+struct SplitEnergyLoss {
+  double splitMomentumMin = 5_GeV;
+
+  template <typename generator_t>
+  bool operator()(generator_t&, const Acts::MaterialProperties&,
+                  ActsFatras::Particle& particle,
+                  std::vector<ActsFatras::Particle>& generated) const {
+    const auto p = particle.absMomentum();
+    if (splitMomentumMin < p) {
+      particle.setAbsMomentum(0.5 * p);
+      const auto pid = particle.particleId().makeDescendant();
+      generated.push_back(particle.withParticleId(pid).setAbsMomentum(0.5 * p));
+    }
+    // never break
+    return false;
+  }
+};
+
+// setup propagator-related types
+// use the default navigation
+using Navigator = Acts::Navigator;
+using MagneticField = Acts::ConstantBField;
+// propagate charged particles numerically in a constant magnetic field
+using ChargedStepper = Acts::EigenStepper<MagneticField>;
+using ChargedPropagator = Acts::Propagator<ChargedStepper, Navigator>;
+// propagate neutral particles with just straight lines
+using NeutralStepper = Acts::StraightLineStepper;
+using NeutralPropagator = Acts::Propagator<NeutralStepper, Navigator>;
+
+// setup simulator-related types
+// the random number generator type
+using Generator = std::ranlux48;
+// all charged particles w/ a mock-up physics list and hits everywhere
+using ChargedSelector = ActsFatras::ChargedSelector;
+using ChargedPhysicsList =
+    ActsFatras::PhysicsList<ActsFatras::detail::StandardScattering,
+                            SplitEnergyLoss>;
+using ChargedSimulator =
+    ActsFatras::ParticleSimulator<ChargedPropagator, ChargedPhysicsList,
+                                  ActsFatras::EverySurface>;
+// all neutral particles w/o physics and no hits
+using NeutralSelector = ActsFatras::NeutralSelector;
+using NeutralSimulator =
+    ActsFatras::ParticleSimulator<NeutralPropagator, ActsFatras::PhysicsList<>,
+                                  ActsFatras::NoSurface>;
+// full simulator type for charged and neutrals
+using Simulator = ActsFatras::Simulator<ChargedSelector, ChargedSimulator,
+                                        NeutralSelector, NeutralSimulator>;
+
+// parameters for data-driven test cases
+
+const auto rangePdg = boost::unit_test::data::make({
+    Acts::PdgParticle::eElectron,
+    Acts::PdgParticle::eMuon,
+    Acts::PdgParticle::ePionPlus,
+    Acts::PdgParticle::ePionZero,
+});
+const auto rangePhi = boost::unit_test::data::make({
+    -135_degree,
+    -45_degree,
+    45_degree,
+    135_degree,
+});
+const auto rangeEta = boost::unit_test::data::make({
+    -1.0,
+    0.0,
+    1.0,
+    3.0,
+});
+const auto rangeP = boost::unit_test::data::make({
+    1_GeV,
+    10_GeV,
+});
+const auto rangeNumParticles = boost::unit_test::data::make({
+    1,
+    2,
+});
+
+const auto dataset =
+    rangePdg * rangePhi * rangeEta * rangeP * rangeNumParticles;
+
+// helper functions for tests
+template <typename Container>
+void sortByParticleId(Container& container) {
+  std::sort(container.begin(), container.end(),
+            [](const auto& lhs, const auto& rhs) {
+              return lhs.particleId() < rhs.particleId();
+            });
+}
+template <typename Container>
+bool areParticleIdsUnique(const Container& sortedByParticleId) {
+  // assumes the container is sorted by particle id
+  auto ret =
+      std::adjacent_find(sortedByParticleId.begin(), sortedByParticleId.end(),
+                         [](const auto& lhs, const auto& rhs) {
+                           return lhs.particleId() == rhs.particleId();
+                         });
+  return ret == sortedByParticleId.end();
+}
+template <typename Container, typename Value>
+bool containsParticleId(const Container& sortedByParticleId,
+                        const Value& value) {
+  return std::binary_search(sortedByParticleId.begin(),
+                            sortedByParticleId.end(), value,
+                            [](const auto& lhs, const auto& rhs) {
+                              return lhs.particleId() < rhs.particleId();
+                            });
+}
+
+}  // namespace
+
+BOOST_DATA_TEST_CASE(FatrasSimulation, dataset, pdg, phi, eta, p,
+                     numParticles) {
+  using namespace Acts::UnitLiterals;
+
+  Acts::GeometryContext geoCtx;
+  Acts::MagneticFieldContext magCtx;
+  Acts::Logging::Level logLevel = Acts::Logging::Level::DEBUG;
+
+  // construct the example detector
+  Acts::Test::CylindricalTrackingGeometry geoBuilder(geoCtx);
+  auto trackingGeometry = geoBuilder();
+
+  // construct the propagators
+  Navigator navigator(trackingGeometry);
+  ChargedStepper chargedStepper(Acts::ConstantBField(0, 0, 1_T));
+  ChargedPropagator chargedPropagator(std::move(chargedStepper), navigator);
+  NeutralPropagator neutralPropagator(NeutralStepper(), navigator);
+
+  // construct the simulator
+  ChargedSimulator simulatorCharged(std::move(chargedPropagator), logLevel);
+  NeutralSimulator simulatorNeutral(std::move(neutralPropagator), logLevel);
+  Simulator simulator(std::move(simulatorCharged), std::move(simulatorNeutral));
+
+  // prepare simulation call parameters
+  // random number generator
+  Generator generator;
+  // input/ output particle and hits containers
+  std::vector<ActsFatras::Particle> input;
+  std::vector<ActsFatras::Particle> simulatedInitial;
+  std::vector<ActsFatras::Particle> simulatedFinal;
+  std::vector<ActsFatras::Hit> hits;
+
+  // create input particles. particle number should ne non-zero.
+  for (auto i = numParticles; 0 < i; --i) {
+    const auto pid = ActsFatras::Barcode().setVertexPrimary(42).setParticle(i);
+    const auto particle =
+        ActsFatras::Particle(pid, pdg)
+            .setDirection(Acts::makeDirectionUnitFromPhiEta(phi, eta))
+            .setAbsMomentum(p);
+    input.push_back(std::move(particle));
+  }
+  BOOST_TEST_INFO(input.front());
+  BOOST_TEST(input.size() == numParticles);
+
+  // run the simulation
+  auto result = simulator.simulate(geoCtx, magCtx, generator, input,
+                                   simulatedInitial, simulatedFinal, hits);
+
+  // should always succeed
+  BOOST_TEST(result.ok());
+
+  // ensure simulated particle containers have consistent content
+  BOOST_TEST(simulatedInitial.size() == simulatedFinal.size());
+  for (std::size_t i = 0; i < simulatedInitial.size(); ++i) {
+    const auto& initialParticle = simulatedInitial[i];
+    const auto& finalParticle = simulatedFinal[i];
+    // particle identify should not change during simulation
+    BOOST_TEST(initialParticle.particleId() == finalParticle.particleId());
+    BOOST_TEST(initialParticle.process() == finalParticle.process());
+    BOOST_TEST(initialParticle.pdg() == finalParticle.pdg());
+    BOOST_TEST(initialParticle.charge() == finalParticle.charge());
+    BOOST_TEST(initialParticle.mass() == finalParticle.mass());
+  }
+
+  // we have no particle cuts and should not loose any particles.
+  // might end up with more due to secondaries
+  BOOST_TEST(input.size() <= simulatedInitial.size());
+  BOOST_TEST(input.size() <= simulatedFinal.size());
+  // there should be some hits if we started with a charged particle
+  if (ActsFatras::findCharge(pdg) != 0) {
+    BOOST_TEST(0u < hits.size());
+  }
+
+  // sort all outputs by particle id to simply further tests
+  sortByParticleId(input);
+  sortByParticleId(simulatedInitial);
+  sortByParticleId(simulatedFinal);
+  sortByParticleId(hits);
+
+  // check that all particle ids are unique
+  BOOST_TEST(areParticleIdsUnique(input));
+  BOOST_TEST(areParticleIdsUnique(simulatedInitial));
+  BOOST_TEST(areParticleIdsUnique(simulatedFinal));
+  // hits must necessarily contain particle id duplicates
+  // check that every input particles is simulated
+  for (const auto& particle : input) {
+    BOOST_TEST(containsParticleId(simulatedInitial, particle));
+    BOOST_TEST(containsParticleId(simulatedFinal, particle));
+  }
+  // check that all hits can be associated to a particle
+  for (const auto& hit : hits) {
+    BOOST_TEST(containsParticleId(simulatedInitial, hit));
+    BOOST_TEST(containsParticleId(simulatedFinal, hit));
+  }
+}
diff --git a/Tests/UnitTests/Core/Utilities/CMakeLists.txt b/Tests/UnitTests/Core/Utilities/CMakeLists.txt
index 4ac191617214774cba59999c7b5eb343c542a923..5462c9b65ee126e349d0e4fda653fa90253a77c3 100644
--- a/Tests/UnitTests/Core/Utilities/CMakeLists.txt
+++ b/Tests/UnitTests/Core/Utilities/CMakeLists.txt
@@ -4,7 +4,6 @@ add_unittest(BFieldMapUtilsTests BFieldMapUtilsTests.cpp)
 add_unittest(BinningDataTests BinningDataTests.cpp)
 add_unittest(BinUtilityTests BinUtilityTests.cpp)
 add_unittest(BoundingBoxTest BoundingBoxTest.cpp)
-add_unittest(CurvilinearUnitVectors CurvilinearUnitVectorsTests.cpp)
 add_unittest(ExtendableTests ExtendableTests.cpp)
 add_unittest(FiniteStateMachineTests FiniteStateMachineTests.cpp)
 add_unittest(FrustumTest FrustumTest.cpp)
@@ -21,4 +20,5 @@ add_unittest(RealQuadraticEquationTests RealQuadraticEquationTests.cpp)
 add_unittest(ResultTests ResultTests.cpp)
 add_unittest(TypeTraitsTest TypeTraitsTest.cpp)
 add_unittest(UnitConversionTests UnitConversionTests.cpp)
+add_unittest(UnitVectors UnitVectorsTests.cpp)
 add_unittest(VisualizationTests VisualizationTests.cpp)
diff --git a/Tests/UnitTests/Core/Utilities/CurvilinearUnitVectorsTests.cpp b/Tests/UnitTests/Core/Utilities/CurvilinearUnitVectorsTests.cpp
deleted file mode 100644
index 55b4b9d0413cdfb7df4728acfedec17522ba96c2..0000000000000000000000000000000000000000
--- a/Tests/UnitTests/Core/Utilities/CurvilinearUnitVectorsTests.cpp
+++ /dev/null
@@ -1,71 +0,0 @@
-// This file is part of the Acts project.
-//
-// Copyright (C) 2020 CERN for the benefit of the Acts project
-//
-// This Source Code Form is subject to the terms of the Mozilla Public
-// License, v. 2.0. If a copy of the MPL was not distributed with this
-// file, You can obtain one at http://mozilla.org/MPL/2.0/.
-
-#include <boost/test/unit_test.hpp>
-
-#include <limits>
-
-#include "Acts/Tests/CommonHelpers/FloatComparisons.hpp"
-#include "Acts/Utilities/CurvilinearUnitVectors.hpp"
-
-using Acts::Vector3D;
-
-namespace {
-constexpr auto eps = std::numeric_limits<double>::epsilon();
-
-template <typename Direction, typename RefUnitU, typename RefUnitV>
-void test(const Eigen::MatrixBase<Direction>& direction,
-          const Eigen::MatrixBase<RefUnitU>& refU,
-          const Eigen::MatrixBase<RefUnitV>& refV) {
-  const auto u = Acts::makeCurvilinearUnitU(direction);
-  const auto uv = Acts::makeCurvilinearUnitVectors(direction);
-  // verify normalization
-  CHECK_CLOSE_ABS(u.norm(), 1, eps);
-  CHECK_CLOSE_ABS(uv.first.norm(), 1, eps);
-  CHECK_CLOSE_ABS(uv.second.norm(), 1, eps);
-  // verify orthonormality
-  CHECK_SMALL(u.dot(direction), eps);
-  CHECK_SMALL(uv.first.dot(direction), eps);
-  CHECK_SMALL(uv.second.dot(direction), eps);
-  CHECK_SMALL(uv.first.dot(uv.second), eps);
-  // verify u is in the x-y plane
-  CHECK_SMALL(u[2], eps);
-  CHECK_SMALL(uv.first[2], eps);
-  // verify references. do not use element-wise comparison to avoid issues with
-  // small, epsilon-like differences.
-  CHECK_CLOSE_ABS(u.dot(refU), 1, eps);
-  CHECK_CLOSE_ABS(uv.first.dot(refU), 1, eps);
-  CHECK_CLOSE_ABS(uv.second.dot(refV), 1, eps);
-}
-}  // namespace
-
-BOOST_AUTO_TEST_SUITE(CurvilinearUnitVectors)
-
-BOOST_AUTO_TEST_CASE(DirectionTransverse) {
-  // curvilinear system w/ direction in the transverse plane
-  test(Vector3D(1, 1, 0), Vector3D(-1, 1, 0).normalized(),
-       Vector3D(0, 0, 1).normalized());
-}
-
-BOOST_AUTO_TEST_CASE(DirectionPositiveZ) {
-  // curvilinear system w/ direction along z
-  test(Vector3D(0, 0, 1), Vector3D(1, 0, 0), Vector3D(0, 1, 0));
-}
-
-BOOST_AUTO_TEST_CASE(DirectionNegativeZ) {
-  // curvilinear system w/ direction along z
-  test(Vector3D(0, 0, -1), Vector3D(1, 0, 0), Vector3D(0, -1, 0));
-}
-
-BOOST_AUTO_TEST_CASE(DirectionCloseToZ) {
-  // curvilinear system w/ direction close to z
-  test(Vector3D(0, 32 * eps, 1 - 32 * eps), Vector3D(-1, 0, 0),
-       Vector3D(0, -1, 32 * eps));
-}
-
-BOOST_AUTO_TEST_SUITE_END()
diff --git a/Tests/UnitTests/Core/Utilities/UnitVectorsTests.cpp b/Tests/UnitTests/Core/Utilities/UnitVectorsTests.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..86beb0610f09bf537005a47277c3054216de17bc
--- /dev/null
+++ b/Tests/UnitTests/Core/Utilities/UnitVectorsTests.cpp
@@ -0,0 +1,198 @@
+// This file is part of the Acts project.
+//
+// Copyright (C) 2020 CERN for the benefit of the Acts project
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+#include <boost/test/unit_test.hpp>
+
+#include <limits>
+
+#include "Acts/Tests/CommonHelpers/FloatComparisons.hpp"
+#include "Acts/Utilities/UnitVectors.hpp"
+
+using Acts::Vector3D;
+
+namespace {
+constexpr auto eps = std::numeric_limits<double>::epsilon();
+}
+
+BOOST_AUTO_TEST_SUITE(UnitVectors)
+
+BOOST_AUTO_TEST_CASE(DirectionPhiEta) {
+  using Acts::makeDirectionUnitFromPhiEta;
+
+  // along positive x
+  const auto xPos1 = makeDirectionUnitFromPhiEta(0.0, 0.0);
+  CHECK_CLOSE_REL(xPos1.norm(), 1, eps);
+  CHECK_CLOSE_REL(xPos1.dot(Vector3D(1, 0, 0)), 1, eps);
+  const auto xPos2 = makeDirectionUnitFromPhiEta(2 * M_PI, 0.0);
+  CHECK_CLOSE_REL(xPos2.norm(), 1, eps);
+  CHECK_CLOSE_REL(xPos2.dot(Vector3D(1, 0, 0)), 1, eps);
+  // along negative x
+  const auto xNeg1 = makeDirectionUnitFromPhiEta(M_PI, 0.0);
+  CHECK_CLOSE_REL(xNeg1.norm(), 1, eps);
+  CHECK_CLOSE_REL(xNeg1.dot(Vector3D(-1, 0, 0)), 1, eps);
+  const auto xNeg2 = makeDirectionUnitFromPhiEta(-M_PI, 0.0);
+  CHECK_CLOSE_REL(xNeg2.norm(), 1, eps);
+  CHECK_CLOSE_REL(xNeg2.dot(Vector3D(-1, 0, 0)), 1, eps);
+  // along positive y
+  const auto yPos1 = makeDirectionUnitFromPhiEta(M_PI_2, 0.0);
+  CHECK_CLOSE_REL(yPos1.norm(), 1, eps);
+  CHECK_CLOSE_REL(yPos1.dot(Vector3D(0, 1, 0)), 1, eps);
+  const auto yPos2 = makeDirectionUnitFromPhiEta(-3 * M_PI_2, 0.0);
+  CHECK_CLOSE_REL(yPos2.norm(), 1, eps);
+  CHECK_CLOSE_REL(yPos2.dot(Vector3D(0, 1, 0)), 1, eps);
+  // along negative y
+  const auto yNeg1 = makeDirectionUnitFromPhiEta(-M_PI_2, 0.0);
+  CHECK_CLOSE_REL(yNeg1.norm(), 1, eps);
+  CHECK_CLOSE_REL(yNeg1.dot(Vector3D(0, -1, 0)), 1, eps);
+  const auto yNeg2 = makeDirectionUnitFromPhiEta(3 * M_PI_2, 0.0);
+  CHECK_CLOSE_REL(yNeg2.norm(), 1, eps);
+  CHECK_CLOSE_REL(yNeg2.dot(Vector3D(0, -1, 0)), 1, eps);
+
+  const auto inf = std::numeric_limits<double>::infinity();
+  // along positive z
+  const auto zPos1 = makeDirectionUnitFromPhiEta(0.0, inf);
+  CHECK_CLOSE_REL(zPos1.norm(), 1, eps);
+  CHECK_CLOSE_REL(zPos1.dot(Vector3D(0, 0, 1)), 1, eps);
+  const auto zPos2 = makeDirectionUnitFromPhiEta(M_PI_2, inf);
+  CHECK_CLOSE_REL(zPos2.norm(), 1, eps);
+  CHECK_CLOSE_REL(zPos2.dot(Vector3D(0, 0, 1)), 1, eps);
+  // along negative z
+  const auto zNeg1 = makeDirectionUnitFromPhiEta(0.0, -inf);
+  CHECK_CLOSE_REL(zNeg1.norm(), 1, eps);
+  CHECK_CLOSE_REL(zNeg1.dot(Vector3D(0, 0, -1)), 1, eps);
+  const auto zNeg2 = makeDirectionUnitFromPhiEta(M_PI_2, -inf);
+  CHECK_CLOSE_REL(zNeg2.norm(), 1, eps);
+  CHECK_CLOSE_REL(zNeg2.dot(Vector3D(0, 0, -1)), 1, eps);
+
+  // mixed direction
+  const auto mixed1 = makeDirectionUnitFromPhiEta(M_PI_4, 1.0);
+  CHECK_CLOSE_REL(mixed1.norm(), 1, eps);
+  CHECK_CLOSE_REL(
+      mixed1.dot(Vector3D(1, 1, M_SQRT2 * std::sinh(1.0)).normalized()), 1,
+      eps);
+  const auto mixed2 = makeDirectionUnitFromPhiEta(M_PI_4, -1.0);
+  CHECK_CLOSE_REL(mixed2.norm(), 1, eps);
+  CHECK_CLOSE_REL(
+      mixed2.dot(Vector3D(1, 1, M_SQRT2 * std::sinh(-1.0)).normalized()), 1,
+      eps);
+  const auto mixed3 = makeDirectionUnitFromPhiEta(-M_PI_4, -1.0);
+  CHECK_CLOSE_REL(mixed3.norm(), 1, eps);
+  CHECK_CLOSE_REL(
+      mixed3.dot(Vector3D(1, -1, M_SQRT2 * std::sinh(-1.0)).normalized()), 1,
+      eps);
+}
+
+BOOST_AUTO_TEST_CASE(DirectionPhiTheta) {
+  using Acts::makeDirectionUnitFromPhiTheta;
+
+  // along positive x
+  const auto xPos1 = makeDirectionUnitFromPhiTheta(0.0, M_PI_2);
+  CHECK_CLOSE_REL(xPos1.norm(), 1, eps);
+  CHECK_CLOSE_REL(xPos1.dot(Vector3D(1, 0, 0)), 1, eps);
+  const auto xPos2 = makeDirectionUnitFromPhiTheta(2 * M_PI, M_PI_2);
+  CHECK_CLOSE_REL(xPos2.norm(), 1, eps);
+  CHECK_CLOSE_REL(xPos2.dot(Vector3D(1, 0, 0)), 1, eps);
+  // along negative x
+  const auto xNeg1 = makeDirectionUnitFromPhiTheta(M_PI, M_PI_2);
+  CHECK_CLOSE_REL(xNeg1.norm(), 1, eps);
+  CHECK_CLOSE_REL(xNeg1.dot(Vector3D(-1, 0, 0)), 1, eps);
+  const auto xNeg2 = makeDirectionUnitFromPhiTheta(-M_PI, M_PI_2);
+  CHECK_CLOSE_REL(xNeg2.norm(), 1, eps);
+  CHECK_CLOSE_REL(xNeg2.dot(Vector3D(-1, 0, 0)), 1, eps);
+  // along positive y
+  const auto yPos1 = makeDirectionUnitFromPhiTheta(M_PI_2, M_PI_2);
+  CHECK_CLOSE_REL(yPos1.norm(), 1, eps);
+  CHECK_CLOSE_REL(yPos1.dot(Vector3D(0, 1, 0)), 1, eps);
+  const auto yPos2 = makeDirectionUnitFromPhiTheta(-3 * M_PI_2, M_PI_2);
+  CHECK_CLOSE_REL(yPos2.norm(), 1, eps);
+  CHECK_CLOSE_REL(yPos2.dot(Vector3D(0, 1, 0)), 1, eps);
+  // along negative y
+  const auto yNeg1 = makeDirectionUnitFromPhiTheta(-M_PI_2, M_PI_2);
+  CHECK_CLOSE_REL(yNeg1.norm(), 1, eps);
+  CHECK_CLOSE_REL(yNeg1.dot(Vector3D(0, -1, 0)), 1, eps);
+  const auto yNeg2 = makeDirectionUnitFromPhiTheta(3 * M_PI_2, M_PI_2);
+  CHECK_CLOSE_REL(yNeg2.norm(), 1, eps);
+  CHECK_CLOSE_REL(yNeg2.dot(Vector3D(0, -1, 0)), 1, eps);
+
+  // along positive z
+  const auto zPos1 = makeDirectionUnitFromPhiTheta(0.0, 0.0);
+  CHECK_CLOSE_REL(zPos1.norm(), 1, eps);
+  CHECK_CLOSE_REL(zPos1.dot(Vector3D(0, 0, 1)), 1, eps);
+  const auto zPos2 = makeDirectionUnitFromPhiTheta(M_PI_2, 0.0);
+  CHECK_CLOSE_REL(zPos2.norm(), 1, eps);
+  CHECK_CLOSE_REL(zPos2.dot(Vector3D(0, 0, 1)), 1, eps);
+  // along negative z
+  const auto zNeg1 = makeDirectionUnitFromPhiTheta(0.0, M_PI);
+  CHECK_CLOSE_REL(zNeg1.norm(), 1, eps);
+  CHECK_CLOSE_REL(zNeg1.dot(Vector3D(0, 0, -1)), 1, eps);
+  const auto zNeg2 = makeDirectionUnitFromPhiTheta(M_PI_2, M_PI);
+  CHECK_CLOSE_REL(zNeg2.norm(), 1, eps);
+  CHECK_CLOSE_REL(zNeg2.dot(Vector3D(0, 0, -1)), 1, eps);
+
+  // mixed direction
+  const auto mixed1 = makeDirectionUnitFromPhiTheta(M_PI_4, M_PI_4);
+  CHECK_CLOSE_REL(mixed1.norm(), 1, eps);
+  CHECK_CLOSE_REL(mixed1.dot(Vector3D(1, 1, M_SQRT2).normalized()), 1, eps);
+  const auto mixed2 = makeDirectionUnitFromPhiTheta(M_PI_4, 3 * M_PI_4);
+  CHECK_CLOSE_REL(mixed2.norm(), 1, eps);
+  CHECK_CLOSE_REL(mixed2.dot(Vector3D(1, 1, -M_SQRT2).normalized()), 1, eps);
+  const auto mixed3 = makeDirectionUnitFromPhiTheta(-M_PI_4, 3 * M_PI_4);
+  CHECK_CLOSE_REL(mixed3.norm(), 1, eps);
+  CHECK_CLOSE_REL(mixed3.dot(Vector3D(1, -1, -M_SQRT2).normalized()), 1, eps);
+}
+
+namespace {
+template <typename Direction, typename RefUnitU, typename RefUnitV>
+void testCurvilinear(const Eigen::MatrixBase<Direction>& direction,
+                     const Eigen::MatrixBase<RefUnitU>& refU,
+                     const Eigen::MatrixBase<RefUnitV>& refV) {
+  const auto u = Acts::makeCurvilinearUnitU(direction);
+  const auto uv = Acts::makeCurvilinearUnitVectors(direction);
+  // verify normalization
+  CHECK_CLOSE_ABS(u.norm(), 1, eps);
+  CHECK_CLOSE_ABS(uv.first.norm(), 1, eps);
+  CHECK_CLOSE_ABS(uv.second.norm(), 1, eps);
+  // verify orthonormality
+  CHECK_SMALL(u.dot(direction), eps);
+  CHECK_SMALL(uv.first.dot(direction), eps);
+  CHECK_SMALL(uv.second.dot(direction), eps);
+  CHECK_SMALL(uv.first.dot(uv.second), eps);
+  // verify u is in the x-y plane
+  CHECK_SMALL(u[2], eps);
+  CHECK_SMALL(uv.first[2], eps);
+  // verify references. do not use element-wise comparison to avoid issues with
+  // small, epsilon-like differences.
+  CHECK_CLOSE_ABS(u.dot(refU), 1, eps);
+  CHECK_CLOSE_ABS(uv.first.dot(refU), 1, eps);
+  CHECK_CLOSE_ABS(uv.second.dot(refV), 1, eps);
+}
+}  // namespace
+
+BOOST_AUTO_TEST_CASE(CurvilinearTransverse) {
+  // curvilinear system w/ direction in the transverse plane
+  testCurvilinear(Vector3D(1, 1, 0), Vector3D(-1, 1, 0).normalized(),
+                  Vector3D(0, 0, 1).normalized());
+}
+
+BOOST_AUTO_TEST_CASE(CurvilinearPositiveZ) {
+  // curvilinear system w/ direction along z
+  testCurvilinear(Vector3D(0, 0, 1), Vector3D(1, 0, 0), Vector3D(0, 1, 0));
+}
+
+BOOST_AUTO_TEST_CASE(CurvilinearNegativeZ) {
+  // curvilinear system w/ direction along z
+  testCurvilinear(Vector3D(0, 0, -1), Vector3D(1, 0, 0), Vector3D(0, -1, 0));
+}
+
+BOOST_AUTO_TEST_CASE(CurvilinearCloseToZ) {
+  // curvilinear system w/ direction close to z
+  testCurvilinear(Vector3D(0, 32 * eps, 1 - 32 * eps), Vector3D(-1, 0, 0),
+                  Vector3D(0, -1, 32 * eps));
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/Tests/UnitTests/Fatras/EventData/HitTests.cpp b/Tests/UnitTests/Fatras/EventData/HitTests.cpp
index 988a5e63ddc3b2fd7c573d75f7d1fcccaa9d1a1e..f9cee9a6f918362ff631e96d5ce06cb882c0136d 100644
--- a/Tests/UnitTests/Fatras/EventData/HitTests.cpp
+++ b/Tests/UnitTests/Fatras/EventData/HitTests.cpp
@@ -30,7 +30,7 @@ BOOST_AUTO_TEST_CASE(WithoutInteraction) {
   auto m4 = Hit::Vector4(1, 1, 1, 4);
   auto h = Hit(gid, pid, p4, m4, m4, 12u);
 
-  BOOST_TEST(h.geoId() == gid);
+  BOOST_TEST(h.geometryId() == gid);
   BOOST_TEST(h.particleId() == pid);
   BOOST_TEST(h.index() == 12u);
   CHECK_CLOSE_REL(h.position4(), p4, eps);
@@ -38,9 +38,11 @@ BOOST_AUTO_TEST_CASE(WithoutInteraction) {
   CHECK_CLOSE_REL(h.time(), 4, eps);
   CHECK_CLOSE_REL(h.momentum4Before(), m4, eps);
   CHECK_CLOSE_REL(h.momentum4After(), m4, eps);
-  CHECK_CLOSE_REL(h.directionBefore(), Hit::Vector3(1, 1, 1).normalized(), eps);
-  CHECK_CLOSE_REL(h.directionAfter(), Hit::Vector3(1, 1, 1).normalized(), eps);
-  CHECK_CLOSE_REL(h.direction(), Hit::Vector3(1, 1, 1).normalized(), eps);
+  CHECK_CLOSE_REL(h.unitDirectionBefore(), Hit::Vector3(1, 1, 1).normalized(),
+                  eps);
+  CHECK_CLOSE_REL(h.unitDirectionAfter(), Hit::Vector3(1, 1, 1).normalized(),
+                  eps);
+  CHECK_CLOSE_REL(h.unitDirection(), Hit::Vector3(1, 1, 1).normalized(), eps);
   CHECK_SMALL(h.depositedEnergy(), eps);
 }
 
@@ -52,7 +54,7 @@ BOOST_AUTO_TEST_CASE(WithEnergyLoss) {
   auto m41 = Hit::Vector4(1.5, 0, 0, 1.5);
   auto h = Hit(gid, pid, p4, m40, m41, 13u);
 
-  BOOST_TEST(h.geoId() == gid);
+  BOOST_TEST(h.geometryId() == gid);
   BOOST_TEST(h.particleId() == pid);
   BOOST_TEST(h.index() == 13u);
   CHECK_CLOSE_REL(h.position4(), p4, eps);
@@ -60,9 +62,10 @@ BOOST_AUTO_TEST_CASE(WithEnergyLoss) {
   CHECK_CLOSE_REL(h.time(), 4, eps);
   CHECK_CLOSE_OR_SMALL(h.momentum4Before(), m40, eps, eps);
   CHECK_CLOSE_OR_SMALL(h.momentum4After(), m41, eps, eps);
-  CHECK_CLOSE_OR_SMALL(h.directionBefore(), Hit::Vector3(1, 0, 0), eps, eps);
-  CHECK_CLOSE_OR_SMALL(h.directionAfter(), Hit::Vector3(1, 0, 0), eps, eps);
-  CHECK_CLOSE_OR_SMALL(h.direction(), Hit::Vector3(1, 0, 0), eps, eps);
+  CHECK_CLOSE_OR_SMALL(h.unitDirectionBefore(), Hit::Vector3(1, 0, 0), eps,
+                       eps);
+  CHECK_CLOSE_OR_SMALL(h.unitDirectionAfter(), Hit::Vector3(1, 0, 0), eps, eps);
+  CHECK_CLOSE_OR_SMALL(h.unitDirection(), Hit::Vector3(1, 0, 0), eps, eps);
   CHECK_CLOSE_REL(h.depositedEnergy(), 0.5, eps);
 }
 
@@ -74,7 +77,7 @@ BOOST_AUTO_TEST_CASE(WithScattering) {
   auto m41 = Hit::Vector4(0, -2, 2, 5);
   auto h = Hit(gid, pid, p4, m40, m41, 42u);
 
-  BOOST_TEST(h.geoId() == gid);
+  BOOST_TEST(h.geometryId() == gid);
   BOOST_TEST(h.particleId() == pid);
   BOOST_TEST(h.index() == 42u);
   CHECK_CLOSE_REL(h.position4(), p4, eps);
@@ -82,11 +85,11 @@ BOOST_AUTO_TEST_CASE(WithScattering) {
   CHECK_CLOSE_REL(h.time(), 4, eps);
   CHECK_CLOSE_OR_SMALL(h.momentum4Before(), m40, eps, eps);
   CHECK_CLOSE_OR_SMALL(h.momentum4After(), m41, eps, eps);
-  CHECK_CLOSE_OR_SMALL(h.directionBefore(), Hit::Vector3(1, 0, 1).normalized(),
-                       eps, eps);
-  CHECK_CLOSE_OR_SMALL(h.directionAfter(), Hit::Vector3(0, -1, 1).normalized(),
-                       eps, eps);
-  CHECK_CLOSE_REL(h.direction(), Hit::Vector3(1, -1, 2).normalized(), eps);
+  CHECK_CLOSE_OR_SMALL(h.unitDirectionBefore(),
+                       Hit::Vector3(1, 0, 1).normalized(), eps, eps);
+  CHECK_CLOSE_OR_SMALL(h.unitDirectionAfter(),
+                       Hit::Vector3(0, -1, 1).normalized(), eps, eps);
+  CHECK_CLOSE_REL(h.unitDirection(), Hit::Vector3(1, -1, 2).normalized(), eps);
   CHECK_SMALL(h.depositedEnergy(), eps);
 }
 
@@ -98,7 +101,7 @@ BOOST_AUTO_TEST_CASE(WithEverything) {
   auto m41 = Hit::Vector4(2, 1, 2, 4);
   auto h = Hit(gid, pid, p4, m40, m41, 1u);
 
-  BOOST_TEST(h.geoId() == gid);
+  BOOST_TEST(h.geometryId() == gid);
   BOOST_TEST(h.particleId() == pid);
   BOOST_TEST(h.index() == 1u);
   CHECK_CLOSE_REL(h.position4(), p4, eps);
@@ -106,10 +109,12 @@ BOOST_AUTO_TEST_CASE(WithEverything) {
   CHECK_CLOSE_REL(h.time(), 4, eps);
   CHECK_CLOSE_OR_SMALL(h.momentum4Before(), m40, eps, eps);
   CHECK_CLOSE_OR_SMALL(h.momentum4After(), m41, eps, eps);
-  CHECK_CLOSE_REL(h.directionBefore(), Hit::Vector3(3, 2, 2).normalized(), eps);
-  CHECK_CLOSE_REL(h.directionAfter(), Hit::Vector3(2, 1, 2).normalized(), eps);
+  CHECK_CLOSE_REL(h.unitDirectionBefore(), Hit::Vector3(3, 2, 2).normalized(),
+                  eps);
+  CHECK_CLOSE_REL(h.unitDirectionAfter(), Hit::Vector3(2, 1, 2).normalized(),
+                  eps);
   CHECK_CLOSE_REL(
-      h.direction(),
+      h.unitDirection(),
       Hit::Vector3(0.7023994590205035, 0.41229136135810396, 0.5802161953247991),
       eps);
   CHECK_CLOSE_REL(h.depositedEnergy(), 1, eps);
diff --git a/Tests/UnitTests/Fatras/EventData/ParticleTests.cpp b/Tests/UnitTests/Fatras/EventData/ParticleTests.cpp
index 28e03564e3a9f1019c166b588d7447f9ed852c95..4ba1b078c260256a1ffb5f1a41b69e7bb23a2cf0 100644
--- a/Tests/UnitTests/Fatras/EventData/ParticleTests.cpp
+++ b/Tests/UnitTests/Fatras/EventData/ParticleTests.cpp
@@ -8,6 +8,9 @@
 
 #include <boost/test/unit_test.hpp>
 
+#include <limits>
+
+#include "Acts/Tests/CommonHelpers/FloatComparisons.hpp"
 #include "Acts/Utilities/Units.hpp"
 #include "ActsFatras/EventData/Particle.hpp"
 
@@ -16,13 +19,17 @@ using ActsFatras::Barcode;
 using ActsFatras::Particle;
 using namespace Acts::UnitLiterals;
 
+namespace {
+constexpr auto eps = std::numeric_limits<Particle::Scalar>::epsilon();
+}
+
 BOOST_AUTO_TEST_SUITE(FatrasParticle)
 
 BOOST_AUTO_TEST_CASE(Construct) {
-  const auto id = Barcode().setVertexPrimary(1).setParticle(42);
-  const auto particle = Particle(id, PdgParticle::eProton, 1_GeV, 1_e);
+  const auto pid = Barcode().setVertexPrimary(1).setParticle(42);
+  const auto particle = Particle(pid, PdgParticle::eProton, 1_GeV, 1_e);
 
-  BOOST_TEST(particle.id() == id);
+  BOOST_TEST(particle.particleId() == pid);
   BOOST_TEST(particle.pdg() == PdgParticle::eProton);
   // particle is at rest at the origin
   BOOST_TEST(particle.position4() == Particle::Vector4::Zero());
@@ -32,17 +39,19 @@ BOOST_AUTO_TEST_CASE(Construct) {
   BOOST_TEST(particle.position4().y() == particle.position().y());
   BOOST_TEST(particle.position4().z() == particle.position().z());
   BOOST_TEST(particle.position4().w() == particle.time());
-  // particle direction is undefined
-  BOOST_TEST(particle.momentum() == Particle::Scalar(0));
+  // particle direction is undefined, but must be normalized
+  CHECK_CLOSE_REL(particle.unitDirection().norm(), 1, eps);
+  BOOST_TEST(particle.transverseMomentum() == Particle::Scalar(0));
+  BOOST_TEST(particle.absMomentum() == Particle::Scalar(0));
   // particle is created at rest and thus not alive
   BOOST_TEST(not particle);
 }
 
 BOOST_AUTO_TEST_CASE(CorrectEnergy) {
-  const auto id = Barcode().setVertexPrimary(1).setParticle(42);
-  auto particle = Particle(id, PdgParticle::eProton, 1_GeV, 1_e)
+  const auto pid = Barcode().setVertexPrimary(1).setParticle(42);
+  auto particle = Particle(pid, PdgParticle::eProton, 1_GeV, 1_e)
                       .setDirection(Particle::Vector3::UnitX())
-                      .setMomentum(2_GeV);
+                      .setAbsMomentum(2_GeV);
 
   BOOST_TEST(particle.mass() == 1_GeV);
   // check that the particle has some input energy
@@ -50,23 +59,32 @@ BOOST_AUTO_TEST_CASE(CorrectEnergy) {
   BOOST_TEST(particle.momentum4().y() == 0_GeV);
   BOOST_TEST(particle.momentum4().z() == 0_GeV);
   BOOST_TEST(particle.momentum4().w() == std::hypot(1_GeV, 2_GeV));
-  BOOST_TEST(particle.momentum() == 2_GeV);
+  BOOST_TEST(particle.transverseMomentum() == 2_GeV);
+  BOOST_TEST(particle.absMomentum() == 2_GeV);
   BOOST_TEST(particle.energy() == std::hypot(1_GeV, 2_GeV));
+  // particle direction must be normalized
+  CHECK_CLOSE_REL(particle.unitDirection().norm(), 1, eps);
   // loose some energy
   particle.correctEnergy(-100_MeV);
-  BOOST_TEST(particle.momentum() < 2_GeV);
+  BOOST_TEST(particle.transverseMomentum() < 2_GeV);
+  BOOST_TEST(particle.absMomentum() < 2_GeV);
   BOOST_TEST(particle.energy() ==
              Particle::Scalar(std::hypot(1_GeV, 2_GeV) - 100_MeV));
+  CHECK_CLOSE_REL(particle.unitDirection().norm(), 1, eps);
   // particle is still alive
   BOOST_TEST(particle);
   // loose a lot of energy
   particle.correctEnergy(-3_GeV);
-  BOOST_TEST(particle.momentum() == Particle::Scalar(0));
+  BOOST_TEST(particle.transverseMomentum() == Particle::Scalar(0));
+  BOOST_TEST(particle.absMomentum() == Particle::Scalar(0));
   BOOST_TEST(particle.energy() == particle.mass());
+  CHECK_CLOSE_REL(particle.unitDirection().norm(), 1, eps);
   // lossing even more energy does nothing
   particle.correctEnergy(-10_GeV);
-  BOOST_TEST(particle.momentum() == Particle::Scalar(0));
+  BOOST_TEST(particle.transverseMomentum() == Particle::Scalar(0));
+  BOOST_TEST(particle.absMomentum() == Particle::Scalar(0));
   BOOST_TEST(particle.energy() == particle.mass());
+  CHECK_CLOSE_REL(particle.unitDirection().norm(), 1, eps);
   // particle is not alive anymore
   BOOST_TEST(not particle);
 }
diff --git a/Tests/UnitTests/Fatras/Kernel/InteractorTests.cpp b/Tests/UnitTests/Fatras/Kernel/InteractorTests.cpp
index cdc1ab664e08679bda965996fe7c69fea02a596b..dd84e572f806237dd1f5582c0ad912e37dbb583c 100644
--- a/Tests/UnitTests/Fatras/Kernel/InteractorTests.cpp
+++ b/Tests/UnitTests/Fatras/Kernel/InteractorTests.cpp
@@ -94,15 +94,15 @@ struct Fixture {
                  Acts::PdgParticle::eProton, 0, 1)
             .setPosition4(1, 2, 3, 4)
             .setDirection(1, 0, 0)
-            .setMomentum(100);
+            .setAbsMomentum(100);
     interactor.generator = &generator;
     interactor.physics.energyLoss = energyLoss;
     interactor.particle = particle;
     state.navigation.currentSurface = surface.get();
     state.stepping.position = particle.position();
     state.stepping.time = particle.time();
-    state.stepping.direction = particle.direction();
-    state.stepping.momentum = particle.momentum();
+    state.stepping.direction = particle.unitDirection();
+    state.stepping.momentum = particle.absMomentum();
   }
 };
 
@@ -142,7 +142,8 @@ BOOST_AUTO_TEST_CASE(HitsOnEmptySurface) {
   BOOST_TEST(f.result.hits[1].index() == 1u);
 
   // particle identity should be the same as the initial input
-  BOOST_TEST(f.result.particle.id() == f.interactor.particle.id());
+  BOOST_TEST(f.result.particle.particleId() ==
+             f.interactor.particle.particleId());
   BOOST_TEST(f.result.particle.process() == f.interactor.particle.process());
   BOOST_TEST(f.result.particle.pdg() == f.interactor.particle.pdg());
   BOOST_TEST(f.result.particle.charge() == f.interactor.particle.charge());
@@ -169,7 +170,8 @@ BOOST_AUTO_TEST_CASE(HitsOnMaterialSurface) {
   BOOST_TEST(f.result.hits[1].index() == 1u);
 
   // particle identity should be the same as the initial input
-  BOOST_TEST(f.result.particle.id() == f.interactor.particle.id());
+  BOOST_TEST(f.result.particle.particleId() ==
+             f.interactor.particle.particleId());
   BOOST_TEST(f.result.particle.process() == f.interactor.particle.process());
   BOOST_TEST(f.result.particle.pdg() == f.interactor.particle.pdg());
   BOOST_TEST(f.result.particle.charge() == f.interactor.particle.charge());
@@ -193,7 +195,8 @@ BOOST_AUTO_TEST_CASE(NoHitsEmptySurface) {
   BOOST_TEST(f.result.hits.size() == 0u);
 
   // particle identity should be the same as the initial input
-  BOOST_TEST(f.result.particle.id() == f.interactor.particle.id());
+  BOOST_TEST(f.result.particle.particleId() ==
+             f.interactor.particle.particleId());
   BOOST_TEST(f.result.particle.process() == f.interactor.particle.process());
   BOOST_TEST(f.result.particle.pdg() == f.interactor.particle.pdg());
   BOOST_TEST(f.result.particle.charge() == f.interactor.particle.charge());
@@ -217,7 +220,8 @@ BOOST_AUTO_TEST_CASE(NoHitsMaterialSurface) {
   BOOST_TEST(f.result.hits.size() == 0u);
 
   // particle identity should be the same as the initial input
-  BOOST_TEST(f.result.particle.id() == f.interactor.particle.id());
+  BOOST_TEST(f.result.particle.particleId() ==
+             f.interactor.particle.particleId());
   BOOST_TEST(f.result.particle.process() == f.interactor.particle.process());
   BOOST_TEST(f.result.particle.pdg() == f.interactor.particle.pdg());
   BOOST_TEST(f.result.particle.charge() == f.interactor.particle.charge());
diff --git a/Tests/UnitTests/Fatras/Kernel/ProcessTests.cpp b/Tests/UnitTests/Fatras/Kernel/ProcessTests.cpp
index 00d281ae683fbb609b6d30127e3c44493caf9c1a..7b4fe2a8c7e56ae061e6689214d0bbb63311be8f 100644
--- a/Tests/UnitTests/Fatras/Kernel/ProcessTests.cpp
+++ b/Tests/UnitTests/Fatras/Kernel/ProcessTests.cpp
@@ -30,10 +30,10 @@ struct MakeChildren {
       ActsFatras::Particle &) const {
     // create daughter particles
     return {
-        Particle().setMomentum(1_GeV),
-        Particle().setMomentum(2_GeV),
-        Particle().setMomentum(3_GeV),
-        Particle().setMomentum(4_GeV),
+        Particle().setAbsMomentum(1_GeV),
+        Particle().setAbsMomentum(2_GeV),
+        Particle().setAbsMomentum(3_GeV),
+        Particle().setAbsMomentum(4_GeV),
     };
   }
 };
@@ -43,14 +43,14 @@ struct HighP {
   double minP = 10_GeV;
 
   bool operator()(const ActsFatras::Particle &particle) const {
-    return (minP <= particle.momentum());
+    return (minP <= particle.absMomentum());
   }
 };
 
 struct Fixture {
   std::default_random_engine generator;
   Acts::MaterialProperties slab{Acts::Test::makeBeryllium(), 1_mm};
-  Particle parent = Particle().setMomentum(10_GeV);
+  Particle parent = Particle().setAbsMomentum(10_GeV);
   std::vector<Particle> children;
 };
 }  // namespace
@@ -72,15 +72,15 @@ BOOST_AUTO_TEST_CASE(WithInputSelector) {
   process.selectInput.minP = 10_GeV;
 
   // above threshold should not abort
-  f.parent.setMomentum(20_GeV);
+  f.parent.setAbsMomentum(20_GeV);
   BOOST_TEST(not process(f.generator, f.slab, f.parent, f.children));
   BOOST_TEST(f.children.size() == 4u);
   // on threshold should still not abort
-  f.parent.setMomentum(10_GeV);
+  f.parent.setAbsMomentum(10_GeV);
   BOOST_TEST(not process(f.generator, f.slab, f.parent, f.children));
   BOOST_TEST(f.children.size() == 8u);
   // below threshold should abort and not run the process at all
-  f.parent.setMomentum(2_GeV);
+  f.parent.setAbsMomentum(2_GeV);
   BOOST_TEST(not process(f.generator, f.slab, f.parent, f.children));
   // process did not run -> no new children
   BOOST_TEST(f.children.size() == 8u);
@@ -92,15 +92,15 @@ BOOST_AUTO_TEST_CASE(WithOutputSelector) {
   process.selectOutputParticle.minP = 10_GeV;
 
   // above threshold should not abort
-  f.parent.setMomentum(20_GeV);
+  f.parent.setAbsMomentum(20_GeV);
   BOOST_TEST(not process(f.generator, f.slab, f.parent, f.children));
   BOOST_TEST(f.children.size() == 4u);
   // on threshold should still not abort
-  f.parent.setMomentum(10_GeV);
+  f.parent.setAbsMomentum(10_GeV);
   BOOST_TEST(not process(f.generator, f.slab, f.parent, f.children));
   BOOST_TEST(f.children.size() == 8u);
   // below threshold should abort but only after running the process
-  f.parent.setMomentum(2_GeV);
+  f.parent.setAbsMomentum(2_GeV);
   BOOST_TEST(process(f.generator, f.slab, f.parent, f.children));
   // process did still run -> new children
   BOOST_TEST(f.children.size() == 12u);
diff --git a/Tests/UnitTests/Fatras/Physics/Dataset.hpp b/Tests/UnitTests/Fatras/Physics/Dataset.hpp
index 63f4a072a5de01006d390aa0e56fe21c590b438b..eee0c82117753522cfbd3d9ade9a182fb58102bd 100644
--- a/Tests/UnitTests/Fatras/Physics/Dataset.hpp
+++ b/Tests/UnitTests/Fatras/Physics/Dataset.hpp
@@ -50,7 +50,7 @@ inline ActsFatras::Particle makeParticle(Acts::PdgParticle pdg, double phi,
       .setPosition4(0, 0, 0, 0)
       .setDirection(std::cos(lambda) * std::cos(phi),
                     std::cos(lambda) * std::sin(phi), std::sin(lambda))
-      .setMomentum(p);
+      .setAbsMomentum(p);
 }
 
 }  // namespace Dataset
diff --git a/Tests/UnitTests/Fatras/Physics/EnergyLossTests.cpp b/Tests/UnitTests/Fatras/Physics/EnergyLossTests.cpp
index ea73418011077b7797a718445f7578caa306db53..8d77fcac83b59b135a41e77da4afd513f471ad22 100644
--- a/Tests/UnitTests/Fatras/Physics/EnergyLossTests.cpp
+++ b/Tests/UnitTests/Fatras/Physics/EnergyLossTests.cpp
@@ -31,7 +31,7 @@ BOOST_DATA_TEST_CASE(BetheBloch, Dataset::parameters, pdg, phi, lambda, p,
   ActsFatras::BetheBloch process;
   const auto outgoing = process(gen, Acts::Test::makeUnitSlab(), after);
   // energy loss changes momentum and energy
-  BOOST_TEST(after.momentum() < before.momentum());
+  BOOST_TEST(after.absMomentum() < before.absMomentum());
   BOOST_TEST(after.energy() < before.energy());
   // energy loss creates no new particles
   BOOST_TEST(outgoing.empty());
@@ -46,7 +46,7 @@ BOOST_DATA_TEST_CASE(BetheHeitler, Dataset::parameters, pdg, phi, lambda, p,
   ActsFatras::BetheHeitler process;
   const auto outgoing = process(gen, Acts::Test::makeUnitSlab(), after);
   // energy loss changes momentum and energy
-  BOOST_TEST(after.momentum() < before.momentum());
+  BOOST_TEST(after.absMomentum() < before.absMomentum());
   BOOST_TEST(after.energy() < before.energy());
   // energy loss creates no new particles
   BOOST_TEST(outgoing.empty());
diff --git a/Tests/UnitTests/Fatras/Physics/ScatteringTests.cpp b/Tests/UnitTests/Fatras/Physics/ScatteringTests.cpp
index 159849f1a95ca3d8c060598bc3e20e19a4bc15a5..7a2cf875428c849ede728110c4622fa45f757aea 100644
--- a/Tests/UnitTests/Fatras/Physics/ScatteringTests.cpp
+++ b/Tests/UnitTests/Fatras/Physics/ScatteringTests.cpp
@@ -19,18 +19,11 @@
 #include "ActsFatras/Physics/Scattering/GaussianMixture.hpp"
 #include "ActsFatras/Physics/Scattering/GeneralMixture.hpp"
 #include "ActsFatras/Physics/Scattering/Highland.hpp"
-#include "ActsFatras/Physics/Scattering/Scattering.hpp"
 #include "Dataset.hpp"
 
 namespace {
 constexpr auto eps = std::numeric_limits<double>::epsilon();
 
-using GeneralMixtureScattering =
-    ActsFatras::Scattering<ActsFatras::GeneralMixture>;
-using GaussianMixtureScattering =
-    ActsFatras::Scattering<ActsFatras::GaussianMixture>;
-using HighlandScattering = ActsFatras::Scattering<ActsFatras::Highland>;
-
 // Common test method that will be instantiated for each scattering model.
 template <typename Scattering>
 void test(const Scattering& scattering, uint32_t seed,
@@ -40,10 +33,10 @@ void test(const Scattering& scattering, uint32_t seed,
 
   const auto outgoing = scattering(gen, Acts::Test::makePercentSlab(), after);
   // scattering leaves absolute energy/momentum unchanged
-  CHECK_CLOSE_REL(after.momentum(), before.momentum(), eps);
+  CHECK_CLOSE_REL(after.absMomentum(), before.absMomentum(), eps);
   CHECK_CLOSE_REL(after.energy(), before.energy(), eps);
   // scattering has changed the direction
-  BOOST_TEST(before.direction().dot(after.direction()) < 1);
+  BOOST_TEST(before.unitDirection().dot(after.unitDirection()) < 1);
   // scattering creates no new particles
   BOOST_TEST(outgoing.empty());
 }
@@ -53,18 +46,19 @@ BOOST_AUTO_TEST_SUITE(FatrasScattering)
 
 BOOST_DATA_TEST_CASE(GeneralMixture, Dataset::parameters, pdg, phi, lambda, p,
                      seed) {
-  test(GeneralMixtureScattering(), seed,
+  test(ActsFatras::GeneralMixtureScattering(), seed,
        Dataset::makeParticle(pdg, phi, lambda, p));
 }
 
 BOOST_DATA_TEST_CASE(GaussianMixture, Dataset::parameters, pdg, phi, lambda, p,
                      seed) {
-  test(GaussianMixtureScattering(), seed,
+  test(ActsFatras::GaussianMixtureScattering(), seed,
        Dataset::makeParticle(pdg, phi, lambda, p));
 }
 
 BOOST_DATA_TEST_CASE(Highland, Dataset::parameters, pdg, phi, lambda, p, seed) {
-  test(HighlandScattering(), seed, Dataset::makeParticle(pdg, phi, lambda, p));
+  test(ActsFatras::HighlandScattering(), seed,
+       Dataset::makeParticle(pdg, phi, lambda, p));
 }
 
 BOOST_AUTO_TEST_SUITE_END()
diff --git a/Tests/UnitTests/Fatras/Selectors/Dataset.hpp b/Tests/UnitTests/Fatras/Selectors/Dataset.hpp
index ddcca97a985ef094bea7fdec2f6b5405c1425eba..f725656d44bdfb47a2b78d3ffee9b10a756d76d6 100644
--- a/Tests/UnitTests/Fatras/Selectors/Dataset.hpp
+++ b/Tests/UnitTests/Fatras/Selectors/Dataset.hpp
@@ -21,7 +21,7 @@ ActsFatras::Particle makeParticle(Acts::PdgParticle pdg, double z, double eta) {
   return ActsFatras::Particle(id, pdg)
       .setPosition4(0.0, 0.0, z, 0.0)
       .setDirection(1.0 / std::cosh(eta), 0.0, std::tanh(eta))
-      .setMomentum(1.5_GeV);
+      .setAbsMomentum(1.5_GeV);
 }
 
 const auto centralElectron =