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
@@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: <c>6 avg_value, 7 min_value, 8 max_value, 9 sum_value</c> (DOUBLE, NULL when no usable sample).
/// </summary>
/// <param name="sample">Prefixed sample table name.</param>
@@ -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
@@ -46,7 +46,7 @@ internal static class SqlAggregateQueries
private const string NonNumericKinds = "4, 5";
/// <summary>
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: <c>6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue</c> (float, NULL when no usable
/// numeric sample).
/// </summary>
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// carries a value; an <c>Empty</c>-kind sample is an outage marker (comms-loss <c>BadNoCommunication</c>
/// or a driver's Bad publish) and is Bad whatever its code. Raw returns every sample as stored, outage
/// markers included. Aggregates exclude Bad samples from the math, report the interval Uncertain when
/// any non-Good sample is present, and count them in PercentBad. AtTime never interpolates across a Bad
/// any non-Good sample is present, and count their duration as Bad time in PercentBad. AtTime never interpolates across a Bad
/// neighbour and reports a time inside an outage with the outage's Bad code (see <see cref="ReadAtTimeAsync"/>).
/// <b>An Empty sample is always served Bad:</b> a row with no value whose stored status is not Bad (only
/// possible for rows written before the writer forced Empty samples Bad) is read back as <c>BadNoData</c>,
@@ -405,14 +405,14 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// returned latest first (<see cref="BucketPlan"/>).
/// </para>
/// <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 start (its later edge on a reverse read), except
/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest). On a forward read every aggregate but Average
/// sets the Partial bit on the bucket the tag's earliest sample lies strictly inside (#677) or its latest sample
/// lies in (#680). #680: Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
/// Per-sample aggregates, per interval: no samples ⇒ <c>BadNoData</c>. 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 start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒
/// earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest
/// sample lies strictly inside (forward, #677) or its latest sample lies in (#680; reverse: strictly inside).
/// PercentGood / PercentBad are duration-based since #680 (time-weighted family, below). #680: Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
/// data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's
/// first or last sample is not usable (see <see cref="AggregateEvaluator"/>).
/// </para>