using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
///
/// The OPC UA Part 13 time-weighted aggregates (): TimeAverage,
/// TimeAverage2, Total, Total2, Interpolative and the duration-based PercentGood /
/// PercentBad (#680), evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results
/// by construction.
///
///
///
/// Input. Samples in strictly ascending time: the bounding rows before the window (the latest
/// usable sample before start, then the latest sample of any quality before start — they differ
/// when an outage straddles the start), every in-window sample, then the bounding rows at/after the window end
/// (the first sample of any quality at or after end, then the first usable one).
/// ignores a sample at or before the last one, so a bound fetched twice is harmless. Memory is O(buckets), not O(samples).
///
///
/// The signal (server defaults: TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
/// UseSlopedExtrapolation = false; the node manager refuses any other client AggregateConfiguration). Between a usable
/// sample p and the next sample s:
///
/// - s usable, both Double/Int64 ⇒ sloped (linear p → s); quality = worse of the two.
/// - s usable, either Boolean ⇒ stepped (p held); quality = p's.
/// - s Bad ⇒ p held up to the outage (never interpolated into it, as AtTime does); Uncertain.
/// - no later sample at all (nothing at or after the window end) ⇒ the data has ENDED at p: no
/// extrapolation (Part 13 "After End of Data", #680).
///
/// From a Bad sample (incl. an Empty outage marker) to the next sample the signal is Bad (outage) — to the
/// window end when nothing follows it; before the first sample there is no data. This is the simple bounding
/// signal.
///
///
/// The interpolated bounding signal (Part 13 "interpolated bounding values") skips Bad samples: it joins
/// consecutive usable samples the same way (sloped / stepped) straight across any outage, and holds the
/// last usable value to the window end. It is undefined before the first usable sample.
///
///
/// - TimeAverage∫ interpolated-bounding signal ÷ its defined duration in the interval.
/// - Total∫ interpolated-bounding signal over its defined part, in value·s — Part 13
/// §5.4.3.8 (= TimeAverage × interval seconds on a covered interval; only the covered part when the data
/// starts inside it, #680).
/// - TimeAverage2∫ simple-bounding signal over its non-Bad parts ÷ non-Bad duration (Bad intervals excluded).
/// - Total2∫ simple-bounding signal over its non-Bad parts, in value·s (= TimeAverage2 × non-Bad seconds).
/// - PercentGood / PercentBad100 × Good / Bad region time ÷ interval duration
/// (Part 13 §5.4.3.31-34): stepped regions, each with the status of the sample that starts it; the region before an
/// interval's first own sample has the simple bounding value's status (Bad after a Bad sample; Uncertain on a
/// segment running into an outage or sloping to/from an Uncertain sample; else the earlier sample's — the SDK's
/// GetSimpleBound); time before the first sample is Bad; at the end of the data the last region runs to the
/// end of its own interval (Part 13; the SDK gives it 1 ms) and later intervals are BadNoData; an interval
/// ending before the first sample ⇒ BadNoData. Status Good; any value kind.
/// - InterpolativeThe interpolated-bounding signal's value at each interval start —
/// for a reverse read, at End − i·interval (Part 13 anchors a reverse read at its later time), emitted
/// latest first.
///
///
/// Status (TimeAverage / Total, #680) — Part 13 ("Uncertain if Bad values are skipped or Uncertain values are
/// used") and the SDK's AverageAggregateCalculator: no defined value in the interval ⇒ BadNoData; any
/// part of it on a bridged segment with an Uncertain end (or Uncertain start when stepped), bridged across a Bad
/// sample, extrapolated past the last usable sample, or not covered at all ⇒ UncertainDataSubNormal (so the
/// latest interval of a live tag is Uncertain, and an interval wholly inside an outage has the bridged value,
/// Uncertain); else Good.
///
///
/// Status (TimeAverage2 / Total2) — the SDK AggregateCalculator.GetTimeBasedStatusCode rule with
/// TreatUncertainAsBad = false, so Uncertain time (Uncertain samples, a value held up to an outage) counts as Good
/// time, over the interval cut at the end of the data: after the end of the data ⇒ BadNoData; the data
/// ending exactly on the interval start ⇒ the last sample's value (Total2 0) with its status; every covered instant
/// non-Bad ⇒ Good; no non-Bad time but some Bad (outage) time ⇒ Bad (Part 13's PercentDataBad rule;
/// the SDK answers BadNoData); no data at all ⇒ BadNoData; otherwise (PercentDataGood = PercentDataBad
/// = 100) UncertainDataSubNormal. Time before the first sample counts as Bad (no data) time.
///
///
/// For the four, a usable String/DateTime sample contributing to the interval ⇒ BadAggregateInvalidInputs.
/// Every status of this family but Interpolative's carries the Calculated bit, plus — on a non-Bad result only — the
/// Partial bit on: the truncated interval (Part 13 §5.3.3.2 Case 3; the SDK does not set it — deliberate); the
/// interval the data starts inside in the read direction (forward: the first sample fed lies strictly within it,
/// #677; reverse: the latest sample fed lies strictly within it, nothing after — the SDK's m_startOfData,
/// keyed on the first value it is fed, #680); and, forward, the end-of-data interval of TimeAverage2 / Total2 /
/// PercentGood / PercentBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total).
/// The timestamp is the interval's (its start forward, its later edge
/// reverse); the value is a (Boolean = 0/1, Int64 as double).
///
///
/// Status (Interpolative). A usable sample exactly at the point ⇒ its value with its own status
/// (raw, no historian bit). Otherwise no usable sample at or before ⇒ BadNoData; else the interpolated
/// value with the Interpolated bit and the status of the simple-bounding signal at that instant: Good on
/// a Good segment, UncertainDataSubNormal on an Uncertain one, inside an outage (interpolated across it)
/// or past the last sample (extrapolated). A reverse read returns the raw sample at a point exactly on a sample
/// too — the SDK's reverse answer (Interpolated, from the segment beyond) differs from its own forward one; that
/// asymmetry is the SDK's defect, deliberately not copied.
///
///
public sealed class TimeWeightedAggregator
{
private const double TicksPerSecond = TimeSpan.TicksPerSecond;
///
/// Default cap on the raw in-window rows one time-weighted read may stream (ServerHistorian:MaxTimeWeightedRawRows).
///
public const int DefaultMaxWindowRows = 1_000_000;
///
/// The TOP/LIMIT a backend puts on its window query for a configured cap: cap + 1 (so one
/// row past the cap proves it was exceeded without reading on), or when the cap is
/// <= 0 (unbounded).
///
/// The configured cap.
/// The row limit to put on the window query.
public static int WindowFetchLimit(int maxWindowRows) =>
maxWindowRows <= 0 || maxWindowRows == int.MaxValue ? int.MaxValue : maxWindowRows + 1;
/// Throws when has passed a positive .
/// Window rows read so far.
/// The configured cap (<= 0 = unbounded).
/// The tag, for the message.
/// The cap is exceeded.
public static void CheckWindowRows(long rowsRead, int maxWindowRows, string tag)
{
if (maxWindowRows > 0 && rowsRead > maxWindowRows)
throw new HistoryReadLimitExceededException(maxWindowRows,
$"The time-weighted aggregate read of '{tag}' covers more than {maxWindowRows} raw samples " +
"(ServerHistorian:MaxTimeWeightedRawRows); narrow the window or use a pushed-down aggregate such as Average.");
}
private readonly HistoryAggregateType _aggregate;
private readonly BucketPlan _plan;
private readonly long _start;
private readonly long _end;
// Simple-bounding (raw) signal, per bucket: ∫ over non-Bad time, non-Bad ticks, ticks inside an outage.
private readonly double[] _rawArea;
private readonly long[] _usableTicks;
private readonly long[] _badTicks;
// Interpolated-bounding (bridged) signal, per bucket.
private readonly double[] _bridgedArea;
private readonly long[] _bridgedTicks;
// A bridged segment touching the bucket was non-Good: an Uncertain end, a Bad sample skipped, or extrapolated (#680).
private readonly bool[] _bridgedNonGood;
private readonly bool[] _invalid;
// PercentGood / PercentBad (#680): stepped status regions, per bucket.
private readonly long[] _goodRegionTicks;
private readonly long[] _badRegionTicks;
// Interpolative, per point (index = ascending time; see PointTicks).
private readonly DataValueSnapshot?[] _points;
private StoredSample? _prevAny;
private StoredSample? _prevUsable;
private DateTime? _firstSampleUtc; // the earliest sample fed (any quality): where the data starts
private long? _dataEndTicks; // the last sample fed, when usable and nothing at/after the end followed
private long _nextPoint; // next point (ascending time) not yet classified
private readonly List _pending = []; // points awaiting the next usable sample
private bool _finished;
///
/// What the simple-bounding signal is at a pending bucket start: no sample yet; on a segment from a usable sample
/// whose end is not known yet; or degraded (inside an outage, or held up to one).
///
private enum PointQuality { NoPrior, OnSegment, Degraded }
private readonly record struct PendingPoint(long Bucket, long Ticks, PointQuality Quality);
/// Starts an evaluation of over .
/// One of the aggregates.
/// The bucket plan (forward window).
/// The aggregate is not time-weighted.
public TimeWeightedAggregator(HistoryAggregateType aggregate, BucketPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
if (AggregateFamilies.FamilyOf(aggregate) != AggregateFamily.TimeWeighted)
throw new HistoryAggregateNotSupportedException(aggregate);
_aggregate = aggregate;
_plan = plan;
_start = plan.StartUtc.Ticks;
_end = plan.EndUtc.Ticks;
var n = (int)plan.BucketCount;
_rawArea = new double[n];
_usableTicks = new long[n];
_badTicks = new long[n];
_bridgedArea = new double[n];
_bridgedTicks = new long[n];
_bridgedNonGood = new bool[n];
_invalid = new bool[n];
_goodRegionTicks = new long[n];
_badRegionTicks = new long[n];
_points = new DataValueSnapshot?[n];
}
/// Evaluates over an already time-ordered sample sequence.
/// One of the aggregates.
/// The bucket plan.
/// Bounds + window samples, ascending by time.
/// Server timestamp for the results.
/// One value per bucket, in the plan's order (descending for a reverse window).
public static IReadOnlyList Evaluate(
HistoryAggregateType aggregate, BucketPlan plan, IEnumerable samples, DateTime now)
{
ArgumentNullException.ThrowIfNull(samples);
var acc = new TimeWeightedAggregator(aggregate, plan);
foreach (var s in samples) acc.Add(s);
return acc.Finish(now);
}
/// Feeds the next sample (ascending time; a sample at or before the previous one is ignored).
/// The sample.
public void Add(StoredSample s)
{
if (_finished) throw new InvalidOperationException("The aggregation is already finished.");
var t = s.TimeUtc.Ticks;
if (_prevAny is { } last && t <= last.TimeUtc.Ticks) return;
if (_firstSampleUtc is null)
{
_firstSampleUtc = s.TimeUtc;
AddRegion(_badRegionTicks, _start, t); // PercentBad: the time before the first sample is Bad (no data)
}
// Interpolative: classify every bucket start before this sample by the simple-bounding signal there.
ClassifyPointsBefore(t);
// Simple-bounding segment [prevAny, s).
if (_prevAny is { IsUsable: true } p)
{
// Toward a usable sample: sloped or stepped; toward a Bad one: held up to the outage.
var sloped = s.IsUsable && IsSloped(p, s);
AddRaw(p, t, sloped ? s.Value.Number : p.Value.Number);
}
else if (_prevAny is { } bad)
{
AddBad(bad, t);
}
if (_prevAny is { } r) AddStatusRegion(r, s, t);
if (!s.IsUsable) DegradePending(); // the segment the pending points sit on runs into an outage
if (s.IsUsable)
{
if (_prevUsable is { } pu)
{
var sloped = IsSloped(pu, s);
// Part 13: Uncertain when a Bad value was skipped or an Uncertain value is used (the end sample only
// contributes its value to a sloped segment).
var nonGood = !pu.IsGood || (sloped && !s.IsGood) || _prevAny is { IsUsable: false };
AddBridged(pu, t, sloped ? s.Value.Number : pu.Value.Number, nonGood);
}
ResolvePending(_prevUsable, s);
_prevUsable = s;
}
_prevAny = s;
}
/// Completes the evaluation (extrapolating past the last sample) and assembles the per-bucket results.
/// Server timestamp for the results.
/// One value per bucket, in the plan's order (descending for a reverse window).
public IReadOnlyList Finish(DateTime now)
{
if (!_finished)
{
_finished = true;
ClassifyPointsBefore(long.MaxValue);
if (_prevAny is { IsUsable: true } p && p.TimeUtc.Ticks < _end)
{
// No sample at or after the window end: the data ENDS here (Part 13 "After End of Data"). The simple-
// bounding signal is not extrapolated (TimeAverage2/Total2 cover up to it; later intervals BadNoData), and
// the status region it starts runs to the end of its own interval only (PercentGood/PercentBad).
_dataEndTicks = p.TimeUtc.Ticks;
if (p.TimeUtc.Ticks >= _start && _plan.BucketCount > 0)
AddRegion(RegionTicks(p), p.TimeUtc.Ticks, BucketEnd(FirstBucketAt(p.TimeUtc.Ticks)));
}
else if (_prevAny is { } bad && bad.TimeUtc.Ticks < _end)
{
AddBad(bad, _end); // an outage marker last: the outage runs to the window end
AddRegion(_badRegionTicks, bad.TimeUtc.Ticks, _end);
}
if (_prevUsable is { } pu && pu.TimeUtc.Ticks < _end)
AddBridged(pu, _end, pu.Value.Number, nonGood: true); // stepped extrapolation ⇒ Uncertain
ResolvePending(_prevUsable, next: null);
}
var byBucket = new Dictionary((int)_plan.BucketCount);
for (long i = 0; i < _plan.BucketCount; i++)
byBucket[i] = Result(i, now);
return _plan.Assemble(byBucket, t => AggregateEvaluator.NoData(t, now));
}
// ------------------------------------------------------------------------------------------ Results
private DataValueSnapshot Result(long i, DateTime now)
{
if (_aggregate == HistoryAggregateType.Interpolative)
return _points[i] is { } point
? point with { ServerTimestampUtc = now }
: new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, PointTime(i), now);
var stamp = _plan.BucketTimestamp(i);
var lo = BucketStartTicks(i);
var hi = BucketEnd(i);
var duration = hi - lo;
// Partial: the truncated interval (the latest forward, the earliest reverse — Part 13 §5.3.3.2 Case 3, which the
// SDK does not implement), or the one the data starts inside IN THE READ DIRECTION (no bounding value on that
// side — the SDK's m_startOfData rule, #677): forward, the earliest sample strictly inside; reverse, the latest
// sample strictly inside (nothing after it — the SDK keys m_startOfData on the first value it is fed, #680).
// Forward, the end-of-data interval (the latest sample inside it, nothing after) is Partial too — never on an
// extrapolated TimeAverage/Total (the SDK's m_endOfData rule, #680). Never on a Bad result.
var latestInside = _plan.Reversed && _prevAny is { } latest
&& latest.TimeUtc.Ticks > lo && latest.TimeUtc.Ticks < hi;
var partial = _plan.IsTruncated(i) || latestInside
|| (!_plan.Reversed && _plan.DataStartsInside(i, _firstSampleUtc));
var endOfData = !_plan.Reversed && _dataEndTicks is { } de && de >= lo && de < hi;
DataValueSnapshot Calc(object? value, uint status, bool endPartial = false)
{
var code = HistorianInfoBits.WithCalculated(status);
if ((partial || endPartial) && !StatusBits.IsBad(status)) code |= HistorianInfoBits.HistorianPartial;
return new DataValueSnapshot(value, code, stamp, now);
}
switch (_aggregate)
{
case HistoryAggregateType.TimeAverage or HistoryAggregateType.Total:
{
// Interpolated bounding: Uncertain when any part of the interval was bridged across a Bad value, used an
// Uncertain value, was extrapolated, or is not covered at all (Part 13; the SDK's AverageAggregateCalculator).
if (_invalid[i]) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
if (_bridgedTicks[i] == 0) return AggregateEvaluator.NoData(stamp, now);
var status = _bridgedNonGood[i] || _bridgedTicks[i] < duration
? HistorianStatusCodes.UncertainDataSubNormal
: HistorianStatusCodes.Good;
return _aggregate == HistoryAggregateType.TimeAverage
? Calc(_bridgedArea[i] / _bridgedTicks[i], status)
: Calc(_bridgedArea[i] / TicksPerSecond, status); // ∫ over the covered part only (Part 13 partial width)
}
case HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total2:
{
if (_invalid[i]) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
if (_dataEndTicks is { } dataEnd)
{
// After the end of the data: nothing to aggregate (Part 13 "After End of Data: Bad_NoData").
if (lo > dataEnd) return AggregateEvaluator.NoData(stamp, now);
if (_usableTicks[i] == 0 && dataEnd < hi && _prevAny is { } last)
{
// The data ends inside this interval and no non-Bad time precedes its last sample here (the data
// ends on the interval's start; or the last sample is the first usable one after an outage — the
// comms-loss recovery shape — or the tag's only sample): nothing covered, so the value at that
// instant (Total2 0). Good only when the data ends exactly on the interval start with a Good
// sample (nothing before it in the interval); otherwise the interval also holds Bad / no-data time
// ⇒ Uncertain (the SDK answers the same, modulo its 1 ms artifact).
if (!IsNumeric(last)) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
var st = lo == dataEnd && last.IsGood ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
return Calc(_aggregate == HistoryAggregateType.TimeAverage2 ? last.Value.Number : 0d, st, endOfData);
}
duration = Math.Min(hi, dataEnd) - lo;
}
if (_usableTicks[i] == 0)
return _badTicks[i] > 0 ? Calc(null, HistorianStatusCodes.Bad) : AggregateEvaluator.NoData(stamp, now);
var status = _usableTicks[i] == duration ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
return _aggregate == HistoryAggregateType.TimeAverage2
? Calc(_rawArea[i] / _usableTicks[i], status, endOfData)
: Calc(_rawArea[i] / TicksPerSecond, status, endOfData);
}
case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad:
{
// No data in or around the interval (it ends before the first sample — the SDK's out-of-range test — or
// starts after the end of the data) ⇒ BadNoData; otherwise the share of the interval's duration.
if (_firstSampleUtc is not { } first || hi < first.Ticks || (_dataEndTicks is { } de2 && lo > de2))
return AggregateEvaluator.NoData(stamp, now);
var ticks = _aggregate == HistoryAggregateType.PercentGood ? _goodRegionTicks[i] : _badRegionTicks[i];
return Calc(100d * ticks / duration, HistorianStatusCodes.Good, endOfData);
}
default:
throw new HistoryAggregateNotSupportedException(_aggregate);
}
}
// ------------------------------------------------------------------------------------------ Segments
private static bool IsNumeric(StoredSample s) =>
s.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64 or HistorianValueKind.Boolean;
/// Sloped between two usable samples only when both are Double/Int64; anything Boolean is stepped.
private static bool IsSloped(StoredSample a, StoredSample b) =>
a.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
&& b.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64;
private long BucketStartTicks(long i) => _plan.LowerTicks(i);
private long BucketEnd(long i) => _plan.UpperTicks(i);
/// The ascending index of the bucket whose (half-open) span holds tick (t ≥ window start).
private long FirstBucketAt(long t) => Math.Max(0, (t - _plan.OriginTicks) / _plan.IntervalTicks);
private void AddRaw(StoredSample from, long toTicks, double toValue) =>
Distribute(from, toTicks, toValue, (i, area, ticks) =>
{
_rawArea[i] += area;
_usableTicks[i] += ticks;
});
/// Counts [from.t, toTicks) as outage (Bad) time; no value, never marks a bucket invalid.
private void AddBad(StoredSample from, long toTicks)
{
var lo = Math.Max(from.TimeUtc.Ticks, _start);
var hi = Math.Min(toTicks, _end);
if (lo >= hi || _plan.BucketCount == 0) return;
for (var i = FirstBucketAt(lo); i < _plan.BucketCount; i++)
{
var bs = BucketStartTicks(i);
if (bs >= hi) break;
var a = Math.Max(lo, bs);
var b = Math.Min(hi, BucketEnd(i));
if (a < b) _badTicks[i] += b - a;
}
}
private void AddBridged(StoredSample from, long toTicks, double toValue, bool nonGood) =>
Distribute(from, toTicks, toValue, (i, area, ticks) =>
{
_bridgedArea[i] += area;
_bridgedTicks[i] += ticks;
if (nonGood) _bridgedNonGood[i] = true;
});
// ------------------------------------------------------------------------------------------ PercentGood / PercentBad
/// The region array a stepped region of 's status counts toward (null: Uncertain, neither).
private long[]? RegionTicks(StoredSample s) =>
s.IsGood ? _goodRegionTicks : !s.IsUsable ? _badRegionTicks : null;
///
/// The status regions between sample and the next sample (at
/// ). Inside the interval lies in, the region has 's
/// status (stepped, Part 13 §5.4.3.31-34). In a later interval the region starts at that interval's simple bounding
/// value, whose status is the simple-bounding signal's: Bad after a Bad ; Uncertain when the
/// segment runs into an outage or a sloped one has a non-Good end; else 's (the SDK's
/// GetSimpleBound).
///
private void AddStatusRegion(StoredSample p, StoredSample s, long t)
{
var lo = Math.Max(p.TimeUtc.Ticks, _start);
var hi = Math.Min(t, _end);
if (lo >= hi || _plan.BucketCount == 0) return;
long[]? bound;
if (!p.IsUsable) bound = _badRegionTicks;
else if (!s.IsUsable) bound = null; // held into an outage ⇒ Uncertain
else if (IsSloped(p, s)) bound = p.IsGood && s.IsGood ? _goodRegionTicks : null;
else bound = RegionTicks(p);
var own = RegionTicks(p);
for (var i = FirstBucketAt(lo); i < _plan.BucketCount; i++)
{
var bs = BucketStartTicks(i);
if (bs >= hi) break;
var a = Math.Max(lo, bs);
var b = Math.Min(hi, BucketEnd(i));
if (a >= b) continue;
var target = p.TimeUtc.Ticks >= bs ? own : bound;
if (target is not null) target[i] += b - a;
}
}
/// Adds [from, to), clipped to the window, to per bucket.
private void AddRegion(long[]? target, long from, long to)
{
if (target is null) return;
var lo = Math.Max(from, _start);
var hi = Math.Min(to, _end);
if (lo >= hi || _plan.BucketCount == 0) return;
for (var i = FirstBucketAt(lo); i < _plan.BucketCount; i++)
{
var bs = BucketStartTicks(i);
if (bs >= hi) break;
var a = Math.Max(lo, bs);
var b = Math.Min(hi, BucketEnd(i));
if (a < b) target[i] += b - a;
}
}
///
/// Splits the linear segment [from.t, toTicks) (value from.v → toValue) at the bucket edges, clipped to
/// the window, and hands each bucket its trapezoid area (value·ticks) and duration. A non-numeric start sample
/// marks every bucket it touches invalid instead.
///
private void Distribute(StoredSample from, long toTicks, double toValue, Action add)
{
var t0 = from.TimeUtc.Ticks;
var lo = Math.Max(t0, _start);
var hi = Math.Min(toTicks, _end);
if (lo >= hi || _plan.BucketCount == 0) return;
var numeric = IsNumeric(from);
var v0 = from.Value.Number;
var span = (double)(toTicks - t0);
double ValueAt(long x) => toValue == v0 ? v0 : v0 + (toValue - v0) * ((x - t0) / span);
for (var i = FirstBucketAt(lo); i < _plan.BucketCount; i++)
{
var bs = BucketStartTicks(i);
if (bs >= hi) break;
var a = Math.Max(lo, bs);
var b = Math.Min(hi, BucketEnd(i));
if (a >= b) continue;
if (!numeric) { _invalid[i] = true; continue; }
add(i, (ValueAt(a) + ValueAt(b)) / 2d * (b - a), b - a);
}
}
// ------------------------------------------------------------------------------------------ Interpolative
///
/// The time of Interpolative point (ascending time): the bucket's timestamp. Forward: the
/// bucket starts start + j·interval. Reverse (Part 13 anchors a reverse read at its later time, the
/// request's start): the later edges End, End − interval, … once has
/// reversed the order — so a reverse read 10:00 → 09:00 at 1 h yields the value at 10:00.
///
private long PointTicks(long j) => _plan.BucketTimestamp(j).Ticks;
private DateTime PointTime(long j) => new(PointTicks(j), DateTimeKind.Utc);
/// Classifies every unclassified point strictly before .
private void ClassifyPointsBefore(long beforeTicks)
{
if (_aggregate != HistoryAggregateType.Interpolative) return;
for (; _nextPoint < _plan.BucketCount; _nextPoint++)
{
var a = PointTicks(_nextPoint);
if (a >= beforeTicks) break;
if (_prevAny is not { } p)
{
_pending.Add(new PendingPoint(_nextPoint, a, PointQuality.NoPrior));
}
else if (p.IsUsable && p.TimeUtc.Ticks == a)
{
// A usable sample exactly at the interval start: its own value and status.
_points[_nextPoint] = IsNumeric(p)
? new DataValueSnapshot(p.Value.Number, p.Status, PointTime(_nextPoint), default)
: InvalidPoint(_nextPoint);
}
else if (!p.IsUsable)
{
_pending.Add(new PendingPoint(_nextPoint, a, PointQuality.Degraded));
}
else
{
// Inside a segment that starts at a usable sample; its quality is only known once the next sample
// arrives (beforeTicks is that sample's time, or MaxValue at Finish ⇒ extrapolated).
_pending.Add(new PendingPoint(_nextPoint, a, PointQuality.OnSegment));
}
}
}
/// Resolves the pending bucket starts against the bridging pair (p, next).
private void ResolvePending(StoredSample? p, StoredSample? next)
{
if (_pending.Count == 0) return;
foreach (var pt in _pending)
{
var bucketStart = PointTime(pt.Bucket);
if (p is not { } prev || pt.Quality == PointQuality.NoPrior)
{
_points[pt.Bucket] = new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, bucketStart, default);
continue;
}
if (!IsNumeric(prev) || next is { } nn && !IsNumeric(nn))
{
_points[pt.Bucket] = InvalidPoint(pt.Bucket);
continue;
}
// Good only on a segment that runs straight from prev to next (no outage skipped, not extrapolated), and only
// when the samples that define the value are Good: both ends when sloped, prev alone when stepped.
double value;
bool good;
if (next is { } n && IsSloped(prev, n))
{
value = prev.Value.Number + (n.Value.Number - prev.Value.Number)
* ((double)(pt.Ticks - prev.TimeUtc.Ticks) / (n.TimeUtc.Ticks - prev.TimeUtc.Ticks));
good = pt.Quality == PointQuality.OnSegment && prev.IsGood && n.IsGood;
}
else
{
value = prev.Value.Number;
good = pt.Quality == PointQuality.OnSegment && next is not null && prev.IsGood;
}
var status = good ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
_points[pt.Bucket] = new DataValueSnapshot(value, HistorianInfoBits.WithInterpolated(status), bucketStart, default);
}
_pending.Clear();
}
/// Marks every pending on-segment point degraded: its segment ends at a Bad sample.
private void DegradePending()
{
for (var k = 0; k < _pending.Count; k++)
if (_pending[k].Quality == PointQuality.OnSegment)
_pending[k] = _pending[k] with { Quality = PointQuality.Degraded };
}
private DataValueSnapshot InvalidPoint(long bucket) =>
new(null, HistorianStatusCodes.BadAggregateInvalidInputs, PointTime(bucket), default);
}