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:
Roman Ettlinger
2026-10-08 15:43:19 +02:00
parent f97e4f8d4d
commit 854769a0c5
4 changed files with 131 additions and 76 deletions
+8
View File
@@ -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));
});
}
}