Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/RawAggregateReader.cs
T

416 lines
27 KiB
C#

using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// <summary>
/// #681: serves the per-sample processed aggregates (the <see cref="AggregateFamily.Simple"/>,
/// <see cref="AggregateFamily.StartEnd"/> and <see cref="AggregateFamily.ActualTime"/> families) on the Gateway backend by
/// streaming the window's RAW samples through the gateway and evaluating them with the shared
/// <c>Driver.Historian.Sql.Common</c> code — <see cref="BucketPlan"/> for the bucket math (forward and reverse, #677) and
/// <see cref="AggregateEvaluator.EvaluateAll"/> for values, status and the Partial/Calculated bits — so the Gateway answers
/// exactly what SqlServer and QuestDB answer for the same stored samples.
/// </summary>
/// <remarks>
/// <para>
/// <b>Forward raw reads, each consumed to completion.</b> The gateway's <c>ReadRaw</c> streams oldest-first over an
/// inclusive <c>[start, end]</c> and refuses <c>end &lt; start</c>, so a reverse read streams the same ascending range
/// and <see cref="BucketPlan"/> assembles the result newest-first. The window stream is classified sample by sample
/// with <see cref="BucketPlan.IndexOf"/> (<c>[start, end)</c> forward, <c>(start, end]</c> reverse) — memory is
/// O(buckets), never O(samples). No gateway stream is ever abandoned mid-way: a client-side break cancels the call,
/// and the gateway currently classifies that cancellation as a connection fault and evicts its shared historian
/// session (HistorianGateway <c>HistorianRpcFaults.IsConnectionFault</c>: any <c>RpcException</c> ⇒ true).
/// </para>
/// <para>
/// <b>The AVEVA start-boundary row.</b> An AVEVA <c>Full</c> raw read emits an initial-value row stamped exactly at
/// the query start (histsdk reverse-engineering: <c>Quality = 133</c>, <c>OpcQuality = 192</c>) carrying the value
/// in effect there, not a stored sample. The window stream is therefore requested from <see cref="BoundaryMargin"/>
/// (1 ms) BEFORE the window, so that row lands outside every bucket and can never be counted as a sample. A row
/// before the window counts only as evidence that the tag has earlier data, and only when its OPC quality is not
/// Bad — a "no earlier data" NULL row still arrives WITH a value (histsdk sets <c>NumericValue = 0</c> on every
/// numeric row, <c>StringValue = ""</c> on a string one) but with a Bad OPC quality (captured: 1, no-data).
/// </para>
/// <para>
/// <b>The data range.</b> The start/end-of-data Partial rules and Count's in-range empty interval need the tag's
/// earliest sample at or before the window's later edge and latest at or after its earlier edge
/// (<see cref="AggregateEvaluator.NeedsDataRange"/>; a plain <c>Average</c> needs neither). Earlier data is a non-Bad
/// pre-window row, or — when none arrived — a <see cref="ProbeRows"/>-row read <c>[</c><see cref="ProbeFloor"/><c>,
/// start)</c>. Later data is a sample exactly at the window end (forward) from the window read, or else a
/// <see cref="ProbeRows"/>-row read <c>[end, now + </c><see cref="LookAheadHorizon"/><c>]</c> (the historian stores
/// nothing further in the future). In both small reads a row stamped exactly at the query start (a boundary row) or
/// at the query end (a possible terminal row) is not a stored sample, whatever its value.
/// </para>
/// <para>
/// <b>Bounded.</b> The in-window rows are capped by <c>ServerHistorian:MaxTimeWeightedRawRows</c> (the same knob the
/// SQL backends' time-weighted reads use): past it the node fails with
/// <see cref="HistoryReadLimitExceededException"/> (⇒ <c>BadTooManyOperations</c>) once the stream — bounded by the
/// requested row budget — has been drained. The gateway is asked for at most <c>cap + 2 + </c>
/// <see cref="PreWindowAllowance"/> rows, clamped at <see cref="GatewayDefaultMaxValuesCeiling"/> while the cap is at
/// or under it (#688, <see cref="ClampToGatewayCeiling"/>); a stream that delivers all of them cannot be proven
/// complete and fails the same way rather than answering from a truncated window. A strict gateway whose ceiling is
/// below that budget (a ceiling under 1,000,000, or an unbounded cap) refuses it naming its ceiling; the window is
/// then re-read once with the ceiling as budget (#691.3, <see cref="GatewayReadLimits"/>), and the same
/// "delivered the whole budget ⇒ cannot be proven complete" rule applies.
/// </para>
/// <para>
/// <b>Values and status.</b> A sample's value is its numeric value (Double), else its string value, else Empty; its
/// status is <see cref="SampleMapper.ToStatus"/> — the same quality mapping a raw HistoryRead serves (C-002: the
/// gateway stores no quality, so AVEVA history is normally all Good). An Empty sample is clamped Bad
/// (<see cref="StoredSample.FromStored"/>), exactly as on the SQL backends.
/// </para>
/// </remarks>
internal static class RawAggregateReader
{
/// <summary>How far before the window the raw stream starts, so the AVEVA initial-value row lands outside it.</summary>
internal static readonly TimeSpan BoundaryMargin = TimeSpan.FromMilliseconds(1);
/// <summary>How far past "now" the stream may look for a sample after the window (end-of-data rules).</summary>
internal static readonly TimeSpan LookAheadHorizon = TimeSpan.FromDays(1);
/// <summary>Rows the stream may spend before the window (the boundary row plus samples inside the margin).</summary>
internal const int PreWindowAllowance = 16;
/// <summary>Start of the "is there data before the window?" probe (the Unix epoch — before any AVEVA history).</summary>
internal static readonly DateTime ProbeFloor = DateTime.UnixEpoch;
/// <summary>Rows the earlier-data probe asks for (a possible boundary row plus the first stored sample).</summary>
internal const int ProbeRows = 2;
/// <summary>
/// The <c>ReadRaw</c> start for a window that begins at <paramref name="windowStartUtc"/>: <see cref="BoundaryMargin"/>
/// earlier, so the AVEVA start-boundary row — stamped exactly at the QUERY start, carrying the value in effect there
/// (<c>Quality = 133</c>) or, with no earlier data, a NULL row (value 0, Bad OPC quality) — lands BEFORE the window
/// (<see cref="IsWindowSample"/>). The one place both the raw-evaluated aggregates (#681) and a raw HistoryRead
/// (#687) decide what the request start is. Never before <see cref="ProbeFloor"/> (the Unix epoch — the start of
/// the historian's time encoding, the same floor the server's reverse walk-back uses, #686): a window starting less
/// than one margin after it is requested from the epoch itself.
/// </summary>
/// <param name="windowStartUtc">The window's (inclusive) start.</param>
/// <returns>The start to request.</returns>
public static DateTime RequestStartFor(DateTime windowStartUtc) =>
windowStartUtc - ProbeFloor > BoundaryMargin ? windowStartUtc - BoundaryMargin
: windowStartUtc > ProbeFloor ? ProbeFloor
: windowStartUtc;
/// <summary>
/// True when a row of a forward raw stream requested from <see cref="RequestStartFor"/> is a stored sample of the raw
/// window <c>[windowStartUtc, windowEndUtc)</c> — the <see cref="IHistorianDataSource.ReadRawAsync"/> contract
/// (exclusive end, as on the SQL backends; the gateway's own <c>ReadRaw</c> end is inclusive) — or, for a point read
/// (<c>start == end</c>, the server's tie-cluster over-fetch), of that one instant. A row before the start is the AVEVA
/// boundary row (whatever its value or quality — a stored NULL looks the same on the wire, so the timestamp is the
/// evidence) or a stored sample inside the margin; a row without a timestamp is never a sample.
/// </summary>
/// <param name="raw">The gateway row.</param>
/// <param name="windowStartUtc">The window's (inclusive) start.</param>
/// <param name="windowEndUtc">The window's end: exclusive, or inclusive when it equals the start.</param>
/// <returns>Whether the row belongs to the window.</returns>
public static bool IsWindowSample(HistorianSample raw, DateTime windowStartUtc, DateTime windowEndUtc) =>
raw.Timestamp is { } ts && ts.ToDateTime() is var t && IsAfterFloor(t) && t >= windowStartUtc
&& (t < windowEndUtc || (t == windowEndUtc && windowStartUtc == windowEndUtc));
/// <summary>
/// A row stamped at or before <see cref="ProbeFloor"/> (the Unix epoch) is never a stored sample: AVEVA stores nothing
/// there (the server's #686 walk-back assumes the same), and a window starting at or before the epoch gets no 1 ms
/// margin (<see cref="RequestStartFor"/>), so AVEVA's synthetic boundary / no-data row lands exactly at its start.
/// </summary>
/// <param name="t">The row's timestamp.</param>
/// <returns>Whether the row can be a stored sample.</returns>
internal static bool IsAfterFloor(DateTime t) => t > ProbeFloor;
/// <summary>True for the aggregates this reader serves: the per-sample families of <see cref="AggregateFamilies"/>.</summary>
/// <param name="aggregate">Requested aggregate.</param>
/// <returns>Whether the aggregate is evaluated from raw samples.</returns>
public static bool Serves(HistoryAggregateType aggregate) =>
AggregateFamilies.FamilyOf(aggregate) is AggregateFamily.Simple or AggregateFamily.StartEnd or AggregateFamily.ActualTime;
/// <summary>The gateway <c>max_values</c> for a configured window cap (<see cref="int.MaxValue"/> when unbounded).</summary>
/// <param name="maxWindowRows">The configured cap (&lt;= 0 = unbounded).</param>
/// <returns>The row limit to request.</returns>
public static int MaxValuesFor(int maxWindowRows) =>
maxWindowRows <= 0 ? int.MaxValue : ClampToGatewayCeiling((long)maxWindowRows + 2 + PreWindowAllowance, maxWindowRows);
/// <summary>
/// The HistorianGateway's default <c>Historian:ReadLimits:MaxValuesCeiling</c>. With
/// <c>AllowStreamingBeyondCeiling=false</c> the gateway refuses any <c>max_values</c> above its ceiling.
/// </summary>
internal const int GatewayDefaultMaxValuesCeiling = 1_000_000;
/// <summary>
/// #688: a <c>ReadRaw</c> row budget of <paramref name="rows"/> plus this backend's own allowance
/// (<paramref name="requested"/>) clamped at <see cref="GatewayDefaultMaxValuesCeiling"/> — but never below
/// <paramref name="rows"/> itself. The allowance (the boundary row, the margin, the completeness row) must not push a
/// budget that fits the gateway's default ceiling over it: the default <c>MaxTimeWeightedRawRows</c> (1,000,000) + 18
/// used to exceed it, so a gateway with <c>AllowStreamingBeyondCeiling=false</c> refused EVERY per-sample read. A
/// count above the ceiling is the operator's (or client's) deliberate choice for a streaming gateway and keeps its
/// allowance. The cost of clamping is that a window holding within the allowance of the ceiling cannot be proven
/// complete and fails as over the cap — the same outcome it would get one row later.
/// </summary>
/// <param name="requested">The budget including the allowance.</param>
/// <param name="rows">The caller's own row count (the cap, or <c>NumValuesPerNode</c>), &gt; 0.</param>
/// <returns>The <c>max_values</c> to request.</returns>
internal static int ClampToGatewayCeiling(long requested, long rows) =>
(int)Math.Min(int.MaxValue, rows > GatewayDefaultMaxValuesCeiling ? requested : Math.Min(requested, GatewayDefaultMaxValuesCeiling));
/// <summary>Maps one gateway raw sample to a stored sample (value + mapped status, Empty clamped Bad).</summary>
/// <param name="sample">The gateway sample; its timestamp must be set.</param>
/// <returns>The stored sample.</returns>
public static StoredSample ToStored(HistorianSample sample)
{
var value = sample.HasNumericValue ? HistorianValue.FromDouble(sample.NumericValue)
: sample.HasStringValue ? HistorianValue.FromString(sample.StringValue)
: HistorianValue.Empty;
return StoredSample.FromStored(sample.Timestamp.ToDateTime(), value, SampleMapper.ToStatus(sample));
}
/// <summary>Reads and evaluates one per-sample processed read.</summary>
/// <param name="client">The gateway client.</param>
/// <param name="ceiling">The data source's learned gateway row ceiling (#691.3).</param>
/// <param name="tag">Historian tag name.</param>
/// <param name="aggregate">A per-sample aggregate (<see cref="Serves"/>).</param>
/// <param name="plan">The bucket plan.</param>
/// <param name="maxWindowRows">The in-window raw-row cap (&lt;= 0 = unbounded).</param>
/// <param name="now">Server timestamp, and the anchor of the look-ahead horizon.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>One value per interval, in the plan's order.</returns>
/// <exception cref="HistoryReadLimitExceededException">The window holds more than <paramref name="maxWindowRows"/> rows,
/// or the stream could not be read to completion within the requested row budget.</exception>
public static async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(IHistorianGatewayClient client, GatewayRowCeiling ceiling, string tag,
HistoryAggregateType aggregate, BucketPlan plan, int maxWindowRows, DateTime now, CancellationToken ct)
{
if (plan.BucketCount == 0) return [];
var needsRange = AggregateEvaluator.NeedsDataRange(aggregate, plan);
var requestStart = RequestStartFor(plan.StartUtc);
// A strict gateway that refuses the budget is re-asked once at the ceiling it names (#691.3; e.g. a ceiling below
// the 1,000,000 #688 clamps at, or an unbounded cap) — and a stream that fills that ceiling is never evaluated.
var requested = MaxValuesFor(maxWindowRows);
var (pass, maxValues) = await ceiling.ReadAsync(requested,
budget => ReadWindowAsync(client, tag, aggregate, plan, requestStart, budget, maxWindowRows, ct),
(r, budget) => !r.OverCap && r.Received >= budget).ConfigureAwait(false);
var (buckets, earliest, latest, received, overCap) = pass;
if (overCap) throw LimitExceeded(tag, maxWindowRows);
if (received >= maxValues) throw BudgetExhausted(tag, maxWindowRows, requested, maxValues);
if (needsRange)
{
// Earlier data: no non-Bad pre-window row ⇒ ask explicitly (normally only when the tag's history really starts
// inside the window).
var earlierSeen = earliest is { } e && (e < plan.StartUtc || (plan.Reversed && e == plan.StartUtc));
if (!earlierSeen && plan.StartUtc.AddTicks(-1) > ProbeFloor)
if (await FirstSampleAsync(client, tag, ProbeFloor, plan.StartUtc.AddTicks(-1), ct).ConfigureAwait(false) is { } before)
earliest = before;
// Later data: the main read covers a sample exactly at the window end (forward: that is already later data);
// otherwise a small separate read from the end to now + horizon.
var laterSeen = latest is { } l && (plan.Reversed ? l > plan.EndUtc : l >= plan.EndUtc);
var horizon = DateTime.MaxValue - now > LookAheadHorizon
? DateTime.SpecifyKind(now + LookAheadHorizon, DateTimeKind.Utc)
: DateTime.MaxValue;
if (!laterSeen && horizon > plan.EndUtc)
if (await FirstSampleAsync(client, tag, plan.EndUtc, horizon, ct).ConfigureAwait(false) is { } after)
latest = after;
}
return AggregateEvaluator.EvaluateAll(aggregate, plan, buckets.Rows(), earliest, latest, now);
}
/// <summary>The window read of <see cref="ReadAsync"/> with one row budget, drained.</summary>
private static async Task<(RawBucketAccumulator Buckets, DateTime? Earliest, DateTime? Latest, long Received, bool OverCap)>
ReadWindowAsync(IHistorianGatewayClient client, string tag, HistoryAggregateType aggregate, BucketPlan plan,
DateTime requestStart, int maxValues, int maxWindowRows, CancellationToken ct)
{
var buckets = new RawBucketAccumulator(aggregate == HistoryAggregateType.MaximumActualTime);
DateTime? earliest = null, latest = null;
long windowRows = 0, received = 0;
var overCap = false;
// Consumed to COMPLETION (never broken out of): abandoning a gateway stream cancels the call server-side, and the
// gateway currently treats that cancellation as a connection fault and evicts its shared historian session. Past
// the cap the rest is drained (bounded by maxValues) without being evaluated.
await foreach (var raw in client.ReadRawAsync(tag, requestStart, plan.EndUtc, maxValues, ct).ConfigureAwait(false))
{
received++;
if (overCap || raw.Timestamp is null) continue;
var s = ToStored(raw);
var t = s.TimeUtc;
if (!IsAfterFloor(t)) continue; // the boundary row of a window starting at/before the epoch, never a sample
if (plan.IndexOf(t) is { } bucket)
{
if (maxWindowRows > 0 && ++windowRows > maxWindowRows)
{
overCap = true;
continue;
}
buckets.Add(bucket, s);
}
// The data range as the SQL backends define it: earliest stored sample at or before the window's later edge,
// latest at or after its earlier edge. A row before the window (the AVEVA boundary row, or a stored sample in
// the margin) proves earlier data only when its OPC quality is not Bad: AVEVA's boundary row for a tag with no
// earlier data is a NULL row, which arrives WITH a value (histsdk sets NumericValue = 0 / StringValue "") and
// a Bad OPC quality (captured: 1, no-data).
if (t >= plan.StartUtc || IsNotBadOnTheWire(raw))
{
if (t <= plan.EndUtc && (earliest is null || t < earliest)) earliest = t;
if (t >= plan.StartUtc && (latest is null || t > latest)) latest = t;
}
}
return (buckets, earliest, latest, received, overCap);
}
/// <summary>
/// The time of the first stored sample (any quality) strictly between <paramref name="startUtc"/> and
/// <paramref name="endUtc"/>, read as a <see cref="ProbeRows"/>-row raw read consumed to completion. A row stamped
/// exactly at the query start is the historian's boundary row (the value in effect there, or a NULL row when there is
/// none) and a row exactly at the query end may be a terminal row — neither is a stored sample, whatever its value.
/// </summary>
private static async Task<DateTime?> FirstSampleAsync(IHistorianGatewayClient client, string tag, DateTime startUtc,
DateTime endUtc, CancellationToken ct)
{
DateTime? found = null;
await foreach (var raw in client.ReadRawAsync(tag, startUtc, endUtc, ProbeRows, ct).ConfigureAwait(false))
{
if (found is not null || raw.Timestamp is null) continue; // drain, never break (see ReadAsync)
var t = raw.Timestamp.ToDateTime();
if (t > startUtc && t < endUtc) found = t;
}
return found;
}
/// <summary>
/// The row's OPC DA quality alone (<c>opc_quality</c> ≥ 64: Good or Uncertain) — deliberately without
/// <see cref="SampleMapper.ToStatus"/>'s fallback to the AVEVA <c>quality</c> field when <c>opc_quality</c> is 0, which
/// is also the OPC DA Bad value a NULL row carries.
/// </summary>
private static bool IsNotBadOnTheWire(HistorianSample raw) => (byte)raw.OpcQuality >= 64; // low byte, as SampleMapper.ToStatus reads it
/// <summary>
/// The stream delivered its whole row budget, so the window cannot be proven complete. Names the limit that actually
/// bound the budget: the gateway's ceiling (a strict gateway refused the requested budget and named it, #691.3), the
/// #688 clamp at the gateway's default ceiling, or the configured cap.
/// </summary>
private static HistoryReadLimitExceededException BudgetExhausted(string tag, int maxWindowRows, int requested, int budget)
{
if (budget < requested)
return new HistoryReadLimitExceededException(budget,
$"The processed read of '{tag}' covers more raw samples than the HistorianGateway's row ceiling ({budget}, its " +
"Historian:ReadLimits:MaxValuesCeiling with AllowStreamingBeyondCeiling=false); the Gateway backend evaluates " +
"per-sample aggregates from raw reads — narrow the window.");
if (maxWindowRows > 0 && budget < (long)maxWindowRows + 2 + PreWindowAllowance)
return new HistoryReadLimitExceededException(budget,
$"The processed read of '{tag}' covers more raw samples than the HistorianGateway's default row ceiling " +
$"({budget}) the request is clamped at; the Gateway backend evaluates per-sample aggregates from raw reads — " +
"narrow the window.");
return LimitExceeded(tag, maxWindowRows);
}
private static HistoryReadLimitExceededException LimitExceeded(string tag, int maxWindowRows) =>
new(maxWindowRows,
$"The processed read of '{tag}' covers more than {maxWindowRows} raw samples " +
"(ServerHistorian:MaxTimeWeightedRawRows); the Gateway backend evaluates per-sample aggregates from raw reads — " +
"narrow the window.");
}
/// <summary>
/// Builds the per-bucket statistics (<see cref="BucketRow"/>) the SQL backends' aggregate queries produce, from a stream
/// of raw samples — the C# statement of <c>SqlAggregateQueries</c>' columns: counts by class (usable = non-Bad with a
/// value; good = usable with Good severity; NonNumeric = usable String/DateTime; GoodNonNumeric = good String/DateTime),
/// Avg/Min/Max/Sum over the GOOD numeric values and UncertainMin/UncertainMax over the usable-but-not-Good numeric values
/// (#684), first/last usable value by time, the extreme GOOD numeric value with its time (ties ⇒ earliest), and the
/// first/last sample of any quality.
/// </summary>
/// <remarks>
/// Keep in step with the SQL backends' per-sample value columns: a change to which samples feed a statistic there
/// (e.g. Uncertain handling) must be repeated here, or the Gateway backend silently diverges.
/// </remarks>
internal sealed class RawBucketAccumulator
{
private readonly bool _maximum;
private readonly Dictionary<long, State> _buckets = new();
/// <summary>Creates an accumulator.</summary>
/// <param name="maximum">Extreme = the maximum (MaximumActualTime); otherwise the minimum.</param>
public RawBucketAccumulator(bool maximum) => _maximum = maximum;
private sealed class State
{
public int Total, Usable, Good, NonNumeric, GoodNonNumeric, GoodNumeric, UncertainNumeric;
public double Sum, Min = double.PositiveInfinity, Max = double.NegativeInfinity;
public double UncertainMin = double.PositiveInfinity, UncertainMax = double.NegativeInfinity;
public StoredSample? First, Last, FirstAny, LastAny, Extreme;
}
/// <summary>Adds one in-window sample to its bucket. Order-independent (picks compare by time).</summary>
/// <param name="bucket">Ascending bucket index (<see cref="BucketPlan.IndexOf"/>).</param>
/// <param name="s">The sample.</param>
public void Add(long bucket, StoredSample s)
{
if (!_buckets.TryGetValue(bucket, out var st)) _buckets[bucket] = st = new State();
st.Total++;
if (st.FirstAny is not { } fa || s.TimeUtc < fa.TimeUtc) st.FirstAny = s;
if (st.LastAny is not { } la || s.TimeUtc >= la.TimeUtc) st.LastAny = s;
if (!s.IsUsable) return;
st.Usable++;
if (s.IsGood) st.Good++;
if (st.First is not { } f || s.TimeUtc < f.TimeUtc) st.First = s;
if (st.Last is not { } l || s.TimeUtc >= l.TimeUtc) st.Last = s;
if (!s.Value.TryGetDouble(out var v))
{
st.NonNumeric++;
if (s.IsGood) st.GoodNonNumeric++;
return;
}
if (!s.IsGood)
{
// #684: an Uncertain value is not part of the value set; it only feeds the Min/Max status rule.
st.UncertainNumeric++;
if (v < st.UncertainMin) st.UncertainMin = v;
if (v > st.UncertainMax) st.UncertainMax = v;
return;
}
st.GoodNumeric++;
st.Sum += v;
if (v < st.Min) st.Min = v;
if (v > st.Max) st.Max = v;
if (st.Extreme is not { } e) st.Extreme = s;
else
{
var ev = e.Value.Number;
var better = _maximum ? v > ev : v < ev;
if (better || (v == ev && s.TimeUtc < e.TimeUtc)) st.Extreme = s;
}
}
/// <summary>The non-empty buckets' rows, ascending by bucket index.</summary>
/// <returns>The rows.</returns>
public IEnumerable<BucketRow> Rows() =>
_buckets.OrderBy(kv => kv.Key).Select(kv =>
{
var st = kv.Value;
var hasNumeric = st.GoodNumeric > 0;
var hasUncertain = st.UncertainNumeric > 0;
return new BucketRow
{
Bucket = kv.Key,
Total = st.Total,
Usable = st.Usable,
Good = st.Good,
Bad = st.Total - st.Usable,
NonNumeric = st.NonNumeric,
GoodNonNumeric = st.GoodNonNumeric,
Avg = hasNumeric ? st.Sum / st.GoodNumeric : null,
Min = hasNumeric ? st.Min : null,
Max = hasNumeric ? st.Max : null,
Sum = hasNumeric ? st.Sum : null,
UncertainMin = hasUncertain ? st.UncertainMin : null,
UncertainMax = hasUncertain ? st.UncertainMax : null,
First = st.First?.Value ?? HistorianValue.Empty,
Last = st.Last?.Value ?? HistorianValue.Empty,
Extreme = st.Extreme?.Value.Number,
ExtremeTime = st.Extreme?.TimeUtc,
FirstAny = st.FirstAny,
LastAny = st.LastAny,
};
});
}