VOLT
Open Source Ecosystem

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:

  1. Reads one or more LAMMPS dump frames.
  2. Runs an analysis algorithm.
  3. 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

RequirementVersion
C++ compilerC++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.yml

Step 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 Volt

The 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 Volt

Registering 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

TypePurpose
modifierPlugin metadata: name, key, icon, author, license, version, homepage, description.
argumentsUI form inputs. Each argument has an argument key (snake_case), a type (number/boolean/select/pluginReference), and optional default/min/max/step/options.
contextA data source, e.g. { "source": "trajectory_dumps" }.
forEachIterates frames via a mustache iterableSource, e.g. {{ trajectory-context.trajectory_dumps }}.
entrypointExecutes the algorithm (see below).
exposureA result surfaced in the UI; results names a produced *.parquet file.
exportA 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 via ChartExporter).

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 *.parquet outputs 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: inherit

Its 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.zst

Each 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

ClassHeaderPurpose
LammpsParservolt/core/lammps_parser.hParse LAMMPS dump files into LammpsParser::Frame (.natoms, .simulationCell, …)
FrameAdaptervolt/core/frame_adapter.hStatic factory: builds ParticleProperty objects from a frame; validates the simulation cell
SimulationCellvolt/core/simulation_cell.hBox vectors, periodicity, coordinate wrapping
ClassHeaderPurpose
CutoffNeighborFindervolt/analysis/cutoff_neighbor_finder.hFind neighbors within a distance cutoff (prepare(...) + nested Query)
NearestNeighborFindervolt/analysis/nearest_neighbor_finder.hFind k-nearest neighbors

Math

ClassHeader
Vector3volt/math/vector3.h
Matrix3volt/math/matrix3.h
Quaternionvolt/math/quaternion.h
SymmetricTensorvolt/math/symmetric_tensor.h
AffineTransformationvolt/math/affine_transformation.h

Output

ClassHeaderPurpose
AnalysisResultvolt/core/analysis_result.hStatic JSON helpers: failure(), success(), addTiming()
ParticlePropertyvolt/core/particle_property.hPer-atom typed property storage

CLI & Plugin Registration

SymbolHeaderPurpose
Volt::CLI::parseArgs / parseFrame / deriveOutputBase / getDouble / getInt / getString / getBoolvolt/cli/common.hParse positional args + flags, parse the frame, read option values
VOLT_SERVICE_PLUGIN(id, desc, ServiceType, bindings)volt/plugin/plugin_entry.hGenerate main() for a service class
opt(name, help, default, &Service::setter)volt/plugin/option_binding.hBind 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's opt(...) bindings (e.g. --cutoff 3.2 --rdf_bins 500). There is no single --params JSON 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).

On this page