fix(historian): #680 lane B — reverse end-of-data Partial (strictly inside), drop dead per-sample Percent cases

Per-sample aggregates (all but Average) now set Partial on a reverse read's interval
that strictly contains the tag's latest sample — the SDK's reverse m_startOfData
(verified vs the SDK harness), matching lane A's time-weighted rule. NeedsDataRange
therefore applies in both directions for every per-sample aggregate but Average.
PercentGood/Bad left the per-sample evaluator (lane A moved them to the time-weighted
family); Evaluate refuses non-per-sample aggregates up front. Comments fixed.
This commit is contained in:
Joseph Doherty
2026-10-11 16:00:13 -04:00
parent dbfe2fc4de
commit b8344bb151
8 changed files with 75 additions and 75 deletions
@@ -7,14 +7,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// </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 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 a forward read's bucket the data starts or ends inside (<see cref="EvaluateAll"/>).
/// 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, Good when every sample is Good, <c>UncertainDataSubNormal</c> when any is Bad or Uncertain.
/// 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 ⇒
@@ -45,13 +46,12 @@ public static class AggregateEvaluator
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)
{
case HistoryAggregateType.PercentGood: return Calc(100d * r.Good / r.Total, HistorianStatusCodes.Good);
case HistoryAggregateType.PercentBad: return Calc(100d * r.Bad / r.Total, HistorianStatusCodes.Good);
}
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.
@@ -96,23 +96,12 @@ public static class AggregateEvaluator
/// <returns>Whether the start-of-data Partial rule applies.</returns>
public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average;
/// <summary>
/// True for the per-sample aggregates the forward end-of-data Partial rule (#680) applies to: the same set as
/// <see cref="SetsStartOfDataPartial"/> less PercentGood / PercentBad, which #680 moves to the time-weighted family
/// (their per-duration definition) — not changed here.
/// </summary>
/// <param name="aggregate">Requested aggregate.</param>
/// <returns>Whether the end-of-data Partial rule applies.</returns>
public static bool SetsEndOfDataPartial(HistoryAggregateType aggregate) =>
SetsStartOfDataPartial(aggregate)
&& aggregate is not (HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad);
/// <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- and end-of-data Partial rules on a <b>forward</b>
/// read of an aggregate other than <c>Average</c> (as in the SDK, which applies both to forward reads only), and
/// Count in either direction (an empty interval inside the range is 0, outside it <c>BadNoData</c>, #680).
/// Otherwise the backend skips both queries.
/// 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>
@@ -120,18 +109,18 @@ public static class AggregateEvaluator
public static bool NeedsDataRange(HistoryAggregateType aggregate, BucketPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
return plan.BucketCount > 0
&& (aggregate == HistoryAggregateType.Count || (!plan.Reversed && SetsStartOfDataPartial(aggregate)));
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, 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 strictly inside (#677, the
/// SDK's <c>m_startOfData</c> rule) or ends inside (#680, <c>m_endOfData</c>, the bucket's earlier edge included).
/// A Bad result never carries Partial, and a reverse read none at all (<see cref="NeedsDataRange"/>).
/// 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>
@@ -147,7 +136,7 @@ public static class AggregateEvaluator
ArgumentNullException.ThrowIfNull(rows);
var evaluated = new Dictionary<long, DataValueSnapshot>();
var startRule = !plan.Reversed && SetsStartOfDataPartial(aggregate);
var endRule = !plan.Reversed && SetsEndOfDataPartial(aggregate);
var endRule = SetsStartOfDataPartial(aggregate);
foreach (var row in rows)
{
var v = Evaluate(aggregate, row, plan.BucketTimestamp(row.Bucket), now);
@@ -156,15 +156,18 @@ public sealed class BucketPlan
earliestSampleUtc is { } e && e.Ticks > LowerTicks(index) && e.Ticks < UpperTicks(index);
/// <summary>
/// True when the latest stored sample of the tag lies in bucket <paramref name="index"/> (its earlier edge
/// included): the data ends part-way through the interval, so Part 13 §5.3.3.2 / the SDK set the Partial bit on a
/// forward read (#680, the SDK's <c>m_endOfData &gt;= startTime &amp;&amp; m_endOfData &lt; endTime</c>).
/// True when the latest stored sample of the tag lies in bucket <paramref name="index"/>: the data ends part-way
/// through the interval, so Part 13 §5.3.3.2 / the SDK set the Partial bit (#680). Forward the earlier edge is
/// included (the SDK's <c>m_endOfData &gt;= startTime &amp;&amp; m_endOfData &lt; endTime</c>); reverse the sample
/// must lie strictly inside (the SDK's reverse read keys <c>m_startOfData</c> — a strict test — on the first value it
/// is fed, the latest).
/// </summary>
/// <param name="index">Zero-based ascending bucket index.</param>
/// <param name="latestSampleUtc">The tag's latest stored sample (any quality), or null when it has none.</param>
/// <returns>Whether the data ends inside the bucket.</returns>
public bool DataEndsInside(long index, DateTime? latestSampleUtc) =>
latestSampleUtc is { } l && l.Ticks >= LowerTicks(index) && l.Ticks < UpperTicks(index);
latestSampleUtc is { } l
&& (Reversed ? l.Ticks > LowerTicks(index) : l.Ticks >= LowerTicks(index)) && l.Ticks < UpperTicks(index);
/// <summary>
/// True when bucket <paramref name="index"/> overlaps the tag's data range <c>[earliest, latest]</c> — neither