using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// /// Maps one bucket's statistics () to the OPC UA value and status of an aggregate. /// /// /// /// Per interval: no samples ⇒ BadNoData (Count: 0 inside the data range). Otherwise no usable sample ⇒ /// BadNoData; a numeric aggregate with a usable String/DateTime sample ⇒ BadAggregateInvalidInputs; /// else the value. #684: Average / Minimum / Maximum / Range / *ActualTime are computed over Good values only /// (Part 13 and the SDK — an Uncertain sample is never part of the value; an interval with no Good value is /// BadNoData). Status: Average value-based (Good only when every sample is Good, else /// UncertainDataSubNormal); Minimum / Maximum / Range / *ActualTime per their Part 13 tables — Uncertain when a /// Bad sample exists or an Uncertain value lies beyond the reported extreme (). /// Every calculated status — including BadNoData — carries the Calculated info bits. Timestamps are the /// bucket's (its start forward, its later edge reverse), except /// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest) and Start/End. Every aggregate but /// Average also carries the Partial bit on the bucket the data starts (forward) or ends inside /// (). PercentGood / PercentBad are duration-based since #680 and evaluated by /// , not here. /// /// /// #680 (Part 13 / the SDK's calculators): Count counts Good samples only, value-based status (all Good ⇒ /// Good, all Bad ⇒ the generic Bad, else Uncertain), and is 0/Good on an empty bucket inside the tag's data range; /// Start / End are the raw first / last sample of any quality (own value — null when Bad —, own status /// without the Calculated bit, own timestamp); Delta is Uncertain only when the bucket's first or last /// sample is not usable. /// /// /// Inconsistent rows (live-gate 8 F1): a row whose counts say a bucket holds samples while a picked sample /// (first/last of any quality, first/last usable, the extreme) is absent is answered from what is present — /// Start/End/Delta/ActualTime without the sample they need are BadNoData — never an exception. The backends' /// queries read counts and picks in one scan so this should not arise; the evaluator does not rely on it. /// /// /// Values: Count is ; Start/End keep the sample's type; every other aggregate is /// computed over the numeric value (Boolean = 0/1). These are the simple per-sample /// definitions; the Part 13 time-weighted aggregates (TimeAverage, TimeAverage2, Total, Total2, Interpolative) /// are evaluated by , not here. /// /// public static class AggregateEvaluator { /// Evaluates over one non-empty bucket. /// Requested aggregate. /// The bucket's statistics. /// The bucket's result timestamp (). /// Server timestamp. /// The calculated value. /// The aggregate is not one of the per-sample (Simple / /// StartEnd / ActualTime family) aggregates. public static DataValueSnapshot Evaluate(HistoryAggregateType aggregate, BucketRow r, DateTime bucketStart, DateTime now) { DataValueSnapshot Calc(object? value, uint status, DateTime? t = null) => new(value, HistorianInfoBits.WithCalculated(status), t ?? bucketStart, now); // Refuse a non-per-sample aggregate up front, whatever the bucket holds (an all-Bad bucket would otherwise answer // BadNoData before the switch's default is reached). if (AggregateFamilies.FamilyOf(aggregate) is not (AggregateFamily.Simple or AggregateFamily.StartEnd or AggregateFamily.ActualTime)) throw new HistoryAggregateNotSupportedException(aggregate); if (r.Total == 0) return NoData(bucketStart, now); switch (aggregate) { // #680 (Part 13 Tables 73/74; SDK ComputeStartEnd): the raw first / last sample of ANY quality — its own value // (null when Bad), its own status (Raw: no Calculated bit) and its own timestamp. case HistoryAggregateType.Start: return Raw(r.FirstAny, bucketStart, now); case HistoryAggregateType.End: return Raw(r.LastAny, bucketStart, now); // #680 (Part 13 §5.4.3.21; SDK ComputeCount): Good samples only; value-based status — Good when every sample // is Good, the generic Bad when every one is Bad (value null), else Uncertain. case HistoryAggregateType.Count: if (r.Bad == r.Total) return Calc(null, HistorianStatusCodes.Bad); return Calc(r.Good, r.Good == r.Total ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal); } if (r.Usable == 0) return NoData(bucketStart, now); // #684 review: Delta reads usable picks, so any usable String/DateTime sample refuses it. The Good-value aggregates // refuse only a GOOD non-numeric sample — an Uncertain one is not part of their value set (it only degrades the // status, as the SDK's skipped/uncastable value does). if ((aggregate == HistoryAggregateType.Delta ? r.NonNumeric : r.GoodNonNumericOrAll) > 0) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs); switch (aggregate) { // #684 (Part 13 §5.4.3.5; SDK ComputeAverage): the mean of the GOOD values — an interval with none (only // Uncertain / Bad) is BadNoData; value-based status: Good only when every sample is Good, else Uncertain. case HistoryAggregateType.Average: if (r.Avg is null) return NoData(bucketStart, now); return Calc(r.Avg, r.Good == r.Total ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal); // #684 (Part 13 §5.4.3.10/11/14; SDK ComputeMinMax): the extreme(s) of the GOOD values; Uncertain when a Bad // sample exists or an Uncertain value lies beyond the reported extreme (see MinMaxStatus). case HistoryAggregateType.Minimum: if (r.Min is null) return NoData(bucketStart, now); return Calc(r.Min, MinMaxStatus(r, r.Min, r.Max, low: true, high: false)); case HistoryAggregateType.Maximum: if (r.Max is null) return NoData(bucketStart, now); return Calc(r.Max, MinMaxStatus(r, r.Min, r.Max, low: false, high: true)); case HistoryAggregateType.Range: if (r.Min is null || r.Max is null) return NoData(bucketStart, now); return Calc(r.Max - r.Min, MinMaxStatus(r, r.Min, r.Max, low: true, high: true)); case HistoryAggregateType.Delta: // #680 (SDK ComputeDelta): last usable − first usable; Uncertain only when the interval's first or last // sample was skipped (not usable) — a Bad sample in the middle does not degrade it. // Live-gate 8 F1: a row whose counts promise a usable sample its picks do not carry (a backend that read // the counts and the picks from two table versions) has no consistent first/last — no data, not a throw // and not a wrong-input verdict. if (r.First.Kind == HistorianValueKind.Empty || r.Last.Kind == HistorianValueKind.Empty) return NoData(bucketStart, now); if (!r.First.TryGetDouble(out var a) || !r.Last.TryGetDouble(out var b)) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs); var bounded = r.FirstAny is { IsUsable: true } && r.LastAny is { IsUsable: true }; return Calc(b - a, bounded ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal); case HistoryAggregateType.MinimumActualTime: case HistoryAggregateType.MaximumActualTime: // Live-gate 8 F1: usable numeric samples counted but no extreme picked (an inconsistent row) is no data, // never a value-less Good. if (r.Extreme is null || r.ExtremeTime is null) return NoData(bucketStart, now); var min = aggregate == HistoryAggregateType.MinimumActualTime; return Calc(r.Extreme, MinMaxStatus(r, r.Extreme, r.Extreme, low: min, high: !min), r.ExtremeTime); default: throw new HistoryAggregateNotSupportedException(aggregate); } } /// /// #684 — the Part 13 status of Minimum / Maximum / Range / *ActualTime (Tables 57–62): Uncertain_DataSubNormal /// when a Bad sample exists in the interval, or when an Uncertain value is less than the minimum Good value /// () / greater than the maximum Good value (); else Good. An Uncertain /// value inside the Good range leaves it Good. An Uncertain String/DateTime sample (no order against the Good values) /// also makes it Uncertain — the SDK's ComputeMinMax flags a value it cannot cast exactly that way. /// /// /// The SDK's ComputeMinMax (1.5.378) applies only the Bad half: it collects the Uncertain extremes and never /// reads them, so it answers Good where Part 13 says Uncertain. Ours follows the specification there — the more /// conservative of the two, and never a status better than the inputs warrant. The comparison is strict, so an /// Uncertain value EQUAL to the Good extreme does not degrade it. /// private static uint MinMaxStatus(BucketRow r, double? goodMin, double? goodMax, bool low, bool high) => r.Bad > 0 || r.NonNumeric > r.GoodNonNumericOrAll || (low && r.UncertainMin < goodMin) || (high && r.UncertainMax > goodMax) ? HistorianStatusCodes.UncertainDataSubNormal : HistorianStatusCodes.Good; /// /// True for the per-sample aggregates the SDK sets the start-of-data Partial bit on (every calculator but /// AverageAggregateCalculator's plain Average, which turns SetPartialBit off). /// /// Requested aggregate. /// Whether the start-of-data Partial rule applies. public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average; /// /// True when a per-sample read must fetch the tag's data range — its earliest and latest stored samples of any /// quality — because the answer can change a result: the start/end-of-data Partial rules for an aggregate other /// than Average (either direction: the end-of-data rule applies to reverse reads too), and Count (an empty /// interval inside the range is 0, outside it BadNoData, #680). Only a per-sample Average read skips /// both queries. /// /// Requested aggregate. /// The bucket plan. /// Whether the earliest and latest samples are needed. public static bool NeedsDataRange(HistoryAggregateType aggregate, BucketPlan plan) { ArgumentNullException.ThrowIfNull(plan); return plan.BucketCount > 0 && (aggregate == HistoryAggregateType.Count || SetsStartOfDataPartial(aggregate)); } /// /// Evaluates every non-empty bucket row and assembles one result per interval in the plan's order (descending for a /// reverse read): each value stamped with (the earlier edge forward, the /// later edge reverse) — Start/End/ActualTime with their sample's own time; empty buckets BadNoData, except a /// Count inside the tag's data range (0, Good — #680); and, for every aggregate but Average, the Partial bit /// on a non-Bad result of the bucket the tag's data starts strictly inside (forward only — #677, the SDK's /// m_startOfData rule) or ends inside (#680 — : forward the SDK's /// m_endOfData, earlier edge included; reverse strictly inside, the SDK's m_startOfData keyed on the /// first value a reverse read is fed — the latest). A Bad result never carries Partial. /// /// A per-sample (Simple / StartEnd / ActualTime) aggregate. /// The bucket plan. /// The backend's non-empty bucket rows (ascending bucket index). /// The tag's earliest stored sample of any quality, or null when none. /// The tag's latest stored sample of any quality, or null when none. /// Server timestamp. /// results. public static IReadOnlyList EvaluateAll(HistoryAggregateType aggregate, BucketPlan plan, IEnumerable rows, DateTime? earliestSampleUtc, DateTime? latestSampleUtc, DateTime now) { ArgumentNullException.ThrowIfNull(plan); ArgumentNullException.ThrowIfNull(rows); var evaluated = new Dictionary(); var startRule = !plan.Reversed && SetsStartOfDataPartial(aggregate); var endRule = SetsStartOfDataPartial(aggregate); foreach (var row in rows) { var v = Evaluate(aggregate, row, plan.BucketTimestamp(row.Bucket), now); if (!StatusBits.IsBad(v.StatusCode) && ((startRule && plan.DataStartsInside(row.Bucket, earliestSampleUtc)) || (endRule && plan.DataEndsInside(row.Bucket, latestSampleUtc)))) // A raw Start/End sample gains the DataValue InfoType with its first aggregate bit (as the SDK's // SetAggregateBits does); a calculated status already carries it. v = v with { StatusCode = v.StatusCode | HistorianInfoBits.InfoTypeDataValue | HistorianInfoBits.HistorianPartial }; evaluated[row.Bucket] = v; } if (aggregate == HistoryAggregateType.Count) { // #680 (Part 13 §5.4.3.21; SDK GetValues): an empty interval inside [earliest, latest] is a Count of 0, Good. for (long i = 0; i < plan.BucketCount; i++) if (!evaluated.ContainsKey(i) && plan.WithinDataRange(i, earliestSampleUtc, latestSampleUtc)) evaluated[i] = new DataValueSnapshot(0, HistorianInfoBits.WithCalculated(HistorianStatusCodes.Good), plan.BucketTimestamp(i), now); } return plan.Assemble(evaluated, t => NoData(t, now)); } /// A raw stored sample as an aggregate result (Start / End): value null when Bad, its own status and time. private static DataValueSnapshot Raw(StoredSample? sample, DateTime bucketStart, DateTime now) => sample is not { } s ? NoData(bucketStart, now) : new(StatusBits.IsBad(s.Status) ? null : s.Value.ToObject(), s.Status, s.TimeUtc, now); /// The result for a bucket with no samples (or no usable sample): BadNoData + Calculated. /// The bucket's start time. /// Server timestamp. /// The no-data value. public static DataValueSnapshot NoData(DateTime t, DateTime now) => new(null, HistorianInfoBits.WithCalculated(HistorianStatusCodes.BadNoData), t, now); }