diff --git a/.claude/rules/historian.md b/.claude/rules/historian.md index 2d37cf743..7c8bf0050 100644 --- a/.claude/rules/historian.md +++ b/.claude/rules/historian.md @@ -32,7 +32,7 @@ historian tagname, which is the tag's driver `FullName`. ### Pluggable backends, SQL Server and HistoryUpdate (2026-10) - **Selection:** `ServerHistorian:Backend` (`Gateway` default | `SqlServer` | `QuestDb`; unknown fails host start). `HistorianBackendRegistry` → `IHistorianBackendProvider` builds all five seams; the gateway keys are only required for `Gateway`. `SqlServer` uses `ServerHistorian:SqlServer:{ConnectionString (required), Schema=hist, CommandTimeout=30s, AutoCreateSchema=true}`. -- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680, and DurationGood/DurationBad, #820 — ms, Uncertain time in neither; the live AggregateFilter still counts Uncertain as Good, #809) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION` (shared host `10.100.0.35,14330`, `sa`/`OtOpcUaDev_2026!` — not docker-dev's `OtOpcUa!Dev123`; QuestDB: `HISTORIAN_QUESTDB_TEST_CONNECTION` against the shared `/opt/otopcua-questdb` fixture `10.100.0.35:8812`, #822, `infra/README.md` §2a). **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. +- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680, and DurationGood/DurationBad, #820 — ms, Uncertain time in neither; since #809 the live AggregateFilter agrees — `GoodOnlyStatusAggregateCalculator` overrides only those two of the frozen calculators) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION` (shared host `10.100.0.35,14330`, `sa`/`OtOpcUaDev_2026!` — not docker-dev's `OtOpcUa!Dev123`; QuestDB: `HISTORIAN_QUESTDB_TEST_CONNECTION` against the shared `/opt/otopcua-questdb` fixture `10.100.0.35:8812`, #822, `infra/README.md` §2a). **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. - **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend". - ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates. - ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density at ≈ N + 8 rows and carried page to page in the continuation point — #689: it used to aim at half `MaxTieClusterOverfetch`, ~32k rows per page on a dense tag; a read asks for `2N + 18` rows (a discovery segment ≤ 1,026), `MaxTieClusterOverfetch` is only the tie-cluster ceiling — a full read is re-read, never answered truncated; only more than max(1, `MaxTieClusterOverfetch`) samples within ONE MILLISECOND ⇒ `BadTooManyOperations` (per ms, not per timestamp — requests are ms-rounded, and letting a segment narrow below 1 ms stepped a page through one ms a tick per read, ~10k reads); walks no later than now + 1 day and never before the Unix epoch; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `docs/Historian.md` §"Open-ended and reverse raw reads". diff --git a/docs/Historian.md b/docs/Historian.md index 6d7fa9963..e160a56b1 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -1827,10 +1827,13 @@ is `Good` (plus Calculated / Partial). Value = `100 × region time ÷ interval d **`DurationGood` / `DurationBad`** (#820) are the same Good / Bad region times as `PercentGood` / `PercentBad` — every rule above (regions, simple bounds, time before the first sample = Bad, end of data, `BadNoData`, status, Partial) applies unchanged — reported as an OPC UA `Duration`, i.e. a `double` in **milliseconds** (the SDK's unit): `DurationGood = PercentGood × interval ms ÷ 100`. Uncertain -time counts toward neither (Mantis 0006166). Before #820 both were `BadAggregateNotSupported` on the SQL backends. ⚠️ A **live** -`AggregateFilter` subscription computes DurationGood/PercentGood with the frozen 1.5.378.182 calculators, which count Uncertain time as -Good — they disagree with HistoryRead whenever Uncertain data is present (#809, owner decision pending). The Gateway backend still -refuses both (`BadAggregateNotSupported`, not advertised in i=11201). +time counts toward neither (Mantis 0006166). Before #820 both were `BadAggregateNotSupported` on the SQL backends. ⚠️ **#809 +(client-visible, owner-approved 2026-11-11):** a **live** `AggregateFilter` subscription now computes DurationGood/PercentGood the same +way — Good time only. They used to come from the frozen 1.5.378.182 calculators, which count Uncertain time as Good; the server now +serves those two through the application's `GoodOnlyStatusAggregateCalculator` (every other live aggregate is still the frozen +baseline). A live PercentGood/DurationGood over Uncertain data therefore drops by the Uncertain share. Pinned live-vs-history by +`SdkAggregateParityTests.Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples`. The Gateway +backend still refuses both (`BadAggregateNotSupported`, not advertised in i=11201). All carry the Calculated bit. A non-Bad result also carries the **Partial** bit (`0x4`, `AggregateBits.Partial`) — never on a Bad result — for: the **truncated interval** (shorter than the processing interval — the latest one forward, the earliest one reverse; @@ -1911,7 +1914,8 @@ registers for **live** `AggregateFilter` items and from SDK 2.0.1's own stock ca `SdkAggregateParityTests.An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data` (ours / frozen / stock 2.0.1, with a Good-first control) and `OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests` (through the booted server's runtime `AggregateManager`), and on both SQL backends by `QualityMixConformance.Time_weighted_values_over_partial_data_cover_only_the_data` -(forward) and `…_in_a_reverse_read` (#821). Whether live subscriptions should follow is the open #809/#821 owner decision. The same +(forward) and `…_in_a_reverse_read` (#821). Whether live subscriptions should follow is the open #821 owner decision (#809, the +PercentGood/DurationGood half, is decided: live follows Part 13). The same suite feeds PercentGood/PercentBad, DurationGood/DurationBad (and Count, Average, TimeAverage(2), Total(2), Start/End/Delta, Interpolative) Good/Uncertain/Bad mixes, all-Uncertain intervals (0 % Good **and** 0 % Bad, Mantis 0006166) and partial-data intervals, hand-computed and against the #796 oracle — the baseline calculator's Uncertain-as-Good PercentGood fails it. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs index c4d1b6f9a..3be72a299 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs @@ -6,10 +6,16 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; /// Adapts the versioned baseline algorithms to the SDK 2 calculator protocol without a second SDK runtime. internal static class CompatibilityAggregateFactory { + /// + /// PercentGood / DurationGood are served by the application-owned + /// (#809: Uncertain time is not Good, as in Part 13 and HistoryRead); every other id is the unchanged baseline. + /// 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.")); + new Adapter(GoodOnlyStatusAggregateCalculator.Overrides(id) + ? new GoodOnlyStatusAggregateCalculator(id, (DateTime)start, (DateTime)end, interval, stepped, configuration, telemetry) + : Aggregators.CreateStandardCalculator(id, (DateTime)start, (DateTime)end, interval, stepped, configuration, telemetry) + ?? throw new ArgumentOutOfRangeException(nameof(id), "Unsupported baseline aggregate.")); /// Replaces exactly the existing standard factories; no aggregate IDs are added or removed. internal static async ValueTask RegisterAsync(AggregateManager manager, CancellationToken ct) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/GoodOnlyStatusAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/GoodOnlyStatusAggregateCalculator.cs new file mode 100644 index 000000000..f8532f7fd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/GoodOnlyStatusAggregateCalculator.cs @@ -0,0 +1,83 @@ +using Opc.Ua; +using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; + +/// +/// Application-owned override (#809) of the baseline for +/// PercentGood and DurationGood only: a region counts toward the Good time only when its status is +/// Good. Uncertain time is in neither Good nor (unless TreatUncertainAsBad) Bad, per Part 13 +/// §5.4.3.31/§5.4.3.33 (Mantis 0006166) — the rule the HistoryRead evaluators (TimeWeightedAggregator, +/// SqlServer + QuestDB) already follow. +/// +/// +/// The baseline 1.5.378.182 ComputeDurationGoodBad counts Uncertain regions as Good when +/// TreatUncertainAsBad = false. This class leaves the copied file untouched and replaces only the Good +/// branch: the slice values, simple bounds, stepped regions, empty-slice BadNoData, timestamp, value type +/// and Calculated status bit are the baseline's. DurationBad / PercentBad / WorstQuality* +/// fall through to the baseline unchanged. +/// +internal sealed class GoodOnlyStatusAggregateCalculator : StatusAggregateCalculator +{ + /// True for the two aggregate ids this class overrides. + internal static bool Overrides(NodeId aggregateId) => + aggregateId == ObjectIds.AggregateFunction_PercentGood || aggregateId == ObjectIds.AggregateFunction_DurationGood; + + /// Initializes the calculator; same contract as the baseline constructor. + public GoodOnlyStatusAggregateCalculator( + NodeId aggregateId, + DateTime startTime, + DateTime endTime, + double processingInterval, + bool stepped, + AggregateConfiguration configuration, + ITelemetryContext? telemetry) + : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry) + { + } + + /// + protected override DataValue ComputeValue(TimeSlice slice) + { + if (AggregateId == ObjectIds.AggregateFunction_DurationGood) return ComputeGoodOnly(slice, usePercent: false); + if (AggregateId == ObjectIds.AggregateFunction_PercentGood) return ComputeGoodOnly(slice, usePercent: true); + return base.ComputeValue(slice); + } + + /// The baseline ComputeDurationGoodBad(slice, isBad: false, usePercent) with Good-only counting. + private DataValue ComputeGoodOnly(TimeSlice slice, bool usePercent) + { + List? values = GetValuesWithSimpleBounds(slice); + if (values == null || values.Count == 0) + { + return GetNoDataValue(slice); + } + + List regions = GetRegionsInValueSet(values, false, true); + + double duration = 0; + double total = 0; + foreach (var region in regions) + { + total += region.Duration; + if (StatusCode.IsGood(region.StatusCode)) + { + duration += region.Duration; + } + } + + if (usePercent) + { + duration = duration / total * 100; + } + + var value = new DataValue + { + WrappedValue = SdkValueBoundary.FromClr(duration), + SourceTimestamp = GetTimestamp(slice), + ServerTimestamp = GetTimestamp(slice) + }; + value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated); + return value; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md index 3f1ddc0b3..1ba0f0c95 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md @@ -17,3 +17,14 @@ The isolated `scripts/validation/opcua-aggregate-baseline` process pins only SDK 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. + +## Recorded semantic decisions + +### #809 — PercentGood / DurationGood count Good time only (owner-approved 2026-11-11) + +The baseline `StatusAggregateCalculator.ComputeDurationGoodBad` counts Uncertain regions as **Good** when `TreatUncertainAsBad = false` (1.5.378.106 and SDK 2.0.1 count Good only). Part 13 §5.4.3.31/§5.4.3.33 (Mantis 0006166) and the HistoryRead evaluators (`TimeWeightedAggregator`, SqlServer + QuestDB) count Good regions only, so live AggregateFilter results disagreed with HistoryRead whenever Uncertain data was present. ScadaBridge uses neither AggregateFilter nor these aggregates. + +- The copied upstream files above are **unchanged** (their SHA-256s still match `baseline-sources.json`). +- `GoodOnlyStatusAggregateCalculator.cs` (application code, not a baseline copy) subclasses the baseline `StatusAggregateCalculator` and replaces only the PercentGood / DurationGood computation: the same slice values, simple bounds, stepped regions, empty-slice `BadNoData`, timestamps, value type and Calculated bit, with a region counted as Good only when its status is Good — whatever `TreatUncertainAsBad` says (when true, Uncertain is Bad, so it was never Good). `CompatibilityAggregateFactory.Create` routes exactly those two IDs to it; the other 35 IDs, including DurationBad / PercentBad / WorstQuality, are the baseline unchanged. +- Golden test: the 64 vectors this changes (38 PercentGood + 26 DurationGood, all with an effective `TreatUncertainAsBad = false` over data with Uncertain regions) are exempted **exactly** — the PercentGood / DurationGood vectors whose baseline output differs from the same case's baseline vector with `TreatUncertainAsBad = true`, where 1.5.378.182 already counted Good regions only — and each is pinned to that twin's output. The pin is therefore baseline evidence, never regenerated from the application's calculator; any further drift in any of the 6,144 cases fails. `Sdk2AggregateCompatibilityTests.Issue809_*` also pins a hand-computed case. +- Live-vs-history parity: `SdkAggregateParityTests.Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples` compares the registered calculator with `TimeWeightedAggregator` over Good / Uncertain / Bad mixes with no masks, and checks that the data discriminates against the pre-#809 baseline. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ServerAggregateConfiguration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ServerAggregateConfiguration.cs index 52f74054d..5f38bb1ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ServerAggregateConfiguration.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ServerAggregateConfiguration.cs @@ -10,7 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// The aggregate evaluators (AggregateEvaluator / TimeWeightedAggregator in /// Driver.Historian.Sql.Common) are written against fixed settings, not parameterised: Uncertain time counts as -/// Good ( = false) in the time-weighted family; the per-sample Average / Minimum / +/// Good ( = false) in TimeAverage2 / Total2, while PercentGood / DurationGood count +/// Good time only and PercentBad / DurationBad Bad time only — Uncertain in neither (Part 13 §5.4.3.31-34, Mantis +/// 0006166); the per-sample Average / Minimum / /// Maximum / Range / *ActualTime / Count are defined by Part 13 over Good values whatever this setting, as the /// SDK computes them under it; an interval is Good only when all of /// it is ( = 100) and Bad only when none of it is non-Bad @@ -23,6 +25,16 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// the SDK manager so live AggregateFilter subscriptions agree with history reads; a test pins both sides. /// /// +/// The live calculators are the application's frozen 1.5.378.182 copies (Compatibility/Aggregates), whose +/// ComputeDurationGoodBad counts Uncertain time as Good under = false. That +/// made live PercentGood / DurationGood disagree with HistoryRead whenever Uncertain data was present. #809 +/// (owner-approved 2026-11-11) overrides exactly those two (GoodOnlyStatusAggregateCalculator), so live and +/// HistoryRead now agree on them — pinned by +/// SdkAggregateParityTests.Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples. +/// Other known live-vs-HistoryRead differences (e.g. #821, an Uncertain first sample in a late-start interval) are +/// documented in docs/Historian.md. +/// +/// /// A request asking for UseServerCapabilitiesDefaults, or spelling out exactly these values, is served; /// any other configuration is refused per node with BadAggregateConfigurationRejected rather than silently /// answered under settings the client did not ask for (it used to be ignored). diff --git a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs index daf57deeb..572a6b303 100644 --- a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs +++ b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs @@ -71,7 +71,7 @@ public abstract class QualityMixConformance : ConformanceBase /// Hand-computed Part 13 DurationBad of Q's eight intervals, seconds. private static readonly double[] QDurationBad = [3, 0, 0, 0, 5, 0, 0, 3]; - /// What the 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796). + /// What the untouched 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796; live before #809). private static readonly double[] QBaselinePercentGood = [70, 100, 100, 100, 50, 100, 100, 70]; /// @@ -182,7 +182,8 @@ public abstract class QualityMixConformance : ConformanceBase var pGood = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples; ShouldBeClose(Values(good), Values(pGood).Select(p => p * Ten.TotalMilliseconds / 100).ToArray(), "DurationGood vs PercentGood"); - // The baseline (the live AggregateFilter calculator, #809) answers Q's DurationGood with the Uncertain time added. + // The untouched baseline (the live AggregateFilter calculator before #809) answers Q's DurationGood with the + // Uncertain time added; the live calculator now answers the Part 13 values above (SdkAggregateParityTests). var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), Ten, Q.Select(r => (r.T, r.V, r.Status))); ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(), diff --git a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs index b791aa83b..e1274e597 100644 --- a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs +++ b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs @@ -2,7 +2,7 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; using BaselineAggregateCalculator = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.AggregateCalculator; -using BaselineStatusAggregateCalculator = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.StatusAggregateCalculator; +using BaselineAggregators = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.Aggregators; using Shouldly; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -18,17 +18,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// Shared (source-linked) by Sql.Common.Tests and both SQL backends' integration suites. /// -/// PercentGood (and, since #820, DurationGood) are the aggregates not taken as-is (#796). The 1.5.378.182 baseline changed +/// PercentGood / DurationGood (#796, #820, #809). The 1.5.378.182 baseline changed /// StatusAggregateCalculator.ComputeDurationGoodBad (1.5.378.106 and earlier counted Good regions only): /// with TreatUncertainAsBad = false it counts Uncertain regions as Good time (as Bad when true). Part 13 /// keeps Uncertain out of both (§5.4.3.31 DurationGood counts Good regions; §5.4.3.33 PercentGood = /// DurationGood ÷ interval; Mantis 0006166, closed "no change required": an all-Uncertain interval is -/// PercentGood 0 and PercentBad 0), and so does ours. therefore answers PercentGood -/// (and DurationGood) through : the SAME baseline calculator, region machinery (simple -/// bounds, end-of-data 1 ms region, status and Partial bits) and server-default configuration, counting only -/// Good regions — exactly what the pre-.182 oracle computed. PercentBad / DurationBad still count Bad regions only -/// (unchanged by .182) and are compared as-is. runs the baseline untouched, for the test that pins -/// the divergence. +/// PercentGood 0 and PercentBad 0), and so does ours. Since #809 the LIVE calculators follow Part 13 too: +/// CompatibilityAggregateFactory serves those two through the application's +/// GoodOnlyStatusAggregateCalculator (the same baseline region machinery — simple bounds, end-of-data 1 ms +/// region, status and Partial bits — counting only Good regions), so / are +/// exactly what a live AggregateFilter computes, for every aggregate. PercentBad / DurationBad count Bad regions only +/// (unchanged by .182). runs the untouched baseline (what live served before #809), for the +/// tests that pin that divergence. /// /// /// Not 100 − PercentBad under TreatUncertainAsBad = true: that flag also changes the region machinery @@ -54,8 +55,9 @@ public static class SdkAggregateOracle }; /// - /// Runs the Part 13 oracle for over : the baseline calculator, - /// except PercentGood / DurationGood, which count Good regions only (see the remarks, #796/#820). + /// Runs the Part 13 oracle for over : the live calculator + /// () — the baseline, except PercentGood / DurationGood, which count Good regions only (see the + /// remarks, #796/#820/#809). /// /// The aggregate. /// Request start (later than for a reverse read). @@ -65,16 +67,29 @@ public static class SdkAggregateOracle /// The oracle's processed values, in the order it returns them. public static IReadOnlyList Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) => - aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood - ? RunCore(new LegacyCalculatorAdapter(new Part13GoodCalculator(AggregateId(aggregate), - start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry, - usePercent: aggregate == HistoryAggregateType.PercentGood)), start, end, samples) - : RunSdkAsIs(aggregate, start, end, interval, samples); + RunLive(aggregate, start, end, interval, samples); /// - /// Runs the baseline (1.5.378.182) calculator for exactly as it computes it, under - /// — including its PercentGood rule (Uncertain counted as Good) that - /// deliberately does not use (#796). + /// Runs exactly the calculator the server registers for live AggregateFilter monitored items + /// (CompatibilityAggregateFactory.Create, the #809 Good-only PercentGood / DurationGood included). + /// + /// The aggregate. + /// Request start (later than for a reverse read). + /// Request end. + /// Processing interval. + /// Every stored sample of the tag, any order (sorted here). + /// The configuration (default ). + /// The calculator's processed values, in the order it returns them. + public static IReadOnlyList RunLive(HistoryAggregateType aggregate, DateTime start, DateTime end, + TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples, + AggregateConfiguration? configuration = null) => + RunCore(CompatibilityAggregateFactory.Create(AggregateId(aggregate), start, end, interval.TotalMilliseconds, false, + configuration ?? DefaultConfiguration, Telemetry), start, end, samples); + + /// + /// Runs the untouched baseline (1.5.378.182) calculator for exactly as it computes it, + /// under — including its PercentGood / DurationGood rule (Uncertain counted as + /// Good) that neither HistoryRead nor, since #809, the live calculators use. /// /// The aggregate. /// Request start (later than for a reverse read). @@ -86,8 +101,9 @@ public static class SdkAggregateOracle public static IReadOnlyList RunSdkAsIs(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples, AggregateConfiguration? configuration = null) => - RunCore(CompatibilityAggregateFactory.Create(AggregateId(aggregate), start, end, interval.TotalMilliseconds, false, - configuration ?? DefaultConfiguration, Telemetry), start, end, samples); + RunCore(new LegacyCalculatorAdapter(BaselineAggregators.CreateStandardCalculator(AggregateId(aggregate), start, end, + interval.TotalMilliseconds, false, configuration ?? DefaultConfiguration, Telemetry) + ?? throw new ArgumentOutOfRangeException(nameof(aggregate))), start, end, samples); private static IReadOnlyList RunCore(IAggregateCalculator calc, DateTime start, DateTime end, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) @@ -155,42 +171,7 @@ public static class SdkAggregateOracle problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems)); } - /// - /// The baseline with PercentGood / DurationGood counting Good - /// regions only — Part 13 §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial, - /// status, the millisecond Duration unit) is the baseline's own code, run under the same configuration as - /// PercentBad / DurationBad. - /// - private sealed class Part13GoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime, - double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry, - bool usePercent) - : BaselineStatusAggregateCalculator(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry) - { - protected override LegacyDataValue ComputeValue(TimeSlice slice) - { - var values = GetValuesWithSimpleBounds(slice); - if (values == null || values.Count == 0) return GetNoDataValue(slice); - - var regions = GetRegionsInValueSet(values, false, true); - double good = 0, total = 0; - foreach (var region in regions) - { - total += region.Duration; - if (StatusCode.IsGood(region.StatusCode)) good += region.Duration; - } - - var value = new LegacyDataValue - { - WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(usePercent ? good / total * 100 : good), - SourceTimestamp = GetTimestamp(slice), - ServerTimestamp = GetTimestamp(slice), - }; - value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated); - return value; - } - } - - /// The adapter, for a calculator built here. + /// The adapter, for a baseline calculator built here. private sealed class LegacyCalculatorAdapter(BaselineAggregateCalculator calculator) : IAggregateCalculator { public NodeId AggregateId => calculator.AggregateId; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs index d7e768fc2..eb25f5cd5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs @@ -414,9 +414,10 @@ public sealed class SdkAggregateParityTests public void PercentGood_keeps_Uncertain_out_of_Good_per_Part_13_where_the_baseline_SDK_counts_it_as_Good( double from, double to) { - // #796, documented divergence (SdkAggregateOracle remarks). Part 13 §5.4.3.31 / §5.4.3.33: PercentGood is the Good - // share of the interval. The 1.5.378.182 StatusAggregateCalculator, under the server's TreatUncertainAsBad = false, - // adds Uncertain regions to it (1.5.378.106 did not). Hand-computed over 4 × 5 s regions G / U / B / G: + // #796, documented divergence of the untouched baseline (SdkAggregateOracle remarks; live follows Part 13 since + // #809). Part 13 §5.4.3.31 / §5.4.3.33: PercentGood is the Good share of the interval. The 1.5.378.182 + // StatusAggregateCalculator, under the server's TreatUncertainAsBad = false, adds Uncertain regions to it + // (1.5.378.106 did not). Hand-computed over 4 × 5 s regions G / U / B / G: // Good 10 s ⇒ 50 %, Bad 5 s ⇒ 25 %, Uncertain 5 s in neither; the baseline says 75 % Good. Pct(AsIs(HistoryAggregateType.PercentGood, from, to, GoodUncertainBadGood)) .ShouldBe(75d, 1e-9, "the baseline counts the Uncertain 5 s as Good — if this moves, revisit the oracle (#796)"); @@ -524,15 +525,16 @@ public sealed class SdkAggregateParityTests public void An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data( double from, double to) { - // #821, pinned divergence (pending the #809/#821 owner decision — this test records, it does not choose). + // #821, pinned divergence (pending the #821 owner decision — this test records, it does not choose). // Part 13 §5.4.3.3/§5.4.3.8: the interpolated-bounding signal is defined from the first sample only — 10 → 11 over // [5,12) (73.5) and 11 → 11 + 8/13 over [12,20) (4·(22 + 8/13)) ⇒ Total 163.96…, TimeAverage = Total ÷ 15 s. const double part13Total = 73.5 + 88 + 32d / 13; Pct(Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total, 1e-9); Pct(Ours(HistoryAggregateType.TimeAverage, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total / 15, 1e-9); - // The frozen 1.5.378.182 calculators — exactly what the server registers for live AggregateFilter items - // (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager serves the same code) + // The frozen 1.5.378.182 calculators — for Total / TimeAverage exactly what the server registers for live + // AggregateFilter items (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager + // serves the same code) // — integrate the uncovered [0,5) as a ramp 0 → 10 (+25) and divide TimeAverage by the WHOLE 20 s interval. Pct(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart)).ShouldBe(part13Total + 25, 1e-9); Pct(AsIs(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart)).ShouldBe((part13Total + 25) / 20, 1e-9); @@ -557,16 +559,108 @@ public sealed class SdkAggregateParityTests Stock201(agg, from, to, GoodFirstLateStart).Single().Value!.Value.ShouldBe(expected, 1e-9); } - // #809 on the same input: DurationGood / PercentGood — Part 13 (ours) and stock SDK 2.0.1 count Good regions only - // ([12,20) = 8 s); the frozen live calculators also count the Uncertain [5,12) as Good (15 s). + // #809 on the same input: DurationGood / PercentGood — Part 13 (ours), stock SDK 2.0.1 and, since #809, the live + // calculators count Good regions only ([12,20) = 8 s); the untouched baseline (live before #809) also counted the + // Uncertain [5,12) as Good (15 s). Pct(Ours(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9); Stock201(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(8_000d, 1e-9); + Pct(Sdk(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9); Pct(AsIs(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart)).ShouldBe(15_000d, 1e-9); Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9); Stock201(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(40d, 1e-9); + Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9); Pct(AsIs(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart)).ShouldBe(75d, 1e-9); } + /// + /// #809: the #810 QualityMix tag Q — Good / Uncertain / Bad samples and an outage marker, Uncertain regions starting + /// at a sample, through a sloped bound and held into an outage, with Good samples before (−10) and after (+90) every + /// window so no interval holds the start or the end of the data. + /// + private static readonly StoredSample[] UncertainMix = + [ + S(-10, HistorianValue.FromDouble(10), Good), + S(0, HistorianValue.FromDouble(10), Good), + S(4, HistorianValue.FromDouble(12), Uncertain), + S(7, HistorianValue.FromDouble(13), Bad), + S(10, HistorianValue.FromDouble(14), Good), + S(13, HistorianValue.FromDouble(15), Uncertain), + S(20, HistorianValue.FromDouble(16), Uncertain), + S(25, HistorianValue.FromDouble(17), Uncertain), + S(33, HistorianValue.FromDouble(18), Uncertain), + S(40, HistorianValue.FromDouble(20), Good), + S(45, HistorianValue.Empty, BadNoCommunication), + S(50, HistorianValue.FromDouble(22), Uncertain), + S(56, HistorianValue.FromDouble(23), Good), + S(64, HistorianValue.FromDouble(25), Uncertain), + S(67, HistorianValue.FromDouble(26), Good), + S(75, HistorianValue.FromDouble(27), Bad), + S(78, HistorianValue.FromDouble(28), Good), + S(90, HistorianValue.FromDouble(30), Good), + ]; + + /// Windows of whole intervals only (no truncated interval — ours flags it Partial, a documented divergence). + public static TheoryData WholeIntervalWindows => new() + { + { 0, 80, 20 }, { 80, 0, 20 }, + { 0, 80, 10 }, { 80, 0, 10 }, + { 5, 80, 15 }, { 80, 5, 15 }, + { 0, 60, 0 }, { 60, 0, 0 }, + }; + + [Theory] + [MemberData(nameof(WholeIntervalWindows))] + public void Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples( + double from, double to, double intervalS) + { + // #809: a live AggregateFilter monitored item and a HistoryRead-Processed (SqlServer / QuestDB share + // TimeWeightedAggregator) answer PercentGood / DurationGood identically over Uncertain data — same values, + // timestamps, status and Partial / Calculated bits, with NO documented-divergence mask. RunLive is exactly + // CompatibilityAggregateFactory.Create, the calculator the server registers (Sdk2AggregateCompatibilityTests + // proves the booted runtime manager serves the same code). + var interval = intervalS > 0 ? TimeSpan.FromSeconds(intervalS) : (At(to) - At(from)).Duration(); + var failures = new List(); + var discriminated = 0; + foreach (var agg in new[] { HistoryAggregateType.PercentGood, HistoryAggregateType.DurationGood }) + foreach (var (name, data) in new[] { ("UncertainMix", UncertainMix), ("DenseUncertain", DenseUncertain) }) + { + var rows = data.Select(x => (x.TimeUtc, x.Value, x.Status)).ToArray(); + var live = SdkAggregateOracle.RunLive(agg, At(from), At(to), interval, rows); + try + { + SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), live, $"live {agg} {name} {from}→{to}/{intervalS}s"); + } + catch (Exception ex) + { + failures.Add(ex.Message); + } + // The data must discriminate: the pre-#809 live calculator (the untouched baseline) counts the Uncertain + // time as Good, so it disagrees somewhere — or this parity would pass against the old behaviour too. + var before = SdkAggregateOracle.RunSdkAsIs(agg, At(from), At(to), interval, rows); + if (before.Zip(live).Any(p => Math.Abs(Convert.ToDouble(p.First.Value) - Convert.ToDouble(p.Second.Value)) > 1e-9)) + discriminated++; + } + failures.ShouldBeEmpty(string.Join("\n", failures)); + discriminated.ShouldBe(4, "every aggregate × data set must differ from the pre-#809 live answer somewhere"); + } + + [Fact] + public void Live_AggregateFilter_PercentGood_and_DurationGood_are_the_hand_computed_Part_13_values() + { + // #809, hand-computed over UncertainMix's eight 10 s intervals (QualityMixConformance derives them region by + // region): Good seconds 4, 3, 0, 0, 5, 4, 3, 2. The pre-#809 live answer added the Uncertain time + // (7, 10, 10, 10, 5, 10, 10, 7 s). + double[] goodSeconds = [4, 3, 0, 0, 5, 4, 3, 2]; + var rows = UncertainMix.Select(x => (x.TimeUtc, x.Value, x.Status)).ToArray(); + var ten = TimeSpan.FromSeconds(10); + SdkAggregateOracle.RunLive(HistoryAggregateType.DurationGood, At(0), At(80), ten, rows) + .Select(r => Convert.ToDouble(r.Value)).ShouldBe(goodSeconds.Select(g => g * 1000), tolerance: 1e-9); + SdkAggregateOracle.RunLive(HistoryAggregateType.PercentGood, At(0), At(80), ten, rows) + .Select(r => Convert.ToDouble(r.Value)).ShouldBe(goodSeconds.Select(g => g * 10), tolerance: 1e-9); + SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), ten, rows) + .Select(r => Convert.ToDouble(r.Value)).ShouldBe([7000d, 10000, 10000, 10000, 5000, 10000, 10000, 7000], tolerance: 1e-9); + } + /// #684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6). private static readonly StoredSample[] UncertainBeyond = [ diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs index 6dffd4484..7132eaf93 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs @@ -6,9 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// #821 / #809 facts, through the BOOTED server's runtime AggregateManager — the calculators a live -/// AggregateFilter monitored item gets, under the server's own default configuration. This pins current behaviour for -/// the owner decision; it does not choose. The HistoryRead (Part 13) answers and SDK 2.0.1's stock answers for the -/// same input are pinned beside it in SdkAggregateParityTests. +/// AggregateFilter monitored item gets, under the server's own default configuration. The #821 Total / TimeAverage +/// facts pin current behaviour for the owner decision; they do not choose. #809 is decided (2026-11-11): live +/// PercentGood / DurationGood count Good time only, equal to HistoryRead. The HistoryRead (Part 13) answers and SDK +/// 2.0.1's stock answers for the same input are pinned beside it in SdkAggregateParityTests. /// public sealed class LiveAggregateLateStartFactsTests { @@ -45,9 +46,10 @@ public sealed class LiveAggregateLateStartFactsTests const double part13Total = 73.5 + 88 + 32d / 13; One(ObjectIds.AggregateFunction_Total).ShouldBe(part13Total + 25, 1e-9); // 188.96… One(ObjectIds.AggregateFunction_TimeAverage).ShouldBe((part13Total + 25) / 20, 1e-9); // 9.448… (Part 13: 10.93…) - // #809: the Uncertain [5,12) counts as Good live (HistoryRead: 8 000 ms / 40 %). - One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(15_000d, 1e-9); - One(ObjectIds.AggregateFunction_PercentGood).ShouldBe(75d, 1e-9); + // #809: the Uncertain [5,12) is not Good — only [12,20) is, live as in HistoryRead (8 000 ms / 40 %; before #809 + // the live calculator counted the Uncertain time too: 15 000 ms / 75 %). + One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(8_000d, 1e-9); + One(ObjectIds.AggregateFunction_PercentGood).ShouldBe(40d, 1e-9); // DurationBad / PercentBad agree with HistoryRead: the 5 s before the data is Bad. One(ObjectIds.AggregateFunction_DurationBad).ShouldBe(5_000d, 1e-9); One(ObjectIds.AggregateFunction_PercentBad).ShouldBe(25d, 1e-9); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AggregateCompatibilityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AggregateCompatibilityTests.cs index 87d1fd57e..e40367fae 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AggregateCompatibilityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AggregateCompatibilityTests.cs @@ -9,8 +9,20 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors. +/// +/// #809 (owner-approved 2026-11-11): PercentGood / DurationGood no longer count Uncertain time as Good. The baseline +/// vectors that changes are exempted EXACTLY — a PercentGood / DurationGood vector whose output differs from the same +/// case computed by the baseline SDK with TreatUncertainAsBad = true (its "twin": there 1.5.378.182 already +/// counted Good regions only) — and each is pinned to that twin instead. The twin is baseline evidence, not output of +/// the application's calculator, so the pin never regenerates expectations from the implementation. +/// public sealed class Sdk2AggregateCompatibilityTests { + private const string PercentGood = "AggregateFunction_PercentGood"; + private const string DurationGood = "AggregateFunction_DurationGood"; + private const int Issue809PercentGoodExemptions = 38; + private const int Issue809DurationGoodExemptions = 26; + [Theory] [InlineData(false)] [InlineData(true)] @@ -19,57 +31,145 @@ public sealed class Sdk2AggregateCompatibilityTests await using var rig = throughRuntimeManager ? await ServerEventRig.StartAsync("AggregateBaseline", encrypted: false, TestContext.Current.CancellationToken) : null; - var baseline = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; - var telemetry = DefaultTelemetry.Create(_ => { }); - var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc); + var baseline = LoadBaseline(); + var exemptions = Issue809Exemptions(baseline); var failures = new List(); foreach (var vector in baseline.Vectors) { - var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!; - var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70); - var profile = vector.Configuration; - var config = new AggregateConfiguration - { - TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad, - PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100, - UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false, - UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false, - }; - var calculator = throughRuntimeManager - ? rig!.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config) - : CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry); - calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}."); - var output = new List(); var accepted = new List(); string? error = null; - try - { - var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds); - foreach (var sample in vector.Reverse ? samples.Reverse() : samples) - { - var time = epoch.AddSeconds(sample.Seconds); - accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds))); - if (vector.Incremental) Drain(false); - } - Drain(true); - } - catch (Exception ex) { error = ex.GetType().FullName; } - if (error != vector.Exception || !output.SequenceEqual(vector.Output) || (vector.QueueAccepted is { } expectedAccepted && !accepted.SequenceEqual(expectedAccepted))) - { - failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}"); - } - void Drain(bool partial) - { - for (var guard = 0; guard < 100; guard++) - { - if (!calculator.TryGetProcessedValue(partial, out var value)) return; - var payload = SdkValueBoundary.ToClr(value.WrappedValue); - output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds)); - } - throw new InvalidOperationException("Baseline calculator did not terminate."); - } + // #809: an exempted case is held to its baseline uncertain-as-bad twin; every other case to its own vector. + var expected = exemptions.TryGetValue(vector, out var twin) ? twin.Output : vector.Output; + if (Check(baseline, vector, expected, rig) is { } failure) failures.Add(failure); } baseline.Vectors.Length.ShouldBe(6144); baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37); - failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing."); + exemptions.Keys.Count(x => x.Aggregate == PercentGood).ShouldBe(Issue809PercentGoodExemptions); + exemptions.Keys.Count(x => x.Aggregate == DurationGood).ShouldBe(Issue809DurationGoodExemptions); + failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing (#809 exemptions: the uncertain-as-bad twin)."); + } + + /// + /// #809 pin: the exemptions are exactly the PercentGood / DurationGood cases that counted Uncertain as Good, and + /// every PercentGood / DurationGood case — exempt or not, whatever its configuration — now answers its baseline + /// uncertain-as-bad twin (Good regions only). + /// + [Fact] + public void Issue809_percent_and_duration_good_count_only_good_time() + { + var baseline = LoadBaseline(); + var exemptions = Issue809Exemptions(baseline); + var failures = new List(); + var checkedCount = 0; + var twinned = 0; + foreach (var vector in baseline.Vectors.Where(v => v.Aggregate is PercentGood or DurationGood)) + { + // A data set with no uncertain-as-bad twin (the numeric-width edge sets) has no Uncertain region to move. + var twin = UncertainAsBadTwin(baseline, vector); + if (exemptions.ContainsKey(vector)) + EffectiveTreatUncertainAsBad(vector).ShouldBeFalse($"{Describe(vector)}: only an Uncertain-as-Good case may change."); + if (Check(baseline, vector, twin?.Output ?? vector.Output, rig: null) is { } failure) failures.Add(failure); + checkedCount++; + if (twin is not null) twinned++; + } + checkedCount.ShouldBe(352); + twinned.ShouldBeGreaterThan(exemptions.Count); + exemptions.Count.ShouldBe(Issue809PercentGoodExemptions + Issue809DurationGoodExemptions); + failures.ShouldBeEmpty("PercentGood / DurationGood must count Good regions only (Part 13 §5.4.3.31/§5.4.3.33, #809)."); + } + + /// + /// #809 hand-computed pin, independent of the expected outputs in any fixture: the "uncertain" data set (0 s Good, + /// 5 s Uncertain, 15 s Good, 25 s Bad, 35 s Good, 50 s Good) over 20 s intervals from 0 to 70 s, sloped, forward. + /// [0,20): Good 0–5 and 15–20 = 10 s (the baseline said 20 s); [20,40): the bound at 20 slopes into the Bad sample + /// (Uncertain), 25–35 Bad, 35–40 Good = 5 s (baseline 10 s); [40,60): 20 s; [60,70): all 10 s of the partial + /// interval = 100 %. + /// + [Theory] + [InlineData(DurationGood, new[] { 10000d, 5000d, 20000d, 10000d })] + [InlineData(PercentGood, new[] { 50d, 25d, 100d, 100d })] + public void Issue809_uncertain_time_is_not_good_hand_computed(string aggregate, double[] expected) + { + var baseline = LoadBaseline(); + var vector = baseline.Vectors.Single(v => v.Aggregate == aggregate && v.DataSet == "uncertain" && !v.Reverse && !v.Stepped + && !v.UncertainAsBad && !v.Incremental && v.Configuration is null); + var output = Run(baseline, vector, rig: null, out var error); + error.ShouldBeNull(); + output.ShouldAllBe(x => x.Type == "Double"); + output.Select(x => double.Parse(x.Payload!, CultureInfo.InvariantCulture)).ShouldBe(expected); + } + + private static Baseline LoadBaseline() => + JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + private static bool EffectiveTreatUncertainAsBad(Vector vector) => vector.Configuration?.TreatUncertainAsBad ?? vector.UncertainAsBad; + + /// The same case computed by the baseline SDK with TreatUncertainAsBad = true and no explicit profile. + private static Vector? UncertainAsBadTwin(Baseline baseline, Vector vector) => + baseline.Vectors.SingleOrDefault(v => v.Aggregate == vector.Aggregate && v.DataSet == vector.DataSet && v.Reverse == vector.Reverse + && v.Stepped == vector.Stepped && v.Incremental == vector.Incremental && v.UncertainAsBad && v.Configuration is null); + + /// #809: the PercentGood / DurationGood vectors whose baseline output differs from their uncertain-as-bad twin. + private static Dictionary Issue809Exemptions(Baseline baseline) => + baseline.Vectors + .Where(v => v.Aggregate is PercentGood or DurationGood) + .Select(v => (Vector: v, Twin: UncertainAsBadTwin(baseline, v))) + .Where(x => x.Twin is not null && !x.Vector.Output.SequenceEqual(x.Twin.Output)) + .ToDictionary<(Vector Vector, Vector? Twin), Vector, Vector>(x => x.Vector, x => x.Twin!, System.Collections.Generic.ReferenceEqualityComparer.Instance); + + private static string Describe(Vector vector) => + $"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}/config={JsonSerializer.Serialize(vector.Configuration)}"; + + private static string? Check(Baseline baseline, Vector vector, Value[] expected, ServerEventRig? rig) + { + var output = Run(baseline, vector, rig, out var error); + return error != vector.Exception || !output.SequenceEqual(expected) || (vector.QueueAccepted is { } expectedAccepted && !output.Accepted.SequenceEqual(expectedAccepted)) + ? $"{Describe(vector)}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(expected)} actual {JsonSerializer.Serialize(output.ToArray())}" + : null; + } + + private static RunOutput Run(Baseline baseline, Vector vector, ServerEventRig? rig, out string? error) + { + var telemetry = DefaultTelemetry.Create(_ => { }); + var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc); + var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!; + var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70); + var profile = vector.Configuration; + var config = new AggregateConfiguration + { + TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad, + PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100, + UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false, + UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false, + }; + var calculator = rig is not null + ? rig.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config) + : CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry); + calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}."); + var output = new RunOutput(); + error = null; + try + { + var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds); + foreach (var sample in vector.Reverse ? samples.Reverse() : samples) + { + var time = epoch.AddSeconds(sample.Seconds); + output.Accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds))); + if (vector.Incremental) Drain(false); + } + Drain(true); + } + catch (Exception ex) { error = ex.GetType().FullName; } + return output; + + void Drain(bool partial) + { + for (var guard = 0; guard < 100; guard++) + { + if (!calculator.TryGetProcessedValue(partial, out var value)) return; + var payload = SdkValueBoundary.ToClr(value.WrappedValue); + output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds)); + } + throw new InvalidOperationException("Baseline calculator did not terminate."); + } } // UA FILETIME zero is the old CLR unset sentinel, not a different historical instant. @@ -84,6 +184,7 @@ public sealed class Sdk2AggregateCompatibilityTests "Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture), "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 f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() }; + private sealed class RunOutput : List { public List Accepted { get; } = []; } record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary Data, Vector[] Vectors); record Sample(double Seconds,string Type,string? Payload,uint Status,ushort SourcePicoseconds = 0,ushort ServerPicoseconds = 0,long ServerOffsetTicks = 0); record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);