This release is a pre-release and may not be stable for production use.
Tianji / Marvin Robot Arm Adaptor
Based on the official TJ_FX_ROBOT_CONTRL_SDK SDK_PYTHON (Apache-2.0, Copyright 2025 上海孚晞科技有限公司).
Installation
pip install unienv-tianji
Note. End-effector-pose control (the :class:
TianjiArmEefActornode below) depends onmujocoandmink(with theosqpQP solver) for inverse kinematics. These are listed as dependencies and installed automatically. Pure joint-position control via :class:TianjiArmActordoes not require them at runtime.
Hardware setup
The Tianji/Marvin dual-arm robot is controlled over Ethernet through a dedicated controller cabinet.
-
Network. Connect the controller to your host machine (directly or via a switch) and configure your host's IPv4 address to be on the same subnet as the controller, e.g.
192.168.1.x. Verify reachability withping 192.168.1.190(the default controller IP). -
Native SDK binary (NOT included). The Python communication binding in
unienv_tianji/sdk/SDK_PYTHON/fx_robot.pyloads its native shared library viactypes, resolving it relative to the vendored file location (i.e. insideunienv_tianji/sdk/SDK_PYTHON/):libMarvinSDK.so(Linux) /libMarvinSDK.dll(Windows) — required for any robot communication (theMarvin_Robot/DCSSlink).
This vendor binary is NOT shipped with this package (it is a git-ignored build artifacts). You can either download prebuilt ones from the official TJ_FX_ROBOT_CONTRL_SDK repository (
SDK_PYTHON/), or build them from the vendored C++ sources — the control SDK directory ships amakefilefor Linux (andmakefile_dll/ upstreammarvinSDK_ubuntu.sh/marvinSDK_windows.batfor Windows):# libMarvinSDK.so (control/communication library) cd unienv_tianji/sdk/contrlSDK && make # install next to the ctypes bindings cp ../contrlSDK/libMarvinSDK.so ../SDK_PYTHON/
The makefiles only need
g++and standard Linux libs (-lpthread -lrt): they rung++ *.cpp -Wall -w -O2 -fPIC -shared ...(with-DCMPL_LINforlibMarvinSDK.so). The resulting files must end up at:<site-packages>/unienv_tianji/sdk/SDK_PYTHON/libMarvinSDK.soWithout
libMarvinSDK.sopresent, instantiate the actor withconnect=False(see below) — all hardware reads return zeros and command sends become no-ops, which is useful for testing the integration without hardware.TianjiArmEefActorresolves IK against a self-contained kinematics-only MJCF shipped underunienv_tianji/assets/.
Usage
One connection per process. The vendored SDK binds a fixed UDP port and exchanges data through a single shared-memory structure, so only one controller connection can exist per process. To drive both arms (
"A"and"B") from the same process, create a single :class:TianjiConnectionand pass it to both actors viaconnection=.connect=False(offline/test mode) needs no connection.
from unienv_interface.backends.numpy import NumpyComputeBackend
from unienv_interface.world import RealWorld
from unienv_tianji import TianjiArmActor, TianjiConnection
import numpy as np
world = RealWorld(
NumpyComputeBackend,
world_timestep=0.04, # Usually set this to exactly the control timestep
batch_size=None, # None means single instance
)
# One shared connection for the whole process (dual-arm safe).
connection = TianjiConnection("192.168.1.190")
actor_a = TianjiArmActor(
world,
ip="192.168.1.190",
arm="A", # "A" (left, index 0) or "B" (right, index 1)
vel_ratio=10,
acc_ratio=10,
control_timestep=0.04,
update_timestep=0.04,
connection=connection,
)
# A second actor sharing the same connection:
actor_b = TianjiArmActor(
world,
arm="B",
vel_ratio=10,
acc_ratio=10,
connection=connection,
)
rng = np.random.default_rng(42)
actor_a.reset()
actor_b.reset()
try:
while True:
obs_a = actor_a.get_observation()
obs_b = actor_b.get_observation()
rng, action_a = actor_a.action_space.sample(rng)
rng, action_b = actor_b.action_space.sample(rng)
actor_a.set_next_action(action_a)
actor_b.set_next_action(action_b)
actor_a.pre_environment_step(0.04)
actor_b.pre_environment_step(0.04)
actor_a.post_environment_step(0.04)
actor_b.post_environment_step(0.04)
finally:
# Disable each arm, then release the shared connection last.
actor_a.close()
actor_b.close()
connection.close()
Testing without hardware
# Offline mode: no hardware I/O. Reads return zeros, sends are no-ops.
# No TianjiConnection is required.
actor = TianjiArmActor(
world,
arm="A",
connect=False,
)
Observations & hardware errors
TianjiArmActor exposes its full proprioception — every channel that rides in
the SDK's regular 1kHz feedback frame (RT_OUT, returned by subscribe()) —
as observation keys. Error/state codes do not become observations; they raise
instead (see below).
| key | shape | unit | source (RT_OUT field) |
|---|---|---|---|
joint_positions |
(7,) | rad | m_FB_Joint_Pos (deg → rad) |
joint_velocities |
(7,) | rad/s | m_FB_Joint_Vel (deg/s → rad/s) |
joint_torques |
(7,) | N·m | m_FB_Joint_SToq (sensor torque) |
joint_currents |
(7,) | per-mille (0/00) | m_FB_Joint_CToq (motor current, ‰ of rated) |
joint_temperatures |
(7,) | °C | m_FB_Joint_Them |
joint_friction_estimates |
(7,) | N·m | m_EST_Joint_Firc |
joint_external_force_estimates |
(7,) | N·m | m_EST_Joint_Force |
cartesian_force_estimate |
(6,) | N / N·m | m_EST_Cart_FN (6D end-effector force estimate) |
All extended channels are read from the same feedback frame as the basic
trio — there are no extra blocking/vendor calls per step. Set
read_extended_proprioception=False on the constructor to expose only the
basic joint_positions / joint_velocities / joint_torques trio (matching
the pre-extension API).
Motor-side torque. The SDK provides a motor current channel (
m_FB_Joint_CToq, exposed asjoint_currentsin per-mille of the rated current), but it does not expose a direct motor-side torque sensor —joint_torques(m_FB_Joint_SToq) is the joint-side / link-side sensor torque. There is no genuine motor-torque sensor channel available.
The previous arm_state and error_code observation keys have been removed
— they are not policy-relevant. Instead, the actor reads a hardware fault from
the feedback channel at every refresh (post_environment_step /
after_reset / after_reload): if the arm reports a non-zero err_code
or cur_state == 100 (ARM_STATE_ERROR), it raises
:class:TianjiArmHardwareError (a RuntimeError subclass carrying the arm
id, cur_state and err_code). State 0/idle is not an error.
Idle (state 0) is safe to leave the arm in: the brakes engage and the arm locks at its current joint positions — verified on hardware that there is no gravity sag when torque is disengaged.
Recover with :meth:TianjiArmActor.clear_errors, then re-enable the arm:
from unienv_tianji import TianjiArmHardwareError
try:
env.step(action)
except TianjiArmHardwareError as e:
print(f"arm {e.arm} fault: cur_state={e.cur_state} err_code={e.error_code}")
actor.clear_errors()
actor.set_enabled(True) # re-runs the full enable sequence
TianjiArmEefActor passes the child's extended-proprioception channels through
in its observation dict alongside its own six keys
(joint_position/joint_velocity/joint_torque/eef_position/eef_quaternion/last_ik_solved_error);
the passthrough keys keep the child's plural adaptor names (e.g.
joint_currents, joint_temperatures, cartesian_force_estimate).
Space metadata
Every observation and action space carries UniEnv
space metadata (unienv>=0.0.1b16), so
downstream code can read units, names and frames from the spaces instead of
this README:
- observation leaves:
kind="proprio"withunits("rad","rad/s","N*m","degC","permille"forjoint_currents),names(the joint namesjoint1…joint7),source(the nodename) andfps(1 / control_timestep, 25 by default);cartesian_force_estimateiskind="force"with per-elementunits["N", "N", "N", "N*m", "N*m", "N*m"](its frame is not documented by the SDK, so none is given); TianjiArmEefActoraddseef_position(units="m",frame="base"),eef_quaternion(rotation="quat_wxyz",frame="base") andlast_ik_solved_error(units[m, m, m, rad, rad, rad],rotation="axis_angle"); its pass-through channels keep the child's metadata withsourceset to the EEF node;- actions:
kind="action",mode="absolute"; joint actions arecontrol="joint_position",units="rad"(in every armcontrol_mode); EEF actions arecontrol="eef_pose",frame="base"withrotation"euler_rxyz"(intrinsic XYZ,R = Rx @ Ry @ Rz),"quat_wxyz"or"rot6d"and per-elementnames/units(the Euler part is"normalized"withnormalize_rotation_action=True).
End-effector pose control — TianjiArmEefActor
TianjiArmEefActor wraps one :class:TianjiArmActor and exposes an absolute
end-effector-pose action space matching the sim's EndEffectorPoseController
contract: a 3D position + a rotation in one of
{"euler" (intrinsic XYZ, R = Rx @ Ry @ Rz), "quat" (wxyz),
"rot6d"}. IK is solved with [`mink`](https://github.com/kevinmks/mink) against a kinematics-only per-arm MJCF, seeded each step from the child actor's *current* feedback joint positions (solution continuity, like the sim's DLS IK). The resulting joint targets are sent through the child actor (send_joint_command``), so it works in every control mode.
EEF actions and observations are always expressed directly in the robot base frame, which is the MJCF robot-root frame. The sim anchors the robot at the world origin, so its robot base and world frames coincide. Teleoperation retargeting nodes are responsible for transforming poses from controller, camera, task, or other source frames into the robot base frame.
Rotation conversions. Action rotation parsing (euler/quat/rot6d) and the EEF quaternion observation call
xbarray's numpy-backend rotation conversions directly (euler_angles_to_matrix,matrix_to_euler_angles,quaternion_to_matrix,matrix_to_quaternion,rotation_6d_to_matrix,matrix_to_rotation_6d) — the single source of truth for the rotation math shared with the sim stack, so the real/spec and sim cannot drift. Requires XBArray ≥ 0.0.1a19 (the numpy backend'smatrix_to_euler_anglesmust work on raw numpy arrays; the fix for this lands in the a19 release — until then it lives in the XBArray working tree, replacing the broken a18 path that invoked the array-api callable.size()on plain numpy). No local rotation helpers remain.
Quickstart — hold pose
from unienv_interface.backends.numpy import NumpyComputeBackend
from unienv_interface.world import RealWorld, WorldEnv
from unienv_tianji import TianjiConnection, TianjiArmEefActor
from xbarray.transformations.rotation_conversions.numpy import (
quaternion_to_matrix, matrix_to_euler_angles,
)
import numpy as np
world = RealWorld(NumpyComputeBackend, world_timestep=0.04, batch_size=None)
connection = TianjiConnection("192.168.1.190")
actor = TianjiArmEefActor(
world,
arm="A", # "A" = left, "B" = right
connection=connection,
connect=True,
rotation_representation="euler", # or "quat" / "rot6d"
normalize_rotation_action=False,
control_mode="cart_impedance", # forwarded to the child actor
)
env = WorldEnv(world, actor)
_, obs, _ = env.reset()
eef_pos = obs["eef_position"] # (3,) metres, robot base frame
eef_quat = obs["eef_quaternion"] # (4,) wxyz, robot base frame
# Build a "hold current pose" euler action from the obs:
eul = matrix_to_euler_angles(quaternion_to_matrix(eef_quat.astype(np.float64)), "XYZ")
action = np.concatenate([eef_pos, eul]).astype(np.float32)
env.step(action)
Action / observation spaces:
| mode / repr | action shape | obs keys |
|---|---|---|
eef_pose + euler |
(6,) | joint_position(7), joint_velocity(7), joint_torque(7), eef_position(3), eef_quaternion(4 wxyz), last_ik_solved_error(6) |
eef_pose + quat |
(7,) | (same as above) |
eef_pose + rot6d |
(9,) | (same as above) |
joint_position |
(7,) | (same as above; last_ik_solved_error stays zeros) |
Velocity limits
The real arm path has no node-side per-step target clip. Velocity limiting is
governed solely by the SDK's vel_ratio and acc_ratio, which default to 10
(about 18 deg/s or 0.31 rad/s) when TianjiArmEefActor constructs its internal
TianjiArmActor.
Override the SDK limits through the EEF actor constructor, for example:
actor = TianjiArmEefActor(
world,
arm="A",
connection=connection,
vel_ratio=12, # forwarded to the internally constructed child
acc_ratio=12,
)
If supplying arm_actor=, configure its vel_ratio / acc_ratio when creating
that child actor instead.
last_ik_solved_error = [pos_err(3), rot_err(3)] from the last IK solve (zeros
before the first eef action; rot_err is the axis-angle vector of the
orientation error). The EEF pose is the per-arm MJCF palm-frame FK reported
directly in the robot base / MJCF robot-root frame, with no configurable frame
transform. Hardware faults propagate as :class:TianjiArmHardwareError (same
as the child actor).
EEF coordinate-frame convention
Absolute EEF actions and EEF pose observations use the robot base frame
(identical to the MJCF robot-root frame) for both arms. In simulation the robot
is anchored at the world origin, so this frame also coincides with the sim world
frame. Any source-frame calibration or transformation—for example from a
teleoperation controller, tracker, camera, or task frame—belongs in the teleop
retargeting node before actions reach TianjiArmEefActor.
MJCF provenance
The kinematics-only per-arm MJCFs ship under unienv_tianji/assets/
(tianji_marvin_left_arm_kine.xml, tianji_marvin_right_arm_kine.xml). They
are derived from the full dual-arm + Wuji-hand source MJCF in
genesis_adaptor/unienv_genesis_collection/.../tianji_marvin_CCS_real_limit_wuji.xml
by scripts/generate_kine_mjcf.py: each file keeps one 7-DoF arm chain (joints,
inertials, defaults, ranges), drops the other arm, all meshes/geoms/materials,
finger joints, actuators, sensors and contacts, welds the palm body to Link7,
adds a palm site replicating the palm body frame (the IK target frame), and
adds a home keyframe. Re-run python scripts/generate_kine_mjcf.py to
regenerate them if the source MJCF changes.
Control modes
TianjiArmActor supports three control modes via the control_mode kwarg. In
every mode the motion target is a joint position command
(set_joint_cmd_pose); the difference is how compliantly the arm tracks it.
control_mode |
SDK state | Impedance type | What it does |
|---|---|---|---|
"position" |
1 (position-follow) | — | Rigid joint tracking. The arm holds the commanded pose as tightly as it can. |
"joint_impedance" |
3 (torque) | 1 (joint) | Compliant joint tracking: each joint behaves like a spring-damper around the commanded joint target. Good for safe contact / hand-guided tweaks. |
"cart_impedance" |
3 (torque) | 2 (cartesian) | Compliant Cartesian tracking: the end-effector behaves like a Cartesian spring-damper around the commanded TCP pose (derived from the joint targets). Good for force / push-along-surface tasks. |
Impedance = compliant tracking of the same joint targets. You still call
set_next_action/send_joint_commandwith joint positions; the controller tracks them with the configured stiffness/damping instead of rigidly.
Stiffness / damping (KD) defaults and units
joint_kd=(K, D)(used byjoint_impedance):K: per-joint stiffness in N·m/deg, recommended ≤ 2 per joint. DefaultK = [2, 2, 2, 1, 1, 1, 1](stiffer on the base joints, softer on the wrist).D: per-joint damping in N·m/(deg/s), range 0–1. DefaultD = [0.6, 0.6, 0.6, 0.4, 0.2, 0.2, 0.2].
cart_kd=(K, D)(used bycart_impedance):K[0:3]translational stiffness in N/m (≤ 3000);K[3:6]rotational stiffness in N·m/rad (≤ 100);K[6]null-space stiffness (≤ 20). DefaultK = [3000, 3000, 3000, 60, 60, 60, 0].D: damping ratios, 0–1 (per axis + null-space). DefaultD = [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0].
Tool payload
Impedance modes apply the controller's gravity/compensation using a tool
payload model. Pass set_tool_payload=True (default) so the enable sequence
calls set_tool with:
tool_mass(kg), default1.40— tuned on arm A hardware (wuji hand + mount + cabling) via joint-impedance droop measurements. Re-tune if the mounted payload changes.tool_com(mm, flange frame), default(0.0, 0.0, 80.0)— center of mass 80 mm out along the flange Z axis. Units are millimeters (per thefx_robot.set_tooldocstring).tool_inertia(Ixx, Ixy, Ixz, Iyy, Iyz, Izz), default all zero.
Set set_tool_payload=False to skip the set_tool call entirely.
License
This repository is licensed under the Apache License 2.0 (see LICENSE and NOTICE).
The files under unienv_tianji/sdk/ are vendored from the official
TJ_FX_ROBOT_CONTRL_SDK
repository and remain Apache-2.0 licensed, Copyright 2025 上海孚晞科技有限公司 —
see unienv_tianji/sdk/LICENSE and the per-file
provenance headers.
Metadata
Release files for unienv-tianji 0.0.1a1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| unienv_tianji-0.0.1a1.tar.gz | 149.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| unienv_tianji-0.0.1a1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 343.3 kB
Release files / unienv_tianji-0.0.1a1.tar.gz
| Download URL | unienv_tianji-0.0.1a1.tar.gz |
|---|---|
| Size | 149.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
97e8a7133abba456ea70cf786c3de1e06174cf43fd4b4d1fcf7160c84a133de0
|
|
BLAKE2b-256 checksum How to use checksums |
032b0386658f6797c68079ac12770e13b19b381b5c632ae47d525506f58b739c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / unienv_tianji-0.0.1a1-py3-none-any.whl
| Download URL | unienv_tianji-0.0.1a1-py3-none-any.whl |
|---|---|
| Size | 193.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e008e6cfae0fae8a80974e3a57a58f716cfdb45903ca73f8915906e51d4738a0
|
|
BLAKE2b-256 checksum How to use checksums |
b9c7c96df1c4285f88e181bccca86b71d7b5da6d6b35fd76528a81faac0d525f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency log