/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Robotics.IntentEnabledRobot.Kinematics;
namespace Robotics.IntentEnabledRobot.Simulation
{
///
/// Immutable observable state of the simulated arm.
///
public sealed class SimulatedArmSnapshot
{
///
/// Initializes a snapshot.
///
public SimulatedArmSnapshot(
ArrayOf jointAngles,
Pose3DDataType toolPose,
ArrayOf jointFramePoses,
double gripperOpening,
bool hasObject,
string toolName,
ArrayOf heldPartPosition,
ArrayOf stackSlotsFilled)
{
JointAngles = jointAngles;
ToolPose = toolPose;
JointFramePoses = jointFramePoses;
GripperOpening = gripperOpening;
HasObject = hasObject;
ToolName = toolName;
HeldPartPosition = heldPartPosition;
StackSlotsFilled = stackSlotsFilled;
}
///
/// Gets joint angles in radians, ordered J1 through J6.
///
public ArrayOf JointAngles { get; }
///
/// Gets the tool-centre-point pose in the robot base frame.
///
public Pose3DDataType ToolPose { get; }
///
/// Gets the six joint-frame poses used to drive the USD chain.
///
public ArrayOf JointFramePoses { get; }
///
/// Gets the parallel gripper opening in metres.
///
public double GripperOpening { get; }
///
/// Gets a value indicating whether the simulated gripper carries an object.
///
public bool HasObject { get; }
///
/// Gets the fitted simulated tool name.
///
public string ToolName { get; }
///
/// Gets the visible position of the part currently carried by the gripper.
///
public ArrayOf HeldPartPosition { get; }
///
/// Gets a value for each pallet slot indicating whether that slot has been filled.
///
public ArrayOf StackSlotsFilled { get; }
}
///
/// Provides deterministic simulation delays.
///
public interface ISimulatedArmClock
{
///
/// Delays the simulated arm by the requested duration.
///
ValueTask DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}
///
/// Executes simulation delays using the wall clock.
///
public sealed class RealTimeSimulatedArmClock : ISimulatedArmClock
{
///
/// Gets the shared real-time clock.
///
public static RealTimeSimulatedArmClock Shared { get; } = new();
///
public async ValueTask DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
///
/// Executes Robot Intent structures against the simulated UR5e-style arm.
///
public sealed class SimulatedArmExecutor : IIntentExecutor
{
///
/// Initializes a simulated executor with a new kinematics instance.
///
public SimulatedArmExecutor()
: this(new SimulatedArmKinematics())
{
}
///
/// Initializes a simulated executor.
///
public SimulatedArmExecutor(SimulatedArmKinematics kinematics)
: this(kinematics, RealTimeSimulatedArmClock.Shared)
{
}
///
/// Initializes a simulated executor.
///
public SimulatedArmExecutor(SimulatedArmKinematics kinematics, ISimulatedArmClock clock)
{
m_kinematics = kinematics ?? throw new ArgumentNullException(nameof(kinematics));
m_clock = clock ?? throw new ArgumentNullException(nameof(clock));
PublishCurrentPoseLocked();
}
///
/// Raised after the observable snapshot changes.
///
public event EventHandler? SnapshotChanged;
///
/// Gets the latest observable state without exposing synchronization primitives.
///
public SimulatedArmSnapshot CurrentSnapshot { get; private set; }
///
public async ValueTask ExecuteAsync(
IntentExecution execution,
CancellationToken cancellationToken)
{
if (execution is null)
{
throw new ArgumentNullException(nameof(execution));
}
return execution.Intent switch
{
JointMoveIntentDataType joint => await ExecuteJointMoveAsync(joint, execution, cancellationToken)
.ConfigureAwait(false),
LinearMoveIntentDataType linear => await ExecuteLinearMoveAsync(linear, execution, cancellationToken)
.ConfigureAwait(false),
CircularMoveIntentDataType circular =>
await ExecuteCircularMoveAsync(circular, execution, cancellationToken)
.ConfigureAwait(false),
TrajectoryIntentDataType trajectory =>
await ExecuteTrajectoryAsync(trajectory, execution, cancellationToken)
.ConfigureAwait(false),
CartesianPathIntentDataType path => await ExecuteCartesianPathAsync(path, execution, cancellationToken)
.ConfigureAwait(false),
ForceIntentDataType force =>
await ExecuteForceAsync(force, execution, cancellationToken).ConfigureAwait(false),
GraspIntentDataType grasp =>
await ExecuteGraspAsync(grasp, execution, cancellationToken).ConfigureAwait(false),
ReleaseIntentDataType release => await ExecuteReleaseAsync(release, execution, cancellationToken)
.ConfigureAwait(false),
PickIntentDataType pick =>
await ExecutePickAsync(pick, execution, cancellationToken).ConfigureAwait(false),
PlaceIntentDataType place =>
await ExecutePlaceAsync(place, execution, cancellationToken).ConfigureAwait(false),
ToolChangeIntentDataType tool => await ExecuteToolChangeAsync(tool, execution, cancellationToken)
.ConfigureAwait(false),
WaitIntentDataType wait =>
await ExecuteWaitAsync(wait, execution, cancellationToken).ConfigureAwait(false),
_ => IntentOutcome.Fail(
IntentFailureEnum.CapabilityNotSupported, "The simulated arm does not support this intent.")
};
}
///
public bool CanCancel(IntentExecution execution)
{
if (execution is null)
{
throw new ArgumentNullException(nameof(execution));
}
lock (m_lock)
{
return m_nonCancellableIntentId.Length == 0 || m_nonCancellableIntentId != execution.IntentId;
}
}
private async ValueTask ExecuteJointMoveAsync(
JointMoveIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
double[] start = GetJoints();
ArrayOf target;
if (intent.HasJointTargets)
{
if (intent.JointTargets.Count != SimulatedArmKinematics.JointCount ||
!m_kinematics.IsWithinLimits(intent.JointTargets.Span))
{
return IntentOutcome.Fail(
IntentFailureEnum.JointLimit, "Joint targets exceed the simulated limits.");
}
target = ArrayOf.Create(intent.JointTargets.Span);
}
else if (m_kinematics.TrySelectNearest(
intent.TargetPose,
start,
out SimulatedArmIkSolution? solution,
out SimulatedArmKinematicFailure failure))
{
target = solution.JointAngles;
}
else
{
return IntentOutcome.Fail(
SimulatedArmKinematics.ToIntentFailure(failure), "The target pose cannot be reached.");
}
double distance = JointDistance(start, target.Span);
var profile = new TrapezoidalVelocityProfile(
distance, JointSpeed(intent.Constraints), DefaultJointAcceleration);
IntentOutcome outcome = await FollowProfileAsync(
profile,
execution,
fraction => SetJoints(m_kinematics.InterpolateJoints(start, target.Span, fraction).Span),
DefaultJointAcceleration,
cancellationToken).ConfigureAwait(false);
return outcome.State == ExecutionStateEnum.Succeeded
? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose)
: outcome;
}
private ValueTask ExecuteLinearMoveAsync(
LinearMoveIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
return MoveCartesianAsync(intent.Target, intent.Constraints, execution, cancellationToken);
}
private async ValueTask ExecuteCircularMoveAsync(
CircularMoveIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
Pose3DDataType start = CurrentSnapshot.ToolPose;
var via = new Pose3DDataType
{
FrameId = intent.ViaPoint.FrameId,
Position = ArrayOf.Create(intent.ViaPoint.Position.Span),
Orientation = ArrayOf.Create(start.Orientation.Span)
};
double distance = ArcLength(start.Position.Span, via.Position.Span, intent.Target.Position.Span);
var profile = new TrapezoidalVelocityProfile(
distance,
CartesianSpeed(intent.Constraints),
CartesianAcceleration(intent.Constraints));
SimulatedArmKinematicFailure moveFailure = SimulatedArmKinematicFailure.None;
IntentOutcome outcome = await FollowProfileAsync(
profile,
execution,
fraction =>
{
Pose3DDataType pose = InterpolateArc(start, via, intent.Target, fraction);
if (m_kinematics.TrySelectNearest(
pose,
CurrentSnapshot.JointAngles.Span,
out SimulatedArmIkSolution? solution,
out SimulatedArmKinematicFailure failure))
{
SetPose(solution.JointAngles.Span);
}
else
{
moveFailure = failure;
}
},
CartesianAcceleration(intent.Constraints),
cancellationToken,
() => moveFailure != SimulatedArmKinematicFailure.None).ConfigureAwait(false);
if (moveFailure != SimulatedArmKinematicFailure.None)
{
return IntentOutcome.Fail(
SimulatedArmKinematics.ToIntentFailure(moveFailure), "The circular path is not feasible.");
}
return outcome.State == ExecutionStateEnum.Succeeded
? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose)
: outcome;
}
private async ValueTask ExecuteTrajectoryAsync(
TrajectoryIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
if (intent.Points.IsEmpty)
{
return IntentOutcome.Fail(IntentFailureEnum.ParameterInvalid, "The trajectory has no points.");
}
double finalMs = intent.Points[^1].TimeFromStart;
double elapsed = 0.0;
int pointIndex = 0;
while (elapsed < finalMs)
{
cancellationToken.ThrowIfCancellationRequested();
while (pointIndex + 1 < intent.Points.Count && intent.Points[pointIndex + 1].TimeFromStart <= elapsed)
{
pointIndex++;
}
TrajectoryPointDataType a = intent.Points[pointIndex];
TrajectoryPointDataType b = intent.Points[Math.Min(pointIndex + 1, intent.Points.Count - 1)];
double fraction = Math.Clamp(
(elapsed - a.TimeFromStart) / Math.Max(1.0, b.TimeFromStart - a.TimeFromStart), 0.0, 1.0);
SetJoints(m_kinematics.InterpolateJoints(a.Positions.Span, b.Positions.Span, fraction).Span);
double goalDeviation = JointDistance(
CurrentSnapshot.JointAngles.Span, intent.Points[^1].Positions.Span);
execution.Progress.ReportProgress(Math.Clamp(elapsed / finalMs, 0.0, 1.0));
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
execution.Progress.ReportTrajectoryDeviation(0.0, goalDeviation, elapsed, false);
if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false))
{
break;
}
elapsed += TickSeconds * 1000.0;
}
SetJoints(intent.Points[^1].Positions.Span);
execution.Progress.ReportProgress(1.0);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
execution.Progress.ReportTrajectoryDeviation(0.0, 0.0, finalMs, true);
return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose);
}
private async ValueTask ExecuteCartesianPathAsync(
CartesianPathIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
for (int i = 0; i < intent.Waypoints.Count; i++)
{
PathWaypointDataType waypoint = intent.Waypoints[i];
IntentOutcome outcome = await MoveCartesianAsync(
waypoint.Pose,
new MotionConstraintsDataType(),
execution,
cancellationToken).ConfigureAwait(false);
if (outcome.State != ExecutionStateEnum.Succeeded)
{
return outcome;
}
}
return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose);
}
private async ValueTask ExecuteForceAsync(
ForceIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
Pose3DDataType start = CurrentSnapshot.ToolPose;
ReadOnlySpan direction = intent.Direction.Span;
double norm = Math.Sqrt(
(direction[0] * direction[0]) + (direction[1] * direction[1]) + (direction[2] * direction[2]));
if (norm == 0.0)
{
return IntentOutcome.Fail(IntentFailureEnum.ParameterInvalid, "Force direction is zero.");
}
double directionX = direction[0] / norm;
double directionY = direction[1] / norm;
double directionZ = direction[2] / norm;
var profile = new TrapezoidalVelocityProfile(
intent.MaxDistance, DefaultCartesianSpeed * 0.35, DefaultCartesianAcceleration);
bool contacted = false;
SimulatedArmKinematicFailure moveFailure = SimulatedArmKinematicFailure.None;
IntentOutcome outcome = await FollowProfileAsync(
profile,
execution,
fraction =>
{
double distance = fraction * intent.MaxDistance;
Pose3DDataType pose = TranslatePose(start, directionX, directionY, directionZ, distance);
contacted = IsContact(pose.Position.Span);
if (m_kinematics.TrySelectNearest(
pose,
CurrentSnapshot.JointAngles.Span,
out SimulatedArmIkSolution? solution,
out SimulatedArmKinematicFailure failure))
{
SetPose(solution.JointAngles.Span);
}
else
{
moveFailure = failure;
}
},
DefaultCartesianAcceleration,
cancellationToken,
() => contacted || moveFailure != SimulatedArmKinematicFailure.None).ConfigureAwait(false);
if (outcome.State != ExecutionStateEnum.Succeeded)
{
return outcome;
}
if (moveFailure != SimulatedArmKinematicFailure.None)
{
return IntentOutcome.Fail(
SimulatedArmKinematics.ToIntentFailure(moveFailure), "The force path is not feasible.");
}
return contacted
? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose)
: IntentOutcome.Fail(
IntentFailureEnum.ObjectNotFound, "No contact occurred before MaxDistance was exhausted.");
}
private async ValueTask ExecuteGraspAsync(
GraspIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
SetNonCancellable(execution.IntentId);
try
{
double width = intent.Width > 0.0
? Math.Clamp(intent.Width, GripperClosed, GripperOpen)
: GripperClosed;
await MoveGripperAsync(width, execution, cancellationToken).ConfigureAwait(false);
lock (m_lock)
{
m_hasObject = true;
PublishCurrentPoseLocked();
}
SnapshotChanged?.Invoke(this, CurrentSnapshot);
return IntentOutcome.Success;
}
finally
{
ClearNonCancellable(execution.IntentId);
}
}
private async ValueTask ExecuteReleaseAsync(
ReleaseIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
if (intent.HasTarget)
{
IntentOutcome motion = await MoveCartesianAsync(
intent.Target,
new MotionConstraintsDataType(),
execution,
cancellationToken).ConfigureAwait(false);
if (motion.State != ExecutionStateEnum.Succeeded)
{
return motion;
}
}
await MoveGripperAsync(GripperOpen, execution, cancellationToken).ConfigureAwait(false);
lock (m_lock)
{
if (m_hasObject)
{
FillNextStackSlotLocked();
}
m_hasObject = false;
PublishCurrentPoseLocked();
}
SnapshotChanged?.Invoke(this, CurrentSnapshot);
return IntentOutcome.Success;
}
private async ValueTask ExecutePickAsync(
PickIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
await m_clock.DelayAsync(TimeSpan.FromMilliseconds(80), cancellationToken).ConfigureAwait(false);
return await ExecuteGraspAsync(
new GraspIntentDataType { Force = intent.Force, Width = GripperClosed, Tool = intent.Tool },
execution,
cancellationToken).ConfigureAwait(false);
}
private async ValueTask ExecutePlaceAsync(
PlaceIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
await m_clock.DelayAsync(TimeSpan.FromMilliseconds(80), cancellationToken).ConfigureAwait(false);
return await ExecuteReleaseAsync(
new ReleaseIntentDataType(), execution, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ExecuteToolChangeAsync(
ToolChangeIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
SetNonCancellable(execution.IntentId);
try
{
await m_clock.DelayAsync(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
lock (m_lock)
{
m_toolName = intent.Tool.IsNull ? "none" : intent.Tool.ToString();
PublishCurrentPoseLocked();
}
SnapshotChanged?.Invoke(this, CurrentSnapshot);
return IntentOutcome.Success;
}
finally
{
ClearNonCancellable(execution.IntentId);
}
}
private async ValueTask ExecuteWaitAsync(
WaitIntentDataType intent,
IntentExecution execution,
CancellationToken cancellationToken)
{
double duration = intent.Duration > 0.0 ? intent.Duration : 100.0;
double elapsed = 0.0;
while (elapsed < duration)
{
cancellationToken.ThrowIfCancellationRequested();
execution.Progress.ReportProgress(Math.Clamp(elapsed / duration, 0.0, 1.0));
if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false))
{
break;
}
elapsed += TickSeconds * 1000.0;
}
execution.Progress.ReportProgress(1.0);
return IntentOutcome.Success;
}
private async ValueTask MoveCartesianAsync(
Pose3DDataType target,
MotionConstraintsDataType constraints,
IntentExecution execution,
CancellationToken cancellationToken)
{
Pose3DDataType start = CurrentSnapshot.ToolPose;
double distance = Distance(start.Position.Span, target.Position.Span);
var profile = new TrapezoidalVelocityProfile(
distance, CartesianSpeed(constraints), CartesianAcceleration(constraints));
SimulatedArmKinematicFailure moveFailure = SimulatedArmKinematicFailure.None;
IntentOutcome outcome = await FollowProfileAsync(
profile,
execution,
fraction =>
{
Pose3DDataType pose = m_kinematics.InterpolateCartesian(start, target, fraction);
if (m_kinematics.TrySelectNearest(
pose,
CurrentSnapshot.JointAngles.Span,
out SimulatedArmIkSolution? solution,
out SimulatedArmKinematicFailure failure))
{
SetPose(solution.JointAngles.Span);
}
else
{
moveFailure = failure;
}
},
CartesianAcceleration(constraints),
cancellationToken,
() => moveFailure != SimulatedArmKinematicFailure.None).ConfigureAwait(false);
if (moveFailure != SimulatedArmKinematicFailure.None)
{
return IntentOutcome.Fail(
SimulatedArmKinematics.ToIntentFailure(moveFailure), "The Cartesian path is not feasible.");
}
return outcome.State == ExecutionStateEnum.Succeeded
? IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose)
: outcome;
}
private async ValueTask FollowProfileAsync(
TrapezoidalVelocityProfile profile,
IntentExecution execution,
Action apply,
double acceleration,
CancellationToken cancellationToken,
Func? stopEarly = null)
{
if (profile.Duration == 0.0)
{
apply(1.0);
execution.Progress.ReportProgress(1.0);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose);
}
double elapsed = 0.0;
double totalDistance = Math.Max(profile.PositionAt(profile.Duration), double.Epsilon);
while (elapsed < profile.Duration)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
double fraction = Math.Clamp(profile.PositionAt(elapsed) / totalDistance, 0.0, 1.0);
apply(fraction);
execution.Progress.ReportProgress(fraction);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
if (stopEarly?.Invoke() == true)
{
break;
}
if (!await DelayTickAsync(cancellationToken).ConfigureAwait(false))
{
break;
}
elapsed += TickSeconds;
}
if (cancellationToken.IsCancellationRequested && stopEarly?.Invoke() != true)
{
await ApplyAcceptedStopAsync(
profile,
execution,
apply,
acceleration,
elapsed,
totalDistance).ConfigureAwait(false);
}
if (!cancellationToken.IsCancellationRequested && stopEarly?.Invoke() != true)
{
apply(1.0);
execution.Progress.ReportProgress(1.0);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
}
return IntentOutcome.SucceededAt(CurrentSnapshot.ToolPose);
}
private async ValueTask ApplyAcceptedStopAsync(
TrapezoidalVelocityProfile profile,
IntentExecution execution,
Action apply,
double acceleration,
double elapsed,
double totalDistance)
{
StopModeEnum stopMode = execution.StopMode;
if (stopMode is StopModeEnum.EndOfInstruction or StopModeEnum.EndOfCycle)
{
await FinishCurrentInstructionAsync(profile, execution, apply, elapsed, totalDistance)
.ConfigureAwait(false);
return;
}
double effectiveAcceleration = StopAcceleration(acceleration, stopMode);
double currentPosition = profile.PositionAt(elapsed);
double velocity = profile.VelocityAt(elapsed);
double remaining = Math.Max(0.0, totalDistance - currentPosition);
double stopDistance = Math.Min(remaining, (velocity * velocity) / (2.0 * effectiveAcceleration));
double duration = velocity / effectiveAcceleration;
double stopElapsed = 0.0;
while (stopElapsed < duration && stopDistance > 0.0)
{
double travelled = Math.Min(
stopDistance,
(velocity * stopElapsed) - (0.5 * effectiveAcceleration * stopElapsed * stopElapsed));
double fraction = Math.Clamp((currentPosition + travelled) / totalDistance, 0.0, 1.0);
apply(fraction);
execution.Progress.ReportProgress(fraction);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
await DelayTickAsync(CancellationToken.None).ConfigureAwait(false);
stopElapsed += TickSeconds;
}
double finalFraction = Math.Clamp((currentPosition + stopDistance) / totalDistance, 0.0, 1.0);
apply(finalFraction);
execution.Progress.ReportProgress(finalFraction);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
}
private async ValueTask FinishCurrentInstructionAsync(
TrapezoidalVelocityProfile profile,
IntentExecution execution,
Action apply,
double elapsed,
double totalDistance)
{
double instructionElapsed = elapsed;
while (instructionElapsed < profile.Duration)
{
double fraction = Math.Clamp(profile.PositionAt(instructionElapsed) / totalDistance, 0.0, 1.0);
apply(fraction);
execution.Progress.ReportProgress(fraction);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
await DelayTickAsync(CancellationToken.None).ConfigureAwait(false);
instructionElapsed += TickSeconds;
}
apply(1.0);
execution.Progress.ReportProgress(1.0);
execution.Progress.ReportPose(CurrentSnapshot.ToolPose);
}
private async ValueTask MoveGripperAsync(
double targetOpening,
IntentExecution execution,
CancellationToken cancellationToken)
{
double start;
lock (m_lock)
{
start = m_gripperOpening;
}
const int steps = 12;
for (int i = 1; i <= steps; i++)
{
cancellationToken.ThrowIfCancellationRequested();
double fraction = (double)i / steps;
lock (m_lock)
{
m_gripperOpening = start + ((targetOpening - start) * fraction);
PublishCurrentPoseLocked();
}
SnapshotChanged?.Invoke(this, CurrentSnapshot);
execution.Progress.ReportProgress(fraction);
await m_clock.DelayAsync(TimeSpan.FromMilliseconds(20), cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask DelayTickAsync(CancellationToken cancellationToken)
{
try
{
await m_clock.DelayAsync(TimeSpan.FromSeconds(TickSeconds), cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
}
private double[] GetJoints()
{
lock (m_lock)
{
return (double[])m_jointAngles.Clone();
}
}
private void SetJoints(ReadOnlySpan jointAngles)
{
lock (m_lock)
{
jointAngles.CopyTo(m_jointAngles);
PublishCurrentPoseLocked();
}
SnapshotChanged?.Invoke(this, CurrentSnapshot);
}
///
/// Publishes a new snapshot from the given joint angles.
///
///
/// The tool pose is derived from the joint angles rather than taken from the pose that
/// was commanded. The two differ by the inverse-kinematics residual, and publishing the
/// commanded pose alongside joint frames computed from the solved joints let the tool
/// centre point and the rendered arm disagree - the twin reported a pose the joints did
/// not produce.
///
/// The joint angles the arm has reached.
private void SetPose(ReadOnlySpan jointAngles)
{
lock (m_lock)
{
jointAngles.CopyTo(m_jointAngles);
SimulatedArmForwardPose forward = m_kinematics.Forward(m_jointAngles);
CurrentSnapshot = new SimulatedArmSnapshot(
ArrayOf.Create(m_jointAngles.AsSpan()),
forward.ToolPose,
forward.JointFramePoses,
m_gripperOpening,
m_hasObject,
m_toolName,
HeldPartPosition(forward.ToolPose),
ArrayOf.Create(m_stackSlotsFilled.AsSpan()));
}
SnapshotChanged?.Invoke(this, CurrentSnapshot);
}
[MemberNotNull(nameof(CurrentSnapshot))]
private void PublishCurrentPoseLocked()
{
SimulatedArmForwardPose pose = m_kinematics.Forward(m_jointAngles);
CurrentSnapshot = new SimulatedArmSnapshot(
ArrayOf.Create(m_jointAngles.AsSpan()),
pose.ToolPose,
pose.JointFramePoses,
m_gripperOpening,
m_hasObject,
m_toolName,
HeldPartPosition(pose.ToolPose),
ArrayOf.Create(m_stackSlotsFilled.AsSpan()));
}
private void FillNextStackSlotLocked()
{
for (int ii = 0; ii < m_stackSlotsFilled.Length; ii++)
{
if (!m_stackSlotsFilled[ii])
{
m_stackSlotsFilled[ii] = true;
return;
}
}
}
private void SetNonCancellable(string intentId)
{
lock (m_lock)
{
m_nonCancellableIntentId = intentId;
}
}
private void ClearNonCancellable(string intentId)
{
lock (m_lock)
{
if (m_nonCancellableIntentId == intentId)
{
m_nonCancellableIntentId = string.Empty;
}
}
}
private static Pose3DDataType TranslatePose(Pose3DDataType pose, double x, double y, double z, double distance)
{
ReadOnlySpan position = pose.Position.Span;
return new Pose3DDataType
{
FrameId = pose.FrameId,
Position = ArrayOf.Create([
position[0] + (x * distance),
position[1] + (y * distance),
position[2] + (z * distance)
]),
Orientation = ArrayOf.Create(pose.Orientation.Span)
};
}
private Pose3DDataType InterpolateArc(
Pose3DDataType start, Pose3DDataType via, Pose3DDataType target, double fraction)
{
if (!TryCircle(start.Position.Span, via.Position.Span, target.Position.Span, out Circle circle))
{
return m_kinematics.InterpolateCartesian(start, target, fraction);
}
double angle = circle.StartAngle + (circle.SweepAngle * Math.Clamp(fraction, 0.0, 1.0));
return new Pose3DDataType
{
FrameId = target.FrameId,
Position = ArrayOf.Create([
circle.CenterX +
(circle.Radius * Math.Cos(angle) * circle.BasisUx) +
(circle.Radius * Math.Sin(angle) * circle.BasisVx),
circle.CenterY +
(circle.Radius * Math.Cos(angle) * circle.BasisUy) +
(circle.Radius * Math.Sin(angle) * circle.BasisVy),
circle.CenterZ +
(circle.Radius * Math.Cos(angle) * circle.BasisUz) +
(circle.Radius * Math.Sin(angle) * circle.BasisVz)
]),
Orientation = m_kinematics.InterpolateCartesian(start, target, fraction).Orientation
};
}
private static bool TryCircle(
ReadOnlySpan start, ReadOnlySpan via, ReadOnlySpan end, out Circle circle)
{
double ax = via[0] - start[0];
double ay = via[1] - start[1];
double az = via[2] - start[2];
double bx = end[0] - start[0];
double by = end[1] - start[1];
double bz = end[2] - start[2];
double nx = (ay * bz) - (az * by);
double ny = (az * bx) - (ax * bz);
double nz = (ax * by) - (ay * bx);
double n2 = (nx * nx) + (ny * ny) + (nz * nz);
if (n2 < 1e-10)
{
circle = default;
return false;
}
double a2 = (ax * ax) + (ay * ay) + (az * az);
double b2 = (bx * bx) + (by * by) + (bz * bz);
double cx = start[0] + (((b2 * ((ay * nz) - (az * ny))) - (a2 * ((by * nz) - (bz * ny)))) / (2.0 * n2));
double cy = start[1] + (((b2 * ((az * nx) - (ax * nz))) - (a2 * ((bz * nx) - (bx * nz)))) / (2.0 * n2));
double cz = start[2] + (((b2 * ((ax * ny) - (ay * nx))) - (a2 * ((bx * ny) - (by * nx)))) / (2.0 * n2));
double ux = start[0] - cx;
double uy = start[1] - cy;
double uz = start[2] - cz;
double radius = Math.Sqrt((ux * ux) + (uy * uy) + (uz * uz));
ux /= radius;
uy /= radius;
uz /= radius;
double nn = Math.Sqrt(n2);
nx /= nn;
ny /= nn;
nz /= nn;
double vx = (ny * uz) - (nz * uy);
double vy = (nz * ux) - (nx * uz);
double vz = (nx * uy) - (ny * ux);
double endAngle = Math.Atan2(
((end[0] - cx) * vx) + ((end[1] - cy) * vy) + ((end[2] - cz) * vz),
((end[0] - cx) * ux) + ((end[1] - cy) * uy) + ((end[2] - cz) * uz));
double viaAngle = Math.Atan2(
((via[0] - cx) * vx) + ((via[1] - cy) * vy) + ((via[2] - cz) * vz),
((via[0] - cx) * ux) + ((via[1] - cy) * uy) + ((via[2] - cz) * uz));
if (endAngle < 0.0)
{
endAngle += 2.0 * Math.PI;
}
if (viaAngle < 0.0)
{
viaAngle += 2.0 * Math.PI;
}
double sweep = viaAngle < endAngle ? endAngle : endAngle - (2.0 * Math.PI);
circle = new Circle(cx, cy, cz, ux, uy, uz, vx, vy, vz, radius, 0.0, sweep);
return true;
}
private static double ArcLength(ReadOnlySpan start, ReadOnlySpan via, ReadOnlySpan end)
{
return TryCircle(start, via, end, out Circle circle)
? Math.Abs(circle.Radius * circle.SweepAngle)
: Distance(start, end);
}
private static bool IsContact(ReadOnlySpan position)
{
if (Math.Abs(position[2] - BenchTopZ) > 0.005)
{
return false;
}
if (Math.Abs(position[0]) <= 0.7 && Math.Abs(position[1]) <= 0.45)
{
return true;
}
return IsInsideTarget(position, 0.41, -0.28, 0.04) ||
IsInsideTarget(position, 0.48, 0.26, 0.04) ||
IsInsideTarget(position, -0.25, 0.30, 0.035) ||
IsInsideTarget(position, -0.46, -0.26, 0.04);
}
private static bool IsInsideTarget(ReadOnlySpan position, double x, double y, double radius)
{
double dx = position[0] - x;
double dy = position[1] - y;
return ((dx * dx) + (dy * dy)) <= radius * radius;
}
private static double CartesianSpeed(MotionConstraintsDataType constraints)
{
double speed = constraints?.CartesianSpeed > 0.0 ? constraints.CartesianSpeed : DefaultCartesianSpeed;
if (constraints?.SpeedFraction > 0.0)
{
speed *= Math.Clamp(constraints.SpeedFraction, 0.05, 1.0);
}
return speed;
}
private static double CartesianAcceleration(MotionConstraintsDataType constraints)
{
return constraints?.CartesianAcceleration > 0.0
? constraints.CartesianAcceleration
: DefaultCartesianAcceleration;
}
private static double JointSpeed(MotionConstraintsDataType constraints)
{
return constraints?.SpeedFraction > 0.0
? DefaultJointSpeed * Math.Clamp(constraints.SpeedFraction, 0.05, 1.0)
: DefaultJointSpeed;
}
private static double StopAcceleration(double acceleration, StopModeEnum stopMode)
{
double boundedAcceleration = Math.Max(acceleration, double.Epsilon);
return stopMode switch
{
StopModeEnum.QuickStop => boundedAcceleration,
StopModeEnum.ProcessStop => boundedAcceleration * 0.5,
StopModeEnum.OnPath => boundedAcceleration * 0.25,
_ => boundedAcceleration * 0.35
};
}
private static double JointDistance(ReadOnlySpan a, ReadOnlySpan b)
{
double sum = 0.0;
for (int i = 0; i < SimulatedArmKinematics.JointCount; i++)
{
double delta = a[i] - b[i];
sum += delta * delta;
}
return Math.Sqrt(sum);
}
private static double Distance(ReadOnlySpan a, ReadOnlySpan b)
{
double dx = a[0] - b[0];
double dy = a[1] - b[1];
double dz = a[2] - b[2];
return Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
}
private static ArrayOf HeldPartPosition(Pose3DDataType toolPose)
{
ReadOnlySpan position = toolPose.Position.Span;
return ArrayOf.Create([
position[0],
position[1],
position[2] - HeldPartTcpOffset
]);
}
private const double DefaultCartesianSpeed = 0.25;
private const double DefaultCartesianAcceleration = 0.7;
private const double DefaultJointSpeed = 0.9;
private const double DefaultJointAcceleration = 2.0;
private const double TickSeconds = 0.02;
private const double BenchTopZ = 0.829;
private const double GripperOpen = 0.08;
private const double GripperClosed = 0.018;
private const double HeldPartTcpOffset = 0.035;
private const int StackSlotCount = 8;
private readonly System.Threading.Lock m_lock = new();
private readonly SimulatedArmKinematics m_kinematics;
private readonly ISimulatedArmClock m_clock;
private readonly double[] m_jointAngles = [-0.45, -0.95, 1.55, -0.9, 0.75, 0.0];
private double m_gripperOpening = GripperOpen;
private bool m_hasObject;
private readonly bool[] m_stackSlotsFilled = new bool[StackSlotCount];
private string m_toolName = "parallel-gripper";
private string m_nonCancellableIntentId = string.Empty;
private readonly struct Circle
{
public Circle(
double centerX,
double centerY,
double centerZ,
double basisUx,
double basisUy,
double basisUz,
double basisVx,
double basisVy,
double basisVz,
double radius,
double startAngle,
double sweepAngle)
{
CenterX = centerX;
CenterY = centerY;
CenterZ = centerZ;
BasisUx = basisUx;
BasisUy = basisUy;
BasisUz = basisUz;
BasisVx = basisVx;
BasisVy = basisVy;
BasisVz = basisVz;
Radius = radius;
StartAngle = startAngle;
SweepAngle = sweepAngle;
}
public double CenterX { get; }
public double CenterY { get; }
public double CenterZ { get; }
public double BasisUx { get; }
public double BasisUy { get; }
public double BasisUz { get; }
public double BasisVx { get; }
public double BasisVy { get; }
public double BasisVz { get; }
public double Radius { get; }
public double StartAngle { get; }
public double SweepAngle { get; }
}
}
}