Port InitRandom for pure SoA (AMReX-Codes/amrex#3325)

This is useful for porting and testing other features, like Checkpoint / Restart. I've also added a test.

Follow-up to #2878.

The proposed changes:
- [x] fix a bug or incorrect behavior in AMReX
- [x] add new capabilities to AMReX
- [ ] changes answers in the test suite to more than roundoff level
- [ ] are likely to significantly affect the results of downstream AMReX users
- [ ] include documentation in the code and/or rst files, if appropriate

Support SoA-Only Particles

## Summary

Further updates of  #2853 where @atmyers and @ax3l can also push to.

## Upgrade Guide for C++ API Changes

These PRs show changes to keep using the *old layout*:
- example in WarpX: https://github.com/ECP-WarpX/WarpX/pull/3848
- example in HiPACE++: https://github.com/Hi-PACE/hipace/pull/921
- example in ImpactX: https://github.com/ECP-WarpX/impactx/pull/342
- example in pyAMReX: https://github.com/AMReX-Codes/pyamrex/pull/119

These PRs show changes to *use the new layout* (pure SoA):
- example in WarpX: https://github.com/ECP-WarpX/WarpX/pull/3850
- example in ImpactX: https://github.com/ECP-WarpX/impactx/pull/348
- example in pyAMReX: https://github.com/AMReX-Codes/pyamrex/pull/124

## Additional background

## Checklist

The proposed changes:
- [ ] fix a bug or incorrect behavior in AMReX
- [x] add new capabilities to AMReX
- [ ] changes answers in the test suite to more than roundoff level
- [ ] are likely to significantly affect the results of downstream AMReX users
- [ ] include documentation in the code and/or rst files, if appropriate

diff --git a/Src/Particle/AMReX_ParticleInit.H b/Src/Particle/AMReX_ParticleInit.H
index 60a20c0e835..28427de32fb 100644
--- a/Src/Particle/AMReX_ParticleInit.H
+++ b/Src/Particle/AMReX_ParticleInit.H
@@ -1046,17 +1046,13 @@ InitRandom (Long                    icount,

         for (Long j = 0; j < icount; j++)
         {
-            ParticleType p;
+            Particle<0, 0> ptest;

             for (int i = 0; i < AMREX_SPACEDIM; i++) {
-                p.pos(i) = pos[j*AMREX_SPACEDIM + i];
+                ptest.pos(i) = pos[j*AMREX_SPACEDIM + i];
             }

-            for (int i = 0; i < NStructReal; i++) {
-                p.rdata(i) = static_cast<ParticleReal>(pdata.real_struct_data[i]);
-            }
-
-            if (!Where(p, pld)) {
+            if (!Where(ptest, pld)) {
                 amrex::Abort("ParticleContainer::InitRandom(): invalid particle");
             }

@@ -1067,27 +1063,58 @@ InitRandom (Long                    icount,

             if (who == MyProc) {

-                // We own it. Add it at the appropriate level.
-                p.id()  = ParticleType::NextID();
-                p.cpu() = MyProc;
+                if constexpr(!ParticleType::is_soa_particle)
+                {
+                    ParticleType p;
+                    for (int i = 0; i < AMREX_SPACEDIM; i++) {
+                        p.pos(i) = pos[j*AMREX_SPACEDIM + i];
+                    }

-                for (int i = 0; i < NStructInt; i++) {
-                    p.idata(i) = pdata.int_struct_data[i];
-                }
+                    // We own it. Add it at the appropriate level.
+                    p.id()  = ParticleType::NextID();
+                    p.cpu() = MyProc;

-                // add the struct
-                host_particles[pld.m_lev][ind].push_back(p);
+                    for (int i = 0; i < NStructInt; i++) {
+                        p.idata(i) = pdata.int_struct_data[i];
+                    }

-                // add the real...
-                for (int i = 0; i < NArrayReal; i++) {
-                    host_real_attribs[pld.m_lev][ind][i].push_back(static_cast<ParticleReal>(pdata.real_array_data[i]));
-                }
+                    for (int i = 0; i < NStructReal; i++) {
+                        p.rdata(i) = static_cast<ParticleReal>(pdata.real_struct_data[i]);
+                    }

-                // ... and int array data
-                for (int i = 0; i < NArrayInt; i++) {
-                    host_int_attribs[pld.m_lev][ind][i].push_back(pdata.int_array_data[i]);
+                    // add the struct
+                    host_particles[pld.m_lev][ind].push_back(p);
+
+                    // add the real...
+                    for (int i = 0; i < NArrayReal; i++) {
+                        host_real_attribs[pld.m_lev][ind][i].push_back(static_cast<ParticleReal>(pdata.real_array_data[i]));
+                    }
+
+                    // ... and int array data
+                    for (int i = 0; i < NArrayInt; i++) {
+                        host_int_attribs[pld.m_lev][ind][i].push_back(pdata.int_array_data[i]);
+                    }
+                } else {
+                    for (int i = 0; i < AMREX_SPACEDIM; i++) {
+                        host_real_attribs[pld.m_lev][ind][i].push_back(pos[j*AMREX_SPACEDIM+i]);
+                    }
+
+                    host_int_attribs[pld.m_lev][ind][0].push_back(ParticleType::NextID());
+                    host_int_attribs[pld.m_lev][ind][1].push_back(MyProc);
+
+                    host_particles[pld.m_lev][ind];
+
+                    // add the real...
+                    for (int i = AMREX_SPACEDIM; i < NArrayReal; i++) {
+                        host_real_attribs[pld.m_lev][ind][i].push_back(static_cast<ParticleReal>(pdata.real_array_data[i]));
+                    }
+
+                    // ... and int array data
+                    for (int i = 2; i < NArrayInt; i++) {
+                        host_int_attribs[pld.m_lev][ind][i].push_back(pdata.int_array_data[i]);
+                    }
                 }
-              }
+            }
         }

         for (int host_lev = 0; host_lev < static_cast<int>(host_particles.size()); ++host_lev)
@@ -1099,11 +1126,20 @@ InitRandom (Long                    icount,

                 auto& dst_tile = GetParticles(host_lev)[std::make_pair(grid,tile)];
                 auto old_size = dst_tile.GetArrayOfStructs().size();
-                auto new_size = old_size + src_tile.size();
+                auto new_size = old_size;
+                if constexpr(!ParticleType::is_soa_particle)
+                {
+                    new_size += src_tile.size();
+                } else {
+                    new_size += host_real_attribs[host_lev][std::make_pair(grid,tile)][0].size();
+                }
                 dst_tile.resize(new_size);

-                Gpu::copyAsync(Gpu::hostToDevice, src_tile.begin(), src_tile.end(),
-                               dst_tile.GetArrayOfStructs().begin() + old_size);
+                if constexpr(!ParticleType::is_soa_particle)
+                {
+                    Gpu::copyAsync(Gpu::hostToDevice, src_tile.begin(), src_tile.end(),
+                                   dst_tile.GetArrayOfStructs().begin() + old_size);
+                }

                 for (int i = 0; i < NArrayReal; ++i) {
                     Gpu::copyAsync(Gpu::hostToDevice,
@@ -1148,7 +1184,7 @@ InitRandom (Long                    icount,
         host_int_attribs.resize(finestLevel()+1);

         for (Long icnt = 0; icnt < M; icnt++) {
-            ParticleType p;
+            Particle<0, 0> ptest;
             for (int i = 0; i < AMREX_SPACEDIM; i++) {
                 do {
                     r = amrex::Random();
@@ -1156,42 +1192,71 @@ InitRandom (Long                    icount,
                 }
                 while (static_cast<ParticleReal>(x) < static_cast<ParticleReal>(xlo[i]) || static_cast<ParticleReal>(x) >= static_cast<ParticleReal>(xhi[i]));

-                p.pos(i) = static_cast<ParticleReal>(x);
+                ptest.pos(i) = static_cast<ParticleReal>(x);

-                AMREX_ASSERT(p.pos(i) < geom.ProbHi(i));
+                AMREX_ASSERT(ptest.pos(i) < geom.ProbHi(i));
             }

-            for (int i = 0; i < NStructReal; i++) {
-                p.rdata(i) = static_cast<ParticleReal>(pdata.real_struct_data[i]);
-            }
-
-            // the int struct data
-            p.id()  = ParticleType::NextID();
-            p.cpu() = ParallelDescriptor::MyProc();
-
-            for (int i = 0; i < NStructInt; i++) {
-                p.idata(i) = pdata.int_struct_data[i];
-            }
+            ptest.id()  = ParticleType::NextID();
+            ptest.cpu() = ParallelDescriptor::MyProc();

             // locate the particle
-            if (!Where(p, pld))
+            if (!Where(ptest, pld))
             {
                 amrex::Abort("ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt>::InitRandom(): invalid particle");
             }
             AMREX_ASSERT(pld.m_lev >= 0 && pld.m_lev <= finestLevel());
             std::pair<int, int> ind(pld.m_grid, pld.m_tile);

-            // add the struct
-            host_particles[pld.m_lev][ind].push_back(p);
+            if constexpr(!ParticleType::is_soa_particle)
+            {
+                ParticleType p;
+                for (int i = 0; i < AMREX_SPACEDIM; i++) {
+                    p.pos(i) = ptest.pos(i);;
+                }

-            // add the real...
-            for (int i = 0; i < NArrayReal; i++) {
-                host_real_attribs[pld.m_lev][ind][i].push_back(static_cast<ParticleReal>(pdata.real_array_data[i]));
-            }
+                p.id()  = ptest.id();
+                p.cpu() = ptest.cpu();

-            // ... and int array data
-            for (int i = 0; i < NArrayInt; i++) {
-                host_int_attribs[pld.m_lev][ind][i].push_back(pdata.int_array_data[i]);
+                for (int i = 0; i < NStructReal; i++) {
+                    p.rdata(i) = static_cast<ParticleReal>(pdata.real_struct_data[i]);
+                }
+
+                for (int i = 0; i < NStructInt; i++) {
+                    p.idata(i) = pdata.int_struct_data[i];
+                }
+
+                // add the struct
+                host_particles[pld.m_lev][ind].push_back(p);
+
+                // add the real...
+                for (int i = 0; i < NArrayReal; i++) {
+                    host_real_attribs[pld.m_lev][ind][i].push_back(static_cast<ParticleReal>(pdata.real_array_data[i]));
+                }
+
+                // ... and int array data
+                for (int i = 0; i < NArrayInt; i++) {
+                    host_int_attribs[pld.m_lev][ind][i].push_back(pdata.int_array_data[i]);
+                }
+            } else {
+                for (int i = 0; i < AMREX_SPACEDIM; i++) {
+                    host_real_attribs[pld.m_lev][ind][i].push_back(ptest.pos(i));
+                }
+
+                host_int_attribs[pld.m_lev][ind][0].push_back(ptest.id());
+                host_int_attribs[pld.m_lev][ind][1].push_back(ptest.cpu());
+
+                host_particles[pld.m_lev][ind];
+
+                // add the real...
+                for (int i = AMREX_SPACEDIM; i < NArrayReal; i++) {
+                    host_real_attribs[pld.m_lev][ind][i].push_back(static_cast<ParticleReal>(pdata.real_array_data[i]));
+                }
+
+                // ... and int array data
+                for (int i = 2; i < NArrayInt; i++) {
+                    host_int_attribs[pld.m_lev][ind][i].push_back(pdata.int_array_data[i]);
+                }
             }
         }

@@ -1204,11 +1269,20 @@ InitRandom (Long                    icount,

                 auto& dst_tile = GetParticles(host_lev)[std::make_pair(grid,tile)];
                 auto old_size = dst_tile.GetArrayOfStructs().size();
-                auto new_size = old_size + src_tile.size();
+                auto new_size = old_size;
+                if constexpr(!ParticleType::is_soa_particle)
+                {
+                    new_size += src_tile.size();
+                } else {
+                    new_size += host_real_attribs[host_lev][std::make_pair(grid,tile)][0].size();
+                }
                 dst_tile.resize(new_size);

-                Gpu::copyAsync(Gpu::hostToDevice, src_tile.begin(), src_tile.end(),
-                               dst_tile.GetArrayOfStructs().begin() + old_size);
+                if constexpr(!ParticleType::is_soa_particle)
+                {
+                    Gpu::copyAsync(Gpu::hostToDevice, src_tile.begin(), src_tile.end(),
+                                   dst_tile.GetArrayOfStructs().begin() + old_size);
+                }

                 for (int i = 0; i < NArrayReal; ++i) {
                     Gpu::copyAsync(Gpu::hostToDevice,

diff --git a/Tests/Particles/InitRandom/CMakeLists.txt b/Tests/Particles/InitRandom/CMakeLists.txt
new file mode 100644
index 00000000000..c032392c5df
--- /dev/null
+++ b/Tests/Particles/InitRandom/CMakeLists.txt
@@ -0,0 +1,14 @@
+# This tests requires particle support
+if (NOT AMReX_PARTICLES)
+   return()
+endif ()
+
+foreach(D IN LISTS AMReX_SPACEDIM)
+    set(_sources     main.cpp)
+    set(_input_files inputs  )
+
+    setup_test(${D} _sources _input_files)
+
+    unset(_sources)
+    unset(_input_files)
+endforeach()
diff --git a/Tests/Particles/InitRandom/GNUmakefile b/Tests/Particles/InitRandom/GNUmakefile
new file mode 100644
index 00000000000..b3510b88d2e
--- /dev/null
+++ b/Tests/Particles/InitRandom/GNUmakefile
@@ -0,0 +1,31 @@
+AMREX_HOME = ../../../
+
+# DEBUG	= TRUE
+DEBUG	= FALSE
+
+DIM	= 3
+
+COMP    = gnu
+
+PRECISION = DOUBLE
+
+USE_MPI   = TRUE
+MPI_THREAD_MULTIPLE = FALSE
+
+USE_OMP   = FALSE
+
+TINY_PROFILE = TRUE
+
+USE_PARTICLES = TRUE
+
+###################################################
+
+EBASE     = main
+
+include $(AMREX_HOME)/Tools/GNUMake/Make.defs
+
+include ./Make.package
+include $(AMREX_HOME)/Src/Base/Make.package
+include $(AMREX_HOME)/Src/Particle/Make.package
+
+include $(AMREX_HOME)/Tools/GNUMake/Make.rules
diff --git a/Tests/Particles/InitRandom/Make.package b/Tests/Particles/InitRandom/Make.package
new file mode 100644
index 00000000000..6b4b865e8fc
--- /dev/null
+++ b/Tests/Particles/InitRandom/Make.package
@@ -0,0 +1,1 @@
+CEXE_sources += main.cpp
diff --git a/Tests/Particles/InitRandom/inputs b/Tests/Particles/InitRandom/inputs
new file mode 100644
index 00000000000..59b4212ed5b
--- /dev/null
+++ b/Tests/Particles/InitRandom/inputs
@@ -0,0 +1,13 @@
+# Domain size
+ncells = 64
+
+# Maximum allowable size of each subdomain in the problem domain;
+# this is used to decompose the domain for parallel calculations.
+max_grid_size = 8
+
+# Number of levels
+nlevs = 1
+
+# Number of particles per cell
+nppc = 2
+
diff --git a/Tests/Particles/InitRandom/main.cpp b/Tests/Particles/InitRandom/main.cpp
new file mode 100644
index 00000000000..c3c9f730b53
--- /dev/null
+++ b/Tests/Particles/InitRandom/main.cpp
@@ -0,0 +1,187 @@
+#include <AMReX.H>
+#include <AMReX_ParmParse.H>
+#include <AMReX_PlotFileUtil.H>
+#include <AMReX_Particles.H>
+
+#include <cstdio>
+
+using namespace amrex;
+
+void set_grids_nested (Vector<Box>& domains,
+                       Vector<BoxArray>& grids,
+                       Vector<IntVect>& ref_ratio);
+void test ();
+void testSOA ();
+
+int main(int argc, char* argv[])
+{
+    amrex::Initialize(argc,argv);
+    test();
+    testSOA();
+    amrex::Finalize();
+}
+
+void testSOA ()
+{
+    int ncells, max_grid_size, nlevs, nppc;
+
+    ParmParse pp;
+    pp.get("ncells", ncells);
+    pp.get("max_grid_size", max_grid_size);
+    pp.get("nlevs", nlevs);
+    pp.get("nppc", nppc);
+
+    Vector<Box> domains;
+    Vector<BoxArray> ba;
+    Vector<IntVect> ref_ratio;
+
+    set_grids_nested(domains, ba, ref_ratio);
+
+    RealBox real_box;
+    for (int n = 0; n < AMREX_SPACEDIM; n++) {
+        real_box.setLo(n, 0.0);
+        real_box.setHi(n, 1.0);
+    }
+
+    // This sets the boundary conditions to be doubly or triply periodic
+    int is_per[] = {AMREX_D_DECL(1,1,1)};
+
+    // This defines a Geometry object for each level
+    Vector<Geometry> geom(nlevs);
+    geom[0].define(domains[0], &real_box, CoordSys::cartesian, is_per);
+    for (int lev = 1; lev < nlevs; lev++) {
+        geom[lev].define(domains[lev], &real_box, CoordSys::cartesian, is_per);
+    }
+
+    Vector<DistributionMapping> dmap(nlevs);
+
+    for (int lev = 0; lev < nlevs; lev++) {
+        dmap[lev] = DistributionMapping{ba[lev]};
+    }
+
+    // Add some particles
+    constexpr int NReal  = 12;
+    constexpr int NInt   = 4;
+
+    typedef ParticleContainerPureSoA<NReal, NInt> MyPC;
+    MyPC myPC(geom, dmap, ba, ref_ratio);
+    myPC.SetVerbose(false);
+
+    int num_particles = nppc * AMREX_D_TERM(ncells, * ncells, * ncells);
+    bool serialize = false;
+    int iseed = 451;
+    MyPC::ParticleInitData pdata = {{}, {},
+                      {1.0, 2.0, 3.0, 4.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0},
+                      {5, 14, 15, 16}};
+
+    myPC.InitRandom(num_particles, iseed, pdata, serialize);
+    amrex::Print() << "Generated " << myPC.TotalNumberOfParticles() << " particles. \n";
+}
+
+void test ()
+{
+    int ncells, max_grid_size, nlevs, nppc;
+
+    ParmParse pp;
+    pp.get("ncells", ncells);
+    pp.get("max_grid_size", max_grid_size);
+    pp.get("nlevs", nlevs);
+    pp.get("nppc", nppc);
+
+    Vector<Box> domains;
+    Vector<BoxArray> ba;
+    Vector<IntVect> ref_ratio;
+
+    set_grids_nested(domains, ba, ref_ratio);
+
+    RealBox real_box;
+    for (int n = 0; n < AMREX_SPACEDIM; n++) {
+        real_box.setLo(n, 0.0);
+        real_box.setHi(n, 1.0);
+    }
+
+    // This sets the boundary conditions to be doubly or triply periodic
+    int is_per[] = {AMREX_D_DECL(1,1,1)};
+
+    // This defines a Geometry object for each level
+    Vector<Geometry> geom(nlevs);
+    geom[0].define(domains[0], &real_box, CoordSys::cartesian, is_per);
+    for (int lev = 1; lev < nlevs; lev++) {
+        geom[lev].define(domains[lev], &real_box, CoordSys::cartesian, is_per);
+    }
+
+    Vector<DistributionMapping> dmap(nlevs);
+
+    for (int lev = 0; lev < nlevs; lev++) {
+        dmap[lev] = DistributionMapping{ba[lev]};
+    }
+
+    // Add some particles
+    constexpr int NStructReal = 4;
+    constexpr int NStructInt  = 1;
+    constexpr int NArrayReal  = 8;
+    constexpr int NArrayInt   = 3;
+
+    typedef ParticleContainer<NStructReal, NStructInt, NArrayReal, NArrayInt> MyPC;
+    MyPC myPC(geom, dmap, ba, ref_ratio);
+    myPC.SetVerbose(false);
+
+    int num_particles = nppc * AMREX_D_TERM(ncells, * ncells, * ncells);
+    bool serialize = false;
+    int iseed = 451;
+    MyPC::ParticleInitData pdata = {{1.0, 2.0, 3.0, 4.0},
+                                    {5},
+                                    {6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0},
+                                    {14, 15, 16}};
+
+    myPC.InitRandom(num_particles, iseed, pdata, serialize);
+    amrex::Print() << "Generated " << myPC.TotalNumberOfParticles() << " particles. \n";
+}
+
+void set_grids_nested (Vector<Box>& domains,
+                       Vector<BoxArray>& grids,
+                       Vector<IntVect>& ref_ratio)
+{
+    int ncells, max_grid_size, nlevs;
+
+    ParmParse pp;
+    pp.get("ncells", ncells);
+    pp.get("max_grid_size", max_grid_size);
+    pp.get("nlevs", nlevs);
+
+    AMREX_ALWAYS_ASSERT(nlevs < 2); // relax this later
+
+    IntVect domain_lo(AMREX_D_DECL(0, 0, 0));
+    IntVect domain_hi(AMREX_D_DECL(ncells-1, ncells-1, ncells-1));
+
+    domains.resize(nlevs);
+    domains[0].setSmall(domain_lo);
+    domains[0].setBig(domain_hi);
+
+    ref_ratio.resize(nlevs-1);
+    for (int lev = 1; lev < nlevs; lev++)
+        ref_ratio[lev-1] = IntVect(AMREX_D_DECL(2, 2, 2));
+
+    grids.resize(nlevs);
+    grids[0].define(domains[0]);
+
+    // Now we make the refined level be the center eighth of the domain
+    if (nlevs > 1) {
+        int n_fine = ncells*ref_ratio[0][0];
+        IntVect refined_lo(AMREX_D_DECL(n_fine/4,n_fine/4,n_fine/4));
+        IntVect refined_hi(AMREX_D_DECL(3*n_fine/4-1,3*n_fine/4-1,3*n_fine/4-1));
+
+        // Build a box for the level 1 domain
+        Box refined_patch(refined_lo, refined_hi);
+        grids[1].define(refined_patch);
+    }
+
+    // break the BoxArrays at both levels into max_grid_size^3 boxes
+    for (int lev = 0; lev < nlevs; lev++) {
+        grids[lev].maxSize(max_grid_size);
+    }
+
+    for (int lev = 1; lev < nlevs; lev++) {
+        domains[lev] = amrex::refine(domains[lev-1], ref_ratio[lev-1]);
+    }
+}


Implement Checkpoint, Restart, and Plotfile for pure SOA (AMReX-Codes/amrex#3332)

Follow-up to #2878.

The proposed changes:
- [x] fix a bug or incorrect behavior in AMReX
- [x] add new capabilities to AMReX
- [ ] changes answers in the test suite to more than roundoff level
- [ ] are likely to significantly affect the results of downstream AMReX users
- [ ] include documentation in the code and/or rst files, if appropriate

Support SoA-Only Particles

## Summary

Further updates of  #2853 where @atmyers and @ax3l can also push to.

## Upgrade Guide for C++ API Changes

These PRs show changes to keep using the *old layout*:
- example in WarpX: https://github.com/ECP-WarpX/WarpX/pull/3848
- example in HiPACE++: https://github.com/Hi-PACE/hipace/pull/921
- example in ImpactX: https://github.com/ECP-WarpX/impactx/pull/342
- example in pyAMReX: https://github.com/AMReX-Codes/pyamrex/pull/119

These PRs show changes to *use the new layout* (pure SoA):
- example in WarpX: https://github.com/ECP-WarpX/WarpX/pull/3850
- example in ImpactX: https://github.com/ECP-WarpX/impactx/pull/348
- example in pyAMReX: https://github.com/AMReX-Codes/pyamrex/pull/124

## Additional background

## Checklist

The proposed changes:
- [ ] fix a bug or incorrect behavior in AMReX
- [x] add new capabilities to AMReX
- [ ] changes answers in the test suite to more than roundoff level
- [ ] are likely to significantly affect the results of downstream AMReX users
- [ ] include documentation in the code and/or rst files, if appropriate

diff --git a/Src/Particle/AMReX_ParticleContainer.H b/Src/Particle/AMReX_ParticleContainer.H
index abffbefc860..3e6a6113fc8 100644
--- a/Src/Particle/AMReX_ParticleContainer.H
+++ b/Src/Particle/AMReX_ParticleContainer.H
@@ -1346,7 +1346,8 @@ protected:
     * \param lev_max
     * \param local_grid
     */
-    bool EnforcePeriodicWhere (ParticleType& prt, ParticleLocData& pld,
+    template <typename P>
+    bool EnforcePeriodicWhere (P& prt, ParticleLocData& pld,
                                int lev_min = 0, int lev_max = -1, int local_grid=-1) const;

 public:
@@ -1378,7 +1379,8 @@ private:
     void RedistributeMPI (std::map<int, Vector<char> >& not_ours,
                           int lev_min = 0, int lev_max = 0, int nGrow = 0, int local=0);

-    void locateParticle (ParticleType& p, ParticleLocData& pld,
+    template <typename P>
+    void locateParticle (P& p, ParticleLocData& pld,
                          int lev_min, int lev_max, int nGrow, int local_grid=-1) const;

     void Initialize ();
diff --git a/Src/Particle/AMReX_ParticleContainerI.H b/Src/Particle/AMReX_ParticleContainerI.H
index 9a5007f86ec..034bc50cd6f 100644
--- a/Src/Particle/AMReX_ParticleContainerI.H
+++ b/Src/Particle/AMReX_ParticleContainerI.H
@@ -164,9 +164,10 @@ ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>

 template <typename ParticleType, int NArrayReal, int NArrayInt,
           template<class> class Allocator>
+template <typename P>
 bool
 ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>
-::EnforcePeriodicWhere (ParticleType&    p,
+::EnforcePeriodicWhere (P&               p,
                         ParticleLocData& pld,
                         int              lev_min,
                         int              lev_max,
@@ -312,8 +313,9 @@ ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>::resizeDa

 template <typename ParticleType, int NArrayReal, int NArrayInt,
           template<class> class Allocator>
+template <typename P>
 void
-ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>::locateParticle (ParticleType& p, ParticleLocData& pld,
+ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>::locateParticle (P& p, ParticleLocData& pld,
                                                                                int lev_min, int lev_max, int nGrow, int local_grid) const
 {
     bool success;
diff --git a/Src/Particle/AMReX_ParticleIO.H b/Src/Particle/AMReX_ParticleIO.H
index a9849ca8a3c..cfc55dfd550 100644
--- a/Src/Particle/AMReX_ParticleIO.H
+++ b/Src/Particle/AMReX_ParticleIO.H
@@ -919,7 +919,7 @@ ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>
     int*   iptr = istuff.dataPtr();
     RTYPE* rptr = rstuff.dataPtr();

-    ParticleType p;
+    Particle<NStructReal, NStructInt> ptemp;
     ParticleLocData pld;

     Vector<std::map<std::pair<int, int>, Gpu::HostVector<ParticleType> > > host_particles;
@@ -937,41 +937,43 @@ ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>
     host_int_attribs.resize(finest_level_in_file+1);

     for (int i = 0; i < cnt; i++) {
-        if (convert_ids) {
+        // note: for pure SoA particle layouts, we do write the id, cpu and positions as a struct
+        //       for backwards compatibility with readers
+        if (!ParticleType::is_soa_particle && convert_ids) {
             std::int32_t  xi, yi;
             std::uint32_t xu, yu;
             xi = iptr[0];
             yi = iptr[1];
             std::memcpy(&xu, &xi, sizeof(xi));
             std::memcpy(&yu, &yi, sizeof(yi));
-            p.m_idcpu = ((std::uint64_t)xu) << 32 | yu;
+            ptemp.m_idcpu = ((std::uint64_t)xu) << 32 | yu;
         } else {
-            p.id()   = iptr[0];
-            p.cpu()  = iptr[1];
+            ptemp.id()   = iptr[0];
+            ptemp.cpu()  = iptr[1];
         }
         iptr += 2;

         for (int j = 0; j < NStructInt; j++)
         {
-            p.idata(j) = *iptr;
+            ptemp.idata(j) = *iptr;
             ++iptr;
         }

-        AMREX_ASSERT(p.id() > 0);
+        AMREX_ASSERT(ptemp.id() > 0);

-        AMREX_D_TERM(p.pos(0) = ParticleReal(rptr[0]);,
-                     p.pos(1) = ParticleReal(rptr[1]);,
-                     p.pos(2) = ParticleReal(rptr[2]););
+        AMREX_D_TERM(ptemp.pos(0) = ParticleReal(rptr[0]);,
+                     ptemp.pos(1) = ParticleReal(rptr[1]);,
+                     ptemp.pos(2) = ParticleReal(rptr[2]););

         rptr += AMREX_SPACEDIM;

         for (int j = 0; j < NStructReal; j++)
         {
-            p.rdata(j) = ParticleReal(*rptr);
+            ptemp.rdata(j) = ParticleReal(*rptr);
             ++rptr;
         }

-        locateParticle(p, pld, 0, finestLevel(), 0);
+        locateParticle(ptemp, pld, 0, finestLevel(), 0);

         std::pair<int, int> ind(grd, pld.m_tile);

@@ -979,35 +981,69 @@ ParticleContainer_impl<ParticleType, NArrayReal, NArrayInt, Allocator>
         host_int_attribs[lev][ind].resize(NumIntComps());

         // add the struct
-        host_particles[lev][ind].push_back(p);
+        if constexpr(!ParticleType::is_soa_particle)
+        {
+            host_particles[lev][ind].push_back(ptemp);

-        // add the real...
-        for (int icomp = 0; icomp < NumRealComps(); icomp++) {
-            host_real_attribs[lev][ind][icomp].push_back(*rptr);
-            ++rptr;
-        }
+            // add the real...
+            for (int icomp = 0; icomp < NumRealComps(); icomp++) {
+                host_real_attribs[lev][ind][icomp].push_back(*rptr);
+                ++rptr;
+            }

-        // ... and int array data
-        for (int icomp = 0; icomp < NumIntComps(); icomp++) {
-            host_int_attribs[lev][ind][icomp].push_back(*iptr);
-            ++iptr;
+            // ... and int array data
+            for (int icomp = 0; icomp < NumIntComps(); icomp++) {
+                host_int_attribs[lev][ind][icomp].push_back(*iptr);
+                ++iptr;
+            }
+        } else {
+            host_particles[lev][ind];
+
+            for (int j = 0; j < AMREX_SPACEDIM; j++) {
+                host_real_attribs[pld.m_lev][ind][j].push_back(ptemp.pos(j));
+            }
+
+            host_int_attribs[pld.m_lev][ind][0].push_back(ptemp.id());
+            host_int_attribs[pld.m_lev][ind][1].push_back(ptemp.cpu());
+
+            // read all other SoA
+            // add the real...
+            for (int icomp = AMREX_SPACEDIM; icomp < NumRealComps(); icomp++) {
+                host_real_attribs[lev][ind][icomp].push_back(*rptr);
+                ++rptr;
+            }
+
+            // ... and int array data
+            for (int icomp = 2; icomp < NumIntComps(); icomp++) {
+                host_int_attribs[lev][ind][icomp].push_back(*iptr);
+                ++iptr;
+            }
         }
     }

     for (int host_lev = 0; host_lev < static_cast<int>(host_particles.size()); ++host_lev)
-      {
+    {
         for (auto& kv : host_particles[host_lev]) {
           auto grid = kv.first.first;
           auto tile = kv.first.second;
           const auto& src_tile = kv.second;

           auto& dst_tile = DefineAndReturnParticleTile(host_lev, grid, tile);
-          auto old_size = dst_tile.GetArrayOfStructs().size();
-          auto new_size = old_size + src_tile.size();
+          auto old_size = dst_tile.size();
+          auto new_size = old_size;
+          if constexpr(!ParticleType::is_soa_particle)
+          {
+              new_size += src_tile.size();
+          } else {
+              new_size += host_real_attribs[host_lev][std::make_pair(grid,tile)][0].size();
+          }
           dst_tile.resize(new_size);

-          Gpu::copyAsync(Gpu::hostToDevice, src_tile.begin(), src_tile.end(),
-                         dst_tile.GetArrayOfStructs().begin() + old_size);
+          if constexpr(!ParticleType::is_soa_particle)
+          {
+              Gpu::copyAsync(Gpu::hostToDevice, src_tile.begin(), src_tile.end(),
+                             dst_tile.GetArrayOfStructs().begin() + old_size);
+          }

           for (int i = 0; i < NumRealComps(); ++i) {
               Gpu::copyAsync(Gpu::hostToDevice,
diff --git a/Src/Particle/AMReX_ParticleTile.H b/Src/Particle/AMReX_ParticleTile.H
index 9f1fe712c64..3b5ee15fdb0 100644
--- a/Src/Particle/AMReX_ParticleTile.H
+++ b/Src/Particle/AMReX_ParticleTile.H
@@ -226,6 +226,9 @@ struct ParticleTileData
     {
         AMREX_ASSERT(index < m_size);
         SuperParticleType sp;
+        for (int i = 0; i < AMREX_SPACEDIM; ++i) {sp.pos(i) = m_rdata[i][index];}
+        sp.id()  = m_idata[0][index];
+        sp.cpu() = m_idata[1][index];
         for (int i = 0; i < NAR; ++i)
             sp.rdata(i) = m_rdata[i][index];
         for (int i = 0; i < NAI; ++i)
@@ -595,6 +598,9 @@ struct ConstParticleTileData
     {
         AMREX_ASSERT(index < m_size);
         SuperParticleType sp;
+        for (int i = 0; i < AMREX_SPACEDIM; ++i) {sp.pos(i) = m_rdata[i][index];}
+        sp.id()  = m_idata[0][index];
+        sp.cpu() = m_idata[1][index];
         for (int i = 0; i < NAR; ++i)
             sp.rdata(i) = m_rdata[i][index];
         for (int i = 0; i < NAI; ++i)
diff --git a/Src/Particle/AMReX_WriteBinaryParticleData.H b/Src/Particle/AMReX_WriteBinaryParticleData.H
index 7767972998d..f182eb2b3e8 100644
--- a/Src/Particle/AMReX_WriteBinaryParticleData.H
+++ b/Src/Particle/AMReX_WriteBinaryParticleData.H
@@ -13,7 +13,7 @@ struct KeepValidFilter
     AMREX_GPU_HOST_DEVICE
     int operator() (const SrcData& src, int i) const noexcept
     {
-        return (src.m_aos[i].id() > 0);
+        return (src.id(i) > 0);
     }
 };

@@ -197,7 +197,7 @@ packIOData (Vector<int>& idata, Vector<ParticleReal>& rdata, const PC& pc, int l
     for (int i = 0; i < tiles.size(); i++) {
         const auto& ptile = pc.ParticlesAt(lev, grid, tiles[i]);
         const auto& pflags = particle_io_flags[lev].at(std::make_pair(grid, tiles[i]));
-        int np_tile = ptile.GetArrayOfStructs().numParticles();
+        int np_tile = ptile.numParticles();
         typename PC::IntVector offsets(np_tile);
         int num_copies = Scan::ExclusiveSum(np_tile, pflags.begin(), offsets.begin(), Scan::retSum);

@@ -283,44 +283,70 @@ packIOData (Vector<int>& idata, Vector<ParticleReal>& rdata, const PC& pc, int l
     const Long rChunkSize = AMREX_SPACEDIM + num_output_real;
     rdata.resize(np*rChunkSize);

-    int* iptr = idata.dataPtr();
+        int* iptr = idata.dataPtr();
     ParticleReal* rptr = rdata.dataPtr();
     for (unsigned i = 0; i < tiles.size(); i++) {
         const auto& ptile = pc.ParticlesAt(lev, grid, tiles[i]);
         const auto& pflags = particle_io_flags[lev].at(std::make_pair(grid, tiles[i]));
-        for (int pindex = 0; pindex < ptile.GetArrayOfStructs().numParticles(); ++pindex) {
-            const auto& aos = ptile.GetArrayOfStructs();
-            const auto& p = aos[pindex];
+        for (int pindex = 0; pindex < ptile.numParticles(); ++pindex) {
             if (pflags[pindex]) {
-                packParticleIDs(iptr, p, is_checkpoint);
-                iptr += 2;
-
-                for (int j = 0; j < AMREX_SPACEDIM; j++) rptr[j] = p.pos(j);
-                rptr += AMREX_SPACEDIM;
+                const auto& soa  = ptile.GetStructOfArrays();

-                for (int j = 0; j < PC::NStructInt; j++) {
-                    if (write_int_comp[j]) {
-                        *iptr = p.idata(j);
-                        ++iptr;
+                // note: for pure SoA particle layouts, we do write the id, cpu and positions as a struct
+                //       for backwards compatibility with readers
+                if constexpr(!PC::ParticleType::is_soa_particle)
+                {
+                    const auto& aos = ptile.GetArrayOfStructs();
+                    const auto& p = aos[pindex];
+
+                    // Int: id, cpu
+                    packParticleIDs(iptr, p, is_checkpoint);
+                    iptr += 2;
+
+                    // Real: positions
+                    for (int j = 0; j < AMREX_SPACEDIM; j++) rptr[j] = p.pos(j);
+                    rptr += AMREX_SPACEDIM;
+
+                    // extra AoS Int components
+                    for (int j = 0; j < PC::NStructInt; j++) {
+                        if (write_int_comp[j]) {
+                            *iptr = p.idata(j);
+                            ++iptr;
+                        }
+                    }
+                    // extra AoS Real components
+                    for (int j = 0; j < PC::NStructReal; j++) {
+                        if (write_real_comp[j]) {
+                            *rptr = p.rdata(j);
+                            ++rptr;
+                        }
                     }
                 }
+                else {
+                    amrex::ignore_unused(is_checkpoint);
+                    // Int: id, cpu
+                    *iptr = soa.GetIntData(0)[pindex];
+                    iptr += 1;
+                    *iptr = soa.GetIntData(1)[pindex];
+                    iptr += 1;
+
+                    // Real: position
+                    for (int j = 0; j < AMREX_SPACEDIM; j++) rptr[j] = soa.GetRealData(j)[pindex];
+                    rptr += AMREX_SPACEDIM;
+                }

-                const auto& soa  = ptile.GetStructOfArrays();
-                for (int j = 0; j < pc.NumIntComps(); j++) {
+                // extra SoA int data
+                const int int_start_offset = PC::ParticleType::is_soa_particle ? 2 : 0; // pure SoA: skip id, cpu
+                for (int j = int_start_offset; j < pc.NumIntComps(); j++) {
                     if (write_int_comp[PC::NStructInt+j]) {
                         *iptr = soa.GetIntData(j)[pindex];
                         ++iptr;
                     }
                 }

-                for (int j = 0; j < PC::NStructReal; j++) {
-                    if (write_real_comp[j]) {
-                        *rptr = p.rdata(j);
-                        ++rptr;
-                    }
-                }
-
-                for (int j = 0; j < pc.NumRealComps(); j++) {
+                // extra SoA Real components
+                const int real_start_offset = PC::ParticleType::is_soa_particle ? AMREX_SPACEDIM : 0; // pure SoA: skip positions
+                for (int j = real_start_offset; j < pc.NumRealComps(); j++) {
                     if (write_real_comp[PC::NStructReal+j]) {
                         *rptr = (ParticleReal) soa.GetRealData(j)[pindex];
                         ++rptr;
@@ -647,8 +673,7 @@ void WriteBinaryParticleDataAsync (PC const& pc,
         {
             int gid = pti.index();
             const auto& ptile = pc.ParticlesAt(lev, pti);
-            const auto& aos = ptile.GetArrayOfStructs();
-            const auto pstruct = aos().dataPtr();
+            const auto& ptd = ptile.getConstParticleTileData();
             const int np = ptile.numParticles();

             ReduceOps<ReduceOpSum> reduce_op;
@@ -658,7 +683,7 @@ void WriteBinaryParticleDataAsync (PC const& pc,
             reduce_op.eval(np, reduce_data,
             [=] AMREX_GPU_DEVICE (int i) -> ReduceTuple
             {
-                return (pstruct[i].id() > 0) ? 1 : 0;
+                return (ptd.id(i) > 0) ? 1 : 0;
             });

             int np_valid = amrex::get<0>(reduce_data.value(reduce_op));
@@ -938,30 +963,43 @@ void WriteBinaryParticleDataAsync (PC const& pc,
                 for (unsigned i = 0; i < tile_map[grid].size(); i++) {
                     auto ptile_index = std::make_pair(grid, tile_map[grid][i]);
                     const auto& pbox = (*myptiles)[lev][ptile_index];
-                    for (int pindex = 0;
-                         pindex < pbox.GetArrayOfStructs().numParticles(); ++pindex)
+                    const auto ptd = pbox.getConstParticleTileData();
+                    for (int pindex = 0; pindex < pbox.numParticles(); ++pindex)
                     {
-                        const auto& aos = pbox.GetArrayOfStructs();
-                        const auto& p = aos[pindex];
+                        const auto& soa  = pbox.GetStructOfArrays();

+                        const auto& p = make_particle<typename PC::ConstParticleType>{}(ptd, pindex);
                         if (p.id() <= 0) continue;

-                        // always write these
-                        particle_detail::packParticleIDs(iptr, p, is_checkpoint);
-                        iptr += 2;
-
-                        // optionally write these
-                        for (int j = 0; j < NStructInt; j++)
+                        // note: for pure SoA particle layouts, we do write the id, cpu and positions as a struct
+                        //       for backwards compatibility with readers
+                        if constexpr(!PC::ParticleType::is_soa_particle)
                         {
-                            if (write_int_comp[j])
+                            // Ints: id, cpu
+                            particle_detail::packParticleIDs(iptr, p, is_checkpoint);
+                            iptr += 2;
+
+                            // extra AoS Int components
+                            for (int j = 0; j < NStructInt; j++)
                             {
-                                *iptr = p.idata(j);
-                                ++iptr;
+                                if (write_int_comp[j])
+                                {
+                                    *iptr = p.idata(j);
+                                    ++iptr;
+                                }
                             }
                         }
+                        else {
+                            // Ints: id, cpu
+                            *iptr = soa.GetIntData(0)[pindex];
+                            iptr += 1;
+                            *iptr = soa.GetIntData(1)[pindex];
+                            iptr += 1;
+                        }

-                        const auto& soa  = pbox.GetStructOfArrays();
-                        for (int j = 0; j < nic; j++)
+                        // extra SoA Ints
+                        const int int_start_offset = PC::ParticleType::is_soa_particle ? 2 : 0; // pure SoA: skip id, cpu
+                        for (int j = int_start_offset; j < nic; j++)
                         {
                             if (write_int_comp[NStructInt+j])
                             {
@@ -987,30 +1025,40 @@ void WriteBinaryParticleDataAsync (PC const& pc,
                 for (unsigned i = 0; i < tile_map[grid].size(); i++) {
                     auto ptile_index = std::make_pair(grid, tile_map[grid][i]);
                     const auto& pbox = (*myptiles)[lev][ptile_index];
+                    const auto ptd = pbox.getConstParticleTileData();
                     for (int pindex = 0;
-                         pindex < pbox.GetArrayOfStructs().numParticles(); ++pindex)
+                         pindex < pbox.numParticles(); ++pindex)
                     {
-                        const auto& aos = pbox.GetArrayOfStructs();
-                        const auto& p = aos[pindex];
+                        const auto& soa  = pbox.GetStructOfArrays();
+                        const auto& p = make_particle<typename PC::ConstParticleType>{}(ptd, pindex);

                         if (p.id() <= 0) continue;

-                        // always write these
-                        for (int j = 0; j < AMREX_SPACEDIM; j++) rptr[j] = p.pos(j);
-                        rptr += AMREX_SPACEDIM;
-
-                        // optionally write these
-                        for (int j = 0; j < NStructReal; j++)
+                        if constexpr(!PC::ParticleType::is_soa_particle)
                         {
-                            if (write_real_comp[j])
+                            // Real: position
+                            for (int j = 0; j < AMREX_SPACEDIM; j++) rptr[j] = p.pos(j);
+                            rptr += AMREX_SPACEDIM;
+
+                            // extra AoS real
+                            for (int j = 0; j < NStructReal; j++)
                             {
-                                *rptr = p.rdata(j);
-                                ++rptr;
+                                if (write_real_comp[j])
+                                {
+                                    *rptr = p.rdata(j);
+                                    ++rptr;
+                                }
                             }
                         }
+                        else {
+                            // Real: position
+                            for (int j = 0; j < AMREX_SPACEDIM; j++) rptr[j] = soa.GetRealData(j)[pindex];
+                            rptr += AMREX_SPACEDIM;
+                        }

-                        const auto& soa  = pbox.GetStructOfArrays();
-                        for (int j = 0; j < nrc; j++)
+                        // extra SoA real
+                        const int real_start_offset = PC::ParticleType::is_soa_particle ? AMREX_SPACEDIM : 0; // pure SoA: positions
+                        for (int j = real_start_offset; j < nrc; j++)
                         {
                             if (write_real_comp[NStructReal+j])
                             {

diff --git a/Tests/Particles/CheckpointRestartSOA/CMakeLists.txt b/Tests/Particles/CheckpointRestartSOA/CMakeLists.txt
new file mode 100644
index 00000000000..087d3ad4998
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA/CMakeLists.txt
@@ -0,0 +1,14 @@
+# This tests requires particle support
+if (NOT AMReX_PARTICLES)
+   return()
+endif ()
+
+foreach(D IN LISTS AMReX_SPACEDIM)
+    set(_sources     main.cpp)
+    set(_input_files inputs)
+
+    setup_test(${D} _sources _input_files)
+
+    unset(_sources)
+    unset(_input_files)
+endforeach()
diff --git a/Tests/Particles/CheckpointRestartSOA/GNUmakefile b/Tests/Particles/CheckpointRestartSOA/GNUmakefile
new file mode 100644
index 00000000000..b3510b88d2e
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA/GNUmakefile
@@ -0,0 +1,31 @@
+AMREX_HOME = ../../../
+
+# DEBUG	= TRUE
+DEBUG	= FALSE
+
+DIM	= 3
+
+COMP    = gnu
+
+PRECISION = DOUBLE
+
+USE_MPI   = TRUE
+MPI_THREAD_MULTIPLE = FALSE
+
+USE_OMP   = FALSE
+
+TINY_PROFILE = TRUE
+
+USE_PARTICLES = TRUE
+
+###################################################
+
+EBASE     = main
+
+include $(AMREX_HOME)/Tools/GNUMake/Make.defs
+
+include ./Make.package
+include $(AMREX_HOME)/Src/Base/Make.package
+include $(AMREX_HOME)/Src/Particle/Make.package
+
+include $(AMREX_HOME)/Tools/GNUMake/Make.rules
diff --git a/Tests/Particles/CheckpointRestartSOA/Make.package b/Tests/Particles/CheckpointRestartSOA/Make.package
new file mode 100644
index 00000000000..6b4b865e8fc
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA/Make.package
@@ -0,0 +1,1 @@
+CEXE_sources += main.cpp
diff --git a/Tests/Particles/CheckpointRestartSOA/inputs b/Tests/Particles/CheckpointRestartSOA/inputs
new file mode 100644
index 00000000000..81855c5947d
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA/inputs
@@ -0,0 +1,26 @@
+# Domain size
+ncells = 64
+
+# Maximum allowable size of each subdomain in the problem domain;
+# this is used to decompose the domain for parallel calculations.
+max_grid_size = 8
+
+# Number of levels
+nlevs = 1
+
+# Number of components in the multifabs
+ncomp = 6
+
+# Number of particles per cell
+nppc = 2
+
+# Number of plot files to write
+nplotfile = 1
+
+# Number of plot files to write
+nparticlefile = 1
+
+# Whether to check the correctness of Checkpoint / Restart
+restart_check = 1
+
+directory=.
diff --git a/Tests/Particles/CheckpointRestartSOA/main.cpp b/Tests/Particles/CheckpointRestartSOA/main.cpp
new file mode 100644
index 00000000000..b6ccc365964
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA/main.cpp
@@ -0,0 +1,245 @@
+#include <AMReX.H>
+#include <AMReX_ParmParse.H>
+#include <AMReX_PlotFileUtil.H>
+#include <AMReX_Particles.H>
+
+#include <cstdio>
+
+using namespace amrex;
+
+void set_grids_nested (Vector<Box>& domains,
+                       Vector<BoxArray>& grids,
+                       Vector<IntVect>& ref_ratio);
+void test ();
+
+int main(int argc, char* argv[])
+{
+    amrex::Initialize(argc,argv);
+    test();
+    amrex::Finalize();
+}
+
+void test ()
+{
+    const int nghost = 0;
+    int ncells, max_grid_size, ncomp, nlevs, nppc;
+    int restart_check = 0, nplotfile = 1, nparticlefile = 1;
+    std::string directory;
+
+    ParmParse pp;
+    pp.get("ncells", ncells);
+    pp.get("max_grid_size", max_grid_size);
+    pp.get("ncomp", ncomp);
+    pp.get("nlevs", nlevs);
+    pp.get("nppc", nppc);
+    pp.query("nplotfile", nplotfile);
+    pp.query("nparticlefile", nparticlefile);
+    pp.query("restart_check", restart_check);
+    pp.query("directory", directory);
+
+    if (!directory.empty() && directory.back() != '/') {
+        // Include separator if one was not provided
+        directory += "/";
+    }
+
+    Vector<Box> domains;
+    Vector<BoxArray> ba;
+    Vector<IntVect> ref_ratio;
+
+    set_grids_nested(domains, ba, ref_ratio);
+
+    RealBox real_box;
+    for (int n = 0; n < AMREX_SPACEDIM; n++) {
+        real_box.setLo(n, 0.0);
+        real_box.setHi(n, 1.0);
+    }
+
+    // This sets the boundary conditions to be doubly or triply periodic
+    int is_per[] = {AMREX_D_DECL(1,1,1)};
+
+    // This defines a Geometry object for each level
+    Vector<Geometry> geom(nlevs);
+    geom[0].define(domains[0], &real_box, CoordSys::cartesian, is_per);
+    for (int lev = 1; lev < nlevs; lev++) {
+        geom[lev].define(domains[lev], &real_box, CoordSys::cartesian, is_per);
+    }
+
+    Vector<DistributionMapping> dmap(nlevs);
+
+    // write some mesh data too, because tools like yt expect them to be there
+    Vector<std::unique_ptr<MultiFab> > mf(nlevs);
+    for (int lev = 0; lev < nlevs; lev++) {
+        dmap[lev] = DistributionMapping{ba[lev]};
+        mf[lev] = std::make_unique<MultiFab>(ba[lev], dmap[lev], ncomp, nghost);
+        mf[lev]->setVal(lev);
+    }
+
+    // these don't really matter, make something up
+    const Real time = 0.0;
+
+    Vector<std::string> varnames;
+    for (int i = 0; i < ncomp; ++i)
+    {
+        varnames.push_back("component_" + std::to_string(i));
+    }
+
+    Vector<int> level_steps(nlevs, 0);
+
+    char fname[512];
+    for (int ts = 0; ts < nplotfile; ts++) {
+        std::snprintf(fname, sizeof fname, "%splt%05d", directory.c_str(), ts);
+
+        amrex::Print() << "Writing plot file [" << fname << "] ..." << std::endl;
+
+        WriteMultiLevelPlotfile(fname, nlevs, amrex::GetVecOfConstPtrs(mf),
+                                varnames, geom, time, level_steps, ref_ratio);
+
+        amrex::Print() << " done \n";
+    }
+
+    // Add some particles
+    constexpr int NReal  = 12;
+    constexpr int NInt   = 4;
+
+    typedef ParticleContainerPureSoA<NReal, NInt> MyPC;
+    MyPC myPC(geom, dmap, ba, ref_ratio);
+    myPC.SetVerbose(false);
+
+    int num_particles = nppc * AMREX_D_TERM(ncells, * ncells, * ncells);
+    bool serialize = false;
+    int iseed = 451;
+    MyPC::ParticleInitData pdata = {{}, {},
+                      {1.0, 2.0, 3.0, 4.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0},
+                      {5, 14, 15, 16}};
+
+    if (nparticlefile > 0) {
+        amrex::Print() << "Init particles ..." << std::endl;
+
+        myPC.InitRandom(num_particles, iseed, pdata, serialize);
+
+        amrex::Print() << " done \n";
+
+        Vector<std::string> particle_realnames;
+        for (int i = 0; i < NReal; ++i)
+            particle_realnames.push_back("particle_real_component_" + std::to_string(i));
+
+        Vector<std::string> particle_intnames;
+        for (int i = 0; i < NInt; ++i)
+            particle_intnames.push_back("particle_int_component_" + std::to_string(i));
+
+        for (int ts = 0; ts < nparticlefile; ts++) {
+            std::snprintf(fname, sizeof fname, "%splt%05d", directory.c_str(), ts);
+
+            amrex::Print() << "Writing particle file [" << fname << "] ..." << std::endl;
+
+            myPC.Checkpoint(fname, "particle0", false, particle_realnames, particle_intnames);
+
+            amrex::Print() << " done \n";
+        }
+    }
+
+    AsyncOut::Finish();
+    ParallelDescriptor::Barrier();
+
+    char directory_path[512];
+    if (restart_check && nparticlefile > 0)
+    {
+        MyPC newPC(geom, dmap, ba, ref_ratio);
+        std::snprintf(directory_path, sizeof directory_path, "%s%s", directory.c_str(), "plt00000");
+        newPC.Restart(directory_path, "particle0");
+
+        using PType = typename MyPC::SuperParticleType;
+
+        for (int icomp=0; icomp<NReal; ++icomp)
+        {
+            amrex::Print() << "working on comp " << icomp << "\n";
+            auto sm_new = amrex::ReduceSum(newPC,
+                [=] AMREX_GPU_HOST_DEVICE (const PType& p) -> Real
+                {
+                    return p.rdata(icomp);
+                });
+
+            auto sm_old = amrex::ReduceSum(myPC,
+                [=] AMREX_GPU_HOST_DEVICE (const PType& p) -> Real
+                {
+                    return p.rdata(icomp);
+                });
+
+            ParallelDescriptor::ReduceRealSum(sm_new);
+            ParallelDescriptor::ReduceRealSum(sm_old);
+
+            amrex::Print() << sm_old << " " << sm_new << "\n";
+            AMREX_ALWAYS_ASSERT(sm_old == sm_new);
+        }
+
+        for (int icomp=0; icomp<NInt; ++icomp)
+        {
+            amrex::Print() << "working on comp " << icomp << "\n";
+            auto sm_new = amrex::ReduceSum(newPC,
+                [=] AMREX_GPU_HOST_DEVICE (const PType& p) -> Real
+                {
+                    return p.idata(icomp);
+                });
+
+            auto sm_old = amrex::ReduceSum(myPC,
+                [=] AMREX_GPU_HOST_DEVICE (const PType& p) -> Real
+                {
+                    return p.idata(icomp);
+                });
+
+            ParallelDescriptor::ReduceRealSum(sm_new);
+            ParallelDescriptor::ReduceRealSum(sm_old);
+
+            amrex::Print() << sm_old << " " << sm_new << "\n";
+            AMREX_ALWAYS_ASSERT(sm_old == sm_new);
+        }
+    }
+}
+
+void set_grids_nested (Vector<Box>& domains,
+                       Vector<BoxArray>& grids,
+                       Vector<IntVect>& ref_ratio)
+{
+    int ncells, max_grid_size, nlevs;
+
+    ParmParse pp;
+    pp.get("ncells", ncells);
+    pp.get("max_grid_size", max_grid_size);
+    pp.get("nlevs", nlevs);
+
+    AMREX_ALWAYS_ASSERT(nlevs < 2); // relax this later
+
+    IntVect domain_lo(AMREX_D_DECL(0, 0, 0));
+    IntVect domain_hi(AMREX_D_DECL(ncells-1, ncells-1, ncells-1));
+
+    domains.resize(nlevs);
+    domains[0].setSmall(domain_lo);
+    domains[0].setBig(domain_hi);
+
+    ref_ratio.resize(nlevs-1);
+    for (int lev = 1; lev < nlevs; lev++)
+        ref_ratio[lev-1] = IntVect(AMREX_D_DECL(2, 2, 2));
+
+    grids.resize(nlevs);
+    grids[0].define(domains[0]);
+
+    // Now we make the refined level be the center eighth of the domain
+    if (nlevs > 1) {
+        int n_fine = ncells*ref_ratio[0][0];
+        IntVect refined_lo(AMREX_D_DECL(n_fine/4,n_fine/4,n_fine/4));
+        IntVect refined_hi(AMREX_D_DECL(3*n_fine/4-1,3*n_fine/4-1,3*n_fine/4-1));
+
+        // Build a box for the level 1 domain
+        Box refined_patch(refined_lo, refined_hi);
+        grids[1].define(refined_patch);
+    }
+
+    // break the BoxArrays at both levels into max_grid_size^3 boxes
+    for (int lev = 0; lev < nlevs; lev++) {
+        grids[lev].maxSize(max_grid_size);
+    }
+
+    for (int lev = 1; lev < nlevs; lev++) {
+        domains[lev] = amrex::refine(domains[lev-1], ref_ratio[lev-1]);
+    }
+}
diff --git a/Tests/Particles/CheckpointRestartSOA_AsyncIO/CMakeLists.txt b/Tests/Particles/CheckpointRestartSOA_AsyncIO/CMakeLists.txt
new file mode 100644
index 00000000000..087d3ad4998
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA_AsyncIO/CMakeLists.txt
@@ -0,0 +1,14 @@
+# This tests requires particle support
+if (NOT AMReX_PARTICLES)
+   return()
+endif ()
+
+foreach(D IN LISTS AMReX_SPACEDIM)
+    set(_sources     main.cpp)
+    set(_input_files inputs)
+
+    setup_test(${D} _sources _input_files)
+
+    unset(_sources)
+    unset(_input_files)
+endforeach()
diff --git a/Tests/Particles/CheckpointRestartSOA_AsyncIO/GNUmakefile b/Tests/Particles/CheckpointRestartSOA_AsyncIO/GNUmakefile
new file mode 120000
index 00000000000..402f7774f22
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA_AsyncIO/GNUmakefile
@@ -0,0 +1,1 @@
+../CheckpointRestartSOA/GNUmakefile
\ No newline at end of file
diff --git a/Tests/Particles/CheckpointRestartSOA_AsyncIO/Make.package b/Tests/Particles/CheckpointRestartSOA_AsyncIO/Make.package
new file mode 120000
index 00000000000..6fb6dc981b7
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA_AsyncIO/Make.package
@@ -0,0 +1,1 @@
+../CheckpointRestartSOA/Make.package
\ No newline at end of file
diff --git a/Tests/Particles/CheckpointRestartSOA_AsyncIO/inputs b/Tests/Particles/CheckpointRestartSOA_AsyncIO/inputs
new file mode 100644
index 00000000000..8efbea33ad4
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA_AsyncIO/inputs
@@ -0,0 +1,28 @@
+# Domain size
+ncells = 64
+
+# Maximum allowable size of each subdomain in the problem domain;
+# this is used to decompose the domain for parallel calculations.
+max_grid_size = 8
+
+# Number of levels
+nlevs = 1
+
+# Number of components in the multifabs
+ncomp = 6
+
+# Number of particles per cell
+nppc = 2
+
+# Number of plot files to write
+nplotfile = 1
+
+# Number of plot files to write
+nparticlefile = 1
+
+# Whether to check the correctness of Checkpoint / Restart
+restart_check = 1
+
+directory=.
+
+amrex.async_out=1
\ No newline at end of file
diff --git a/Tests/Particles/CheckpointRestartSOA_AsyncIO/main.cpp b/Tests/Particles/CheckpointRestartSOA_AsyncIO/main.cpp
new file mode 120000
index 00000000000..36e9ca62484
--- /dev/null
+++ b/Tests/Particles/CheckpointRestartSOA_AsyncIO/main.cpp
@@ -0,0 +1,1 @@
+../CheckpointRestartSOA/main.cpp
\ No newline at end of file


Ascent: SoA Particle Support (AMReX-Codes/amrex#3350)

## Summary

Add support for pure SoA layouted particle containers for Ascent.

## Additional background

Follow-up to #2878.

## Checklist

The proposed changes:
- [ ] fix a bug or incorrect behavior in AMReX
- [x] add new capabilities to AMReX
- [ ] changes answers in the test suite to more than roundoff level
- [ ] are likely to significantly affect the results of downstream AMReX users
- [ ] include documentation in the code and/or rst files, if appropriate

Support SoA-Only Particles

## Summary

Further updates of  #2853 where @atmyers and @ax3l can also push to.

## Upgrade Guide for C++ API Changes

These PRs show changes to keep using the *old layout*:
- example in WarpX: https://github.com/ECP-WarpX/WarpX/pull/3848
- example in HiPACE++: https://github.com/Hi-PACE/hipace/pull/921
- example in ImpactX: https://github.com/ECP-WarpX/impactx/pull/342
- example in pyAMReX: https://github.com/AMReX-Codes/pyamrex/pull/119

These PRs show changes to *use the new layout* (pure SoA):
- example in WarpX: https://github.com/ECP-WarpX/WarpX/pull/3850
- example in ImpactX: https://github.com/ECP-WarpX/impactx/pull/348
- example in pyAMReX: https://github.com/AMReX-Codes/pyamrex/pull/124

## Additional background

## Checklist

The proposed changes:
- [ ] fix a bug or incorrect behavior in AMReX
- [x] add new capabilities to AMReX
- [ ] changes answers in the test suite to more than roundoff level
- [ ] are likely to significantly affect the results of downstream AMReX users
- [ ] include documentation in the code and/or rst files, if appropriate

diff --git a/Src/Extern/Conduit/AMReX_Conduit_Blueprint.H b/Src/Extern/Conduit/AMReX_Conduit_Blueprint.H
index 6d23bcf07ed..9ac8eb31fd0 100644
--- a/Src/Extern/Conduit/AMReX_Conduit_Blueprint.H
+++ b/Src/Extern/Conduit/AMReX_Conduit_Blueprint.H
@@ -96,9 +96,8 @@ namespace amrex
     //  coordset and fields used to represent the passed particle container.
     //  This allows you to use unique names to wrap multiple particle containers
     //  into a single blueprint tree.
-    template <int NStructReal, int NStructInt, int NArrayReal, int NArrayInt>
-    void ParticleContainerToBlueprint (const ParticleContainer<NStructReal,
-                                                               NStructInt,
+    template <typename ParticleType, int NArrayReal, int NArrayInt>
+    void ParticleContainerToBlueprint (const ParticleContainer_impl<ParticleType,
                                                                NArrayReal,
                                                                NArrayInt> &pc,
                                        const Vector<std::string> &real_comp_names,
diff --git a/Src/Extern/Conduit/AMReX_Conduit_Blueprint_ParticlesI.H b/Src/Extern/Conduit/AMReX_Conduit_Blueprint_ParticlesI.H
index f2d7d1ed2da..e4186ba247b 100644
--- a/Src/Extern/Conduit/AMReX_Conduit_Blueprint_ParticlesI.H
+++ b/Src/Extern/Conduit/AMReX_Conduit_Blueprint_ParticlesI.H
@@ -20,10 +20,9 @@ namespace amrex
 // Note:
 // This is a helper function, it's not part of the AMReX Blueprint Interface.
 //---------------------------------------------------------------------------//
-template <int NStructReal, int NStructInt, int NArrayReal, int NArrayInt>
+template <typename ParticleType, int NArrayReal, int NArrayInt>
 void
-ParticleTileToBlueprint(const ParticleTile<amrex::Particle<NStructReal,
-                                                           NStructInt>,
+ParticleTileToBlueprint(const ParticleTile<ParticleType,
                                            NArrayReal,
                                            NArrayInt> &ptile,
                         const Vector<std::string> &real_comp_names,
@@ -31,15 +30,11 @@ ParticleTileToBlueprint(const ParticleTile<amrex::Particle<NStructReal,
                         conduit::Node &res,
                         const std::string &topology_name)
 {
-    int num_particles = ptile.GetArrayOfStructs().size();
-    int struct_size   = sizeof(Particle<NStructReal, NStructInt>);
+    int num_particles = ptile.size();

     // knowing the above, we can zero copy the x,y,z positions + id, cpu
     // and any user fields in the AOS

-    // get the first particle's struct
-    const auto &pstruct = ptile.GetArrayOfStructs();
-
     // setup a blueprint description for the particle mesh
     // create a coordinate set
     std::string coordset_name = topology_name + "_coords";
@@ -63,29 +58,56 @@ ParticleTileToBlueprint(const ParticleTile<amrex::Particle<NStructReal,
     //----------------------------------//
     // point locations from from aos
     //----------------------------------//
+    char* pbuf = nullptr;

-    const char* pbuf_const = reinterpret_cast<const char*>(pstruct.data());
-    char* pbuf = const_cast<char*>(pbuf_const);
+    if constexpr(ParticleType::is_soa_particle)
+    {
+        amrex::ignore_unused(pbuf);

-    ParticleReal* xp = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
-    n_coords["values/x"].set_external(xp,
-                                      num_particles,
-                                      0,
-                                      struct_size);
+        const auto &soa = ptile.GetStructOfArrays();
+
+        // for soa entries, we can use standard strides,
+        // since these are contiguous arrays
+
+        n_coords["values/x"].set_external(const_cast<ParticleReal*>(&soa.GetRealData(0)[0]),
+                                          num_particles);
 #if AMREX_SPACEDIM > 1
-    ParticleReal* yp = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
-    n_coords["values/y"].set_external(yp,
-                                      num_particles,
-                                      0,
-                                      struct_size);
+        n_coords["values/y"].set_external(const_cast<ParticleReal*>(&soa.GetRealData(1)[0]),
+                                          num_particles);
 #endif
 #if AMREX_SPACEDIM > 2
-    ParticleReal* zp = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
-    n_coords["values/z"].set_external(zp,
-                                      num_particles,
-                                      0,
-                                      struct_size);
+        n_coords["values/z"].set_external(const_cast<ParticleReal*>(&soa.GetRealData(2)[0]),
+                                          num_particles);
+#endif
+    } else
+    {
+        // get the first particle's struct
+        const auto &pstruct = ptile.GetArrayOfStructs();
+        const int struct_size = sizeof(ParticleType);
+
+        const char* pbuf_const = reinterpret_cast<const char*>(pstruct.data());
+        pbuf = const_cast<char*>(pbuf_const);
+
+        ParticleReal* xp = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
+        n_coords["values/x"].set_external(xp,
+                                          num_particles,
+                                          0,
+                                          struct_size);
+#if AMREX_SPACEDIM > 1
+        ParticleReal* yp = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
+        n_coords["values/y"].set_external(yp,
+                                          num_particles,
+                                          0,
+                                          struct_size);
+#endif
+#if AMREX_SPACEDIM > 2
+        ParticleReal* zp = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
+        n_coords["values/z"].set_external(zp,
+                                          num_particles,
+                                          0,
+                                          struct_size);
 #endif
+    }

     // fields
     conduit::Node &n_fields = res["fields"];
@@ -95,20 +117,26 @@ ParticleTileToBlueprint(const ParticleTile<amrex::Particle<NStructReal,
     // -----------------------------

     int vname_real_idx = 0;
-    // struct real fields, the first set are always the particle positions
-    // which we wrap above
-    for (int i = 0; i < NStructReal; i++)
+    if constexpr(!ParticleType::is_soa_particle)
     {
-        ParticleReal* val = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
-        conduit::Node &n_f = n_fields[real_comp_names.at(vname_real_idx)];
-        n_f["topology"] = topology_name;
-        n_f["association"] = "element";
-        n_f["values"].set_external(val,
-                                   num_particles,
-                                   0,
-                                   struct_size);
+        constexpr int struct_size = sizeof(ParticleType);
+        constexpr int NStructReal = ParticleType::NReal;

-        vname_real_idx++;
+        // struct real fields, the first set are always the particle positions
+        // which we wrap above
+        for (int i = 0; i < NStructReal; i++)
+        {
+            ParticleReal* val = reinterpret_cast<ParticleReal*>(pbuf); pbuf += sizeof(ParticleReal);
+            conduit::Node &n_f = n_fields[real_comp_names.at(vname_real_idx)];
+            n_f["topology"] = topology_name;
+            n_f["association"] = "element";
+            n_f["values"].set_external(val,
+                                       num_particles,
+                                       0,
+                                       struct_size);
+
+            vname_real_idx++;
+        }
     }

     //----------------------------------//
@@ -116,44 +144,77 @@ ParticleTileToBlueprint(const ParticleTile<amrex::Particle<NStructReal,
     // (id, cpu)
     //----------------------------------//

-    // id is the first int entry
-    int* id = reinterpret_cast<int*>(pbuf); pbuf += sizeof(int);
-    conduit::Node &n_f_id = n_fields[topology_name + "_id"];
+    if constexpr(!ParticleType::is_soa_particle)
+    {
+        const int struct_size = sizeof(ParticleType);
+
+        // id is the first int entry
+        int* id = reinterpret_cast<int*>(pbuf); pbuf += sizeof(int);
+        conduit::Node &n_f_id = n_fields[topology_name + "_id"];
+
+        n_f_id["topology"] = topology_name;
+        n_f_id["association"] = "element";
+        n_f_id["values"].set_external(id,
+                                      num_particles,
+                                      0,
+                                      struct_size);
+
+        // cpu is the second int entry
+        int* cpu = reinterpret_cast<int*>(pbuf); pbuf += sizeof(int);
+        conduit::Node &n_f_cpu = n_fields[topology_name + "_cpu"];
+
+        n_f_cpu["topology"] = topology_name;
+        n_f_cpu["association"] = "element";
+        n_f_cpu["values"].set_external(cpu,
+                                       num_particles,
+                                       0,
+                                       struct_size);
+    } else {
+        const auto &soa = ptile.GetStructOfArrays();
+
+        // for soa entries, we can use standard strides,
+        // since these are contiguous arrays

-    n_f_id["topology"] = topology_name;
-    n_f_id["association"] = "element";
-    n_f_id["values"].set_external(id,
-                                  num_particles,
-                                  0,
-                                  struct_size);
+        // id is the first int entry
+        conduit::Node &n_f_id = n_fields[topology_name + "_id"];

-    // cpu is the second int entry
-    int* cpu = reinterpret_cast<int*>(pbuf); pbuf += sizeof(int);
-    conduit::Node &n_f_cpu = n_fields[topology_name + "_cpu"];
+        n_f_id["topology"] = topology_name;
+        n_f_id["association"] = "element";
+        n_f_id["values"].set_external(const_cast<int*>(&soa.GetIntData(0)[0]),
+                                      num_particles);

-    n_f_cpu["topology"] = topology_name;
-    n_f_cpu["association"] = "element";
-    n_f_cpu["values"].set_external(cpu,
-                                   num_particles,
-                                   0,
-                                   struct_size);
+        // cpu is the second int entry
+        conduit::Node &n_f_cpu = n_fields[topology_name + "_cpu"];
+
+        n_f_cpu["topology"] = topology_name;
+        n_f_cpu["association"] = "element";
+        n_f_cpu["values"].set_external(const_cast<int*>(&soa.GetIntData(0)[0]),
+                                       num_particles);
+
+    }

     // --------------------------------
     // user defined, integer aos fields
     // --------------------------------

     int vname_int_idx = 0;
-    for (int i = 0; i < NStructInt; i++)
+    if constexpr(!ParticleType::is_soa_particle)
     {
-        int* val = reinterpret_cast<int*>(pbuf); pbuf += sizeof(int);
-        conduit::Node &n_f = n_fields[int_comp_names.at(vname_int_idx)];
-        n_f["topology"] = topology_name;
-        n_f["association"] = "element";
-        n_f["values"].set_external(val,
-                                   num_particles,
-                                   0,
-                                   struct_size);
-        vname_int_idx++;
+        constexpr int struct_size = sizeof(ParticleType);
+        constexpr int NStructInt = ParticleType::NInt;
+
+        for (int i = 0; i < NStructInt; i++)
+        {
+            int* val = reinterpret_cast<int*>(pbuf); pbuf += sizeof(int);
+            conduit::Node &n_f = n_fields[int_comp_names.at(vname_int_idx)];
+            n_f["topology"] = topology_name;
+            n_f["association"] = "element";
+            n_f["values"].set_external(val,
+                                       num_particles,
+                                       0,
+                                       struct_size);
+            vname_int_idx++;
+        }
     }

     // -------------------------
@@ -193,10 +254,9 @@ ParticleTileToBlueprint(const ParticleTile<amrex::Particle<NStructReal,
 //---------------------------------------------------------------------------//
 // Converts a AMReX Particle Container into a Conduit Mesh Blueprint Hierarchy.
 //---------------------------------------------------------------------------//
-template <int NStructReal, int NStructInt, int NArrayReal, int NArrayInt>
+template <typename ParticleType, int NArrayReal, int NArrayInt>
 void
-ParticleContainerToBlueprint(const ParticleContainer<NStructReal,
-                                                     NStructInt,
+ParticleContainerToBlueprint(const ParticleContainer_impl<ParticleType,
                                                      NArrayReal,
                                                      NArrayInt> &pc,
                              const Vector<std::string> &real_comp_names,
@@ -209,8 +269,13 @@ ParticleContainerToBlueprint(const ParticleContainer<NStructReal,
     // validate varnames, which are used to provide field names
     // for user defined aos and soa values.

-    BL_ASSERT(real_comp_names.size() == (NStructReal + NArrayReal) );
-    BL_ASSERT(int_comp_names.size()  == (NStructInt + NArrayInt) );
+    if constexpr(ParticleType::is_soa_particle) {
+        BL_ASSERT(real_comp_names.size() == NArrayReal);
+        BL_ASSERT(int_comp_names.size()  == NArrayInt);
+    } else {
+        BL_ASSERT(real_comp_names.size() == (ParticleType::NReal + NArrayReal) );
+        BL_ASSERT(int_comp_names.size()  == (ParticleType::NInt + NArrayInt) );
+    }

     int num_levels = pc.maxLevel() + 1;
     int num_domains = 0;
@@ -224,7 +289,7 @@ ParticleContainerToBlueprint(const ParticleContainer<NStructReal,
     int rank   = ParallelDescriptor::MyProc();
     int nprocs = ParallelDescriptor::NProcs();

-    using MyParConstIter = ParConstIter<NStructReal, NStructInt, NArrayReal, NArrayInt>;
+    using MyParConstIter = ParConstIter_impl<ParticleType, NArrayReal, NArrayInt>;

     //
     // blueprint expects unique ids for each domain published

diff --git a/Tests/Particles/Ascent_Insitu_SOA/CMakeLists.txt b/Tests/Particles/Ascent_Insitu_SOA/CMakeLists.txt
new file mode 100644
index 00000000000..82216a02af8
--- /dev/null
+++ b/Tests/Particles/Ascent_Insitu_SOA/CMakeLists.txt
@@ -0,0 +1,13 @@
+if ( NOT AMReX_ASCENT )
+   return ()
+endif ()
+
+foreach(D IN LISTS AMReX_SPACEDIM)
+    set(_sources     main.cpp)
+    set(_input_files inputs.rt  )
+
+    setup_test(${D} _sources _input_files NTASKS 2)
+
+    unset(_sources)
+    unset(_input_files)
+endforeach()
diff --git a/Tests/Particles/Ascent_Insitu_SOA/GNUmakefile b/Tests/Particles/Ascent_Insitu_SOA/GNUmakefile
new file mode 100644
index 00000000000..660e4a13f22
--- /dev/null
+++ b/Tests/Particles/Ascent_Insitu_SOA/GNUmakefile
@@ -0,0 +1,24 @@
+AMREX_HOME = ../../../
+
+DEBUG	= FALSE
+
+DIM	= 3
+
+COMP    = gcc
+
+USE_MPI   = TRUE
+USE_OMP   = FALSE
+USE_CUDA  = FALSE
+
+TINY_PROFILE = TRUE
+USE_PARTICLES = TRUE
+USE_ASCENT = TRUE
+
+include $(AMREX_HOME)/Tools/GNUMake/Make.defs
+
+include ./Make.package
+include $(AMREX_HOME)/Src/Base/Make.package
+include $(AMREX_HOME)/Src/Particle/Make.package
+include $(AMREX_HOME)/Src/Extern/Conduit/Make.package
+
+include $(AMREX_HOME)/Tools/GNUMake/Make.rules
diff --git a/Tests/Particles/Ascent_Insitu_SOA/inputs.rt b/Tests/Particles/Ascent_Insitu_SOA/inputs.rt
new file mode 100644
index 00000000000..e34fda14923
--- /dev/null
+++ b/Tests/Particles/Ascent_Insitu_SOA/inputs.rt
@@ -0,0 +1,10 @@
+ascent.size = (32, 64, 64)
+ascent.max_grid_size = 32
+ascent.is_periodic = 1
+ascent.num_ppc = 1
+ascent.nlevs = 1
+
+ascent.num_runtime_real = 0
+ascent.num_runtime_int = 0
+
+particles.do_tiling = 1
diff --git a/Tests/Particles/Ascent_Insitu_SOA/main.cpp b/Tests/Particles/Ascent_Insitu_SOA/main.cpp
new file mode 100644
index 00000000000..46e2af98422
--- /dev/null
+++ b/Tests/Particles/Ascent_Insitu_SOA/main.cpp
@@ -0,0 +1,306 @@
+#include <AMReX.H>
+#include <AMReX_ParmParse.H>
+#include <AMReX_Particles.H>
+
+#if !defined(AMREX_PARTICLES) || !defined(AMREX_USE_CONDUIT)
+#error Incompatible AMReX library configuration! This tutorial requires AMREX_PARTICLES and AMREX_USE_CONDUIT
+#endif
+
+#include <AMReX_Conduit_Blueprint.H>
+
+#include <ascent.hpp>
+
+#include <string>
+
+
+using namespace amrex;
+
+static constexpr int NR = 7;
+static constexpr int NI = 4;
+
+int num_runtime_real = 0;
+int num_runtime_int = 0;
+
+bool remove_negative = true;
+
+void get_position_unit_cell (Real* r, const IntVect& nppc, int i_part)
+{
+    int nx = nppc[0];
+#if AMREX_SPACEDIM > 1
+    int ny = nppc[1];
+#else
+    int ny = 1;
+#endif
+#if AMREX_SPACEDIM > 2
+    int nz = nppc[2];
+#else
+    int nz = 1;
+#endif
+
+    int ix_part = i_part/(ny * nz);
+    int iy_part = (i_part % (ny * nz)) % ny;
+    int iz_part = (i_part % (ny * nz)) / ny;
+
+    r[0] = (0.5+ix_part)/nx;
+    r[1] = (0.5+iy_part)/ny;
+    r[2] = (0.5+iz_part)/nz;
+}
+
+class TestParticleContainer
+    : public amrex::ParticleContainerPureSoA<NR, NI>
+{
+
+public:
+
+    TestParticleContainer (const Vector<amrex::Geometry>            & a_geom,
+                           const Vector<amrex::DistributionMapping> & a_dmap,
+                           const Vector<amrex::BoxArray>            & a_ba,
+                           const Vector<amrex::IntVect>             & a_rr)
+        : amrex::ParticleContainerPureSoA<NR, NI>(a_geom, a_dmap, a_ba, a_rr)
+    {
+        for (int i = 0; i < num_runtime_real; ++i)
+        {
+            AddRealComp(true);
+        }
+        for (int i = 0; i < num_runtime_int; ++i)
+        {
+            AddIntComp(true);
+        }
+    }
+
+    void InitParticles (const amrex::IntVect& a_num_particles_per_cell)
+    {
+        BL_PROFILE("InitParticles");
+
+        const int lev = 0;  // only add particles on level 0
+        const Real* dx = Geom(lev).CellSize();
+        const Real* plo = Geom(lev).ProbLo();
+
+        const int num_ppc = AMREX_D_TERM( a_num_particles_per_cell[0],
+                                         *a_num_particles_per_cell[1],
+                                         *a_num_particles_per_cell[2]);
+
+        for(MFIter mfi = MakeMFIter(lev); mfi.isValid(); ++mfi)
+        {
+            const Box& tile_box  = mfi.tilebox();
+
+            std::array<Gpu::HostVector<ParticleReal>, NR> host_real;
+            std::array<Gpu::HostVector<int>, NI> host_int;
+
+            std::vector<Gpu::HostVector<ParticleReal> > host_runtime_real(NumRuntimeRealComps());
+            std::vector<Gpu::HostVector<int> > host_runtime_int(NumRuntimeIntComps());
+
+            for (IntVect iv = tile_box.smallEnd(); iv <= tile_box.bigEnd(); tile_box.next(iv))
+            {
+                for (int i_part=0; i_part<num_ppc;i_part++) {
+                    Real r[3];
+                    get_position_unit_cell(r, a_num_particles_per_cell, i_part);
+
+                    amrex::Long id = ParticleType::NextID();
+
+                    host_int[0].push_back(static_cast<int>(id));
+                    host_int[1].push_back(ParallelDescriptor::MyProc());
+                    host_real[0].push_back(static_cast<ParticleReal> (plo[0] + (iv[0] + r[0])*dx[0]));
+#if AMREX_SPACEDIM > 1
+                    host_real[1].push_back(static_cast<ParticleReal> (plo[1] + (iv[1] + r[1])*dx[1]));
+#endif
+#if AMREX_SPACEDIM > 2
+                    host_real[2].push_back(static_cast<ParticleReal> (plo[2] + (iv[2] + r[2])*dx[2]));
+#endif
+
+                    for (int i = AMREX_SPACEDIM; i < NR; ++i)
+                        host_real[i].push_back(static_cast<ParticleReal>(id));
+                    for (int i = 2; i < NI; ++i)
+                        host_int[i].push_back(static_cast<int>(id));
+                    for (int i = 0; i < NumRuntimeRealComps(); ++i)
+                        host_runtime_real[i].push_back(static_cast<ParticleReal>(id));
+                    for (int i = 0; i < NumRuntimeIntComps(); ++i)
+                        host_runtime_int[i].push_back(static_cast<int>(id));
+                }
+            }
+
+            auto& particle_tile = DefineAndReturnParticleTile(lev, mfi.index(), mfi.LocalTileIndex());
+            auto old_size = particle_tile.size();
+            auto new_size = old_size + host_real[0].size();
+            particle_tile.resize(new_size);
+
+            auto& soa = particle_tile.GetStructOfArrays();
+            for (int i = 0; i < NR; ++i)
+            {
+                Gpu::copyAsync(Gpu::hostToDevice,
+                               host_real[i].begin(),
+                               host_real[i].end(),
+                               soa.GetRealData(i).begin() + old_size);
+            }
+
+            for (int i = 0; i < NI; ++i)
+            {
+                Gpu::copyAsync(Gpu::hostToDevice,
+                               host_int[i].begin(),
+                               host_int[i].end(),
+                               soa.GetIntData(i).begin() + old_size);
+            }
+            for (int i = 0; i < NumRuntimeRealComps(); ++i)
+            {
+                Gpu::copyAsync(Gpu::hostToDevice,
+                               host_runtime_real[i].begin(),
+                               host_runtime_real[i].end(),
+                               soa.GetRealData(NR+i).begin() + old_size);
+            }
+
+            for (int i = 0; i < NumRuntimeIntComps(); ++i)
+            {
+                Gpu::copyAsync(Gpu::hostToDevice,
+                               host_runtime_int[i].begin(),
+                               host_runtime_int[i].end(),
+                               soa.GetIntData(NI+i).begin() + old_size);
+            }
+
+            Gpu::streamSynchronize();
+        }
+
+        Redistribute();
+    }
+};
+
+struct TestParams
+{
+    IntVect size;
+    int max_grid_size;
+    int num_ppc;
+    int is_periodic;
+    int nlevs;
+};
+
+void testAscent ();
+
+int main (int argc, char* argv[])
+{
+    amrex::Initialize(argc,argv);
+
+    amrex::Print() << "Running redistribute test \n";
+    testAscent();
+
+    amrex::Finalize();
+}
+
+void get_test_params (TestParams& params, const std::string& prefix)
+{
+    ParmParse pp(prefix);
+    pp.get("size", params.size);
+    pp.get("max_grid_size", params.max_grid_size);
+    pp.get("num_ppc", params.num_ppc);
+    pp.get("is_periodic", params.is_periodic);
+    pp.get("nlevs", params.nlevs);
+    pp.query("num_runtime_real", num_runtime_real);
+    pp.query("num_runtime_int", num_runtime_int);
+}
+
+void testAscent ()
+{
+    BL_PROFILE("testAscent");
+    TestParams params;
+    get_test_params(params, "ascent");
+
+    int is_per[BL_SPACEDIM];
+    for (int & d : is_per)
+        d = params.is_periodic;
+
+    Vector<IntVect> rr(params.nlevs-1);
+    for (int lev = 1; lev < params.nlevs; lev++)
+        rr[lev-1] = IntVect(AMREX_D_DECL(2,2,2));
+
+    RealBox real_box;
+    for (int n = 0; n < BL_SPACEDIM; n++)
+    {
+        real_box.setLo(n, 0.0);
+        real_box.setHi(n, params.size[n]);
+    }
+
+    IntVect domain_lo(AMREX_D_DECL(0, 0, 0));
+    IntVect domain_hi(AMREX_D_DECL(params.size[0]-1,params.size[1]-1,params.size[2]-1));
+    const Box base_domain(domain_lo, domain_hi);
+
+    Vector<Geometry> geom(params.nlevs);
+    geom[0].define(base_domain, &real_box, CoordSys::cartesian, is_per);
+    for (int lev = 1; lev < params.nlevs; lev++) {
+        geom[lev].define(amrex::refine(geom[lev-1].Domain(), rr[lev-1]),
+                         &real_box, CoordSys::cartesian, is_per);
+    }
+
+    Vector<BoxArray> ba(params.nlevs);
+    Vector<DistributionMapping> dm(params.nlevs);
+    auto lo = IntVect(AMREX_D_DECL(0, 0, 0));
+    IntVect size = params.size;
+    for (int lev = 0; lev < params.nlevs; ++lev)
+    {
+        ba[lev].define(Box(lo, lo+params.size-1));
+        ba[lev].maxSize(params.max_grid_size);
+        dm[lev].define(ba[lev]);
+        lo += size/2;
+        size *= 2;
+    }
+
+    TestParticleContainer pc(geom, dm, ba, rr);
+
+    int npc = params.num_ppc;
+    auto nppc = IntVect(AMREX_D_DECL(npc, npc, npc));
+
+    amrex::Print() << "About to initialize particles \n";
+
+    pc.InitParticles(nppc);
+
+    {
+        conduit::Node bp_mesh;
+        /* TODO
+        amrex::MultiLevelToBlueprint(
+            nlev,
+            amrex::GetVecOfConstPtrs(mf),
+            varnames,
+            geom,
+            time,
+            iteration,
+            warpx.refRatio(),
+            bp_mesh
+        );
+        */
+
+        // wrap pc for current species into a blueprint topology
+        std::string const prefix = "particle";
+        Vector<std::string> particle_varnames;
+        for (int i = 0; i < pc.NumRealComps(); ++i) {
+            particle_varnames.push_back(prefix + "_real_" + std::to_string(i));
+        }
+        Vector<std::string> particle_int_varnames;
+        for (int i = 0; i < pc.NumIntComps(); ++i) {
+            particle_int_varnames.push_back(prefix + "_int_" + std::to_string(i));
+        }
+
+        amrex::ParticleContainerToBlueprint(pc,
+                                            particle_varnames,
+                                            particle_int_varnames,
+                                            bp_mesh,
+                                            prefix);
+        // publish
+        ascent::Ascent ascent;
+        conduit::Node opts;
+        opts["exceptions"] = "catch";
+        opts["mpi_comm"] = MPI_Comm_c2f(ParallelDescriptor::Communicator());
+        ascent.open(opts);
+        ascent.publish(bp_mesh);
+
+        // If you want to save blueprint HDF5 files w/o using an Ascent
+        // extract, you can call the following AMReX helper:
+        // const auto step = istep[0];
+        // amrex::WriteBlueprintFiles(bp_mesh, "bp_export", step, "hdf5");
+
+        // render
+        conduit::Node actions;
+        ascent.execute(actions);
+        ascent.close();
+    }
+
+    // the way this test is set up, if we make it here we pass
+    amrex::Print() << "pass \n";
+}
+