First commit
This commit is contained in:
commit
33449ce211
28 files changed
+1343
No files matched your search
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
bin/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,7 @@
|
||||
ioc_name: MTEST04-MTN-ARM
|
||||
ioc_host: c6025a-08
|
||||
ioc_port: 50001
|
||||
ioc_user: root
|
||||
epics_version: 7.0.10
|
||||
os: deb12
|
||||
cpu_architecture: x86_64
|
||||
@@ -0,0 +1,8 @@
|
||||
# Simulation only: no EtherCAT master. Run from this project directory.
|
||||
# Build with make logic ECMC_ROOT=<source matching the IOC>.
|
||||
# PSI launcher provides iocInit and ecmccfg finalization.
|
||||
epicsEnvSet("IOC", "${IOC=MTEST04-MTN-ARM}")
|
||||
require ecmccfg v11.2.0_RC1 "ECMC_VER=v11.2.0_RC1,MASTER_ID=-1,EC_RATE=1000"
|
||||
${SCRIPTEXEC} ./cfg/load.cmd
|
||||
# caqtdm -macro "IOC=MTEST04-MTN-ARM" ecmcCppLogicOverview.ui
|
||||
# caqtdm -macro "IOC=MTEST04-MTN-ARM,CPP_ID=0" ecmcCppLogic.ui
|
||||
@@ -0,0 +1,36 @@
|
||||
CXX ?= c++
|
||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||
PYTHON ?= python3
|
||||
ECMC_ROOT ?= ../ecmc
|
||||
ifeq ($(shell uname -s),Darwin)
|
||||
EXT := dylib
|
||||
SHARED := -dynamiclib
|
||||
else
|
||||
EXT := so
|
||||
SHARED := -shared
|
||||
endif
|
||||
.DEFAULT_GOAL := all
|
||||
.PHONY: all viewer-lib test test-logic logic clean
|
||||
all: viewer-lib build/test_kinematics
|
||||
viewer-lib: build/libarm.$(EXT)
|
||||
build/libarm.$(EXT): src/c_api.cpp src/kinematics.hpp
|
||||
mkdir -p $(@D)
|
||||
$(CXX) $(CXXFLAGS) -fPIC $(SHARED) -o $@ src/c_api.cpp
|
||||
build/test_kinematics: tests/test_kinematics.cpp src/kinematics.hpp
|
||||
mkdir -p $(@D)
|
||||
$(CXX) $(CXXFLAGS) -Isrc -o $@ $<
|
||||
test: all
|
||||
./build/test_kinematics
|
||||
$(PYTHON) python/viewer.py --self-test
|
||||
$(PYTHON) tests/test_epics_source.py
|
||||
logic: bin/libmain.$(EXT)
|
||||
bin/libmain.$(EXT): src/logic.cpp src/kinematics.hpp
|
||||
mkdir -p $(@D)
|
||||
$(CXX) $(CXXFLAGS) -fPIC $(SHARED) -I$(ECMC_ROOT)/devEcmcSup/logic -o $@ $<
|
||||
build/test_logic: tests/test_logic.cpp src/logic.cpp src/kinematics.hpp
|
||||
mkdir -p $(@D)
|
||||
$(CXX) $(CXXFLAGS) -I$(ECMC_ROOT)/devEcmcSup/logic -o $@ $<
|
||||
test-logic: build/test_logic
|
||||
./build/test_logic
|
||||
clean:
|
||||
rm -rf build bin
|
||||
@@ -0,0 +1,199 @@
|
||||
# ecmc six-DOF robot arm — simulation prototype
|
||||
|
||||

|
||||
|
||||
A generic six-revolute-joint industrial-style arm with C++ forward and inverse
|
||||
kinematics, twelve ecmc axes, and an offline/read-only EPICS 3D viewer.
|
||||
This is an example geometry, not a calibrated model of a commercial robot.
|
||||
|
||||
**All twelve axes are virtual in this simulation.** `virt` and `phys` are group
|
||||
names: `virt` contains the six Cartesian tool coordinates; `phys` contains the
|
||||
six simulated rotary joints. No physical drives or EtherCAT hardware are used.
|
||||
|
||||
| Axis IDs | Group | Meaning | Trajectory | Encoder feedback |
|
||||
|---|---|---|---|---|
|
||||
| 1–6 | `virt` | X, Y, Z, Rx, Ry, Rz offsets from home tool pose | Internal | FK from joint encoders |
|
||||
| 7–12 | `phys` | J1–J6 angular offsets from home joints | External IK, or internal for direct joint moves | Ideal copy of selected joint trajectory |
|
||||
|
||||
## Run the offline demo
|
||||
|
||||
```sh
|
||||
make test
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r python/requirements.txt
|
||||
.venv/bin/python python/viewer.py
|
||||
# Direct joint controls instead of Cartesian controls:
|
||||
.venv/bin/python python/viewer.py --joints
|
||||
# Headless screenshot:
|
||||
.venv/bin/python python/viewer.py --snapshot build/arm.png
|
||||
```
|
||||
|
||||
The six sliders set Cartesian home offsets, then **Move (3 seconds)** starts a
|
||||
shared quintic interpolation. Blue shows joint feedback and orange the IK
|
||||
command. An unsuccessful IK solve retains the last valid joints and displays an
|
||||
error. Retargeting starts a new profile without velocity continuity. Joint mode
|
||||
interpolates joint offsets instead. The offline plant is ideal; it has no lag,
|
||||
load, drive dynamics, collision checks or physical safety system.
|
||||
|
||||
Both Python and the IOC share `src/kinematics.hpp`. The viewer calls a small
|
||||
C ABI in `src/c_api.cpp`; `make viewer-lib` rebuilds `build/libarm.so` (`.dylib`
|
||||
on macOS). No EPICS or Python GUI dependencies are needed by `make test`.
|
||||
|
||||
## Arm geometry and conventions
|
||||
|
||||
Lengths are in mm; joint and Cartesian Euler angles are in degrees. The base
|
||||
frame is right handed, with +Z upward. Revolute axes act in each successive
|
||||
local frame. With transforms multiplied in the order written:
|
||||
|
||||
```text
|
||||
T = Rz(q1) Tz(H) Ry(q2) Tx(A) Ry(q3) Tx(B)
|
||||
Rx(q4) Ry(q5) Rx(q6) Tx(Tool)
|
||||
```
|
||||
|
||||
The last three axes intersect at the wrist centre. The eight plotted points
|
||||
include coincident wrist joints; the drawing is a link skeleton, not solid CAD.
|
||||
|
||||
`cfg/geometry.json` contains `[H, A, B, Tool]`, default `[300, 300, 250, 80]`.
|
||||
The IOC uses macros `BASE_HEIGHT`, `UPPER_ARM`, `FOREARM`, `TOOL` in the
|
||||
`MACROS` argument in `cfg/load.cmd`. They have the same defaults. Keep JSON and
|
||||
IOC macros identical; neither side automatically reads the other's geometry.
|
||||
|
||||
| Joint | Local rotation | Home angle | Absolute model limits | Axis offset limits |
|
||||
|---|---|---:|---|---|
|
||||
| J1 | Z | 0° | −170…170° | −170…170° |
|
||||
| J2 | Y | −35° | −120…120° | −85…155° |
|
||||
| J3 | Y | 70° | −140…140° | −210…70° |
|
||||
| J4 | X | 0° | −180…180° | −180…180° |
|
||||
| J5 | Y | 35° | −120…120° | −155…85° |
|
||||
| J6 | X | 0° | −180…180° | −180…180° |
|
||||
|
||||
The chosen home avoids a straight elbow/wrist and gives a useful local workspace.
|
||||
All IOC axes start at **zero offsets**, representing these nominal joint angles.
|
||||
This is mathematical initialization, not a homing or calibration procedure.
|
||||
|
||||
Cartesian XYZ are base-frame displacements from the home tool position. For
|
||||
orientation, `R = Rz(Rz) Ry(Ry) Rx(Rx) R_home`. Thus zero Cartesian angles mean
|
||||
the home tool orientation, not alignment of the tool with the base. The Euler
|
||||
representation has a singularity at Ry = ±90°. FK computes absolute tool
|
||||
transforms directly, then converts them into these Cartesian offsets for EPICS.
|
||||
|
||||
To model a particular robot, replace the transform chain, joint limits and home
|
||||
configuration consistently in C++, YAML and viewer. Arbitrary URDF loading,
|
||||
manufacturer-specific wrist offsets and calibration are not implemented.
|
||||
|
||||
## Inverse kinematics
|
||||
|
||||
IK is a bounded, local damped least-squares solve, seeded from the previous
|
||||
accepted joint command (nominal home initially). It minimizes a six-component
|
||||
residual: three translation errors and the rotation logarithm of
|
||||
`R_target R_actual^T`, weighted by 200 mm/radian. Orientation residuals use
|
||||
rotation matrices rather than subtracting Euler angles.
|
||||
|
||||
A central-difference Jacobian uses 1e-5-radian perturbations. Each solve allows
|
||||
at most 100 iterations, clamps trial angles to joint limits, limits steps to
|
||||
0.2 radians, and adapts damping according to whether the error decreases.
|
||||
Convergence requires weighted residual norm below 1e-5. All cyclic arrays have
|
||||
fixed size; the solver performs no file I/O or heap allocation.
|
||||
|
||||
The seed encourages a nearby continuous solution. It does not enumerate all
|
||||
elbow/wrist configurations or guarantee the nearest branch. Unreachable targets,
|
||||
joint limits, singularities and poor seeds can prevent convergence. No explicit
|
||||
Jacobian-condition monitor or global path planner is provided. A reachable
|
||||
endpoint alone does not validate the path to it.
|
||||
|
||||
## ecmc IOC
|
||||
|
||||
Build on the Linux IOC development host with the source headers matching the
|
||||
installed ecmc version:
|
||||
|
||||
```sh
|
||||
make logic ECMC_ROOT=/path/to/ecmc
|
||||
make test-logic ECMC_ROOT=/path/to/ecmc
|
||||
```
|
||||
|
||||
`make logic` produces `bin/libmain.so`; deploy it with `cfg/` and the startup
|
||||
script. Custom PV substitutions are checked in as
|
||||
`cfg/libmain.so_cpp_logic.subs`, matching `loadCppLogic.cmd` defaults.
|
||||
`src/Makefile` also provides the PSI `driver.makefile` build option for Debian
|
||||
IOC architectures; it builds only `logic.cpp`. The top-level Makefile is a
|
||||
portable local build and does not install or launch an IOC.
|
||||
|
||||
`MTEST04-MTN-ARM_startup.script` follows the ecmccfg `cpp_logic` examples and
|
||||
requires `ecmccfg v11.2.0_RC1` / `ecmc v11.2.0_RC1`, no EtherCAT master,
|
||||
and `EC_RATE=1000`. Start with your normal PSI IOC launcher from this project
|
||||
directory; the launcher supplies `iocInit`, and ecmccfg finalizes at that point.
|
||||
The parameters YAML is a deployment example: adjust host, port and version for
|
||||
your installation. Axis IDs 1–12 and the default state-machine ID 0 must be free.
|
||||
|
||||
The startup loads the shared Cartesian YAML six times and the six numbered
|
||||
joint YAML files (each with its own numeric limits), then C++ logic, then
|
||||
`Arm_SM` (`virt` master group, `phys` slave group). Each axis has motor records,
|
||||
source changes allowed while enabled, 1 s auto-enable timeout, 2 s auto-disable
|
||||
delay, and at-target monitoring with a 0.01-unit band for 100 cycles (100 ms
|
||||
at 1 kHz). Default velocity is 1 mm/s for XYZ and 1°/s for rotation/joints.
|
||||
Joint soft limits are configured in home-relative degrees. Cartesian soft limits
|
||||
are disabled; IK checks joint limits on each accepted command.
|
||||
|
||||
C++ binds to asyn `axN.setpos` and `axN.actpos`, not PLC object paths.
|
||||
IK feeds external joint setpoints. The ideal joint plant copies the selected
|
||||
joint trajectory into the external encoder, so direct joint trajectories also
|
||||
produce FK feedback. Axis/C++ ordering introduces cyclic pipeline latency.
|
||||
A disabled simulated joint still follows this mathematical ideal plant.
|
||||
|
||||
**Use Cartesian `virt` axes for normal tool motion.** Independent `phys` joint
|
||||
moves can cross singularities, change assembly branches or cause collisions.
|
||||
Direct joint moves are for controlled simulation/commissioning checks, with the
|
||||
state machine selecting internal joint trajectories. Before returning to
|
||||
Cartesian motion, synchronize Cartesian commands to actual pose and verify the
|
||||
IK seed/branch; automatic bumpless mode transfer is not implemented.
|
||||
|
||||
Fault codes are latched: 0 OK; 1 invalid input or joint bounds; 2 IK failure;
|
||||
3 host output write failure; 4 axis error. Fix the cause and restart/reload.
|
||||
A fault retains outputs; it is not a coherent group stop, and a failed write can
|
||||
leave partial updates. C++ faults are not wired into a safety-rated stop system.
|
||||
The built-in master/slave state machine does not replace that integration.
|
||||
|
||||
Independent Cartesian motor moves are not a coordinated six-axis Cartesian path.
|
||||
No joint-space speed/acceleration limiting of IK output, collision avoidance,
|
||||
singularity avoidance, torque control, real drives or physical safety integration
|
||||
is implemented. This is simulation software, not a hardware motion controller
|
||||
ready for commissioning. Hexapod timing measurements do not apply to this arm.
|
||||
Measure the complete IOC cycle and overruns before assuming 1 kHz operation.
|
||||
|
||||
## Read-only EPICS viewer and caQtDM
|
||||
|
||||
```sh
|
||||
.venv/bin/pip install -r python/requirements-ioc.txt
|
||||
make viewer-lib
|
||||
.venv/bin/python python/viewer.py --prefix MTEST04-MTN-ARM:
|
||||
caqtdm -macro "IOC=MTEST04-MTN-ARM" ecmcCppLogicOverview.ui
|
||||
caqtdm -macro "IOC=MTEST04-MTN-ARM,CPP_ID=0" ecmcCppLogic.ui
|
||||
```
|
||||
|
||||
The standard caQtDM panels come from ecmccfg. There is no custom arm panel yet.
|
||||
Live Python mode disables motion controls and subscribes to:
|
||||
|
||||
| PV suffix | Value |
|
||||
|---|---|
|
||||
| `arm-pose-set` / `arm-pose-actual` | Six home-relative tool coordinates, mm/degrees |
|
||||
| `arm-joint-set` / `arm-joint-actual` | Six home-relative joint offsets, degrees |
|
||||
| `arm-fault` | Latched adapter fault |
|
||||
| `arm-ik-residual` | Weighted IK residual norm |
|
||||
| `arm-ik-iterations` | IK correction attempts |
|
||||
| `arm-simulation` | Always 1 |
|
||||
|
||||
The viewer accepts a prefix with or without the trailing colon. Its geometry
|
||||
must match the IOC. pyepics requires libca and CA connectivity. Callbacks update
|
||||
a locked cache; GUI refresh does not issue blocking PV reads. Disconnected,
|
||||
invalid and pending samples are reported. Separate PV records are not an atomic
|
||||
IOC snapshot, and unchanged values do not constitute a heartbeat check.
|
||||
|
||||
## Validation
|
||||
|
||||
`make test` covers independent straight-arm/base-rotation checks, rotation error
|
||||
at 180°, 500 near-home IK/FK round trips, 1,000 continuous joint-path samples,
|
||||
invalid/unreachable inputs, singular pose holding, Python/C ABI round trips,
|
||||
and mocked CA cache connection/value checks. `make test-logic` checks bindings,
|
||||
IK/plant/FK timing, direct joint feedback, joint limits and latched faults.
|
||||
A headless viewer snapshot checks rendering separately. A live robot-arm IOC and
|
||||
physical robot have not been tested; the full workspace is not validated.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Coordinates: base-frame offsets from nominal home. Joint axes: home-relative degrees.
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_coordinate.yaml,AX_ID=1,AX_NAME=X,AX_UNIT=mm"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_coordinate.yaml,AX_ID=2,AX_NAME=Y,AX_UNIT=mm"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_coordinate.yaml,AX_ID=3,AX_NAME=Z,AX_UNIT=mm"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_coordinate.yaml,AX_ID=4,AX_NAME=Rx,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_coordinate.yaml,AX_ID=5,AX_NAME=Ry,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_coordinate.yaml,AX_ID=6,AX_NAME=Rz,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_joint1.yaml,AX_ID=7,AX_NAME=J1,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_joint2.yaml,AX_ID=8,AX_NAME=J2,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_joint3.yaml,AX_ID=9,AX_NAME=J3,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_joint4.yaml,AX_ID=10,AX_NAME=J4,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_joint5.yaml,AX_ID=11,AX_NAME=J5,AX_UNIT=deg"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadYamlAxis.cmd, "FILE=./cfg/axis_joint6.yaml,AX_ID=12,AX_NAME=J6,AX_UNIT=deg"
|
||||
@@ -0,0 +1,62 @@
|
||||
# Macros: AX_ID, AX_NAME, AX_UNIT (default mm).
|
||||
# Coordinate axis: external encoder from forward kinematics.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: virt
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=mm}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by C++ arm FK; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 0
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: false
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
# Axis units: 0.01 mm for X/Y/Z, 0.01 deg for Rx/Ry/Rz.
|
||||
tolerance: 0.01
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1,66 @@
|
||||
# Simulated rotary joints, home-relative degrees.
|
||||
# WARNING: independent joint moves can leave the Cartesian workspace or cross
|
||||
# singularities; use virt axes for normal tool moves. No collision checking.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: phys
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=deg}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by the C++ ideal plant; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 1
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: true
|
||||
forwardEnable: true
|
||||
backwardEnable: true
|
||||
backward: -170
|
||||
forward: 170
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
tolerance: 0.01 # deg
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1,66 @@
|
||||
# Simulated rotary joints, home-relative degrees.
|
||||
# WARNING: independent joint moves can leave the Cartesian workspace or cross
|
||||
# singularities; use virt axes for normal tool moves. No collision checking.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: phys
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=deg}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by the C++ ideal plant; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 1
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: true
|
||||
forwardEnable: true
|
||||
backwardEnable: true
|
||||
backward: -85
|
||||
forward: 155
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
tolerance: 0.01 # deg
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1,66 @@
|
||||
# Simulated rotary joints, home-relative degrees.
|
||||
# WARNING: independent joint moves can leave the Cartesian workspace or cross
|
||||
# singularities; use virt axes for normal tool moves. No collision checking.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: phys
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=deg}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by the C++ ideal plant; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 1
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: true
|
||||
forwardEnable: true
|
||||
backwardEnable: true
|
||||
backward: -210
|
||||
forward: 70
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
tolerance: 0.01 # deg
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1,66 @@
|
||||
# Simulated rotary joints, home-relative degrees.
|
||||
# WARNING: independent joint moves can leave the Cartesian workspace or cross
|
||||
# singularities; use virt axes for normal tool moves. No collision checking.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: phys
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=deg}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by the C++ ideal plant; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 1
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: true
|
||||
forwardEnable: true
|
||||
backwardEnable: true
|
||||
backward: -180
|
||||
forward: 180
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
tolerance: 0.01 # deg
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1,66 @@
|
||||
# Simulated rotary joints, home-relative degrees.
|
||||
# WARNING: independent joint moves can leave the Cartesian workspace or cross
|
||||
# singularities; use virt axes for normal tool moves. No collision checking.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: phys
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=deg}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by the C++ ideal plant; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 1
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: true
|
||||
forwardEnable: true
|
||||
backwardEnable: true
|
||||
backward: -155
|
||||
forward: 85
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
tolerance: 0.01 # deg
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1,66 @@
|
||||
# Simulated rotary joints, home-relative degrees.
|
||||
# WARNING: independent joint moves can leave the Cartesian workspace or cross
|
||||
# singularities; use virt axes for normal tool moves. No collision checking.
|
||||
axis:
|
||||
id: ${AX_ID}
|
||||
group: phys
|
||||
type: virtual
|
||||
features:
|
||||
allowSrcChangeWhenEnabled: true
|
||||
autoEnable:
|
||||
atStartup: false
|
||||
enableTimeout: 1.0
|
||||
disableTimeout: 2.0
|
||||
|
||||
epics:
|
||||
name: ${AX_NAME}
|
||||
precision: 4
|
||||
unit: ${AX_UNIT=deg}
|
||||
motorRecord:
|
||||
enable: true
|
||||
description: "Arm ${AX_NAME}"
|
||||
|
||||
encoder:
|
||||
type: 0
|
||||
source: 1
|
||||
numerator: 1
|
||||
denominator: 1
|
||||
bits: 32
|
||||
# External feedback is supplied by the C++ ideal plant; no EtherCAT position link.
|
||||
|
||||
trajectory:
|
||||
source: 1
|
||||
axis:
|
||||
velocity: 1
|
||||
acceleration: 10
|
||||
deceleration: 10
|
||||
|
||||
input:
|
||||
limit:
|
||||
forward: ec0.s-1.ONE.0
|
||||
backward: ec0.s-1.ONE.0
|
||||
home: ec0.s-1.ZERO.0
|
||||
|
||||
softlimits:
|
||||
enable: true
|
||||
forwardEnable: true
|
||||
backwardEnable: true
|
||||
backward: -180
|
||||
forward: 180
|
||||
|
||||
monitoring:
|
||||
target:
|
||||
enable: true
|
||||
tolerance: 0.01 # deg
|
||||
# Consecutive cycles within tolerance: 100 ms at 1 kHz.
|
||||
time: 100
|
||||
lag:
|
||||
enable: false
|
||||
tolerance: 0.1
|
||||
time: 100
|
||||
velocity:
|
||||
enable: false
|
||||
max: 0
|
||||
time:
|
||||
trajectory: 100
|
||||
drive: 100
|
||||
@@ -0,0 +1 @@
|
||||
[300, 300, 250, 80]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Auto-generated C++ logic substitutions
|
||||
# Logic: arm
|
||||
|
||||
file ecmcCppLogicWaveformIn.template {
|
||||
pattern { REC, PARAM, DESC, NELM, FTVL, DTYP }
|
||||
{ "arm-pose-set", "arm.pose_set", "arm.pose_set", "6", "DOUBLE", "asynFloat64ArrayIn" }
|
||||
{ "arm-pose-actual", "arm.pose_actual", "arm.pose_actual", "6", "DOUBLE", "asynFloat64ArrayIn" }
|
||||
{ "arm-joint-set", "arm.joint_set", "arm.joint_set", "6", "DOUBLE", "asynFloat64ArrayIn" }
|
||||
{ "arm-joint-actual", "arm.joint_actual", "arm.joint_actual", "6", "DOUBLE", "asynFloat64ArrayIn" }
|
||||
}
|
||||
|
||||
file ecmcCppLogicLongIn.template {
|
||||
pattern { REC, PARAM, DESC }
|
||||
{ "arm-fault", "arm.fault", "arm.fault" }
|
||||
{ "arm-ik-iterations", "arm.ik_iterations", "arm.ik_iterations" }
|
||||
{ "arm-simulation", "arm.simulation", "arm.simulation" }
|
||||
}
|
||||
|
||||
file ecmcCppLogicAi.template {
|
||||
pattern { REC, PARAM, DESC, PREC }
|
||||
{ "arm-ik-residual", "arm.ik_residual", "arm.ik_residual", "3" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Include after ecmccfg initialization, before runtime startup.
|
||||
# Axis IDs 1..12 and state-machine ID 0 must be available.
|
||||
${SCRIPTEXEC} ./cfg/axes.cmd
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}loadCppLogic.cmd, "DIR=./bin/,FILE=libmain.so,ASYN_PORT=CPP.ARM,REPORT=1,MACROS='COORD_FIRST=1,JOINT_FIRST=7,SIMULATION=1'"
|
||||
${SCRIPTEXEC} ${ecmccfg_DIR}addMasterSlaveSM.cmd, "NAME=Arm_SM,MST_GRP_NAME=virt,SLV_GRP_NAME=phys"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
@@ -0,0 +1,79 @@
|
||||
"""Read-only CA monitor cache; GUI reads never issue blocking network requests."""
|
||||
import math
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class IocSource:
|
||||
SUFFIXES = {
|
||||
'pose_set': 'arm-pose-set',
|
||||
'pose_actual': 'arm-pose-actual',
|
||||
'joint_set': 'arm-joint-set',
|
||||
'joint_actual': 'arm-joint-actual',
|
||||
'fault': 'arm-fault',
|
||||
'residual': 'arm-ik-residual',
|
||||
}
|
||||
ARRAYS = {'pose_set', 'pose_actual', 'joint_set', 'joint_actual'}
|
||||
|
||||
def __init__(self, prefix, pv_factory=None):
|
||||
prefix = prefix.rstrip(':') + ':'
|
||||
if pv_factory is None:
|
||||
from epics import PV
|
||||
pv_factory = PV
|
||||
self._lock = Lock()
|
||||
self._values = {}
|
||||
self._connected = {}
|
||||
self._errors = {}
|
||||
self.pvs = {}
|
||||
for key, suffix in self.SUFFIXES.items():
|
||||
self.pvs[key] = pv_factory(
|
||||
prefix + suffix, auto_monitor=True,
|
||||
callback=lambda value=None, _key=key, **kw: self._update(_key, value),
|
||||
connection_callback=lambda conn=False, _key=key, **kw: self._connection(_key, conn))
|
||||
|
||||
def _connection(self, key, connected):
|
||||
with self._lock:
|
||||
self._connected[key] = connected
|
||||
if not connected:
|
||||
self._values.pop(key, None)
|
||||
self._errors.pop(key, None)
|
||||
|
||||
def _update(self, key, value):
|
||||
try:
|
||||
if key in self.ARRAYS:
|
||||
value = tuple(float(v) for v in value)
|
||||
if len(value) != 6 or not all(math.isfinite(v) for v in value):
|
||||
raise ValueError('expected six finite values')
|
||||
else:
|
||||
value = float(value)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError('expected finite scalar')
|
||||
if key == 'fault':
|
||||
if not value.is_integer():
|
||||
raise ValueError('expected integer fault code')
|
||||
value = int(value)
|
||||
except (TypeError, ValueError, OverflowError) as exc:
|
||||
with self._lock:
|
||||
self._values.pop(key, None)
|
||||
self._errors[key] = str(exc)
|
||||
return
|
||||
with self._lock:
|
||||
self._values[key] = value
|
||||
self._errors.pop(key, None)
|
||||
|
||||
def snapshot(self):
|
||||
# Consistent local cache copy; separate CA records are not a single IOC cycle.
|
||||
with self._lock:
|
||||
missing = [self.SUFFIXES[k] for k in self.SUFFIXES if not self._connected.get(k)]
|
||||
if missing:
|
||||
raise RuntimeError('EPICS disconnected:\n' + '\n'.join(missing))
|
||||
if self._errors:
|
||||
key = next(iter(self._errors))
|
||||
raise RuntimeError(f'{self.SUFFIXES[key]}:\n{self._errors[key]}')
|
||||
pending = [self.SUFFIXES[k] for k in self.SUFFIXES if k not in self._values]
|
||||
if pending:
|
||||
raise RuntimeError('Waiting for PV data:\n' + '\n'.join(pending))
|
||||
return dict(self._values)
|
||||
|
||||
def close(self):
|
||||
for pv in self.pvs.values():
|
||||
pv.disconnect()
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pyepics>=3.5
|
||||
@@ -0,0 +1 @@
|
||||
matplotlib>=3.7
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generic six-joint arm: offline Cartesian/joint controls or read-only IOC view."""
|
||||
import argparse
|
||||
import ctypes as ct
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HOME = [0., -35., 70., 0., 35., 0.]
|
||||
LOWER = [-170., -120., -140., -180., -120., -180.]
|
||||
UPPER = [170., 120., 140., 180., 120., 180.]
|
||||
|
||||
class Model:
|
||||
def __init__(self, geometry):
|
||||
if len(geometry) != 4 or not all(math.isfinite(x) and x > 0 for x in geometry):
|
||||
raise ValueError('Geometry requires four positive finite lengths in mm')
|
||||
path = next((ROOT/'build'/('libarm.'+ext) for ext in ('so','dylib')
|
||||
if (ROOT/'build'/('libarm.'+ext)).exists()), None)
|
||||
if path is None:
|
||||
raise RuntimeError('Build the viewer library first: make viewer-lib')
|
||||
self.lib = ct.CDLL(str(path))
|
||||
ptr = ct.POINTER(ct.c_double)
|
||||
self.lib.arm_forward.argtypes = [ptr]*4
|
||||
self.lib.arm_forward.restype = ct.c_int
|
||||
self.lib.arm_inverse.argtypes = [ptr]*5
|
||||
self.lib.arm_inverse.restype = ct.c_int
|
||||
self.geometry = (ct.c_double*4)(*geometry)
|
||||
|
||||
@staticmethod
|
||||
def six(values):
|
||||
if len(values) != 6 or not all(math.isfinite(x) for x in values):
|
||||
raise ValueError('Expected six finite numbers')
|
||||
return (ct.c_double*6)(*values)
|
||||
|
||||
def forward(self, joints):
|
||||
pose, points = (ct.c_double*6)(), (ct.c_double*24)()
|
||||
if self.lib.arm_forward(self.geometry, self.six(joints), pose, points):
|
||||
raise ValueError('Invalid geometry or joint limits exceeded')
|
||||
return list(pose), [list(points[i:i+3]) for i in range(0,24,3)]
|
||||
|
||||
def inverse(self, pose, seed):
|
||||
joints, residual = (ct.c_double*6)(), ct.c_double()
|
||||
status = self.lib.arm_inverse(self.geometry,self.six(pose),self.six(seed),joints,ct.byref(residual))
|
||||
if status < 0:
|
||||
raise ValueError('Invalid geometry, pose or seed')
|
||||
return list(joints), residual.value, status == 0
|
||||
|
||||
|
||||
def self_test(model):
|
||||
joints = [a+b for a,b in zip(HOME,[5,-4,3,2,-3,4])]
|
||||
pose, points = model.forward(joints)
|
||||
recovered, residual, ok = model.inverse(pose,HOME)
|
||||
pose2, _ = model.forward(recovered)
|
||||
if not ok or len(points) != 8 or max(abs(a-b) for a,b in zip(pose,pose2)) > 1e-4:
|
||||
raise RuntimeError('Python/C++ round trip failed')
|
||||
if model.inverse([10000,0,0,0,0,0],HOME)[2]:
|
||||
raise RuntimeError('Unreachable pose accepted')
|
||||
print(f'PASS: Python/C++ arm IK/FK; residual={residual:.3g}')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prefix',help='Read-only IOC view, e.g. MTEST04-MTN-ARM:')
|
||||
parser.add_argument('--joints',action='store_true',help='Offline joint-space controls, home-relative degrees')
|
||||
parser.add_argument('--geometry',type=Path,default=ROOT/'cfg'/'geometry.json')
|
||||
parser.add_argument('--self-test',action='store_true')
|
||||
parser.add_argument('--snapshot',type=Path,help='Save one offline view as PNG')
|
||||
args = parser.parse_args()
|
||||
if args.prefix and (args.joints or args.snapshot):
|
||||
parser.error('--prefix cannot be combined with --joints or --snapshot')
|
||||
model = Model(json.loads(args.geometry.read_text()))
|
||||
if args.self_test:
|
||||
self_test(model)
|
||||
return
|
||||
import matplotlib
|
||||
if args.snapshot:
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.animation import FuncAnimation
|
||||
from matplotlib.widgets import Slider, Button
|
||||
source = None
|
||||
if args.prefix:
|
||||
from epics_source import IocSource
|
||||
source = IocSource(args.prefix)
|
||||
fig = plt.figure(figsize=(12,9))
|
||||
ax = fig.add_axes([.04,.32,.68,.61],projection='3d')
|
||||
status = fig.text(.73,.85,'',family='monospace',fontsize=9,va='top')
|
||||
fig.suptitle('ecmc 6-DOF arm — generic simulation (no collision checking)')
|
||||
sliders = []
|
||||
labels = [f'J{i+1} offset [deg]' for i in range(6)] if args.joints else ['X [mm]','Y [mm]','Z [mm]','Rx [deg]','Ry [deg]','Rz [deg]']
|
||||
for i,label in enumerate(labels):
|
||||
limit = 45 if args.joints else (50 if i<3 else 20)
|
||||
slider = Slider(fig.add_axes([.16,.25-i*.035,.46,.02]),label,-limit,limit,valinit=0)
|
||||
if source:
|
||||
slider.set_active(False)
|
||||
sliders.append(slider)
|
||||
state = dict(current=[0.]*6,start=[0.]*6,target=[0.]*6,started=time.monotonic(),
|
||||
command=list(HOME),actual=list(HOME),pose=[0.]*6)
|
||||
def move(_):
|
||||
state.update(start=list(state['current']),target=[s.val for s in sliders],started=time.monotonic())
|
||||
button = Button(fig.add_axes([.75,.12,.2,.05]),'Move (3 seconds)')
|
||||
button.on_clicked(move)
|
||||
if source:
|
||||
button.set_active(False)
|
||||
button.label.set_text('IOC monitor')
|
||||
if args.snapshot:
|
||||
state.update(current=[10,-5,5,3,-3,4],start=[10,-5,5,3,-3,4],target=[10,-5,5,3,-3,4])
|
||||
for slider,value in zip(sliders,state['current']):
|
||||
slider.set_val(value)
|
||||
def update(_):
|
||||
try:
|
||||
fault, residual, ok = 0, 0., True
|
||||
if source:
|
||||
live = source.snapshot()
|
||||
state['command'] = [h+v for h,v in zip(HOME,live['joint_set'])]
|
||||
state['actual'] = [h+v for h,v in zip(HOME,live['joint_actual'])]
|
||||
state['pose'] = list(live['pose_actual'])
|
||||
desired_pose = live['pose_set']
|
||||
residual, fault = live['residual'], live['fault']
|
||||
else:
|
||||
t = min(1.,(time.monotonic()-state['started'])/3.)
|
||||
blend = t*t*t*(10+t*(-15+6*t))
|
||||
state['current'] = [a+blend*(b-a) for a,b in zip(state['start'],state['target'])]
|
||||
if args.joints:
|
||||
state['command'] = [h+v for h,v in zip(HOME,state['current'])]
|
||||
desired_pose, _ = model.forward(state['command'])
|
||||
else:
|
||||
desired_pose = state['current']
|
||||
command,residual,ok = model.inverse(desired_pose,state['command'])
|
||||
if ok:
|
||||
state['command'] = command
|
||||
# Offline ideal plant. There is no dynamics or drive model.
|
||||
state['actual'] = list(state['command'])
|
||||
state['pose'], _ = model.forward(state['actual'])
|
||||
_, requested = model.forward(state['command'])
|
||||
_, actual = model.forward(state['actual'])
|
||||
ax.clear()
|
||||
ax.plot(*zip(*requested),'o--',color='tab:orange',label='IK command',alpha=.7)
|
||||
ax.plot(*zip(*actual),'o-',color='tab:blue',linewidth=3,label='Joint feedback')
|
||||
for index,label in ((0,'J1'),(1,'J2'),(2,'J3'),(3,'J4/5/6')):
|
||||
ax.text(*actual[index],label)
|
||||
reach = sum(model.geometry)
|
||||
ax.set(xlim=(-reach,reach),ylim=(-reach,reach),zlim=(0,reach),xlabel='X [mm]',ylabel='Y [mm]',zlabel='Z [mm]')
|
||||
ax.set_box_aspect((2,2,1))
|
||||
ax.legend(loc='upper left')
|
||||
text = ('IOC read-only' if source else ('Offline joint mode' if args.joints else 'Offline Cartesian mode'))
|
||||
text += f'\nIK: {"OK" if ok else "NO SOLUTION"}\nWeighted residual: {residual:.3g}\nIOC fault: {fault}\n'
|
||||
text += '\nJoint offsets [deg]\n IK set Actual\n'
|
||||
text += '\n'.join(f'J{i+1} {a-h:8.3f} {b-h:8.3f}' for i,(h,a,b) in enumerate(zip(HOME,state['command'],state['actual'])))
|
||||
text += '\n\nTool offsets [mm / deg]\n Set Actual\n'
|
||||
text += '\n'.join(f'{n:2} {a:8.3f} {b:8.3f}' for n,a,b in zip(('X','Y','Z','Rx','Ry','Rz'),desired_pose,state['pose']))
|
||||
if not ok:
|
||||
text += '\n\nHolding last valid joints.'
|
||||
text += '\n\nNo collision checking.'
|
||||
status.set_text(text)
|
||||
status.set_color('black' if ok and fault==0 else 'red')
|
||||
except (RuntimeError,ValueError,TypeError) as exc:
|
||||
status.set_text(str(exc))
|
||||
status.set_color('red')
|
||||
return ()
|
||||
if args.snapshot:
|
||||
update(0)
|
||||
args.snapshot.parent.mkdir(parents=True,exist_ok=True)
|
||||
fig.savefig(args.snapshot,dpi=130)
|
||||
else:
|
||||
animation = FuncAnimation(fig,update,interval=50,cache_frame_data=False)
|
||||
try:
|
||||
plt.show()
|
||||
finally:
|
||||
if source:
|
||||
source.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
# Optional PSI driver build: make -C src build (on the IOC development host).
|
||||
include /ioc/tools/driver.makefile
|
||||
MODULE = main
|
||||
BUILDCLASSES = Linux
|
||||
ARCH_FILTER = deb10% deb12%
|
||||
EXCLUDE_VERSIONS += 3 7.0.5 7.0.6 7.0.7
|
||||
USR_CXXFLAGS += -std=c++17
|
||||
ecmc_VERSION = v11.2.0_RC1
|
||||
SOURCES = logic.cpp
|
||||
HEADERS = kinematics.hpp
|
||||
# The portable C API for the viewer is built separately by make viewer-lib.
|
||||
@@ -0,0 +1,20 @@
|
||||
#include "kinematics.hpp"
|
||||
extern "C" {
|
||||
// Geometry: base height, upper-arm length, forearm length, tool length, all mm.
|
||||
int arm_forward(const double* geometry,const double* joints,double* pose,double* points) {
|
||||
arm::Model m({geometry[0],geometry[1],geometry[2],geometry[3]}); arm::Six q{};
|
||||
std::copy(joints,joints+6,q.begin());
|
||||
if(!m.g.valid()||!m.withinLimits(q)) return -1;
|
||||
std::array<arm::Vec,8> p{}; m.forward(q,&p); auto result=m.pose(q);
|
||||
std::copy(result.begin(),result.end(),pose);
|
||||
for(int i=0;i<8;++i) for(int j=0;j<3;++j) points[i*3+j]=p[i][j];
|
||||
return 0;
|
||||
}
|
||||
int arm_inverse(const double* geometry,const double* pose,const double* seed,double* joints,double* residual) {
|
||||
arm::Model m({geometry[0],geometry[1],geometry[2],geometry[3]}); arm::Six p{},q{};
|
||||
std::copy(pose,pose+6,p.begin()); std::copy(seed,seed+6,q.begin());
|
||||
if(!m.g.valid()||!arm::finite(p)||!m.withinLimits(q)) return -1;
|
||||
auto result=m.inverse(p,q); std::copy(result.joints.begin(),result.joints.end(),joints); *residual=result.residual;
|
||||
return result.converged?0:1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace arm {
|
||||
constexpr double pi=3.14159265358979323846, rad=pi/180.;
|
||||
using Six=std::array<double,6>;
|
||||
using Vec=std::array<double,3>;
|
||||
using Mat=std::array<Vec,3>;
|
||||
inline bool finite(const Six& v) { for(double x:v) if(!std::isfinite(x)) return false; return true; }
|
||||
inline Mat identity() { return {{{1,0,0},{0,1,0},{0,0,1}}}; }
|
||||
inline Mat transpose(const Mat& a) { Mat b{}; for(int i=0;i<3;++i) for(int j=0;j<3;++j) b[i][j]=a[j][i]; return b; }
|
||||
inline Mat mul(const Mat& a,const Mat& b) { Mat c{}; for(int i=0;i<3;++i) for(int j=0;j<3;++j) for(int k=0;k<3;++k) c[i][j]+=a[i][k]*b[k][j]; return c; }
|
||||
inline Vec mul(const Mat& a,const Vec& b) { Vec c{}; for(int i=0;i<3;++i) for(int k=0;k<3;++k) c[i]+=a[i][k]*b[k]; return c; }
|
||||
inline Mat rotation(int axis,double angle) {
|
||||
Mat m=identity(); const int i=(axis+1)%3,j=(axis+2)%3;
|
||||
m[i][i]=m[j][j]=std::cos(angle); m[i][j]=-std::sin(angle); m[j][i]=std::sin(angle); return m;
|
||||
}
|
||||
struct Transform { Vec p{}; Mat r=identity(); };
|
||||
struct Geometry {
|
||||
double baseHeight=300,upperArm=300,forearm=250,tool=80;
|
||||
bool valid() const { for(double x:{baseHeight,upperArm,forearm,tool}) if(!std::isfinite(x)||x<=0) return false; return true; }
|
||||
};
|
||||
struct Result { Six joints{}; double residual=0; int iterations=0; bool converged=false; };
|
||||
inline bool solve(double a[6][6],double b[6],Six& x) {
|
||||
for(int k=0;k<6;++k) {
|
||||
int p=k; for(int i=k+1;i<6;++i) if(std::abs(a[i][k])>std::abs(a[p][k])) p=i;
|
||||
if(std::abs(a[p][k])<1e-14) return false;
|
||||
for(int j=k;j<6;++j) std::swap(a[k][j],a[p][j]);
|
||||
std::swap(b[k],b[p]);
|
||||
for(int i=k+1;i<6;++i) { const double f=a[i][k]/a[k][k]; for(int j=k;j<6;++j) a[i][j]-=f*a[k][j]; b[i]-=f*b[k]; }
|
||||
}
|
||||
for(int i=5;i>=0;--i) { double s=b[i]; for(int j=i+1;j<6;++j) s-=a[i][j]*x[j]; x[i]=s/a[i][i]; } return true;
|
||||
}
|
||||
// Rotation logarithm, including a stable branch near 180 degrees.
|
||||
inline Vec logRotation(const Mat& m) {
|
||||
const double angle=std::acos(std::clamp((m[0][0]+m[1][1]+m[2][2]-1)*.5,-1.,1.));
|
||||
Vec v{m[2][1]-m[1][2],m[0][2]-m[2][0],m[1][0]-m[0][1]};
|
||||
if(angle<1e-7) { for(double& x:v) x*=.5; return v; }
|
||||
if(pi-angle<1e-5) {
|
||||
int k=0; for(int i=1;i<3;++i) if(m[i][i]>m[k][k]) k=i;
|
||||
Vec axis{}; axis[k]=std::sqrt(std::max(0.,(m[k][k]+1)*.5));
|
||||
for(int i=0;i<3;++i) if(i!=k) axis[i]=(m[i][k]+m[k][i])/(4*axis[k]);
|
||||
double dot=0; for(int i=0;i<3;++i) dot+=axis[i]*v[i];
|
||||
for(int i=0;i<3;++i) v[i]=axis[i]*angle*(dot<0?-1:1);
|
||||
return v;
|
||||
}
|
||||
for(double& x:v) x*=angle/(2*std::sin(angle));
|
||||
return v;
|
||||
}
|
||||
struct Model {
|
||||
Geometry g;
|
||||
// A generic elbow arm with a spherical X-Y-X wrist; all axes are revolute.
|
||||
const Six home{0,-35,70,0,35,0};
|
||||
const Six lower{-170,-120,-140,-180,-120,-180};
|
||||
const Six upper{170,120,140,180,120,180};
|
||||
explicit Model(Geometry geometry={}):g(geometry) {}
|
||||
bool withinLimits(const Six& q) const { if(!finite(q)) return false; for(int i=0;i<6;++i) if(q[i]<lower[i]||q[i]>upper[i]) return false; return true; }
|
||||
Transform forward(const Six& q,std::array<Vec,8>* points=nullptr) const {
|
||||
Transform t; int n=0;
|
||||
auto point=[&]() { if(points) (*points)[n]=t.p; ++n; };
|
||||
auto move=[&](Vec local) { const Vec d=mul(t.r,local); for(int i=0;i<3;++i) t.p[i]+=d[i]; };
|
||||
point(); t.r=mul(t.r,rotation(2,q[0]*rad)); move({0,0,g.baseHeight}); point();
|
||||
t.r=mul(t.r,rotation(1,q[1]*rad)); move({g.upperArm,0,0}); point();
|
||||
t.r=mul(t.r,rotation(1,q[2]*rad)); move({g.forearm,0,0}); point();
|
||||
t.r=mul(t.r,rotation(0,q[3]*rad)); point();
|
||||
t.r=mul(t.r,rotation(1,q[4]*rad)); point();
|
||||
t.r=mul(t.r,rotation(0,q[5]*rad)); point();
|
||||
move({g.tool,0,0}); point(); return t;
|
||||
}
|
||||
// Cartesian axes are base-frame offsets from the nominal home tool pose.
|
||||
// R = Rz(Rz)*Ry(Ry)*Rx(Rx)*R_home; XYZ are added in the base frame.
|
||||
Transform target(const Six& pose) const {
|
||||
Transform t=forward(home);
|
||||
for(int i=0;i<3;++i) t.p[i]+=pose[i];
|
||||
t.r=mul(mul(mul(rotation(2,pose[5]*rad),rotation(1,pose[4]*rad)),rotation(0,pose[3]*rad)),t.r); return t;
|
||||
}
|
||||
Six pose(const Six& q) const {
|
||||
const auto t=forward(q),h=forward(home); Six p{};
|
||||
for(int i=0;i<3;++i) p[i]=t.p[i]-h.p[i];
|
||||
const Mat r=mul(t.r,transpose(h.r));
|
||||
p[4]=std::asin(std::clamp(-r[2][0],-1.,1.))/rad;
|
||||
if(std::abs(std::cos(p[4]*rad))>1e-8) { p[3]=std::atan2(r[2][1],r[2][2])/rad; p[5]=std::atan2(r[1][0],r[0][0])/rad; }
|
||||
else { p[3]=0; p[5]=std::atan2(-r[0][1],r[1][1])/rad; }
|
||||
return p;
|
||||
}
|
||||
Six error(const Transform& desired,const Six& q) const {
|
||||
auto t=forward(q); Six e{}; for(int i=0;i<3;++i) e[i]=desired.p[i]-t.p[i];
|
||||
const Vec v=logRotation(mul(desired.r,transpose(t.r)));
|
||||
for(int i=0;i<3;++i) e[i+3]=v[i]*200.; // orientation weight: 200 mm/radian
|
||||
return e;
|
||||
}
|
||||
Result inverse(const Six& pose,const Six& seed,int maxIterations=100) const {
|
||||
Result out; out.joints=seed;
|
||||
if(!g.valid()||!finite(pose)||!withinLimits(seed)) return out;
|
||||
const Transform desired=target(pose); double damping=.1;
|
||||
auto cost=[](const Six& e) { double c=0; for(double v:e)c+=v*v; return c; };
|
||||
for(int step=0;step<=maxIterations;++step) {
|
||||
const Six e=error(desired,out.joints); out.residual=std::sqrt(cost(e)); out.iterations=step;
|
||||
if(out.residual<1e-5) { out.converged=true; return out; }
|
||||
if(step==maxIterations) break;
|
||||
double j[6][6]{},a[6][6]{},b[6]{};
|
||||
for(int k=0;k<6;++k) {
|
||||
Six plus=out.joints,minus=out.joints; plus[k]+=1e-5/rad; minus[k]-=1e-5/rad;
|
||||
const Six ep=error(desired,plus),em=error(desired,minus);
|
||||
for(int i=0;i<6;++i) j[i][k]=-(ep[i]-em[i])/(2e-5);
|
||||
}
|
||||
for(int k=0;k<6;++k) { for(int i=0;i<6;++i) b[k]+=j[i][k]*e[i]; for(int l=0;l<6;++l) for(int i=0;i<6;++i) a[k][l]+=j[i][k]*j[i][l]; a[k][k]+=damping; }
|
||||
Six delta{}; if(!solve(a,b,delta)) return out;
|
||||
double largest=0; for(double v:delta) largest=std::max(largest,std::abs(v));
|
||||
const double scale=largest>.2?.2/largest:1.; Six trial=out.joints;
|
||||
for(int k=0;k<6;++k) trial[k]=std::clamp(trial[k]+scale*delta[k]/rad,lower[k],upper[k]);
|
||||
if(cost(error(desired,trial))<cost(e)) { out.joints=trial; damping=std::max(1e-8,damping*.3); }
|
||||
else damping=std::min(1e12,damping*10);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
};
|
||||
} // namespace arm
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "ecmcCppLogic.hpp"
|
||||
#include "kinematics.hpp"
|
||||
#include <stdexcept>
|
||||
|
||||
struct ArmLogic : ecmcCpp::LogicBase {
|
||||
arm::Model model;
|
||||
arm::Six pose_set{},pose_actual{},joint_set{},joint_actual{},joint_trajectory{};
|
||||
arm::Six seed=model.home;
|
||||
int32_t fault=0,ik_iterations=0,simulation=1;
|
||||
double ik_residual=0;
|
||||
int coordFirst=1,jointFirst=7;
|
||||
ArmLogic() {
|
||||
const auto macros=ecmcCpp::getMacrosString();
|
||||
coordFirst=ecmcCpp::getMacroValueInt(macros,"COORD_FIRST",1);
|
||||
jointFirst=ecmcCpp::getMacroValueInt(macros,"JOINT_FIRST",7);
|
||||
simulation=ecmcCpp::getMacroValueInt(macros,"SIMULATION",1);
|
||||
if(simulation!=1) throw std::runtime_error("Only SIMULATION=1 is supported");
|
||||
if(coordFirst<1||jointFirst<1||std::abs(coordFirst-jointFirst)<6) throw std::runtime_error("Axis ranges must be positive and disjoint");
|
||||
auto& g=model.g;
|
||||
g.baseHeight=ecmcCpp::getMacroValueDouble(macros,"BASE_HEIGHT",g.baseHeight);
|
||||
g.upperArm=ecmcCpp::getMacroValueDouble(macros,"UPPER_ARM",g.upperArm);
|
||||
g.forearm=ecmcCpp::getMacroValueDouble(macros,"FOREARM",g.forearm);
|
||||
g.tool=ecmcCpp::getMacroValueDouble(macros,"TOOL",g.tool);
|
||||
if(!g.valid()) throw std::runtime_error("Invalid geometry");
|
||||
for(int i=0;i<6;++i) {
|
||||
ecmc.input("ax"+std::to_string(coordFirst+i)+".setpos",pose_set[i]);
|
||||
ecmc.input("ax"+std::to_string(jointFirst+i)+".actpos",joint_actual[i]);
|
||||
ecmc.input("ax"+std::to_string(jointFirst+i)+".setpos",joint_trajectory[i]);
|
||||
}
|
||||
epics.readOnlyArray("arm.pose_set",pose_set)
|
||||
.readOnlyArray("arm.pose_actual",pose_actual)
|
||||
.readOnlyArray("arm.joint_set",joint_set)
|
||||
.readOnlyArray("arm.joint_actual",joint_actual)
|
||||
.readOnly("arm.fault",fault)
|
||||
.readOnly("arm.ik_iterations",ik_iterations)
|
||||
.readOnly("arm.ik_residual",ik_residual)
|
||||
.readOnly("arm.simulation",simulation);
|
||||
}
|
||||
void run() override {
|
||||
if(fault) return;
|
||||
for(int i=0;i<6;++i) if(ecmcCpp::axisHasError(coordFirst+i)||ecmcCpp::axisHasError(jointFirst+i)) { fault=4; return; }
|
||||
if(!arm::finite(pose_set)||!arm::finite(joint_actual)||!arm::finite(joint_trajectory)) { fault=1; return; }
|
||||
arm::Six actual{},trajectory{};
|
||||
for(int i=0;i<6;++i) { actual[i]=joint_actual[i]+model.home[i]; trajectory[i]=joint_trajectory[i]+model.home[i]; }
|
||||
if(!model.withinLimits(actual)||!model.withinLimits(trajectory)) { fault=1; return; }
|
||||
// Previous accepted command selects a continuous local IK branch.
|
||||
const auto result=model.inverse(pose_set,seed);
|
||||
ik_iterations=result.iterations; ik_residual=result.residual;
|
||||
if(!result.converged) { fault=2; return; }
|
||||
seed=result.joints; pose_actual=model.pose(actual);
|
||||
for(int i=0;i<6;++i) joint_set[i]=result.joints[i]-model.home[i];
|
||||
for(int i=0;i<6;++i) {
|
||||
int rc=ecmcCpp::axisSetExternalSetpointPos(jointFirst+i,joint_set[i]);
|
||||
rc|=ecmcCpp::axisSetExternalEncoderPos(jointFirst+i,joint_trajectory[i]);
|
||||
rc|=ecmcCpp::axisSetExternalEncoderPos(coordFirst+i,pose_actual[i]);
|
||||
if(rc) { fault=3; return; }
|
||||
}
|
||||
}
|
||||
};
|
||||
ECMC_CPP_LOGIC_REGISTER_DEFAULT(ArmLogic)
|
||||
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'python'))
|
||||
from epics_source import IocSource
|
||||
|
||||
|
||||
class FakePV:
|
||||
def __init__(self, name, auto_monitor, callback, connection_callback):
|
||||
self.name, self.callback, self.connection = name, callback, connection_callback
|
||||
assert auto_monitor
|
||||
def send(self, value): self.callback(value=value)
|
||||
def connect(self): self.connection(conn=True)
|
||||
def disconnect(self): self.connection(conn=False)
|
||||
|
||||
|
||||
class SourceTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = IocSource('ARM:', FakePV)
|
||||
def ready(self):
|
||||
for key, pv in self.source.pvs.items():
|
||||
pv.connect()
|
||||
pv.send([1, 2, 3, 4, 5, 6] if key in IocSource.ARRAYS else 0)
|
||||
def test_prefix_normalization(self):
|
||||
source = IocSource("ARM", FakePV)
|
||||
self.assertEqual(source.pvs["fault"].name, "ARM:arm-fault")
|
||||
def test_mapping_and_updates(self):
|
||||
self.ready()
|
||||
self.assertEqual(self.source.pvs['pose_actual'].name, 'ARM:arm-pose-actual')
|
||||
self.assertEqual(self.source.pvs['joint_set'].name, 'ARM:arm-joint-set')
|
||||
old = self.source.snapshot()
|
||||
self.source.pvs['pose_actual'].send([6]*6)
|
||||
self.assertEqual(self.source.snapshot()['pose_actual'], (6,)*6)
|
||||
self.assertEqual(old['pose_actual'], (1,2,3,4,5,6))
|
||||
def test_disconnect_and_reconnect_waits_for_data(self):
|
||||
self.ready()
|
||||
pv = self.source.pvs['joint_actual']
|
||||
pv.disconnect()
|
||||
with self.assertRaisesRegex(RuntimeError, 'disconnected'): self.source.snapshot()
|
||||
pv.connect()
|
||||
with self.assertRaisesRegex(RuntimeError, 'Waiting'): self.source.snapshot()
|
||||
pv.send([0]*6)
|
||||
self.assertEqual(self.source.snapshot()['joint_actual'], (0,)*6)
|
||||
def test_bad_array_and_recovery(self):
|
||||
self.ready()
|
||||
for bad in ([1]*5, [float('nan')]*6, None):
|
||||
self.source.pvs['pose_set'].send(bad)
|
||||
with self.assertRaisesRegex(RuntimeError, 'arm-pose-set'): self.source.snapshot()
|
||||
self.source.pvs['pose_set'].send([0]*6)
|
||||
self.source.pvs['fault'].send(2)
|
||||
self.assertEqual(self.source.snapshot()['fault'], 2)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "kinematics.hpp"
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
void check(bool ok,const char* msg) { if(!ok) throw std::runtime_error(msg); }
|
||||
int main() {
|
||||
arm::Model m;
|
||||
arm::Six zero{}; const auto home=m.pose(m.home);
|
||||
for(double x:home) check(std::abs(x)<1e-10,"home pose offsets");
|
||||
// Independent straight-arm and base-yaw geometry checks.
|
||||
auto t=m.forward(zero); check(std::abs(t.p[0]-630)<1e-10 && t.p[2]==300,"straight arm dimensions");
|
||||
zero[0]=90; t=m.forward(zero); check(std::abs(t.p[0])<1e-9 && std::abs(t.p[1]-630)<1e-9,"base rotation");
|
||||
auto half=arm::logRotation(arm::rotation(0,arm::pi)); check(std::abs(std::abs(half[0])-arm::pi)<1e-8,"180 degree orientation error");
|
||||
std::mt19937 rng(42); std::uniform_real_distribution<double> jitter(-8,8);
|
||||
for(int sample=0;sample<500;++sample) {
|
||||
auto q=m.home; for(double& v:q) v+=jitter(rng);
|
||||
const auto pose=m.pose(q); auto r=m.inverse(pose,m.home);
|
||||
check(r.converged && m.withinLimits(r.joints),"near-home IK round trip");
|
||||
check(r.residual<1e-5,"round trip residual");
|
||||
}
|
||||
auto seed=m.home;
|
||||
for(int i=0;i<1000;++i) {
|
||||
auto q=m.home; for(int k=0;k<6;++k) q[k]+=10*std::sin(i*.005+k*.4);
|
||||
auto r=m.inverse(m.pose(q),seed); check(r.converged,"continuous IK tracking"); seed=r.joints;
|
||||
}
|
||||
check(!m.inverse({10000,0,0,0,0,0},m.home).converged,"unreachable target");
|
||||
auto bad=m.home; bad[0]=1000; check(!m.inverse({},bad).converged,"invalid seed");
|
||||
arm::Geometry invalid; invalid.tool=-1; check(!arm::Model(invalid).inverse({},m.home).converged,"invalid geometry");
|
||||
auto nan=arm::Six{}; nan[0]=NAN; check(!m.inverse(nan,m.home).converged,"nonfinite pose");
|
||||
// Straight wrist is singular but holding its current pose must remain valid.
|
||||
arm::Six singular{}; check(m.inverse(m.pose(singular),singular).converged,"singular pose hold");
|
||||
std::cout<<"PASS: geometry, orientation, 500 IK round trips, 1000 tracking samples, invalid and unreachable inputs\n";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "../src/logic.cpp"
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
std::array<double,13> setpoint{},encoder{};
|
||||
int badAxis=0,writeFailure=0;
|
||||
int32_t error(int32_t axis) { return axis==badAxis; }
|
||||
int32_t putSet(int32_t axis,double value) { setpoint.at(axis)=value; return writeFailure; }
|
||||
int32_t putEnc(int32_t axis,double value) { encoder.at(axis)=value; return writeFailure; }
|
||||
void check(bool ok,const char* msg) { if(!ok) throw std::runtime_error(msg); }
|
||||
int main() {
|
||||
ecmcCppLogicHostServices host{};
|
||||
host.get_axis_error=error; host.set_axis_ext_set_pos=putSet; host.set_axis_ext_act_pos=putEnc;
|
||||
ecmcCpp::setHostServices(&host);
|
||||
ArmLogic logic; logic.run(); check(!logic.fault,"zero axes represent home");
|
||||
// Verify real binding descriptors rather than relying on the mock to resolve names.
|
||||
check(logic.ecmc.count()==18,"18 input bindings");
|
||||
check(std::string(logic.ecmc.data()[0].itemName)=="ax1.setpos","coordinate asyn path");
|
||||
check(std::string(logic.ecmc.data()[1].itemName)=="ax7.actpos","joint asyn path");
|
||||
logic.pose_set={5,-4,8,2,-3,4}; logic.run(); check(!logic.fault,"IK cycle");
|
||||
for(int i=0;i<6;++i) logic.joint_trajectory[i]=setpoint[i+7];
|
||||
logic.run();
|
||||
for(int i=0;i<6;++i) logic.joint_actual[i]=encoder[i+7];
|
||||
logic.run(); check(!logic.fault,"FK cycle");
|
||||
for(int i=0;i<6;++i) check(std::abs(encoder[i+1]-logic.pose_set[i])<1e-4,"coordinate FK feedback");
|
||||
ArmLogic direct; direct.joint_trajectory={1,2,3,4,5,6}; direct.run();
|
||||
for(int i=0;i<6;++i) { check(encoder[i+7]==i+1,"direct joint ideal plant"); direct.joint_actual[i]=encoder[i+7]; }
|
||||
direct.run(); check(!direct.fault&&std::abs(direct.pose_actual[0])>.01,"direct joint FK");
|
||||
const auto held=setpoint; logic.pose_set[0]=10000; logic.run(); check(logic.fault==2&&setpoint==held,"IK failure holds outputs");
|
||||
logic.pose_set={}; logic.run(); check(logic.fault==2,"latched fault");
|
||||
ArmLogic limits; limits.joint_trajectory[0]=200; limits.run(); check(limits.fault==1,"joint limit");
|
||||
ArmLogic axis; badAxis=7; axis.run(); check(axis.fault==4,"axis error"); badAxis=0;
|
||||
ArmLogic write; writeFailure=1; write.run(); check(write.fault==3,"host failure");
|
||||
std::cout<<"PASS: input bindings, IK/FK pipeline, direct joint plant, latched faults\n";
|
||||
}
|
||||
Reference in new issue
Block a user