Plugin Development
Build custom analysis plugins for VOLT using CoreToolkit.
TL;DR — A plugin is a C++23 service that links CoreToolkit. Register the service class with the VOLT_SERVICE_PLUGIN macro, which turns it into a CLI binary invoked as myplugin <lammps_file> [output_base] [options]. Results are one or more *.parquet files. Ship a plugin.json (a workflow DAG describing the UI, frame iteration, entrypoint, and outputs) and a Python wrapper that VOLT invokes. Build with Conan + CMake; CI bundles and publishes the binary.
Overview
VOLT plugins are C++23 analysis algorithms built on CoreToolkit (coretoolkit::coretoolkit). Each plugin:
- Reads one or more LAMMPS dump frames.
- Runs an analysis algorithm.
- Writes results as Apache Parquet (
*.parquet) files derived from an output base path.
VOLT and the ClusterDaemon don't call the binary directly; they invoke a Python wrapper (scripts/<name>_plugin_wrapper.py) declared in plugin.json, which locates the binary, filters VOLT runtime flags, runs it, and validates the outputs. Examples here use coordination-analysis (per-atom coordination numbers + a radial distribution function, RDF).
Prerequisites
| Requirement | Version |
|---|---|
| C++ compiler | C++23 capable (GCC 14+ or Clang 17+) |
| CMake | ≥ 3.20 |
| Conan | ≥ 2.0 |
If you don't have these, the Ecosystem Setup script can install them.
Project Structure
MyPlugin/
├── CMakeLists.txt
├── conanfile.py
├── plugin.json # workflow DAG (UI form, iteration, entrypoint, outputs)
├── include/
│ └── volt/
│ └── myplugin_service.h
├── src/
│ ├── plugin.cpp # entrypoint: registers the service via the macro
│ └── myplugin_service.cpp # the algorithm
├── scripts/
│ └── myplugin_plugin_wrapper.py
└── .github/
└── workflows/
└── publish-plugin-binary.ymlStep 1: CMakeLists.txt
Use the volt_add_plugin(...) macro from CoreToolkit's VoltPlugin.cmake. It sets C++23, finds the dependencies (Boost, OneTBB, spdlog, nlohmann_json), builds a <name>_lib static library plus an executable from MAIN_SOURCE, and wires up the install targets:
cmake_minimum_required(VERSION 3.20 FATAL_ERROR)
project(myplugin VERSION 1.0.0 LANGUAGES CXX)
find_package(coretoolkit REQUIRED)
volt_add_plugin(myplugin
MAIN_SOURCE src/plugin.cpp
)The macro also accepts SOURCES, DEPENDENCIES, PACKAGES, INCLUDE_DIRS, VENDOR_SOURCES, and NO_EXECUTABLE (for library-only plugins). If MAIN_SOURCE is omitted it falls back to src/main.cpp, then src/*plugin*.cpp. A handful of plugins (e.g. cluster-analysis) configure their targets manually instead of using the macro.
Step 2: conanfile.py
A plugin is packaged as a Conan static-library, requiring CoreToolkit plus the same third-party libraries the macro links, and sets the hwloc/*:shared=True default option. package_info() exposes the <name>_lib library so other plugins can depend on it.
from conan import ConanFile
from conan.tools.cmake import CMake, CMakeToolchain, CMakeDeps, cmake_layout
class MyPluginConan(ConanFile):
name = "myplugin"
version = "1.0.0"
package_type = "static-library"
license = "MIT"
settings = "os", "arch", "compiler", "build_type"
requires = (
"boost/1.88.0",
"onetbb/2021.12.0",
"coretoolkit/1.0.0",
"spdlog/1.14.1",
"nlohmann_json/3.11.3",
)
default_options = {"hwloc/*:shared": True}
exports_sources = "CMakeLists.txt", "include/*", "src/*"
def layout(self):
cmake_layout(self)
def generate(self):
CMakeToolchain(self).generate()
CMakeDeps(self).generate()
def build(self):
cmake = CMake(self)
cmake.configure()
cmake.build()
def package(self):
cmake = CMake(self)
cmake.install()
def package_info(self):
self.cpp_info.set_property("cmake_target_name", "myplugin::myplugin")
self.cpp_info.libs = ["myplugin_lib"]
self.cpp_info.requires = [
"boost::headers",
"onetbb::onetbb",
"coretoolkit::coretoolkit",
"nlohmann_json::nlohmann_json",
"spdlog::spdlog",
]Step 3: Plugin Source Code
A plugin is a service class with parameter setters and a compute(...) method, registered by the VOLT_SERVICE_PLUGIN macro. The macro generates main(), parses CLI arguments, applies the option bindings to the service, parses the input frame, and calls compute.
The service class
Declare setters for each tunable parameter and a compute method that takes a parsed LammpsParser::Frame and an outputBase path:
// include/volt/myplugin_service.h
#pragma once
#include <volt/core/lammps_parser.h>
#include <nlohmann/json.hpp>
#include <string>
namespace Volt {
using json = nlohmann::json;
class MyPluginService {
public:
void setCutoff(double v);
json compute(const LammpsParser::Frame& frame, const std::string& outputBase);
private:
double _cutoff = 3.2;
};
} // namespace VoltThe algorithm
FrameAdapter is a static factory that converts a parsed frame into ParticleProperty objects and validates the simulation cell. CutoffNeighborFinder is default-constructed, then prepare(cutoff, positions, cell), and iterated with its nested Query class. AnalysisResult provides static helpers (failure, success, addTiming) that return JSON — there is no addProperty/write:
// src/myplugin_service.cpp
#include <volt/myplugin_service.h>
#include <volt/core/frame_adapter.h>
#include <volt/core/analysis_result.h>
#include <volt/analysis/cutoff_neighbor_finder.h>
#include <vector>
namespace Volt {
void MyPluginService::setCutoff(double v) { _cutoff = v; }
json MyPluginService::compute(const LammpsParser::Frame& frame, const std::string& outputBase) {
if (frame.natoms <= 0)
return AnalysisResult::failure("Invalid number of atoms");
if (!FrameAdapter::validateSimulationCell(frame.simulationCell))
return AnalysisResult::failure("Invalid simulation cell");
// Zero-copy position property bound to the frame's memory.
auto positions = FrameAdapter::createPositionPropertyShared(frame);
if (!positions)
return AnalysisResult::failure("Failed to create position property");
// Build the neighbor list.
CutoffNeighborFinder finder;
finder.prepare(_cutoff, positions.get(), frame.simulationCell);
// Count neighbors per atom.
std::vector<int> coordination(static_cast<std::size_t>(frame.natoms), 0);
for (std::size_t i = 0; i < static_cast<std::size_t>(frame.natoms); ++i) {
int count = 0;
for (CutoffNeighborFinder::Query q(finder, i); !q.atEnd(); q.next())
++count;
coordination[i] = count;
}
// Build a JSON result. Use AnalysisResult / JsonUtils / the output
// serializer helpers to write the *.parquet files under outputBase.
json result;
result["main_listing"] = {{"cutoff", _cutoff}, {"total_atoms", frame.natoms}};
return result;
}
} // namespace VoltRegistering the plugin
src/plugin.cpp binds each CLI flag to a setter with opt(...) and registers the service. The macro produces the CLI binary:
// src/plugin.cpp
#include <volt/plugin/plugin_entry.h>
#include <volt/myplugin_service.h>
using namespace Volt;
using namespace Volt::Plugin;
using S = MyPluginService;
static const std::vector<OptionBinding<S>> bindings = {
opt("--cutoff", "Cutoff radius for neighbor search", 3.2, &S::setCutoff),
};
VOLT_SERVICE_PLUGIN("volt-myplugin", "My Plugin", S, bindings)Option names are snake_case flags (e.g. --cutoff, --rdf_bins) and map 1:1 to the argument keys in plugin.json. STDOUT is reserved for the binary IPC protocol — write logs to STDERR (the CLI helpers in CoreToolkit are configured this way).
Step 4: Build Locally
Create the CoreToolkit package in your local Conan cache from a checkout:
# Clone CoreToolkit if you haven't already
git clone https://github.com/VoltLabs-Research/CoreToolkit.git
conan create CoreToolkit --build=missing -o "hwloc/*:shared=True"Then build the plugin using the Conan-generated CMake presets:
cd MyPlugin
conan install . -of build --build=missing -o "hwloc/*:shared=True"
cmake --preset conan-release
cmake --build build/build/Release -j$(nproc)The resulting binary is named after the project (e.g. build/build/Release/myplugin). Run it directly:
./build/build/Release/myplugin <lammps_file> [output_base] [--cutoff 3.2]Step 5: plugin.json (the workflow DAG)
Every plugin ships a plugin.json describing a { workflow: { nodes, edges } } graph. It defines the UI form, how the trajectory is iterated, how the binary is invoked, and which *.parquet files are surfaced to the viewer. Edges wire each node's output handle to the next node's input handle.
Node types
| Type | Purpose |
|---|---|
modifier | Plugin metadata: name, key, icon, author, license, version, homepage, description. |
arguments | UI form inputs. Each argument has an argument key (snake_case), a type (number/boolean/select/pluginReference), and optional default/min/max/step/options. |
context | A data source, e.g. { "source": "trajectory_dumps" }. |
forEach | Iterates frames via a mustache iterableSource, e.g. {{ trajectory-context.trajectory_dumps }}. |
entrypoint | Executes the algorithm (see below). |
exposure | A result surfaced in the UI; results names a produced *.parquet file. |
export | A post-process export, e.g. { "exporter": "AtomisticExporter", "type": "glb" } or ChartExporter. |
The entrypoint node
The entrypoint runs the Python wrapper (type: "python-script") and builds the wrapper's argument list from a mustache template. The template feeds the binary its positional <lammps_file> <output_base> arguments plus the user's flags:
{
"id": "analysis-entrypoint",
"type": "entrypoint",
"data": {
"entrypoint": {
"type": "python-script",
"binary": "myplugin-plugin.zip",
"binaryFileName": "myplugin-plugin.zip",
"entrypointScript": "scripts/myplugin_plugin_wrapper.py",
"arguments": "{{ foreach-trajectory-dumps.currentValue.path }} {{ foreach-trajectory-dumps.outputPath }} {{ plugin-arguments.as_str }}",
"timeout": -1
}
}
}Template variables:
currentValue.path— the current frame's dump path (positional<lammps_file>).outputPath— the<output_base>.plugin-arguments.as_str— expands the configured arguments into flags, e.g.--cutoff 3.2 --rdf_bins 500.timeout: -1— no timeout.
Exposures and exports
Each exposure node references one of the *.parquet files the binary writes. For coordination-analysis, three exposures surface:
coordination.parquet— the named summary.atoms.parquet— the per-atom data (AtomisticExporter shape, exported to GLB).rdf_chart.parquet— the RDF chart (exported viaChartExporter).
Per-atom data is written to a separate {outputBase}_atoms.parquet file (the AtomisticExporter shape), not embedded inside the named summary result.
Step 6: The Python wrapper
VOLT and the ClusterDaemon invoke scripts/<name>_plugin_wrapper.py, not the binary directly. The wrapper:
- Resolves/locates the compiled binary (bundle
bin/, local build dirs,$PATH, or an env override). - Filters VOLT runtime flags that the binary doesn't understand (e.g.
--selectedTimesteps). - Runs the binary with the positional
<input_dump> <output_base>arguments plus the parameter flags. - Validates that the required
*.parquetoutputs were produced.
STDOUT is reserved for the binary IPC protocol; the wrapper writes its logs to STDERR. A minimal wrapper exposes a process(frame, config) entry point (called by the daemon, with config["args"] holding the argument list) and a CLI fallback (python myplugin_plugin_wrapper.py <input_dump> <output_base> [flags]).
Step 7: CI with GitHub Actions
CoreToolkit provides a reusable workflow, build-plugin-binary.yml, that builds the plugin on Linux, macOS, and Windows, bundles each install tree into a .tar.zst archive, and publishes those bundles as a GitHub Release (tag v<version>). On tag pushes it also publishes the plugin to the Registry via vpm.
Create .github/workflows/publish-plugin-binary.yml:
name: Publish Plugin Binary
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
publish:
uses: VoltLabs-Research/CoreToolkit/.github/workflows/build-plugin-binary.yml@main
secrets: inheritIts only workflow_call inputs are coretoolkit_ref (the CoreToolkit ref to build against) and dependency_repos (newline-separated plugin repos to export to Conan first, for plugins that depend on other plugins); there is no plugin_name input.
After a build, the release assets are named:
<key>-<version>-<os>-<arch>.tar.zst
# e.g. myplugin-1.0.0-linux-x86_64.tar.zst
# myplugin-1.0.0-darwin-arm64.tar.zst
# myplugin-1.0.0-windows-x86_64.tar.zstEach bundle contains plugin.json, bin/<binary>, and (when present) lib/, scripts/, and share/. A companion .sha256 checksum is published alongside each archive.
CoreToolkit API Quick Reference
Parsing
| Class | Header | Purpose |
|---|---|---|
LammpsParser | volt/core/lammps_parser.h | Parse LAMMPS dump files into LammpsParser::Frame (.natoms, .simulationCell, …) |
FrameAdapter | volt/core/frame_adapter.h | Static factory: builds ParticleProperty objects from a frame; validates the simulation cell |
SimulationCell | volt/core/simulation_cell.h | Box vectors, periodicity, coordinate wrapping |
Neighbor Search
| Class | Header | Purpose |
|---|---|---|
CutoffNeighborFinder | volt/analysis/cutoff_neighbor_finder.h | Find neighbors within a distance cutoff (prepare(...) + nested Query) |
NearestNeighborFinder | volt/analysis/nearest_neighbor_finder.h | Find k-nearest neighbors |
Math
| Class | Header |
|---|---|
Vector3 | volt/math/vector3.h |
Matrix3 | volt/math/matrix3.h |
Quaternion | volt/math/quaternion.h |
SymmetricTensor | volt/math/symmetric_tensor.h |
AffineTransformation | volt/math/affine_transformation.h |
Output
| Class | Header | Purpose |
|---|---|---|
AnalysisResult | volt/core/analysis_result.h | Static JSON helpers: failure(), success(), addTiming() |
ParticleProperty | volt/core/particle_property.h | Per-atom typed property storage |
CLI & Plugin Registration
| Symbol | Header | Purpose |
|---|---|---|
Volt::CLI::parseArgs / parseFrame / deriveOutputBase / getDouble / getInt / getString / getBool | volt/cli/common.h | Parse positional args + flags, parse the frame, read option values |
VOLT_SERVICE_PLUGIN(id, desc, ServiceType, bindings) | volt/plugin/plugin_entry.h | Generate main() for a service class |
opt(name, help, default, &Service::setter) | volt/plugin/option_binding.h | Bind a CLI flag to a service setter |
Plugin I/O Contract
Input
The binary is invoked as:
myplugin <lammps_file> [output_base] [options]<lammps_file>— path to a LAMMPS dump file (positional, required).[output_base]— base path for the output files (positional, optional; derived from the input path when omitted).--<param> <value>— one flag per parameter, named after the service'sopt(...)bindings (e.g.--cutoff 3.2 --rdf_bins 500). There is no single--paramsJSON blob.
Output
The plugin writes one or more Apache Parquet files derived from output_base, each declared by an exposure node in plugin.json — typically a named summary plus a separate {outputBase}_atoms.parquet (per-atom data, AtomisticExporter shape). VOLT reads these files, stores them in MinIO, and serves them to the 3D viewer (and to chart/GLB export where plugin.json declares it).
CoreToolkit
Core C++ library powering VOLT's atomistic simulation analysis. Provides math primitives, spatial data structures, neighbor search, simulation cell handling, and the plugin build system.
VPM
The VOLT Cloud plugin registry CLI — authenticate, publish, install, and manage VOLT plugin packages from the terminal.