mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 08:41:51 +08:00
466c5cb982
Follow-ups to #4165, plus the work that followed from re-checking this branch against the specification. Everything started as a defect found by review or by probing the running code; the specification is unchanged except where noted. The TLS certificate lifetime fix (#4196) and the OpenUSD work (#4198) were both merged into this branch. #4196 has since landed on master, so it no longer appears in this diff. #4198 was closed in favour of this PR and its content is here. The MCP trim/AOT work (#4228), the `DataGenerator` null `NodeId` fix (#4227) and the NodeSet external-parent import fix (#4229) have since been merged in as well. All three are closed in favour of this PR and their content is here, described under *Making the MCP libraries trim and AOT clean*, *A generator that returned the value you opted out of* and *A NodeSet parent that lived outside the import batch* below. ## A two-arm cell could not be built `IntentExecution` carried no controller identity, and there was effectively one `IIntentExecutor` per server. An executor handed an execution therefore could not tell **which arm** it was for, so a cell with two `IntentControllerType` instances — an ordinary configuration — was not expressible through the builder or DI. Both halves are addressed: - `IntentExecution` now carries `ControllerId` (`NodeId`, the OPC UA identity) and `ControllerName`, so one shared executor can discriminate; - DI gained per-controller registration — `AddRobotIntentExecutor<TExecutor>(controllerBrowseName)` and an instance overload — so two genuinely different executors can coexist. Resolution order is `WithExecutor` → per-controller DI registration → the single DI executor → a build-time throw. There is still **no silent fallback** to a rejecting executor; that was a Critical defect fixed in #4165 and it stays fixed. `IIntentExecutor` is unchanged, so this is purely additive. ## Declarations that were quietly untrue - **`Accepts<T>` silently discarded a redeclaration.** Declaring an intent type twice — say to narrow `cancelSupported` on the second call — kept the first, wider declaration and said nothing. That is the same class of defect as the capability-honesty problems #4165 fixed (`PauseSupported` declaring a pause the host could not perform; `Aborting` degrading silently to `Buffered`). Identical redeclarations remain idempotent; conflicting ones now throw `BadInvalidArgument`. - **`RI-Interop-40010` assumed Annex B item 3 rather than checking it.** It now requires exact set equality between the OPC 40010 `TaskProgramName` values and the Robot Intent `ProgramType.ProgramId` values. Both sides are readable from the address space without commanding the robot, which is what makes the requirement structural under §12.1. ## Two things the specification requires that this did not do Re-checking the implementation against the merged draft revision turned up two genuine violations. Both are fixed, and the NodeSet is untouched by either. - **Every graphed mission reported `Failed` on success.** `MissionRules.SelectTransition` returned `null` for two different situations — a graph *terminus*, which is the normal success path, and a branch point whose conditions all evaluated false — and the caller collapsed both into `Failed`/`NoTransition`. A three-way result (`Terminus` / `Selected` / `NoConditionMatched`) separates them, so a mission that runs to its end now completes. The failure reason surfaces through the inherited Part 10 result container rather than a new member. - **Facet URIs were never published.** An upstream `may` became a `shall` in the revision this branch adopts. `RobotIntentConformanceUris.Facets` held 31 URIs with **zero** production references — the set existed but nothing emitted it. The published set is now derived from `RobotIntentFacetCalculator`, the same authority that already computed `SupportedFacets`, rather than from a second hand-maintained list that could drift; a reflection test pins the two together. ## An easy client mistake `RequestAuthorityAsync` returns a lease the caller must remember to inspect. Forgetting makes every later submission fail with `ControlNotOwned` for reasons that are invisible at the call site. `RequireAuthorityAsync` throws instead, and names the current owner. `RequestAuthorityAsync` is untouched — inspecting and branching is legitimate. ## Letting an agent drive the robot New `tools/Opc.Ua.Mcp.Robotics` gives a language model the intent API as MCP tools: 36 across discovery, monitoring, direct control and missions, behind a `Robotics` tool profile, composable into any MCP host exactly as the existing OPC UA tool packages are. The `IntentViewerClient` sample hosts them behind `--mcp` (stdio by default, HTTP when `--view`, since MCP stdio and the in-process viewer would otherwise share stdout), and the sample gained a pallet scene so an agent can pick and stack parts and watch the result in the OpenUSD viewer. All the scene logic is server-side — no viewer or connector changes. Four gaps in the client API had to be filled first, since the tools are a thin layer over it and could not have been written honestly without them: `ReadStateAsync`, `ListOperationsAsync`/`ListMissionsAsync`, controller-level `CancelIntent`/`CancelAll`/`Pause`/`Resume`/`Retry`/`ReleaseControl`, and `WaitForCompletionAsync`. The tools refuse rather than paper over: a refusal from the server is returned with its `IntentFailureEnum` and never retried, and command authority is never acquired as a side effect of a tool call. **One defect this surfaced:** `--profile robotics` shipped 36 tools that could not do anything. Every robotics tool resolves a named OPC UA session and only the connection tools can open one, so with no `Connect` in the catalogue the whole profile failed. A test had pinned the defect in place, asserting the profile did *not* contain `Connect` on the line below the `diagnostics` assertion requiring it. Each package now declares its own requirement in its registration extension rather than the host special-casing it, so an application embedding `Opc.Ua.Mcp.Robotics` directly gets a usable catalogue without reproducing the host's pairing. `Full` still takes the connection tools from the core package, so they are never registered twice. The replacement test states the invariant — a profile exposing session-scoped tools must expose the tools that open a session — rather than the instance. ## Making the MCP libraries trim and AOT clean *Merged from #4228.* `Opc.Ua.Mcp.Core` is now `IsAotCompatible` on `net10.0`, and the reflection the analysers then reported is gone. Turning the analysers on is the point: until now they were not running on these libraries at all — including on the tool library this branch adds. - **`OpcUaJsonHelper.Serialize`** went through `JsonSerializer` (IL2026 / IL3050). The conversion helpers in that class already reduce every OPC UA value to a closed set — `null`, the primitive scalars, `string`, `Dictionary<string, object?>` and lists thereof — so the JSON is now written directly with a `Utf8JsonWriter`. Output is unchanged, and that is asserted rather than assumed: `OpcUaJsonHelperTests.SerializeMatchesTheReflectionBasedSerializer` compares the new writer byte for byte against `JsonSerializer` for every shape the helpers emit, including all numeric widths, the custom-formatted scalars, nested sequences and strings that force the encoder to escape. One deliberate contract change: arbitrary object graphs are no longer accepted. Serializing them needs reflection, so `Serialize` now throws `NotSupportedException` naming the type instead of silently stringifying it. No call site in the repository passes anything outside the closed set. - **`McpSchemaFilters`** used `JsonSerializer.SerializeToElement` on a `JsonObject`; it now parses the node's own text, which needs no reflection. `Opc.Ua.Mcp.Diagnostics` is deliberately **not** marked. It depends on SharpPcap and on the reflective service-call dissection in `Opc.Ua.Core.Diagnostics`, neither of which is trim safe — which is why that package does not opt in either. Two improvements are kept regardless: `PacketDecodeTools` serializes `DecodedServiceCall` through a source-generated `JsonSerializerContext`, and `PacketCaptureTools.ListInterfaces` now carries the `RequiresUnreferencedCode` / `RequiresDynamicCode` annotations that `NetworkInterfaceEnumerator.ListLocalInterfaces` already declares, so the constraint is visible to callers instead of implicit. Nothing is suppressed. Marking an assembly `IsAotCompatible` only proves its own IL is clean; it says nothing about whether the library still functions once reflection is gone. So `Opc.Ua.Aot.Tests` references `Opc.Ua.Mcp.Core` and exercises it with JSON reflection disabled, stating what is and is not true today: - tool **results** serialize without reflection — this would fail outright if the `Utf8JsonWriter` change regressed; - tool **registration** still requires reflection, because the MCP SDK builds each tool's input schema by asking `JsonSchemaExporter` for a `JsonTypeInfo` of every parameter type. Without a source-generated context covering the tool signatures — and with the injected `OpcUaSessionManager` being described rather than treated as a service — that throws `NotSupportedException`. The second test asserts the throw. It documents the remaining gap and turns red the moment someone closes it, rather than leaving it to be found in a published binary. The package README says the same thing in prose. **Two pre-existing issues this surfaced, both outside this PR:** pulling `Opc.Ua.Mcp.Diagnostics` into an AOT publish reports `IL2046` / `IL3051` on `NicCaptureSource.StartAsync`, which is annotated `RequiresUnreferencedCode` / `RequiresDynamicCode` while the `ICaptureSource.StartAsync` it implements is not, and `IL2075` on `ServiceCallReassembler.GetCollectionCount` / `GetPropertyString`, which call `Type.GetProperty` on the result of `GetType()`. Happy to file these separately if useful. ## A generator that returned the value you opted out of *Merged from #4227. Fixes #4226.* `GetRandomNodeId(useBoundaryValues: false)` could still return `NodeId.Null` — one of the boundary values the same method offers when boundary values *are* requested. A caller that opted out should not receive one, and `GetRandomExpandedNodeId` builds on it and inherited the same behaviour. The `IdType.Opaque` branch is the practical path: `GetRandomByteString()` draws its length with `NextInt32(MaxStringLength)`, which is inclusive of zero, and a zero-length opaque identifier in namespace 0 satisfies `NodeId.IsNull`. With three namespaces and `MaxStringLength` at 10 that is roughly **one call in 130**. A numeric identifier of `0` in namespace 0 is null too, at around one in four billion. The draw is now repeated while the result is null and boundary values were not requested. Guarding the result rather than the individual branches covers every identifier type, including the numeric case, rather than only the one that happens to be reachable today. The `useBoundaryValues: true` path is untouched, so boundary values remain available when they are asked for. Four tests in `tests/Opc.Ua.Core.Tests/Types/DataGeneratorTests.cs` loop 20,000 draws, because at one call in 130 a single draw proves nothing. Against the **unmodified** generator they fail on draw 93 and draw 1346. Two of the four exist to keep the fix honest rather than to test the bug: `GetRandomNodeIdStillProducesEveryIdentifierType` catches a "fix" that simply stopped emitting opaque identifiers, which would satisfy the null assertions while silently narrowing the generator, and `GetRandomNodeIdWithBoundaryValuesStillYieldsThem` confirms the leak was closed rather than the feature removed. **How it was found:** an intermittent downstream test failure. A test server initialising a static `NodeId`-valued variable via `TestDataSystem.ReadValue` occasionally stored `NodeId.Null`; the encoder correctly writes that as JSON `null`, and a consumer asserting a value was present failed about one run in five — never reproducibly in isolation. It only surfaced under load because `UnsecureRandom.Shared` is a fixed-seed, process-global generator shared between `DataGenerator` and OPC UA transport jitter, so the number of draws preceding address-space initialisation depends on what else is running in the process. ## A NodeSet parent that lived outside the import batch *Merged from #4229.* `UANodeSetHelpers.LinkParentChildRelationships` builds its lookup table from **only the nodes in the current import batch**, links a child when the parent is found — and then cleared `instance.Handle` **unconditionally**, which is exactly where `Import` had stashed the authored `ParentNodeId`. A node whose parent lives in another NodeSet, or is owned by another NodeManager, therefore lost that parent **silently** at import, and nothing downstream could recover it. `RuntimeNodeSetNodeManager.CreateAddressSpaceAsync` already has a step that wires references to external node managers; the parent link never reached it. Such a parent is now recorded rather than discarded, and exposed through `UANodeSet.TryGetUnresolvedParentNodeId`. A parent that resolved normally inside the batch is **not** recorded, so a caller does not re-wire references that already exist. The record lives in a `ConditionalWeakTable`, so it does not keep an imported node alive and does not widen `NodeState` — whose `Handle` is a general-purpose slot a caller may legitimately use for its own purposes, and so is not a safe place to leave the value. `RuntimeNodeSetNodeManager` consumes it to report the one case that cannot be repaired automatically. In NodeSet2 the `ParentNodeId` attribute is a *hint*; the authoritative edge is the entry under `<References>`, which the reverse-reference pass already turns into an external reference. A node that names an out-of-manager parent but carries **no such Reference** materializes with no path from that parent, and no ReferenceType can be inferred to synthesise one — so it is reported as an authoring defect rather than guessed at or silently accepted. This is deliberately **not** a fix for a specific address-space symptom. It removes a silent loss of authored information and gives callers the means to act on it. Whether any particular orphaned-node report is caused by this path, or by namespace-index translation elsewhere, still needs separate diagnosis. **Changed while merging.** As authored in #4229 the accessor was `GetUnresolvedParentNodeId`, returning `NodeId?`. `NodeId` is a readonly struct implementing `INullable`, so that was a `System.Nullable<NodeId>` wrapped around a type that already carries its own null representation — which this repository excludes in favour of an `IsNull` check, and it asks for `TryGet` over casting on struct types. The tests correspondingly asserted `Is.Null` against a struct, which the testing rules also exclude. It is now `bool TryGetUnresolvedParentNodeId(NodeState, out NodeId)`, yielding `NodeId.Null` when there is no unresolved parent. The API is unshipped — it arrives with this branch — so nothing depended on the previous shape. Both tests were re-verified by mutation after the rewrite: disabling the record in `LinkParentChildRelationships` still fails `ImportKeepsAParentThatIsNotInTheSameBatch`. ## Dependencies - **OpenUSD to `0.7.0-alpha`.** All 11 issues we filed upstream are fixed, so the workarounds they justified are gone and the parts of the pump and generator samples that were blocked on them are implemented. - **ModelContextProtocol to `2.1.0`** across all five MCP tool libraries and the executable. No code changes were needed — the surface we use is unchanged in 2.x — but central package management required a cascade of pins, each commented where it sits. Both transports were verified against a running server rather than assumed. MCP 2.x is stateless (no `initialize`, no `Mcp-Session-Id`); an OPC UA session is application state addressed by a caller-supplied name, which fits that model but does assume process affinity, and that assumption is now written down rather than implied. ## Infrastructure The `code coverage` gate was measuring nothing on this branch and reporting success. It is fixed and now enforced, which immediately exposed real gaps — most usefully `RoboticsIntentJson`, which parses agent-supplied JSON (untrusted input from a language model) and had no tests at all. It is now at full line coverage in CI. Patch coverage is **90.20%** against a 75% floor. The `netstandard2.1` build leg was also failing. The MCP projects build as empty shells under a legacy `CustomTestTarget`, and the viewer sample referenced them on the target framework alone, so it tried to resolve a net10.0 shell from net8.0. The sample now derives MCP support from whether those projects are actually being built. ## From review A review pass over the branch found two real defects, both fixed. `RoboticsIntentJson` parses JSON written by a language model, so every malformed shape it can be handed is reachable input; it indexed pose arrays without checking their length and enumerated properties without checking their kind, so a short or wrongly-typed array escaped as `IndexOutOfRangeException` or `InvalidOperationException` rather than the argument error the tool descriptions promise the agent. Separately, `IntentOperationHandle` treated a zero `QueuePosition` as "not observed" and discarded it, so a handle kept reporting the last queued position after the server had said the operation left the queue. The pose validation immediately caught a latent test defect: the pallet scenario interpolated doubles into JSON with the current culture, so under a comma-decimal culture `[0.12, 0.16, 0.28]` became a six-element array. It passed only because that submission is expected to be refused for want of authority, so the wrong coordinates were never used. A third review finding — that a default-constructed `RobotIntentControllerState` hands out a null `NodeId` — was checked and rejected: `NodeId`, `QualifiedName` and `LocalizedText` are readonly structs here, so `default` is the null value and `IsNull` is `true`. The tests asserting that invariant are kept. A second review pass over the merged branch found two more, both fixed and both about an error surfacing as the wrong kind of failure rather than about a wrong result: - **Malformed agent JSON did not arrive as an argument error.** `RoboticsIntentJson` turns every *validation* failure into an `ArgumentException` — a wrong root kind, a missing property, a short pose array — but three `JsonDocument.Parse` calls and the NodeId parse were unguarded. A syntax error escaped as `JsonException` and an unparseable NodeId as `ServiceResultException`, which are the two shapes a language model is most likely to produce. That is the same defect class as the pose/kind checking above: a caller separating bad input from a genuine fault had to know to catch three unrelated exception types. Parsing now goes through one helper that reports a syntax error as an `ArgumentException` naming the argument, keeping the `JsonException` as the inner exception so the position is not lost, and the NodeId goes through `NodeId.TryParse` rather than `Parse`, collapsing its two failure modes into one. `MalformedIntentJsonIsRejected` had asserted `InstanceOf<JsonException>`, pinning the behaviour rather than the intended contract; it now asserts `ArgumentException` like every neighbouring test, and three tests cover the newly guarded paths. - **`ConditionalWeakTable.Add` throws on a duplicate key.** Not reachable today, but `TryGetUnresolvedParentNodeId` documents `Handle` as a general-purpose slot a caller may use for its own purposes — and a caller doing exactly that before re-importing into the same collection would take a hard throw from inside the import pipeline. Removing first makes the most recent import win. `AddOrUpdate` would say this more directly but does not exist on `netstandard2.0`. ## Deliberately not fixed here `FastenIntentDataType.Joint` is refused when the Server exposes no OPC 40450/40451 model, and a client cannot discover that before calling. That is **not fixable in this repository**: §5.4.2 makes `Joint` the defining mechanism while §11.3 says the parameters otherwise stand alone, and `IntentCapabilityDataType` declares capabilities at intent-type granularity with no member-level mechanism anywhere in the model. It is a specification gap, raised as S4 on [marcschier/opcua-drafts#60](https://github.com/marcschier/opcua-drafts/pull/60) rather than worked around with a bespoke mechanism the spec does not define. ## Validation `dotnet build UA.slnx -c Release` on the merged branch — **0 errors, 0 warnings**, with the AOT analysers now running over `Opc.Ua.Mcp.Core`. | Suite (net10.0) | Tests | |---|---| | `Opc.Ua.Core.Tests` | 4247 (86 skipped) | | `Opc.Ua.Robotics.Tests` | 540 | | `Opc.Ua.OpenUsd.Tests` | 909 | | `Opc.Ua.Tools.Tests` | 419 | | `Opc.Ua.Server.Tests` — `RuntimeNodeSet` | 94 | | `Opc.Ua.Robotics.Intent.Tests` | 22 | | `Opc.Ua.Aot.Tests` — `McpAotTests` | 3 | 0 failures. `Opc.Ua.Tools.Tests` rose from 409 to 419 with the serializer-equivalence tests from #4228 and the malformed-JSON tests from the second review pass, and `Opc.Ua.Core.Tests` covers the `DataGenerator` tests from #4227 and the import tests from #4229. `UANodeSetHelpers` also passes on **net48** (5/5). The MCP agent-usability grader scores Core 100 / Full 100. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: a248a589-9a20-4372-868e-5d347e57001b Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 Copilot-Session: a0718e8a-a158-45c5-929d-e1d6c0982431
1148 lines
46 KiB
C#
1148 lines
46 KiB
C#
/* ========================================================================
|
|
* 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
|
|
{
|
|
/// <summary>
|
|
/// Immutable observable state of the simulated arm.
|
|
/// </summary>
|
|
public sealed class SimulatedArmSnapshot
|
|
{
|
|
/// <summary>
|
|
/// Initializes a snapshot.
|
|
/// </summary>
|
|
public SimulatedArmSnapshot(
|
|
ArrayOf<double> jointAngles,
|
|
Pose3DDataType toolPose,
|
|
ArrayOf<Pose3DDataType> jointFramePoses,
|
|
double gripperOpening,
|
|
bool hasObject,
|
|
string toolName,
|
|
ArrayOf<double> heldPartPosition,
|
|
ArrayOf<bool> stackSlotsFilled)
|
|
{
|
|
JointAngles = jointAngles;
|
|
ToolPose = toolPose;
|
|
JointFramePoses = jointFramePoses;
|
|
GripperOpening = gripperOpening;
|
|
HasObject = hasObject;
|
|
ToolName = toolName;
|
|
HeldPartPosition = heldPartPosition;
|
|
StackSlotsFilled = stackSlotsFilled;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets joint angles in radians, ordered J1 through J6.
|
|
/// </summary>
|
|
public ArrayOf<double> JointAngles { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the tool-centre-point pose in the robot base frame.
|
|
/// </summary>
|
|
public Pose3DDataType ToolPose { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the six joint-frame poses used to drive the USD chain.
|
|
/// </summary>
|
|
public ArrayOf<Pose3DDataType> JointFramePoses { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the parallel gripper opening in metres.
|
|
/// </summary>
|
|
public double GripperOpening { get; }
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the simulated gripper carries an object.
|
|
/// </summary>
|
|
public bool HasObject { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the fitted simulated tool name.
|
|
/// </summary>
|
|
public string ToolName { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the visible position of the part currently carried by the gripper.
|
|
/// </summary>
|
|
public ArrayOf<double> HeldPartPosition { get; }
|
|
|
|
/// <summary>
|
|
/// Gets a value for each pallet slot indicating whether that slot has been filled.
|
|
/// </summary>
|
|
public ArrayOf<bool> StackSlotsFilled { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Provides deterministic simulation delays.
|
|
/// </summary>
|
|
public interface ISimulatedArmClock
|
|
{
|
|
/// <summary>
|
|
/// Delays the simulated arm by the requested duration.
|
|
/// </summary>
|
|
ValueTask DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes simulation delays using the wall clock.
|
|
/// </summary>
|
|
public sealed class RealTimeSimulatedArmClock : ISimulatedArmClock
|
|
{
|
|
/// <summary>
|
|
/// Gets the shared real-time clock.
|
|
/// </summary>
|
|
public static RealTimeSimulatedArmClock Shared { get; } = new();
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes Robot Intent structures against the simulated UR5e-style arm.
|
|
/// </summary>
|
|
public sealed class SimulatedArmExecutor : IIntentExecutor
|
|
{
|
|
/// <summary>
|
|
/// Initializes a simulated executor with a new kinematics instance.
|
|
/// </summary>
|
|
public SimulatedArmExecutor()
|
|
: this(new SimulatedArmKinematics())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a simulated executor.
|
|
/// </summary>
|
|
public SimulatedArmExecutor(SimulatedArmKinematics kinematics)
|
|
: this(kinematics, RealTimeSimulatedArmClock.Shared)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a simulated executor.
|
|
/// </summary>
|
|
public SimulatedArmExecutor(SimulatedArmKinematics kinematics, ISimulatedArmClock clock)
|
|
{
|
|
m_kinematics = kinematics ?? throw new ArgumentNullException(nameof(kinematics));
|
|
m_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
PublishCurrentPoseLocked();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raised after the observable snapshot changes.
|
|
/// </summary>
|
|
public event EventHandler<SimulatedArmSnapshot>? SnapshotChanged;
|
|
|
|
/// <summary>
|
|
/// Gets the latest observable state without exposing synchronization primitives.
|
|
/// </summary>
|
|
public SimulatedArmSnapshot CurrentSnapshot { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<IntentOutcome> 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.")
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
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<IntentOutcome> ExecuteJointMoveAsync(
|
|
JointMoveIntentDataType intent,
|
|
IntentExecution execution,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
double[] start = GetJoints();
|
|
ArrayOf<double> 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<IntentOutcome> ExecuteLinearMoveAsync(
|
|
LinearMoveIntentDataType intent,
|
|
IntentExecution execution,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return MoveCartesianAsync(intent.Target, intent.Constraints, execution, cancellationToken);
|
|
}
|
|
|
|
private async ValueTask<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> ExecuteForceAsync(
|
|
ForceIntentDataType intent,
|
|
IntentExecution execution,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Pose3DDataType start = CurrentSnapshot.ToolPose;
|
|
ReadOnlySpan<double> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> 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<IntentOutcome> FollowProfileAsync(
|
|
TrapezoidalVelocityProfile profile,
|
|
IntentExecution execution,
|
|
Action<double> apply,
|
|
double acceleration,
|
|
CancellationToken cancellationToken,
|
|
Func<bool>? 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<double> 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<double> 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<bool> 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<double> jointAngles)
|
|
{
|
|
lock (m_lock)
|
|
{
|
|
jointAngles.CopyTo(m_jointAngles);
|
|
PublishCurrentPoseLocked();
|
|
}
|
|
SnapshotChanged?.Invoke(this, CurrentSnapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes a new snapshot from the given joint angles.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="jointAngles">The joint angles the arm has reached.</param>
|
|
private void SetPose(ReadOnlySpan<double> 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<double> 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<double> start, ReadOnlySpan<double> via, ReadOnlySpan<double> 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<double> start, ReadOnlySpan<double> via, ReadOnlySpan<double> end)
|
|
{
|
|
return TryCircle(start, via, end, out Circle circle)
|
|
? Math.Abs(circle.Radius * circle.SweepAngle)
|
|
: Distance(start, end);
|
|
}
|
|
|
|
private static bool IsContact(ReadOnlySpan<double> 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<double> 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<double> a, ReadOnlySpan<double> 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<double> a, ReadOnlySpan<double> 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<double> HeldPartPosition(Pose3DDataType toolPose)
|
|
{
|
|
ReadOnlySpan<double> 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; }
|
|
}
|
|
}
|
|
}
|