Time Integration Architecture Contract¶
This document defines the formal contract between time integrators and the OpenPFC simulator framework. It specifies the responsibilities of integrator implementations, state access patterns, boundary preparation ordering, and output scheduling guarantees. All integrator implementations must adhere to this contract to ensure correct behavior across CPU and accelerator backends.
1. Contract scope and responsibilities¶
Integrator role¶
A time integrator is responsible for managing the advancement of simulation time through one or more computational stages. The integrator coordinates physics evaluation with boundary condition application and respects output scheduling. It acts as the orchestration layer between the temporal evolution logic and the spatial discretization implemented by the model.
The formal contract boundary is defined by two hooks in the Simulator class:
Simulator::begin_integrator_step()— prologue called before physics evaluationSimulator::end_integrator_step()— epilogue called after physics evaluation
These hooks are implemented in simulator_integrator::begin_integrator_step() and simulator_integrator::end_integrator_step().
When to use step_with_physics() vs direct hooks¶
For simple single-stage integrators, use Simulator::step_with_physics() which internally calls the begin/end hooks:
simulator.step_with_physics([&]() {
model.step(time.get_current());
});
For multi-stage integrators or custom orchestration, call the hooks explicitly to maintain control over intermediate computations:
simulator.begin_integrator_step();
// Perform multi-stage computations here
simulator.end_integrator_step();
The step_with_physics() method is equivalent to begin_integrator_step(); physics_fn(); end_integrator_step() and should be preferred when the physics body is a single function call.
2. Simulator hook semantics¶
Full ordering contract¶
The simulator_integrator::begin_integrator_step() function implements the following ordering contract:
Initial condition path (when
pfc::time::increment(time) == 0):Apply initial conditions via
Simulator::apply_initial_conditions()Apply boundary conditions via
Simulator::apply_boundary_conditions()Optionally write results if
pfc::time::do_save(time)returns trueAdvance time via
pfc::time::next(time)Apply boundary conditions again at the new time
Steady-state path (when
pfc::time::increment(time) > 0):Advance time via
pfc::time::next(time)Apply boundary conditions at the new time
The simulator_integrator::end_integrator_step() function:
Optionally writes results if
pfc::time::do_save(time)returns true
State access patterns¶
Integrators may read and write model fields through the Model API:
Model::get_real_field(std::string_view name)— access real-valued fieldsModel::get_complex_field(std::string_view name)— access complex-valued fields
These methods return references to field storage that can be modified directly by the integrator. All field access must respect the halo exchange requirements specified in Section 4.
The Time object provides temporal state queries:
Time::get_current()— current physical timeTime::get_increment()— current step counter (0-based)Time::do_save()— whether output should be written at this timeTime::next()— advance to next time step
3. Physics evaluation patterns¶
Single-stage integrators¶
Single-stage integrators (e.g., explicit Euler) call Model::step(double t) exactly once per begin_integrator_step()/end_integrator_step() pair:
simulator.begin_integrator_step();
model.step(time.get_current());
simulator.end_integrator_step();
The existing pfc::sim::steppers::EulerStepper provides a reference implementation for single-stage integration with the signature:
double step(double t, std::vector<double>& u);
Multi-stage integrators¶
Critical contract requirement: begin_integrator_step() and end_integrator_step() hooks bracket the full timestep, not each substage. All intermediate substage computations occur between these two calls with no additional hook invocations.
For example, a 4th-order Runge-Kutta integrator performs all four slope evaluations between begin_integrator_step() and end_integrator_step():
simulator.begin_integrator_step();
// All RK4 substages occur here, with no hook calls
double t = time.get_current();
auto& u = model.get_real_field("density");
auto k1 = compute_rhs(t, u);
auto k2 = compute_rhs(t + 0.5*dt, u + 0.5*dt*k1);
auto k3 = compute_rhs(t + 0.5*dt, u + 0.5*dt*k2);
auto k4 = compute_rhs(t + dt, u + dt*k3);
u += (dt/6.0) * (k1 + 2*k2 + 2*k3 + k4);
simulator.end_integrator_step();
This design ensures that boundary conditions and output scheduling are applied only at the physical time level, not at intermediate substage times that may not correspond to meaningful physical states.
Physics evaluation API¶
The physics evaluation is performed through the Model::step(double t) method:
virtual void step(double t) = 0;
This pure virtual method must be implemented by concrete model classes. It receives the current physical time and is responsible for evolving the model state by one time step. The implementation typically involves:
Computing spatial derivatives (spectral or finite difference)
Applying nonlinear terms
Updating field values using the chosen time integration scheme
4. Boundary and halo preparation¶
Halo exchange architecture¶
The halo exchange architecture is specified in docs/concepts/halo_exchange.md. Integrators must respect the halo exchange requirements when using real-space stencils on distributed domains.
Finite difference integrators¶
Finite difference integrators must exchange halos before physics evaluation when using real-space stencils. The halo exchange synchronizes ghost cells between neighboring ranks to ensure stencil operations have access to valid neighbor data:
// Example: FD integrator with halo exchange
auto& u = model.get_real_field("density");
auto& lap = model.get_real_field("laplacian");
// Exchange halos before physics evaluation
exchanger.exchange_halos(u.data(), u.size());
halo::copy_to_face_layout(exchanger, face_halos);
// Compute physics with valid ghost data
field::fd::laplacian_periodic_separated<2>(
u.data(), face_ptrs, lap.data(),
nx, ny, nz, inv_dx2, inv_dx2, inv_dx2, halo_width
);
Spectral integrators¶
Spectral integrators typically skip halo exchange because the FFT-based operations operate on global k-space representations where neighbor communication is handled implicitly by the FFT decomposition.
Boundary condition application¶
Boundary conditions are applied in two places:
In
begin_integrator_step()— atincrement==0(after ICs) and aftertime.next()Post-physics — required for multi-stage schemes where intermediate states violate boundary constraints
Single-stage integrators generally do not require post-physics BC reapplication because the physics update preserves boundary-enforced values.
Multi-stage integrators may require post-physics BC application depending on the scheme:
Predictor-corrector methods that compute temporary states outside valid bounds must reapply BCs after each substage that produces a physically invalid intermediate state
Runge-Kutta methods typically do not require intermediate BC application unless the problem has strong boundary constraints that must be enforced at all times
Split-step methods may require BC application after each substep if the substep modifies boundary values
Example of post-physics BC application in a predictor-corrector scheme:
simulator.begin_integrator_step();
// Predictor step (may violate BCs)
auto& u = model.get_real_field("density");
auto u_pred = u + dt * compute_rhs(t, u);
// Reapply BCs if predictor violates constraints
simulator.apply_boundary_conditions();
// Corrector step
auto rhs_corrector = compute_rhs(t + dt, u_pred);
u += 0.5 * dt * (compute_rhs(t, u) + rhs_corrector);
simulator.end_integrator_step();
5. Output scheduling guarantees¶
Save point detection¶
Output scheduling is controlled by the Time object through Time::do_save(). This method returns true when the current time corresponds to a scheduled save point based on the saveat interval:
if (time.do_save()) {
simulator.write_results();
}
The Time::saveat() interval uses floating-point modulo with a tolerance of 1e-6 to determine save points. A saveat value of 0.0 disables automatic saving.
Result writing mechanism¶
Result writing is performed through Simulator::write_results(), which internally calls write_scheduled_simulator_results(). This function:
Dispatches all registered
ResultsWriterswith the current field stateIncrements the internal result counter via
simulator.set_result_counter(file_num + 1)
The result counter is used by writers to generate incrementing filenames (e.g., field_0000.bin, field_0001.bin).
Custom integrator responsibilities¶
Custom integrators must respect Time::saveat() semantics or call Simulator::write_results() explicitly at save points. The recommended approach is to rely on the hook semantics:
simulator.begin_integrator_step(); // Handles initial save if needed
// Perform physics
simulator.end_integrator_step(); // Handles final save if needed
For integrators that need custom output timing, explicitly check time.do_save() and call write_results():
if (time.do_save()) {
simulator.write_results();
}
6. Restart and checkpoint semantics¶
Checkpoint writing¶
Scheduled field dumps still go through ResultsWriters / frontend
BinaryWriter as headerless MPI-IO bricks (periodic output and
post-processing). That raw layout is specified in
docs/reference/binary_field_io_spec.md.
For a durable accepted-state restart bundle, use
pfc::checkpoint::publish_checkpoint_directory (kernel headers under
include/openpfc/kernel/checkpoint/). It stages versioned metadata.json
plus accepted field bricks, then atomically renames the staging directory to
the final path so incomplete writes are never loadable. See
docs/development/checkpoint_publish.md.
Restore / migration validation is not claimed here.
Key properties of the headerless scheduled dump format:
Layout: Single global 3D array in Fortran (column-major) order
Element type:
doublefor real fields,std::complex<double>for complex fieldsByte order: Native to the writing machine
No header: Files contain only raw payload data
Per-rank data: Each rank writes its local brick, together covering the global grid
Resume expectations¶
Restart logic expects:
Consistent field state — All fields must contain values matching the checkpoint time
Matching
Timestate — TheTimeobject must be configured with the samet0,t1,dt, andsaveatvalues used when the checkpoint was writtenIncrement alignment — The
Time::get_increment()value should match the increment number encoded in the checkpoint filename
When reading a checkpoint, the BinaryReader uses the same MPI-IO subarray view as the writer, requiring the same communicator, decomposition, and data type configuration.
7. CPU and accelerator numerical contract¶
Numerical equivalence requirement¶
Both CPU and GPU paths must produce identical results within floating-point tolerance. This requirement ensures that:
Simulations can be developed and debugged on CPU workstations
Results are reproducible across different hardware backends
Validation studies are not tied to a specific platform
Architecture layering¶
The kernel/runtime separation described in docs/concepts/architecture.md enforces this contract:
Kernel layer — Backend-agnostic numerical algorithms and data structures
Runtime layer — Backend-specific implementations (CPU, CUDA, HIP)
Numerical behavior is defined at the kernel layer and must be preserved by all runtime implementations.
GPU implementation obligations¶
GPU implementations of steppers (via runtime/cuda or runtime/hip) must:
Obey the same stage ordering as CPU implementations
Use equivalent numerical algorithms — same order of operations, same stencil coefficients
Handle boundary conditions identically — same BC application points and logic
Respect the same halo exchange contract — same ghost cell synchronization patterns
Deviations from these requirements are acceptable only when documented as platform-specific limitations with clear justification.
8. Example integration patterns¶
Single-stage Euler using step_with_physics()¶
#include <openpfc/kernel/simulation/simulator.hpp>
void run_euler_simulation(Simulator& simulator, Model& model) {
auto& time = simulator.get_time();
while (!time.done()) {
simulator.step_with_physics([&]() {
model.step(time.get_current());
});
}
}
This pattern is used in the time stepping demo at docs/api/examples/04_time_stepping.cpp.
Multi-stage integrator with explicit begin/end calls¶
#include <openpfc/kernel/simulation/simulator.hpp>
class RK4Integrator {
public:
void step(Simulator& simulator, Model& model, double dt) {
auto& time = simulator.get_time();
auto& u = model.get_real_field("density");
simulator.begin_integrator_step();
double t = time.get_current();
auto u_backup = u; // Save initial state
// Stage 1
auto k1 = compute_rhs(model, t, u);
// Stage 2
u = u_backup + 0.5 * dt * k1;
auto k2 = compute_rhs(model, t + 0.5*dt, u);
// Stage 3
u = u_backup + 0.5 * dt * k2;
auto k3 = compute_rhs(model, t + 0.5*dt, u);
// Stage 4
u = u_backup + dt * k3;
auto k4 = compute_rhs(model, t + dt, u);
// Final combination
u = u_backup + (dt / 6.0) * (k1 + 2.0*k2 + 2.0*k3 + k4);
simulator.end_integrator_step();
}
private:
std::vector<double> compute_rhs(Model& model, double t,
const std::vector<double>& u);
};
Custom integrator with halo exchange¶
#include <openpfc/kernel/decomposition/sparse_halo_exchange.hpp>
#include <openpfc/kernel/decomposition/halo_face_layout.hpp>
#include <openpfc/kernel/field/finite_difference.hpp>
class FDHeatIntegrator {
public:
FDHeatIntegrator(SparseHaloExchanger<double>& exchanger,
std::array<std::vector<double>, 6>& face_halos,
int nx, int ny, int nz, double dx, double D)
: m_exchanger(exchanger), m_face_halos(face_halos),
m_nx(nx), m_ny(ny), m_nz(nz),
m_inv_dx2(1.0 / (dx * dx)), m_D(D) {}
void step(Simulator& simulator, Model& model, double dt) {
auto& u = model.get_real_field("temperature");
auto& lap = model.get_real_field("laplacian");
simulator.begin_integrator_step();
// Exchange halos before physics evaluation
m_exchanger.exchange_halos(u.data(), u.size());
halo::copy_to_face_layout(m_exchanger, m_face_halos);
// Build face pointer array for Laplacian
std::array<const double*, 6> face_ptrs;
for (int i = 0; i < 6; ++i) {
face_ptrs[i] = m_face_halos[i].data();
}
// Compute Laplacian using finite differences
field::fd::laplacian_periodic_separated<2>(
u.data(), face_ptrs, lap.data(),
m_nx, m_ny, m_nz,
m_inv_dx2, m_inv_dx2, m_inv_dx2, 1
);
// Explicit Euler update
for (size_t i = 0; i < u.size(); ++i) {
u[i] += dt * m_D * lap[i];
}
simulator.end_integrator_step();
}
private:
SparseHaloExchanger<double>& m_exchanger;
std::array<std::vector<double>, 6>& m_face_halos;
int m_nx, m_ny, m_nz;
double m_inv_dx2;
double m_D;
};
This pattern extends the finite difference heat example at examples/15_finite_difference_heat.cpp into a full integrator with simulator hook integration.
See also: docs/concepts/architecture.md for overall system architecture, docs/concepts/halo_exchange.md for halo exchange patterns, docs/development/checkpoint_publish.md for atomic accepted-state publication, and docs/reference/binary_field_io_spec.md for headerless scheduled field dumps.