fix(historian): #680 review — recovery/only-sample TA2/Total2 value, outage-last + Boolean parity, docs
TimeAverage2/Total2: the interval the data ends inside with no non-Bad time before its last sample (comms-loss recovery, a single sample) answers that sample's value (Total2 0), Uncertain + Partial, instead of Bad/BadNoData — as the SDK. Parity datasets RecoveryEnds, OutageLast, BoolSwitch with documented masks; ProcessedConformance's reverse TA/Total mask narrowed to the later-edge-sample interval. Docs: report-by-exception consequence, outage-last and Boolean differences; CLAUDE.md historian bullet.
This commit is contained in:
@@ -1350,7 +1350,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 (12, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted five (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM`) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; client `AggregateConfiguration` ignored; status = the SDK `AggregateCalculator` time-based rule, Uncertain time counts as Good; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`). Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **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) 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". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **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.
|
||||
- **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good.
|
||||
@@ -1381,7 +1381,7 @@ authorization uses the standard `AccessLevels.HistoryRead` bit set at materializ
|
||||
| `MaxTieClusterOverfetch` | `65536` | Bounded over-fetch the HistoryRead-Raw paging uses to page within an oversized same-timestamp tie cluster (retained from the prior backend) |
|
||||
| `HistoryReadBatchParallelism` | `4` | Max nodes served concurrently within one HistoryRead batch (03/S3); `⌈N/P⌉ × CallTimeout` worst case instead of sequential |
|
||||
| `MaxConcurrentHistoryReadBatches` | `16` | Process-wide concurrent HistoryRead batch cap (03/S3); flood degrades to `BadTooManyOperations`. Total in-flight reads capped at 16 × 4 = 64 |
|
||||
| `MaxTimeWeightedRawRows` | `1000000` | `SqlServer`/`QuestDb` only (#671): raw in-window rows one time-weighted aggregate read (incl. `Total`) may stream into the C# evaluator; window query capped at this + 1, past it ⇒ `BadTooManyOperations` for that node. ≤ 0 = unbounded |
|
||||
| `MaxTimeWeightedRawRows` | `1000000` | `SqlServer`/`QuestDb` only (#671): raw in-window rows one time-weighted aggregate read (incl. `Total` and, since #680, PercentGood/PercentBad) may stream into the C# evaluator; window query capped at this + 1, past it ⇒ `BadTooManyOperations` for that node. ≤ 0 = unbounded |
|
||||
| `HistoryReadDeadline` | `00:01:00` | Per-request HistoryRead wall-clock deadline (03/S3); a node still in flight surfaces `BadTimeout`. Non-positive = unbounded |
|
||||
|
||||
### Alarm-history path (`AlarmHistorian` section)
|
||||
|
||||
+23
-3
@@ -1002,6 +1002,16 @@ and the raw-row cap below). Bucketing (edges, truncated interval, reverse anchor
|
||||
> two samples now has a value instead of `BadNoData`.
|
||||
> - **Partial:** set on the forward end-of-data interval (`TimeAverage2`/`Total2`/percentages) and, on a reverse read, on the interval
|
||||
> holding the latest sample when nothing follows it (all of this family but `Interpolative`).
|
||||
> - ⚠️ **Report-by-exception tags (a slowly changing or deadband-filtered value) are the visible case.** A tag whose last stored
|
||||
> sample lies *before* the window and that has nothing after it has, by Part 13, **ended its data**: every interval of
|
||||
> `TimeAverage2` / `Total2` / `PercentGood` / `PercentBad` is **`BadNoData`**, and every interval of `TimeAverage` / `Total` is the
|
||||
> held value, **Uncertain**. When the next sample arrives, a re-read of the same window returns values / `Good` for those intervals
|
||||
> (the held value is then interpolated between real samples, not extrapolated). So a dashboard, a ScadaBridge consumer or any
|
||||
> client that caches processed results for "closed" intervals can see an interval go `BadNoData` → value, or Uncertain → Good, on a
|
||||
> later read — re-read recent intervals, or use `TimeAverage`/`Total` (which always carry a value) for a report-by-exception tag. The
|
||||
> SDK's own calculators behave the same way. Likewise the interval holding a tag's latest sample is Partial and, until a later
|
||||
> sample arrives, `TimeAverage2`/`Total2` there cover only up to it — the comms-loss recovery shape (a single Good sample after an
|
||||
> outage marker, nothing after) answers that sample's value (`Total2` 0), Uncertain + Partial, never Bad/null.
|
||||
|
||||
**Server defaults, fixed:** `TreatUncertainAsBad = false`, `PercentDataBad = PercentDataGood = 100`, `UseSlopedExtrapolation = false`
|
||||
(`ServerAggregateConfiguration` in the OPC UA server project — the SDK's own `AggregateManager` defaults, which a test pins). **A
|
||||
@@ -1049,8 +1059,10 @@ are used", and the SDK's `AverageAggregateCalculator`):
|
||||
value held up to an outage). The duration is the interval's, cut at the end of the data:
|
||||
|
||||
- After the end of the data ⇒ `BadNoData` (Part 13 "After End of Data"); the interval the data ends inside covers only up to the last
|
||||
sample and is **Partial** (forward); when the data ends exactly on its start there is nothing to cover and the value is the last
|
||||
sample's (`Total2` 0), with its status.
|
||||
sample and is **Partial** (forward). When it has no non-Bad time before the last sample — the data ends exactly on its start, or the
|
||||
last sample is the first usable one after an outage (comms-loss recovery), or the tag's only sample — there is nothing to cover and
|
||||
the value is the last sample's (`Total2` 0): `Good` only when the data ends exactly on the interval start with a Good sample,
|
||||
otherwise `UncertainDataSubNormal` (the SDK answers the same, modulo its 1 ms artifact).
|
||||
- Every instant of the (covered) interval non-Bad ⇒ `Good`.
|
||||
- Some non-Bad time but not all (an outage, or time before the first sample) ⇒ `UncertainDataSubNormal`.
|
||||
- No non-Bad time but some outage time (an interval wholly inside an outage) ⇒ generic **`Bad`** (`0x80000000`), value null — Part 13's
|
||||
@@ -1118,7 +1130,15 @@ ours follow Part 13: `TimeAverage2`/`Total2` stop at the last sample, the percen
|
||||
read the SDK's backward slice files a sample exactly on an interval's later edge as its early bound — so a reverse `Interpolative` at
|
||||
such a point comes back Interpolated (Uncertain if a Bad sample follows) and a reverse `TimeAverage`/`Total` interval ending on it takes
|
||||
the status of the segment beyond it — where ours, like the SDK forward, returns the raw sample and keeps the interval's own segments
|
||||
(an SDK defect: its forward and reverse answers differ).
|
||||
(an SDK defect: its forward and reverse answers differ). Three more, kept deliberately (#680 review): an **outage marker as the last
|
||||
sample** (nothing after it) runs Bad to the window end here — `TimeAverage2`/`Total2` generic `Bad` and the percentages 100 % Bad
|
||||
after it, no end-of-data Partial on the interval holding it, and `TimeAverage`/`Total` Uncertain over the span the last usable value is
|
||||
held up to the marker (no Good value follows ⇒ extrapolated) — where the SDK treats the data as ended at the marker (`BadNoData` after
|
||||
it, Partial on that interval, the held span Good): the tag is in an outage, not out of data. **Booleans** are stepped here (a
|
||||
non-analog value is held; the simple bound next to an Uncertain sample takes the earlier sample's status), while the SDK interpolates
|
||||
them as numbers — e.g. an interval that is `false` for its first half and `true` for its second averages 0.5 here, 1 in the SDK, and a
|
||||
PercentGood region ahead of an Uncertain Boolean is Good here, Uncertain in the SDK. Each is pinned by `TimeWeightedAggregatorTests` and
|
||||
by a parity dataset (`OutageLast`, `BoolSwitch`) whose masks are documented in `SdkAggregateParityTests`.
|
||||
|
||||
**Pushdown trade-off.** Unlike the per-sample aggregates, the time-weighted family is **not** pushed down to SQL: the backend streams
|
||||
the window's rows plus the four bounds (one batch of five result sets on SQL Server; a bounds `UNION ALL` + a window select on QuestDB)
|
||||
|
||||
@@ -337,11 +337,16 @@ public sealed class TimeWeightedAggregator
|
||||
{
|
||||
// After the end of the data: nothing to aggregate (Part 13 "After End of Data: Bad_NoData").
|
||||
if (lo > dataEnd) return AggregateEvaluator.NoData(stamp, now);
|
||||
if (lo == dataEnd && _prevAny is { } last)
|
||||
if (_usableTicks[i] == 0 && dataEnd < hi && _prevAny is { } last)
|
||||
{
|
||||
// The data ends exactly on this interval's start: nothing covered; the value at that instant.
|
||||
// The data ends inside this interval and no non-Bad time precedes its last sample here (the data
|
||||
// ends on the interval's start; or the last sample is the first usable one after an outage — the
|
||||
// comms-loss recovery shape — or the tag's only sample): nothing covered, so the value at that
|
||||
// instant (Total2 0). Good only when the data ends exactly on the interval start with a Good
|
||||
// sample (nothing before it in the interval); otherwise the interval also holds Bad / no-data time
|
||||
// ⇒ Uncertain (the SDK answers the same, modulo its 1 ms artifact).
|
||||
if (!IsNumeric(last)) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
|
||||
var st = last.IsGood ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
|
||||
var st = lo == dataEnd && last.IsGood ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
|
||||
return Calc(_aggregate == HistoryAggregateType.TimeAverage2 ? last.Value.Number : 0d, st, endOfData);
|
||||
}
|
||||
duration = Math.Min(hi, dataEnd) - lo;
|
||||
|
||||
@@ -352,16 +352,28 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
}
|
||||
}
|
||||
var reverseInterpolative = agg == HistoryAggregateType.Interpolative && from > to;
|
||||
// Documented SDK divergence (#680, see SdkAggregateParityTests): on a reverse read the SDK files a sample exactly
|
||||
// on an interval's later edge as its early bound, so TimeAverage/Total there take the status of the segment
|
||||
// beyond it (Dense: +20 next to the Bad +22.5).
|
||||
var reverseEdgeStatus = from > to && agg is HistoryAggregateType.TimeAverage or HistoryAggregateType.Total;
|
||||
SdkAggregateOracle.ShouldMatch(ours, SdkAggregateOracle.Run(agg, start, end, interval, rows),
|
||||
var sdk = SdkAggregateOracle.Run(agg, start, end, interval, rows);
|
||||
if (from > to && agg is HistoryAggregateType.TimeAverage or HistoryAggregateType.Total)
|
||||
{
|
||||
// Documented SDK divergence (#680, see SdkAggregateParityTests.MaskDocumented): on a reverse read the SDK files
|
||||
// a sample exactly on an interval's later edge as its early bound, so a TimeAverage/Total interval ending on a
|
||||
// sample takes the status of the segment beyond it (Dense: +20 next to the Bad +22.5). Only that interval,
|
||||
// only Good (ours) vs Uncertain (SDK), is aligned.
|
||||
var plan = BucketPlan.Create(start, end, interval);
|
||||
for (long i = 0; i < plan.BucketCount; i++)
|
||||
{
|
||||
var k = (int)(plan.BucketCount - 1 - i);
|
||||
if (k < sdk.Count && rows.Any(r => r.Item1.Ticks == plan.UpperTicks(i))
|
||||
&& (ours[k].StatusCode & 0xFFFF0000u) == 0
|
||||
&& (sdk[k].StatusCode & 0xFFFF0000u) == 0x40A40000u)
|
||||
ours[k] = ours[k] with { StatusCode = ours[k].StatusCode | 0x40A40000u };
|
||||
}
|
||||
}
|
||||
SdkAggregateOracle.ShouldMatch(ours, sdk,
|
||||
$"{agg} {from}→{to}/{intervalS}s",
|
||||
compareCalculatedInterpolatedBits: tw && !reverseInterpolative,
|
||||
// pre-existing, #680: Start/End raw-sample timestamp + status; Delta status (see SdkAggregateParityTests).
|
||||
compareCodeBits: agg != HistoryAggregateType.Delta && !reverseInterpolative && !startOrEnd
|
||||
&& !reverseEdgeStatus,
|
||||
compareCodeBits: agg != HistoryAggregateType.Delta && !reverseInterpolative && !startOrEnd,
|
||||
compareTimestamps: !startOrEnd);
|
||||
}
|
||||
}
|
||||
|
||||
+88
-1
@@ -32,6 +32,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
||||
/// <c>GetRegionsInValueSet</c>); Part 13 ends TimeAverage2/Total2 at the last sample and runs the PercentGood/Bad
|
||||
/// region to the interval end, which ours does (#680; pinned by <c>TimeWeightedAggregatorTests</c>). That
|
||||
/// interval's value is not compared; its status, Partial bit and timestamp are.</item>
|
||||
/// <item><b>an outage marker as the last sample</b> (<see cref="OutageLast"/>): ours runs the outage to the window
|
||||
/// end — TimeAverage2/Total2 <c>Bad</c>, the percentages 100 % Bad, no end-of-data Partial on the interval holding
|
||||
/// it; the SDK treats the data as ended (<c>BadNoData</c> after it, Partial on that interval); and TimeAverage/Total
|
||||
/// over the span the last usable value is held up to the marker are Uncertain here (no Good value follows ⇒
|
||||
/// extrapolated, Part 13), Good in the SDK — masked by
|
||||
/// <see cref="MaskDocumented"/>;</item>
|
||||
/// <item><b>Booleans</b> (<see cref="BoolSwitch"/>): ours steps them (Part 13: a non-analog value is held, and the
|
||||
/// bound next to an Uncertain sample takes the earlier sample's status); the SDK interpolates them as numbers — its
|
||||
/// values (e.g. 1 vs our 0.5 over an interval switching half-way) and the Good/Uncertain choice are not compared,
|
||||
/// Bad vs non-Bad, timestamps and Partial are;</item>
|
||||
/// <item><b>TimeAverage / Total, reverse, a sample exactly on an interval's later edge:</b> the SDK's backward
|
||||
/// <c>UpdateSlice</c> files it as the early bound (below), so the interval takes the status of the segment beyond
|
||||
/// it (Uncertain when that segment skips a Bad sample); ours keeps the interval's own segments — masked by
|
||||
@@ -91,6 +101,48 @@ public sealed class SdkAggregateParityTests
|
||||
S(100, HistorianValue.FromDouble(40), Good),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// #680 review: the comms-loss recovery shape — a Good sample, an outage marker, then ONE recovery sample and nothing
|
||||
/// after (+27.5, inside an interval of every window, never on an edge): the interval holding the recovery sample has
|
||||
/// no non-Bad time before it, so TimeAverage2/Total2 answer the sample's value (Total2 0), Uncertain + Partial.
|
||||
/// </summary>
|
||||
private static readonly StoredSample[] RecoveryEnds =
|
||||
[
|
||||
S(-10, HistorianValue.FromDouble(10), Good),
|
||||
S(5, HistorianValue.Empty, BadNoCommunication),
|
||||
S(27.5, HistorianValue.FromDouble(20), Good),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// #680 review: an outage marker as the LAST sample (+31, nothing after) — ours: the outage runs to the window end
|
||||
/// (TimeAverage2/Total2 Bad, the percentages 100 % Bad); the SDK: the data ended (BadNoData, Partial on the interval
|
||||
/// holding the marker). Also TimeAverage/Total over the span the last usable value is held up to the marker: ours
|
||||
/// Uncertain (Part 13 — no Good value after it, so extrapolated), the SDK Good. Documented, deliberate differences.
|
||||
/// </summary>
|
||||
private static readonly StoredSample[] OutageLast =
|
||||
[
|
||||
S(-10, HistorianValue.FromDouble(10), Good),
|
||||
S(25, HistorianValue.FromDouble(20), Good),
|
||||
S(31, HistorianValue.Empty, BadNoCommunication),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// #680 review: Boolean (stepped) data with an Uncertain and a Bad sample. Ours steps Booleans (Part 13: a
|
||||
/// non-analog value is held); the SDK interpolates them as numbers and bounds them sloped — so only timestamps,
|
||||
/// status and Partial are compared for the values it computes that way (see <see cref="MaskDocumented"/>).
|
||||
/// </summary>
|
||||
private static readonly StoredSample[] BoolSwitch =
|
||||
[
|
||||
S(-10, HistorianValue.FromBoolean(true), Good),
|
||||
S(10, HistorianValue.FromBoolean(false), Good),
|
||||
S(27.5, HistorianValue.FromBoolean(true), Uncertain),
|
||||
S(32, HistorianValue.FromBoolean(false), Good),
|
||||
S(47.5, HistorianValue.FromBoolean(true), Good),
|
||||
S(62, HistorianValue.FromBoolean(true), Bad),
|
||||
S(71, HistorianValue.FromBoolean(false), Good),
|
||||
S(100, HistorianValue.FromBoolean(true), Good),
|
||||
];
|
||||
|
||||
/// <summary>Data that starts inside the window (no sample before +7) and runs past its end.</summary>
|
||||
private static readonly StoredSample[] LateStart = Enumerable.Range(0, 20)
|
||||
.Select(i => S(7 + i * 5, HistorianValue.FromDouble(i % 2 == 0 ? 20 : 40), Good))
|
||||
@@ -148,7 +200,11 @@ public sealed class SdkAggregateParityTests
|
||||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||||
var list = ours.ToList();
|
||||
var last = data.Max(x => x.TimeUtc).Ticks;
|
||||
var dataEnds = last < Math.Max(At(from).Ticks, At(to).Ticks) && data.Single(x => x.TimeUtc.Ticks == last).IsUsable;
|
||||
var endsInWindow = last < Math.Max(At(from).Ticks, At(to).Ticks);
|
||||
var dataEnds = endsInWindow && data.Single(x => x.TimeUtc.Ticks == last).IsUsable;
|
||||
var outageEnds = endsInWindow && !dataEnds;
|
||||
var boolean = data.Any(x => x.Value.Kind == HistorianValueKind.Boolean);
|
||||
var percent = agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad;
|
||||
for (long i = 0; i < plan.BucketCount; i++)
|
||||
{
|
||||
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
|
||||
@@ -170,6 +226,36 @@ public sealed class SdkAggregateParityTests
|
||||
if (dataEnds && (twoVariant || agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad)
|
||||
&& last >= plan.LowerTicks(i) && last < plan.UpperTicks(i) && sdk[k].Value is not null)
|
||||
list[k] = list[k] with { Value = sdk[k].Value };
|
||||
// An outage marker as the last sample: ours runs the outage to the window end (no end-of-data Partial on the
|
||||
// interval holding it; Bad / 100 % Bad after it); the SDK treats the data as ended there.
|
||||
if (outageEnds && (twoVariant || percent))
|
||||
{
|
||||
if (last >= plan.LowerTicks(i) && last < plan.UpperTicks(i))
|
||||
list[k] = list[k] with
|
||||
{
|
||||
StatusCode = (list[k].StatusCode & ~HistorianInfoBits.HistorianPartial)
|
||||
| (sdk[k].StatusCode & HistorianInfoBits.HistorianPartial),
|
||||
};
|
||||
else if (plan.LowerTicks(i) > last && (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.BadNoData)
|
||||
list[k] = list[k] with { Value = null, StatusCode = HistorianStatusCodes.BadNoData };
|
||||
}
|
||||
// ...and TimeAverage/Total hold the last usable value up to that marker: Part 13 calls that extrapolation (no
|
||||
// Good value after it) ⇒ Uncertain; the SDK, which sees a later (Bad) sample, reports the held span Good.
|
||||
var lastUsable = data.Where(x => x.IsUsable).Max(x => x.TimeUtc).Ticks;
|
||||
if (outageEnds && agg is HistoryAggregateType.TimeAverage or HistoryAggregateType.Total
|
||||
&& plan.LowerTicks(i) < last && plan.UpperTicks(i) > lastUsable
|
||||
&& (list[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.UncertainDataSubNormal
|
||||
&& (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.Good)
|
||||
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~StatusBits.CodeBitsMask };
|
||||
// Booleans: the SDK slopes them (values, and the status of a bound next to an Uncertain sample); ours steps.
|
||||
if (boolean && sdk[k].Value is not null && list[k].Value is not null)
|
||||
list[k] = list[k] with { Value = sdk[k].Value };
|
||||
if (boolean && (StatusBits.IsGood(list[k].StatusCode) || StatusBits.IsGood(sdk[k].StatusCode))
|
||||
&& !StatusBits.IsBad(list[k].StatusCode) && !StatusBits.IsBad(sdk[k].StatusCode))
|
||||
list[k] = list[k] with
|
||||
{
|
||||
StatusCode = (list[k].StatusCode & ~StatusBits.CodeBitsMask) | (sdk[k].StatusCode & StatusBits.CodeBitsMask),
|
||||
};
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -188,6 +274,7 @@ public sealed class SdkAggregateParityTests
|
||||
foreach (var (name, data) in new[]
|
||||
{
|
||||
("Bounded", Bounded), ("Dense", Dense), ("EndsInside", EndsInside), ("OutageInside", OutageInside),
|
||||
("RecoveryEnds", RecoveryEnds), ("OutageLast", OutageLast), ("BoolSwitch", BoolSwitch),
|
||||
})
|
||||
{
|
||||
try
|
||||
|
||||
+78
@@ -180,6 +180,84 @@ public sealed class TimeWeightedAggregatorTests
|
||||
((double)Run(HistoryAggregateType.TimeAverage, 0, 20, 0, late).Single().Value!).ShouldBe(10d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_recovery_sample_after_an_outage_that_ends_the_data_is_its_own_value_Uncertain_and_Partial()
|
||||
{
|
||||
// #680 review (the comms-loss recovery shape): −10 = 10 G, +5 outage, +25 = 20 G (the recovery), nothing after.
|
||||
// [0,20): 10 held into the outage over [0,5) ⇒ TimeAverage2 10 (Total2 50), Uncertain. [20,40) holds the end of the
|
||||
// data but no non-Bad time before it ⇒ the sample's value 20 (Total2 0), Uncertain + Partial — never Bad/null (the
|
||||
// SDK answers the same). [40,60) is after the end of the data ⇒ BadNoData.
|
||||
StoredSample[] s =
|
||||
[
|
||||
S(-10, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.Empty, BadNoCommunication),
|
||||
S(25, HistorianValue.FromDouble(20), Good),
|
||||
];
|
||||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 60, 20, s);
|
||||
Values(ta2, 10, 20, null);
|
||||
ta2.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalcPartial, NoDataCalc]);
|
||||
var t2 = Run(HistoryAggregateType.Total2, 0, 60, 20, s);
|
||||
Values(t2, 50, 0, null);
|
||||
t2.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalcPartial, NoDataCalc]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_single_sample_is_its_own_value_Uncertain_and_Partial()
|
||||
{
|
||||
// #680 review: the tag's only sample, +25 = 20. One interval [0,60) ⇒ 20 (Total2 0), Uncertain + Partial (time
|
||||
// before the data is Bad). At 20 s: [0,20) before the data ⇒ BadNoData; [20,40) the sample ⇒ 20, Uncertain +
|
||||
// Partial; [40,60) after the end of the data ⇒ BadNoData.
|
||||
StoredSample[] one = [S(25, HistorianValue.FromDouble(20), Good)];
|
||||
var whole = Run(HistoryAggregateType.TimeAverage2, 0, 60, 0, one).Single();
|
||||
((double)whole.Value!).ShouldBe(20d);
|
||||
whole.StatusCode.ShouldBe(UncertainCalcPartial);
|
||||
var total = Run(HistoryAggregateType.Total2, 0, 60, 0, one).Single();
|
||||
((double)total.Value!).ShouldBe(0d);
|
||||
total.StatusCode.ShouldBe(UncertainCalcPartial);
|
||||
var split = Run(HistoryAggregateType.TimeAverage2, 0, 60, 20, one);
|
||||
Values(split, null, 20, null);
|
||||
split.Select(x => x.StatusCode).ShouldBe([NoDataCalc, UncertainCalcPartial, NoDataCalc]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_outage_marker_as_the_last_sample_runs_Bad_to_the_window_end()
|
||||
{
|
||||
// #680 review, a deliberate difference from the SDK (which treats the data as ended at the marker): −10 = 10 G,
|
||||
// +25 = 20 G, +30 outage, nothing after. TimeAverage2 [20,40): [20,25) 18.5714…→20 (96.43) + [25,30) 20 held into
|
||||
// the outage (100) over 10 s = 19.642857…, Uncertain, NOT Partial (the data did not end — the tag is in an
|
||||
// outage); [40,60) inside the still-open outage ⇒ generic Bad. PercentBad [40,60) = 100, Good. TimeAverage holds
|
||||
// 20 to the window end — extrapolated (no Good value follows) ⇒ Uncertain.
|
||||
StoredSample[] s =
|
||||
[
|
||||
S(-10, HistorianValue.FromDouble(10), Good), S(25, HistorianValue.FromDouble(20), Good),
|
||||
S(30, HistorianValue.Empty, BadNoCommunication),
|
||||
];
|
||||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 60, 20, s);
|
||||
((double)ta2[1].Value!).ShouldBe(19.642857142857142, 1e-9);
|
||||
ta2.Select(x => x.StatusCode).Skip(1).ShouldBe([UncertainCalc, BadCalc]);
|
||||
var pb = Run(HistoryAggregateType.PercentBad, 0, 60, 20, s);
|
||||
Values(pb, 0, 50, 100);
|
||||
pb.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||||
Run(HistoryAggregateType.TimeAverage, 0, 60, 20, s).Select(x => x.StatusCode).Skip(1)
|
||||
.ShouldBe([UncertainCalc, UncertainCalc]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Boolean_percent_regions_take_the_stepped_bound_status()
|
||||
{
|
||||
// #680 review: −10 = true G, +15 = true U, +30 = false G, +50 = false G. [0,20): the bound at +0 is stepped from the
|
||||
// Good −10 (a Boolean is held, not sloped toward the Uncertain +15) ⇒ [0,15) Good, [15,20) Uncertain ⇒ PercentGood
|
||||
// 75. (The SDK slopes Booleans, so it calls the bound Uncertain and answers 0.) [20,40): [20,30) Uncertain (bound
|
||||
// stepped from the Uncertain +15), [30,40) Good ⇒ 50.
|
||||
StoredSample[] s =
|
||||
[
|
||||
S(-10, HistorianValue.FromBoolean(true), Good), S(15, HistorianValue.FromBoolean(true), Uncertain),
|
||||
S(30, HistorianValue.FromBoolean(false), Good), S(50, HistorianValue.FromBoolean(false), Good),
|
||||
];
|
||||
Values(Run(HistoryAggregateType.PercentGood, 0, 40, 20, s), 75, 50);
|
||||
// TimeAverage over the same Booleans is stepped: [20,40) = 1 over [20,30), 0 over [30,40) ⇒ 0.5 (the SDK: 1).
|
||||
Values(Run(HistoryAggregateType.TimeAverage, 0, 40, 20, s), 1, 0.5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- #680: PercentGood / PercentBad are duration-based
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user