Your first plugin¶
This tutorial adds a continuous ring-shaped pump, runs a small CPU simulation, checks the saved pump field, and then shows how to repeat the run on a GPU. The working class is the same file shown on the laser contract page.
1. Create the project layout¶
Install Polarism in your project's virtual environment and work from the
directory containing polarism_plugins/:
my_project/
├── .venv/
├── polarism_plugins/
│ └── laser/
│ └── ring_laser.py
├── run.py
└── lasers.yaml # optional, for multiple-laser mode
mkdir -p my_project/polarism_plugins/laser
cd my_project
python -m venv .venv
.venv/bin/pip install polarism
Copy the example file
into polarism_plugins/laser/ring_laser.py. The runtime imports it when a
controller is created from this directory. No __init__.py is required.
2. Read the registration and class¶
from polarism.laser import register_laser
from polarism.laser.abstract_laser import AbstractLaser
@register_laser("example-ring")
class RingLaser(AbstractLaser):
def __init__(self, laser_config, X, Y, precision="double"):
super().__init__(laser_config, X, Y, precision)
self.radius = laser_config.extra.get("ring_radius", 8.0)
self.width = laser_config.sigma_space
self._finalize_spatial_envelope(X, Y)
def _amplitude(self, t):
return self.P0
def _P_space(self, X, Y):
r = self.xp.sqrt((X - self.x0) ** 2 + (Y - self.y0) ** 2)
return self.xp.exp(-0.5 * ((r - self.radius) / self.width) ** 2)
def _P_time(self, t):
return 1.0
@register_laser("example-ring") is the selection name. The constructor calls
AbstractLaser.__init__ for backend, dtype, power, center, and delay fields.
It reads ring_radius from laser_config.extra, then caches the spatial
envelope with _finalize_spatial_envelope(X, Y). The three methods provide
amplitude, spatial shape, and time dependence; the base class combines them.
3. Select it from Python and run¶
Create run.py:
import polarism as ps
cfg = ps.Config()
cfg.grid.nx = cfg.grid.ny = 32
cfg.grid.lx = cfg.grid.ly = 40.0
cfg.laser.mode = "single"
cfg.laser.laser_type = "example-ring"
cfg.laser.extra = {"ring_radius": 8.0}
cfg.laser.P0 = 0.02
cfg.laser.sigma_space = 2.0
cfg.reservoir.reservoir_type = "single"
cfg.solver.method = "rk4-fdm"
cfg.solver.dt = 0.001
cfg.solver.total_time = 0.01
cfg.result.save_results = True
cfg.result.save_hdf5 = True
cfg.result.output_directory = "results"
ps.SimulationController(cfg).run()
The controller searches the current working directory, so run this from
my_project/. The configured ten steps are intentionally small for a first
check. The output writer creates a timestamped directory below results/.
4. Inspect the pump in HDF5 output¶
The controller exposes a Pump result node when
cfg.laser.expose_results is true (its default). HDF5 stores that field at
fields/Pump. Check the latest run:
from pathlib import Path
import h5py
files = sorted(Path("results").glob("*/results.h5"))
assert files, "run.py did not write results.h5"
with h5py.File(files[-1], "r") as saved:
pump = saved["fields/Pump"][:]
print(pump.shape) # (saved frames, 32, 32)
assert pump.shape[1:] == (32, 32)
assert pump.max() > 0
Save this as inspect.py and run .venv/bin/python inspect.py. A fresh
results/ directory makes the last-file selection unambiguous.
5. Select it from YAML and the CLI¶
The CLI cannot supply arbitrary extra keys as individual flags. For a
custom radius in CLI use, create lasers.yaml:
Unknown laser entry keys, including ring_radius, become that laser's
LaserParameters.extra. Run from my_project/:
.venv/bin/polarism --laser.mode multiple --laser.config-file lasers.yaml \
--grid.nx 32 --grid.ny 32 --grid.lx 40 --grid.ly 40 \
--reservoir.reservoir-type single --solver.method rk4-fdm \
--solver.dt 0.001 --solver.total-time 0.01 \
--result.save-results --result.save-hdf5 --result.output-directory results
For a single pump with the example's default radius, the direct CLI selection
is --laser.laser-type example-ring; set the remaining physics and output
flags as above. --laser.mode multiple instead reads the YAML entries.
6. Add a CPU test¶
The repository's integration test
copies example modules into tmp_path, loads them, runs a short controller
simulation, and restores the registries and sys.modules afterward. For your
project, begin with this focused check in test_ring.py:
import numpy as np
import polarism as ps
def test_ring_runs(monkeypatch, tmp_path):
# Copy your plugin tree into tmp_path before changing directories.
import shutil
shutil.copytree("polarism_plugins", tmp_path / "polarism_plugins")
monkeypatch.chdir(tmp_path)
cfg = ps.Config()
cfg.grid.nx = cfg.grid.ny = 8
cfg.grid.lx = cfg.grid.ly = 16.0
cfg.laser.laser_type = "example-ring"
cfg.laser.extra = {"ring_radius": 4.0}
cfg.reservoir.reservoir_type = "single"
cfg.solver.dt = 0.001
cfg.solver.total_time = 0.002
run = ps.SimulationController(cfg)
run.run()
assert np.isfinite(run.state.psi).all()
assert run.pump_field(0).shape == (8, 8)
Isolate registration across tests
Python caches polarism_plugins.* modules and Polarism registries are
process-global. The short test above is suitable for a separate pytest
process. For a larger suite, restore registry snapshots and remove the
imported modules after each test, as the repository's integration fixture
does.
7. Repeat on a GPU¶
Install the CUDA extra that matches your environment, for example
.venv/bin/pip install "polarism[cuda12]" for CUDA 12.x. In run.py, set:
Run .venv/bin/python run.py again on a machine with an available CUDA GPU.
The ring laser uses self.xp, so its spatial field stays on the selected
backend. Compare a small CPU and GPU result at a tolerance appropriate to
your model before scaling up. See Running on GPUs
for solver and precision details.