Skip to content

Python Digital Twin

Build a motion simulation in Python. A matching published wheel requires no C++ toolchain; an sdist or source install requires a C++17 compiler and CMake >= 3.21.

The primary 30-minute path is: install (5 minutes), run the single-axis digital twin and SI configuration (10 minutes), then exercise the trajectory stream and inspect diagnostics (15 minutes). Pose control and cam generation are optional extensions.

Prefer a runnable notebook? Download Five-minute plcopen digital twin. Repository CI executes its code cells without interaction against the build-tree module; the post-merge Cold User workflow downloads the public notebook and repeats the smoke against the published wheel.

0–5 minutes: install

Install the published v0.20.0 package from PyPI:

python -m pip install pyplcopen==0.20.0

To build the current source instead, clone the repository and install it with a C++17 compiler and CMake >= 3.21:

git clone https://github.com/lusipad/plcopen.git
cd plcopen
python -m pip install .

Note

Published wheels cover Windows, Linux, and macOS for Python 3.10-3.13. If no wheel matches your interpreter or platform, pip builds from the sdist and therefore needs the source-build toolchain above. For a compiler-free install, use CPython 3.10-3.13.

5–10 minutes: single-axis motion

import pyplcopen

axis = pyplcopen.AxisSim()
axis.power_on()

# Point-to-point: position, velocity, acceleration, deceleration
axis.move_absolute(100.0, 10.0, 5.0, 5.0)
print(f"Position: {axis.command_position()}")  # 100.0

# Relative move
axis.move_relative(-30.0, 10.0, 5.0, 5.0)
print(f"Position: {axis.command_position()}")  # 70.0

# Velocity mode + halt
axis.move_velocity(5.0, acceleration=2.0)
axis.halt(deceleration=3.0)
print(f"Status: {axis.status()}")  # AxisStatus.STANDSTILL

Each move_* call submits a command and runs cycles until the axis settles. The jerk parameter (default 1.0) controls the smoothness of the acceleration ramp.

10–15 minutes: SI configuration

The core uses per-cycle units internally. Use CycleConfig to convert from human-readable SI values:

Current source API

AxisSiConfig / GroupSiConfig were added after v0.20.0. For this subsection, use the current-source install command above until the next maintainer-authorized release. The CycleConfig conversions themselves are available in v0.20.0.

import pyplcopen

cfg = pyplcopen.CycleConfig.at_1khz()  # 1 ms cycle
velocity_per_cycle = cfg.velocity_to_cycle(200.0)

limits = pyplcopen.AxisSiConfig()
limits.max_velocity = 200.0
limits.max_acceleration = 1000.0
limits.max_deceleration = 1000.0
limits.max_jerk = 50000.0

axis = pyplcopen.AxisSim()
axis.configure_si(cfg, limits)  # load-domain conversion; call before power_on()
axis.power_on()
axis.move_absolute(
    100.0,                              # position (same units)
    velocity_per_cycle,                 # 200 mm/s
    cfg.acceleration_to_cycle(1000.0),  # 1000 mm/s^2
    cfg.acceleration_to_cycle(1000.0),  # deceleration
    cfg.jerk_to_cycle(50000.0),         # 50000 mm/s^3
)

# move_absolute() returns at standstill, so command_velocity() is now zero.
# Verify the configured limit with an SI round trip instead.
print(f"Position: {axis.command_position()} mm")
print(f"Configured velocity: {cfg.velocity_to_si(velocity_per_cycle)} mm/s")
assert abs(axis.si_config(cfg).max_velocity - 200.0) < 1e-9

Presets: at_1khz(), at_2khz(), at_4khz(), or from_period_ns() for any cycle rate. GroupSiConfig applies a complete set of member-axis limits atomically to an idle group; an invalid member leaves every existing limit unchanged.

15–25 minutes: trajectory stream

This capability is provided by the core/stream support library (B9): a jerk-limited online filter that sits beside the layer ladder, depends only on otg/rt, and is consumed by the L5 axis layer — see the architecture diagram.

For robot joint control: push targets at a low rate (e.g. 100 Hz), the library upsamples to the cycle rate through a jerk-limited online filter.

import math
import pyplcopen

axis = pyplcopen.AxisSim()
axis.power_on()

# Engage stream with motion limits
axis.stream_engage(
    velocity_limit=0.5,
    acceleration_limit=0.05,
    jerk_limit=0.01,
    timeout_cycles=30,       # dropout watchdog
    extrapolation_cycles=40, # coast before controlled stop
)

# Push 200 targets at ~100 Hz (cycle(10) = 10 ms at 1 kHz)
for k in range(200):
    target = 0.3 * math.sin(0.02 * k)
    axis.stream_push(target, axis.stream_now() + 1)
    axis.cycle(10)

# Stop pushing — watchdog triggers controlled stop
axis.cycle(400)
print(f"Mode: {axis.stream_mode()}")  # "stopped"
axis.stream_disengage()

25–30 minutes: inspect a diagnostic

The current source exposes the same stable error metadata used by the C++ load-domain API:

import pyplcopen

diagnostic = pyplcopen.diagnose(pyplcopen.ErrorCode.INVALID_ARGUMENT)
print(diagnostic.summary)
print(f"next: {diagnostic.hint}")

Current source API

The structured ErrorCode / diagnose() Python binding was added after v0.20.0; install the current source for this subsection until the next maintainer-authorized release. C++ diagnostics and the standalone trace viewer are covered in the diagnostics guide.

Optional: 6-DOF pose control

Drive a simulated 6R arm by TCP pose — Cartesian interpolation with inverse kinematics per cycle.

import pyplcopen

arm = pyplcopen.PoseArmSim(
    base_height=0.3, upper_arm=0.4,
    forearm=0.35, tool=0.08,
)

# Move to a joint configuration first
arm.move_joints(
    [0.3, 0.6, 1.0, -0.4, 0.9, 0.2],
    velocity=0.05, acceleration=0.004,
    deceleration=0.004, jerk=0.004,
)

# Now command a TCP pose (x, y, z, roll, pitch, yaw)
arm.move_pose(
    0.35, 0.15, 0.55,   # position
    0.3, -0.5, 1.2,     # RPY orientation
    velocity=0.01, acceleration=0.002,
    deceleration=0.002, jerk=0.002,
    cartesian=True,      # Cartesian-space interpolation
)

pose, gimbal = arm.read_pose()
print(f"TCP: {pose}")
print(f"Gimbal lock: {gimbal}")

Cam Law Generation

Generate standard cam motion profiles for electronic cams:

import pyplcopen

# Available laws: "cycloidal", "modified_sine", "poly345"
table = pyplcopen.generate_cam_law("cycloidal", master_span=360.0, rise=50.0, points=64)
for master, slave in table[:5]:
    print(f"  master={master:.1f}  slave={slave:.4f}")

Next Steps