mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:31:32 +08:00
c48c8b93dd
**Stack 3/5** — base #4124. Part of the #4096 split; see #4096 for the overall picture. > Review after #4123 and #4124. ## What this is The OPC 10000-210 Relative Spatial Location and OPC 10000-211 Global Positioning companion models, with their server and client libraries, plus the ISA-95 migration onto the shared geolocation seam. ## Why it is one change A deployment writes **one** provider implementation and both OPC 10000-211 and OPC 10030 are served from it. The specifications disagree on representation, not on content: - GPOS publishes the structured `GlobalLocationDataType` and validates the EPSG code against the configured CRS. - ISA-95 publishes the same sample as the `String` array that OPC 10030 §8.3.2.2 requires. Splitting them would mean two provider interfaces for one physical sensor, which is exactly what this avoids. The seam itself lives in `Opc.Ua.Server` and landed in 1/5. ## Contents - `Opc.Ua.Positioning`, `.Server` and `.Client`. - `Opc.Ua.ISA95.Server` moves onto `IGeoLocationProvider`; the three ISA-95-specific provider files are removed because the shared seam and its in-memory implementation replace them. - **Fixes a conformance gap**: the ISA-95 binder wrote a scalar where the type declares `ValueRank` OneOrMoreDimensions. It now writes `String[]`. - **Fixes a liveness bug**: a provider that cannot push is polled at the Variable's `MinimumSamplingInterval` rather than installed once, so a non-pushing provider no longer serves its start-up sample with `Good` status forever. - `samples/MinimalIsa95Server`. ## Validation `dotnet build UA.slnx` — 0 errors. | Suite | Result | | --- | --- | | Opc.Ua.Positioning.Tests | 69 / 0 | | Opc.Ua.ISA95.Tests | 136 / 0 | | Opc.Ua.Di.Tests | 304 / 0 | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
437 lines
18 KiB
C#
437 lines
18 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.Collections.Generic;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Gpos;
|
|
using static Opc.Ua.Positioning.Tests.Transforms.TransformTestHelpers;
|
|
|
|
namespace Opc.Ua.Positioning.Tests.Transforms
|
|
{
|
|
/// <summary>
|
|
/// Tests for <see cref="GroundControlPointFitter"/> covering the rigid,
|
|
/// similarity and affine modes with exact, noisy, inverse, reflection and
|
|
/// degenerate inputs, as well as the two/three dimensional adaptivity.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Positioning")]
|
|
public sealed class GroundControlPointFitterTests
|
|
{
|
|
private static readonly S3DGeographicCoordinateDataType s_origin = new()
|
|
{
|
|
EncodingMask = (uint)S3DGeographicCoordinateDataTypeFields.Elevation,
|
|
Longitude = 8.0,
|
|
Latitude = 47.0,
|
|
Elevation = 400.0
|
|
};
|
|
|
|
private static readonly double[][] s_localCloud =
|
|
[
|
|
[0.0, 0.0, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[0.0, 0.0, 10.0],
|
|
[5.0, 5.0, 2.0],
|
|
[3.0, 4.0, 8.0]
|
|
];
|
|
|
|
private static readonly double[][] s_planarCloud =
|
|
[
|
|
[0.0, 0.0, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[10.0, 10.0, 0.0],
|
|
[5.0, 3.0, 0.0],
|
|
[3.0, 7.0, 0.0]
|
|
];
|
|
|
|
private static LocalTangentPlane Plane()
|
|
{
|
|
return new(s_origin, AngleUnit.Degrees);
|
|
}
|
|
|
|
private static double[,] Rotation(double a, double b, double c)
|
|
{
|
|
ArrayOf<double> r = RslFrameTransform
|
|
.FromComponents(Orientation(a, b, c), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees)
|
|
.RotationMatrix;
|
|
return new double[,]
|
|
{
|
|
{ r[0], r[1], r[2] },
|
|
{ r[3], r[4], r[5] },
|
|
{ r[6], r[7], r[8] }
|
|
};
|
|
}
|
|
|
|
private static double[] ApplyLinear(double[,] a, double[] t, double[] p, double scale = 1.0)
|
|
{
|
|
return
|
|
[
|
|
(scale * ((a[0, 0] * p[0]) + (a[0, 1] * p[1]) + (a[0, 2] * p[2]))) + t[0],
|
|
(scale * ((a[1, 0] * p[0]) + (a[1, 1] * p[1]) + (a[1, 2] * p[2]))) + t[1],
|
|
(scale * ((a[2, 0] * p[0]) + (a[2, 1] * p[1]) + (a[2, 2] * p[2]))) + t[2]
|
|
];
|
|
}
|
|
|
|
private static double Determinant3(double[,] a)
|
|
{
|
|
return (a[0, 0] * ((a[1, 1] * a[2, 2]) - (a[1, 2] * a[2, 1]))) -
|
|
(a[0, 1] * ((a[1, 0] * a[2, 2]) - (a[1, 2] * a[2, 0]))) +
|
|
(a[0, 2] * ((a[1, 0] * a[2, 1]) - (a[1, 1] * a[2, 0])));
|
|
}
|
|
|
|
private static GroundControlPointDataType Gcp(double[] local, double[] enu, bool elevation)
|
|
{
|
|
LocalTangentPlane plane = Plane();
|
|
S3DGeographicCoordinateDataType geo = plane.EnuToGeographic(
|
|
Cartesian(enu[0], enu[1], enu[2]), AngleUnit.Degrees, elevation);
|
|
return new GroundControlPointDataType
|
|
{
|
|
LocalPosition = Cartesian(local[0], local[1], local[2]),
|
|
GlobalPosition = geo
|
|
};
|
|
}
|
|
|
|
private static ArrayOf<GroundControlPointDataType> BuildControlPoints(
|
|
double[,] linear, double[] translation, double scale, bool elevation)
|
|
{
|
|
var points = new List<GroundControlPointDataType>();
|
|
foreach (double[] local in s_localCloud)
|
|
{
|
|
double[] enu = ApplyLinear(linear, translation, local, scale);
|
|
points.Add(Gcp(local, enu, elevation));
|
|
}
|
|
return ArrayOf.Create(points.ToArray());
|
|
}
|
|
|
|
[Test]
|
|
public void RigidExactFitRecoversTransform()
|
|
{
|
|
double[,] rotation = Rotation(30.0, 10.0, 5.0);
|
|
double[] translation = [2.0, -3.0, 1.0];
|
|
ArrayOf<GroundControlPointDataType> gcps =
|
|
BuildControlPoints(rotation, translation, 1.0, elevation: true);
|
|
|
|
var fitter = new GroundControlPointFitter();
|
|
GroundControlPointFitResult result = fitter.Fit(gcps);
|
|
|
|
Assert.That(result.Mode, Is.EqualTo(GroundControlPointFitMode.Rigid));
|
|
Assert.That(result.Dimension, Is.EqualTo(3));
|
|
Assert.That(result.Rank, Is.EqualTo(3));
|
|
Assert.That(result.IsInvertible, Is.True);
|
|
Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6));
|
|
Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2));
|
|
|
|
AssertRoundTrips(gcps, result, 1e-2);
|
|
}
|
|
|
|
[Test]
|
|
public void SimilarityExactFitRecoversScale()
|
|
{
|
|
double[,] rotation = Rotation(-20.0, 15.0, 40.0);
|
|
double[] translation = [5.0, 6.0, -7.0];
|
|
const double scale = 1.7;
|
|
ArrayOf<GroundControlPointDataType> gcps =
|
|
BuildControlPoints(rotation, translation, scale, elevation: true);
|
|
|
|
var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Similarity };
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options);
|
|
|
|
Assert.That(result.Mode, Is.EqualTo(GroundControlPointFitMode.Similarity));
|
|
Assert.That(result.Dimension, Is.EqualTo(3));
|
|
Assert.That(result.Determinant, Is.EqualTo(scale * scale * scale).Within(1e-2));
|
|
Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2));
|
|
|
|
AssertRoundTrips(gcps, result, 1e-2);
|
|
}
|
|
|
|
[Test]
|
|
public void AffineExactFitRecoversTransform()
|
|
{
|
|
double[,] linear =
|
|
{
|
|
{ 1.2, 0.1, 0.0 },
|
|
{ 0.0, 0.9, 0.05 },
|
|
{ 0.02, 0.0, 1.1 }
|
|
};
|
|
double[] translation = [1.0, 2.0, 3.0];
|
|
ArrayOf<GroundControlPointDataType> gcps =
|
|
BuildControlPoints(linear, translation, 1.0, elevation: true);
|
|
|
|
var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine };
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options);
|
|
|
|
Assert.That(result.Mode, Is.EqualTo(GroundControlPointFitMode.Affine));
|
|
Assert.That(result.Dimension, Is.EqualTo(3));
|
|
Assert.That(result.Determinant, Is.EqualTo(Determinant3(linear)).Within(1e-3));
|
|
Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2));
|
|
|
|
AssertRoundTrips(gcps, result, 1e-2);
|
|
}
|
|
|
|
[Test]
|
|
public void NoisyRigidFitStaysProperAndBounded()
|
|
{
|
|
double[,] rotation = Rotation(12.0, -8.0, 25.0);
|
|
double[] translation = [1.0, 1.0, 1.0];
|
|
|
|
var points = new List<GroundControlPointDataType>();
|
|
double[] noise = [0.03, -0.04, 0.02, 0.05, -0.03, 0.01];
|
|
for (int i = 0; i < s_localCloud.Length; i++)
|
|
{
|
|
double[] enu = ApplyLinear(rotation, translation, s_localCloud[i]);
|
|
enu[0] += noise[i];
|
|
enu[1] -= noise[i];
|
|
enu[2] += noise[i] * 0.5;
|
|
points.Add(Gcp(s_localCloud[i], enu, elevation: true));
|
|
}
|
|
var gcps = ArrayOf.Create(points.ToArray());
|
|
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps);
|
|
|
|
Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6));
|
|
Assert.That(result.RootMeanSquareError, Is.GreaterThan(0.0));
|
|
Assert.That(result.RootMeanSquareError, Is.LessThan(0.2));
|
|
Assert.That(result.MaxResidual, Is.LessThan(0.5));
|
|
}
|
|
|
|
[Test]
|
|
public void RigidFitOfMirroredInputReturnsProperRotation()
|
|
{
|
|
// A reflection (det -1) applied in ENU: a rigid fit must return the
|
|
// closest proper rotation, not the improper one.
|
|
double[,] reflection =
|
|
{
|
|
{ 1.0, 0.0, 0.0 },
|
|
{ 0.0, 1.0, 0.0 },
|
|
{ 0.0, 0.0, -1.0 }
|
|
};
|
|
double[] translation = [0.0, 0.0, 0.0];
|
|
ArrayOf<GroundControlPointDataType> gcps =
|
|
BuildControlPoints(reflection, translation, 1.0, elevation: true);
|
|
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps);
|
|
|
|
// Proper rotation regardless of the mirrored data.
|
|
Assert.That(result.Determinant, Is.GreaterThan(0.0));
|
|
Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6));
|
|
// The reflection cannot be represented, so the residual is large.
|
|
Assert.That(result.RootMeanSquareError, Is.GreaterThan(1.0));
|
|
}
|
|
|
|
[Test]
|
|
public void AffineRejectsReflectionByDefault()
|
|
{
|
|
double[,] reflection =
|
|
{
|
|
{ 1.1, 0.0, 0.0 },
|
|
{ 0.0, 1.0, 0.0 },
|
|
{ 0.0, 0.0, -1.2 }
|
|
};
|
|
double[] translation = [0.0, 0.0, 0.0];
|
|
ArrayOf<GroundControlPointDataType> gcps =
|
|
BuildControlPoints(reflection, translation, 1.0, elevation: true);
|
|
|
|
var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine };
|
|
Assert.That(() => new GroundControlPointFitter().Fit(gcps, options),
|
|
Throws.ArgumentException);
|
|
}
|
|
|
|
[Test]
|
|
public void AffineAllowsReflectionWhenRequested()
|
|
{
|
|
double[,] reflection =
|
|
{
|
|
{ 1.1, 0.0, 0.0 },
|
|
{ 0.0, 1.0, 0.0 },
|
|
{ 0.0, 0.0, -1.2 }
|
|
};
|
|
double[] translation = [0.0, 0.0, 0.0];
|
|
ArrayOf<GroundControlPointDataType> gcps =
|
|
BuildControlPoints(reflection, translation, 1.0, elevation: true);
|
|
|
|
var options = new GroundControlPointFitOptions
|
|
{
|
|
Mode = GroundControlPointFitMode.Affine,
|
|
AllowReflection = true
|
|
};
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options);
|
|
|
|
Assert.That(result.Determinant, Is.LessThan(0.0));
|
|
Assert.That(result.Determinant, Is.EqualTo(Determinant3(reflection)).Within(1e-3));
|
|
Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2));
|
|
AssertRoundTrips(gcps, result, 1e-2);
|
|
}
|
|
|
|
[Test]
|
|
public void HorizontalFitWhenElevationMissing()
|
|
{
|
|
double[,] rotation = Rotation(0.0, 0.0, 35.0);
|
|
double[] translation = [4.0, -5.0, 0.0];
|
|
|
|
var points = new List<GroundControlPointDataType>();
|
|
foreach (double[] local in s_planarCloud)
|
|
{
|
|
double[] planar = [local[0], local[1], 0.0];
|
|
double[] enu = ApplyLinear(rotation, translation, planar);
|
|
enu[2] = 0.0;
|
|
points.Add(Gcp(planar, enu, elevation: false));
|
|
}
|
|
var gcps = ArrayOf.Create(points.ToArray());
|
|
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps);
|
|
|
|
Assert.That(result.Dimension, Is.EqualTo(2));
|
|
Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6));
|
|
|
|
// The forward mapping produces no elevation, the inverse produces a
|
|
// zero Z (no fabricated elevation).
|
|
S3DGeographicCoordinateDataType global =
|
|
result.LocalToGlobal(Cartesian(2.0, 3.0, 99.0), AngleUnit.Degrees);
|
|
Assert.That(GeographicCoordinates.HasElevation(global), Is.False);
|
|
|
|
ThreeDCartesianCoordinates back = result.GlobalToLocal(global, AngleUnit.Degrees);
|
|
Assert.That(back.Z, Is.Zero.Within(1e-9));
|
|
}
|
|
|
|
[Test]
|
|
public void AffineWithCoplanarThreeDPointsFallsBackToHorizontal()
|
|
{
|
|
// Points carry elevation but are coplanar in the local frame (z=0),
|
|
// so a full 3D affine fit is not possible and the fit drops to 2D.
|
|
double[,] linear =
|
|
{
|
|
{ 1.1, 0.05, 0.0 },
|
|
{ 0.0, 0.95, 0.0 },
|
|
{ 0.0, 0.0, 1.0 }
|
|
};
|
|
double[] translation = [1.0, 1.0, 5.0];
|
|
|
|
var points = new List<GroundControlPointDataType>();
|
|
foreach (double[] local in s_planarCloud)
|
|
{
|
|
double[] planar = [local[0], local[1], 0.0];
|
|
double[] enu = ApplyLinear(linear, translation, planar);
|
|
points.Add(Gcp(planar, enu, elevation: true));
|
|
}
|
|
var gcps = ArrayOf.Create(points.ToArray());
|
|
|
|
var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine };
|
|
GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options);
|
|
|
|
Assert.That(result.Dimension, Is.EqualTo(2));
|
|
}
|
|
|
|
[Test]
|
|
public void RejectsNullControlPoints()
|
|
{
|
|
Assert.That(() => new GroundControlPointFitter().Fit(ArrayOf<GroundControlPointDataType>.Null),
|
|
Throws.ArgumentException);
|
|
}
|
|
|
|
[Test]
|
|
public void RejectsInsufficientControlPoints()
|
|
{
|
|
var single = new GroundControlPointDataType
|
|
{
|
|
LocalPosition = Cartesian(0.0, 0.0, 0.0),
|
|
GlobalPosition = s_origin
|
|
};
|
|
var gcps = ArrayOf.Create([single]);
|
|
|
|
Assert.That(() => new GroundControlPointFitter().Fit(gcps), Throws.ArgumentException);
|
|
}
|
|
|
|
[Test]
|
|
public void RejectsDuplicateLocalPositions()
|
|
{
|
|
GroundControlPointDataType a = Gcp([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], elevation: true);
|
|
GroundControlPointDataType b = Gcp([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], elevation: true);
|
|
GroundControlPointDataType c = Gcp([9.0, 8.0, 7.0], [7.0, 8.0, 9.0], elevation: true);
|
|
var gcps = ArrayOf.Create([a, b, c]);
|
|
|
|
Assert.That(() => new GroundControlPointFitter().Fit(gcps), Throws.ArgumentException);
|
|
}
|
|
|
|
[Test]
|
|
public void RejectsNonFiniteLocalPosition()
|
|
{
|
|
var a = new GroundControlPointDataType
|
|
{
|
|
LocalPosition = Cartesian(double.NaN, 0.0, 0.0),
|
|
GlobalPosition = s_origin
|
|
};
|
|
GroundControlPointDataType b = Gcp([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], elevation: true);
|
|
GroundControlPointDataType c = Gcp([0.0, 1.0, 0.0], [0.0, 1.0, 0.0], elevation: true);
|
|
var gcps = ArrayOf.Create([a, b, c]);
|
|
|
|
Assert.That(() => new GroundControlPointFitter().Fit(gcps), Throws.ArgumentException);
|
|
}
|
|
|
|
[Test]
|
|
public void RejectsRankDeficientAffineInput()
|
|
{
|
|
// Collinear points (rank one) cannot support an affine fit.
|
|
var points = new List<GroundControlPointDataType>();
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
double[] local = [i, 0.0, 0.0];
|
|
double[] enu = [2.0 * i, 0.0, 0.0];
|
|
points.Add(Gcp(local, enu, elevation: false));
|
|
}
|
|
var gcps = ArrayOf.Create(points.ToArray());
|
|
|
|
var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine };
|
|
Assert.That(() => new GroundControlPointFitter().Fit(gcps, options), Throws.ArgumentException);
|
|
}
|
|
|
|
private static void AssertRoundTrips(
|
|
ArrayOf<GroundControlPointDataType> gcps, GroundControlPointFitResult result, double toleranceMeters)
|
|
{
|
|
for (int i = 0; i < gcps.Count; i++)
|
|
{
|
|
GroundControlPointDataType gcp = gcps[i];
|
|
S3DGeographicCoordinateDataType predicted =
|
|
result.LocalToGlobal(gcp.LocalPosition, AngleUnit.Degrees);
|
|
Assert.That(
|
|
GeographicDistanceMeters(predicted, gcp.GlobalPosition),
|
|
Is.LessThan(toleranceMeters),
|
|
$"forward mapping mismatch at index {i}");
|
|
|
|
ThreeDCartesianCoordinates back =
|
|
result.GlobalToLocal(gcp.GlobalPosition, AngleUnit.Degrees);
|
|
Assert.That(
|
|
Distance(back, gcp.LocalPosition),
|
|
Is.LessThan(toleranceMeters),
|
|
$"inverse mapping mismatch at index {i}");
|
|
}
|
|
}
|
|
}
|
|
}
|