From 8b327e05301de1dd825dbd506d2cdb551379a5d9 Mon Sep 17 00:00:00 2001
From: "Seth R. Johnson" <johnsonsr@ornl.gov>
Date: Wed, 4 Sep 2024 08:47:49 -0400
Subject: [PATCH] Add skeleton optical core params and launch action (#1386)

---
 src/celeritas/CMakeLists.txt                  |   3 +
 src/celeritas/optical/CoreParams.cc           | 140 ++++++++++++++
 src/celeritas/optical/CoreParams.hh           | 145 +++++++++++++++
 src/celeritas/optical/CoreState.cc            |  84 +++++++++
 src/celeritas/optical/CoreState.hh            | 174 ++++++++++++++++++
 src/celeritas/optical/OpticalCollector.cc     |  58 ++++--
 src/celeritas/optical/OpticalCollector.hh     |  41 +++--
 src/celeritas/optical/TrackData.cc            |   2 +-
 src/celeritas/optical/TrackData.hh            |  28 +--
 src/celeritas/optical/detail/OffloadParams.hh |   2 +-
 .../optical/detail/OpticalLaunchAction.cc     | 152 +++++++++++++++
 .../optical/detail/OpticalLaunchAction.hh     | 112 +++++++++++
 src/corecel/data/CollectionStateStore.hh      |   5 +
 .../optical/OpticalCollector.test.cc          |   6 +-
 14 files changed, 897 insertions(+), 55 deletions(-)
 create mode 100644 src/celeritas/optical/CoreParams.cc
 create mode 100644 src/celeritas/optical/CoreParams.hh
 create mode 100644 src/celeritas/optical/CoreState.cc
 create mode 100644 src/celeritas/optical/CoreState.hh
 create mode 100644 src/celeritas/optical/detail/OpticalLaunchAction.cc
 create mode 100644 src/celeritas/optical/detail/OpticalLaunchAction.hh

diff --git a/src/celeritas/CMakeLists.txt b/src/celeritas/CMakeLists.txt
index f81370771c..09cad246f5 100644
--- a/src/celeritas/CMakeLists.txt
+++ b/src/celeritas/CMakeLists.txt
@@ -72,11 +72,14 @@ list(APPEND SOURCES
   neutron/process/NeutronElasticProcess.cc
   neutron/process/NeutronInelasticProcess.cc
   optical/CerenkovParams.cc
+  optical/CoreParams.cc
+  optical/CoreState.cc
   optical/OpticalCollector.cc
   optical/MaterialParams.cc
   optical/TrackData.cc
   optical/ScintillationParams.cc
   optical/detail/OffloadParams.cc
+  optical/detail/OpticalLaunchAction.cc
   phys/CutoffParams.cc
   phys/ImportedModelAdapter.cc
   phys/ImportedProcessAdapter.cc
diff --git a/src/celeritas/optical/CoreParams.cc b/src/celeritas/optical/CoreParams.cc
new file mode 100644
index 0000000000..146f43f9a8
--- /dev/null
+++ b/src/celeritas/optical/CoreParams.cc
@@ -0,0 +1,140 @@
+//----------------------------------*-C++-*----------------------------------//
+// Copyright 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/optical/CoreParams.cc
+//---------------------------------------------------------------------------//
+#include "CoreParams.hh"
+
+#include "corecel/io/Logger.hh"
+#include "corecel/sys/ActionRegistry.hh"
+#include "corecel/sys/ScopedMem.hh"
+#include "celeritas/geo/GeoParams.hh"
+#include "celeritas/mat/MaterialParams.hh"
+#include "celeritas/random/RngParams.hh"
+#include "celeritas/track/SimParams.hh"
+#include "celeritas/track/TrackInitParams.hh"
+
+#include "CoreState.hh"
+#include "MaterialParams.hh"
+
+namespace celeritas
+{
+namespace optical
+{
+namespace
+{
+//---------------------------------------------------------------------------//
+// HELPER CLASSES AND FUNCTIONS
+//---------------------------------------------------------------------------//
+//!@{
+template<MemSpace M>
+CoreParamsData<Ownership::const_reference, M>
+build_params_refs(CoreParams::Input const& p, CoreScalars const& scalars)
+{
+    CELER_EXPECT(scalars);
+
+    CoreParamsData<Ownership::const_reference, M> ref;
+
+    ref.scalars = scalars;
+    ref.geometry = get_ref<M>(*p.geometry);
+    ref.material = get_ref<M>(*p.material);
+    // TODO: ref.physics = get_ref<M>(*p.physics);
+    ref.rng = get_ref<M>(*p.rng);
+    ref.sim = get_ref<M>(*p.sim);
+    ref.init = get_ref<M>(*p.init);
+
+    CELER_ENSURE(ref);
+    return ref;
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct always-required actions and set IDs.
+ */
+CoreScalars build_actions(ActionRegistry* reg)
+{
+    using std::make_shared;
+
+    CoreScalars scalars;
+
+    //// START ACTIONS ////
+
+    CELER_DISCARD(reg);
+#if 0
+    // NOTE: due to ordering by {start, ID}, ExtendFromPrimariesAction *must*
+    // precede InitializeTracksAction
+    reg->insert(make_shared<ExtendFromPrimariesAction>(reg->next_id()));
+    reg->insert(make_shared<InitializeTracksAction>(reg->next_id()));
+#endif
+
+    //// PRE-STEP ACTIONS ////
+
+    //// POST-STEP ACTIONS ////
+
+    // Construct geometry boundary action
+    scalars.boundary_action = reg->next_id();
+#if 0
+    reg->insert(make_shared<detail::BoundaryAction>(
+        scalars.boundary_action));
+#endif
+
+    //// END ACTIONS ////
+
+    // TODO: extend from secondaries action
+
+    return scalars;
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with all problem data, creating some actions too.
+ */
+CoreParams::CoreParams(Input&& input) : input_(std::move(input))
+{
+#define CP_VALIDATE_INPUT(MEMBER) \
+    CELER_VALIDATE(input_.MEMBER, \
+                   << "optical core input is missing " << #MEMBER << " data")
+    CP_VALIDATE_INPUT(geometry);
+    CP_VALIDATE_INPUT(material);
+    // TODO: CP_VALIDATE_INPUT(physics);
+    CP_VALIDATE_INPUT(rng);
+    CP_VALIDATE_INPUT(sim);
+    CP_VALIDATE_INPUT(init);
+    CP_VALIDATE_INPUT(action_reg);
+    CP_VALIDATE_INPUT(max_streams);
+#undef CP_VALIDATE_INPUT
+
+    CELER_EXPECT(input_);
+
+    ScopedMem record_mem("optical::CoreParams.construct");
+
+    // Construct always-on actions and save their IDs
+    CoreScalars scalars = build_actions(input_.action_reg.get());
+
+    // Save maximum number of streams
+    scalars.max_streams = input_.max_streams;
+
+    // Save host reference
+    host_ref_ = build_params_refs<MemSpace::host>(input_, scalars);
+    if (celeritas::device())
+    {
+        device_ref_ = build_params_refs<MemSpace::device>(input_, scalars);
+        // Copy device ref to device global memory
+        device_ref_vec_ = DeviceVector<DeviceRef>(1);
+        device_ref_vec_.copy_to_device({&device_ref_, 1});
+    }
+
+    CELER_LOG(status) << "Celeritas optical setup complete";
+
+    CELER_ENSURE(host_ref_);
+    CELER_ENSURE(host_ref_.scalars.max_streams == this->max_streams());
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace optical
+}  // namespace celeritas
diff --git a/src/celeritas/optical/CoreParams.hh b/src/celeritas/optical/CoreParams.hh
new file mode 100644
index 0000000000..b726e1be53
--- /dev/null
+++ b/src/celeritas/optical/CoreParams.hh
@@ -0,0 +1,145 @@
+//----------------------------------*-C++-*----------------------------------//
+// Copyright 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/optical/CoreParams.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/Assert.hh"
+#include "corecel/data/DeviceVector.hh"
+#include "corecel/data/ObserverPtr.hh"
+#include "corecel/data/ParamsDataInterface.hh"
+#include "celeritas/geo/GeoFwd.hh"
+#include "celeritas/global/ActionInterface.hh"
+#include "celeritas/random/RngParamsFwd.hh"
+
+#include "TrackData.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+class ActionRegistry;
+class TrackInitParams;
+class SimParams;
+
+namespace optical
+{
+//---------------------------------------------------------------------------//
+class MaterialParams;
+// TODO: class PhysicsParams;
+
+//---------------------------------------------------------------------------//
+/*!
+ * Shared parameters for the optical photon loop.
+ */
+class CoreParams final : public ParamsDataInterface<CoreParamsData>
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using SPConstGeo = std::shared_ptr<GeoParams const>;
+    using SPConstMaterial = std::shared_ptr<MaterialParams const>;
+    using SPConstRng = std::shared_ptr<RngParams const>;
+    using SPConstSim = std::shared_ptr<SimParams const>;
+    using SPConstTrackInit = std::shared_ptr<TrackInitParams const>;
+    using SPActionRegistry = std::shared_ptr<ActionRegistry>;
+
+    template<MemSpace M>
+    using ConstRef = CoreParamsData<Ownership::const_reference, M>;
+    template<MemSpace M>
+    using ConstPtr = ObserverPtr<ConstRef<M> const, M>;
+    //!@}
+
+    struct Input
+    {
+        SPConstGeo geometry;
+        SPConstMaterial material;
+        // TODO: physics
+        SPConstRng rng;
+        SPConstSim sim;
+        SPConstTrackInit init;
+
+        SPActionRegistry action_reg;
+
+        //! Maximum number of simultaneous threads/tasks per process
+        StreamId::size_type max_streams{1};
+
+        //! True if all params are assigned and valid
+        explicit operator bool() const
+        {
+            return geometry && material && rng && sim && init && action_reg
+                   && max_streams;
+        }
+    };
+
+  public:
+    // Construct with all problem data, creating some actions too
+    CoreParams(Input&& inp);
+
+    //!@{
+    //! \name Data interface
+    //! Access data on the host
+    HostRef const& host_ref() const final { return host_ref_; }
+    //! Access data on the device
+    DeviceRef const& device_ref() const final { return device_ref_; }
+    //!@}
+
+    //!@{
+    //! Access shared problem parameter data.
+    SPConstGeo const& geometry() const { return input_.geometry; }
+    SPConstMaterial const& material() const { return input_.material; }
+    SPConstRng const& rng() const { return input_.rng; }
+    SPConstTrackInit const& init() const { return input_.init; }
+    SPActionRegistry const& action_reg() const { return input_.action_reg; }
+    //!@}
+
+    // Access host pointers to core data
+    using ParamsDataInterface<CoreParamsData>::ref;
+
+    // Access a native pointer to properties in the native memory space
+    template<MemSpace M>
+    inline ConstPtr<M> ptr() const;
+
+    //! Maximum number of streams
+    size_type max_streams() const { return input_.max_streams; }
+
+  private:
+    Input input_;
+    HostRef host_ref_;
+    DeviceRef device_ref_;
+
+    // Copy of DeviceRef in device memory
+    DeviceVector<DeviceRef> device_ref_vec_;
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Access a native pointer to a NativeCRef.
+ *
+ * This way, CUDA kernels only need to copy a pointer in the kernel arguments,
+ * rather than the entire (rather large) DeviceRef object.
+ */
+template<MemSpace M>
+auto CoreParams::ptr() const -> ConstPtr<M>
+{
+    if constexpr (M == MemSpace::host)
+    {
+        return make_observer(&host_ref_);
+    }
+#ifndef __NVCC__
+    // CUDA 11.4 complains about 'else if constexpr' ("missing return
+    // statement") and GCC 11.2 complains about leaving off the 'else'
+    // ("inconsistent deduction for auto return type")
+    else
+#endif
+    {
+        CELER_ENSURE(!device_ref_vec_.empty());
+        return make_observer(device_ref_vec_);
+    }
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace optical
+}  // namespace celeritas
diff --git a/src/celeritas/optical/CoreState.cc b/src/celeritas/optical/CoreState.cc
new file mode 100644
index 0000000000..cbb8abd955
--- /dev/null
+++ b/src/celeritas/optical/CoreState.cc
@@ -0,0 +1,84 @@
+//----------------------------------*-C++-*----------------------------------//
+// Copyright 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/optical/CoreState.cc
+//---------------------------------------------------------------------------//
+#include "CoreState.hh"
+
+#include "corecel/data/CollectionAlgorithms.hh"
+#include "corecel/data/Copier.hh"
+#include "corecel/io/Logger.hh"
+#include "corecel/sys/ScopedProfiling.hh"
+
+#include "CoreParams.hh"
+
+namespace celeritas
+{
+namespace optical
+{
+//---------------------------------------------------------------------------//
+//! Support polymorphic deletion
+CoreStateInterface::~CoreStateInterface() = default;
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from CoreParams.
+ */
+template<MemSpace M>
+CoreState<M>::CoreState(CoreParams const& params,
+                        StreamId stream_id,
+                        size_type num_track_slots)
+{
+    CELER_VALIDATE(stream_id < params.max_streams(),
+                   << "stream ID " << stream_id.unchecked_get()
+                   << " is out of range: max streams is "
+                   << params.max_streams());
+    CELER_VALIDATE(num_track_slots > 0, << "number of track slots is not set");
+
+    ScopedProfiling profile_this{"construct-optical-state"};
+
+    states_ = CollectionStateStore<CoreStateData, M>(
+        params.host_ref(), stream_id, num_track_slots);
+
+    counters_.num_vacancies = num_track_slots;
+
+    if constexpr (M == MemSpace::device)
+    {
+        device_ref_vec_ = DeviceVector<Ref>(1);
+        device_ref_vec_.copy_to_device({&this->ref(), 1});
+        ptr_ = make_observer(device_ref_vec_);
+    }
+    else if constexpr (M == MemSpace::host)
+    {
+        ptr_ = make_observer(&this->ref());
+    }
+
+    CELER_LOG_LOCAL(status) << "Celeritas optical state initialization "
+                               "complete";
+    CELER_ENSURE(states_);
+    CELER_ENSURE(ptr_);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Inject primaries to be turned into TrackInitializers.
+ *
+ * These will be converted by the ProcessPrimaries action.
+ */
+template<MemSpace M>
+void CoreState<M>::insert_primaries(Span<Primary const>)
+{
+    CELER_NOT_IMPLEMENTED("primary insertion");
+}
+
+//---------------------------------------------------------------------------//
+// EXPLICIT INSTANTIATION
+//---------------------------------------------------------------------------//
+template class CoreState<MemSpace::host>;
+template class CoreState<MemSpace::device>;
+
+//---------------------------------------------------------------------------//
+}  // namespace optical
+}  // namespace celeritas
diff --git a/src/celeritas/optical/CoreState.hh b/src/celeritas/optical/CoreState.hh
new file mode 100644
index 0000000000..4bedd63492
--- /dev/null
+++ b/src/celeritas/optical/CoreState.hh
@@ -0,0 +1,174 @@
+//----------------------------------*-C++-*----------------------------------//
+// Copyright 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/optical/CoreState.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include "corecel/cont/Span.hh"
+#include "corecel/data/AuxInterface.hh"
+#include "corecel/data/CollectionStateStore.hh"
+#include "corecel/data/ObserverPtr.hh"
+#include "celeritas/Types.hh"
+
+#include "Primary.hh"
+#include "TrackData.hh"
+
+namespace celeritas
+{
+namespace optical
+{
+class CoreParams;
+
+//---------------------------------------------------------------------------//
+/*!
+ * Counters for track initialization and activity.
+ *
+ * These counters are updated *by value on the host at every step* so they
+ * should not be stored in TrackInitStateData because then the device-memory
+ * copy will not be synchronized.
+ */
+struct CoreStateCounters
+{
+    // Initialization input
+    size_type num_vacancies{};  //!< Number of unused track slots
+    size_type num_primaries{};  //!< Number of primaries to be converted
+    size_type num_initializers{};  //!< Number of track initializers
+
+    // Diagnostic output
+    size_type num_secondaries{};  //!< Number of secondaries produced in a step
+    size_type num_active{};  //!< Number of active tracks at start of a step
+    size_type num_alive{};  //!< Number of alive tracks at end of step
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Interface class for optical state data.
+ *
+ * This inherits from the "aux state" interface to allow stream-local storage
+ * with the optical offload data.
+ */
+class CoreStateInterface : public AuxStateInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using size_type = TrackSlotId::size_type;
+    //!@}
+
+  public:
+    // Support polymorphic deletion
+    virtual ~CoreStateInterface();
+
+    //! Thread/stream ID
+    virtual StreamId stream_id() const = 0;
+
+    //! Access track initialization counters
+    virtual CoreStateCounters const& counters() const = 0;
+
+    //! Number of track slots
+    virtual size_type size() const = 0;
+
+    // Inject optical primaries
+    virtual void insert_primaries(Span<Primary const> host_primaries) = 0;
+
+  protected:
+    CoreStateInterface() = default;
+    CELER_DEFAULT_COPY_MOVE(CoreStateInterface);
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Store all state data for a single thread.
+ *
+ * When the state lives on the device, we maintain a separate copy of the
+ * device "ref" in device memory: otherwise we'd have to copy the entire state
+ * in launch arguments and access it through constant memory.
+ *
+ * \todo Encapsulate all the action management accessors in a helper class.
+ */
+template<MemSpace M>
+class CoreState final : public CoreStateInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    template<template<Ownership, MemSpace> class S>
+    using StateRef = S<Ownership::reference, M>;
+
+    using Ref = StateRef<CoreStateData>;
+    using Ptr = ObserverPtr<Ref, M>;
+    //!@}
+
+  public:
+    // Construct from CoreParams
+    CoreState(CoreParams const& params,
+              StreamId stream_id,
+              size_type num_track_slots);
+
+    //! Thread/stream ID
+    StreamId stream_id() const final { return this->ref().stream_id; }
+
+    //! Number of track slots
+    size_type size() const final { return states_.size(); }
+
+    // Whether the state is being transported with no active particles
+    inline bool warming_up() const;
+
+    //// CORE DATA ////
+
+    //! Get a reference to the mutable state data
+    Ref& ref() { return states_.ref(); }
+
+    //! Get a reference to the mutable state data
+    Ref const& ref() const { return states_.ref(); }
+
+    //! Get a native-memspace pointer to the mutable state data
+    Ptr ptr() { return ptr_; }
+
+    //// COUNTERS ////
+
+    //! Track initialization counters
+    CoreStateCounters& counters() { return counters_; }
+
+    //! Track initialization counters
+    CoreStateCounters const& counters() const final { return counters_; }
+
+    // Inject primaries to be turned into TrackInitializers
+    void insert_primaries(Span<Primary const> host_primaries) final;
+
+  private:
+    // State data
+    CollectionStateStore<CoreStateData, M> states_;
+
+    // Copy of state ref in device memory, if M == MemSpace::device
+    DeviceVector<Ref> device_ref_vec_;
+
+    // Native pointer to ref or
+    Ptr ptr_;
+
+    // Counters for track initialization and activity
+    CoreStateCounters counters_;
+};
+
+//---------------------------------------------------------------------------//
+/*!
+ * Whether the state is being transported with no active particles.
+ *
+ * The warmup stage is useful for profiling and debugging since the first
+ * step iteration can do the following:
+ * - Initialize asynchronous memory pools
+ * - Interrogate kernel functions for properties to be output later
+ * - Allocate "lazy" auxiliary data (e.g. action diagnostics)
+ */
+template<MemSpace M>
+bool CoreState<M>::warming_up() const
+{
+    return counters_.num_active == 0 && counters_.num_primaries == 0;
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace optical
+}  // namespace celeritas
diff --git a/src/celeritas/optical/OpticalCollector.cc b/src/celeritas/optical/OpticalCollector.cc
index c98c514ffe..54ac7312a7 100644
--- a/src/celeritas/optical/OpticalCollector.cc
+++ b/src/celeritas/optical/OpticalCollector.cc
@@ -10,18 +10,28 @@
 #include "corecel/data/AuxParamsRegistry.hh"
 #include "corecel/sys/ActionRegistry.hh"
 #include "celeritas/global/CoreParams.hh"
-#include "celeritas/optical/CerenkovParams.hh"
-#include "celeritas/optical/MaterialParams.hh"
-#include "celeritas/optical/OffloadData.hh"
-#include "celeritas/optical/ScintillationParams.hh"
+#include "celeritas/track/SimParams.hh"
+#include "celeritas/track/TrackInitParams.hh"
 
+#include "CerenkovParams.hh"
+#include "CoreParams.hh"
+#include "MaterialParams.hh"
+#include "OffloadData.hh"
+#include "ScintillationParams.hh"
+
+#include "detail/CerenkovOffloadAction.hh"
+#include "detail/OffloadGatherAction.hh"
 #include "detail/OffloadParams.hh"
+#include "detail/OpticalLaunchAction.hh"
+#include "detail/ScintOffloadAction.hh"
 
 namespace celeritas
 {
 //---------------------------------------------------------------------------//
 /*!
  * Construct with core data and optical data.
+ *
+ * This adds several actions and auxiliary data to the registry.
  */
 OpticalCollector::OpticalCollector(CoreParams const& core, Input&& inp)
 {
@@ -32,10 +42,10 @@ OpticalCollector::OpticalCollector(CoreParams const& core, Input&& inp)
     setup.scintillation = static_cast<bool>(inp.scintillation);
     setup.capacity = inp.buffer_capacity;
 
-    // Create aux params and add to core
-    gen_params_ = std::make_shared<detail::OffloadParams>(
-        core.aux_reg()->next_id(), setup);
-    core.aux_reg()->insert(gen_params_);
+    // Create offload params
+    AuxParamsRegistry& aux = *core.aux_reg();
+    gen_params_ = std::make_shared<detail::OffloadParams>(aux.next_id(), setup);
+    aux.insert(gen_params_);
 
     // Action to gather pre-step data needed to generate optical distributions
     ActionRegistry& actions = *core.action_reg();
@@ -46,33 +56,41 @@ OpticalCollector::OpticalCollector(CoreParams const& core, Input&& inp)
     if (setup.cerenkov)
     {
         // Action to generate Cerenkov optical distributions
-        cerenkov_offload_action_
-            = std::make_shared<detail::CerenkovOffloadAction>(
-                actions.next_id(),
-                gen_params_->aux_id(),
-                std::move(inp.material),
-                std::move(inp.cerenkov));
-        actions.insert(cerenkov_offload_action_);
+        cerenkov_action_ = std::make_shared<detail::CerenkovOffloadAction>(
+            actions.next_id(),
+            gen_params_->aux_id(),
+            inp.material,
+            std::move(inp.cerenkov));
+        actions.insert(cerenkov_action_);
     }
 
     if (setup.scintillation)
     {
         // Action to generate scintillation optical distributions
-        scint_offload_action_ = std::make_shared<detail::ScintOffloadAction>(
+        scint_action_ = std::make_shared<detail::ScintOffloadAction>(
             actions.next_id(),
             gen_params_->aux_id(),
             std::move(inp.scintillation));
-        actions.insert(scint_offload_action_);
+        actions.insert(scint_action_);
     }
 
-    // TODO: add an action to launch optical tracking loop
+    // Create launch action with optical params+state and access to gen data
+    launch_action_ = detail::OpticalLaunchAction::make_and_insert(
+        core, inp.material, gen_params_);
+
+    // Launch action must be *after* generator actions
+    CELER_ENSURE(!cerenkov_action_
+                 || launch_action_->action_id()
+                        > cerenkov_action_->action_id());
+    CELER_ENSURE(!scint_action_
+                 || launch_action_->action_id() > scint_action_->action_id());
 }
 
 //---------------------------------------------------------------------------//
 /*!
- * Aux ID for optical generator data.
+ * Aux ID for optical generator data used for offloading.
  */
-AuxId OpticalCollector::aux_id() const
+AuxId OpticalCollector::offload_aux_id() const
 {
     return gen_params_->aux_id();
 }
diff --git a/src/celeritas/optical/OpticalCollector.hh b/src/celeritas/optical/OpticalCollector.hh
index 8034369153..a94da58396 100644
--- a/src/celeritas/optical/OpticalCollector.hh
+++ b/src/celeritas/optical/OpticalCollector.hh
@@ -14,41 +14,46 @@
 
 #include "OffloadData.hh"
 
-#include "detail/CerenkovOffloadAction.hh"
-#include "detail/OffloadGatherAction.hh"
-#include "detail/ScintOffloadAction.hh"
-
 namespace celeritas
 {
 //---------------------------------------------------------------------------//
 class ActionRegistry;
-class CerenkovParams;
-class ScintillationParams;
 class CoreParams;
 
 namespace optical
 {
+class CerenkovParams;
 class MaterialParams;
+class ScintillationParams;
 }
 
 namespace detail
 {
+class CerenkovOffloadAction;
+class OffloadGatherAction;
+class OpticalLaunchAction;
 class OffloadParams;
+class ScintOffloadAction;
 }  // namespace detail
 
 //---------------------------------------------------------------------------//
 /*!
- * Generate scintillation and Cerenkov optical distribution data at each step.
+ * Generate and track optical photons.
  *
  * This class is the interface between the main stepping loop and the photon
  * stepping loop and constructs kernel actions for:
  * - gathering the pre-step data needed to generate the optical distributions,
- * - generating the optical distributions at the end of the step, and
+ * - generating the scintillation and Cerenkov optical distributions at the
+ *   end of the step, and
  * - launching the photon stepping loop.
  *
+ * The photon stepping loop will then generate optical primaries.
+ *
  * The "collector" (TODO: rename?) will "own" the optical state data and
  * optical params since it's the only thing that launches the optical stepping
  * loop.
+ *
+ * \todo Rename to OpticalOffload
  */
 class OpticalCollector
 {
@@ -74,7 +79,7 @@ class OpticalCollector
         //! True if all input is assigned and valid
         explicit operator bool() const
         {
-            return (scintillation || (cerenkov && material))
+            return material && (scintillation || cerenkov)
                    && buffer_capacity > 0;
         }
     };
@@ -83,28 +88,28 @@ class OpticalCollector
     // Construct with core data and optical params
     OpticalCollector(CoreParams const&, Input&&);
 
-    // Aux ID for optical generator data
-    AuxId aux_id() const;
+    // Aux ID for optical offload data
+    AuxId offload_aux_id() const;
 
   private:
     //// TYPES ////
 
     using SPOffloadParams = std::shared_ptr<detail::OffloadParams>;
-    using SPCerenkovOffloadAction
-        = std::shared_ptr<detail::CerenkovOffloadAction>;
-    using SPScintOffloadAction = std::shared_ptr<detail::ScintOffloadAction>;
+    using SPCerenkovAction = std::shared_ptr<detail::CerenkovOffloadAction>;
+    using SPScintAction = std::shared_ptr<detail::ScintOffloadAction>;
     using SPGatherAction = std::shared_ptr<detail::OffloadGatherAction>;
+    using SPLaunchAction = std::shared_ptr<detail::OpticalLaunchAction>;
 
     //// DATA ////
 
     SPOffloadParams gen_params_;
 
     SPGatherAction gather_action_;
-    SPCerenkovOffloadAction cerenkov_offload_action_;
-    SPScintOffloadAction scint_offload_action_;
+    SPCerenkovAction cerenkov_action_;
+    SPScintAction scint_action_;
+    SPLaunchAction launch_action_;
 
-    // TODO: tracking loop launcher
-    // TODO: store optical core params and state?
+    // TODO: tracking loop launch action
 };
 
 //---------------------------------------------------------------------------//
diff --git a/src/celeritas/optical/TrackData.cc b/src/celeritas/optical/TrackData.cc
index 6f99312c78..61fd956699 100644
--- a/src/celeritas/optical/TrackData.cc
+++ b/src/celeritas/optical/TrackData.cc
@@ -42,7 +42,7 @@ void resize(CoreStateData<Ownership::value, M>* state,
     resize(&state->init, params.init, stream_id, size);
     state->stream_id = stream_id;
 
-    CELER_ENSURE(state);
+    CELER_ENSURE(*state);
 }
 
 //---------------------------------------------------------------------------//
diff --git a/src/celeritas/optical/TrackData.hh b/src/celeritas/optical/TrackData.hh
index 985828f31d..a64b94072f 100644
--- a/src/celeritas/optical/TrackData.hh
+++ b/src/celeritas/optical/TrackData.hh
@@ -15,6 +15,7 @@
 #include "celeritas/track/SimData.hh"
 #include "celeritas/track/TrackInitData.hh"
 
+#include "MaterialData.hh"
 #include "Types.hh"
 
 namespace celeritas
@@ -28,18 +29,26 @@ namespace optical
 template<Ownership W, MemSpace M>
 struct PhysicsParamsData
 {
-    explicit CELER_FUNCTION operator bool() const { return false; }
+    explicit CELER_FUNCTION operator bool() const { return true; }
 };
 template<Ownership W, MemSpace M>
 struct PhysicsStateData
 {
+    explicit CELER_FUNCTION operator bool() const { return true; }
+
+    //! Assign from another set of data
+    template<Ownership W2, MemSpace M2>
+    PhysicsStateData& operator=(PhysicsStateData<W2, M2>&)
+    {
+        return *this;
+    }
 };
+
 template<MemSpace M>
 inline void resize(PhysicsStateData<Ownership::value, M>*,
                    HostCRef<PhysicsParamsData> const&,
                    size_type)
 {
-    CELER_NOT_IMPLEMENTED("optical physics state");
 }
 
 // XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX  XXX
@@ -54,12 +63,11 @@ struct CoreScalars
     ActionId boundary_action;
 
     StreamId::size_type max_streams{0};
-    OpticalMaterialId::size_type num_materials{0};
 
     //! True if assigned and valid
     explicit CELER_FUNCTION operator bool() const
     {
-        return boundary_action && max_streams > 0 && num_materials > 0;
+        return boundary_action && max_streams > 0;
     }
 };
 
@@ -70,11 +78,8 @@ struct CoreScalars
 template<Ownership W, MemSpace M>
 struct CoreParamsData
 {
-    template<class T>
-    using VolumeItems = celeritas::Collection<T, W, M, VolumeId>;
-
     GeoParamsData<W, M> geometry;
-    VolumeItems<OpticalMaterialId> materials;
+    MaterialParamsData<W, M> material;
     PhysicsParamsData<W, M> physics;
     RngParamsData<W, M> rng;
     SimParamsData<W, M> sim;
@@ -85,8 +90,7 @@ struct CoreParamsData
     //! True if all params are assigned
     explicit CELER_FUNCTION operator bool() const
     {
-        return geometry && !materials.empty() && physics && rng && sim && init
-               && scalars;
+        return geometry && material && physics && rng && sim && init && scalars;
     }
 
     //! Assign from another set of data
@@ -95,7 +99,7 @@ struct CoreParamsData
     {
         CELER_EXPECT(other);
         geometry = other.geometry;
-        materials = other.materials;
+        material = other.material;
         physics = other.physics;
         rng = other.rng;
         sim = other.sim;
@@ -131,7 +135,7 @@ struct CoreStateData
     //! Whether the data are assigned
     explicit CELER_FUNCTION operator bool() const
     {
-        return geometry && materials && physics && rng && sim && init
+        return geometry && !materials.empty() && physics && rng && sim && init
                && stream_id;
     }
 
diff --git a/src/celeritas/optical/detail/OffloadParams.hh b/src/celeritas/optical/detail/OffloadParams.hh
index 094d9d664f..6f6a1e9970 100644
--- a/src/celeritas/optical/detail/OffloadParams.hh
+++ b/src/celeritas/optical/detail/OffloadParams.hh
@@ -20,7 +20,7 @@ namespace detail
 {
 //---------------------------------------------------------------------------//
 /*!
- * Manage metadata about optical offloading.
+ * Manage metadata for optical offload generation.
  */
 class OffloadParams final : public AuxParamsInterface,
                             public ParamsDataInterface<OffloadParamsData>
diff --git a/src/celeritas/optical/detail/OpticalLaunchAction.cc b/src/celeritas/optical/detail/OpticalLaunchAction.cc
new file mode 100644
index 0000000000..d93fdabde1
--- /dev/null
+++ b/src/celeritas/optical/detail/OpticalLaunchAction.cc
@@ -0,0 +1,152 @@
+//----------------------------------*-C++-*----------------------------------//
+// Copyright 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/optical/detail/OpticalLaunchAction.cc
+//---------------------------------------------------------------------------//
+#include "OpticalLaunchAction.hh"
+
+#include "corecel/data/AuxParamsRegistry.hh"
+#include "corecel/data/AuxStateVec.hh"
+#include "corecel/sys/ActionRegistry.hh"
+#include "celeritas/global/CoreParams.hh"
+#include "celeritas/global/CoreState.hh"
+#include "celeritas/optical/CoreParams.hh"
+#include "celeritas/optical/CoreState.hh"
+#include "celeritas/track/SimParams.hh"
+
+#include "OffloadParams.hh"
+
+namespace celeritas
+{
+namespace detail
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Construct and add to core params.
+ */
+std::shared_ptr<OpticalLaunchAction>
+OpticalLaunchAction::make_and_insert(CoreParams const& core,
+                                     SPConstMaterial material,
+                                     SPOffloadParams offload)
+{
+    CELER_EXPECT(material);
+    CELER_EXPECT(offload);
+    ActionRegistry& actions = *core.action_reg();
+    AuxParamsRegistry& aux = *core.aux_reg();
+    auto result = std::make_shared<OpticalLaunchAction>(actions.next_id(),
+                                                        aux.next_id(),
+                                                        core,
+                                                        std::move(material),
+                                                        std::move(offload));
+
+    actions.insert(result);
+    aux.insert(result);
+    return result;
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Construct with action ID, generator storage.
+ */
+OpticalLaunchAction::OpticalLaunchAction(ActionId action_id,
+                                         AuxId data_id,
+                                         CoreParams const& core,
+                                         SPConstMaterial material,
+                                         SPOffloadParams offload)
+    : action_id_{action_id}
+    , aux_id_{data_id}
+    , offload_params_{std::move(offload)}
+{
+    CELER_EXPECT(material);
+    CELER_EXPECT(offload_params_);
+
+    // Create optical core params
+    optical_params_ = std::make_shared<optical::CoreParams>([&] {
+        optical::CoreParams::Input inp;
+        inp.geometry = core.geometry();
+        inp.material = std::move(material);
+        // TODO: unique RNG streams for optical loop
+        inp.rng = core.rng();
+        inp.sim = std::make_shared<SimParams>();
+        inp.init = core.init();
+        inp.action_reg = std::make_shared<ActionRegistry>();
+        inp.max_streams = core.max_streams();
+        CELER_ENSURE(inp);
+        return inp;
+    }());
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Descriptive name of the action.
+ */
+std::string_view OpticalLaunchAction::description() const
+{
+    return "launch the optical stepping loop";
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build state data for a stream.
+ */
+auto OpticalLaunchAction::create_state(MemSpace m,
+                                       StreamId sid,
+                                       size_type size) const -> UPState
+{
+    if (m == MemSpace::host)
+    {
+        return std::make_unique<optical::CoreState<MemSpace::host>>(
+            *optical_params_, sid, size);
+    }
+    else if (m == MemSpace::device)
+    {
+        return std::make_unique<optical::CoreState<MemSpace::device>>(
+            *optical_params_, sid, size);
+    }
+    CELER_ASSERT_UNREACHABLE();
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Perform a step action with host data.
+ */
+void OpticalLaunchAction::step(CoreParams const& params,
+                               CoreStateHost& state) const
+{
+    return this->execute_impl(params, state);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Perform a step action with device data.
+ */
+void OpticalLaunchAction::step(CoreParams const& params,
+                               CoreStateDevice& state) const
+{
+    return this->execute_impl(params, state);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Launch the optical tracking loop.
+ */
+template<MemSpace M>
+void OpticalLaunchAction::execute_impl(CoreParams const& core_params,
+                                       CoreState<M>& core_state) const
+{
+    auto& offload_state = get<OpticalOffloadState<M>>(
+        core_state.aux(), offload_params_->aux_id());
+    auto& optical_state
+        = get<optical::CoreState<M>>(core_state.aux(), this->aux_id());
+
+    // Loop!
+    CELER_ASSERT(offload_state);
+    CELER_ASSERT(optical_state.size() > 0);
+    CELER_DISCARD(core_params);
+}
+
+//---------------------------------------------------------------------------//
+}  // namespace detail
+}  // namespace celeritas
diff --git a/src/celeritas/optical/detail/OpticalLaunchAction.hh b/src/celeritas/optical/detail/OpticalLaunchAction.hh
new file mode 100644
index 0000000000..5c9ae9697b
--- /dev/null
+++ b/src/celeritas/optical/detail/OpticalLaunchAction.hh
@@ -0,0 +1,112 @@
+//----------------------------------*-C++-*----------------------------------//
+// Copyright 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/optical/detail/OpticalLaunchAction.hh
+//---------------------------------------------------------------------------//
+#pragma once
+
+#include <memory>
+#include <string_view>
+
+#include "corecel/Macros.hh"
+#include "corecel/data/AuxInterface.hh"
+#include "celeritas/global/ActionInterface.hh"
+
+namespace celeritas
+{
+//---------------------------------------------------------------------------//
+class CoreParams;
+namespace optical
+{
+class CoreParams;
+class MaterialParams;
+}  // namespace optical
+
+namespace detail
+{
+class OffloadParams;
+}
+
+namespace detail
+{
+//---------------------------------------------------------------------------//
+/*!
+ * Manage optical params and state, launching the optical stepping loop.
+ *
+ * This stores the optical tracking loop's core params, initializing them at
+ * the beginning of the run, and stores the optical core state as "aux"
+ * data.
+ */
+class OpticalLaunchAction : public AuxParamsInterface,
+                            public CoreStepActionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using SPOffloadParams = std::shared_ptr<detail::OffloadParams>;
+    using SPConstMaterial = std::shared_ptr<optical::MaterialParams const>;
+    //!@}
+
+  public:
+    // Construct and add to core params
+    static std::shared_ptr<OpticalLaunchAction>
+    make_and_insert(CoreParams const& core,
+                    SPConstMaterial material,
+                    SPOffloadParams offload);
+
+    // Construct with IDs, core for copying params, offload gen data
+    OpticalLaunchAction(ActionId id,
+                        AuxId data_id,
+                        CoreParams const& core,
+                        SPConstMaterial material,
+                        SPOffloadParams offload);
+
+    //!@{
+    //! \name Aux/action metadata interface
+    //! Short name for the action
+    std::string_view label() const final { return "optical-offload-launch"; }
+    // Name of the action (for user output)
+    std::string_view description() const final;
+    //!@}
+
+    //!@{
+    //! \name Aux interface
+    //! Index of this class instance in its registry
+    AuxId aux_id() const final { return aux_id_; }
+    // Build optical core state data for a stream
+    UPState create_state(MemSpace, StreamId, size_type) const final;
+    //!@}
+
+    //!@{
+    //! \name Action interface
+    //! ID of the model
+    ActionId action_id() const final { return action_id_; }
+    //! Dependency ordering of the action
+    StepActionOrder order() const final { return StepActionOrder::user_post; }
+    // Launch kernel with host data
+    void step(CoreParams const&, CoreStateHost&) const final;
+    // Launch kernel with device data
+    void step(CoreParams const&, CoreStateDevice&) const final;
+    //!@}
+
+  private:
+    using SPOpticalParams = std::shared_ptr<optical::CoreParams>;
+
+    //// DATA ////
+
+    ActionId action_id_;
+    AuxId aux_id_;
+    SPOffloadParams offload_params_;
+    SPOpticalParams optical_params_;
+
+    //// HELPERS ////
+
+    template<MemSpace M>
+    void execute_impl(CoreParams const&, CoreState<M>&) const;
+};
+
+//---------------------------------------------------------------------------//
+}  // namespace detail
+}  // namespace celeritas
diff --git a/src/corecel/data/CollectionStateStore.hh b/src/corecel/data/CollectionStateStore.hh
index 1c6d674ef0..c9ec5189a9 100644
--- a/src/corecel/data/CollectionStateStore.hh
+++ b/src/corecel/data/CollectionStateStore.hh
@@ -115,6 +115,7 @@ CollectionStateStore<S, M>::CollectionStateStore(HostCRef<P> const& p,
     CELER_EXPECT(sid);
     CELER_EXPECT(size > 0);
     resize(&val_, p, sid, size);
+    CELER_ASSERT(val_);
 
     // Save reference
     ref_ = val_;
@@ -134,6 +135,7 @@ CollectionStateStore<S, M>::CollectionStateStore(HostCRef<P> const& p,
 {
     CELER_EXPECT(size > 0);
     resize(&val_, p, size);
+    CELER_ASSERT(val_);
 
     // Save reference
     ref_ = val_;
@@ -151,6 +153,7 @@ CollectionStateStore<S, M>::CollectionStateStore(size_type size)
 {
     CELER_EXPECT(size > 0);
     resize(&val_, size);
+    CELER_ASSERT(val_);
 
     // Save reference
     ref_ = val_;
@@ -181,6 +184,7 @@ CollectionStateStore<S, M>::CollectionStateStore(S<W2, M2> const& other)
     // Assign using const-cast because state copy operators have to be mutable
     // even when they're just copying...
     val_ = const_cast<S<W2, M2>&>(other);
+    CELER_ASSERT(val_);
     // Save reference
     ref_ = val_;
 }
@@ -197,6 +201,7 @@ auto CollectionStateStore<S, M>::operator=(S<W2, M2> const& other)
     CELER_EXPECT(other);
     // Assign
     val_ = const_cast<S<W2, M2>&>(other);
+    CELER_ASSERT(val_);
     // Save reference
     ref_ = val_;
     return *this;
diff --git a/test/celeritas/optical/OpticalCollector.test.cc b/test/celeritas/optical/OpticalCollector.test.cc
index 7aa33eed73..4cf598ef9c 100644
--- a/test/celeritas/optical/OpticalCollector.test.cc
+++ b/test/celeritas/optical/OpticalCollector.test.cc
@@ -155,9 +155,9 @@ auto LArSphereOffloadTest::build_along_step() -> SPConstAction
 void LArSphereOffloadTest::build_optical_collector()
 {
     OpticalCollector::Input inp;
+    inp.material = this->optical_material();
     if (use_cerenkov_)
     {
-        inp.material = this->optical_material();
         inp.cerenkov = this->cerenkov();
     }
     if (use_scintillation_)
@@ -261,8 +261,8 @@ auto LArSphereOffloadTest::run(size_type num_tracks,
           };
 
     RunResult result;
-    auto& optical_state = get<OpticalOffloadState<M>>(step.state().aux(),
-                                                      collector_->aux_id());
+    auto& optical_state = get<OpticalOffloadState<M>>(
+        step.state().aux(), collector_->offload_aux_id());
 
     auto const& state = optical_state.store.ref();
     auto const& sizes = optical_state.buffer_size;