Solver plugins¶
When to write one¶
Write a solver when you need an integrator with different numerical stages,
boundary treatment, or performance behavior. The example here is a compact
explicit midpoint (second-order RK2) finite-difference method, implemented
without inheriting a built-in solver's step.
Contract¶
create_solver calls SolverClass(config, grid). Subclass AbstractSolver;
call super().__init__(config) to get self.config, self.physics, and
self.xp.
| Name and signature | Caller and timing | Return or state | Status |
|---|---|---|---|
__init__(config, grid) |
Factory, after grid and reservoir setup | Validate method constraints; retain grid data | Required |
step(potential, pump, reservoir, boundary_condition, state) |
Default advance, once per step |
Update state.psi and reservoir; returns None |
Abstract |
advance(potential, pump, reservoir, boundary_condition, state, start_step, n_steps) |
Controller, once per output chunk | Advance exactly n_steps; maintain stage times |
Inherited; override only for chunking |
pump(t) |
Called by step at its stage times |
Backend pump array, usually (ny, nx) |
Input callable |
state.t |
Read by step |
Start time; inherited advance updates it afterward |
Input state |
state.psi / state.psi_k |
Read or written by step |
Real-space field / optional spectral cache | State contract |
The solver decides when to sample pump and when to call
reservoir.get_derivatives, reservoir.step, or set_state. A real-space
method should pass the final field through
boundary_condition.after_step_action(psi). Assigning state.psi clears its
psi_k cache. A spectral method that keeps Fourier state must maintain
state.psi_k and real-space materialization consistently.
Plugin compatibility is your responsibility
solver_compatibility.py stores capabilities for built-in method names.
An unknown plugin name bypasses those grid, reservoir, potential, and
kinetic-relaxation checks. Validate unsupported settings in your own
constructor and test the configurations you advertise.
Step-by-step¶
- Create
polarism_plugins/solver/midpoint_fdm.pyand register a unique solver name. - In
__init__, call the base initializer and reject unsupported grids or physics terms. The example supports periodic grids and requires zerokinetic_relaxation_eta. - Write the condensate and reservoir right-hand sides using
self.xpand the reservoir interface. The example computes a five-point periodic Laplacian withxp.roll; this private helper is specific to the example. - In
step, samplepump(state.t)andpump(state.t + dt/2), form a coupled midpoint state, then use its derivative for the full update. - Apply the boundary after-step action, assign
state.psi, and callreservoir.set_state. Leave the time update to inheritedadvance. - Compare with
rk4-fdmon a small periodic case and reducedtwhen checking accuracy for your own model.
Complete example¶
"""A small periodic finite-difference midpoint solver for plugin authors."""
from polarism.solver import AbstractSolver, register_solver
@register_solver("example-midpoint-fdm")
class MidpointFDMSolver(AbstractSolver):
def __init__(self, config, grid):
super().__init__(config)
if grid.boundary != "periodic":
raise ValueError("example-midpoint-fdm requires a periodic grid")
if config.physics.kinetic_relaxation_eta != 0:
raise ValueError("example-midpoint-fdm does not implement kinetic relaxation")
self.dx2 = grid.dx**2
self.dy2 = grid.dy**2
def _laplacian(self, psi):
xp = self.xp
return (
(xp.roll(psi, 1, 0) - 2 * psi + xp.roll(psi, -1, 0)) / self.dy2
+ (xp.roll(psi, 1, 1) - 2 * psi + xp.roll(psi, -1, 1)) / self.dx2
)
def _rhs(self, psi, fields, potential, pump_field, reservoir):
p = self.physics
xp = self.xp
rho = xp.abs(psi) ** 2
active = reservoir.get_active_density(fields)
inactive = reservoir.get_inactive_density(fields)
dfields = reservoir.get_derivatives(psi, pump_field, fields, rho)
kinetic = -(p.hbar**2 / (2 * p.m_eff)) * self._laplacian(psi)
energy = potential + p.g_C * rho + p.g_R * active + p.g_I * inactive
gain = (p.R * active - p.gamma_C) / 2
dpsi = (-1j / p.hbar) * (kinetic + energy * psi) + gain * psi
return dpsi, dfields
def step(self, potential, pump, reservoir, boundary_condition, state):
dt = self.config.solver.dt
psi0 = state.psi
fields0 = reservoir.get_state()
# Explicit midpoint: evaluate both coupled equations at t and t + dt/2.
k1_psi, k1_fields = self._rhs(
psi0, fields0, potential, pump(state.t), reservoir
)
psi_mid = psi0 + 0.5 * dt * k1_psi
fields_mid = tuple(
field + 0.5 * dt * rate
for field, rate in zip(fields0, k1_fields)
)
k2_psi, k2_fields = self._rhs(
psi_mid, fields_mid, potential, pump(state.t + 0.5 * dt), reservoir
)
psi_new = boundary_condition.after_step_action(psi0 + dt * k2_psi)
state.psi = psi_new.astype(psi0.dtype, copy=False)
reservoir.set_state(tuple(
field + dt * rate for field, rate in zip(fields0, k2_fields)
))
The _rhs function reproduces the configured kinetic, potential,
interaction, reservoir blueshift, and gain/loss terms for this example's
supported settings. The first derivative predicts a midpoint psi and
reservoir tuple; the second advances both by dt. state.psi is cast back
to its original complex dtype before storage.
GPU and performance notes¶
xp.roll and the other self.xp operations run on NumPy or CuPy. This
teaching solver uses the inherited step loop and makes no CUDA graph; it is
not presented as a faster replacement for the built-in fused solvers. Avoid
host transfers in _rhs, and benchmark on the target hardware before making
performance claims.
Supported by solvers¶
| Relationship | Behavior |
|---|---|
Controller with cfg.solver.method = "example-midpoint-fdm" |
Factory selects this plugin; controller calls its inherited advance |
Built-in rk4-fdm |
Reference for a short-run comparison, not a dependency of the plugin |
| Other built-in solvers | Alternative method selections, not called by this plugin |
The example calls the generic reservoir state and derivative methods, so it can run with the built-in reservoirs and plugins that implement that contract. Its own constructor limits it to periodic grids.
Pitfalls¶
pumpis a callable, not the pump array for the current step.- Do not increment
state.tinstepwhen using inheritedadvance. - A solver registered under a built-in name inherits that name's compatibility checks even if its implementation differs.
- Preserve the configured precision and handle any optional model term you claim to support; reject unsupported terms explicitly.
Testing¶
candidate.solver.method = "example-midpoint-fdm"
midpoint = SimulationController(candidate)
midpoint.run()
reference.solver.method = "rk4-fdm"
rk4 = SimulationController(reference)
rk4.run()
np.testing.assert_allclose(midpoint.state.psi, rk4.state.psi,
rtol=1e-3, atol=1e-6)
The checked integration test also compares reservoir fields after five short steps. The tolerance is for that test case, not a general error guarantee.