Files
Marc Schier b2b4dbb13f [Server] Part 13 Aggregates compliance fixes, full test coverage and docs (#3863)
# Description

Brings the server-side **OPC UA Part 13 (Aggregates) v1.05.07** support
to full conformance: fixes the three genuine non-compliances found by a
spec review, completes value-validated test coverage for **all 37
standard aggregate functions**, and adds documentation. The existing
aggregate engine is a faithful port and was already largely compliant;
this PR closes the remaining gaps.

## Compliance fixes

- **`TreatUncertainAsBad` default (§4.2.1.2).** The server-default
`AggregateConfiguration` (used when a `ReadProcessedDetails` request
sets `UseServerCapabilitiesDefaults = true`) now defaults
`TreatUncertainAsBad` to **`True`** as the spec mandates, in both
`AggregateManager.GetDefaultConfiguration` and the `HistorianDispatcher`
fallback. Behavior change noted in `MigrationGuide.md`.
- **`AnnotationCount` (§5.4.3.20).** Previously counted raw data values.
It now counts **Annotations** per interval, computed in
`HistorianDispatcher.DispatchProcessedReadAsync` from
`IHistorianAnnotationProvider` (endTime-exclusive bucketing;
reverse-time and `ProcessingInterval == 0` handled). Returns
`Bad_AggregateNotSupported` when the provider has no annotation history.
- **StandardDeviation/Variance use all n Good raw values
(§5.4.3.37/.39).** `ComputeStdDev` previously computed over the N−1
sub-regions returned by `GetRegionsInValueSet`, silently dropping the
last raw value. It now computes the mean/variance directly over all Good
raw values in the interval (UseBounds = None), differing only by the
divisor (sample n−1, population n). The population spec-example tests
that used to add a duplicate trailing value as a workaround were updated
to the natural 5-value datasets matching §A.35/.37.

## Tests

- **Calculator coverage is complete for all 37 aggregates** — added
`StatusAggregateCalculatorTests` (DurationGood/Bad, PercentGood/Bad,
WorstQuality/WorstQuality2), the Min/Max `*2` simple-bound variants
(`Minimum2`/`Maximum2`/`Range2`/`MinimumActualTime2`/`MaximumActualTime2`),
and `Interpolative`.
- **End-to-end integration** in `HistoryClientIntegrationTests` over the
live in-process ReferenceServer: value-validated
`Average`/`Minimum`/`Maximum` processed reads and an **AnnotationCount**
write→read round-trip.
- **Dispatcher-level** `AnnotationCount` tests (counts per interval;
`Bad_AggregateNotSupported` without an annotation provider).
- **Tightened `AggregateBaseTests`**: dropped `allowAnyResult` from
`StandardDeviationSample`/`StandardDeviationPopulation`/`NumberOfTransitions`
basecases (now deterministic on seeded data after the D3 fix).
- **Validation tests** for `AggregateConfiguration` per §4.2.1.2:
`PercentDataBad`/`PercentDataGood > 100` and the invalid relationship
`PercentDataGood < (100 - PercentDataBad)`.
- **Cross-cutting**: reversed time-range test (start > end).

## Verification

- Builds: **0 warnings / 0 errors**.
- Aggregate + historian dispatcher + History integration suites green on
**.NET 10** and **.NET Framework 4.8** (cross-TFM).
- Black-box (MCP) check against a standalone `ConsoleReferenceServer`:
both `Server.ServerCapabilities.AggregateFunctions` and
`HistoryServerCapabilities.AggregateFunctions` advertise all 37
functions (§4.2.2.1).

## Notes / out of scope

- AnnotationCount advertisement remains the full standard set with
per-node `Bad_AggregateNotSupported`. This is spec-acceptable: §4.2.2.1
lists what the server *can* support; per-node refinement is the optional
`HistoricalConfiguration.AggregateFunctions`. Conditional advertisement
could be added later as a quality refinement.
- A native-vs-framework-fallback parity test would be useful once a
third-party `IHistorianProcessedProvider` implementation exists; the
bundled `InMemoryHistorianProvider` uses only the framework fallback
today.

## Related Issues

_No existing tracking issue; this addresses OPC 10000-13 (Part 13,
Aggregates) v1.05.07 conformance gaps. Maintainers: please link or open
a tracking issue if one is required._

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(Ran the
aggregate/historian/integration suites on net48 + net10; full-solution
run pending CI.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-10 08:34:09 +02:00

139 lines
6.7 KiB
Markdown

# Aggregates (OPC UA Part 13)
## Overview
The .NET Standard stack implements **OPC UA Part 13 (OPC 10000-13) v1.05.07 Aggregates** on the server
side, computed over historical data retrieved through [Historical Access](HistoricalAccess.md) (Part 11).
All **37 standard aggregate functions** are supported and advertised through the address space.
Aggregates are requested with the `HistoryRead` service using `ReadProcessedDetails` (a `startTime`,
`endTime`, `ProcessingInterval`, one or more `AggregateType` NodeIds, and an optional
`AggregateConfiguration`). The server divides the time range into intervals of `ProcessingInterval`
milliseconds and produces one aggregate value per interval.
## Supported aggregate functions
| Category | Functions |
|---|---|
| Interpolative | `Interpolative` |
| Averages / integrals | `Average`, `TimeAverage`, `TimeAverage2`, `Total`, `Total2` |
| Extrema | `Minimum`, `Maximum`, `MinimumActualTime`, `MaximumActualTime`, `Range`, `Minimum2`, `Maximum2`, `MinimumActualTime2`, `MaximumActualTime2`, `Range2` |
| Counts | `Count`, `AnnotationCount`, `DurationInStateZero`, `DurationInStateNonZero`, `NumberOfTransitions` |
| Start / end | `Start`, `End`, `Delta`, `StartBound`, `EndBound`, `DeltaBounds` |
| Quality / time-in-state | `DurationGood`, `DurationBad`, `PercentGood`, `PercentBad`, `WorstQuality`, `WorstQuality2` |
| Statistics | `StandardDeviationSample`, `VarianceSample`, `StandardDeviationPopulation`, `VariancePopulation` |
The exact per-aggregate semantics (type, bounding behaviour, timestamp, status-code rules, special cases)
follow Part 13 §5.4.3.4–§5.4.3.40.
## Architecture
```
HistoryRead (ReadProcessedDetails)
↓
AsyncCustomNodeManager / CustomNodeManager2 → HistorianDispatcher.DispatchProcessedReadAsync
↓ ├─ provider implements IHistorianProcessedProvider → native push-down
↓ └─ otherwise: framework fallback
↓ ├─ AnnotationCount → IHistorianAnnotationProvider (counts Annotations)
↓ └─ other aggregates → AggregateManager.CreateCalculator
↓ → stream raw values through IAggregateCalculator
```
- **`AggregateManager`** (`Opc.Ua.Server`) owns the registered aggregate factories, the server default
`AggregateConfiguration`, and the `MinimumProcessingInterval`. The standard functions are registered via
`Aggregators` and advertised into `Server.ServerCapabilities.AggregateFunctions` and
`HistoryServerCapabilities.AggregateFunctions`.
- **`IAggregateCalculator`** implementations (`AggregateCalculator` and the specialized
`Average`/`MinMax`/`Count`/`StartEnd`/`Status`/`StdDev` calculators) compute the aggregates from a stream
of raw `DataValue`s.
- A historian provider may compute aggregates itself by implementing `IHistorianProcessedProvider`
(native push-down). When it does not, the framework streams raw values through the calculator.
### AnnotationCount
`AnnotationCount` (Part 13 §5.4.3.20) counts **Annotations** in each interval, not raw data values.
It is therefore computed from the node's annotation history via `IHistorianAnnotationProvider`, not from
the raw-value calculator. If the resolved provider does not expose annotation history, an `AnnotationCount`
request returns `Bad_AggregateNotSupported`.
## Server configuration
`AggregateConfiguration` controls how non-Good data affects the result:
| Property | Default | Meaning |
|---|---|---|
| `TreatUncertainAsBad` | **`true`** | Whether Uncertain samples are treated as Bad when computing the aggregate `StatusCode` (Part 13 §4.2.1.2). |
| `PercentDataBad` | `100` | Minimum % of Bad data in an interval for the interval `StatusCode` to be Bad. |
| `PercentDataGood` | `100` | Minimum % of Good data in an interval for the interval `StatusCode` to be Good. |
| `UseSlopedExtrapolation` | `false` | Stepped (hold-last) vs sloped extrapolation past the last value. Ignored for Simple Bounds. |
The server's default configuration is returned by `AggregateManager.GetDefaultConfiguration(...)` and is
used whenever a request sets `AggregateConfiguration.UseServerCapabilitiesDefaults = true`. Per Part 13
§4.2.1.2 the default `TreatUncertainAsBad` value is `true`.
> **Migration note:** in earlier builds the server default used `TreatUncertainAsBad = false`. Clients that
> require the old behaviour should send an explicit `AggregateConfiguration` with
> `TreatUncertainAsBad = false` rather than relying on `UseServerCapabilitiesDefaults`. See the
> [Migration Guide](MigrationGuide.md).
`AggregateManager.MinimumProcessingInterval` bounds the smallest interval the server will accept.
## Client usage
Use the `HistoryClient` (`session.Historian()`) `ReadProcessedAsync` helper, which streams one
`DataValue` per processing interval as an `IAsyncEnumerable<DataValue>`:
```csharp
// Average of a historizing variable over the last hour, in 1-minute buckets.
DateTime end = DateTime.UtcNow;
DateTime start = end.AddHours(-1);
await foreach (DataValue value in session.Historian().ReadProcessedAsync(
nodeId,
aggregateFunctionId: ObjectIds.AggregateFunction_Average,
startTime: start,
endTime: end,
processingInterval: 60_000 /* ms */))
{
Console.WriteLine($"{value.SourceTimestamp:O}: {value.Value} ({value.StatusCode})");
}
```
Pass an explicit `AggregateConfiguration` to override the server defaults:
```csharp
await foreach (DataValue value in session.Historian().ReadProcessedAsync(
nodeId,
ObjectIds.AggregateFunction_TimeAverage,
start,
end,
processingInterval: 60_000,
configuration: new AggregateConfiguration
{
TreatUncertainAsBad = false,
PercentDataBad = 100,
PercentDataGood = 100,
UseSlopedExtrapolation = false
}))
{
// ...
}
```
Discover the aggregates a server supports with `session.Historian().GetServerCapabilitiesAsync(...)` or by
browsing `Server.ServerCapabilities.AggregateFunctions`.
## Notes and limitations
- `AnnotationCount` requires a provider with annotation history; otherwise it returns
`Bad_AggregateNotSupported`.
- The bundled in-memory historian (`InMemoryHistorianProvider`) supports all aggregates through the
framework fallback and supports annotation history, so `AnnotationCount` works out of the box.
- Custom providers can override aggregate computation by implementing `IHistorianProcessedProvider`.
## References
- OPC 10000-13 (Aggregates) v1.05.07: https://reference.opcfoundation.org/Core/Part13/v105/docs/
- [Historical Access (Part 11)](HistoricalAccess.md)
- [Migration Guide](MigrationGuide.md)