ADR 0003: Time integrator interface contracts¶
Status¶
Proposed
Context¶
OpenPFC has multiple integration paths: Model::step(double t) for legacy spectral apps, EulerStepper/MultiEulerStepper for new explicit methods, and DuField for ergonomic single-field loops. However, there is no unified design documentation specifying contracts between integrators, models, and spatial discretizations. As new time integration methods (RK2, RK4, IMEX) are added, without explicit contracts each stepper could evolve different assumptions about state ownership, halo timing, or multi-field composition. This ADR establishes explicit boundaries to enable replaceable integrators without rewriting model physics, advancing the long-term goal of “replaceable integrators without rewriting model physics” referenced in docs/development/refactoring_roadmap.md.
Decision¶
The following six contract areas define the interface boundaries between integrators, models, and spatial discretizations in OpenPFC:
1. Integrator surface API contracts¶
The Simulator::step() method isolates integrator stages via prologue/epilogue hooks defined in include/openpfc/kernel/simulation/simulator_integrator.hpp:
inline void Simulator::begin_integrator_step() {
simulator_integrator::begin_integrator_step(*this);
}
inline void Simulator::end_integrator_step() {
simulator_integrator::end_integrator_step(*this);
}
The ordering contract implemented in simulator_integrator::begin_integrator_step():
On first call (
increment == 0): apply initial conditions, apply boundary conditions, optionally write results ifdo_save()Call
pfc::time::next()(increment advances; current time becomest0 + increment * dt, clamped tot1)Apply boundary conditions at the new time
Call
Model::step()or custom physics body with the new current timeCall
end_integrator_step()to write results if at a save point
This contract ensures that initial conditions run only on the first step, boundary conditions apply before every physics update, and result writing happens at the appropriate times. Integrators using begin_integrator_step() and end_integrator_step() must respect this ordering.
2. State access patterns¶
Two distinct RHS evaluation patterns are supported:
Legacy field-only RHS pattern (spectral apps):
// include/openpfc/kernel/simulation/model.hpp
class Model {
virtual void step(double t); // directly operates on get_real_field()
// Model owns the field and updates it in place via FFTs
};
Gradient-access pattern (new explicit methods):
// include/openpfc/kernel/simulation/for_each_interior.hpp
template <class Model, class Eval, class DuOut>
inline void for_each_interior(const Model &model, Eval &eval, DuOut du, double t) {
// Calls model.rhs(t, eval(i,j,k)) and scatters to du
// Model is const; only du is written
}
// Example from apps/wave2d/include/wave2d/wave_model.hpp
struct WaveModel {
[[nodiscard]] WaveIncrements rhs(double /*t*/, double v_val,
const WaveLaplacian &lap) const noexcept {
const double lap_u = inv_dx2 * lap.lxx + inv_dy2 * lap.lyy;
return WaveIncrements{v_val, kC * kC * lap_u};
}
};
The gradient-access pattern treats the physics model as a pure function rhs(t, g) → du that reads spatial gradients but does not modify any state directly. This enables pluggable integrators because the model has no ownership of the time-stepping logic.
3. Workspace ownership¶
Stage state lives in integrator allocations, not in Model fields. Model fields are read-only during integrator stages.
EulerStepper owns scratch buffer:
// include/openpfc/kernel/simulation/steppers/euler.hpp
template <class Rhs> class EulerStepper {
template <class U> double step(double t, std::vector<double> &u) {
m_rhs(t, u, m_du); // fills m_du with increments
const std::ptrdiff_t n = static_cast<std::ptrdiff_t>(u.size());
for (std::ptrdiff_t li = 0; li < n; ++li) {
u[static_cast<std::size_t>(li)] += m_dt * m_du[static_cast<std::size_t>(li)];
}
return t + m_dt;
}
private:
double m_dt{0.0};
std::vector<double> m_du; // owned by integrator
Rhs m_rhs;
};
DuField owns internal buffer:
// include/openpfc/kernel/simulation/du_field.hpp
template <class G, class Eval> class DuField {
template <class PrepareFn>
DuField(std::size_t local_size, Eval eval, PrepareFn &&prepare_parent)
: m_data(local_size, 0.0), m_eval(std::move(eval)),
m_prepare(std::forward<PrepareFn>(prepare_parent)) {}
template <class RhsFn> void apply(RhsFn &&rhs_fn, double t = 0.0) {
m_prepare(); // FD halo exchange or spectral no-op
LambdaModel<std::decay_t<RhsFn>> model{std::forward<RhsFn>(rhs_fn)};
pfc::sim::for_each_interior(model, m_eval, m_data.data(), t);
}
private:
std::vector<double> m_data; // owned by DuField
Eval m_eval;
std::function<void()> m_prepare;
};
Multi-field MultiEulerStepper owns per-field buffers:
// include/openpfc/kernel/simulation/steppers/euler.hpp
template <class Rhs, std::size_t N> class MultiEulerStepper {
private:
double m_dt{0.0};
std::array<std::vector<double>, N> m_du; // one buffer per field
Rhs m_rhs;
};
The integrator owns the du buffer and is responsible for accumulating u += dt * du. The model’s fields are read-only during RHS evaluation; any updates happen via the stepper’s accumulation step.
4. Multi-field composition boundaries¶
The tuple protocol from include/openpfc/kernel/field/tuple_protocol.hpp enables multi-field models to bundle increments into a single record:
// include/openpfc/kernel/field/tuple_protocol.hpp
namespace pfc::field::detail {
namespace sfinae {
template <class T, class = void>
struct has_as_tuple : std::false_type {};
template <class T>
struct has_as_tuple<T, std::void_t<decltype(std::declval<T &>().as_tuple())>>
: std::true_type {};
template <class T> struct is_std_tuple : std::false_type {};
template <class... Ts> struct is_std_tuple<std::tuple<Ts...>> : std::true_type {};
template <class T>
struct is_tuple : is_std_tuple<std::remove_cv_t<std::remove_reference_t<T>>> {};
} // namespace sfinae
// Boolean predicates preserve former concept call syntax (`has_as_tuple<T>`).
template <class T>
inline constexpr bool has_as_tuple = sfinae::has_as_tuple<T>::value;
template <class T>
inline constexpr bool is_tuple = sfinae::is_tuple<T>::value;
/**
* @brief Normalize `t` into a tuple-like view for fan-out.
*
* Returns `t.as_tuple()` if `T` opts in, `t` itself if it is already a
* `std::tuple`, otherwise `std::forward_as_tuple(t)` (one-element view).
* Host-oriented — device multi-field scatter uses `DevicePtrPackN` instead.
*/
template <class T> constexpr decltype(auto) to_tuple(T &t) {
if constexpr (has_as_tuple<T>) {
return t.as_tuple(); // user-defined opt-in
} else if constexpr (is_tuple<T>) {
return (t); // std::tuple accepted as-is
} else {
return std::forward_as_tuple(t); // scalar handled as 1-tuple
}
}
} // namespace pfc::field::detail
Example from wave2d WaveModel:
// apps/wave2d/include/wave2d/wave_model.hpp
/** Increments \f$(du, dv)\f$ for `MultiEulerStepper` tuple protocol. */
struct WaveIncrements {
double du = 0.0;
double dv = 0.0;
auto as_tuple() { return std::tie(du, dv); }
auto as_tuple() const { return std::tie(du, dv); }
};
MultiEulerStepper factory wiring:
// include/openpfc/kernel/simulation/steppers/euler.hpp
template <class... Ts, class Eval, class Model>
[[nodiscard]] auto create(std::tuple<pfc::field::LocalField<Ts> &...> fields,
Eval &eval, const Model &model, double dt) {
constexpr std::size_t N = sizeof...(Ts);
// ... build sizes array ...
auto rhs = [&eval, &model](double t, auto & /*u_tuple*/, auto &du_tuple) {
auto du_ptrs = std::apply(
[](auto &...vs) { return std::make_tuple(vs.data()...); }, du_tuple);
pfc::sim::for_each_interior(model, eval, du_ptrs, t);
};
return MultiEulerStepper<decltype(rhs), N>(dt, sizes, std::move(rhs));
}
The tuple protocol allows for_each_interior to scatter multi-field increments into the correct per-field du buffers without requiring the integrator to know the field names or order.
5. Halo exchange timing expectations¶
Halo exchange timing differs between finite-difference and spectral backends:
Finite-difference: pre-RHS via prepare_parent hook
// include/openpfc/kernel/simulation/du_field.hpp
template <class RhsFn> void DuField::apply(RhsFn &&rhs_fn, double t = 0.0) {
m_prepare(); // FD: MPI halo exchange via PaddedHaloExchanger or SparseHaloExchanger
LambdaModel<std::decay_t<RhsFn>> model{std::forward<RhsFn>(rhs_fn)};
pfc::sim::for_each_interior(model, m_eval, m_data.data(), t);
// m_eval.prepare() is a no-op for FD evaluators
}
For FD, the application-supplied prepare_parent callable performs the exchange before gradients are computed. This is typically a pfc::communication::PaddedHaloExchanger<T> for classical padded-brick layouts or a pfc::SparseHaloExchanger<T> for FFT-safe separated layouts. See docs/concepts/halo_exchange.md for halo policies and exchange patterns.
Spectral: internal via Eval::prepare()
// include/openpfc/kernel/field/spectral_gradient.hpp
template <class G> class SpectralGradient {
void prepare() {
m_fft->forward(*m_u_in, m_u_F); // FFT happens inside eval
if constexpr (has_x<G>) invert_complex_op(m_op_x, m_dx);
if constexpr (has_y<G>) invert_complex_op(m_op_y, m_dy);
if constexpr (has_z<G>) invert_complex_op(m_op_z, m_dz);
if constexpr (has_xx<G>) invert_real_op(m_op_xx, m_dxx);
// ... spectral multiplies and inverse FFTs per requested member
}
};
Spectral evaluators run forward FFTs and spectral multiplies inside eval.prepare(), which is called by for_each_interior at the start of each RHS evaluation. No external halo exchange is needed for pure spectral methods. The prepare_parent hook is typically a no-op for spectral.
Timing contract:
FD:
m_prepare()(halo exchange) →eval.prepare()(no-op) →for_each_interiorloopSpectral:
m_prepare()(no-op) →eval.prepare()(FFT + spectral multiplies) →for_each_interiorloop
6. Migration path from Model::step(double t) to explicit integrator composition¶
Legacy pattern (spectral apps):
// The model owns time integration
model.step(t); // updates internal fields directly via FFTs
New pattern (explicit integrator composition):
// include/openpfc/kernel/simulation/steppers/euler.hpp
template <class Eval, class Model>
[[nodiscard]] auto create(Eval &eval, const Model &model, double dt,
std::size_t local_size) {
auto rhs = [&eval, &model](double t, const std::vector<double> & /*u*/,
std::vector<double> &du) {
pfc::sim::for_each_interior(model, eval, du.data(), t);
};
return EulerStepper<decltype(rhs)>(dt, local_size, std::move(rhs));
}
// Usage
auto grad = pfc::field::create<MyGrads>(u, fft);
auto stepper = pfc::sim::steppers::create(grad, model, dt, u.size());
t = stepper.step(t, u); // integrator owns the step logic
Migration steps:
Extract point-wise physics into a
rhs(double t, const G& g)callableBuild a gradient evaluator (
pfc::field::FdGradient<G>orpfc::field::SpectralGradient<G>)Use
pfc::sim::steppers::createfactory to bind model + evaluator + time stepCall
stepper.step(t, u)instead ofmodel.step(t)
Simulator integration:
// Using stepper instead of Model::step(simulator, model)
auto stepper = pfc::sim::steppers::create(eval, model, dt, u.size());
sim.step_with_physics([&]() {
t = stepper.step(t, u);
});
The model no longer owns time integration, enabling pluggable steppers (RK2, RK4, IMEX) without modifying model physics code.
Consequences¶
New integrator methods (RK2, RK4, IMEX) can be added without modifying model physics code by following the documented contracts
Model authors can choose between legacy
Model::step(double t)(spectral apps) and explicit integrator composition (new explicit methods) based on their needsHalo policy and gradient evaluator choices are now documented, enabling backend-agnostic numeric expectations
Integration path divergence is minimized through explicit interface contracts
Future integrators must respect the
begin_integrator_step()/end_integrator_step()ordering and must not modify Model fields during RHS evaluationSpectrum of abstraction:
DuField(compact single-field) →EulerStepper(stepper-owns logic) →Model::step(legacy model-owns logic) provides multiple entry points with clear contracts
See also¶
include/openpfc/kernel/simulation/simulator.hpp—Simulator::step()orchestrationinclude/openpfc/kernel/simulation/simulator_integrator.hpp—begin_integrator_step()/end_integrator_step()implementationinclude/openpfc/kernel/simulation/steppers/euler.hpp—EulerStepperandMultiEulerStepperimplementationinclude/openpfc/kernel/simulation/for_each_interior.hpp— Canonical point-wise driver loopinclude/openpfc/kernel/field/tuple_protocol.hpp— Multi-field bundling conventioninclude/openpfc/kernel/simulation/du_field.hpp— Stack-friendly residual field withprepare_parenthooksinclude/openpfc/kernel/field/spectral_gradient.hpp— Spectral evaluator with internalprepare()for FFTsinclude/openpfc/kernel/simulation/model.hpp— LegacyModel::step(double t)interfaceapps/wave2d/include/wave2d/wave_model.hpp— Example multi-field model with tuple protocoldocs/concepts/halo_exchange.md— Halo exchange policies, timing, and separated layout recommendationdocs/science/numerics_limits.md— Backend-specific stability constraintsdocs/adr/0002-gradient-operators-fd-vs-spectral.md— Spatial operator directionality (FD vs spectral)docs/development/refactoring_roadmap.md— Phase integration and refactor tracking