fix: preserve baseline OPC UA aggregate algorithms under SDK 2.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
|
||||
<ItemGroup><PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.182" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project>
|
||||
<!-- Deliberately isolated baseline process: never inherit or load the candidate SDK. -->
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><TreatWarningsAsErrors>true</TreatWarningsAsErrors><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
if (args.Length != 1) throw new ArgumentException("Pass a fresh output JSON path.");
|
||||
if (File.Exists(args[0])) throw new IOException("Refusing to overwrite existing baseline evidence.");
|
||||
var epoch = new DateTime(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var offsets = new double[] { -10, 0, 5, 15, 25, 35, 50, 80 };
|
||||
var data = new Dictionary<string, Sample[]>
|
||||
{
|
||||
["double"] = offsets.Select((s, i) => new Sample(s, "Double", (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), StatusCodes.Good)).ToArray(),
|
||||
["uncertain"] = offsets.Select((s, i) => new Sample(s, i == 4 ? "Null" : "Double", i == 4 ? null : i == 2 ? "100" : (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), i == 2 ? StatusCodes.UncertainDataSubNormal : i == 4 ? StatusCodes.BadNoCommunication : StatusCodes.Good)).ToArray(),
|
||||
["integer"] = offsets.Select((s, i) => new Sample(s, "Int64", (i * 7 - 10).ToString(CultureInfo.InvariantCulture), i == 3 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
|
||||
["boolean"] = offsets.Select((s, i) => new Sample(s, "Boolean", (i % 2 == 0).ToString(), i == 2 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
|
||||
["sparse"] = [new(-10, "Double", "10", StatusCodes.Good), new(7, "Double", "20", StatusCodes.Good), new(18, "Null", null, StatusCodes.BadNoCommunication), new(45, "Double", "30", StatusCodes.Good)],
|
||||
["bad"] = offsets.Select(s => new Sample(s, "Null", null, StatusCodes.BadNoCommunication)).ToArray(),
|
||||
["empty"] = [],
|
||||
};
|
||||
var names = new string[]
|
||||
{
|
||||
"AggregateFunction_Interpolative",
|
||||
"AggregateFunction_Average",
|
||||
"AggregateFunction_TimeAverage",
|
||||
"AggregateFunction_TimeAverage2",
|
||||
"AggregateFunction_Total",
|
||||
"AggregateFunction_Total2",
|
||||
"AggregateFunction_Minimum",
|
||||
"AggregateFunction_Maximum",
|
||||
"AggregateFunction_MinimumActualTime",
|
||||
"AggregateFunction_MaximumActualTime",
|
||||
"AggregateFunction_Range",
|
||||
"AggregateFunction_Minimum2",
|
||||
"AggregateFunction_Maximum2",
|
||||
"AggregateFunction_MinimumActualTime2",
|
||||
"AggregateFunction_MaximumActualTime2",
|
||||
"AggregateFunction_Range2",
|
||||
"AggregateFunction_Count",
|
||||
"AggregateFunction_AnnotationCount",
|
||||
"AggregateFunction_DurationInStateZero",
|
||||
"AggregateFunction_DurationInStateNonZero",
|
||||
"AggregateFunction_NumberOfTransitions",
|
||||
"AggregateFunction_Start",
|
||||
"AggregateFunction_End",
|
||||
"AggregateFunction_Delta",
|
||||
"AggregateFunction_StartBound",
|
||||
"AggregateFunction_EndBound",
|
||||
"AggregateFunction_DeltaBounds",
|
||||
"AggregateFunction_DurationGood",
|
||||
"AggregateFunction_DurationBad",
|
||||
"AggregateFunction_PercentGood",
|
||||
"AggregateFunction_PercentBad",
|
||||
"AggregateFunction_WorstQuality",
|
||||
"AggregateFunction_WorstQuality2",
|
||||
"AggregateFunction_StandardDeviationPopulation",
|
||||
"AggregateFunction_VariancePopulation",
|
||||
"AggregateFunction_StandardDeviationSample",
|
||||
"AggregateFunction_VarianceSample",
|
||||
};
|
||||
var vectors = new List<Vector>();
|
||||
foreach (var name in names)
|
||||
foreach (var dataset in data)
|
||||
foreach (var reverse in new[] { false, true })
|
||||
foreach (var stepped in new[] { false, true })
|
||||
foreach (var uncertainAsBad in new[] { false, true })
|
||||
foreach (var incremental in new[] { false, true })
|
||||
{
|
||||
var id = (NodeId)typeof(ObjectIds).GetField(name)!.GetValue(null)!;
|
||||
var start = epoch.AddSeconds(reverse ? 70 : 0);
|
||||
var end = epoch.AddSeconds(reverse ? 0 : 70);
|
||||
var config = new AggregateConfiguration { TreatUncertainAsBad = uncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false };
|
||||
var calculator = Aggregators.CreateStandardCalculator(id, start, end, 20_000, stepped, config, telemetry);
|
||||
var output = new List<Value>();
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
var samples = dataset.Value.OrderBy(x => x.Seconds);
|
||||
foreach (var sample in reverse ? samples.Reverse() : samples)
|
||||
{
|
||||
var time = epoch.AddSeconds(sample.Seconds);
|
||||
calculator.QueueRawValue(new DataValue(new Variant(Payload(sample)!), sample.Status, time, time));
|
||||
if (incremental) Drain(false);
|
||||
}
|
||||
Drain(true);
|
||||
}
|
||||
catch (Exception ex) { error = ex.GetType().FullName; }
|
||||
vectors.Add(new(name, dataset.Key, reverse, stepped, uncertainAsBad, incremental, output.ToArray(), error));
|
||||
void Drain(bool partial)
|
||||
{
|
||||
for (var guard = 0; guard < 100; guard++)
|
||||
{
|
||||
var value = calculator.GetProcessedValue(partial);
|
||||
if (value is null) return;
|
||||
output.Add(new(partial, value.Value?.GetType().Name, Format(value.Value), value.StatusCode.Code, value.SourceTimestamp.Ticks, value.ServerTimestamp.Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
|
||||
}
|
||||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||||
}
|
||||
}
|
||||
var assembly = typeof(Aggregators).Assembly.Location;
|
||||
var baseline = new { sdk = "1.5.378.182", assemblySha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(assembly))), epochTicks = epoch.Ticks, intervalMilliseconds = 20_000, data, vectors };
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(args[0]))!);
|
||||
File.WriteAllText(args[0], JsonSerializer.Serialize(baseline, new JsonSerializerOptions { WriteIndented = true }) + "\n");
|
||||
Console.WriteLine($"Generated {vectors.Count} vectors for {names.Length} aggregate IDs; baseline assembly SHA256 {baseline.assemblySha256}.");
|
||||
|
||||
static object? Payload(Sample sample) => sample.Type switch
|
||||
{
|
||||
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Boolean" => bool.Parse(sample.Payload!),
|
||||
_ => null,
|
||||
};
|
||||
static string? Format(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
double number => number.ToString("R", CultureInfo.InvariantCulture),
|
||||
float number => number.ToString("R", CultureInfo.InvariantCulture),
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
record Sample(double Seconds, string Type, string? Payload, uint Status);
|
||||
record Value(bool PartialRequested, string? Type, string? Payload, uint Status, long SourceTicks, long ServerTicks, ushort SourcePicoseconds, ushort ServerPicoseconds);
|
||||
record Vector(string Aggregate, string DataSet, bool Reverse, bool Stepped, bool UncertainAsBad, bool Incremental, Value[] Output, string? Exception);
|
||||
+1579
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of an aggregate factory.
|
||||
/// </summary>
|
||||
internal delegate AggregateCalculator AggregatorFactory(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry);
|
||||
|
||||
/// <summary>
|
||||
/// The set of built-in aggregate factories.
|
||||
/// </summary>
|
||||
internal static class Aggregators
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores the mapping for a aggregate id to the calculator.
|
||||
/// </summary>
|
||||
private class FactoryMapping
|
||||
{
|
||||
public NodeId AggregateId { get; set; }
|
||||
|
||||
public QualifiedName AggregateName { get; set; }
|
||||
|
||||
public required Type Calculator { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mapping for all of the standard aggregates.
|
||||
/// </summary>
|
||||
private static readonly FactoryMapping[] s_mappings =
|
||||
[
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Interpolative,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Interpolative),
|
||||
Calculator = typeof(AggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Average,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Average),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_TimeAverage,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_TimeAverage),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_TimeAverage2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_TimeAverage2),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Total,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Total),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Total2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Total2),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Minimum,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Minimum),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Maximum,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Maximum),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MinimumActualTime,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MinimumActualTime),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MaximumActualTime,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MaximumActualTime),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Range,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Range),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Minimum2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Minimum2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Maximum2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Maximum2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MinimumActualTime2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MinimumActualTime2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MaximumActualTime2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MaximumActualTime2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Range2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Range2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Count,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Count),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_AnnotationCount,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_AnnotationCount),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationInStateZero,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationInStateZero),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationInStateNonZero,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationInStateNonZero),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_NumberOfTransitions,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_NumberOfTransitions),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Start,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Start),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_End,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_End),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Delta,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Delta),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_StartBound,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StartBound),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_EndBound,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_EndBound),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DeltaBounds,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DeltaBounds),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationGood,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationGood),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationBad,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationBad),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_PercentGood,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_PercentGood),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_PercentBad,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_PercentBad),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_WorstQuality,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_WorstQuality),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_WorstQuality2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_WorstQuality2),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_StandardDeviationPopulation,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StandardDeviationPopulation),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_VariancePopulation,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_VariancePopulation),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_StandardDeviationSample,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StandardDeviationSample),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_VarianceSample,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_VarianceSample),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
}
|
||||
];
|
||||
|
||||
internal static IEnumerable<NodeId> SupportedIds => s_mappings.Select(x => x.AggregateId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name for a standard aggregates.
|
||||
/// </summary>
|
||||
public static QualifiedName GetNameForStandardAggregate(NodeId aggregateId)
|
||||
{
|
||||
for (int ii = 0; ii < s_mappings.Length; ii++)
|
||||
{
|
||||
if (s_mappings[ii].AggregateId == aggregateId)
|
||||
{
|
||||
return s_mappings[ii].AggregateName;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the id for a standard aggregates.
|
||||
/// </summary>
|
||||
public static NodeId GetIdForStandardAggregate(QualifiedName aggregateName)
|
||||
{
|
||||
for (int ii = 0; ii < s_mappings.Length; ii++)
|
||||
{
|
||||
if (s_mappings[ii].AggregateName == aggregateName)
|
||||
{
|
||||
return s_mappings[ii].AggregateId;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calculator for one of the standard aggregates.
|
||||
/// </summary>
|
||||
[Obsolete("Use CreateStandardCalculator with telemetry context.")]
|
||||
public static AggregateCalculator? CreateStandardCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration)
|
||||
{
|
||||
return CreateStandardCalculator(
|
||||
aggregateId,
|
||||
startTime,
|
||||
endTime,
|
||||
processingInterval,
|
||||
stepped,
|
||||
configuration,
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calculator for one of the standard aggregates.
|
||||
/// </summary>
|
||||
public static AggregateCalculator? CreateStandardCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
{
|
||||
for (int ii = 0; ii < s_mappings.Length; ii++)
|
||||
{
|
||||
if (s_mappings[ii].AggregateId == aggregateId)
|
||||
{
|
||||
return (AggregateCalculator?)
|
||||
Activator.CreateInstance(
|
||||
s_mappings[ii].Calculator,
|
||||
aggregateId,
|
||||
startTime,
|
||||
endTime,
|
||||
processingInterval,
|
||||
stepped,
|
||||
configuration,
|
||||
telemetry);
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class AverageAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public AverageAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = aggregateId != ObjectIds.AggregateFunction_Average;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Average:
|
||||
return ComputeAverage(slice);
|
||||
case Objects.AggregateFunction_TimeAverage:
|
||||
return ComputeTimeAverage(slice, false, 1);
|
||||
case Objects.AggregateFunction_Total:
|
||||
return ComputeTimeAverage(slice, false, 2);
|
||||
case Objects.AggregateFunction_TimeAverage2:
|
||||
return ComputeTimeAverage(slice, true, 1);
|
||||
case Objects.AggregateFunction_Total2:
|
||||
return ComputeTimeAverage(slice, true, 2);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeAverage(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// calculate total and count.
|
||||
int count = 0;
|
||||
double total = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsGood(values[ii].StatusCode))
|
||||
{
|
||||
try
|
||||
{
|
||||
double sample = CastToDouble(values[ii]);
|
||||
total += sample;
|
||||
count++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore conversion errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for empty slice.
|
||||
if (count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = total / count;
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the StdDev, Variance, StdDev2 and Variance2 aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeTimeAverage(TimeSlice slice, bool useSimpleBounds, int valueType)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values;
|
||||
if (useSimpleBounds)
|
||||
{
|
||||
values = GetValuesWithSimpleBounds(slice);
|
||||
}
|
||||
else
|
||||
{
|
||||
values = GetValuesWithInterpolatedBounds(slice);
|
||||
}
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, !useSimpleBounds, Stepped);
|
||||
|
||||
double total = 0;
|
||||
double totalDuration = 0;
|
||||
bool nonGoodRegionsExists = false;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
double duration = regions[ii].Duration / 1000.0;
|
||||
|
||||
if (StatusCode.IsNotBad(regions[ii].StatusCode))
|
||||
{
|
||||
total += (regions[ii].StartValue + regions[ii].EndValue) * duration / 2;
|
||||
totalDuration += duration;
|
||||
}
|
||||
|
||||
if (StatusCode.IsNotGood(regions[ii].StatusCode))
|
||||
{
|
||||
nonGoodRegionsExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (totalDuration == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = 0;
|
||||
|
||||
switch (valueType)
|
||||
{
|
||||
case 1:
|
||||
result = total / totalDuration;
|
||||
break;
|
||||
case 2:
|
||||
result = total;
|
||||
break;
|
||||
default:
|
||||
Debug.Fail($"Unexpected value type {valueType}");
|
||||
break;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (useSimpleBounds)
|
||||
{
|
||||
value.StatusCode = GetTimeBasedStatusCode(regions, value.StatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
value.StatusCode = StatusCodes.Good;
|
||||
|
||||
if (nonGoodRegionsExists)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||||
|
||||
/// <summary>Adapts the versioned baseline algorithms to the SDK 2 calculator protocol without a second SDK runtime.</summary>
|
||||
internal static class CompatibilityAggregateFactory
|
||||
{
|
||||
internal static IAggregateCalculator Create(NodeId id, DateTimeUtc start, DateTimeUtc end, double interval,
|
||||
bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry) =>
|
||||
new Adapter(Aggregators.CreateStandardCalculator(id, (DateTime)start, (DateTime)end, interval, stepped, configuration, telemetry)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(id), "Unsupported baseline aggregate."));
|
||||
|
||||
/// <summary>Replaces exactly the existing standard factories; no aggregate IDs are added or removed.</summary>
|
||||
internal static async ValueTask RegisterAsync(AggregateManager manager, CancellationToken ct)
|
||||
{
|
||||
foreach (var id in Aggregators.SupportedIds)
|
||||
await manager.RegisterFactoryAsync(id, Aggregators.GetNameForStandardAggregate(id).Name!, Create, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class Adapter(AggregateCalculator calculator) : IAggregateCalculator
|
||||
{
|
||||
public NodeId AggregateId => calculator.AggregateId;
|
||||
public bool QueueRawValue(Opc.Ua.DataValue value) => calculator.QueueRawValue(LegacyDataValue.FromSdk(value));
|
||||
public bool HasEndTimePassed(DateTimeUtc currentTime) => calculator.HasEndTimePassed((DateTime)currentTime);
|
||||
public bool TryGetProcessedValue(bool returnPartial, out Opc.Ua.DataValue value)
|
||||
{
|
||||
var processed = calculator.GetProcessedValue(returnPartial);
|
||||
value = processed is null ? default : processed.ToSdk();
|
||||
return processed is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class CountAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public CountAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Count:
|
||||
return ComputeCount(slice);
|
||||
case Objects.AggregateFunction_AnnotationCount:
|
||||
return ComputeAnnotationCount(slice);
|
||||
case Objects.AggregateFunction_DurationInStateZero:
|
||||
return ComputeDurationInState(slice, false);
|
||||
case Objects.AggregateFunction_DurationInStateNonZero:
|
||||
return ComputeDurationInState(slice, true);
|
||||
case Objects.AggregateFunction_NumberOfTransitions:
|
||||
return ComputeNumberOfTransitions(slice);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Count aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeCount(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// count the values.
|
||||
int count = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsGood(values[ii].StatusCode))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(count),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
|
||||
|
||||
if (!StatusCode.IsBad(value.StatusCode))
|
||||
{
|
||||
// set aggregate bits fon non Bad values
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
}
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the AnnotationCount aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeAnnotationCount(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// count the values.
|
||||
int count = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(count),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the DurationInStateZero and DurationInStateNonZero aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDurationInState(TimeSlice slice, bool isNonZero)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
double duration = 0;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsNotGood(regions[ii].StatusCode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNonZero)
|
||||
{
|
||||
if (regions[ii].StartValue != 0)
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
else if (regions[ii].StartValue == 0)
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(duration),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = GetTimeBasedStatusCode(regions, value.StatusCode);
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Count aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeNumberOfTransitions(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// The first non-bad value is a transition when no previous non-bad value exists.
|
||||
LinkedListNode<DataValue>? previousValue = slice.NonBadEarlyBound;
|
||||
bool hasLastValue = previousValue != null;
|
||||
object? lastValue = previousValue?.Value.Value;
|
||||
|
||||
// count the transitions.
|
||||
int count = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsBad(values[ii].StatusCode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? nextValue = values[ii].Value;
|
||||
|
||||
if (!hasLastValue || !Utils.IsEqual(lastValue, nextValue))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
hasLastValue = true;
|
||||
lastValue = nextValue;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(count),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||||
|
||||
/// <summary>Private mutable work value preserving the baseline calculator's ownership and timestamp semantics.</summary>
|
||||
internal sealed class LegacyDataValue
|
||||
{
|
||||
internal LegacyDataValue() { }
|
||||
internal LegacyDataValue(LegacyDataValue other)
|
||||
{
|
||||
WrappedValue = other.WrappedValue;
|
||||
StatusCode = other.StatusCode;
|
||||
SourceTimestamp = other.SourceTimestamp;
|
||||
ServerTimestamp = other.ServerTimestamp;
|
||||
SourcePicoseconds = other.SourcePicoseconds;
|
||||
ServerPicoseconds = other.ServerPicoseconds;
|
||||
}
|
||||
internal LegacyDataValue(Variant value, StatusCode status, DateTime source, DateTime server)
|
||||
{
|
||||
WrappedValue = value; StatusCode = status; SourceTimestamp = source; ServerTimestamp = server;
|
||||
}
|
||||
internal Variant WrappedValue { get; set; }
|
||||
internal object? Value { get => SdkValueBoundary.ToClr(WrappedValue); set => WrappedValue = SdkValueBoundary.FromClr(value); }
|
||||
internal StatusCode StatusCode { get; set; } = StatusCodes.Good;
|
||||
internal DateTime SourceTimestamp { get; set; }
|
||||
internal DateTime ServerTimestamp { get; set; }
|
||||
internal ushort SourcePicoseconds { get; set; }
|
||||
internal ushort ServerPicoseconds { get; set; }
|
||||
internal static LegacyDataValue FromSdk(Opc.Ua.DataValue value) => new()
|
||||
{
|
||||
WrappedValue = value.WrappedValue, StatusCode = value.StatusCode,
|
||||
SourceTimestamp = (DateTime)value.SourceTimestamp, ServerTimestamp = (DateTime)value.ServerTimestamp,
|
||||
SourcePicoseconds = value.SourcePicoseconds, ServerPicoseconds = value.ServerPicoseconds,
|
||||
};
|
||||
internal Opc.Ua.DataValue ToSdk() => new(WrappedValue, StatusCode, SourceTimestamp, ServerTimestamp, SourcePicoseconds, ServerPicoseconds);
|
||||
}
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class MinMaxAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public MinMaxAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Minimum:
|
||||
return ComputeMinMax(slice, 1, false);
|
||||
case Objects.AggregateFunction_MinimumActualTime:
|
||||
return ComputeMinMax(slice, 1, true);
|
||||
case Objects.AggregateFunction_Maximum:
|
||||
return ComputeMinMax(slice, 2, false);
|
||||
case Objects.AggregateFunction_MaximumActualTime:
|
||||
return ComputeMinMax(slice, 2, true);
|
||||
case Objects.AggregateFunction_Range:
|
||||
return ComputeMinMax(slice, 3, false);
|
||||
case Objects.AggregateFunction_Minimum2:
|
||||
return ComputeMinMax2(slice, 1, false);
|
||||
case Objects.AggregateFunction_MinimumActualTime2:
|
||||
return ComputeMinMax2(slice, 1, true);
|
||||
case Objects.AggregateFunction_Maximum2:
|
||||
return ComputeMinMax2(slice, 2, false);
|
||||
case Objects.AggregateFunction_MaximumActualTime2:
|
||||
return ComputeMinMax2(slice, 2, true);
|
||||
case Objects.AggregateFunction_Range2:
|
||||
return ComputeMinMax2(slice, 3, false);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Minimum, Maximum, MinimumActualTime and MaximumActualTime aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeMinMax(TimeSlice slice, int valueType, bool returnActualTime)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double minimumGoodValue = double.MaxValue;
|
||||
double minimumUncertainValue = double.MaxValue;
|
||||
double maximumGoodValue = double.MinValue;
|
||||
double maximumUncertainValue = double.MinValue;
|
||||
|
||||
DateTime minimumGoodTimestamp = DateTime.MinValue;
|
||||
DateTime maximumGoodTimestamp = DateTime.MinValue;
|
||||
|
||||
TypeInfo? minimumOriginalType = null;
|
||||
TypeInfo? maximumOriginalType = null;
|
||||
|
||||
bool badValuesExist = false;
|
||||
bool duplicatesMinimumsExist = false;
|
||||
bool duplicatesMaximumsExist = false;
|
||||
bool goodValueExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
DateTime currentTime = values[ii].SourceTimestamp;
|
||||
StatusCode currentStatus = values[ii].StatusCode;
|
||||
|
||||
// ignore bad values.
|
||||
if (!IsGood(values[ii]))
|
||||
{
|
||||
badValuesExist = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
double currentValue;
|
||||
// convert to double.
|
||||
try
|
||||
{
|
||||
currentValue = CastToDouble(values[ii]);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
badValuesExist = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// check for uncertain.
|
||||
if (StatusCode.IsUncertain(currentStatus))
|
||||
{
|
||||
if (minimumUncertainValue > currentValue)
|
||||
{
|
||||
minimumUncertainValue = currentValue;
|
||||
}
|
||||
|
||||
if (maximumUncertainValue < currentValue)
|
||||
{
|
||||
maximumUncertainValue = currentValue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// check for new minimum.
|
||||
if (minimumGoodValue > currentValue)
|
||||
{
|
||||
minimumGoodValue = currentValue;
|
||||
minimumGoodTimestamp = currentTime;
|
||||
minimumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMinimumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate minimums.
|
||||
else if (minimumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMinimumsExist = true;
|
||||
}
|
||||
|
||||
// check for new maximum.
|
||||
if (maximumGoodValue < currentValue)
|
||||
{
|
||||
maximumGoodValue = currentValue;
|
||||
maximumGoodTimestamp = currentTime;
|
||||
maximumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMaximumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate maximums.
|
||||
else if (maximumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMaximumsExist = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if at least one good value exists.
|
||||
if (!goodValueExists)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// set the status code.
|
||||
StatusCode statusCode = StatusCodes.Good;
|
||||
|
||||
// uncertain if any bad values exist.
|
||||
if (badValuesExist)
|
||||
{
|
||||
statusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
// determine the calculated value to return.
|
||||
object? processedValue = null;
|
||||
TypeInfo? processedType = null;
|
||||
DateTime processedTimestamp = DateTime.MinValue;
|
||||
bool duplicatesExist = false;
|
||||
|
||||
if (valueType == 1)
|
||||
{
|
||||
processedValue = minimumGoodValue;
|
||||
processedTimestamp = minimumGoodTimestamp;
|
||||
processedType = minimumOriginalType;
|
||||
_ = minimumGoodValue > minimumUncertainValue;
|
||||
duplicatesExist = duplicatesMinimumsExist;
|
||||
}
|
||||
else if (valueType == 2)
|
||||
{
|
||||
processedValue = maximumGoodValue;
|
||||
processedTimestamp = maximumGoodTimestamp;
|
||||
processedType = maximumOriginalType;
|
||||
_ = maximumGoodValue < maximumUncertainValue;
|
||||
duplicatesExist = duplicatesMaximumsExist;
|
||||
}
|
||||
else if (valueType == 3)
|
||||
{
|
||||
processedValue = Math.Abs(maximumGoodValue - minimumGoodValue);
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
_ = maximumGoodValue < maximumUncertainValue ||
|
||||
minimumGoodValue > minimumUncertainValue;
|
||||
}
|
||||
|
||||
// set calculated if not returning actual time and value is not at the start time.
|
||||
if (!returnActualTime && processedTimestamp != GetTimestamp(slice))
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
}
|
||||
|
||||
// set the multiple values flags.
|
||||
if (duplicatesExist)
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(
|
||||
statusCode.AggregateBits | AggregateBits.MultipleValues);
|
||||
}
|
||||
|
||||
// convert back to original datatype.
|
||||
if (processedType != null && processedType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
processedValue = LegacyCast(
|
||||
processedValue,
|
||||
TypeInfo.Scalars.Double,
|
||||
processedType.Value.BuiltInType);
|
||||
}
|
||||
else
|
||||
{
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
}
|
||||
|
||||
// create processed value.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(processedValue),
|
||||
StatusCode = statusCode
|
||||
};
|
||||
|
||||
if (returnActualTime)
|
||||
{
|
||||
value.SourceTimestamp = processedTimestamp;
|
||||
value.ServerTimestamp = processedTimestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.SourceTimestamp = GetTimestamp(slice);
|
||||
value.ServerTimestamp = GetTimestamp(slice);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Minimum2, Maximum2, MinimumActualTime2, MaximumActualTime2 and Range2 aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeMinMax2(TimeSlice slice, int valueType, bool returnActualTime)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double minimumGoodValue = double.MaxValue;
|
||||
double maximumGoodValue = double.MinValue;
|
||||
|
||||
DateTime minimumGoodTimestamp = DateTime.MinValue;
|
||||
DateTime maximumGoodTimestamp = DateTime.MinValue;
|
||||
|
||||
StatusCode minimumGoodStatusCode = StatusCodes.Good;
|
||||
StatusCode maximumGoodStatusCode = StatusCodes.Good;
|
||||
|
||||
TypeInfo? minimumOriginalType = null;
|
||||
TypeInfo? maximumOriginalType = null;
|
||||
|
||||
bool duplicatesMinimumsExist = false;
|
||||
bool duplicatesMaximumsExist = false;
|
||||
bool goodValueExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
DateTime currentTime = values[ii].SourceTimestamp;
|
||||
StatusCode currentStatus = values[ii].StatusCode;
|
||||
|
||||
// ignore bad values (as determined by the TreatUncertainAsBad parameter).
|
||||
if (!IsGood(values[ii]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double currentValue;
|
||||
// convert to double.
|
||||
try
|
||||
{
|
||||
currentValue = CastToDouble(values[ii]);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip endpoint if stepped.
|
||||
if (currentTime == slice.EndTime && Stepped)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// check for new minimum.
|
||||
if (minimumGoodValue > currentValue)
|
||||
{
|
||||
minimumGoodValue = currentValue;
|
||||
minimumGoodTimestamp = currentTime;
|
||||
minimumGoodStatusCode = currentStatus;
|
||||
minimumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMinimumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate minimums.
|
||||
else if (minimumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMinimumsExist = true;
|
||||
}
|
||||
|
||||
// check for new maximum.
|
||||
if (maximumGoodValue < currentValue)
|
||||
{
|
||||
maximumGoodValue = currentValue;
|
||||
maximumGoodTimestamp = currentTime;
|
||||
maximumGoodStatusCode = currentStatus;
|
||||
maximumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMaximumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate maximums.
|
||||
else if (maximumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMaximumsExist = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if at least one good value exists.
|
||||
if (!goodValueExists)
|
||||
{
|
||||
DataValue noDataValue = GetNoDataValue(slice);
|
||||
// check if interval is partial and set the flag accordingly
|
||||
if (slice.Partial)
|
||||
{
|
||||
noDataValue.StatusCode = noDataValue.StatusCode
|
||||
.WithAggregateBits(AggregateBits.Partial);
|
||||
}
|
||||
return noDataValue;
|
||||
}
|
||||
|
||||
// determine the calculated value to return.
|
||||
object? processedValue = null;
|
||||
TypeInfo? processedType = null;
|
||||
DateTime processedTimestamp = DateTime.MinValue;
|
||||
StatusCode processedStatusCode = StatusCodes.Good;
|
||||
bool duplicatesExist = false;
|
||||
|
||||
if (valueType == 1)
|
||||
{
|
||||
processedValue = minimumGoodValue;
|
||||
processedTimestamp = minimumGoodTimestamp;
|
||||
processedStatusCode = minimumGoodStatusCode;
|
||||
processedType = minimumOriginalType;
|
||||
duplicatesExist = duplicatesMinimumsExist;
|
||||
}
|
||||
else if (valueType == 2)
|
||||
{
|
||||
processedValue = maximumGoodValue;
|
||||
processedTimestamp = maximumGoodTimestamp;
|
||||
processedStatusCode = maximumGoodStatusCode;
|
||||
processedType = maximumOriginalType;
|
||||
duplicatesExist = duplicatesMaximumsExist;
|
||||
}
|
||||
else if (valueType == 3)
|
||||
{
|
||||
processedValue = Math.Abs(maximumGoodValue - minimumGoodValue);
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
}
|
||||
|
||||
// set the status code.
|
||||
StatusCode statusCode = processedStatusCode;
|
||||
|
||||
// set calculated if not returning actual time and value is not at the start time.
|
||||
if (!returnActualTime &&
|
||||
processedTimestamp != GetTimestamp(slice) &&
|
||||
(statusCode.AggregateBits & AggregateBits.Interpolated) == 0)
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(
|
||||
statusCode.AggregateBits | AggregateBits.Calculated);
|
||||
}
|
||||
|
||||
// set the multiple values flags.
|
||||
if (duplicatesExist)
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(
|
||||
statusCode.AggregateBits | AggregateBits.MultipleValues);
|
||||
}
|
||||
|
||||
// convert back to original datatype.
|
||||
if (processedType != null && processedType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
processedValue = LegacyCast(
|
||||
processedValue,
|
||||
TypeInfo.Scalars.Double,
|
||||
processedType.Value.BuiltInType);
|
||||
}
|
||||
else
|
||||
{
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
}
|
||||
|
||||
// create processed value.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(processedValue),
|
||||
StatusCode = GetTimeBasedStatusCode(slice, values, statusCode)
|
||||
};
|
||||
|
||||
// zero value if status is bad.
|
||||
if (StatusCode.IsBad(value.StatusCode))
|
||||
{
|
||||
value.WrappedValue = Variant.Null;
|
||||
}
|
||||
|
||||
if (returnActualTime)
|
||||
{
|
||||
// calculate effective time if end bound is used.
|
||||
if (TimeFlowsBackward)
|
||||
{
|
||||
if (processedTimestamp == slice.StartTime)
|
||||
{
|
||||
processedTimestamp = processedTimestamp.AddMilliseconds(+1);
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(
|
||||
value.StatusCode.AggregateBits | AggregateBits.Interpolated);
|
||||
}
|
||||
}
|
||||
else if (processedTimestamp == slice.EndTime)
|
||||
{
|
||||
processedTimestamp = processedTimestamp.AddMilliseconds(-1);
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(
|
||||
value.StatusCode.AggregateBits | AggregateBits.Interpolated);
|
||||
}
|
||||
|
||||
value.SourceTimestamp = processedTimestamp;
|
||||
value.ServerTimestamp = processedTimestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.SourceTimestamp = GetTimestamp(slice);
|
||||
value.ServerTimestamp = GetTimestamp(slice);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Preserved aggregate calculations
|
||||
|
||||
The SDK 2.0 upgrade keeps the application's existing historian and live AggregateFilter results (owner decision, 2026-11-10, #782). SDK 2.0.1 changes more than the default configuration: uncertain-sample selection, value-based quality, reverse data edges, partial results and interpolation differ. Changing TreatUncertainAsBad alone does not preserve the contract.
|
||||
|
||||
The eight upstream algorithm/factory files here originate at OPCFoundation/UA-.NETStandard tag `1.5.378.182`. Original MIT license headers remain. `baseline-sources.json` records each original URL and SHA-256. They are application-private source, not an old SDK runtime or migration shim.
|
||||
|
||||
Representation adaptations:
|
||||
|
||||
- Private `LegacyDataValue` retains mutable intermediate work values and CLR DateTime timestamps; the adapter accepts/emits immutable SDK 2 values, including status and picoseconds.
|
||||
- Nullable reference annotations describe the existing optional bounds/slices; no warning policy is disabled.
|
||||
- Immutable StatusCode methods replace the old setters. NodeId extraction and QualifiedName construction use supported typed APIs.
|
||||
- Removed TypeInfo.Cast/Variant(object, TypeInfo) operations use the SDK 2 typed conversion surface; baseline vectors verify value **types**, values, timestamps and all status bits.
|
||||
- Interval, quality, sample selection, queue trimming, interpolation and partial-result algorithms remain the baseline algorithms. Factory registration replaces the existing 37 IDs through the public SDK API; it must not add capabilities or duplicate advertised references.
|
||||
|
||||
The isolated `scripts/validation/opcua-aggregate-baseline` process pins only SDK 1.5.378.182 and generates the checked-in golden fixture. It is deliberately outside the solution and does not inherit the candidate package graph. The golden file records the baseline assembly hash, shared inputs and 4,144 outputs/cases across all 37 aggregate IDs, seven datasets, forward/reverse, stepped/sloped interpolation, uncertain-as-bad flags and incremental/partial processing. No baseline binary is loaded by the candidate tests or production host.
|
||||
|
||||
Run the generator with a **new** output path, then compare it with `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Fixtures/opcua-1.5.378.182-aggregates.json`. It refuses to overwrite evidence. Never regenerate expectations from the compatibility implementation. Existing historian parity tests retain their application-specific documented divergences from the baseline SDK, while new compatibility tests check the complete old-calculator output.
|
||||
|
||||
This preserves older behavior deliberately; it does not claim that all older SDK algorithms implement the newest OPC UA interpretation. A future semantic migration needs its own consumer-impact decision and versioned tests.
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class StartEndAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public StartEndAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Start:
|
||||
return ComputeStartEnd(slice, false);
|
||||
case Objects.AggregateFunction_End:
|
||||
return ComputeStartEnd(slice, true);
|
||||
case Objects.AggregateFunction_Delta:
|
||||
return ComputeDelta(slice);
|
||||
case Objects.AggregateFunction_StartBound:
|
||||
return ComputeStartEnd2(slice, false);
|
||||
case Objects.AggregateFunction_EndBound:
|
||||
return ComputeStartEnd2(slice, true);
|
||||
case Objects.AggregateFunction_DeltaBounds:
|
||||
return ComputeDelta2(slice);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Start and End aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeStartEnd(TimeSlice slice, bool returnEnd)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// return start value.
|
||||
if (!returnEnd)
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return values[^1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Delta aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDelta(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double startValue = double.NaN;
|
||||
TypeInfo? originalType = null;
|
||||
bool badDataSkipped = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
// find start value.
|
||||
DataValue start = values[ii];
|
||||
|
||||
if (IsGood(start))
|
||||
{
|
||||
try
|
||||
{
|
||||
startValue = CastToDouble(start);
|
||||
originalType = start.WrappedValue.TypeInfo;
|
||||
break;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
startValue = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
badDataSkipped = true;
|
||||
}
|
||||
|
||||
double endValue = double.NaN;
|
||||
|
||||
for (int ii = values.Count - 1; ii >= 0; ii--)
|
||||
{
|
||||
// find end value.
|
||||
DataValue end = values[ii];
|
||||
|
||||
if (IsGood(end))
|
||||
{
|
||||
try
|
||||
{
|
||||
endValue = CastToDouble(end);
|
||||
break;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
endValue = double.NaN;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
badDataSkipped = true;
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (double.IsNaN(startValue) || double.IsNaN(endValue))
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
var value = new DataValue
|
||||
{
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
// set status code.
|
||||
if (badDataSkipped)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// calculate delta.
|
||||
double delta = endValue - startValue;
|
||||
|
||||
if (originalType != null && originalType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
object delta2 = LegacyCast(
|
||||
delta,
|
||||
TypeInfo.Scalars.Double,
|
||||
originalType.Value.BuiltInType);
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta2);
|
||||
}
|
||||
else
|
||||
{
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta);
|
||||
}
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Start2 and End2 aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeStartEnd2(TimeSlice slice, bool returnEnd)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
DataValue value;
|
||||
|
||||
// return start bound.
|
||||
if ((!returnEnd && !TimeFlowsBackward) || (returnEnd && TimeFlowsBackward))
|
||||
{
|
||||
value = values[0];
|
||||
}
|
||||
// return end bound.
|
||||
else
|
||||
{
|
||||
value = values[^1];
|
||||
}
|
||||
|
||||
if (!IsGood(value))
|
||||
{
|
||||
value.StatusCode = StatusCodes.BadNoData;
|
||||
}
|
||||
|
||||
if (returnEnd)
|
||||
{
|
||||
value.SourceTimestamp = GetTimestamp(slice);
|
||||
value.ServerTimestamp = GetTimestamp(slice);
|
||||
|
||||
if (StatusCode.IsNotBad(value.StatusCode))
|
||||
{
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Delta2 aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDelta2(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
DataValue start = values[0];
|
||||
DataValue end = values[^1];
|
||||
|
||||
// check for bad bounds.
|
||||
if (!IsGood(start) || !IsGood(end))
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
TypeInfo? originalType = null;
|
||||
|
||||
// convert to doubles.
|
||||
double startValue;
|
||||
try
|
||||
{
|
||||
startValue = CastToDouble(start);
|
||||
originalType = start.WrappedValue.TypeInfo;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
startValue = double.NaN;
|
||||
}
|
||||
|
||||
double endValue;
|
||||
try
|
||||
{
|
||||
endValue = CastToDouble(end);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
endValue = double.NaN;
|
||||
}
|
||||
|
||||
// check for bad bounds.
|
||||
if (double.IsNaN(startValue) || double.IsNaN(endValue))
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
var value = new DataValue
|
||||
{
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (!IsGood(start) || !IsGood(end))
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// calculate delta.
|
||||
double delta = endValue - startValue;
|
||||
|
||||
if (originalType != null && originalType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
object delta2 = LegacyCast(
|
||||
delta,
|
||||
TypeInfo.Scalars.Double,
|
||||
originalType.Value.BuiltInType);
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta2);
|
||||
}
|
||||
else
|
||||
{
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta);
|
||||
}
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class StatusAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public StatusAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_DurationGood:
|
||||
return ComputeDurationGoodBad(slice, false, false);
|
||||
case Objects.AggregateFunction_DurationBad:
|
||||
return ComputeDurationGoodBad(slice, true, false);
|
||||
case Objects.AggregateFunction_PercentGood:
|
||||
return ComputeDurationGoodBad(slice, false, true);
|
||||
case Objects.AggregateFunction_PercentBad:
|
||||
return ComputeDurationGoodBad(slice, true, true);
|
||||
case Objects.AggregateFunction_WorstQuality:
|
||||
return ComputeWorstQuality(slice, false);
|
||||
case Objects.AggregateFunction_WorstQuality2:
|
||||
return ComputeWorstQuality(slice, true);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the DurationGood and DurationBad aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDurationGoodBad(TimeSlice slice, bool isBad, bool usePercent)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
double duration = 0;
|
||||
double total = 0;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
total += regions[ii].Duration;
|
||||
|
||||
if (isBad)
|
||||
{
|
||||
if (StatusCode.IsBad(regions[ii].StatusCode) ||
|
||||
(Configuration.TreatUncertainAsBad &&
|
||||
StatusCode.IsUncertain(regions[ii].StatusCode)))
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
else if (StatusCode.IsGood(regions[ii].StatusCode) ||
|
||||
(!Configuration.TreatUncertainAsBad &&
|
||||
StatusCode.IsUncertain(regions[ii].StatusCode)))
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (usePercent)
|
||||
{
|
||||
duration = duration / total * 100;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(duration),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the DurationGood and DurationBad aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeWorstQuality(TimeSlice slice, bool includeBounds)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
if (includeBounds && values != null)
|
||||
{
|
||||
DateTime startTime = GetTimestamp(slice);
|
||||
DataValue? startBound = GetSimpleBound(startTime, slice);
|
||||
int startIndex = TimeFlowsBackward ? values.Count - 1 : 0;
|
||||
|
||||
if (startBound != null &&
|
||||
(values.Count == 0 || values[startIndex].SourceTimestamp != startTime))
|
||||
{
|
||||
if (TimeFlowsBackward)
|
||||
{
|
||||
values.Add(startBound);
|
||||
}
|
||||
else
|
||||
{
|
||||
values.Insert(0, startBound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
_ = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
int firstIndex = TimeFlowsBackward ? values.Count - 1 : 0;
|
||||
StatusCode worstQuality = values[firstIndex].StatusCode.CodeBits;
|
||||
int badQualityCount = 0;
|
||||
int uncertainQualityCount = 0;
|
||||
int goodQualityCount = 0;
|
||||
|
||||
for (int index = 0; index < values.Count; index++)
|
||||
{
|
||||
int ii = TimeFlowsBackward ? values.Count - 1 - index : index;
|
||||
StatusCode quality = values[ii].StatusCode;
|
||||
|
||||
if (StatusCode.IsBad(quality))
|
||||
{
|
||||
badQualityCount++;
|
||||
|
||||
if (StatusCode.IsNotBad(worstQuality))
|
||||
{
|
||||
worstQuality = quality.CodeBits;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (StatusCode.IsUncertain(quality))
|
||||
{
|
||||
uncertainQualityCount++;
|
||||
|
||||
if (StatusCode.IsGood(worstQuality))
|
||||
{
|
||||
worstQuality = quality.CodeBits;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
goodQualityCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(worstQuality),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
if ((StatusCode.IsBad(worstQuality) && badQualityCount > 1) ||
|
||||
(StatusCode.IsUncertain(worstQuality) && uncertainQualityCount > 1) ||
|
||||
(StatusCode.IsGood(worstQuality) && goodQualityCount > 1))
|
||||
{
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(
|
||||
value.StatusCode.AggregateBits | AggregateBits.MultipleValues);
|
||||
}
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 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 Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class StdDevAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public StdDevAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
// valueType == 1: StandardDeviation, valueType == 2: Variance
|
||||
|
||||
// includeBounds == true: sample, includeBounds == false: population
|
||||
// (this is a strange way to distinguish between sample and population)
|
||||
|
||||
case Objects.AggregateFunction_StandardDeviationPopulation:
|
||||
return ComputeStdDev(slice, false, 1);
|
||||
case Objects.AggregateFunction_StandardDeviationSample:
|
||||
return ComputeStdDev(slice, true, 1);
|
||||
case Objects.AggregateFunction_VariancePopulation:
|
||||
return ComputeStdDev(slice, false, 2);
|
||||
case Objects.AggregateFunction_VarianceSample:
|
||||
return ComputeStdDev(slice, true, 2);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeRegression(TimeSlice slice, int valueType)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
var xData = new List<double>();
|
||||
var yData = new List<double>();
|
||||
|
||||
double duration = 0;
|
||||
bool nonGoodDataExists = false;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsGood(regions[ii].StatusCode))
|
||||
{
|
||||
xData.Add(regions[ii].StartValue);
|
||||
yData.Add(duration);
|
||||
}
|
||||
else
|
||||
{
|
||||
nonGoodDataExists = true;
|
||||
}
|
||||
|
||||
// normalize to seconds.
|
||||
duration += regions[ii].Duration / 1000.0;
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (xData.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// compute the regression parameters.
|
||||
double regSlope = 0;
|
||||
double regConst = 0;
|
||||
double regStdDev = 0;
|
||||
|
||||
if (xData.Count > 1)
|
||||
{
|
||||
double xAvg = 0;
|
||||
double yAvg = 0;
|
||||
double xxAgv = 0;
|
||||
double xyAvg = 0;
|
||||
|
||||
for (int ii = 0; ii < xData.Count; ii++)
|
||||
{
|
||||
xAvg += xData[ii];
|
||||
yAvg += yData[ii];
|
||||
xxAgv += xData[ii] * xData[ii];
|
||||
xyAvg += xData[ii] * yData[ii];
|
||||
}
|
||||
|
||||
xAvg /= xData.Count;
|
||||
yAvg /= xData.Count;
|
||||
xxAgv /= xData.Count;
|
||||
xyAvg /= xData.Count;
|
||||
|
||||
regSlope = (xyAvg - (xAvg * yAvg)) / (xxAgv - (xAvg * xAvg));
|
||||
regConst = yAvg - (regSlope * xAvg);
|
||||
|
||||
var errors = new List<double>();
|
||||
|
||||
double eAvg = 0;
|
||||
|
||||
for (int ii = 0; ii < xData.Count; ii++)
|
||||
{
|
||||
double error = yData[ii] - regConst - (regSlope * xData[ii]);
|
||||
errors.Add(error);
|
||||
eAvg += error;
|
||||
}
|
||||
|
||||
eAvg /= errors.Count;
|
||||
|
||||
double variance = 0;
|
||||
|
||||
for (int ii = 0; ii < errors.Count; ii++)
|
||||
{
|
||||
double error = errors[ii] - eAvg;
|
||||
variance += error * error;
|
||||
}
|
||||
|
||||
variance /= errors.Count;
|
||||
regStdDev = Math.Sqrt(variance);
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = 0;
|
||||
|
||||
switch (valueType)
|
||||
{
|
||||
case 1:
|
||||
result = regSlope;
|
||||
break;
|
||||
case 2:
|
||||
result = regConst;
|
||||
break;
|
||||
case 3:
|
||||
result = regStdDev;
|
||||
break;
|
||||
default:
|
||||
Debug.Fail($"Unexpected value type {valueType}");
|
||||
break;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (nonGoodDataExists)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates sample or population standard deviation/variance for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeStdDev(TimeSlice slice, bool includeBounds, int valueType)
|
||||
{
|
||||
// Part 13 defines these over good raw values in the interval, without bounds.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
var xData = new List<double>();
|
||||
double sum = 0;
|
||||
bool nonGoodDataExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (IsGood(values[ii]))
|
||||
{
|
||||
try
|
||||
{
|
||||
double sample = CastToDouble(values[ii]);
|
||||
xData.Add(sample);
|
||||
sum += sample;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
nonGoodDataExists = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
nonGoodDataExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (xData.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double average = sum / xData.Count;
|
||||
|
||||
// calculate variance.
|
||||
double variance = 0;
|
||||
|
||||
for (int ii = 0; ii < xData.Count; ii++)
|
||||
{
|
||||
double error = xData[ii] - average;
|
||||
variance += error * error;
|
||||
}
|
||||
|
||||
if (includeBounds)
|
||||
{
|
||||
// Spec part 13 v105 section 5.4.3.37 and subsequent
|
||||
if (xData.Count <= 1)
|
||||
{
|
||||
variance = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
variance /= xData.Count - 1;
|
||||
}
|
||||
}
|
||||
// use the population variance if bounds are not included.
|
||||
else
|
||||
{
|
||||
variance /= xData.Count;
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = 0;
|
||||
|
||||
switch (valueType)
|
||||
{
|
||||
case 1:
|
||||
result = Math.Sqrt(variance);
|
||||
break;
|
||||
case 2:
|
||||
result = variance;
|
||||
break;
|
||||
default:
|
||||
Debug.Fail($"Unexpected value type {valueType}");
|
||||
break;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (nonGoodDataExists)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"file": "AggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs",
|
||||
"sha256": "913a98e96fe1daeb592d754f42729fe73e6ea696f8094b56089bad1ad4853905"
|
||||
},
|
||||
{
|
||||
"file": "AverageAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/AverageAggregateCalculator.cs",
|
||||
"sha256": "6292ca7d277de2588046a789d1cd74aea3cb8c4c8ca698ff2d6a4e32e2a58ab0"
|
||||
},
|
||||
{
|
||||
"file": "MinMaxAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs",
|
||||
"sha256": "3a9a07aa0e8eb37a905bb670e766531332abad393e3ad57b3985beee6b44549c"
|
||||
},
|
||||
{
|
||||
"file": "CountAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs",
|
||||
"sha256": "1e98095aaa4fa30918df6e3d63abb261852587cdf2c079d842d8bf2acecb48b8"
|
||||
},
|
||||
{
|
||||
"file": "StartEndAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StartEndAggregateCalculator.cs",
|
||||
"sha256": "db4d8a69b4d8d5f599f60ee85861252e55cc1779f6ba29a3146ddf4c2958543a"
|
||||
},
|
||||
{
|
||||
"file": "StatusAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs",
|
||||
"sha256": "82fa78d6cd3331103ce7f2c03f0545078e355062650ebf1ec07a96446aa5854f"
|
||||
},
|
||||
{
|
||||
"file": "StdDevAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs",
|
||||
"sha256": "c202d66c2cad946554ffac3b26ecd22e59f458894637a8cd98bbc541519996a2"
|
||||
},
|
||||
{
|
||||
"file": "Aggregators.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/Aggregators.cs",
|
||||
"sha256": "1d47a0a3d8a6a4985cbafc54425fed26ab2a20e22ec964eb83553e6145b71fa0"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user