c0d6bacd9c
- Average/Minimum/Maximum/Range/*ActualTime refuse (BadAggregateInvalidInputs) only a GOOD String/DateTime sample (new GoodNonNumericCount / good_non_numeric_count column); an Uncertain one is skipped and makes the min/max family Uncertain, matching the SDK (Average skips it, ComputeMinMax flags the uncastable value). Delta keeps its usable-sample refusal. - ProcessedConformance: an Uncertain value above the Good maximum (pins UncMax/unc_max through the real SQL) and an Uncertain String through the real SQL. - The_uncertain_dataset_is_not_vacuous also proves the min/max STATUS discrimination. Co-Authored-By: Claude <noreply@anthropic.com>
228 lines
16 KiB
C#
228 lines
16 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||
|
||
/// <summary>
|
||
/// Maps one bucket's statistics (<see cref="BucketRow"/>) to the OPC UA value and status of an aggregate.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Per interval: no samples ⇒ <c>BadNoData</c> (Count: 0 inside the data range). Otherwise no usable sample ⇒
|
||
/// <c>BadNoData</c>; a numeric aggregate with a usable String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>;
|
||
/// else the value. #684: Average / Minimum / Maximum / Range / *ActualTime are computed over <b>Good</b> values only
|
||
/// (Part 13 and the SDK — an Uncertain sample is never part of the value; an interval with no Good value is
|
||
/// <c>BadNoData</c>). Status: Average value-based (Good only when every sample is Good, else
|
||
/// <c>UncertainDataSubNormal</c>); Minimum / Maximum / Range / *ActualTime per their Part 13 tables — Uncertain when a
|
||
/// Bad sample exists or an Uncertain value lies beyond the reported extreme (<see cref="MinMaxStatus"/>).
|
||
/// Every calculated status — including <c>BadNoData</c> — carries the Calculated info bits. Timestamps are the
|
||
/// bucket's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its later edge reverse), except
|
||
/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest) and Start/End. Every aggregate but
|
||
/// <c>Average</c> also carries the Partial bit on the bucket the data starts (forward) or ends inside
|
||
/// (<see cref="EvaluateAll"/>). PercentGood / PercentBad are duration-based since #680 and evaluated by
|
||
/// <see cref="TimeWeightedAggregator"/>, not here.
|
||
/// </para>
|
||
/// <para>
|
||
/// #680 (Part 13 / the SDK's calculators): <b>Count</b> 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;
|
||
/// <b>Start / End</b> are the raw first / last sample of any quality (own value — null when Bad —, own status
|
||
/// without the Calculated bit, own timestamp); <b>Delta</b> is Uncertain only when the bucket's first or last
|
||
/// sample is not usable.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Inconsistent rows</b> (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 <c>BadNoData</c> — 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// Values: Count is <see cref="int"/>; Start/End keep the sample's type; every other aggregate is
|
||
/// <see cref="double"/> 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 <see cref="TimeWeightedAggregator"/>, not here.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static class AggregateEvaluator
|
||
{
|
||
/// <summary>Evaluates <paramref name="aggregate"/> over one non-empty bucket.</summary>
|
||
/// <param name="aggregate">Requested aggregate.</param>
|
||
/// <param name="r">The bucket's statistics.</param>
|
||
/// <param name="bucketStart">The bucket's result timestamp (<see cref="BucketPlan.BucketTimestamp"/>).</param>
|
||
/// <param name="now">Server timestamp.</param>
|
||
/// <returns>The calculated value.</returns>
|
||
/// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the per-sample (Simple /
|
||
/// StartEnd / ActualTime family) aggregates.</exception>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #684 — the Part 13 status of Minimum / Maximum / Range / *ActualTime (Tables 57–62): <c>Uncertain_DataSubNormal</c>
|
||
/// when a Bad sample exists in the interval, or when an Uncertain value is less than the minimum Good value
|
||
/// (<paramref name="low"/>) / greater than the maximum Good value (<paramref name="high"/>); 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 <c>ComputeMinMax</c> flags a value it cannot cast exactly that way.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The SDK's <c>ComputeMinMax</c> (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.
|
||
/// </remarks>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// True for the per-sample aggregates the SDK sets the start-of-data Partial bit on (every calculator but
|
||
/// <c>AverageAggregateCalculator</c>'s plain <c>Average</c>, which turns <c>SetPartialBit</c> off).
|
||
/// </summary>
|
||
/// <param name="aggregate">Requested aggregate.</param>
|
||
/// <returns>Whether the start-of-data Partial rule applies.</returns>
|
||
public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average;
|
||
|
||
/// <summary>
|
||
/// 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 <c>Average</c> (either direction: the end-of-data rule applies to reverse reads too), and Count (an empty
|
||
/// interval inside the range is 0, outside it <c>BadNoData</c>, #680). Only a per-sample <c>Average</c> read skips
|
||
/// both queries.
|
||
/// </summary>
|
||
/// <param name="aggregate">Requested aggregate.</param>
|
||
/// <param name="plan">The bucket plan.</param>
|
||
/// <returns>Whether the earliest and latest samples are needed.</returns>
|
||
public static bool NeedsDataRange(HistoryAggregateType aggregate, BucketPlan plan)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(plan);
|
||
return plan.BucketCount > 0 && (aggregate == HistoryAggregateType.Count || SetsStartOfDataPartial(aggregate));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="BucketPlan.BucketTimestamp"/> (the earlier edge forward, the
|
||
/// later edge reverse) — Start/End/ActualTime with their sample's own time; empty buckets <c>BadNoData</c>, except a
|
||
/// Count inside the tag's data range (0, Good — #680); and, for every aggregate but <c>Average</c>, the Partial bit
|
||
/// on a non-Bad result of the bucket the tag's data starts strictly inside (forward only — #677, the SDK's
|
||
/// <c>m_startOfData</c> rule) or ends inside (#680 — <see cref="BucketPlan.DataEndsInside"/>: forward the SDK's
|
||
/// <c>m_endOfData</c>, earlier edge included; reverse strictly inside, the SDK's <c>m_startOfData</c> keyed on the
|
||
/// first value a reverse read is fed — the latest). A Bad result never carries Partial.
|
||
/// </summary>
|
||
/// <param name="aggregate">A per-sample (Simple / StartEnd / ActualTime) aggregate.</param>
|
||
/// <param name="plan">The bucket plan.</param>
|
||
/// <param name="rows">The backend's non-empty bucket rows (ascending bucket index).</param>
|
||
/// <param name="earliestSampleUtc">The tag's earliest stored sample of any quality, or null when none.</param>
|
||
/// <param name="latestSampleUtc">The tag's latest stored sample of any quality, or null when none.</param>
|
||
/// <param name="now">Server timestamp.</param>
|
||
/// <returns><see cref="BucketPlan.BucketCount"/> results.</returns>
|
||
public static IReadOnlyList<DataValueSnapshot> EvaluateAll(HistoryAggregateType aggregate, BucketPlan plan,
|
||
IEnumerable<BucketRow> rows, DateTime? earliestSampleUtc, DateTime? latestSampleUtc, DateTime now)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(plan);
|
||
ArgumentNullException.ThrowIfNull(rows);
|
||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||
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));
|
||
}
|
||
|
||
/// <summary>A raw stored sample as an aggregate result (Start / End): value null when Bad, its own status and time.</summary>
|
||
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);
|
||
|
||
/// <summary>The result for a bucket with no samples (or no usable sample): <c>BadNoData</c> + Calculated.</summary>
|
||
/// <param name="t">The bucket's start time.</param>
|
||
/// <param name="now">Server timestamp.</param>
|
||
/// <returns>The no-data value.</returns>
|
||
public static DataValueSnapshot NoData(DateTime t, DateTime now) =>
|
||
new(null, HistorianInfoBits.WithCalculated(HistorianStatusCodes.BadNoData), t, now);
|
||
}
|