/* ========================================================================
* 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.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Opc.Ua;
using Opc.Ua.Gpos;
using Opc.Ua.Positioning;
namespace Robotics
{
///
/// Deterministic sample provider that publishes a global location per robot.
///
public sealed class MobileRobotPositionProvider : IGeoLocationProvider
{
///
/// The EPSG code of WGS84, which the cell's GlobalLocation Variables
/// are configured for.
///
private const uint WGS84EpsgCode = 4326;
private readonly MobileRobotPositionOptions m_options;
private readonly TimeProvider m_timeProvider;
private readonly DateTimeOffset m_startedAt;
public MobileRobotPositionProvider(
IOptions options,
RobotPositioningScenario scenario,
CellChoreographer choreographer)
: this(options.Value, scenario, TimeProvider.System)
{
Choreographer = choreographer ??
throw new ArgumentNullException(nameof(choreographer));
}
public MobileRobotPositionProvider(
MobileRobotPositionOptions options,
RobotPositioningScenario scenario,
TimeProvider timeProvider)
{
m_options = options ?? throw new ArgumentNullException(nameof(options));
Scenario = scenario ?? throw new ArgumentNullException(nameof(scenario));
m_timeProvider = timeProvider ??
throw new ArgumentNullException(nameof(timeProvider));
m_startedAt = m_timeProvider.GetUtcNow();
}
public RobotPositioningScenario Scenario { get; }
///
/// The choreographer whose agents own the robot poses, when the provider is hosted.
///
public CellChoreographer? Choreographer { get; }
///
public bool SupportsPush => true;
///
public ValueTask ReadAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
TimeSpan elapsed = m_timeProvider.GetUtcNow() - m_startedAt;
return new ValueTask(
CreateSample(sourceId, elapsed));
}
///
public async IAsyncEnumerable WatchAsync(
string sourceId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Choreographer == null && GetOptions(sourceId).Mode == RobotMotionMode.Fixed)
{
yield break;
}
var interval = TimeSpan.FromMilliseconds(
Math.Max(20, m_options.UpdateIntervalMilliseconds));
while (!cancellationToken.IsCancellationRequested)
{
#if NET8_0_OR_GREATER
await Task.Delay(
interval,
m_timeProvider,
cancellationToken).ConfigureAwait(false);
#else
await Task.Delay(
interval,
cancellationToken).ConfigureAwait(false);
#endif
TimeSpan elapsed = m_timeProvider.GetUtcNow() - m_startedAt;
yield return CreateSample(sourceId, elapsed);
}
}
internal ThreeDFrame EvaluateLocalFrame(
string sourceId,
TimeSpan elapsed)
{
// The choreographer owns the pose whenever the provider is hosted: the two
// robots have to agree on where each other is, which independent parametric
// paths could never do. The options-driven path below stays for tests that
// exercise the provider on its own.
if (Choreographer != null)
{
foreach (RobotAgent agent in Choreographer.Robots)
{
if (!string.Equals(agent.Id, sourceId, StringComparison.Ordinal))
{
continue;
}
return new ThreeDFrame
{
CartesianCoordinates = new ThreeDCartesianCoordinates
{
X = agent.X,
Y = agent.Y,
Z = 0.0
},
Orientation = new ThreeDOrientation
{
A = 0.0,
B = 0.0,
C = agent.HeadingDegrees
}
};
}
}
RobotMotionOptions options = GetOptions(sourceId);
double period = Math.Max(0.1, options.PeriodSeconds);
double theta = 2.0 *
Math.PI *
(elapsed.TotalSeconds + options.PhaseSeconds) /
period;
double x = options.OriginX;
double y = options.OriginY;
double dx = 1.0;
double dy = 0.0;
switch (options.Mode)
{
case RobotMotionMode.FigureEight:
x += options.AmplitudeX * Math.Sin(theta);
y += options.AmplitudeY * Math.Sin(2.0 * theta);
dx = options.AmplitudeX * Math.Cos(theta);
dy = 2.0 * options.AmplitudeY * Math.Cos(2.0 * theta);
break;
case RobotMotionMode.Circle:
x += options.Radius * Math.Cos(theta);
y += options.Radius * Math.Sin(theta);
dx = -options.Radius * Math.Sin(theta);
dy = options.Radius * Math.Cos(theta);
break;
case RobotMotionMode.Shuttle:
x += options.ShuttleDistance * Math.Sin(theta);
dx = options.ShuttleDistance * Math.Cos(theta);
break;
case RobotMotionMode.Fixed:
break;
default:
throw new InvalidOperationException(
$"Unsupported robot motion mode '{options.Mode}'.");
}
double heading = options.HeadingFollowsPath &&
options.Mode != RobotMotionMode.Fixed
? Math.Atan2(dy, dx) * (180.0 / Math.PI)
: options.FixedHeadingDegrees;
return new ThreeDFrame
{
CartesianCoordinates = new ThreeDCartesianCoordinates
{
X = x,
Y = y,
Z = options.OriginZ
},
Orientation = new ThreeDOrientation
{
A = 0.0,
B = 0.0,
C = heading
}
};
}
private GeoLocationSample CreateSample(
string sourceId,
TimeSpan elapsed)
{
ThreeDFrame local = EvaluateLocalFrame(sourceId, elapsed);
S3DGeographicCoordinateDataType geographic = Scenario.Fit.LocalToGlobal(
local.CartesianCoordinates,
AngleUnit.Degrees);
bool hasElevation = (geographic.EncodingMask &
(uint)S3DGeographicCoordinateDataTypeFields.Elevation) != 0;
var position = new GeoPosition(
geographic.Latitude,
geographic.Longitude,
hasElevation ? geographic.Elevation : null,
Accuracy: 0.05,
EpsgCode: WGS84EpsgCode);
var orientation = new GeoOrientation(
local.Orientation.A,
local.Orientation.B,
local.Orientation.C);
return new GeoLocationSample(
position,
orientation,
default,
StatusCodes.Good,
new DateTimeUtc(m_timeProvider.GetUtcNow()),
sourceId);
}
private RobotMotionOptions GetOptions(string sourceId)
{
return sourceId switch
{
"R1" => m_options.R1,
"R2" => m_options.R2,
_ => throw new ArgumentException(
$"Unknown robot positioning source '{sourceId}'.",
nameof(sourceId))
};
}
}
}