7349260231
F1: the SDK applies its m_startOfData Partial rule to forward reads only; ours now does too (AggregateEvaluator.EvaluateAll + TimeWeightedAggregator). New reverse late-start parity tests through the SDK oracle (unit) and real SQL (conformance, both backends). F2: AggregateEvaluator.NeedsEarliestSample — the earliest-sample query runs only for forward reads of aggregates other than Average, and is bounded by the window end (SqlServer < @end, QuestDB ts < $2). F3: per-sample SDK parity extended to Range, Start, End, Delta, Min/Max ActualTime and PercentGood/Bad (oracle maps StatusAggregateCalculator); pre-existing definitional differences masked and marked #680. Docs: Historian.md Partial/SDK-parity sections, CLAUDE.md bullet.
134 lines
8.1 KiB
C#
134 lines
8.1 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>. PercentGood / PercentBad ⇒ <c>100 × good|bad / all</c> over
|
||
/// every sample, status Good. Otherwise no usable sample ⇒ <c>BadNoData</c>; a numeric aggregate with a usable
|
||
/// String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>; else the value, Good when every sample is Good,
|
||
/// <c>UncertainDataSubNormal</c> when any is Bad or Uncertain. Every 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).
|
||
/// Every aggregate but <c>Average</c> also carries the Partial bit on the bucket the data starts inside
|
||
/// (<see cref="EvaluateAll"/>).
|
||
/// </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);
|
||
|
||
if (r.Total == 0) return NoData(bucketStart, now);
|
||
switch (aggregate)
|
||
{
|
||
case HistoryAggregateType.PercentGood: return Calc(100d * r.Good / r.Total, HistorianStatusCodes.Good);
|
||
case HistoryAggregateType.PercentBad: return Calc(100d * r.Bad / r.Total, HistorianStatusCodes.Good);
|
||
}
|
||
if (r.Usable == 0) return NoData(bucketStart, now);
|
||
|
||
var anyText = aggregate is HistoryAggregateType.Count or HistoryAggregateType.Start or HistoryAggregateType.End;
|
||
if (!anyText && r.NonNumeric > 0)
|
||
return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
|
||
|
||
var status = r.Good == r.Total ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
|
||
switch (aggregate)
|
||
{
|
||
case HistoryAggregateType.Count: return Calc(r.Usable, status);
|
||
case HistoryAggregateType.Average: return Calc(r.Avg, status);
|
||
case HistoryAggregateType.Minimum: return Calc(r.Min, status);
|
||
case HistoryAggregateType.Maximum: return Calc(r.Max, status);
|
||
case HistoryAggregateType.Range: return Calc(r.Max - r.Min, status);
|
||
case HistoryAggregateType.Start: return Calc(r.First.ToObject(), status);
|
||
case HistoryAggregateType.End: return Calc(r.Last.ToObject(), status);
|
||
case HistoryAggregateType.Delta:
|
||
return r.First.TryGetDouble(out var a) && r.Last.TryGetDouble(out var b)
|
||
? Calc(b - a, status)
|
||
: Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
|
||
case HistoryAggregateType.MinimumActualTime:
|
||
case HistoryAggregateType.MaximumActualTime:
|
||
return Calc(r.Extreme, status, r.ExtremeTime);
|
||
default:
|
||
throw new HistoryAggregateNotSupportedException(aggregate);
|
||
}
|
||
}
|
||
|
||
/// <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 earliest sample: the start-of-data Partial rule applies only to a
|
||
/// <b>forward</b> read (as in the SDK, whose reverse read keys <c>m_startOfData</c> on the first value it is fed — the
|
||
/// latest) of an aggregate other than <c>Average</c>. Otherwise the backend skips that query.
|
||
/// </summary>
|
||
/// <param name="aggregate">Requested aggregate.</param>
|
||
/// <param name="plan">The bucket plan.</param>
|
||
/// <returns>Whether the earliest sample is needed.</returns>
|
||
public static bool NeedsEarliestSample(HistoryAggregateType aggregate, BucketPlan plan)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(plan);
|
||
return !plan.Reversed && plan.BucketCount > 0 && 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); empty buckets <c>BadNoData</c>; and — on a forward read, for every aggregate but
|
||
/// <c>Average</c> — the Partial bit on a non-Bad result of the bucket the tag's data starts inside (#677, the SDK's
|
||
/// <c>m_startOfData</c> rule; the SDK applies it to forward reads only, <see cref="NeedsEarliestSample"/>).
|
||
/// </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="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 now)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(plan);
|
||
ArgumentNullException.ThrowIfNull(rows);
|
||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||
var partialRule = NeedsEarliestSample(aggregate, plan);
|
||
foreach (var row in rows)
|
||
{
|
||
var v = Evaluate(aggregate, row, plan.BucketTimestamp(row.Bucket), now);
|
||
if (partialRule && !StatusBits.IsBad(v.StatusCode) && plan.DataStartsInside(row.Bucket, earliestSampleUtc))
|
||
v = v with { StatusCode = v.StatusCode | HistorianInfoBits.HistorianPartial };
|
||
evaluated[row.Bucket] = v;
|
||
}
|
||
return plan.Assemble(evaluated, t => NoData(t, 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);
|
||
}
|