Repository: celeritas-project/celeritas
Pull request #1662: Add 3D Cylindrical field map
Author: login: esseivaju
Labels: name: enhancement
description: New feature or request
name: field
description: Magnetic field and propagation
Created: 2025-03-11T00:31:52Z | Merged: 2025-03-18T13:39:27Z

Support for 3D field interpolation in a R-Phi-Z map. The implementation is based on the existing RZMapField: we add a nonuniform grid for the phi coordinate and perform trilinear interpolation instead of the bilinear interpolation in RZ.

There is no support for JSON input, instead we provide an utility to sample an existing `G4Field` 



Diff:
diff --git a/doc/implementation/core-physics/propagation.rst b/doc/implementation/core-physics/propagation.rst
index c6bb0ebc1a..f22d9972b4 100644
--- a/doc/implementation/core-physics/propagation.rst
+++ b/doc/implementation/core-physics/propagation.rst
@@ -32,6 +32,14 @@ Propagation
 
 .. doxygenfunction:: celeritas::make_mag_field_propagator
 
+Magnetic field types
+--------------------
+
+.. doxygenclass:: celeritas::UniformField
+
+.. doxygenclass:: celeritas::RZMapField
+
+.. doxygenclass:: celeritas::CylMapField
 
 .. _api_field_data:
 
@@ -45,6 +53,11 @@ JSON input for the field setup corresponds to the uniform field input
    :members:
    :no-link:
 
+as well as fully cylindrical input:
+
+.. doxygenstruct:: celeritas::CylMapFieldInput
+   :members:
+   :no-link:
 
 The field driver options are not yet a stable part of the API:
 
diff --git a/doc/implementation/geant4-interface.rst b/doc/implementation/geant4-interface.rst
index 1579d9661a..d6d9f23526 100644
--- a/doc/implementation/geant4-interface.rst
+++ b/doc/implementation/geant4-interface.rst
@@ -164,6 +164,10 @@ written to be usable both by the ``celer-g4`` app and potential other users.
 
 .. doxygenclass:: celeritas::RZMapMagneticField
 
+.. doxygenclass:: celeritas::CylMapMagneticField
+
+.. doxygenfunction:: celeritas::MakeCylMapFieldInput
+
 
 Low-level Celeritas integration
 -------------------------------
diff --git a/src/accel/AlongStepFactory.cc b/src/accel/AlongStepFactory.cc
index 785f100ae4..6a6ff2c231 100644
--- a/src/accel/AlongStepFactory.cc
+++ b/src/accel/AlongStepFactory.cc
@@ -13,12 +13,14 @@
 #include "corecel/math/ArrayUtils.hh"
 #include "corecel/math/QuantityIO.hh"
 #include "geocel/g4/Convert.hh"
+#include "celeritas/alongstep/AlongStepCylMapFieldMscAction.hh"
 #include "celeritas/alongstep/AlongStepGeneralLinearAction.hh"
 #include "celeritas/alongstep/AlongStepRZMapFieldMscAction.hh"
 #include "celeritas/alongstep/AlongStepUniformMscAction.hh"
 #include "celeritas/em/params/UrbanMscParams.hh"
 #include "celeritas/ext/GeantUnits.hh"
 #include "celeritas/ext/GeantVolumeMapper.hh"
+#include "celeritas/field/CylMapFieldInput.hh"
 #include "celeritas/field/RZMapFieldInput.hh"
 #include "celeritas/field/UniformFieldData.hh"
 #include "celeritas/io/ImportData.hh"
@@ -162,5 +164,45 @@ RZMapFieldInput RZMapFieldAlongStepFactory::get_field() const
     return this->get_fieldmap_();
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Emit an along-step action with a non-uniform magnetic field.
+ *
+ * The action will embed the field propagator with a CylMapField.
+ */
+CylMapFieldAlongStepFactory::CylMapFieldAlongStepFactory(CylMapFieldFunction f)
+    : get_fieldmap_(std::move(f))
+{
+    CELER_EXPECT(get_fieldmap_);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Emit an along-step action.
+ */
+auto CylMapFieldAlongStepFactory::operator()(
+    AlongStepFactoryInput const& input) const -> result_type
+{
+    CELER_LOG(info) << "Creating along-step action with a CylMapField";
+
+    return celeritas::AlongStepCylMapFieldMscAction::from_params(
+        input.action_id,
+        *input.material,
+        *input.particle,
+        get_fieldmap_(),
+        celeritas::UrbanMscParams::from_import(
+            *input.particle, *input.material, *input.imported),
+        input.imported->em_params.energy_loss_fluct);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Get the field params (used for converting to celeritas::inp).
+ */
+CylMapFieldInput CylMapFieldAlongStepFactory::get_field() const
+{
+    return this->get_fieldmap_();
+}
+
 //---------------------------------------------------------------------------//
 }  // namespace celeritas
diff --git a/src/accel/AlongStepFactory.hh b/src/accel/AlongStepFactory.hh
index cda79076a9..2a154678e1 100644
--- a/src/accel/AlongStepFactory.hh
+++ b/src/accel/AlongStepFactory.hh
@@ -21,6 +21,7 @@ namespace celeritas
 {
 struct ImportData;
 struct RZMapFieldInput;
+struct CylMapFieldInput;
 class CutoffParams;
 class FluctuationParams;
 class GeoMaterialParams;
@@ -162,5 +163,32 @@ class RZMapFieldAlongStepFactory final : public AlongStepFactoryInterface
   private:
     RZMapFieldFunction get_fieldmap_;
 };
+
+//---------------------------------------------------------------------------//
+/*!
+ * Create an along-step method for a three-dimensional (r-phi-z in the
+ * cylindical coordinate system) map field (CylMapField).
+ */
+class CylMapFieldAlongStepFactory final : public AlongStepFactoryInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using CylMapFieldFunction = std::function<CylMapFieldInput()>;
+    //!@}
+
+  public:
+    // Construct with a function to return CylMapFieldInput
+    explicit CylMapFieldAlongStepFactory(CylMapFieldFunction f);
+
+    // Emit an along-step action
+    result_type operator()(argument_type input) const final;
+
+    // Get the field params (used for converting to celeritas::inp)
+    CylMapFieldInput get_field() const;
+
+  private:
+    CylMapFieldFunction get_fieldmap_;
+};
 //---------------------------------------------------------------------------//
 }  // namespace celeritas
diff --git a/src/accel/CMakeLists.txt b/src/accel/CMakeLists.txt
index a3cf810991..98197b70b5 100644
--- a/src/accel/CMakeLists.txt
+++ b/src/accel/CMakeLists.txt
@@ -20,6 +20,7 @@ list(APPEND SOURCES
   IntegrationBase.cc
   Logger.cc
   LocalTransporter.cc
+  CylMapMagneticField.cc
   SetupOptions.cc
   SetupOptionsMessenger.cc
   SharedParams.cc
diff --git a/src/accel/CylMapMagneticField.cc b/src/accel/CylMapMagneticField.cc
new file mode 100644
index 0000000000..98fcc6bc0b
--- /dev/null
+++ b/src/accel/CylMapMagneticField.cc
@@ -0,0 +1,134 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/CylMapMagneticField.cc
+//---------------------------------------------------------------------------//
+
+#include "CylMapMagneticField.hh"
+
+#include <algorithm>
+#include <cmath>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/ext/GeantUnits.hh"
+#include "celeritas/field/CylMapFieldInput.hh"
+#include "celeritas/field/CylMapFieldParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+namespace
+{
+//---------------------------------------------------------------------------//
+
+//! Cartesian to cylindrical 3D vector conversion.
+inline void cartesian_to_cylindrical(Array<G4double, 3> const& cart,
+                                     EnumArray<CylAxis, G4double>& cyl)
+{
+    double const phi = std::atan2(cart[1], cart[0]);
+    cyl[CylAxis::r] = cart[0] * std::cos(phi) + cart[1] * std::sin(phi);
+    cyl[CylAxis::phi] = -cart[0] * std::sin(phi) + cart[1] * std::cos(phi);
+    cyl[CylAxis::z] = cart[2];
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace
+
+//---------------------------------------------------------------------------//
+/*!
+ * Generates input for CylMapField params with configurable nonuniform grid
+ * dimensions in native Geant4 units, and \f$\phi\f$ should be in the range
+ * [0;\f$2\times\pi\f$]. This must be called after G4RunManager::Initialize as
+ * it will retrieve the G4FieldManager's field to sample it.
+ */
+CylMapFieldParams::Input
+MakeCylMapFieldInput(std::vector<real_type> const& r_grid,
+                     std::vector<real_type> const& phi_values,
+                     std::vector<real_type> const& z_grid)
+{
+    CylMapFieldParams::Input field_input;
+    field_input.grid_r.reserve(r_grid.size());
+    field_input.grid_phi.reserve(phi_values.size());
+    field_input.grid_z.reserve(z_grid.size());
+
+    // Convert from geant
+    std::transform(
+        r_grid.cbegin(),
+        r_grid.cend(),
+        std::back_inserter(field_input.grid_r),
+        [](real_type r) { return convert_from_geant(r, clhep_length); });
+    //  Convert phi values to Turn type
+    std::transform(phi_values.cbegin(),
+                   phi_values.cend(),
+                   std::back_inserter(field_input.grid_phi),
+                   [](real_type phi) { return native_value_to<Turn>(phi); });
+    std::transform(
+        z_grid.cbegin(),
+        z_grid.cend(),
+        std::back_inserter(field_input.grid_z),
+        [](real_type z) { return convert_from_geant(z, clhep_length); });
+
+    size_type const nr = field_input.grid_r.size();
+    size_type const nphi = field_input.grid_phi.size();
+    size_type const nz = field_input.grid_z.size();
+    size_type const total_points = nr * nphi * nz;
+
+    field_input.field.resize(static_cast<size_type>(CylAxis::size_)
+                             * total_points);
+
+    Array<size_type, 4> const dims{
+        nr, nphi, nz, static_cast<size_type>(CylAxis::size_)};
+    HyperslabIndexer const flat_index{dims};
+
+    CELER_EXPECT(G4TransportationManager::GetTransportationManager());
+    CELER_EXPECT(
+        G4TransportationManager::GetTransportationManager()->GetFieldManager());
+    CELER_EXPECT(G4TransportationManager::GetTransportationManager()
+                     ->GetFieldManager()
+                     ->GetDetectorField());
+    auto& field = *G4TransportationManager::GetTransportationManager()
+                       ->GetFieldManager()
+                       ->GetDetectorField();
+    Array<G4double, 3> bfield;
+    for (size_type ir = 0; ir < nr; ++ir)
+    {
+        real_type r = r_grid[ir];
+        for (size_type iphi = 0; iphi < nphi; ++iphi)
+        {
+            real_type phi = phi_values[iphi];
+            for (size_type iz = 0; iz < nz; ++iz)
+            {
+                auto* cur_bfield = field_input.field.data()
+                                   + flat_index(ir, iphi, iz, 0);
+                Array<G4double, 4> pos
+                    = {r * std::cos(phi), r * std::sin(phi), z_grid[iz], 0};
+                field.GetFieldValue(pos.data(), bfield.data());
+                EnumArray<CylAxis, G4double> bfield_cyl;
+                cartesian_to_cylindrical(bfield, bfield_cyl);
+                auto bfield_cyl_native
+                    = convert_from_geant(bfield_cyl.data(), clhep_field);
+                std::copy(bfield_cyl_native.cbegin(),
+                          bfield_cyl_native.cend(),
+                          cur_bfield);
+            }
+        }
+    }
+    CELER_ENSURE(field_input);
+    return field_input;
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/accel/CylMapMagneticField.hh b/src/accel/CylMapMagneticField.hh
new file mode 100644
index 0000000000..c18bf436c0
--- /dev/null
+++ b/src/accel/CylMapMagneticField.hh
@@ -0,0 +1,85 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/CylMapMagneticField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <memory>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/math/Quantity.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/field/CylMapField.hh"
+#include "celeritas/field/CylMapFieldParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+// Generate field input with user-defined grid
+CylMapFieldParams::Input
+MakeCylMapFieldInput(std::vector<real_type> const& r_grid,
+                     std::vector<real_type> const& phi_values,
+                     std::vector<real_type> const& z_grid);
+
+//---------------------------------------------------------------------------//
+/*!
+ * A user magnetic field equivalent to celeritas::CylMapField.
+ */
+class CylMapMagneticField : public G4MagneticField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using SPConstFieldParams = std::shared_ptr<CylMapFieldParams const>;
+    //!@}
+
+  public:
+    // Construct with CylMapFieldParams
+    inline explicit CylMapMagneticField(SPConstFieldParams field_params);
+
+    // Calculate values of the magnetic field vector
+    inline void
+    GetFieldValue(double const point[3], double* field) const override;
+
+  private:
+    SPConstFieldParams params_;
+    CylMapField calc_field_;
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the Celeritas shared CylMapFieldParams.
+ */
+CylMapMagneticField::CylMapMagneticField(SPConstFieldParams params)
+    : params_(std::move(params))
+    , calc_field_(CylMapField{params_->ref<MemSpace::native>()})
+{
+    CELER_EXPECT(params_);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector at the given position.
+ */
+void CylMapMagneticField::GetFieldValue(double const pos[3], double* field) const
+{
+    // Calculate the magnetic field value in the native Celeritas unit system
+    Real3 result = calc_field_(convert_from_geant(pos, clhep_length));
+    for (auto i = 0; i < 3; ++i)
+    {
+        // Return values of the field vector in CLHEP::tesla for Geant4
+        auto ft = native_value_to<units::FieldTesla>(result[i]);
+        field[i] = convert_to_geant(ft.value(), CLHEP::tesla);
+    }
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/accel/SetupOptions.cc b/src/accel/SetupOptions.cc
index 4f090d998f..8465d03d2e 100644
--- a/src/accel/SetupOptions.cc
+++ b/src/accel/SetupOptions.cc
@@ -10,6 +10,7 @@
 #include "corecel/math/ArrayUtils.hh"
 #include "geocel/GeantGeoUtils.hh"
 #include "geocel/GeantUtils.hh"
+#include "celeritas/field/CylMapFieldInput.hh"
 #include "celeritas/field/RZMapFieldInput.hh"
 #include "celeritas/field/UniformFieldData.hh"
 #include "celeritas/inp/FrameworkInput.hh"
@@ -132,6 +133,11 @@ void ProblemSetup::operator()(inp::Problem& p) const
         CELER_LOG(debug) << "Getting RZ map field";
         p.field = u->get_field();
     }
+    else if (auto* u = so.make_along_step.target<CylMapFieldAlongStepFactory>())
+    {
+        CELER_LOG(debug) << "Getting Cyl map field";
+        p.field = u->get_field();
+    }
     else
     {
         CELER_NOT_IMPLEMENTED("processing generic along-step factory");
diff --git a/src/celeritas/CMakeLists.txt b/src/celeritas/CMakeLists.txt
index ae59a8b01b..7be5c7bd16 100644
--- a/src/celeritas/CMakeLists.txt
+++ b/src/celeritas/CMakeLists.txt
@@ -40,6 +40,7 @@ list(APPEND SOURCES
   field/FieldDriverOptionsIO.json.cc
   field/RZMapFieldInputIO.json.cc
   field/RZMapFieldParams.cc
+  field/CylMapFieldParams.cc
   field/UniformFieldParams.cc
   geo/GeoMaterialParams.cc
   global/ActionGroups.cc
@@ -289,6 +290,7 @@ celeritas_polysource(alongstep/AlongStepGeneralLinearAction)
 celeritas_polysource(alongstep/AlongStepNeutralAction)
 celeritas_polysource(alongstep/AlongStepUniformMscAction)
 celeritas_polysource(alongstep/AlongStepRZMapFieldMscAction)
+celeritas_polysource(alongstep/AlongStepCylMapFieldMscAction)
 celeritas_polysource(em/model/BetheHeitlerModel)
 celeritas_polysource(em/model/BetheBlochModel)
 celeritas_polysource(em/model/BraggModel)
diff --git a/src/celeritas/Types.hh b/src/celeritas/Types.hh
index ce44adb53f..0c1863b289 100644
--- a/src/celeritas/Types.hh
+++ b/src/celeritas/Types.hh
@@ -191,6 +191,16 @@ enum class NuclearFormFactorType
     size_
 };
 
+//---------------------------------------------------------------------------//
+//! Cylindrical coordinates indices
+enum class CylAxis
+{
+    r = 0,
+    phi,
+    z,
+    size_
+};
+
 //---------------------------------------------------------------------------//
 // HELPER STRUCTS
 //---------------------------------------------------------------------------//
diff --git a/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.cc b/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.cc
new file mode 100644
index 0000000000..38b36965fc
--- /dev/null
+++ b/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.cc
@@ -0,0 +1,129 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/alongstep/AlongStepCylMapFieldMscAction.cc
+//---------------------------------------------------------------------------//
+#include "AlongStepCylMapFieldMscAction.hh"
+
+#include <type_traits>
+#include <utility>
+
+#include "corecel/Assert.hh"
+#include "celeritas/em/msc/UrbanMsc.hh"
+#include "celeritas/em/params/FluctuationParams.hh"  // IWYU pragma: keep
+#include "celeritas/em/params/UrbanMscParams.hh"  // IWYU pragma: keep
+#include "celeritas/field/CylMapFieldInput.hh"
+#include "celeritas/geo/GeoFwd.hh"
+#include "celeritas/global/ActionLauncher.hh"
+#include "celeritas/global/CoreParams.hh"
+#include "celeritas/global/CoreState.hh"
+#include "celeritas/global/TrackExecutor.hh"
+#include "celeritas/phys/ParticleTrackView.hh"
+
+#include "AlongStep.hh"
+
+#include "detail/CylMapFieldPropagatorFactory.hh"
+#include "detail/FluctELoss.hh"
+#include "detail/MeanELoss.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct the along-step action from input parameters.
+ */
+std::shared_ptr<AlongStepCylMapFieldMscAction>
+AlongStepCylMapFieldMscAction::from_params(ActionId id,
+                                           MaterialParams const& materials,
+                                           ParticleParams const& particles,
+                                           CylMapFieldInput const& field_input,
+                                           SPConstMsc const& msc,
+                                           bool eloss_fluctuation)
+{
+    CELER_EXPECT(field_input);
+
+    SPConstFluctuations fluct;
+    if (eloss_fluctuation)
+    {
+        fluct = std::make_shared<FluctuationParams>(particles, materials);
+    }
+
+    return std::make_shared<AlongStepCylMapFieldMscAction>(
+        id, field_input, std::move(fluct), msc);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with next action ID, energy loss parameters, and MSC.
+ */
+AlongStepCylMapFieldMscAction::AlongStepCylMapFieldMscAction(
+    ActionId id,
+    CylMapFieldInput const& input,
+    SPConstFluctuations fluct,
+    SPConstMsc msc)
+    : id_(id)
+    , field_{std::make_shared<CylMapFieldParams>(input)}
+    , fluct_(std::move(fluct))
+    , msc_(std::move(msc))
+{
+    CELER_EXPECT(id_);
+    CELER_EXPECT(field_);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Launch the along-step action on host.
+ */
+void AlongStepCylMapFieldMscAction::step(CoreParams const& params,
+                                         CoreStateHost& state) const
+{
+    using namespace ::celeritas::detail;
+
+    auto launch_impl = [&](auto&& execute_track) {
+        return launch_action(
+            *this,
+            params,
+            state,
+            make_along_step_track_executor(
+                params.ptr<MemSpace::native>(),
+                state.ptr(),
+                this->action_id(),
+                std::forward<decltype(execute_track)>(execute_track)));
+    };
+
+    launch_impl([&](CoreTrackView& track) {
+        if (this->has_msc())
+        {
+            MscStepLimitApplier{UrbanMsc{msc_->ref<MemSpace::native>()}}(track);
+        }
+        PropagationApplier{CylMapFieldPropagatorFactory{
+            field_->ref<MemSpace::native>()}}(track);
+        if (this->has_msc())
+        {
+            MscApplier{UrbanMsc{msc_->ref<MemSpace::native>()}}(track);
+        }
+        TimeUpdater{}(track);
+        if (this->has_fluct())
+        {
+            ElossApplier{FluctELoss{fluct_->ref<MemSpace::native>()}}(track);
+        }
+        else
+        {
+            ElossApplier{MeanELoss{}}(track);
+        }
+        TrackUpdater{}(track);
+    });
+}
+
+//---------------------------------------------------------------------------//
+#if !CELER_USE_DEVICE
+void AlongStepCylMapFieldMscAction::step(CoreParams const&,
+                                         CoreStateDevice&) const
+{
+    CELER_NOT_CONFIGURED("CUDA OR HIP");
+}
+#endif
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.cu b/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.cu
new file mode 100644
index 0000000000..b77292c4bf
--- /dev/null
+++ b/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.cu
@@ -0,0 +1,68 @@
+//---------------------------------*-CUDA-*----------------------------------//
+// Copyright 2023-2024 UT-Battelle, LLC, and other Celeritas developers.
+// See the top-level COPYRIGHT file for details.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/alongstep/AlongStepCylMapFieldMscAction.cu
+//---------------------------------------------------------------------------//
+#include "AlongStepCylMapFieldMscAction.hh"
+
+#include "corecel/sys/ScopedProfiling.hh"
+#include "celeritas/em/params/FluctuationParams.hh"
+#include "celeritas/em/params/UrbanMscParams.hh"
+#include "celeritas/field/CylMapFieldParams.hh"
+#include "celeritas/global/ActionLauncher.device.hh"
+#include "celeritas/global/CoreParams.hh"
+#include "celeritas/global/CoreState.hh"
+#include "celeritas/global/TrackExecutor.hh"
+
+#include "detail/AlongStepKernels.hh"
+#include "detail/CylMapFieldPropagatorFactory.hh"
+#include "detail/PropagationApplier.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Launch the along-step action on device.
+ */
+void AlongStepCylMapFieldMscAction::step(CoreParams const& params,
+                                         CoreStateDevice& state) const
+{
+    if (this->has_msc())
+    {
+        detail::launch_limit_msc_step(
+            *this, msc_->ref<MemSpace::native>(), params, state);
+    }
+    {
+        ScopedProfiling profile_this{"propagate"};
+        auto execute_thread = make_along_step_track_executor(
+            params.ptr<MemSpace::native>(),
+            state.ptr(),
+            this->action_id(),
+            detail::PropagationApplier{detail::CylMapFieldPropagatorFactory{
+                field_->ref<MemSpace::native>()}});
+        static ActionLauncher<decltype(execute_thread)> const launch_kernel(
+            *this, "propagate-CylMap");
+        launch_kernel(*this, params, state, execute_thread);
+    }
+    if (this->has_msc())
+    {
+        detail::launch_apply_msc(
+            *this, msc_->ref<MemSpace::native>(), params, state);
+    }
+    detail::launch_update_time(*this, params, state);
+    if (this->has_fluct())
+    {
+        detail::launch_apply_eloss(
+            *this, fluct_->ref<MemSpace::native>(), params, state);
+    }
+    else
+    {
+        detail::launch_apply_eloss(*this, params, state);
+    }
+    detail::launch_update_track(*this, params, state);
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.hh b/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.hh
new file mode 100644
index 0000000000..10b9f626dd
--- /dev/null
+++ b/src/celeritas/alongstep/AlongStepCylMapFieldMscAction.hh
@@ -0,0 +1,99 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/alongstep/AlongStepCylMapFieldMscAction.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include "corecel/Assert.hh"
+#include "corecel/Macros.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/em/data/FluctuationData.hh"
+#include "celeritas/em/data/UrbanMscData.hh"
+#include "celeritas/field/CylMapFieldData.hh"
+#include "celeritas/field/CylMapFieldParams.hh"
+#include "celeritas/global/ActionInterface.hh"
+
+namespace celeritas
+{
+class UrbanMscParams;
+class FluctuationParams;
+class PhysicsParams;
+class MaterialParams;
+class ParticleParams;
+struct CylMapFieldInput;
+
+//---------------------------------------------------------------------------//
+/*!
+ * Along-step kernel with MSC, energy loss fluctuations, and a CylMapField.
+ */
+class AlongStepCylMapFieldMscAction final : public CoreStepActionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using SPConstFluctuations = std::shared_ptr<FluctuationParams const>;
+    using SPConstMsc = std::shared_ptr<UrbanMscParams const>;
+    using SPConstFieldParams = std::shared_ptr<CylMapFieldParams const>;
+    //!@}
+
+  public:
+    static std::shared_ptr<AlongStepCylMapFieldMscAction>
+    from_params(ActionId id,
+                MaterialParams const& materials,
+                ParticleParams const& particles,
+                CylMapFieldInput const& field_input,
+                SPConstMsc const& msc,
+                bool eloss_fluctuation);
+
+    // Construct with next action ID and physics properties
+    AlongStepCylMapFieldMscAction(ActionId id,
+                                  CylMapFieldInput const& input,
+                                  SPConstFluctuations fluct,
+                                  SPConstMsc msc);
+
+    // Launch kernel with host data
+    void step(CoreParams const&, CoreStateHost&) const final;
+
+    // Launch kernel with device data
+    void step(CoreParams const&, CoreStateDevice&) const final;
+
+    //! ID of the model
+    ActionId action_id() const final { return id_; }
+
+    //! Short name for the interaction kernel
+    std::string_view label() const final { return "along-step-cylmap-msc"; }
+
+    //! Short description of the action
+    std::string_view description() const final
+    {
+        return "apply along-step in a R-Phi-Z map field with Urban MSC";
+    }
+
+    //! Dependency ordering of the action
+    StepActionOrder order() const final { return StepActionOrder::along; }
+
+    //// ACCESSORS ////
+
+    //! Whether energy flucutation is in use
+    bool has_fluct() const { return static_cast<bool>(fluct_); }
+
+    //! Whether MSC is in use
+    bool has_msc() const { return static_cast<bool>(msc_); }
+
+    //! Field map data
+    SPConstFieldParams const& field() const { return field_; }
+
+  private:
+    ActionId id_;
+    SPConstFieldParams field_;
+    SPConstFluctuations fluct_;
+    SPConstMsc msc_;
+};
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/celeritas/alongstep/detail/CylMapFieldPropagatorFactory.hh b/src/celeritas/alongstep/detail/CylMapFieldPropagatorFactory.hh
new file mode 100644
index 0000000000..b168bd88df
--- /dev/null
+++ b/src/celeritas/alongstep/detail/CylMapFieldPropagatorFactory.hh
@@ -0,0 +1,43 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/alongstep/detail/CylMapFieldPropagatorFactory.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "celeritas/field/CylMapField.hh"  // IWYU pragma: associated
+#include "celeritas/field/CylMapFieldData.hh"  // IWYU pragma: associated
+#include "celeritas/field/DormandPrinceStepper.hh"
+#include "celeritas/field/MakeMagFieldPropagator.hh"
+#include "celeritas/global/CoreTrackView.hh"
+
+namespace celeritas
+{
+namespace detail
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Propagate a track in an Cyl map magnetic field.
+ */
+struct CylMapFieldPropagatorFactory
+{
+    CELER_FUNCTION decltype(auto) operator()(CoreTrackView const& track) const
+    {
+        return make_mag_field_propagator<DormandPrinceStepper>(
+            CylMapField{field},
+            field.options,
+            track.particle(),
+            track.geometry());
+    }
+
+    static CELER_CONSTEXPR_FUNCTION bool tracks_can_loop() { return true; }
+
+    //// DATA ////
+
+    NativeCRef<CylMapFieldParamsData> field;
+};
+
+//---------------------------------------------------------------------------//
+}  // namespace detail
+}  // namespace celeritas
diff --git a/src/celeritas/alongstep/detail/RZMapFieldPropagatorFactory.hh b/src/celeritas/alongstep/detail/RZMapFieldPropagatorFactory.hh
index d679327fdc..8f0538ccda 100644
--- a/src/celeritas/alongstep/detail/RZMapFieldPropagatorFactory.hh
+++ b/src/celeritas/alongstep/detail/RZMapFieldPropagatorFactory.hh
@@ -10,6 +10,7 @@
 #include "celeritas/field/MakeMagFieldPropagator.hh"
 #include "celeritas/field/RZMapField.hh"  // IWYU pragma: associated
 #include "celeritas/field/RZMapFieldData.hh"  // IWYU pragma: associated
+#include "celeritas/global/CoreTrackView.hh"
 
 namespace celeritas
 {
diff --git a/src/celeritas/field/CylMapField.hh b/src/celeritas/field/CylMapField.hh
new file mode 100644
index 0000000000..75daf3d003
--- /dev/null
+++ b/src/celeritas/field/CylMapField.hh
@@ -0,0 +1,156 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/CylMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "CylMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Interpolate a magnetic field vector on an r/phi/z grid.
+ *
+ * The field vector is stored as a cartesian \f$(x,y,z)\f$ value on the
+ * cylindrical mesh grid points, and trilinear interpolation is performed
+ * within each grid cell. The value outside the grid is zero.
+ *
+ * Currently the grid requires a full \f$2\pi\f$ azimuthal grid.
+ */
+class CylMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<CylMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit CylMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_phi_;
+    NonuniformGrid<real_type> const grid_z_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+CylMapField::CylMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.axes[CylAxis::r], params_.grids.storage}
+    , grid_phi_{params_.grids.axes[CylAxis::phi], params_.grids.storage}
+    , grid_z_{params_.grids.axes[CylAxis::z], params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto CylMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    Turn phi = atan2turn(pos[1], pos[0]);
+    if (phi < zero_quantity())
+    {
+        phi.value() += 1;
+    }
+
+    // Check if point is within field map bounds
+    if (!params_.valid(r, phi, pos[2]))
+        return value;
+
+    // Find interpolation points for given r, phi, z
+    auto [ir, wr1] = find_interp<NonuniformGrid<real_type>>(grid_r_, r);
+    auto [iphi, wphi1]
+        = find_interp<NonuniformGrid<real_type>>(grid_phi_, phi.value());
+    auto [iz, wz1] = find_interp<NonuniformGrid<real_type>>(grid_z_, pos[2]);
+
+    auto get_field = [this](size_type ir, size_type iphi, size_type iz) {
+        return params_.fieldmap[params_.id(ir, iphi, iz)];
+    };
+
+    EnumArray<CylAxis, real_type> interp_field;
+
+    for (auto axis : range(CylAxis::size_))
+    {
+        // clang-format off
+        real_type v000 = get_field(ir,     iphi    ,     iz)[axis];
+        real_type v001 = get_field(ir,     iphi    , iz + 1)[axis];
+        real_type v010 = get_field(ir,     iphi + 1,     iz)[axis];
+        real_type v011 = get_field(ir,     iphi + 1, iz + 1)[axis];
+        real_type v100 = get_field(ir + 1, iphi    ,     iz)[axis];
+        real_type v101 = get_field(ir + 1, iphi    , iz + 1)[axis];
+        real_type v110 = get_field(ir + 1, iphi + 1,     iz)[axis];
+        real_type v111 = get_field(ir + 1, iphi + 1, iz + 1)[axis];
+        // clang-format on
+        // Trilinear interpolation formula for the current component
+        interp_field[axis]
+            = (1 - wr1)
+                  * ((1 - wphi1) * ((1 - wz1) * v000 + wz1 * v001)
+                     + wphi1 * ((1 - wz1) * v010 + wz1 * v011))
+              + wr1
+                    * ((1 - wphi1) * ((1 - wz1) * v100 + wz1 * v101)
+                       + wphi1 * ((1 - wz1) * v110 + wz1 * v111));
+    }
+
+    // Project cylindrical components to Cartesian coordinates
+    // default for r == 0
+    real_type cos_phi = 1.;
+    real_type sin_phi = 0.;
+
+    if (r != 0)
+    {
+        cos_phi = pos[0] / r;
+        sin_phi = pos[1] / r;
+    }
+    value[0] = interp_field[CylAxis::r] * cos_phi
+               - interp_field[CylAxis::phi] * sin_phi;
+    value[1] = interp_field[CylAxis::r] * sin_phi
+               + interp_field[CylAxis::phi] * cos_phi;
+    value[2] = interp_field[CylAxis::z];
+
+    return value;
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/celeritas/field/CylMapFieldData.hh b/src/celeritas/field/CylMapFieldData.hh
new file mode 100644
index 0000000000..05c4362c33
--- /dev/null
+++ b/src/celeritas/field/CylMapFieldData.hh
@@ -0,0 +1,120 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/CylMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * MapField (3-dimensional R-Phi-Z map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct CylMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [R, Phi, Z]
+    EnumArray<CylAxis, ItemRange<real_type>> axes;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty()
+               && storage.size()
+                      == axes[CylAxis::r].size() + axes[CylAxis::phi].size()
+                             + axes[CylAxis::z].size();
+    }
+
+    //! Assign from another set of data
+    template<Ownership W2, MemSpace M2>
+    CylMapGridData& operator=(CylMapGridData<W2, M2> const& other)
+    {
+        CELER_EXPECT(other);
+        storage = other.storage;
+        axes = other.axes;
+        return *this;
+    }
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Device data for interpolating field values.
+ */
+template<Ownership W, MemSpace M>
+struct CylMapFieldParamsData
+{
+    //! Grids of MapField
+    CylMapGridData<W, M> grids;
+
+    //! Options for FieldDriver
+    FieldDriverOptions options;
+
+    //! Index of MapField Collection
+    using ElementId = ItemId<size_type>;
+
+    template<class T>
+    using ElementItems = Collection<T, W, M, ElementId>;
+
+    //! MapField data
+    ElementItems<EnumArray<CylAxis, real_type>> fieldmap;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !fieldmap.empty();
+    }
+
+    //! Check if the given position is within the field map bounds
+    inline CELER_FUNCTION bool valid(real_type r, Turn phi, real_type z) const
+    {
+        CELER_EXPECT(grids);
+        return (
+            r >= grids.storage[grids.axes[CylAxis::r].front()]
+            && r <= grids.storage[grids.axes[CylAxis::r].back()]
+            && phi.value() >= grids.storage[grids.axes[CylAxis::phi].front()]
+            && phi.value() <= grids.storage[grids.axes[CylAxis::phi].back()]
+            && z >= grids.storage[grids.axes[CylAxis::z].front()]
+            && z <= grids.storage[grids.axes[CylAxis::z].back()]);
+    }
+
+    inline CELER_FUNCTION ElementId id(size_type idx_r,
+                                       size_type idx_phi,
+                                       size_type idx_z) const
+    {
+        CELER_EXPECT(grids);
+        Array<size_type, static_cast<size_type>(CylAxis::size_)> tmp{
+            grids.axes[CylAxis::r].size(),
+            grids.axes[CylAxis::phi].size(),
+            grids.axes[CylAxis::z].size()};
+        return ElementId{HyperslabIndexer{tmp}(idx_r, idx_phi, idx_z)};
+    }
+
+    //! Assign from another set of data
+    template<Ownership W2, MemSpace M2>
+    CylMapFieldParamsData& operator=(CylMapFieldParamsData<W2, M2> const& other)
+    {
+        CELER_EXPECT(other);
+        grids = other.grids;
+        options = other.options;
+        fieldmap = other.fieldmap;
+        return *this;
+    }
+};
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
\ No newline at end of file
diff --git a/src/celeritas/field/CylMapFieldInput.hh b/src/celeritas/field/CylMapFieldInput.hh
new file mode 100644
index 0000000000..cc9763cf7f
--- /dev/null
+++ b/src/celeritas/field/CylMapFieldInput.hh
@@ -0,0 +1,67 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/CylMapFieldInput.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <algorithm>
+#include <vector>
+
+#include "corecel/Types.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Input data for a magnetic R-Phi-Z vector field stored on an R-Phi-Z grid.
+ *
+ * The magnetic field is discretized at nodes on an R-Phi-Z grid, and at each
+ * point the field vector is approximated by a 3-D vector in R-Phi-Z. The input
+ * units of this field are in *NATIVE UNITS* (cm/gauss when CGS). An optional
+ * \c _units field in the input can specify whether the input is in SI or CGS
+ * units, with allowable values of "si", "cgs", or "clhep". The native CLHEP
+ * unit strength is 1000*tesla.
+ *
+ * The field values are all indexed with Z having stride 1, Phi having stride
+ * (num_grid_z), and R having stride (num_grid_phi * num_grid_z): [R][Phi][Z]
+ */
+struct CylMapFieldInput
+{
+    std::vector<real_type> grid_r;  //!< R grid points [len]
+    std::vector<Turn> grid_phi;  //!< Phi grid points [AU]
+    std::vector<real_type> grid_z;  //!< Z grid points [len]
+
+    std::vector<real_type> field;  //!< Flattened R-Phi-Z field component
+                                   //!< [bfield]
+
+    // TODO: remove from field input; should be a separate input
+    FieldDriverOptions driver_options;
+
+    //! Whether all data are assigned and valid
+    explicit operator bool() const
+    {
+        // clang-format off
+        return 
+             (grid_r.size() >= 2)
+            && (grid_phi.size() >= 2)
+            && (grid_z.size() >= 2)
+            && (grid_r.front() >= 0)
+            && (soft_zero(grid_phi.front().value()))
+            && (soft_equal(real_type{1}, grid_phi.back().value()))
+            && std::is_sorted(grid_r.cbegin(), grid_r.cend())
+            && std::is_sorted(grid_phi.cbegin(), grid_phi.cend())
+            && std::is_sorted(grid_z.cbegin(), grid_z.cend())
+            && (field.size() == static_cast<size_type>(CylAxis::size_) * grid_r.size() * grid_phi.size() * grid_z.size());
+        // clang-format on
+    }
+};
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
\ No newline at end of file
diff --git a/src/celeritas/field/CylMapFieldParams.cc b/src/celeritas/field/CylMapFieldParams.cc
new file mode 100644
index 0000000000..7ab4412fd8
--- /dev/null
+++ b/src/celeritas/field/CylMapFieldParams.cc
@@ -0,0 +1,122 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/CylMapFieldParams.cc
+//---------------------------------------------------------------------------//
+#include "CylMapFieldParams.hh"
+
+#include <algorithm>
+#include <utility>
+#include <vector>
+
+#include "corecel/Assert.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/CollectionBuilder.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Types.hh"
+
+#include "CylMapFieldData.hh"
+#include "CylMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a user-defined field map.
+ */
+CylMapFieldParams::CylMapFieldParams(CylMapFieldInput const& inp)
+{
+    CELER_VALIDATE(
+        inp.grid_r.size() >= 2,
+        << "invalid field parameter (num_grid_r=" << inp.grid_r.size() << ")");
+    CELER_VALIDATE(inp.grid_phi.size() >= 2,
+                   << "invalid field parameter (num_grid_phi="
+                   << inp.grid_phi.size() << ")");
+    CELER_VALIDATE(
+        inp.grid_z.size() >= 2,
+        << "invalid field parameter (num_grid_z=" << inp.grid_z.size() << ")");
+
+    CELER_VALIDATE(inp.grid_r.back() > inp.grid_r.front(),
+                   << "invalid field parameter (max_r=" << inp.grid_r.back()
+                   << " <= min_r= " << inp.grid_r.front() << ")");
+    CELER_VALIDATE(
+        inp.grid_phi.back() > inp.grid_phi.front(),
+        << "invalid field parameter (max_phi=" << inp.grid_phi.back().value()
+        << " <= min_phi= " << inp.grid_phi.front().value() << ")");
+    CELER_VALIDATE(inp.grid_z.back() > inp.grid_z.front(),
+                   << "invalid field parameter (max_z=" << inp.grid_z.back()
+                   << " <= min_z= " << inp.grid_z.front() << ")");
+
+    CELER_VALIDATE(inp.grid_r.front() >= 0,
+                   << "invalid field parameter (min_r=" << inp.grid_r.front()
+                   << ")");
+    CELER_VALIDATE(soft_zero(inp.grid_phi.front().value()),
+                   << "Phi grid must be a complete circle (grid_phi min="
+                   << inp.grid_phi.front().value() << "): should be 0");
+    CELER_VALIDATE(soft_equal(real_type{1}, inp.grid_phi.back().value()),
+                   << "Phi grid must be a complete circle (grid_phi max="
+                   << inp.grid_phi.back().value() << "): should be 1");
+
+    CELER_VALIDATE(
+        inp.field.size()
+            == static_cast<size_type>(CylAxis::size_) * inp.grid_r.size()
+                   * inp.grid_phi.size() * inp.grid_z.size(),
+        << "invalid field length (field size=" << inp.field.size()
+        << "): should be "
+        << static_cast<size_type>(CylAxis::size_) * inp.grid_r.size()
+                   * inp.grid_phi.size() * inp.grid_z.size());
+
+    // Throw a runtime error if any driver options are invalid
+    validate_input(inp.driver_options);
+
+    auto host_data = [&inp] {
+        HostVal<CylMapFieldParamsData> host;
+
+        auto grid = make_builder(&host.grids.storage);
+        grid.reserve(inp.grid_phi.size() + inp.grid_r.size()
+                     + inp.grid_z.size());
+
+        host.grids.axes[CylAxis::r]
+            = grid.insert_back(inp.grid_r.begin(), inp.grid_r.end());
+        auto phi_start = grid.size_id();
+        std::transform(inp.grid_phi.cbegin(),
+                       inp.grid_phi.cend(),
+                       std::back_inserter(grid),
+                       [](auto const& val) { return val.value(); });
+        host.grids.axes[CylAxis::phi]
+            = ItemRange<real_type>{phi_start, grid.size_id()};
+        host.grids.axes[CylAxis::z]
+            = grid.insert_back(inp.grid_z.begin(), inp.grid_z.end());
+
+        auto fieldmap = make_builder(&host.fieldmap);
+        fieldmap.reserve(inp.field.size());
+        for (auto i :
+             range(inp.grid_r.size() * inp.grid_phi.size() * inp.grid_z.size()))
+        {
+            // Save field vector
+            fieldmap.push_back(
+                [idx = inp.field.cbegin()
+                       + i * static_cast<size_type>(CylAxis::size_)] {
+                    EnumArray<CylAxis, real_type> el;
+                    std::copy(idx,
+                              idx + static_cast<size_type>(CylAxis::size_),
+                              el.begin());
+                    return el;
+                }());
+        }
+
+        host.options = inp.driver_options;
+        return host;
+    }();
+
+    // Move to mirrored data, copying to device
+    mirror_ = CollectionMirror<CylMapFieldParamsData>{std::move(host_data)};
+    CELER_ENSURE(this->mirror_);
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
\ No newline at end of file
diff --git a/src/celeritas/field/CylMapFieldParams.hh b/src/celeritas/field/CylMapFieldParams.hh
new file mode 100644
index 0000000000..f5f0781dc0
--- /dev/null
+++ b/src/celeritas/field/CylMapFieldParams.hh
@@ -0,0 +1,50 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/CylMapFieldParams.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/data/CollectionMirror.hh"
+#include "corecel/data/ParamsDataInterface.hh"
+
+#include "CylMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+struct CylMapFieldInput;
+
+//---------------------------------------------------------------------------//
+/*!
+ * Set up a 3D CylMapFieldParams.
+ *
+ * The input values are in the native unit system.
+ */
+class CylMapFieldParams final
+    : public ParamsDataInterface<CylMapFieldParamsData>
+{
+  public:
+    //@{
+    //! \name Type aliases
+    using Input = CylMapFieldInput;
+    //@}
+
+  public:
+    // Construct with a magnetic field map
+    explicit CylMapFieldParams(Input const& inp);
+
+    //! Access field map data on the host
+    HostRef const& host_ref() const final { return mirror_.host_ref(); }
+
+    //! Access field map data on the device
+    DeviceRef const& device_ref() const final { return mirror_.device_ref(); }
+
+  private:
+    // Host/device storage and reference
+    CollectionMirror<CylMapFieldParamsData> mirror_;
+};
+
+//---------------------------------------------------------------------------//
+}  // namespace celeritas
diff --git a/src/celeritas/field/RZMapFieldData.hh b/src/celeritas/field/RZMapFieldData.hh
index 88315bddef..98bdab8d66 100644
--- a/src/celeritas/field/RZMapFieldData.hh
+++ b/src/celeritas/field/RZMapFieldData.hh
@@ -17,9 +17,9 @@ namespace celeritas
 {
 //---------------------------------------------------------------------------//
 /*!
- * FieldMap (2-dimensional RZ map) grid data
+ * MapField (2-dimensional RZ map) grid data
  */
-struct FieldMapGridData
+struct MapFieldGridData
 {
     UniformGridData data_z;
     UniformGridData data_r;
@@ -27,9 +27,9 @@ struct FieldMapGridData
 
 //---------------------------------------------------------------------------//
 /*!
- * FieldMap element
+ * MapField element
  */
-struct FieldMapElement
+struct MapFieldElement
 {
     real_type value_z;
     real_type value_r;
@@ -42,18 +42,18 @@ struct FieldMapElement
 template<Ownership W, MemSpace M>
 struct RZMapFieldParamsData
 {
-    //! Grids of FieldMap
-    FieldMapGridData grids;
+    //! Grids of MapField
+    MapFieldGridData grids;
 
     //! Options for FieldDriver
     FieldDriverOptions options;
 
-    //! Index of FieldMap Collection
+    //! Index of MapField Collection
     using ElementId = ItemId<size_type>;
 
     template<class T>
     using ElementItems = Collection<T, W, M, ElementId>;
-    ElementItems<FieldMapElement> fieldmap;
+    ElementItems<MapFieldElement> fieldmap;
 
     //! Check whether the data is assigned
     explicit inline CELER_FUNCTION operator bool() const
diff --git a/src/celeritas/field/RZMapFieldParams.cc b/src/celeritas/field/RZMapFieldParams.cc
index dbe88bffed..dcb66b0101 100644
--- a/src/celeritas/field/RZMapFieldParams.cc
+++ b/src/celeritas/field/RZMapFieldParams.cc
@@ -67,7 +67,7 @@ RZMapFieldParams::RZMapFieldParams(RZMapFieldInput const& inp)
         for (auto i : range(inp.field_z.size()))
         {
             // Save field vector
-            FieldMapElement el;
+            MapFieldElement el;
             el.value_z = inp.field_z[i];
             el.value_r = inp.field_r[i];
             fieldmap.push_back(el);
diff --git a/src/celeritas/inp/Field.hh b/src/celeritas/inp/Field.hh
index fb90f91825..8bb0f9239d 100644
--- a/src/celeritas/inp/Field.hh
+++ b/src/celeritas/inp/Field.hh
@@ -11,6 +11,7 @@
 
 #include "geocel/Types.hh"
 #include "celeritas/UnitTypes.hh"
+#include "celeritas/field/CylMapFieldInput.hh"
 #include "celeritas/field/RZMapFieldInput.hh"
 
 namespace celeritas
@@ -59,10 +60,11 @@ struct UniformField
  * \todo Move field input here
  */
 using RZMapField = ::celeritas::RZMapFieldInput;
+using CylMapField = ::celeritas::CylMapFieldInput;
 
 //---------------------------------------------------------------------------//
 //! Field type
-using Field = std::variant<NoField, UniformField, RZMapField>;
+using Field = std::variant<NoField, UniformField, RZMapField, CylMapField>;
 
 //---------------------------------------------------------------------------//
 }  // namespace inp
diff --git a/src/celeritas/setup/Problem.cc b/src/celeritas/setup/Problem.cc
index 55d7f298a8..f2d4494fdf 100644
--- a/src/celeritas/setup/Problem.cc
+++ b/src/celeritas/setup/Problem.cc
@@ -284,6 +284,10 @@ auto build_along_step(inp::Field const& var_field,
                 -> std::shared_ptr<CoreStepActionInterface> {
                 CELER_NOT_IMPLEMENTED("building RZ map field through input");
             },
+            [](inp::CylMapField const&)
+                -> std::shared_ptr<CoreStepActionInterface> {
+                CELER_NOT_IMPLEMENTED("building Cyl map field through input");
+            },
         }),
         var_field);
 }
diff --git a/test/celeritas/field/Fields.test.cc b/test/celeritas/field/Fields.test.cc
index 490000df0d..b9f1b5e781 100644
--- a/test/celeritas/field/Fields.test.cc
+++ b/test/celeritas/field/Fields.test.cc
@@ -4,11 +4,21 @@
 //---------------------------------------------------------------------------//
 //! \file celeritas/field/Fields.test.cc
 //---------------------------------------------------------------------------//
+#include <algorithm>
 #include <fstream>
 
+#include "corecel/Constants.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
 #include "corecel/cont/Range.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
 #include "geocel/UnitUtils.hh"
 #include "celeritas/Quantities.hh"
+#include "celeritas/field/CylMapField.hh"
+#include "celeritas/field/CylMapFieldInput.hh"
+#include "celeritas/field/CylMapFieldParams.hh"
 #include "celeritas/field/RZMapField.hh"
 #include "celeritas/field/RZMapFieldInput.hh"
 #include "celeritas/field/RZMapFieldParams.hh"
@@ -147,6 +157,119 @@ TEST_F(RZMapFieldTest, all)
                                                3.757196366787};
     EXPECT_VEC_NEAR(expected_field, actual, real_type{1e-7});
 }
+
+using CylMapFieldTest = ::celeritas::test::Test;
+
+TEST_F(CylMapFieldTest, all)
+{
+    CylMapFieldParams field_map = [] {
+        CylMapFieldInput inp;
+        // Set up grid points in cylindrical coordinates
+        inp.grid_r = {0.0, 50.0, 100.0, 150.0};
+        Array<real_type, 7> const phi_values = {
+            0.0, 1.0 / 6.0, 2.0 / 6.0, 3.0 / 6.0, 4.0 / 6.0, 5.0 / 6.0, 1.0};
+        inp.grid_phi.resize(phi_values.size());
+        std::transform(phi_values.begin(),
+                       phi_values.end(),
+                       inp.grid_phi.begin(),
+                       [](real_type phi) { return Turn{phi}; });
+        inp.grid_z = {-150.0, -100.0, -50.0, 0.0, 50.0, 100.0, 150.0};
+
+        // Initialize field values with a predominantly z-directed field
+        size_type const nr = inp.grid_r.size();
+        size_type const nphi = inp.grid_phi.size();
+        size_type const nz = inp.grid_z.size();
+        Array<size_type, 4> const dims{
+            nr, nphi, nz, static_cast<size_type>(CylAxis::size_)};
+        size_type const total_points = nr * nphi * nz;
+
+        // Resize each component of the field
+        inp.field.resize(static_cast<size_type>(CylAxis::size_) * total_points);
+
+        // Fill with a simple field pattern
+        HyperslabIndexer const flat_index{dims};
+        for (size_type ir = 0; ir < nr; ++ir)
+        {
+            real_type r = inp.grid_r[ir];
+            for (size_type iphi = 0; iphi < nphi; ++iphi)
+            {
+                // Convert turns to radians
+                real_type phi = inp.grid_phi[iphi].value() * 2 * constants::pi;
+                for (size_type iz = 0; iz < nz; ++iz)
+                {
+                    // Index calculation for 3D array
+                    auto idx = flat_index(ir, iphi, iz, 0);
+
+                    // Set field components
+                    inp.field[idx + static_cast<size_type>(CylAxis::r)]
+                        = 0.02 * r / 100.0 * std::cos(phi);
+                    inp.field[idx + static_cast<size_type>(CylAxis::phi)]
+                        = 0.02 * r / 100.0 * std::sin(phi);
+                    inp.field[idx + static_cast<size_type>(CylAxis::z)]
+                        = 3.8 - 0.0005 * (r / 100.0) * (r / 100.0);
+                }
+            }
+        }
+        return CylMapFieldParams(inp);
+    }();
+
+    CylMapField calc_field(field_map.host_ref());
+
+    // Define samples in cylindrical coordinates
+    size_type const nr_samples = 2;
+    size_type const nphi_samples = 2;
+    size_type const nz_samples = 2;
+
+    // Define sampling ranges
+    real_type r_min = 10.0;
+    real_type r_max = 100.0;
+    real_type phi_min = 0.0;
+    real_type phi_max = constants::pi.value() / 2;
+    real_type z_min = -100.0;
+    real_type z_max = 100.0;
+
+    std::vector<real_type> actual;
+
+    for (size_type ir = 0; ir < nr_samples; ++ir)
+    {
+        real_type r = r_min + ir * (r_max - r_min) / (nr_samples - 1);
+        for (size_type iphi = 0; iphi < nphi_samples; ++iphi)
+        {
+            real_type phi = phi_min
+                            + iphi * (phi_max - phi_min) / (nphi_samples - 1);
+            for (size_type iz = 0; iz < nz_samples; ++iz)
+            {
+                real_type z = z_min + iz * (z_max - z_min) / (nz_samples - 1);
+
+                // Convert cylindrical to Cartesian coordinates for field
+                // lookup
+                Real3 pos{r * std::cos(phi), r * std::sin(phi), z};
+
+                Real3 field = calc_field(pos);
+                for (real_type f : field)
+                {
+                    actual.push_back(f);
+                }
+            }
+        }
+    }
+
+    // Expected field values at the 8 sample points (2×2×2 grid in r, phi, z)
+    // clang-format off
+    static real_type const expected_field[] = {
+        0.002,                0, 3.799975, // r=10cm,  phi=0,    z=-100cm
+        0.002,                0, 3.799975, // r=10cm,  phi=0,    z=100cm
+        -0.00173205080756888, 0, 3.799975, // r=10cm,  phi=pi/2, z=-100cm
+        -0.00173205080756888, 0, 3.799975, // r=10cm,  phi=pi/2, z=100cm
+        0.02,                 0, 3.7995,   // r=100cm, phi=0,    z=-100cm
+        0.02,                 0, 3.7995,   // r=100cm, phi=0,    z=100cm
+        -0.0173205080756888,  0, 3.7995,   // r=100cm, phi=pi/2, z=-100cm
+        -0.0173205080756888,  0, 3.7995,   // r=100cm, phi=pi/2, z=100cm
+    };
+    // clang-format on
+
+    EXPECT_VEC_NEAR(expected_field, actual, real_type{1e-7});
+}
 //---------------------------------------------------------------------------//
 }  // namespace test
 }  // namespace celeritas


Reviews:
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T11:19:31Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T00:02:21Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T00:11:58Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T00:12:42Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T00:16:32Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T19:22:38Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T19:24:42Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T22:54:34Z
user: login: sethrj
body: Couple of comments; I'm adding Quantity support for findinterp and templating Turn on a different branch.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:47:55Z
user: login: amandalund
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:32:14Z
user: login: amandalund
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:39:30Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T17:04:44Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T17:16:21Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T17:17:34Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T17:18:16Z
user: login: esseivaju
body: I think I addressed most of the comments
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T19:36:39Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T20:15:13Z
user: login: sethrj
body: Amazing work! We can (should) wait until a follow-on to optimize by using single-precision field.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-17T20:31:25Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T20:32:49Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T20:36:06Z
user: login: amandalund
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T21:03:58Z
user: login: esseivaju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T21:22:01Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T21:34:58Z
user: login: sethrj
body: Awesome work!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T12:06:22Z

Review comments:
diff_hunk: @@ -0,0 +1,93 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldInput.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <iosfwd>
+#include <vector>
+
+#include "corecel/Config.hh"
+
+#include "corecel/Assert.hh"
+#include "corecel/Macros.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Input data for a magnetic R-Z-Phi vector field stored on an R-Z-Phi grid.
+ *
+ * The magnetic field is discretized at nodes on an R-Z-Phi grid, and at each
+ * point the field vector is approximated by a 3-D vector in R-Z-Phi. The input
+ * units of this field are in *NATIVE UNITS* (cm/gauss when CGS). An optional
+ * \c _units field in the input can specify whether the input is in SI or CGS
+ * units, with allowable values of "si", "cgs", or "clhep". The native CLHEP
+ * unit strength is 1000*tesla.
+ *
+ * The field values are all indexed with phi having stride 1, R having stride
+ * (num_grid_phi), and Z having stride (num_grid_r * num_grid_phi): [Z][R][Phi]
+ */
+struct RZPhiMapFieldInput
+{
+    unsigned int num_grid_z{};
+    unsigned int num_grid_r{};
+    unsigned int num_grid_phi{};
+    double min_z{};  //!< Lower z coordinate [len]
+    double max_z{};  //!< Last z coordinate [len]
+    double min_r{};  //!< Lower r coordinate [len]
+    double max_r{};  //!< Last r coordinate [len]
path: src/celeritas/field/RZPhiMapFieldInput.hh
user: login: sethrj
body: We can definitely use nonuniform grids for any/all of these if we want, implementing on device using `NonuniformGrid`.
created_at: 2025-03-11T09:41:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldInput.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <iosfwd>
+#include <vector>
+
+#include "corecel/Config.hh"
+
+#include "corecel/Assert.hh"
+#include "corecel/Macros.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Input data for a magnetic R-Z-Phi vector field stored on an R-Z-Phi grid.
+ *
+ * The magnetic field is discretized at nodes on an R-Z-Phi grid, and at each
+ * point the field vector is approximated by a 3-D vector in R-Z-Phi. The input
+ * units of this field are in *NATIVE UNITS* (cm/gauss when CGS). An optional
+ * \c _units field in the input can specify whether the input is in SI or CGS
+ * units, with allowable values of "si", "cgs", or "clhep". The native CLHEP
+ * unit strength is 1000*tesla.
+ *
+ * The field values are all indexed with phi having stride 1, R having stride
+ * (num_grid_phi), and Z having stride (num_grid_r * num_grid_phi): [Z][R][Phi]
+ */
+struct RZPhiMapFieldInput
+{
+    unsigned int num_grid_z{};
+    unsigned int num_grid_r{};
+    unsigned int num_grid_phi{};
+    double min_z{};  //!< Lower z coordinate [len]
+    double max_z{};  //!< Last z coordinate [len]
+    double min_r{};  //!< Lower r coordinate [len]
+    double max_r{};  //!< Last r coordinate [len]
+    double min_phi{};  //!< Lower phi coordinate [rad]
+    double max_phi{};  //!< Last phi coordinate [rad], typically 2π for full
+                       //!< coverage Note: For a full circle (max_phi - min_phi
+                       //!< = 2π), the field is treated as periodic, with
path: src/celeritas/field/RZPhiMapFieldInput.hh
user: login: sethrj
body: To avoid complexity in the build system (including doxygen potentially!), let's avoid unicode characters in source files (especially doxygen comments) until at least C++20. Either encode as HTML `&#x03C0;` or `&pi;` or `\f$\pi\f$`.
created_at: 2025-03-11T09:49:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,197 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldInputIO.json.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldInputIO.json.hh"
+
+#include <initializer_list>
+#include <ostream>
+#include <string>
+#include <vector>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/io/JsonUtils.json.hh"
+#include "corecel/io/Logger.hh"
+#include "celeritas/Quantities.hh"
+
+#include "FieldDriverOptionsIO.json.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+static char const format_str[] = "rzphi-map-field";
+
+//---------------------------------------------------------------------------//
+/*!
+ * Read field from JSON.
+ */
+void from_json(nlohmann::json const& j, RZPhiMapFieldInput& inp)
+{
+#define RZPFI_LOAD(NAME) j.at(#NAME).get_to(inp.NAME)
+    using namespace celeritas::units;
+
+    check_format(j, format_str);
+    check_units(j, format_str);
+
+    RZPFI_LOAD(num_grid_z);
+    RZPFI_LOAD(num_grid_r);
+    RZPFI_LOAD(num_grid_phi);
+    RZPFI_LOAD(min_z);
+    RZPFI_LOAD(min_r);
+    RZPFI_LOAD(min_phi);
+    RZPFI_LOAD(max_z);
+    RZPFI_LOAD(max_r);
+    RZPFI_LOAD(max_phi);
+    RZPFI_LOAD(field_z);
+    RZPFI_LOAD(field_r);
+    RZPFI_LOAD(field_phi);
+    if (j.contains("driver_options"))
+    {
+        RZPFI_LOAD(driver_options);
+    }
+
+    // Convert unit systems based on input
+    UnitSystem length_units{UnitSystem::cgs};  // cm
+    UnitSystem field_units{UnitSystem::si};  // tesla
+    if (auto iter = j.find("_units"); iter != j.end())
path: src/celeritas/field/RZPhiMapFieldInputIO.json.cc
user: login: sethrj
body: Let's extract this block to a "unit utils .json.hh,cc" perhaps to reuse for the other file? Or at the very least let's delete the deprecated part and require a single consistent unit system for this input.
created_at: 2025-03-11T09:51:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldParams.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldParams.hh"
+
+#include <utility>
+#include <vector>
+
+#include "corecel/Assert.hh"
+#include "corecel/Constants.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/data/CollectionBuilder.hh"
+#include "corecel/grid/UniformGridData.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a user-defined field map.
+ */
+RZPhiMapFieldParams::RZPhiMapFieldParams(RZPhiMapFieldInput const& inp)
+{
+    CELER_VALIDATE(inp.num_grid_z >= 2,
+                   << "invalid field parameter (num_grid_z=" << inp.num_grid_z
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_r >= 2,
+                   << "invalid field parameter (num_grid_r=" << inp.num_grid_r
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_phi >= 2,
+                   << "invalid field parameter (num_grid_phi="
+                   << inp.num_grid_phi << ")");
+    CELER_VALIDATE(inp.min_r >= 0,
+                   << "invalid field parameter (min_r=" << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_r > inp.min_r,
+                   << "invalid field parameter (max_r=" << inp.max_r
+                   << " <= min_r= " << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_z > inp.min_z,
+                   << "invalid field parameter (max_z=" << inp.max_z
+                   << " <= min_z= " << inp.min_z << ")");
+    CELER_VALIDATE(inp.max_phi > inp.min_phi,
+                   << "invalid field parameter (max_phi=" << inp.max_phi
+                   << " <= min_phi= " << inp.min_phi << ")");
+
+    CELER_VALIDATE(inp.field_z.size()
+                       == inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi,
+                   << "invalid field length (field_z size="
+                   << inp.field_z.size() << "): should be "
+                   << inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi);
+    CELER_VALIDATE(
+        inp.field_r.size() == inp.field_z.size(),
+        << "invalid field length (field_r size=" << inp.field_r.size()
+        << "): should be " << inp.field_z.size());
+    CELER_VALIDATE(
+        inp.field_phi.size() == inp.field_z.size(),
+        << "invalid field length (field_phi size=" << inp.field_phi.size()
+        << "): should be " << inp.field_z.size());
+
+    // Throw a runtime error if any driver options are invalid
+    validate_input(inp.driver_options);
+
+    auto host_data = [&inp] {
+        HostVal<RZPhiMapFieldParamsData> host;
+
+        host.grids.data_r = UniformGridData::from_bounds(
+            inp.min_r, inp.max_r, inp.num_grid_r);
+        host.grids.data_z = UniformGridData::from_bounds(
+            inp.min_z, inp.max_z, inp.num_grid_z);
+
+        // For phi, ensure we're creating a periodic grid
+        // If the input specifies a full circle, adjust the grid to handle
+        // periodicity
+
+        bool is_full_circle = soft_zero(
+            std::fabs((inp.max_phi - inp.min_phi) - 2. * constants::pi));
+        if (is_full_circle)
path: src/celeritas/field/RZPhiMapFieldParams.cc
user: login: sethrj
body: For the input and runtime let's use `vector<Turn>` to avoid roundoff issues from pi: everything's just an exactly FP-representable fraction of a circle. Check out `SolidEnclosedAngle` in `orange/orangeinp/Solid.{hh,cc}` for examples.
created_at: 2025-03-11T09:57:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,231 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/UniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZ-Phi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    UniformGrid const grid_r_;
+    UniformGrid const grid_z_;
+    UniformGrid const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_(params)
+    , grid_r_(params_.grids.data_r)
+    , grid_z_(params_.grids.data_z)
+    , grid_phi_(params_.grids.data_phi)
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    real_type phi = atan2(pos[1], pos[0]);
+
+    // Ensure phi is in [0, 2π)
+    if (phi < 0)
+        phi += 2. * constants::pi;
+
+    // Handle phi periodicity for field map
+    if (phi == grid_phi_.back())
+        phi = grid_phi_.front();
+
+    // Check if point is within field map bounds
+    if (!params_.valid(pos[2], r, phi))
+        return value;
+
+    // Find interpolation points for given r, z, and phi
+    FindInterp<real_type> interp_r = find_interp<UniformGrid>(grid_r_, r);
+    FindInterp<real_type> interp_z = find_interp<UniformGrid>(grid_z_, pos[2]);
+
+    // Special handling for phi to account for periodicity
+    FindInterp<real_type> interp_phi;
+
+    // Check if we have a full circle (max_phi ~= min_phi + 2π)
+    bool is_full_circle
+        = soft_zero(std::fabs((grid_phi_.back() - grid_phi_.front())
+                              - 2. * constants::pi))
+          || soft_zero(std::fabs((grid_phi_.back() - grid_phi_.front())));
+
+    if (is_full_circle)
+    {
+        // For a full circle, we need to handle wraparound specially
+        if (phi >= grid_phi_.back())
+        {
+            // If phi is at or beyond the last grid point, wrap to the first
+            // grid point
+            phi = grid_phi_.front()
+                  + std::fmod(phi - grid_phi_.front(), 2. * constants::pi);
+        }
+    }
+
+    // Now find the interpolation point
+    interp_phi = find_interp<UniformGrid>(grid_phi_, phi);
+    size_type ir = interp_r.index;
+    size_type iz = interp_z.index;
+    size_type iphi = interp_phi.index;
+
+    // Handle phi wrap-around for interpolation
+    size_type iphi_next = iphi + 1;
+    if (iphi_next >= grid_phi_.size())
+    {
+        if (is_full_circle)
+        {
+            // For a full circle, wrap around to first point
+            iphi_next = 0;
+        }
+        else
+        {
+            // For a partial circle, clamp to the last point
+            iphi_next = grid_phi_.size() - 1;
+        }
+    }
+
+    // Perform trilinear interpolation for each field component
+    // Define the interpolation weights
+    real_type wr0 = 1.0 - interp_r.fraction;
+    real_type wr1 = interp_r.fraction;
+    real_type wz0 = 1.0 - interp_z.fraction;
+    real_type wz1 = interp_z.fraction;
+    real_type wphi0 = 1.0 - interp_phi.fraction;
+    real_type wphi1 = interp_phi.fraction;
path: src/celeritas/field/RZPhiMapField.hh
user: login: sethrj
body: Er, no offense meant since I think you said you used AI to help, but did claude generate this code? We want to avoid `1.0` since we'll probably want to do these calculations with single precision, and avoid the register usage of having extra variables for `1 - x`.
created_at: 2025-03-11T10:13:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,231 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/UniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZ-Phi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    UniformGrid const grid_r_;
+    UniformGrid const grid_z_;
+    UniformGrid const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_(params)
+    , grid_r_(params_.grids.data_r)
+    , grid_z_(params_.grids.data_z)
+    , grid_phi_(params_.grids.data_phi)
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    real_type phi = atan2(pos[1], pos[0]);
+
+    // Ensure phi is in [0, 2π)
+    if (phi < 0)
+        phi += 2. * constants::pi;
+
+    // Handle phi periodicity for field map
+    if (phi == grid_phi_.back())
+        phi = grid_phi_.front();
+
+    // Check if point is within field map bounds
+    if (!params_.valid(pos[2], r, phi))
+        return value;
+
+    // Find interpolation points for given r, z, and phi
+    FindInterp<real_type> interp_r = find_interp<UniformGrid>(grid_r_, r);
+    FindInterp<real_type> interp_z = find_interp<UniformGrid>(grid_z_, pos[2]);
+
+    // Special handling for phi to account for periodicity
+    FindInterp<real_type> interp_phi;
+
+    // Check if we have a full circle (max_phi ~= min_phi + 2π)
+    bool is_full_circle
+        = soft_zero(std::fabs((grid_phi_.back() - grid_phi_.front())
+                              - 2. * constants::pi))
+          || soft_zero(std::fabs((grid_phi_.back() - grid_phi_.front())));
path: src/celeritas/field/RZPhiMapField.hh
user: login: sethrj
body: Perhaps the easiest solution is to require a full circle? If we have a nonuniform grid and they want a partial circle (which seems an unlikely use case) they could add extra points with zeros.
created_at: 2025-03-11T10:15:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,197 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldInputIO.json.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldInputIO.json.hh"
+
+#include <initializer_list>
+#include <ostream>
+#include <string>
+#include <vector>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/io/JsonUtils.json.hh"
+#include "corecel/io/Logger.hh"
+#include "celeritas/Quantities.hh"
+
+#include "FieldDriverOptionsIO.json.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+static char const format_str[] = "rzphi-map-field";
+
+//---------------------------------------------------------------------------//
+/*!
+ * Read field from JSON.
+ */
+void from_json(nlohmann::json const& j, RZPhiMapFieldInput& inp)
+{
+#define RZPFI_LOAD(NAME) j.at(#NAME).get_to(inp.NAME)
+    using namespace celeritas::units;
+
+    check_format(j, format_str);
+    check_units(j, format_str);
+
+    RZPFI_LOAD(num_grid_z);
+    RZPFI_LOAD(num_grid_r);
+    RZPFI_LOAD(num_grid_phi);
+    RZPFI_LOAD(min_z);
+    RZPFI_LOAD(min_r);
+    RZPFI_LOAD(min_phi);
+    RZPFI_LOAD(max_z);
+    RZPFI_LOAD(max_r);
+    RZPFI_LOAD(max_phi);
+    RZPFI_LOAD(field_z);
+    RZPFI_LOAD(field_r);
+    RZPFI_LOAD(field_phi);
+    if (j.contains("driver_options"))
+    {
+        RZPFI_LOAD(driver_options);
+    }
+
+    // Convert unit systems based on input
+    UnitSystem length_units{UnitSystem::cgs};  // cm
+    UnitSystem field_units{UnitSystem::si};  // tesla
+    if (auto iter = j.find("_units"); iter != j.end())
path: src/celeritas/field/RZPhiMapFieldInputIO.json.cc
user: login: esseivaju
body: removed for now since we won't do json input in that PR
created_at: 2025-03-14T00:02:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldParams.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldParams.hh"
+
+#include <utility>
+#include <vector>
+
+#include "corecel/Assert.hh"
+#include "corecel/Constants.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/data/CollectionBuilder.hh"
+#include "corecel/grid/UniformGridData.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a user-defined field map.
+ */
+RZPhiMapFieldParams::RZPhiMapFieldParams(RZPhiMapFieldInput const& inp)
+{
+    CELER_VALIDATE(inp.num_grid_z >= 2,
+                   << "invalid field parameter (num_grid_z=" << inp.num_grid_z
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_r >= 2,
+                   << "invalid field parameter (num_grid_r=" << inp.num_grid_r
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_phi >= 2,
+                   << "invalid field parameter (num_grid_phi="
+                   << inp.num_grid_phi << ")");
+    CELER_VALIDATE(inp.min_r >= 0,
+                   << "invalid field parameter (min_r=" << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_r > inp.min_r,
+                   << "invalid field parameter (max_r=" << inp.max_r
+                   << " <= min_r= " << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_z > inp.min_z,
+                   << "invalid field parameter (max_z=" << inp.max_z
+                   << " <= min_z= " << inp.min_z << ")");
+    CELER_VALIDATE(inp.max_phi > inp.min_phi,
+                   << "invalid field parameter (max_phi=" << inp.max_phi
+                   << " <= min_phi= " << inp.min_phi << ")");
+
+    CELER_VALIDATE(inp.field_z.size()
+                       == inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi,
+                   << "invalid field length (field_z size="
+                   << inp.field_z.size() << "): should be "
+                   << inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi);
+    CELER_VALIDATE(
+        inp.field_r.size() == inp.field_z.size(),
+        << "invalid field length (field_r size=" << inp.field_r.size()
+        << "): should be " << inp.field_z.size());
+    CELER_VALIDATE(
+        inp.field_phi.size() == inp.field_z.size(),
+        << "invalid field length (field_phi size=" << inp.field_phi.size()
+        << "): should be " << inp.field_z.size());
+
+    // Throw a runtime error if any driver options are invalid
+    validate_input(inp.driver_options);
+
+    auto host_data = [&inp] {
+        HostVal<RZPhiMapFieldParamsData> host;
+
+        host.grids.data_r = UniformGridData::from_bounds(
+            inp.min_r, inp.max_r, inp.num_grid_r);
+        host.grids.data_z = UniformGridData::from_bounds(
+            inp.min_z, inp.max_z, inp.num_grid_z);
+
+        // For phi, ensure we're creating a periodic grid
+        // If the input specifies a full circle, adjust the grid to handle
+        // periodicity
+
+        bool is_full_circle = soft_zero(
+            std::fabs((inp.max_phi - inp.min_phi) - 2. * constants::pi));
+        if (is_full_circle)
path: src/celeritas/field/RZPhiMapFieldParams.cc
user: login: esseivaju
body: with `vector<Turn>`, and equivalently `NonuniformGrid<Turn>`, we have grids of different underlying types for r, z, and phi.  From discussing with @amandalund, I understand you usually have a single collection as backing storage for each grid. If we were to do this here, we'd lose the type safety on `Turn`  
created_at: 2025-03-14T00:11:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,231 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/UniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZ-Phi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    UniformGrid const grid_r_;
+    UniformGrid const grid_z_;
+    UniformGrid const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_(params)
+    , grid_r_(params_.grids.data_r)
+    , grid_z_(params_.grids.data_z)
+    , grid_phi_(params_.grids.data_phi)
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    real_type phi = atan2(pos[1], pos[0]);
+
+    // Ensure phi is in [0, 2π)
+    if (phi < 0)
+        phi += 2. * constants::pi;
+
+    // Handle phi periodicity for field map
+    if (phi == grid_phi_.back())
+        phi = grid_phi_.front();
+
+    // Check if point is within field map bounds
+    if (!params_.valid(pos[2], r, phi))
+        return value;
+
+    // Find interpolation points for given r, z, and phi
+    FindInterp<real_type> interp_r = find_interp<UniformGrid>(grid_r_, r);
+    FindInterp<real_type> interp_z = find_interp<UniformGrid>(grid_z_, pos[2]);
+
+    // Special handling for phi to account for periodicity
+    FindInterp<real_type> interp_phi;
+
+    // Check if we have a full circle (max_phi ~= min_phi + 2π)
+    bool is_full_circle
+        = soft_zero(std::fabs((grid_phi_.back() - grid_phi_.front())
+                              - 2. * constants::pi))
+          || soft_zero(std::fabs((grid_phi_.back() - grid_phi_.front())));
path: src/celeritas/field/RZPhiMapField.hh
user: login: esseivaju
body: requiring a full circle now, I check that the first / last turn are 0, and 1, respectively
created_at: 2025-03-14T00:12:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,114 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Span.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> z;
+    Items<real_type> r;
+    Items<Turn> phi;
+
+    operator bool() const { return !z.empty() && !r.empty() && !phi.empty(); }
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap element for RZ-Phi map
+ */
+struct RZPhiMapElement
+{
+    real_type value_z;
+    real_type value_r;
+    real_type value_phi;
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Device data for interpolating field values.
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapFieldParamsData
+{
+    //! Grids of FieldMap
+    RZPhiMapGridData<W, M> grids;
+
+    //! Options for FieldDriver
+    FieldDriverOptions options;
+
+    //! Index of FieldMap Collection
+    using ElementId = ItemId<size_type>;
+
+    template<class T>
+    using ElementItems = Collection<T, W, M, ElementId>;
+    ElementItems<RZPhiMapElement> fieldmap;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !fieldmap.empty();
+    }
+
+    inline CELER_FUNCTION bool
+    valid(real_type z, real_type r, real_type phi) const
+    {
+        CELER_EXPECT(grids);
+        Turn turn_phi{phi / Turn::unit_type::value()};
+        auto view_z
+            = Span<real_type const>{grids.z.data().get(), grids.z.size()};
+        auto view_r
+            = Span<real_type const>{grids.r.data().get(), grids.r.size()};
+        auto view_phi
+            = Span<Turn const>{grids.phi.data().get(), grids.phi.size()};
+        return (z >= view_z.front() && z <= view_z.back()
+                && r >= view_r.front() && r <= view_r.back()
+                && turn_phi >= view_phi.front() && turn_phi <= view_phi.back());
+    }
+
+    inline CELER_FUNCTION ElementId id(size_type idx_z,
+                                       size_type idx_r,
+                                       size_type idx_phi) const
+    {
+        CELER_EXPECT(grids);
+        // Index with ordering [Z][R][Phi]
+        return ElementId(idx_z * grids.r.size() * grids.phi.size()
+                         + idx_r * grids.phi.size() + idx_phi);
+    }
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: esseivaju
body: Should we replace this with the `HyperslabIndexer` (and same in `RZMap`)?
created_at: 2025-03-14T00:16:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldParams.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldParams.hh"
+
+#include <utility>
+#include <vector>
+
+#include "corecel/Assert.hh"
+#include "corecel/Constants.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/data/CollectionBuilder.hh"
+#include "corecel/grid/UniformGridData.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a user-defined field map.
+ */
+RZPhiMapFieldParams::RZPhiMapFieldParams(RZPhiMapFieldInput const& inp)
+{
+    CELER_VALIDATE(inp.num_grid_z >= 2,
+                   << "invalid field parameter (num_grid_z=" << inp.num_grid_z
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_r >= 2,
+                   << "invalid field parameter (num_grid_r=" << inp.num_grid_r
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_phi >= 2,
+                   << "invalid field parameter (num_grid_phi="
+                   << inp.num_grid_phi << ")");
+    CELER_VALIDATE(inp.min_r >= 0,
+                   << "invalid field parameter (min_r=" << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_r > inp.min_r,
+                   << "invalid field parameter (max_r=" << inp.max_r
+                   << " <= min_r= " << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_z > inp.min_z,
+                   << "invalid field parameter (max_z=" << inp.max_z
+                   << " <= min_z= " << inp.min_z << ")");
+    CELER_VALIDATE(inp.max_phi > inp.min_phi,
+                   << "invalid field parameter (max_phi=" << inp.max_phi
+                   << " <= min_phi= " << inp.min_phi << ")");
+
+    CELER_VALIDATE(inp.field_z.size()
+                       == inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi,
+                   << "invalid field length (field_z size="
+                   << inp.field_z.size() << "): should be "
+                   << inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi);
+    CELER_VALIDATE(
+        inp.field_r.size() == inp.field_z.size(),
+        << "invalid field length (field_r size=" << inp.field_r.size()
+        << "): should be " << inp.field_z.size());
+    CELER_VALIDATE(
+        inp.field_phi.size() == inp.field_z.size(),
+        << "invalid field length (field_phi size=" << inp.field_phi.size()
+        << "): should be " << inp.field_z.size());
+
+    // Throw a runtime error if any driver options are invalid
+    validate_input(inp.driver_options);
+
+    auto host_data = [&inp] {
+        HostVal<RZPhiMapFieldParamsData> host;
+
+        host.grids.data_r = UniformGridData::from_bounds(
+            inp.min_r, inp.max_r, inp.num_grid_r);
+        host.grids.data_z = UniformGridData::from_bounds(
+            inp.min_z, inp.max_z, inp.num_grid_z);
+
+        // For phi, ensure we're creating a periodic grid
+        // If the input specifies a full circle, adjust the grid to handle
+        // periodicity
+
+        bool is_full_circle = soft_zero(
+            std::fabs((inp.max_phi - inp.min_phi) - 2. * constants::pi));
+        if (is_full_circle)
path: src/celeritas/field/RZPhiMapFieldParams.cc
user: login: sethrj
body: Yeah for quantities on device we usually use `real_type` on the backend anyway, and convert when we pull the data from global memory. I think we could do that here too? (Our grids aren't exactly quantity-friendly.)
created_at: 2025-03-14T19:22:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,114 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Span.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> z;
+    Items<real_type> r;
+    Items<Turn> phi;
+
+    operator bool() const { return !z.empty() && !r.empty() && !phi.empty(); }
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap element for RZ-Phi map
+ */
+struct RZPhiMapElement
+{
+    real_type value_z;
+    real_type value_r;
+    real_type value_phi;
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Device data for interpolating field values.
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapFieldParamsData
+{
+    //! Grids of FieldMap
+    RZPhiMapGridData<W, M> grids;
+
+    //! Options for FieldDriver
+    FieldDriverOptions options;
+
+    //! Index of FieldMap Collection
+    using ElementId = ItemId<size_type>;
+
+    template<class T>
+    using ElementItems = Collection<T, W, M, ElementId>;
+    ElementItems<RZPhiMapElement> fieldmap;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !fieldmap.empty();
+    }
+
+    inline CELER_FUNCTION bool
+    valid(real_type z, real_type r, real_type phi) const
+    {
+        CELER_EXPECT(grids);
+        Turn turn_phi{phi / Turn::unit_type::value()};
+        auto view_z
+            = Span<real_type const>{grids.z.data().get(), grids.z.size()};
+        auto view_r
+            = Span<real_type const>{grids.r.data().get(), grids.r.size()};
+        auto view_phi
+            = Span<Turn const>{grids.phi.data().get(), grids.phi.size()};
+        return (z >= view_z.front() && z <= view_z.back()
+                && r >= view_r.front() && r <= view_r.back()
+                && turn_phi >= view_phi.front() && turn_phi <= view_phi.back());
+    }
+
+    inline CELER_FUNCTION ElementId id(size_type idx_z,
+                                       size_type idx_r,
+                                       size_type idx_phi) const
+    {
+        CELER_EXPECT(grids);
+        // Index with ordering [Z][R][Phi]
+        return ElementId(idx_z * grids.r.size() * grids.phi.size()
+                         + idx_r * grids.phi.size() + idx_phi);
+    }
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: sethrj
body: There are a bunch of places where we do this and yes please do. Also, I know RZ map indexes as [Z][R] which I didn't realize during the original pull, but let's keep the accessors C-like: [Phi][R][Z].
created_at: 2025-03-14T19:24:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldParams.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldParams.hh"
+
+#include <utility>
+#include <vector>
+
+#include "corecel/Assert.hh"
+#include "corecel/Constants.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/data/CollectionBuilder.hh"
+#include "corecel/grid/UniformGridData.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Units.hh"
+
+#include "RZPhiMapFieldData.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a user-defined field map.
+ */
+RZPhiMapFieldParams::RZPhiMapFieldParams(RZPhiMapFieldInput const& inp)
+{
+    CELER_VALIDATE(inp.num_grid_z >= 2,
+                   << "invalid field parameter (num_grid_z=" << inp.num_grid_z
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_r >= 2,
+                   << "invalid field parameter (num_grid_r=" << inp.num_grid_r
+                   << ")");
+    CELER_VALIDATE(inp.num_grid_phi >= 2,
+                   << "invalid field parameter (num_grid_phi="
+                   << inp.num_grid_phi << ")");
+    CELER_VALIDATE(inp.min_r >= 0,
+                   << "invalid field parameter (min_r=" << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_r > inp.min_r,
+                   << "invalid field parameter (max_r=" << inp.max_r
+                   << " <= min_r= " << inp.min_r << ")");
+    CELER_VALIDATE(inp.max_z > inp.min_z,
+                   << "invalid field parameter (max_z=" << inp.max_z
+                   << " <= min_z= " << inp.min_z << ")");
+    CELER_VALIDATE(inp.max_phi > inp.min_phi,
+                   << "invalid field parameter (max_phi=" << inp.max_phi
+                   << " <= min_phi= " << inp.min_phi << ")");
+
+    CELER_VALIDATE(inp.field_z.size()
+                       == inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi,
+                   << "invalid field length (field_z size="
+                   << inp.field_z.size() << "): should be "
+                   << inp.num_grid_z * inp.num_grid_r * inp.num_grid_phi);
+    CELER_VALIDATE(
+        inp.field_r.size() == inp.field_z.size(),
+        << "invalid field length (field_r size=" << inp.field_r.size()
+        << "): should be " << inp.field_z.size());
+    CELER_VALIDATE(
+        inp.field_phi.size() == inp.field_z.size(),
+        << "invalid field length (field_phi size=" << inp.field_phi.size()
+        << "): should be " << inp.field_z.size());
+
+    // Throw a runtime error if any driver options are invalid
+    validate_input(inp.driver_options);
+
+    auto host_data = [&inp] {
+        HostVal<RZPhiMapFieldParamsData> host;
+
+        host.grids.data_r = UniformGridData::from_bounds(
+            inp.min_r, inp.max_r, inp.num_grid_r);
+        host.grids.data_z = UniformGridData::from_bounds(
+            inp.min_z, inp.max_z, inp.num_grid_z);
+
+        // For phi, ensure we're creating a periodic grid
+        // If the input specifies a full circle, adjust the grid to handle
+        // periodicity
+
+        bool is_full_circle = soft_zero(
+            std::fabs((inp.max_phi - inp.min_phi) - 2. * constants::pi));
+        if (is_full_circle)
path: src/celeritas/field/RZPhiMapFieldParams.cc
user: login: esseivaju
body: With the tweak to `find_interp`, a `Grid<Quantity>` would be fine.
created_at: 2025-03-14T22:54:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,172 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/RZPhiMapMagneticField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <algorithm>
+#include <cmath>
+#include <memory>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/ext/GeantUnits.hh"
+#include "celeritas/field/RZPhiMapField.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+#include "celeritas/field/RZPhiMapFieldParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Generates input for RZPhiMapField params with configurable nonuniform grid
+ * dimensions in native Geant4 units. This must be called after
+ * G4RunManager::Initialize as it will retrieve the G4FieldManager's field to
+ * sample it.
+ */
+class RZPhiMapFieldSampler
+{
+  public:
+    // Generate field with user-defined grid
+    RZPhiMapFieldParams::Input
+    operator()(std::vector<real_type> r_grid,
+               std::vector<real_type> z_grid,
+               std::vector<real_type> phi_values) const
path: src/accel/RZPhiMapMagneticField.hh
user: login: sethrj
body: Const references, and this should be in a .cc file
created_at: 2025-03-17T04:48:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,172 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/RZPhiMapMagneticField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <algorithm>
+#include <cmath>
+#include <memory>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/ext/GeantUnits.hh"
+#include "celeritas/field/RZPhiMapField.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+#include "celeritas/field/RZPhiMapFieldParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Generates input for RZPhiMapField params with configurable nonuniform grid
+ * dimensions in native Geant4 units. This must be called after
+ * G4RunManager::Initialize as it will retrieve the G4FieldManager's field to
+ * sample it.
+ */
+class RZPhiMapFieldSampler
+{
+  public:
+    // Generate field with user-defined grid
+    RZPhiMapFieldParams::Input
+    operator()(std::vector<real_type> r_grid,
+               std::vector<real_type> z_grid,
+               std::vector<real_type> phi_values) const
+    {
+        RZPhiMapFieldParams::Input field_input;
+        field_input.grid_r.reserve(r_grid.size());
+        field_input.grid_z.reserve(z_grid.size());
+        field_input.grid_phi.reserve(phi_values.size());
+
+        // Convert from geant
+        std::transform(
+            r_grid.cbegin(),
+            r_grid.cend(),
+            std::back_inserter(field_input.grid_r),
+            [](real_type r) { return convert_from_geant(r, clhep_length); });
+        std::transform(
+            z_grid.cbegin(),
+            z_grid.cend(),
+            std::back_inserter(field_input.grid_z),
+            [](real_type z) { return convert_from_geant(z, clhep_length); });
+
+        //  Convert phi values to Turn type
+        std::transform(
+            phi_values.cbegin(),
+            phi_values.cend(),
+            std::back_inserter(field_input.grid_phi),
+            [](real_type phi) { return native_value_to<Turn>(phi); });
+
+        size_type const nr = field_input.grid_r.size();
+        size_type const nz = field_input.grid_z.size();
+        size_type const nphi = field_input.grid_phi.size();
+        size_type const total_points = nr * nz * nphi;
+
+        field_input.field_r.resize(total_points);
+        field_input.field_phi.resize(total_points);
+        field_input.field_z.resize(total_points);
+
+        Array<size_type, 3> const dims{nphi, nr, nz};
+        HyperslabIndexer const flat_index{dims};
+        auto& field = *G4TransportationManager::GetTransportationManager()
+                           ->GetFieldManager()
+                           ->GetDetectorField();
path: src/accel/RZPhiMapMagneticField.hh
user: login: sethrj
body: We should add assertions in here , no telling when some of these may be uninitailized
created_at: 2025-03-17T04:49:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,172 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/RZPhiMapMagneticField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <algorithm>
+#include <cmath>
+#include <memory>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/ext/GeantUnits.hh"
+#include "celeritas/field/RZPhiMapField.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+#include "celeritas/field/RZPhiMapFieldParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Generates input for RZPhiMapField params with configurable nonuniform grid
+ * dimensions in native Geant4 units. This must be called after
+ * G4RunManager::Initialize as it will retrieve the G4FieldManager's field to
+ * sample it.
+ */
+class RZPhiMapFieldSampler
+{
+  public:
+    // Generate field with user-defined grid
+    RZPhiMapFieldParams::Input
+    operator()(std::vector<real_type> r_grid,
+               std::vector<real_type> z_grid,
+               std::vector<real_type> phi_values) const
+    {
+        RZPhiMapFieldParams::Input field_input;
+        field_input.grid_r.reserve(r_grid.size());
+        field_input.grid_z.reserve(z_grid.size());
+        field_input.grid_phi.reserve(phi_values.size());
+
+        // Convert from geant
+        std::transform(
+            r_grid.cbegin(),
+            r_grid.cend(),
+            std::back_inserter(field_input.grid_r),
+            [](real_type r) { return convert_from_geant(r, clhep_length); });
+        std::transform(
+            z_grid.cbegin(),
+            z_grid.cend(),
+            std::back_inserter(field_input.grid_z),
+            [](real_type z) { return convert_from_geant(z, clhep_length); });
+
+        //  Convert phi values to Turn type
+        std::transform(
+            phi_values.cbegin(),
+            phi_values.cend(),
+            std::back_inserter(field_input.grid_phi),
+            [](real_type phi) { return native_value_to<Turn>(phi); });
+
+        size_type const nr = field_input.grid_r.size();
+        size_type const nz = field_input.grid_z.size();
+        size_type const nphi = field_input.grid_phi.size();
+        size_type const total_points = nr * nz * nphi;
+
+        field_input.field_r.resize(total_points);
+        field_input.field_phi.resize(total_points);
+        field_input.field_z.resize(total_points);
+
+        Array<size_type, 3> const dims{nphi, nr, nz};
+        HyperslabIndexer const flat_index{dims};
+        auto& field = *G4TransportationManager::GetTransportationManager()
+                           ->GetFieldManager()
+                           ->GetDetectorField();
+        Array<G4double, 3> bfield;
+        for (size_type iphi = 0; iphi < nphi; ++iphi)
+        {
+            real_type phi = native_value_from(field_input.grid_phi[iphi]);
+            for (size_type ir = 0; ir < nr; ++ir)
+            {
+                real_type r = field_input.grid_r[ir];
+                for (size_type iz = 0; iz < nz; ++iz)
+                {
+                    auto idx = flat_index(iphi, ir, iz);
+                    Array<G4double, 4> pos = {r * std::cos(phi),
+                                              r * std::sin(phi),
+                                              field_input.grid_z[iz],
+                                              0};
+                    field.GetFieldValue(pos.data(), bfield.data());
+
+                    // values in cylindrical vector space
+                    field_input.field_r[idx] = convert_from_geant(
+                        bfield[0] * std::cos(phi) + bfield[1] * std::sin(phi),
+                        clhep_field);
+                    field_input.field_phi[idx] = convert_from_geant(
+                        -bfield[0] * std::sin(phi) + bfield[1] * std::cos(phi),
+                        clhep_field);
+                    field_input.field_z[idx]
path: src/accel/RZPhiMapMagneticField.hh
user: login: sethrj
body: Is it not better to have the 'field input' be a 3-D vector field than having three separate inputs? so [nr][nphi][nz][3], with maybe an enum for the cylindrical axes 
created_at: 2025-03-17T04:53:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,172 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/RZPhiMapMagneticField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <algorithm>
+#include <cmath>
+#include <memory>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/ext/GeantUnits.hh"
+#include "celeritas/field/RZPhiMapField.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+#include "celeritas/field/RZPhiMapFieldParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Generates input for RZPhiMapField params with configurable nonuniform grid
+ * dimensions in native Geant4 units. This must be called after
+ * G4RunManager::Initialize as it will retrieve the G4FieldManager's field to
+ * sample it.
+ */
+class RZPhiMapFieldSampler
+{
+  public:
+    // Generate field with user-defined grid
+    RZPhiMapFieldParams::Input
+    operator()(std::vector<real_type> r_grid,
+               std::vector<real_type> z_grid,
+               std::vector<real_type> phi_values) const
+    {
+        RZPhiMapFieldParams::Input field_input;
+        field_input.grid_r.reserve(r_grid.size());
+        field_input.grid_z.reserve(z_grid.size());
+        field_input.grid_phi.reserve(phi_values.size());
+
+        // Convert from geant
+        std::transform(
+            r_grid.cbegin(),
+            r_grid.cend(),
+            std::back_inserter(field_input.grid_r),
+            [](real_type r) { return convert_from_geant(r, clhep_length); });
+        std::transform(
+            z_grid.cbegin(),
+            z_grid.cend(),
+            std::back_inserter(field_input.grid_z),
+            [](real_type z) { return convert_from_geant(z, clhep_length); });
+
+        //  Convert phi values to Turn type
+        std::transform(
+            phi_values.cbegin(),
+            phi_values.cend(),
+            std::back_inserter(field_input.grid_phi),
+            [](real_type phi) { return native_value_to<Turn>(phi); });
+
+        size_type const nr = field_input.grid_r.size();
+        size_type const nz = field_input.grid_z.size();
+        size_type const nphi = field_input.grid_phi.size();
+        size_type const total_points = nr * nz * nphi;
+
+        field_input.field_r.resize(total_points);
+        field_input.field_phi.resize(total_points);
+        field_input.field_z.resize(total_points);
+
+        Array<size_type, 3> const dims{nphi, nr, nz};
+        HyperslabIndexer const flat_index{dims};
+        auto& field = *G4TransportationManager::GetTransportationManager()
+                           ->GetFieldManager()
+                           ->GetDetectorField();
+        Array<G4double, 3> bfield;
+        for (size_type iphi = 0; iphi < nphi; ++iphi)
+        {
+            real_type phi = native_value_from(field_input.grid_phi[iphi]);
+            for (size_type ir = 0; ir < nr; ++ir)
+            {
+                real_type r = field_input.grid_r[ir];
+                for (size_type iz = 0; iz < nz; ++iz)
+                {
+                    auto idx = flat_index(iphi, ir, iz);
+                    Array<G4double, 4> pos = {r * std::cos(phi),
+                                              r * std::sin(phi),
+                                              field_input.grid_z[iz],
+                                              0};
+                    field.GetFieldValue(pos.data(), bfield.data());
+
+                    // values in cylindrical vector space
+                    field_input.field_r[idx] = convert_from_geant(
+                        bfield[0] * std::cos(phi) + bfield[1] * std::sin(phi),
+                        clhep_field);
+                    field_input.field_phi[idx] = convert_from_geant(
+                        -bfield[0] * std::sin(phi) + bfield[1] * std::cos(phi),
+                        clhep_field);
+                    field_input.field_z[idx]
path: src/accel/RZPhiMapMagneticField.hh
user: login: sethrj
body: Also a helper function to convert cartesian->polar/cyl would be good here.
created_at: 2025-03-17T04:54:33Z
author_association: MEMBER
diff_hunk: @@ -132,6 +133,12 @@ void ProblemSetup::operator()(inp::Problem& p) const
         CELER_LOG(debug) << "Getting RZ map field";
         p.field = u->get_field();
     }
+    else if (auto* u
+             = so.make_along_step.target<RZPhiMapFieldAlongStepFactory>())
+    {
+        CELER_LOG(debug) << "Getting RZPhi map field";
path: src/accel/SetupOptions.cc
user: login: sethrj
body: Yeah this won't be used till #1632 sorry for the confusion:(
created_at: 2025-03-17T04:56:24Z
author_association: MEMBER
diff_hunk: @@ -19,11 +21,10 @@ namespace celeritas
  * The resulting index will be in [0, grid.size() - 1)
  * and the fraction will be in [0, 1).
  */
-template<class T>
 struct FindInterp
 {
     size_type index{};  //!< Lower index into the grid
-    T fraction{};  //!< Fraction of the value between its neighbors
+    real_type fraction{};  //!< Fraction of the value between its neighbors
path: src/corecel/grid/FindInterp.hh
user: login: sethrj
body: I think we want to leave this templated? Particularly because for the field I think we want to store and compute in single precision. 
created_at: 2025-03-17T04:58:17Z
author_association: MEMBER
diff_hunk: @@ -147,6 +157,146 @@ TEST_F(RZMapFieldTest, all)
                                                3.757196366787};
     EXPECT_VEC_NEAR(expected_field, actual, real_type{1e-7});
 }
+
+using RZPhiMapFieldTest = ::celeritas::test::Test;
+
+TEST_F(RZPhiMapFieldTest, all)
+{
+    RZPhiMapFieldParams field_map = [] {
+        RZPhiMapFieldInput inp;
+        // Set up grid points in cylindrical coordinates
+        inp.grid_r = {0.0, 50.0, 100.0, 150.0};
+        inp.grid_z = {-150.0, -100.0, -50.0, 0.0, 50.0, 100.0, 150.0};
+        Array<real_type, 7> const phi_values = {
+            0.0, 1.0 / 6.0, 2.0 / 6.0, 3.0 / 6.0, 4.0 / 6.0, 5.0 / 6.0, 1.0};
+        inp.grid_phi.resize(phi_values.size());
+        std::transform(phi_values.begin(),
+                       phi_values.end(),
+                       inp.grid_phi.begin(),
+                       [](real_type phi) { return Turn{phi}; });
+
+        // Initialize field values with a predominantly z-directed field
+        size_type const nr = inp.grid_r.size();
+        size_type const nz = inp.grid_z.size();
+        size_type const nphi = inp.grid_phi.size();
+        Array<size_type, 3> const dims{nphi, nr, nz};
+        size_type const total_points = nr * nz * nphi;
+
+        // Resize each component of the field
+        inp.field_r.resize(total_points);
+        inp.field_phi.resize(total_points);
+        inp.field_z.resize(total_points);
+
+        // Fill with a simple field pattern
+        HyperslabIndexer<3> const flat_index{dims};
+        for (size_type ir = 0; ir < nr; ++ir)
+        {
+            real_type r = inp.grid_r[ir];
+            for (size_type iz = 0; iz < nz; ++iz)
+            {
+                for (size_type iphi = 0; iphi < nphi; ++iphi)
+                {
+                    // Convert turns to radians
+                    real_type phi = inp.grid_phi[iphi].value() * 2
+                                    * constants::pi;
+
+                    // Index calculation for 3D array
+                    auto idx = flat_index(iphi, ir, iz);
+
+                    // Set field components
+                    inp.field_r[idx] = 0.02 * r / 100.0 * std::cos(phi);
+                    inp.field_phi[idx] = 0.02 * r / 100.0 * std::sin(phi);
+                    inp.field_z[idx] = 3.8 - 0.0005 * (r / 100.0) * (r / 100.0);
+                }
+            }
+        }
+        return RZPhiMapFieldParams(inp);
+    }();
+
+    RZPhiMapField calc_field(field_map.host_ref());
+
+    // Define samples in cylindrical coordinates
+    size_type const nr_samples = 2;
+    size_type const nphi_samples = 2;
+    size_type const nz_samples = 2;
+
+    // Define sampling ranges
+    real_type r_min = 10.0;
+    real_type r_max = 100.0;
+    real_type phi_min = 0.0;
+    real_type phi_max = constants::pi.value() / 2;
+    real_type z_min = -100.0;
+    real_type z_max = 100.0;
+
+    std::vector<real_type> actual;
+
+    for (size_type ir = 0; ir < nr_samples; ++ir)
+    {
+        real_type r = r_min + ir * (r_max - r_min) / (nr_samples - 1);
+        for (size_type iphi = 0; iphi < nphi_samples; ++iphi)
+        {
+            real_type phi = phi_min
+                            + iphi * (phi_max - phi_min) / (nphi_samples - 1);
+            for (size_type iz = 0; iz < nz_samples; ++iz)
+            {
+                real_type z = z_min + iz * (z_max - z_min) / (nz_samples - 1);
+
+                // Convert cylindrical to Cartesian coordinates for field
+                // lookup
+                Real3 pos{r * std::cos(phi), r * std::sin(phi), z};
+
+                Real3 field = calc_field(pos);
+                for (real_type f : field)
+                {
+                    actual.push_back(f);
+                }
+            }
+        }
+    }
+
+    // Expected field values at the 8 sample points (2×2×2 grid in r, phi, z)
+    static real_type const expected_field[] = {// r=10cm, phi=0, z=-100cm
path: test/celeritas/field/Fields.test.cc
user: login: sethrj
body: The formatting here got borked
created_at: 2025-03-17T04:59:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZPhi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_z_;
+    NonuniformGrid<real_type> const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.r, params_.grids.storage}
+    , grid_z_{params_.grids.z, params_.grids.storage}
+    , grid_phi_{params_.grids.phi, params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    real_type phi = std::fmod(atan2(pos[1], pos[0]), 2 * constants::pi.value());
+    auto turn_phi{native_value_to<Turn>(phi)};
+
+    // Check if point is within field map bounds
+    if (!params_.valid(pos[2], r, turn_phi))
+        return value;
+
+    // Find interpolation points for given r, z, and phi
+    auto [ir, wr1] = find_interp<NonuniformGrid<real_type>>(grid_r_, r);
+    auto [iz, wz1] = find_interp<NonuniformGrid<real_type>>(grid_z_, pos[2]);
+    auto [iphi, wphi1]
+        = find_interp<NonuniformGrid<real_type>>(grid_phi_, turn_phi.value());
+
+    auto get_field = [this](size_type iz, size_type ir, size_type iphi) {
+        return params_.fieldmap[params_.id(iphi, ir, iz)];
+    };
+
+    // Get the eight corner values for Z component of the field
+    // clang-format off
+    real_type v000 = get_field(iz,     ir,     iphi    ).value_z;
+    real_type v001 = get_field(iz,     ir,     iphi + 1).value_z;
+    real_type v010 = get_field(iz,     ir + 1, iphi    ).value_z;
+    real_type v011 = get_field(iz,     ir + 1, iphi + 1).value_z;
+    real_type v100 = get_field(iz + 1, ir,     iphi    ).value_z;
+    real_type v101 = get_field(iz + 1, ir,     iphi + 1).value_z;
+    real_type v110 = get_field(iz + 1, ir + 1, iphi    ).value_z;
+    real_type v111 = get_field(iz + 1, ir + 1, iphi + 1).value_z;
+    // clang-format on
+
+    // Trilinear interpolation formula for Z component
+    value[2] = (1 - wz1)
+                   * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                      + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+               + wz1
+                     * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                        + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Get the eight corner values for R component of the field
+    // clang-format off
+    v000 = get_field(iz,     ir,     iphi    ).value_r;
+    v001 = get_field(iz,     ir,     iphi + 1).value_r;
+    v010 = get_field(iz,     ir + 1, iphi    ).value_r;
+    v011 = get_field(iz,     ir + 1, iphi + 1).value_r;
+    v100 = get_field(iz + 1, ir,     iphi    ).value_r;
+    v101 = get_field(iz + 1, ir,     iphi + 1).value_r;
+    v110 = get_field(iz + 1, ir + 1, iphi    ).value_r;
+    v111 = get_field(iz + 1, ir + 1, iphi + 1).value_r;
+    // clang-format on
+
+    // Interpolate for R component
+    real_type field_r = (1 - wz1)
+                            * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                               + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+                        + wz1
+                              * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                                 + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Get the eight corner values for Phi component of the field
+    // clang-format off
+    v000 = get_field(iz,     ir,     iphi    ).value_phi;
+    v001 = get_field(iz,     ir,     iphi + 1).value_phi;
+    v010 = get_field(iz,     ir + 1, iphi    ).value_phi;
+    v011 = get_field(iz,     ir + 1, iphi + 1).value_phi;
+    v100 = get_field(iz + 1, ir,     iphi    ).value_phi;
+    v101 = get_field(iz + 1, ir,     iphi + 1).value_phi;
+    v110 = get_field(iz + 1, ir + 1, iphi    ).value_phi;
+    v111 = get_field(iz + 1, ir + 1, iphi + 1).value_phi;
+    // clang-format on
+
+    // Interpolate for Phi component
+    real_type field_phi
+        = (1 - wz1)
+              * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                 + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+          + wz1
+                * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                   + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Project cylindrical components to Cartesian coordinates
+    // default for r == 0
+    real_type cos_phi = 1.;
+    real_type sin_phi = 0.;
+
+    if (r != 0)
+    {
+        cos_phi = pos[0] / r;
+        sin_phi = pos[1] / r;
+    }
+    value[0] = field_r * cos_phi - field_phi * sin_phi;
+    value[1] = field_r * sin_phi + field_phi * cos_phi;
+
+    return value;
+}
path: src/celeritas/field/RZPhiMapField.hh
user: login: sethrj
body: After all the madness of the next two weeks is done it would be interesting to look at how refactoring this might be able to reduce register usage and see how that correlates to performance. Great job on getting this to work!
created_at: 2025-03-17T05:06:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZPhi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_z_;
+    NonuniformGrid<real_type> const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.r, params_.grids.storage}
+    , grid_z_{params_.grids.z, params_.grids.storage}
+    , grid_phi_{params_.grids.phi, params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    real_type phi = std::fmod(atan2(pos[1], pos[0]), 2 * constants::pi.value());
+    auto turn_phi{native_value_to<Turn>(phi)};
+
+    // Check if point is within field map bounds
+    if (!params_.valid(pos[2], r, turn_phi))
+        return value;
+
+    // Find interpolation points for given r, z, and phi
+    auto [ir, wr1] = find_interp<NonuniformGrid<real_type>>(grid_r_, r);
+    auto [iz, wz1] = find_interp<NonuniformGrid<real_type>>(grid_z_, pos[2]);
+    auto [iphi, wphi1]
+        = find_interp<NonuniformGrid<real_type>>(grid_phi_, turn_phi.value());
+
+    auto get_field = [this](size_type iz, size_type ir, size_type iphi) {
+        return params_.fieldmap[params_.id(iphi, ir, iz)];
+    };
+
+    // Get the eight corner values for Z component of the field
+    // clang-format off
+    real_type v000 = get_field(iz,     ir,     iphi    ).value_z;
+    real_type v001 = get_field(iz,     ir,     iphi + 1).value_z;
+    real_type v010 = get_field(iz,     ir + 1, iphi    ).value_z;
+    real_type v011 = get_field(iz,     ir + 1, iphi + 1).value_z;
+    real_type v100 = get_field(iz + 1, ir,     iphi    ).value_z;
+    real_type v101 = get_field(iz + 1, ir,     iphi + 1).value_z;
+    real_type v110 = get_field(iz + 1, ir + 1, iphi    ).value_z;
+    real_type v111 = get_field(iz + 1, ir + 1, iphi + 1).value_z;
+    // clang-format on
+
+    // Trilinear interpolation formula for Z component
+    value[2] = (1 - wz1)
+                   * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                      + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+               + wz1
+                     * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                        + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Get the eight corner values for R component of the field
+    // clang-format off
+    v000 = get_field(iz,     ir,     iphi    ).value_r;
+    v001 = get_field(iz,     ir,     iphi + 1).value_r;
+    v010 = get_field(iz,     ir + 1, iphi    ).value_r;
+    v011 = get_field(iz,     ir + 1, iphi + 1).value_r;
+    v100 = get_field(iz + 1, ir,     iphi    ).value_r;
+    v101 = get_field(iz + 1, ir,     iphi + 1).value_r;
+    v110 = get_field(iz + 1, ir + 1, iphi    ).value_r;
+    v111 = get_field(iz + 1, ir + 1, iphi + 1).value_r;
+    // clang-format on
+
+    // Interpolate for R component
+    real_type field_r = (1 - wz1)
+                            * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                               + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+                        + wz1
+                              * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                                 + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Get the eight corner values for Phi component of the field
+    // clang-format off
+    v000 = get_field(iz,     ir,     iphi    ).value_phi;
+    v001 = get_field(iz,     ir,     iphi + 1).value_phi;
+    v010 = get_field(iz,     ir + 1, iphi    ).value_phi;
+    v011 = get_field(iz,     ir + 1, iphi + 1).value_phi;
+    v100 = get_field(iz + 1, ir,     iphi    ).value_phi;
+    v101 = get_field(iz + 1, ir,     iphi + 1).value_phi;
+    v110 = get_field(iz + 1, ir + 1, iphi    ).value_phi;
+    v111 = get_field(iz + 1, ir + 1, iphi + 1).value_phi;
+    // clang-format on
+
+    // Interpolate for Phi component
+    real_type field_phi
+        = (1 - wz1)
+              * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                 + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+          + wz1
+                * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                   + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Project cylindrical components to Cartesian coordinates
+    // default for r == 0
+    real_type cos_phi = 1.;
+    real_type sin_phi = 0.;
+
+    if (r != 0)
+    {
+        cos_phi = pos[0] / r;
+        sin_phi = pos[1] / r;
+    }
+    value[0] = field_r * cos_phi - field_phi * sin_phi;
+    value[1] = field_r * sin_phi + field_phi * cos_phi;
+
+    return value;
+}
path: src/celeritas/field/RZPhiMapField.hh
user: login: amandalund
body: We could also probably reduce some of the duplication here—e.g. by storing the `fieldmap` as an array of `Real3` instead of `struct RZPhiMapElement` and iterating over the coordinates.
created_at: 2025-03-17T16:32:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: amandalund
body: I wish the `HyperslabIndexer` didn't require creating an extra array for the dimensions... because of that it often looks cleaner/clearer/requires less code just to write out the index calculation (@sethrj).
created_at: 2025-03-17T16:39:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: sethrj
body: I'm more than happy to redesign the multi-D indexer (new names, arrays by value instead of reference, overloading `operator()` instead of having two separate classes, or just naming them instead of making them function-like). I think we'd also get improved clarity here if we had 
```c++
enum class CylAxis
{
    phi,
    r,
    z
    size_
};
```
with `EnumArray<CylAxis, ItemRange<real_type>> grid`, and (once we pass values into the indexer) a helper function `Indexer::from_sizes(...)` or something.
created_at: 2025-03-17T17:04:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: esseivaju
body: I'll refactor that to an `EnumArray`
created_at: 2025-03-17T17:16:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZPhi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_z_;
+    NonuniformGrid<real_type> const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.r, params_.grids.storage}
+    , grid_z_{params_.grids.z, params_.grids.storage}
+    , grid_phi_{params_.grids.phi, params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    real_type phi = std::fmod(atan2(pos[1], pos[0]), 2 * constants::pi.value());
+    auto turn_phi{native_value_to<Turn>(phi)};
+
+    // Check if point is within field map bounds
+    if (!params_.valid(pos[2], r, turn_phi))
+        return value;
+
+    // Find interpolation points for given r, z, and phi
+    auto [ir, wr1] = find_interp<NonuniformGrid<real_type>>(grid_r_, r);
+    auto [iz, wz1] = find_interp<NonuniformGrid<real_type>>(grid_z_, pos[2]);
+    auto [iphi, wphi1]
+        = find_interp<NonuniformGrid<real_type>>(grid_phi_, turn_phi.value());
+
+    auto get_field = [this](size_type iz, size_type ir, size_type iphi) {
+        return params_.fieldmap[params_.id(iphi, ir, iz)];
+    };
+
+    // Get the eight corner values for Z component of the field
+    // clang-format off
+    real_type v000 = get_field(iz,     ir,     iphi    ).value_z;
+    real_type v001 = get_field(iz,     ir,     iphi + 1).value_z;
+    real_type v010 = get_field(iz,     ir + 1, iphi    ).value_z;
+    real_type v011 = get_field(iz,     ir + 1, iphi + 1).value_z;
+    real_type v100 = get_field(iz + 1, ir,     iphi    ).value_z;
+    real_type v101 = get_field(iz + 1, ir,     iphi + 1).value_z;
+    real_type v110 = get_field(iz + 1, ir + 1, iphi    ).value_z;
+    real_type v111 = get_field(iz + 1, ir + 1, iphi + 1).value_z;
+    // clang-format on
+
+    // Trilinear interpolation formula for Z component
+    value[2] = (1 - wz1)
+                   * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                      + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+               + wz1
+                     * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                        + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Get the eight corner values for R component of the field
+    // clang-format off
+    v000 = get_field(iz,     ir,     iphi    ).value_r;
+    v001 = get_field(iz,     ir,     iphi + 1).value_r;
+    v010 = get_field(iz,     ir + 1, iphi    ).value_r;
+    v011 = get_field(iz,     ir + 1, iphi + 1).value_r;
+    v100 = get_field(iz + 1, ir,     iphi    ).value_r;
+    v101 = get_field(iz + 1, ir,     iphi + 1).value_r;
+    v110 = get_field(iz + 1, ir + 1, iphi    ).value_r;
+    v111 = get_field(iz + 1, ir + 1, iphi + 1).value_r;
+    // clang-format on
+
+    // Interpolate for R component
+    real_type field_r = (1 - wz1)
+                            * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                               + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+                        + wz1
+                              * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                                 + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Get the eight corner values for Phi component of the field
+    // clang-format off
+    v000 = get_field(iz,     ir,     iphi    ).value_phi;
+    v001 = get_field(iz,     ir,     iphi + 1).value_phi;
+    v010 = get_field(iz,     ir + 1, iphi    ).value_phi;
+    v011 = get_field(iz,     ir + 1, iphi + 1).value_phi;
+    v100 = get_field(iz + 1, ir,     iphi    ).value_phi;
+    v101 = get_field(iz + 1, ir,     iphi + 1).value_phi;
+    v110 = get_field(iz + 1, ir + 1, iphi    ).value_phi;
+    v111 = get_field(iz + 1, ir + 1, iphi + 1).value_phi;
+    // clang-format on
+
+    // Interpolate for Phi component
+    real_type field_phi
+        = (1 - wz1)
+              * ((1 - wr1) * ((1 - wphi1) * v000 + wphi1 * v001)
+                 + wr1 * ((1 - wphi1) * v010 + wphi1 * v011))
+          + wz1
+                * ((1 - wr1) * ((1 - wphi1) * v100 + wphi1 * v101)
+                   + wr1 * ((1 - wphi1) * v110 + wphi1 * v111));
+
+    // Project cylindrical components to Cartesian coordinates
+    // default for r == 0
+    real_type cos_phi = 1.;
+    real_type sin_phi = 0.;
+
+    if (r != 0)
+    {
+        cos_phi = pos[0] / r;
+        sin_phi = pos[1] / r;
+    }
+    value[0] = field_r * cos_phi - field_phi * sin_phi;
+    value[1] = field_r * sin_phi + field_phi * cos_phi;
+
+    return value;
+}
path: src/celeritas/field/RZPhiMapField.hh
user: login: esseivaju
body: That would make sense, I was following the scheme we have for the `RZMapField`, but for 2D the duplication isn't as bad
created_at: 2025-03-17T17:17:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: esseivaju
body: should we order it as R-Phi-Z, which is the standard ordering?
created_at: 2025-03-17T17:18:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,126 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    EnumArray<CylAxis, size_type> grid_size;
+    EnumArray<CylAxis, ItemRange<real_type>> axes;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[CylAxis::Phi] > 1
+               && grid_size[CylAxis::R] > 1 && grid_size[CylAxis::Z] > 1
+               && axes[CylAxis::Phi].size() == grid_size[CylAxis::Phi]
+               && axes[CylAxis::R].size() == grid_size[CylAxis::R]
+               && axes[CylAxis::Z].size() == grid_size[CylAxis::Z];
+    }
+
+    //! Assign from another set of data
+    template<Ownership W2, MemSpace M2>
+    RZPhiMapGridData& operator=(RZPhiMapGridData<W2, M2> const& other)
+    {
+        CELER_EXPECT(other);
+        storage = other.storage;
+        grid_size = other.grid_size;
+        axes = other.axes;
+        return *this;
+    }
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Device data for interpolating field values.
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapFieldParamsData
+{
+    //! Grids of FieldMap
+    RZPhiMapGridData<W, M> grids;
+
+    //! Options for FieldDriver
+    FieldDriverOptions options;
+
+    //! Index of FieldMap Collection
+    using ElementId = ItemId<size_type>;
+
+    template<class T>
+    using ElementItems = Collection<T, W, M, ElementId>;
+
+    //! FieldMap data
+    ElementItems<EnumArray<CylAxis, real_type>> fieldmap;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !fieldmap.empty();
+    }
+
+    //! Check if the given position is within the field map bounds
+    inline CELER_FUNCTION bool valid(real_type z, real_type r, Turn phi) const
+    {
+        CELER_EXPECT(grids);
+        return (
+            z >= grids.storage[grids.axes[CylAxis::Z].front()]
+            && z <= grids.storage[grids.axes[CylAxis::Z].back()]
+            && r >= grids.storage[grids.axes[CylAxis::R].front()]
+            && r <= grids.storage[grids.axes[CylAxis::R].back()]
+            && phi.value() >= grids.storage[grids.axes[CylAxis::Phi].front()]
+            && phi.value() <= grids.storage[grids.axes[CylAxis::Phi].back()]);
+    }
+
+    inline CELER_FUNCTION ElementId id(size_type idx_phi,
+                                       size_type idx_r,
+                                       size_type idx_z) const
+    {
+        CELER_EXPECT(grids);
+        // HyperSlabIndexer does not take Array<T const>
+        Array<size_type, static_cast<size_type>(CylAxis::size_)> tmp{
+            grids.grid_size[CylAxis::Phi],
+            grids.grid_size[CylAxis::R],
+            grids.grid_size[CylAxis::Z]};
+        return ElementId{HyperslabIndexer{tmp}(idx_phi, idx_r, idx_z)};
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: esseivaju
body: not the cleanest, I think we have to create a tmp array for now because `HyperslabIndexer` doesn't take `Array<T const>`...
created_at: 2025-03-17T19:35:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,155 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZPhi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_z_;
+    NonuniformGrid<real_type> const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.axes[CylAxis::R], params_.grids.storage}
+    , grid_z_{params_.grids.axes[CylAxis::Z], params_.grids.storage}
+    , grid_phi_{params_.grids.axes[CylAxis::Phi], params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    real_type phi = atan2(pos[1], pos[0]);
+    if (phi < 0)
+    {
+        phi += TwoPi::value();
+    }
path: src/celeritas/field/RZPhiMapField.hh
user: login: esseivaju
body: Not using `atan2Turn` because I am expecting a turn between [0;1] instead of [-0.5;0.5]
created_at: 2025-03-17T19:36:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,155 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZPhi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_z_;
+    NonuniformGrid<real_type> const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.axes[CylAxis::R], params_.grids.storage}
+    , grid_z_{params_.grids.axes[CylAxis::Z], params_.grids.storage}
+    , grid_phi_{params_.grids.axes[CylAxis::Phi], params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    real_type phi = atan2(pos[1], pos[0]);
+    if (phi < 0)
+    {
+        phi += TwoPi::value();
+    }
path: src/celeritas/field/RZPhiMapField.hh
user: login: sethrj
body: You'd have to do the same thing right?
```suggestion
    auto phi = atan2turn(pos[1], pos[0]);
    if (phi < zero_quantity())
    {
        phi += Turn_t<real_type>{1};
    }
```
The whole point of using Turns is you don't have to accumulate floating point error by mucking with pi. 
created_at: 2025-03-17T20:15:13Z
author_association: MEMBER
diff_hunk: @@ -191,6 +191,16 @@ enum class NuclearFormFactorType
     size_
 };
 
+//---------------------------------------------------------------------------//
+//! Cylindrical coordinates indices
+enum class CylAxis
+{
+    Phi = 0,
+    R = 1,
+    Z = 2,
path: src/celeritas/Types.hh
user: login: sethrj
body: To conform to our style conventions:
```suggestion
    phi = 0,
    r = 1,
    z = 2,
```
created_at: 2025-03-17T20:17:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,126 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    EnumArray<CylAxis, size_type> grid_size;
+    EnumArray<CylAxis, ItemRange<real_type>> axes;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[CylAxis::Phi] > 1
+               && grid_size[CylAxis::R] > 1 && grid_size[CylAxis::Z] > 1
+               && axes[CylAxis::Phi].size() == grid_size[CylAxis::Phi]
+               && axes[CylAxis::R].size() == grid_size[CylAxis::R]
+               && axes[CylAxis::Z].size() == grid_size[CylAxis::Z];
+    }
+
+    //! Assign from another set of data
+    template<Ownership W2, MemSpace M2>
+    RZPhiMapGridData& operator=(RZPhiMapGridData<W2, M2> const& other)
+    {
+        CELER_EXPECT(other);
+        storage = other.storage;
+        grid_size = other.grid_size;
+        axes = other.axes;
+        return *this;
+    }
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Device data for interpolating field values.
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapFieldParamsData
+{
+    //! Grids of FieldMap
+    RZPhiMapGridData<W, M> grids;
+
+    //! Options for FieldDriver
+    FieldDriverOptions options;
+
+    //! Index of FieldMap Collection
+    using ElementId = ItemId<size_type>;
+
+    template<class T>
+    using ElementItems = Collection<T, W, M, ElementId>;
+
+    //! FieldMap data
+    ElementItems<EnumArray<CylAxis, real_type>> fieldmap;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !fieldmap.empty();
+    }
+
+    //! Check if the given position is within the field map bounds
+    inline CELER_FUNCTION bool valid(real_type z, real_type r, Turn phi) const
+    {
+        CELER_EXPECT(grids);
+        return (
+            z >= grids.storage[grids.axes[CylAxis::Z].front()]
+            && z <= grids.storage[grids.axes[CylAxis::Z].back()]
+            && r >= grids.storage[grids.axes[CylAxis::R].front()]
+            && r <= grids.storage[grids.axes[CylAxis::R].back()]
+            && phi.value() >= grids.storage[grids.axes[CylAxis::Phi].front()]
+            && phi.value() <= grids.storage[grids.axes[CylAxis::Phi].back()]);
+    }
+
+    inline CELER_FUNCTION ElementId id(size_type idx_phi,
+                                       size_type idx_r,
+                                       size_type idx_z) const
+    {
+        CELER_EXPECT(grids);
+        // HyperSlabIndexer does not take Array<T const>
+        Array<size_type, static_cast<size_type>(CylAxis::size_)> tmp{
+            grids.grid_size[CylAxis::Phi],
+            grids.grid_size[CylAxis::R],
+            grids.grid_size[CylAxis::Z]};
+        return ElementId{HyperslabIndexer{tmp}(idx_phi, idx_r, idx_z)};
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: sethrj
body: Yeah correct. Future work we'll improve this.
created_at: 2025-03-17T20:28:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldParams.cc
+//---------------------------------------------------------------------------//
+#include "RZPhiMapFieldParams.hh"
+
+#include <algorithm>
+#include <utility>
+#include <vector>
+
+#include "corecel/Assert.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/data/CollectionBuilder.hh"
+#include "corecel/math/SoftEqual.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/field/RZPhiMapField.hh"
+
+#include "RZPhiMapFieldData.hh"
+#include "RZPhiMapFieldInput.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a user-defined field map.
+ */
+RZPhiMapFieldParams::RZPhiMapFieldParams(RZPhiMapFieldInput const& inp)
+{
+    CELER_VALIDATE(
+        inp.grid_z.size() >= 2,
+        << "invalid field parameter (num_grid_z=" << inp.grid_z.size() << ")");
+    CELER_VALIDATE(
+        inp.grid_r.size() >= 2,
+        << "invalid field parameter (num_grid_r=" << inp.grid_r.size() << ")");
+    CELER_VALIDATE(inp.grid_phi.size() >= 2,
+                   << "invalid field parameter (num_grid_phi="
+                   << inp.grid_phi.size() << ")");
+    CELER_VALIDATE(inp.grid_r.front() >= 0,
+                   << "invalid field parameter (min_r=" << inp.grid_r.front()
+                   << ")");
+    CELER_VALIDATE(inp.grid_r.back() > inp.grid_r.front(),
+                   << "invalid field parameter (max_r=" << inp.grid_r.back()
+                   << " <= min_r= " << inp.grid_r.front() << ")");
+    CELER_VALIDATE(inp.grid_z.back() > inp.grid_z.front(),
+                   << "invalid field parameter (max_z=" << inp.grid_z.back()
+                   << " <= min_z= " << inp.grid_z.front() << ")");
+    CELER_VALIDATE(
+        inp.grid_phi.back() > inp.grid_phi.front(),
+        << "invalid field parameter (max_phi=" << inp.grid_phi.back().value()
+        << " <= min_phi= " << inp.grid_phi.front().value() << ")");
+
+    CELER_VALIDATE(
+        inp.field.size()
+            == static_cast<size_type>(CylAxis::size_) * inp.grid_z.size()
+                   * inp.grid_r.size() * inp.grid_phi.size(),
+        << "invalid field length (field size=" << inp.field.size()
+        << "): should be "
+        << 3 * inp.grid_z.size() * inp.grid_r.size() * inp.grid_phi.size());
+    CELER_VALIDATE(soft_zero(inp.grid_phi.front().value()),
+                   << "Phi grid must be a complete circle (grid_phi min="
+                   << inp.grid_phi.front().value() << "): should be 0");
+    CELER_VALIDATE(soft_equal(real_type{1}, inp.grid_phi.back().value()),
+                   << "Phi grid must be a complete circle (grid_phi max="
+                   << inp.grid_phi.back().value() << "): should be 1");
+
+    // Throw a runtime error if any driver options are invalid
+    validate_input(inp.driver_options);
+
+    auto host_data = [&inp] {
+        HostVal<RZPhiMapFieldParamsData> host;
+
+        host.grids.grid_size[CylAxis::Phi] = inp.grid_phi.size();
+        host.grids.grid_size[CylAxis::R] = inp.grid_r.size();
+        host.grids.grid_size[CylAxis::Z] = inp.grid_z.size();
+
+        auto grid = make_builder(&host.grids.storage);
+        grid.reserve(inp.grid_phi.size() + inp.grid_r.size()
+                     + inp.grid_z.size());
+        std::transform(inp.grid_phi.cbegin(),
+                       inp.grid_phi.cend(),
+                       std::back_inserter(grid),
path: src/celeritas/field/RZPhiMapFieldParams.cc
user: login: sethrj
body: Neat trick!
created_at: 2025-03-17T20:30:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: sethrj
body: The "standard" ordering is, based on my experience, a holdover from fortran indexing. We have the chance to start clean and we should take it :) 
created_at: 2025-03-17T20:32:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,155 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <cmath>
+
+#include "corecel/Constants.hh"
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "RZPhiMapFieldData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based RZPhi field
+ * map.
+ */
+class RZPhiMapField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<RZPhiMapFieldParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit RZPhiMapField(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_z_;
+    NonuniformGrid<real_type> const grid_phi_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+RZPhiMapField::RZPhiMapField(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.axes[CylAxis::R], params_.grids.storage}
+    , grid_z_{params_.grids.axes[CylAxis::Z], params_.grids.storage}
+    , grid_phi_{params_.grids.axes[CylAxis::Phi], params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto RZPhiMapField::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    real_type phi = atan2(pos[1], pos[0]);
+    if (phi < 0)
+    {
+        phi += TwoPi::value();
+    }
path: src/celeritas/field/RZPhiMapField.hh
user: login: esseivaju
body: Maybe it's actually better to use turn in [-0.5;0.5], that will be one less branch on GPU
created_at: 2025-03-17T20:36:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,126 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/field/RZPhiMapFieldInput.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    EnumArray<CylAxis, size_type> grid_size;
+    EnumArray<CylAxis, ItemRange<real_type>> axes;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[CylAxis::Phi] > 1
+               && grid_size[CylAxis::R] > 1 && grid_size[CylAxis::Z] > 1
+               && axes[CylAxis::Phi].size() == grid_size[CylAxis::Phi]
+               && axes[CylAxis::R].size() == grid_size[CylAxis::R]
+               && axes[CylAxis::Z].size() == grid_size[CylAxis::Z];
+    }
+
+    //! Assign from another set of data
+    template<Ownership W2, MemSpace M2>
+    RZPhiMapGridData& operator=(RZPhiMapGridData<W2, M2> const& other)
+    {
+        CELER_EXPECT(other);
+        storage = other.storage;
+        grid_size = other.grid_size;
+        axes = other.axes;
+        return *this;
+    }
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Device data for interpolating field values.
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapFieldParamsData
+{
+    //! Grids of FieldMap
+    RZPhiMapGridData<W, M> grids;
+
+    //! Options for FieldDriver
+    FieldDriverOptions options;
+
+    //! Index of FieldMap Collection
+    using ElementId = ItemId<size_type>;
+
+    template<class T>
+    using ElementItems = Collection<T, W, M, ElementId>;
+
+    //! FieldMap data
+    ElementItems<EnumArray<CylAxis, real_type>> fieldmap;
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !fieldmap.empty();
+    }
+
+    //! Check if the given position is within the field map bounds
+    inline CELER_FUNCTION bool valid(real_type z, real_type r, Turn phi) const
+    {
+        CELER_EXPECT(grids);
+        return (
+            z >= grids.storage[grids.axes[CylAxis::Z].front()]
+            && z <= grids.storage[grids.axes[CylAxis::Z].back()]
+            && r >= grids.storage[grids.axes[CylAxis::R].front()]
+            && r <= grids.storage[grids.axes[CylAxis::R].back()]
+            && phi.value() >= grids.storage[grids.axes[CylAxis::Phi].front()]
+            && phi.value() <= grids.storage[grids.axes[CylAxis::Phi].back()]);
+    }
+
+    inline CELER_FUNCTION ElementId id(size_type idx_phi,
+                                       size_type idx_r,
+                                       size_type idx_z) const
+    {
+        CELER_EXPECT(grids);
+        // HyperSlabIndexer does not take Array<T const>
+        Array<size_type, static_cast<size_type>(CylAxis::size_)> tmp{
+            grids.grid_size[CylAxis::Phi],
+            grids.grid_size[CylAxis::R],
+            grids.grid_size[CylAxis::Z]};
+        return ElementId{HyperslabIndexer{tmp}(idx_phi, idx_r, idx_z)};
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: amandalund
body: If we're creating a temporary array anyway, could we remove `grid_size` from the `RZPhiMapGridData` and use  `tmp{grids.axes[CylAxis::Phi].size(), ...}` instead?
created_at: 2025-03-17T21:03:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: esseivaju
body: should I rename the map `PhiRZ` then?
created_at: 2025-03-17T21:22:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/RZPhiMapFieldData.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/data/Collection.hh"
+#include "corecel/data/HyperslabIndexer.hh"
+#include "corecel/math/Turn.hh"
+
+#include "FieldDriverOptions.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * FieldMap (3-dimensional RZ-Phi map) grid data
+ */
+template<Ownership W, MemSpace M>
+struct RZPhiMapGridData
+{
+    template<class T>
+    using Items = Collection<T, W, M>;
+    Items<real_type> storage;  //!< [Phi, R, Z]
+    Array<size_type, 3> grid_size;  //!< [Phi, R, Z]
+
+    ItemRange<real_type> phi;  //!< Index range for phi
+    ItemRange<real_type> r;  //!< Index range for r
+    ItemRange<real_type> z;  //!< Index range for z
+
+    //! Check whether the data is assigned
+    explicit inline CELER_FUNCTION operator bool() const
+    {
+        return !storage.empty() && grid_size[0] > 1 && grid_size[1] > 1
+               && grid_size[2] > 1 && phi.size() == grid_size[0]
+               && r.size() == grid_size[1] && z.size() == grid_size[2];
path: src/celeritas/field/RZPhiMapFieldData.hh
user: login: sethrj
body: Oh I didn't notice the ordering was flipped around from the name 😭 Maybe we should just call it CylFieldMap... and the RZ one SimpleCylFieldMap (because it's both the field vector and the grid that are azimuthally symmetric)? 

Let's order it naturally: `[x][y][z]` if it's cartesian, `[r][phi/theta][z]` or `[r][z]` if cylindrical, `[r][theta][phi]` if polar.

In my old code base we'd order a cartesian grid `data[z][y][x]` so it would have the same stride pattern as a fortran `data(x, y, z)` grid. I think our `RZField`, which is based on CMS data, uses `[z][r]` for the same reason (and I'd like to change it before v1).
created_at: 2025-03-17T21:34:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,134 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/CylMapMagneticField.cc
+//---------------------------------------------------------------------------//
+
+#include "CylMapMagneticField.hh"
+
+#include <algorithm>
+#include <cmath>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/ext/GeantUnits.hh"
+#include "celeritas/field/CylFieldMapInput.hh"
+#include "celeritas/field/CylFieldMapParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+namespace
+{
+//---------------------------------------------------------------------------//
+
+//! Cartesian to cylindrical 3D vector conversion.
+inline void cartesian_to_cylindrical(Array<G4double, 3> const& cart,
+                                     EnumArray<CylAxis, G4double>& cyl)
+{
+    double const phi = std::atan2(cart[1], cart[0]);
+    cyl[CylAxis::r] = cart[0] * std::cos(phi) + cart[1] * std::sin(phi);
+    cyl[CylAxis::phi] = -cart[0] * std::sin(phi) + cart[1] * std::cos(phi);
+    cyl[CylAxis::z] = cart[2];
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace
+
+//---------------------------------------------------------------------------//
+/*!
+ * Generates input for CylFieldMap params with configurable nonuniform grid
+ * dimensions in native Geant4 units, and \f$\phi\f$ should be in the range
+ * [0;\f$2\times\pi\f$]. This must be called after G4RunManager::Initialize as
+ * it will retrieve the G4FieldManager's field to sample it.
+ */
+CylFieldMapParams::Input
+MakeCylFieldMapInput(std::vector<real_type> const& r_grid,
+                     std::vector<real_type> const& phi_values,
+                     std::vector<real_type> const& z_grid)
+{
+    CylFieldMapParams::Input field_input;
+    field_input.grid_r.reserve(r_grid.size());
+    field_input.grid_phi.reserve(phi_values.size());
+    field_input.grid_z.reserve(z_grid.size());
+
+    // Convert from geant
+    std::transform(
+        r_grid.cbegin(),
+        r_grid.cend(),
+        std::back_inserter(field_input.grid_r),
+        [](real_type r) { return convert_from_geant(r, clhep_length); });
+    //  Convert phi values to Turn type
+    std::transform(phi_values.cbegin(),
+                   phi_values.cend(),
+                   std::back_inserter(field_input.grid_phi),
+                   [](real_type phi) { return native_value_to<Turn>(phi); });
+    std::transform(
+        z_grid.cbegin(),
+        z_grid.cend(),
+        std::back_inserter(field_input.grid_z),
+        [](real_type z) { return convert_from_geant(z, clhep_length); });
+
+    size_type const nr = field_input.grid_r.size();
+    size_type const nphi = field_input.grid_phi.size();
+    size_type const nz = field_input.grid_z.size();
+    size_type const total_points = nr * nphi * nz;
+
+    field_input.field.resize(static_cast<size_type>(CylAxis::size_)
+                             * total_points);
+
+    Array<size_type, 4> const dims{
+        nr, nphi, nz, static_cast<size_type>(CylAxis::size_)};
+    HyperslabIndexer const flat_index{dims};
+
+    CELER_EXPECT(G4TransportationManager::GetTransportationManager());
+    CELER_EXPECT(
+        G4TransportationManager::GetTransportationManager()->GetFieldManager());
+    CELER_EXPECT(G4TransportationManager::GetTransportationManager()
+                     ->GetFieldManager()
+                     ->GetDetectorField());
+    auto& field = *G4TransportationManager::GetTransportationManager()
+                       ->GetFieldManager()
+                       ->GetDetectorField();
+    Array<G4double, 3> bfield;
+    for (size_type ir = 0; ir < nr; ++ir)
+    {
+        real_type r = r_grid[ir];
+        for (size_type iphi = 0; iphi < nphi; ++iphi)
+        {
+            real_type phi = phi_values[iphi];
+            for (size_type iz = 0; iz < nz; ++iz)
+            {
+                auto* cur_bfield = field_input.field.data()
+                                   + flat_index(ir, iphi, iz, 0);
+                Array<G4double, 4> pos
+                    = {r * std::cos(phi), r * std::sin(phi), z_grid[iz], 0};
+                field.GetFieldValue(pos.data(), bfield.data());
path: src/accel/CylMapMagneticField.cc
user: login: sethrj
body: Future follow-on work: let's add a little wrapper class in `celeritas/ext` that wraps the Geant4 field manager and returns a Celeritas type.
created_at: 2025-03-18T11:55:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,85 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file accel/CylMapMagneticField.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <memory>
+#include <CLHEP/Units/SystemOfUnits.h>
+#include <G4FieldManager.hh>
+#include <G4MagneticField.hh>
+#include <G4TransportationManager.hh>
+#include <corecel/Assert.hh>
+
+#include "corecel/Types.hh"
+#include "corecel/math/Quantity.hh"
+#include "geocel/g4/Convert.hh"
+#include "celeritas/Quantities.hh"
+#include "celeritas/field/CylFieldMap.hh"
+#include "celeritas/field/CylFieldMapParams.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+// Generate field input with user-defined grid
+CylFieldMapParams::Input
+MakeCylFieldMapInput(std::vector<real_type> const& r_grid,
+                     std::vector<real_type> const& phi_values,
+                     std::vector<real_type> const& z_grid);
+
+//---------------------------------------------------------------------------//
+/*!
+ * A user magnetic field equivalent to celeritas::CylFieldMap.
+ */
+class CylMapMagneticField : public G4MagneticField
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using SPConstFieldParams = std::shared_ptr<CylFieldMapParams const>;
+    //!@}
+
+  public:
+    // Construct with CylFieldMapParams
+    inline explicit CylMapMagneticField(SPConstFieldParams field_params);
+
+    // Calculate values of the magnetic field vector
+    inline void
+    GetFieldValue(double const point[3], double* field) const override;
+
+  private:
+    SPConstFieldParams params_;
+    CylFieldMap calc_field_;
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the Celeritas shared CylFieldMapParams.
+ */
+CylMapMagneticField::CylMapMagneticField(SPConstFieldParams params)
+    : params_(std::move(params))
+    , calc_field_(CylFieldMap{params_->ref<MemSpace::native>()})
path: src/accel/CylMapMagneticField.hh
user: login: sethrj
body: Also follow-on work: we could probably make a template class that does this for both RZ and Cyl.
created_at: 2025-03-18T11:56:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,99 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/alongstep/AlongStepCylFieldMapMscAction.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include "corecel/Assert.hh"
+#include "corecel/Macros.hh"
+#include "celeritas/Types.hh"
+#include "celeritas/em/data/FluctuationData.hh"
+#include "celeritas/em/data/UrbanMscData.hh"
+#include "celeritas/field/CylFieldMapData.hh"
+#include "celeritas/field/CylFieldMapParams.hh"
+#include "celeritas/global/ActionInterface.hh"
+
+namespace celeritas
+{
+class UrbanMscParams;
+class FluctuationParams;
+class PhysicsParams;
+class MaterialParams;
+class ParticleParams;
+struct CylFieldMapInput;
+
+//---------------------------------------------------------------------------//
+/*!
+ * Along-step kernel with MSC, energy loss fluctuations, and a CylFieldMap.
+ */
+class AlongStepCylFieldMapMscAction final : public CoreStepActionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using SPConstFluctuations = std::shared_ptr<FluctuationParams const>;
+    using SPConstMsc = std::shared_ptr<UrbanMscParams const>;
+    using SPConstFieldParams = std::shared_ptr<CylFieldMapParams const>;
+    //!@}
+
+  public:
+    static std::shared_ptr<AlongStepCylFieldMapMscAction>
+    from_params(ActionId id,
+                MaterialParams const& materials,
+                ParticleParams const& particles,
+                CylFieldMapInput const& field_input,
+                SPConstMsc const& msc,
+                bool eloss_fluctuation);
+
+    // Construct with next action ID and physics properties
+    AlongStepCylFieldMapMscAction(ActionId id,
+                                  CylFieldMapInput const& input,
+                                  SPConstFluctuations fluct,
+                                  SPConstMsc msc);
+
+    // Launch kernel with host data
+    void step(CoreParams const&, CoreStateHost&) const final;
+
+    // Launch kernel with device data
+    void step(CoreParams const&, CoreStateDevice&) const final;
+
+    //! ID of the model
+    ActionId action_id() const final { return id_; }
+
+    //! Short name for the interaction kernel
+    std::string_view label() const final { return "along-step-CylMap-msc"; }
path: src/celeritas/alongstep/AlongStepCylFieldMapMscAction.hh
user: login: sethrj
body: find-replace error I think
```suggestion
    std::string_view label() const final { return "along-step-cylmap-msc"; }
```
created_at: 2025-03-18T12:01:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,151 @@
+//------------------------------- -*- C++ -*- -------------------------------//
+// Copyright Celeritas contributors: see top-level COPYRIGHT file for details
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+//---------------------------------------------------------------------------//
+//! \file celeritas/field/CylFieldMap.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Macros.hh"
+#include "corecel/Types.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/cont/Range.hh"
+#include "corecel/grid/FindInterp.hh"
+#include "corecel/grid/NonuniformGrid.hh"
+#include "corecel/math/Algorithms.hh"
+#include "corecel/math/Quantity.hh"
+#include "corecel/math/Turn.hh"
+#include "celeritas/Types.hh"
+
+#include "CylFieldMapData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Evaluate the value of magnetic field based on a volume-based Cyl field
+ * map.
+ */
+class CylFieldMap
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using Real3 = Array<real_type, 3>;
+    using FieldParamsRef = NativeCRef<CylFieldMapParamsData>;
+    //!@}
+
+  public:
+    // Construct with the shared map data
+    inline CELER_FUNCTION explicit CylFieldMap(FieldParamsRef const& shared);
+
+    // Evaluate the magnetic field value for the given position
+    CELER_FUNCTION
+    inline Real3 operator()(Real3 const& pos) const;
+
+  private:
+    // Shared constant field map
+    FieldParamsRef const& params_;
+
+    NonuniformGrid<real_type> const grid_r_;
+    NonuniformGrid<real_type> const grid_phi_;
+    NonuniformGrid<real_type> const grid_z_;
+};
+
+//---------------------------------------------------------------------------//
+// INLINE DEFINITIONS
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with the shared magnetic field map data.
+ */
+CELER_FUNCTION
+CylFieldMap::CylFieldMap(FieldParamsRef const& params)
+    : params_{params}
+    , grid_r_{params_.grids.axes[CylAxis::r], params_.grids.storage}
+    , grid_phi_{params_.grids.axes[CylAxis::phi], params_.grids.storage}
+    , grid_z_{params_.grids.axes[CylAxis::z], params_.grids.storage}
+{
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the magnetic field vector for the given position.
+ *
+ * This does a 3-D interpolation on the input grid and reconstructs the
+ * magnetic field vector from the stored R, Z, and Phi components of the field.
+ * The result is in the native Celeritas unit system.
+ */
+CELER_FUNCTION auto CylFieldMap::operator()(Real3 const& pos) const -> Real3
+{
+    CELER_ENSURE(params_);
+
+    Real3 value{0, 0, 0};
+
+    // Convert Cartesian to cylindrical coordinates
+    real_type r = hypot(pos[0], pos[1]);
+    // Ensure phi is in [0, 2\f$\pi\f$)
+    Turn phi = atan2turn(pos[1], pos[0]);
+    if (phi < zero_quantity())
+    {
+        phi.value() += 1;
path: src/celeritas/field/CylFieldMap.hh
user: login: sethrj
body: I forgot that this worked :) 
created_at: 2025-03-18T12:02:23Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-11T01:33:56Z
body: ## Test summary
 4 593 files   6 997 suites   14m 6s :stopwatch:
 1 782 tests  1 771 :white_check_mark: 11 :zzz: 0 :x:
24 254 runs  24 166 :white_check_mark: 88 :zzz: 0 :x:

Results for commit 4221641e.

[test-results]:data:application/gzip;base64,H4sIAAK32GcC/03Myw6DIBCF4VcxrLuQcRiGvkyjiAmplwZhZfruRett+X8nOYvofO9m8SxQmepRiDn5uDUZo3O3KdTRT2MWRsqQ17juUjMc+ZqTtZtpednbf1a7pKt9n6U8wYUwhV1CGtdbQFC453ELKIlO+98yH3B73fp+aqdh8DGHQABJKF0FyrasayBgyaZx1DaoSufIlgSVFN8fIajPYhMBAAA=

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: sethrj
created_at: 2025-03-11T18:57:28Z
body: pseudocode from discussion for r/z/phi:

```c++

namespace accel
{

void fill_field(FieldInput& field)
{
    // assert r, z, phi are there
    // resize field, loop over points, store
}

}

namespace inp
{
class FieldInput
{
    using real_type = float;

    vector<real_type> r_grid; // native units (?)
    vector<real_type> z_grid; // native units (?)
    vector<Turn> phi_grid; // turns

    vector<Array<fast_real_type, 3>> field;
};
}

namespace gpu
{
class FieldData
{
    Array<size_type, 3> grid_size; // r, z, phi

    using real_type = float;
    Items<real_type> field;
};

Real3 const& get_field(Real3  const& point)
{
    // calc r, z, phi
    // look up i,j,k
    HyperslabIndexer get_flattened_index{data.grid_size};
    return field[get_flattened_index(i, j, k)];
}
}
```
author_association: MEMBER
user: login: esseivaju
created_at: 2025-03-14T00:13:51Z
body: This still need some work, (integration in access, unit tests) but to accelerate this PR, you can already review the current status
author_association: MEMBER
user: login: esseivaju
created_at: 2025-03-16T22:49:52Z
body: I think this is ready. I have the fullsimlight integration [here](https://github.com/celeritas-project/gpu-delta-review-2025/blob/5964d0a65742a29736bbe49edfef1bebf1d597b6/ATLAS/GeoModel/FullSimLight/fullSimLight.cc#L248-L283)
author_association: MEMBER
user: login: esseivaju
created_at: 2025-03-18T03:07:33Z
body: > optimize by using single-precision field

Do you mean just the interpolation would be done in single precision, but we are still storing the grid, and field using real precision? 

author_association: MEMBER
user: login: sethrj
created_at: 2025-03-18T11:49:05Z
body: > Do you mean just the interpolation would be done in single precision, but we are still storing the grid, and field using real precision?

Everything can/should be stored in single precision. I don't think the grid will ever be fine enough or the field accurate enough to require double. Future optimization work could also actually store the map as a 3D [texture](https://docs.nvidia.com/cuda/cuda-c-programming-guide/#texture-memory) and use built-in CUDA/HIP interpolation for performance.
author_association: MEMBER
user: login: sethrj
created_at: 2025-03-18T13:05:04Z
body: Follow-on work:
- Single precision, or possibly even texture map
- Field conversion tool: https://github.com/celeritas-project/celeritas/pull/1662#discussion_r2000883142
- Use templating to improve code reuse: https://github.com/celeritas-project/celeritas/pull/1662#discussion_r2000886091
author_association: MEMBER