mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
Aggregates: Minimum/Maximum/ActualTime/Range treat Uncertain values as candidates (Mantis 11426)
Paul Hunkar on Mantis 11426 (~0025852): Minimum, Maximum, MinimumActualTime, MaximumActualTime and Range follow OPC Classic. Uncertain values are looked at as if they are Good, whatever TreatUncertainAsBad is; an Uncertain extremum is returned with Uncertain_DataSubNormal. The status is otherwise Good unless Bad values exist in the interval (Uncertain_DataSubNormal), and Bad_NoData if all values are Bad. The server ignored an Uncertain value beyond the Good extremum and returned the Good one. Minimum2/Maximum2 are unchanged (5.4.3.15 ties them to TreatUncertainAsBad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -227,6 +227,14 @@ used to determine whether Uncertain values are Bad or non-Bad"*; the answer is r
|
||||
TreatUncertainAsBad does not apply, DurationGood counts only Good and DurationBad only Bad regions (an Uncertain
|
||||
region counts for neither), and the first region takes the status of the raw value at or before the start
|
||||
(§5.4.3.31–34). The server counted Uncertain regions as Good or Bad depending on TreatUncertainAsBad.
|
||||
- **Aggregate definitions win over TreatUncertainAsBad (fixed).** Paul's note on
|
||||
[11426](https://mantis.opcfoundation.org/view.php?id=11426) (~0025852): Minimum, Maximum, MinimumActualTime,
|
||||
MaximumActualTime and Range follow OPC Classic. Uncertain values are looked at as if they are Good, so an
|
||||
Uncertain extremum is returned with `UncertainDataSubNormal`; the status is otherwise Good unless Bad values exist
|
||||
(`UncertainDataSubNormal`), and `BadNoData` if all values are Bad. The server ignored an Uncertain value beyond the
|
||||
Good extremum. NumberOfTransitions follows §5.4.3.24 ("non-Bad") the same way: Uncertain values are counted and
|
||||
can be the previous value, whatever TreatUncertainAsBad is. Minimum2/Maximum2 keep TreatUncertainAsBad, which
|
||||
§5.4.3.15 names explicitly.
|
||||
- With the patched scripts corrected accordingly, Aggregates (p06) has 0 errors against the fixed server and 99
|
||||
against the server before the fix.
|
||||
|
||||
|
||||
@@ -110,21 +110,26 @@ namespace Opc.Ua.Server
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double minimumGoodValue = double.MaxValue;
|
||||
double minimumUncertainValue = double.MaxValue;
|
||||
double maximumGoodValue = double.MinValue;
|
||||
double maximumUncertainValue = double.MinValue;
|
||||
// Mantis 11426 ~0025852 (OPC Classic semantics of Minimum, Maximum, MinimumActualTime,
|
||||
// MaximumActualTime and Range): Uncertain values are looked at as if they are Good and are
|
||||
// candidates whatever TreatUncertainAsBad is; the status is Good unless Bad values exist in
|
||||
// the interval or the selected extremum is Uncertain (then Uncertain_DataSubNormal), and
|
||||
// Bad_NoData if all values are Bad.
|
||||
double minimumValue = double.MaxValue;
|
||||
double maximumValue = double.MinValue;
|
||||
|
||||
DateTime minimumGoodTimestamp = DateTime.MinValue;
|
||||
DateTime maximumGoodTimestamp = DateTime.MinValue;
|
||||
DateTime minimumTimestamp = DateTime.MinValue;
|
||||
DateTime maximumTimestamp = DateTime.MinValue;
|
||||
|
||||
TypeInfo minimumOriginalType = default;
|
||||
TypeInfo maximumOriginalType = default;
|
||||
|
||||
bool minimumIsUncertain = false;
|
||||
bool maximumIsUncertain = false;
|
||||
bool badValuesExist = false;
|
||||
bool duplicatesMinimumsExist = false;
|
||||
bool duplicatesMaximumsExist = false;
|
||||
bool goodValueExists = false;
|
||||
bool nonBadValueExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
@@ -132,7 +137,7 @@ namespace Opc.Ua.Server
|
||||
StatusCode currentStatus = values[ii].StatusCode;
|
||||
|
||||
// ignore bad values.
|
||||
if (!IsGood(values[ii]))
|
||||
if (!IsBoundCandidate(values[ii]))
|
||||
{
|
||||
badValuesExist = true;
|
||||
continue;
|
||||
@@ -150,55 +155,43 @@ namespace Opc.Ua.Server
|
||||
continue;
|
||||
}
|
||||
|
||||
// check for uncertain.
|
||||
if (StatusCode.IsUncertain(currentStatus))
|
||||
{
|
||||
if (minimumUncertainValue > currentValue)
|
||||
{
|
||||
minimumUncertainValue = currentValue;
|
||||
}
|
||||
|
||||
if (maximumUncertainValue < currentValue)
|
||||
{
|
||||
maximumUncertainValue = currentValue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
bool currentIsUncertain = StatusCode.IsUncertain(currentStatus);
|
||||
|
||||
// check for new minimum.
|
||||
if (minimumGoodValue > currentValue)
|
||||
if (!nonBadValueExists || minimumValue > currentValue)
|
||||
{
|
||||
minimumGoodValue = currentValue;
|
||||
minimumGoodTimestamp = currentTime;
|
||||
minimumValue = currentValue;
|
||||
minimumTimestamp = currentTime;
|
||||
minimumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
minimumIsUncertain = currentIsUncertain;
|
||||
duplicatesMinimumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate minimums.
|
||||
else if (minimumGoodValue == currentValue)
|
||||
else if (minimumValue == currentValue)
|
||||
{
|
||||
duplicatesMinimumsExist = true;
|
||||
}
|
||||
|
||||
// check for new maximum.
|
||||
if (maximumGoodValue < currentValue)
|
||||
if (!nonBadValueExists || maximumValue < currentValue)
|
||||
{
|
||||
maximumGoodValue = currentValue;
|
||||
maximumGoodTimestamp = currentTime;
|
||||
maximumValue = currentValue;
|
||||
maximumTimestamp = currentTime;
|
||||
maximumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
maximumIsUncertain = currentIsUncertain;
|
||||
duplicatesMaximumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate maximums.
|
||||
else if (maximumGoodValue == currentValue)
|
||||
else if (maximumValue == currentValue)
|
||||
{
|
||||
duplicatesMaximumsExist = true;
|
||||
}
|
||||
|
||||
nonBadValueExists = true;
|
||||
}
|
||||
|
||||
// check if at least one good value exists.
|
||||
if (!goodValueExists)
|
||||
// check if at least one non-bad value exists.
|
||||
if (!nonBadValueExists)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
@@ -207,8 +200,8 @@ namespace Opc.Ua.Server
|
||||
StatusCode statusCode = StatusCodes.Good;
|
||||
|
||||
if (badValuesExist ||
|
||||
(valueType is 1 or 3 && minimumUncertainValue < minimumGoodValue) ||
|
||||
(valueType is 2 or 3 && maximumUncertainValue > maximumGoodValue))
|
||||
(valueType is 1 or 3 && minimumIsUncertain) ||
|
||||
(valueType is 2 or 3 && maximumIsUncertain))
|
||||
{
|
||||
statusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
@@ -221,21 +214,21 @@ namespace Opc.Ua.Server
|
||||
|
||||
if (valueType == 1)
|
||||
{
|
||||
processedValue = minimumGoodValue;
|
||||
processedTimestamp = minimumGoodTimestamp;
|
||||
processedValue = minimumValue;
|
||||
processedTimestamp = minimumTimestamp;
|
||||
processedType = minimumOriginalType;
|
||||
duplicatesExist = duplicatesMinimumsExist;
|
||||
}
|
||||
else if (valueType == 2)
|
||||
{
|
||||
processedValue = maximumGoodValue;
|
||||
processedTimestamp = maximumGoodTimestamp;
|
||||
processedValue = maximumValue;
|
||||
processedTimestamp = maximumTimestamp;
|
||||
processedType = maximumOriginalType;
|
||||
duplicatesExist = duplicatesMaximumsExist;
|
||||
}
|
||||
else if (valueType == 3)
|
||||
{
|
||||
double range = Math.Abs(maximumGoodValue - minimumGoodValue);
|
||||
double range = Math.Abs(maximumValue - minimumValue);
|
||||
processedValue = range;
|
||||
processedType = GetRangeType(minimumOriginalType, range);
|
||||
}
|
||||
|
||||
@@ -351,16 +351,18 @@ namespace Opc.Ua.History.Tests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies extrema retain good source values while uncertain candidates affect the aggregate quality.
|
||||
/// Verifies that uncertain values are extrema candidates and an uncertain extremum is returned with
|
||||
/// Uncertain_DataSubNormal (Mantis 11426 ~0025852: Uncertain values are looked at as if they are Good).
|
||||
/// </summary>
|
||||
[TestCase(Objects.AggregateFunction_Minimum, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum, 10.0)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, 10.0)]
|
||||
public async Task MinMaxHistoryPreservesGoodValuesAndReportsUncertainExtremaAsync(
|
||||
[TestCase(Objects.AggregateFunction_Minimum, 1.0, 0)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum, 20.0, 0)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 19.0, 0)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime, 1.0, 1)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, 20.0, 3)]
|
||||
public async Task MinMaxHistoryReturnsUncertainExtremaAsUncertainAsync(
|
||||
uint aggregateTypeId,
|
||||
double expected)
|
||||
double expected,
|
||||
int expectedTimestampSeconds)
|
||||
{
|
||||
var client = new HistoryClient(Session);
|
||||
var nodeId = new NodeId("Extrema", m_namespaceIndex);
|
||||
@@ -400,10 +402,9 @@ namespace Opc.Ua.History.Tests
|
||||
Assert.That(
|
||||
values[0].StatusCode,
|
||||
Is.EqualTo(StatusCodes.UncertainDataSubNormal.WithAggregateBits(AggregateBits.Calculated)));
|
||||
DateTime expectedTimestamp = aggregateTypeId == Objects.AggregateFunction_MaximumActualTime
|
||||
? start.AddSeconds(2)
|
||||
: start;
|
||||
Assert.That(values[0].SourceTimestamp, Is.EqualTo((DateTimeUtc)expectedTimestamp));
|
||||
Assert.That(
|
||||
values[0].SourceTimestamp,
|
||||
Is.EqualTo((DateTimeUtc)start.AddSeconds(expectedTimestampSeconds)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,18 +41,25 @@ namespace Opc.Ua.Server.Tests.Aggregates
|
||||
public sealed class MinMaxUncertainRegressionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that an uncertain sample outside the good range lowers quality without replacing good extrema.
|
||||
/// Verifies that an uncertain sample beyond the good values is the extremum, returned with
|
||||
/// Uncertain_DataSubNormal, whatever TreatUncertainAsBad is: Minimum, Maximum, the ActualTime
|
||||
/// variants and Range look at Uncertain values as if they are Good (Mantis 11426 ~0025852).
|
||||
/// </summary>
|
||||
[TestCase(Objects.AggregateFunction_Minimum, 1.0, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum, 9.0, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 1.0, 0.0)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 9.0, 0.0)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime, 1.0, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, 9.0, 5.0)]
|
||||
public void MinMaxPreservesGoodExtremaAndReportsUncertainInputs(
|
||||
[TestCase(Objects.AggregateFunction_Minimum, 1.0, 1.0, 0, false)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum, 9.0, 9.0, 0, false)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 1.0, 4.0, 0, false)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 9.0, 4.0, 0, false)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime, 1.0, 1.0, 5_000, false)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, 9.0, 9.0, 5_000, false)]
|
||||
[TestCase(Objects.AggregateFunction_Minimum, 1.0, 1.0, 0, true)]
|
||||
[TestCase(Objects.AggregateFunction_Range, 9.0, 4.0, 0, true)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, 9.0, 9.0, 5_000, true)]
|
||||
public void MinMaxReturnsUncertainExtremumAsUncertain(
|
||||
uint aggregateTypeId,
|
||||
double uncertainValue,
|
||||
double expectedValue)
|
||||
double expectedValue,
|
||||
int timestampOffset,
|
||||
bool treatUncertainAsBad)
|
||||
{
|
||||
DateTimeUtc start = new(2025, 1, 1, 0, 0, 0);
|
||||
IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
|
||||
@@ -63,7 +70,7 @@ namespace Opc.Ua.Server.Tests.Aggregates
|
||||
false,
|
||||
new AggregateConfiguration
|
||||
{
|
||||
TreatUncertainAsBad = false,
|
||||
TreatUncertainAsBad = treatUncertainAsBad,
|
||||
PercentDataBad = 100,
|
||||
PercentDataGood = 100
|
||||
},
|
||||
@@ -85,7 +92,7 @@ namespace Opc.Ua.Server.Tests.Aggregates
|
||||
result.StatusCode,
|
||||
Is.EqualTo(StatusCodes.UncertainDataSubNormal
|
||||
.WithAggregateBits(AggregateBits.Calculated)));
|
||||
Assert.That(result.SourceTimestamp, Is.EqualTo(start));
|
||||
Assert.That(result.SourceTimestamp, Is.EqualTo(start.AddMilliseconds(timestampOffset)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,17 +144,19 @@ namespace Opc.Ua.Server.Tests.Aggregates
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an interval without good samples reports no data rather than a fabricated extremum.
|
||||
/// Verifies that an interval with only an uncertain sample returns it as the extremum with
|
||||
/// Uncertain_DataSubNormal (Range 0), whatever TreatUncertainAsBad is (Mantis 11426 ~0025852).
|
||||
/// </summary>
|
||||
[TestCase(Objects.AggregateFunction_Minimum, false)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum, false)]
|
||||
[TestCase(Objects.AggregateFunction_Range, false)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime, false)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, false)]
|
||||
[TestCase(Objects.AggregateFunction_Minimum, true)]
|
||||
public void AllUncertainIntervalDoesNotInventAGoodExtremum(
|
||||
[TestCase(Objects.AggregateFunction_Minimum, false, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum, false, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_Range, false, 0.0)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime, false, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime, false, 5.0)]
|
||||
[TestCase(Objects.AggregateFunction_Minimum, true, 5.0)]
|
||||
public void AllUncertainIntervalReturnsUncertainExtremum(
|
||||
uint aggregateTypeId,
|
||||
bool treatUncertainAsBad)
|
||||
bool treatUncertainAsBad,
|
||||
double expectedValue)
|
||||
{
|
||||
DateTimeUtc start = new(2025, 1, 1, 0, 0, 0);
|
||||
IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
|
||||
@@ -171,8 +180,52 @@ namespace Opc.Ua.Server.Tests.Aggregates
|
||||
Assert.That(calculator.TryGetProcessedValue(true, out DataValue result), Is.True);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.StatusCode.Code & 0xFFFF0000u, Is.EqualTo(StatusCodes.BadNoData.Code));
|
||||
Assert.That(result.WrappedValue.IsNull, Is.True);
|
||||
Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
|
||||
Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(expectedValue));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an interval with only Bad samples returns Bad_NoData (Mantis 11426 ~0025852), and
|
||||
/// that Bad samples next to a Good one make the result Uncertain_DataSubNormal.
|
||||
/// </summary>
|
||||
[TestCase(Objects.AggregateFunction_Minimum)]
|
||||
[TestCase(Objects.AggregateFunction_Maximum)]
|
||||
[TestCase(Objects.AggregateFunction_Range)]
|
||||
[TestCase(Objects.AggregateFunction_MinimumActualTime)]
|
||||
[TestCase(Objects.AggregateFunction_MaximumActualTime)]
|
||||
public void BadSamplesMakeTheResultUncertainOrBadNoData(uint aggregateTypeId)
|
||||
{
|
||||
DateTimeUtc start = new(2025, 1, 1, 0, 0, 0);
|
||||
DateTimeUtc middle = start.AddMilliseconds(5_000);
|
||||
DateTimeUtc end = start.AddMilliseconds(10_000);
|
||||
var configuration = new AggregateConfiguration
|
||||
{
|
||||
TreatUncertainAsBad = true,
|
||||
PercentDataBad = 100,
|
||||
PercentDataGood = 100
|
||||
};
|
||||
|
||||
IAggregateCalculator allBad = Aggregators.CreateStandardCalculator(
|
||||
new NodeId(aggregateTypeId), start, end, 10_000, false, configuration,
|
||||
NUnitTelemetryContext.Create())!;
|
||||
allBad.QueueRawValue(new DataValue(5.0, StatusCodes.BadDataUnavailable, start, start));
|
||||
allBad.QueueRawValue(new DataValue(6.0, StatusCodes.BadDataUnavailable, middle, middle));
|
||||
allBad.QueueRawValue(new DataValue(7.0, StatusCodes.Good, end, end));
|
||||
|
||||
IAggregateCalculator mixed = Aggregators.CreateStandardCalculator(
|
||||
new NodeId(aggregateTypeId), start, end, 10_000, false, configuration,
|
||||
NUnitTelemetryContext.Create())!;
|
||||
mixed.QueueRawValue(new DataValue(5.0, StatusCodes.Good, start, start));
|
||||
mixed.QueueRawValue(new DataValue(1.0, StatusCodes.BadDataUnavailable, middle, middle));
|
||||
mixed.QueueRawValue(new DataValue(7.0, StatusCodes.Good, end, end));
|
||||
|
||||
Assert.That(allBad.TryGetProcessedValue(true, out DataValue allBadResult), Is.True);
|
||||
Assert.That(mixed.TryGetProcessedValue(true, out DataValue mixedResult), Is.True);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(allBadResult.StatusCode.CodeBits, Is.EqualTo(StatusCodes.BadNoData));
|
||||
Assert.That(mixedResult.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user