VOLT
Plugins

Plugin System

Plugin workflow structure

Plugin workflows are built in the UI at https://app.voltcloud.dev/dashboard/plugins/list.

A plugin starts with Modifier, Arguments, Context, ForEach, and Entrypoint. One Exposure node is added for each result file VOLT should ingest. To turn an exposure into a 3D artifact, an Export node is connected to it.

NodeResponsibility
ModifierDefines how the plugin appears in the UI
ArgumentsDefines the parameters the user configures before execution
Context + ForEachDefine which trajectory data is iterated
EntrypointDefines the runtime and the uploaded payload to execute
ExposureDefines which output file VOLT reads after execution
ExportDefines how VOLT interprets geometry or chart data from an exposure

What a plugin looks like inside

A complete example plugin is available at atomistic-exporter-clusters.zip. The ZIP is packaged as an importable plugin with its plugin.json and the binary ZIP expected by the Entrypoint node.

The script reads atom positions from the current dump and groups them into Cluster 0, Cluster 1, and Cluster 2. Those keys are the groups VOLT uses to color atoms in the viewer. The same exposure exports a per-atom property named cluster_id. The grouping rule uses atom_id % 3 to demonstrate the output format and is meant to be replaced with real clustering logic.

The image below shows the result in the canvas, with atoms grouped and colored by the keys exported through AtomisticExporter.

Plugin workflow: Atomistic exporter example workflow

The script runs in four phases: it reads the current dump and finds the ITEM: ATOMS section, validates that the required columns exist, assigns each atom to an example cluster, and builds a single MessagePack payload containing listings, per-atom-properties, and the grouped AtomisticExporter data.

import msgpack
import sys

# From "arguments" in Entrypoint node configuration.
input_dump_path = sys.argv[1]
output_base = sys.argv[2]

# Read the current trajectory dump and find the ATOMS section.
with open(input_dump_path) as f:
    lines = f.read().splitlines()

atoms_header_idx = next(
    idx for idx, line in enumerate(lines)
    if line.startswith('ITEM: ATOMS')
)

# The AtomisticExporter needs atom id + position.
atom_columns = lines[atoms_header_idx].split()[2:]

id_idx = atom_columns.index('id')
x_idx = atom_columns.index('x')
y_idx = atom_columns.index('y')
z_idx = atom_columns.index('z')

# This is only an example grouping strategy.
# Replace atom_id % cluster_count with your real cluster assignment logic.
cluster_count = 3
cluster_labels = [f'Cluster {index}' for index in range(cluster_count)]
grouped_atoms = {label: [] for label in cluster_labels}
per_atom_properties = []

# Build both outputs at the same time:
# 1. grouped atoms for AtomisticExporter
# 2. per-atom properties so cluster_id is also available in VOLT tables/filters
for raw_line in lines[atoms_header_idx + 1:]:
    values = raw_line.split()
    atom_id = int(values[id_idx])
    position = [
        float(values[x_idx]),
        float(values[y_idx]),
        float(values[z_idx])
    ]

    # cluster_id is the numeric property, cluster_label is the export group name.
    cluster_id = atom_id % cluster_count
    cluster_label = cluster_labels[cluster_id]
    grouped_atoms[cluster_label].append({
        'id': atom_id,
        'pos': position
    })
    per_atom_properties.append({
        'id': atom_id,
        'cluster_id': cluster_id
    })

export_groups = {
    label: atoms
    for label, atoms in grouped_atoms.items()
    if atoms
}

# sub-listing
cluster_rows = [
    {
        'cluster': label,
        'atoms': len(atoms)
    }
    for label, atoms in export_groups.items()
]

# One exposure file can include listings, per-atom properties, and 3D export data.
payload = {
    'main_listing': {
        'cluster_count': len(export_groups),
        'exported_atoms': sum(len(atoms) for atoms in export_groups.values())
    },
    'sub_listings': {
        'clusters': cluster_rows
    },
    'per-atom-properties': per_atom_properties,
    'export': {
        'AtomisticExporter': export_groups
    }
}

# The Exposure node expects: {output_base}_example.msgpack
with open(f'{output_base}_example.msgpack', 'wb') as f:
    f.write(msgpack.packb(payload, use_bin_type=True))

print(f'Wrote {output_base}_example.msgpack with {len(export_groups)} atom groups')

Atomistic exporter canvas example

Atomistic exporter workflow explanation

Atomistic exporter workflow explanation 2

All listings exported by plugin analyses are also visible in the dashboard. To find them, go through the sidebar using Analysis > Plugin Name > Exposure Name.

The results exported after running the plugin are also visible through the Dashboard:

Atomistic exporter workflow data dashboard

The same visual effect is reproducible without AtomisticExporter groups: color coding on the exported cluster_id property produces the same cluster-based coloring. AtomisticExporter groups and color coding + cluster_id are equivalent expressions of the same grouping logic.

Atomistic exporter color coding comparison

A scene can contain multiple models simultaneously, allowing comparison of several exported artifacts or addition of plugin-contributed models on top of the base trajectory.

Multiple models in scene

Plugin right-click menu

The right-click menu manages an existing plugin.

Plugin right click menu

ActionEffect
EditOpen the plugin builder and modify the workflow
CloneCreate a copy of the plugin
ExportExport the plugin for reuse or import elsewhere
PublishMake the plugin available in the canvas
Set as DraftReturn the plugin to draft state and hide it from the canvas
DeleteRemove the plugin

A new plugin must be published to appear in the canvas. Plugins in draft state remain hidden.

Node-by-node configuration

Modifier

The Modifier node defines how the plugin appears in VOLT.

Typical configuration:

FieldExample valueWhat it does
NameHello World PluginPlugin name shown in the UI
Version1.0.0Visible plugin version
DescriptionPrints execution information to the logShort plugin summary
AuthorVolt LabsPlugin author
IconTbPlugConnectedIcon used in the plugin card/editor

The image below shows a typical Modifier node configuration in the builder.

Modifier node configuration

Arguments

The Arguments node defines the parameters that the program receives at runtime.

If your Entrypoint arguments template contains {{ plugin-arguments.as_str }}, VOLT serializes the configured arguments and passes them to your program.

For Python plugins, those values arrive in sys.argv together with the input path and the output base path.

Example configuration:

FieldExample valueWhat it does
ArgumentcutoffCLI parameter name
Typenumber, boolean, string, select, listInput type shown in the UI
LabelCutoffUser-facing label
Default Value3.25Default runtime value
Min / Max / Step0 / 10 / 0.05Numeric constraints
OptionsFCC, HCP, BCCOptions for select-like inputs

The image below shows an Arguments node with several argument types configured in the builder.

Arguments node configuration

Context

The Context node defines where the workflow gets its runtime data from.

For trajectory-based plugins, the configuration is:

FieldExample valueWhat it does
Sourcetrajectory_dumpsUses the trajectory frames generated by VOLT

Most analysis workflows use trajectory_dumps.

The image below shows the Context node configured with trajectory_dumps.

Context node configuration

ForEach

The ForEach node defines how the workflow iterates over the selected context.

Typical configuration:

FieldExample valueWhat it does
Iterable Source{{ trajectory-context.trajectory_dumps }}Iterates through the trajectory dumps one by one

This is what makes most plugins run frame by frame.

The image below shows the ForEach node configured with {{ trajectory-context.trajectory_dumps }}.

ForEach node configuration

The next image shows the autocomplete that appears while typing {{ tra... }}. The trajectory_dumps value comes from the Context node and is referenced directly from ForEach.

ForEach trajectory dumps autocomplete

Entrypoint

The Entrypoint node defines how the uploaded payload is executed.

Main fields:

FieldExample valueWhat it does
Typepython-scriptChooses the runtime mode
Binary / Packagelisting-example.zipUploaded file to execute
Entry Scriptmain.pyScript or executable inside the uploaded package
Requirements FilemsgpackPython dependencies to install
Arguments{{ foreach-trajectory-dumps.currentValue.path }} {{ foreach-trajectory-dumps.outputPath }} {{ plugin-arguments.as_str }}Runtime argument template
Timeout-1Optional execution timeout

The Entrypoint node selects whether the payload is a Python ZIP, a single executable, or a packaged executable with extra runtime files.

The first image shows an Entrypoint node configured for a binary-style payload.

Binary entrypoint node configuration

The next image shows the Entrypoint node configured as python-script.

Python script entrypoint node configuration

When the type is python-script, VOLT enables an extra field to specify the PyPI dependencies required by the script. Those dependencies are written in Requirements File and are installed before execution.

Exposure

The Exposure node tells VOLT which output file should be ingested after execution.

Typical configuration:

FieldExample valueWhat it does
Exposure NameStructure IdentificationResult name shown in VOLT
Results File Suffixatoms.msgpackFile suffix your program must write
IconTbEyeOptional result icon

The first image shows one Entrypoint connected to several Exposure nodes — a single execution can produce multiple result files, each with its own exposure.

Entrypoint with many exposures

The next image shows several Exposure node configurations together with the Export node configuration connected to each one. Use this pattern when different result files from the same entrypoint need different exporters.

Exposure and export configuration examples

Export

The Export node tells VOLT how to convert the export payload from an exposure into an artifact.

Typical configuration:

FieldExample valueWhat it does
ExporterAtomisticExporterChooses the exporter implementation
TypeglbDefines the artifact type
Options{ "smoothIterations": 8 }Exporter-specific options

An Export node requires a connected Exposure node: the exporter reads the export key from the exposure result file.

Choosing the right Entrypoint type

The Type field in the Entrypoint node selects the runtime model.

Executable

Choose Executable when you already have a single runnable binary.

  • Upload a binary file directly.
  • You do not set Entry Script.
  • You do not use Requirements File.
  • The daemon runs that binary as-is.

Use this when your plugin payload is already one compiled executable and does not need extra project files around it.

Python Script

Choose Python Script when your plugin is Python code packaged as a ZIP project.

  • Upload a ZIP file that contains your Python project.
  • Set Entry Script to the Python file inside that ZIP, for example main.py or scripts/cna_plugin_wrapper.py.
  • If your project needs Python dependencies, paste them into Requirements File.
  • If it does not need dependencies, leave Requirements File empty.

Packaged Executable

Choose Packaged Executable when your runtime is a ZIP bundle that contains an executable plus supporting files such as bin/, lib/, lookup tables, or other resources.

  • Upload a ZIP file.
  • Set Entry Script to the executable or launcher inside the archive.
  • Do not use Requirements File.

At runtime, team clusters extracts the ZIP and resolves the executable path from Entry Script before running it.

Use this when your plugin needs more than one file to run. A typical case is a packaged scientific binary with shared libraries. This is the pattern used by the native OpenDXA executable bundle.

A practical rule

If your payload is...Entrypoint type
one compiled binary fileExecutable
a Python project in a ZIPPython Script
a ZIP with an executable plus lib/, bin/, or other runtime filesPackaged Executable

What to put in Arguments

The Arguments field is the command template passed to the selected runtime.

VariableResolves to
{{ foreach-trajectory-dumps.currentValue.path }}The current input dump path
{{ foreach-trajectory-dumps.outputPath }}The output base path used by exposures
{{ plugin-arguments.as_str }}All UI-configured plugin arguments serialized as CLI flags
{{ <entrypoint-node-id>.projectPath }}The extracted package directory (for packaged executables)

A Python ZIP typically uses:

{{ foreach-trajectory-dumps.currentValue.path }} {{ foreach-trajectory-dumps.outputPath }} {{ plugin-arguments.as_str }}

A packaged executable typically uses:

--library-path {{ opendxa-entrypoint.projectPath }}/lib {{ opendxa-entrypoint.projectPath }}/bin/opendxa {{ foreach-trajectory-dumps.outputPath }}_annotated.dump {{ foreach-trajectory-dumps.outputPath }}

The upload button in the Entrypoint editor is enabled only after the plugin has been saved at least once. Save the workflow before uploading the binary or ZIP.

Exposure file naming contract

The Exposure node defines a file contract. Given:

FieldValue
Exposure NameStructure Identification
Results File Suffixatoms.msgpack

and an output base of abc, the plugin must write the file:

abc_atoms.msgpack

VOLT will not ingest the result if the filename does not match, even when the payload is valid.

For example, if your entrypoint receives the output base in sys.argv[2], the code can write the exposure file like this:

import msgpack
import sys

output_base = sys.argv[2]

payload = {
    "main_listing": {
        "identified_atoms": 9211,
        "defect_atoms": 314
    }
}

with open(f"{output_base}_atoms.msgpack", "wb") as f:
    f.write(msgpack.packb(payload, use_bin_type=True))

If output_base is abc, this writes abc_atoms.msgpack, which matches the Exposure node contract.

What the Exposure node is for

The two essential fields are:

Exposure fieldWhat it means
Exposure NameThe label shown in VOLT for that result.
Results File SuffixThe exact suffix the plugin writes after the output base (e.g. atoms.msgpack, defect_mesh.msgpack, dislocations.msgpack).

Entrypoint arguments

The arguments delivered to the entrypoint depend on the node configuration. The common pattern is:

  • input dump path,
  • output base path,
  • optional serialized parameters.

For example:

import sys
import msgpack

input_file = sys.argv[1]
output_base = sys.argv[2]

payload = {
    "main_listing": {
        "ok": True
    }
}

with open(f"{output_base}_results.msgpack", "wb") as f:
    f.write(msgpack.packb(payload, use_bin_type=True))

When the exposure suffix is results.msgpack, this file is ingested correctly.

What can go inside one exposure file

An exposure file is a MessagePack object containing one or more of these keys:

{
  "main_listing": {},
  "sub_listings": {},
  "per-atom-properties": [],
  "export": {}
}
KeyUse it for
main_listingSmall summary values
sub_listingsTables with many rows
per-atom-propertiesValues attached to atoms by id
exportData consumed by AtomisticExporter, MeshExporter, DislocationExporter, or ChartExporter

One exposure can contain only one of these keys, or several at once.

Returning listings

If you want VOLT to show summary values and result tables, return main_listing and sub_listings.

payload = {
    "main_listing": {
        "total_points": 1145,
        "dislocations": 319,
        "total_length": 2825.21
    },
    "sub_listings": {
        "dislocation_segments": [
            {
                "segment_id": 0,
                "length": 11.45,
                "magnitude": 0.408,
                "burgers_vector": [-0.16, -0.16, -0.33]
            }
        ]
    }
}

Use this when your result is mostly tabular or summary-oriented.

Returning per-atom-properties

Use per-atom-properties when you want VOLT to attach analysis values back to atoms.

The important rule is that each row must identify the atom with id.

Row format

{
  "per-atom-properties": [
    {
      "id": 1,
      "csp": 0.042,
      "strain": [0.10, 0.02, -0.01],
      "structure_type": 2
    },
    {
      "id": 2,
      "csp": 0.731,
      "strain": [0.18, 0.05, 0.00],
      "structure_type": 0
    }
  ]
}

Columnar format

VOLT also accepts a columnar shape:

{
  "per-atom-properties": {
    "id": [1, 2],
    "csp": [0.042, 0.731],
    "strain": [
      [0.10, 0.02, -0.01],
      [0.18, 0.05, 0.00]
    ],
    "structure_type": [2, 0]
  }
}

Important details:

  • id is what links the property row back to the atom.
  • Scalar values work as-is.
  • Array values also work. VOLT flattens them into fields such as strain[0], strain[1], and strain[2] when needed.
  • If you want users to filter atoms, color by a numeric property, or inspect analysis values in the particles table, this is the key to use.

Using AtomisticExporter

Use AtomisticExporter when your result is a set of atoms or points that should come back into the viewer as a GLB artifact.

In the Export node:

  • choose AtomisticExporter,
  • choose export type glb,
  • connect that export node to the exposure that will contain the atom payload.

Then write the export payload inside the same MessagePack file under export.

Single-object export format

{
  "export": {
    "AtomisticExporter": {
      "FCC": [
        { "id": 1, "pos": [0.0, 0.0, 0.0] },
        { "id": 2, "pos": [0.5, 0.5, 0.0] }
      ],
      "HCP": [
        { "id": 3, "pos": [1.0, 0.0, 0.0] }
      ],
      "Other": [
        { "id": 4, "pos": [1.5, 0.5, 0.0] }
      ]
    }
  }
}

Array export format

{
  "export": [
    {
      "AtomisticExporter": {
        "FCC": [
          { "id": 1, "pos": [0.0, 0.0, 0.0] }
        ]
      }
    },
    {
      "AtomisticExporter": {
        "Defects": [
          { "id": 9, "pos": [2.0, 0.0, 0.0] }
        ]
      }
    }
  ]
}

Important details:

  • VOLT accepts both envelopes: one exporter object, or an array of exporter objects.
  • In the single-object form, the payload is a grouped object where each key is a group name.
  • Those group names drive the colouring in the viewer.
  • Names like FCC, BCC, HCP, Other, or Cluster 7 map directly to existing palettes.
  • The pos field is required for each atom.
  • The array form produces one artifact per array entry.

This is the exporter used by Structure Identification and coherent crystalline region overlays.

Combining multiple result types in one exposure

A single exposure can return:

  • a summary in main_listing,
  • atom-attached values in per-atom-properties,
  • and a 3D overlay in export.
{
  "main_listing": {
    "identified_atoms": 9211,
    "defect_atoms": 314
  },
  "per-atom-properties": [
    { "id": 1, "structure_type": 2 },
    { "id": 2, "structure_type": 0 }
  ],
  "export": {
    "AtomisticExporter": {
      "FCC": [
        { "id": 1, "pos": [0, 0, 0] }
      ],
      "Other": [
        { "id": 2, "pos": [1, 0, 0] }
      ]
    }
  }
}

That pattern is often the most useful one: one exposure file, several ways for the user to inspect the same analysis.

Downloadable example plugins

Most ZIP files below are meant to be used as small entrypoint examples.

Those entrypoint-only examples contain the plugin code, while the workflow itself is still something you configure from the UI with nodes.

Included examples:

hello-world-plugin

  • Download: hello-world-plugin.zip
  • This is the smallest possible example of an Entrypoint node.
  • Its purpose is to show that VOLT executes your script and that anything you print(...) appears in the execution log.
  • Does not write an exposure file. Useful for verifying entrypoint execution before adding Exposure outputs.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint

The Arguments node can stay empty for this example.

The first image shows the full workflow in the builder. The example stops at Entrypoint and does not add an Exposure node.

Hello World workflow

The next image shows the Entrypoint node configuration. This is where the ZIP is uploaded and where VOLT is told how to execute the Python script inside it.

Hello World entrypoint configuration

The Python code inside the ZIP prints information to verify that execution happened.

import sys

print('Hello world VOLT!')

# VOLT first-argument correspond to the input file.
input_file = sys.argv[1]

print(f'Input file: {input_file}')

The last image shows the execution log. This is the expected result of the example: the print(...) output appears in the log after the Entrypoint node runs.

Hello World execution log

arguments-example

  • Download: arguments-example.zip
  • Demonstrates what the Entrypoint node passes into the script through sys.argv.
  • Prints the runtime arguments to the execution log for inspection.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint

The image below shows the full example: user-facing arguments at the top, the workflow in the center, and the Arguments node configuration at the bottom.

Arguments plugin example

The Python code inside arguments-example.zip prints the received arguments directly:

import sys

print('sys.argv:')
for idx, arg in enumerate(sys.argv):
    print(idx, arg)

The next image shows the execution log for this example. This is the expected result: the script lists the received arguments one by one, which makes it easy to verify the final runtime values.

Arguments plugin example log

listing-example

  • Download: listing-example.zip
  • This example shows how an Exposure node can return main_listing and sub_listings.
  • Its purpose is to teach the result shape VOLT reads to render summary values and result tables.
  • If the Exposure node uses example.msgpack as its results suffix, this script writes the correct file: {outputBase}_example.msgpack.
  • Once the run finishes, the output is visible through the exposure result in VOLT rather than only in the execution log.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint -> Exposure

Here the important node is Exposure, because it tells VOLT to ingest the MessagePack file written by the program and render the listing output in the UI.

The first image shows the full workflow. The important part is the last node: Exposure. That is the node that reads the file exported by the code.

Listing Example workflow

The next image shows the Exposure node configuration used by this example. This is where the results file suffix is defined, so it must match the filename written by the Python code.

Listing Example exposure configuration

The Python code below writes the MessagePack file consumed by that Exposure node.

import msgpack
import sys

# Each key in main_listing and sub_listings becomes a column name in VOLT.
payload = {
    'main_listing': {
        'average_segment_length': 8.856480893829213,
        'max_segment_length': 15.068283281607219,
        'min_segment_length': 0.0004022584697172903,
        'total_length': 2825.2174051315187,
        'total_points': 1145,
        'dislocations': 319
    },
    'sub_listings': {
        'circuit_information': [
            {
                'average_edge_count': 5.155642023346304,
                'dangling_circuits': 0,
                'total_circuits': 514
            }
        ],
        'dislocation_segments': [
            {
                'burgers_vector': [-0.16666666666666669, -0.16666666666666666, -0.3333333333333333],
                'length': 11.45334956118728,
                'magnitude': 0.408248290463863,
                'segment_id': 0
            },
            {
                'burgers_vector': [-0.3333333333333333, 0.16666666666666652, 0.16666666666666674],
                'length': 10.363263816655667,
                'magnitude': 0.40824829046386296,
                'segment_id': 1
            }
        ]
    }
}

output_base = sys.argv[2]
with open(f'{output_base}_example.msgpack', 'wb') as f:
    f.write(msgpack.packb(payload, use_bin_type=True))

print(f'Wrote {output_base}_example.msgpack')

Running plugins in the canvas

Once the plugin is created and published, it becomes available in the trajectory canvas.

The image below shows hello-world-plugin selected in the canvas. Two runtime inputs appear by default:

  • Cluster: the cluster where the plugin will run.
  • Selected timesteps: the timesteps that will be executed.

Canvas plugin arguments

When testing a plugin on a trajectory, select a specific timestep first. Leaving the execution on the default full range will fail across all timesteps if the plugin is misconfigured.

When a plugin starts executing, its status is updated in real time:

  • queue
  • running
  • success

An executed plugin exposes a right-click menu in the canvas with the following actions:

  • Select
  • Download
  • Delete

Executed plugin right click menu

When a plugin has execution output, the canvas timeline enables a new tab named Log. That tab shows the execution output for the currently selected timestep.

Canvas plugin log

If the plugin returns an exposure with main_listing, the canvas also enables a new tab named after that exposure. In the listing-example, that tab corresponds to the exposure we already configured earlier in the workflow.

The image below shows the listing output for the selected timestep. Because the example uses hardcoded values, every timestep shows the same main_listing rows.

Canvas plugin example listing

Each main_listing row also has its own right-click menu. The image below shows the available actions:

  • View inspect atoms
  • Delete
  • View <sub_listing_name> for each exported sub-listing

In this example, the code exports two sub-listings:

  • circuit_information
  • dislocation_segments

Canvas plugin main listing row right click menu

The last image shows one of those sub-listings, in this case circuit_information, for the selected timestep.

Canvas plugin sub listing example

per-atom-properties

  • Download: per-atom-properties.zip
  • This example shows how to return per-atom-properties from an Exposure node.
  • Its purpose is to attach derived values back to atoms by id.
  • Those values can then be used in the particles table, filters, and color coding workflows.
  • It does not create a GLB by itself. It teaches the per-atom data contract, not a 3D exporter.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint -> Exposure

This flow looks similar to the listing example, but the exposure payload is different: instead of main_listing / sub_listings, it returns per-atom-properties keyed by atom id.

import math
import msgpack
import sys

with open(sys.argv[1]) as f:
    lines = f.read().splitlines()

cols_idx = next(
    idx for idx, line in enumerate(lines)
    if line.startswith('ITEM: ATOMS')
)

cols = lines[cols_idx].split()[2:]
id_idx = cols.index('id')
x_idx = cols.index('x') if 'x' in cols else None
y_idx = cols.index('y') if 'y' in cols else None
z_idx = cols.index('z') if 'z' in cols else None

rows = []
for raw_line in lines[cols_idx + 1:]:
    values = raw_line.split()
    atom_id = int(values[id_idx])

    position = None
    if x_idx is not None and y_idx is not None and z_idx is not None:
        position = [
            float(values[x_idx]),
            float(values[y_idx]),
            float(values[z_idx])
        ]

    rows.append({
        'id': atom_id,
        'structure_type': atom_id % 4,
        'coordination': 12 if atom_id % 2 == 0 else 11,
        'distance_from_origin': math.sqrt(sum(component * component for component in position)) if position else 0.0,
        'position_copy': position or [0.0, 0.0, 0.0]
    })

payload = {
    'per-atom-properties': rows
}

output_base = sys.argv[2]
with open(f'{output_base}_example.msgpack', 'wb') as f:
    f.write(msgpack.packb(payload, use_bin_type=True))

print(f'Wrote {output_base}_example.msgpack')

On this page