External coupling

OpenPFC can run as a library inside a host solver (FEM, another PDE code, or a custom orchestrator). The stable surface is small on purpose.

Runnable sketch: examples/coupling/host-orchestrator/model.cpp. The accompanying Quarto lesson explains the field lifetime, coordinate offset and complete accepted-state check.

What is stable

Piece

Header

Role

pfc::coupling::FieldHandle

kernel/simulation/coupling.hpp

Non-owning host export: name, FieldView<double>, owned Box3i, spacing, origin, memory space ("host")

export_host_field(state, name)

same

Build a handle from SimulationState

pfc::sim::run / SimulationDriver

kernel/simulation/simulation_driver.hpp

Free-function time loop the host can own or call step-by-step

Time::clip_attempt_dt / begin_attempt / commit_attempt

kernel/simulation/time.hpp

dt negotiation: the host proposes a step; OpenPFC clips to t1 and the next saveat

pfc::mpi::communicator::duplicate

kernel/mpi/communicator.hpp

Opt-in MPI_Comm_dup so OpenPFC halo tags do not collide with the host on MPI_COMM_WORLD

CheckpointService

kernel/simulation/checkpoint_service.hpp

Restart owned by OpenPFC; coordinate checkpoints with the host clock via restart_from

FieldHandle is a read export. Write a source or boundary back through SimulationState (typically get_field<double>(name).apply(...)) or a FieldModifier-shaped adapter apply(SimulationState&, double) as in the example header. Do not treat FieldView as mutable.

pfc::coupling::FieldHandle is not pfc::FieldHandle<T> (the typed index inside SimulationState).

What is not stable

  • Device-resident fields (CUDASpace / HIPSpace): this export is host-only.

  • Gen-1 Model / Simulator accessors.

  • Stage buffers, FFT plans, operator caches, stepper rollback scratch.

  • Halo cells: the handle’s owned_box is the owned index box; ghost values need a halo exchange after the host writes owned cells.

  • Changing the MPI rank map or global grid between checkpoint and restore.

dt negotiation

The host owns the outer loop. Before an attempt:

const double t0 = time.get_accepted_time();
time.begin_attempt(std::min(host_proposed_dt, time.get_dt()));
const double dt = time.get_attempted_dt();
// ... host physics / OpenPFC step ...
source.apply(state, t0 + dt);  // This sketch prescribes the new endpoint values.
time.commit_attempt();
time.increment_step_success();

begin_attempt uses clip_attempt_dt, which never lengthens the proposal. The sketch imposes field values rather than adding a time-integrated source. Choose the application time according to the actual coupling algorithm. Reject with time.reject_attempt() if the host step fails.

Restart coordination

Use one OpenPFC bundle, not a second host-side dump of the same field:

{
  "restart_from": "results/ckpt/step_10",
  "checkpoint": { "every": 10, "directory": "results/ckpt" }
}

CheckpointService::restore_from_config loads fields, accepted time, increment, result counter, and method identity. The host must restart at the same accepted time; mismatch of grid or method is a hard error. See checkpoint_publish.md.

Communicator isolation

If the host already uses MPI_COMM_WORLD for its own halo or reductions, duplicate before constructing OpenPFC exchanges:

pfc::mpi::communicator world;
auto isolated = world.duplicate();

Pass MPI_Comm(isolated) into stacks, CheckpointService, and BinaryReader. Destroying the wrapper frees the duplicate.