fix(historian): per-sample Average/Min/Max/Range/ActualTime use Good samples only, Part 13 min/max status (#684)
Part 13 defines Average/Minimum/Maximum/Range/*ActualTime over Good raw values (independent of TreatUncertainAsBad) and the SDK's calculators skip Uncertain values under the server's TreatUncertainAsBad=false. SqlServer/QuestDB value columns now use the good flag (g) instead of usable (u); new UncMin/UncMax columns feed the Part 13 Minimum/Maximum/Range status rule (Uncertain on a Bad sample or an Uncertain value beyond the Good extreme -- the SDK applies only the Bad half). An Uncertain-only interval is BadNoData. Uncertain-bearing parity datasets (in-memory + real SQL) match the SDK unmasked; the beyond-extreme divergence is pinned explicitly. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -351,12 +351,46 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
}
|
||||
|
||||
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 (no empty interval).</summary>
|
||||
private Task SeedDenseAsync() => B.SeedAsync("P", DenseRows());
|
||||
private Task SeedDenseAsync(bool uncertain = false) => B.SeedAsync(uncertain ? "PU" : "P", DenseRows(uncertain));
|
||||
|
||||
private static (DateTime T, HistorianValue V, uint Status)[] DenseRows() => Enumerable.Range(0, 45)
|
||||
.Select(i => (At(-10 + i * 2.5), HistorianValue.FromDouble(10 + (i * 7 % 13)), i == 13 ? Bad : Good))
|
||||
/// <summary>
|
||||
/// #684 <paramref name="uncertain"/>: every fifth sample (other than the Bad one) Uncertain at 16 — inside every
|
||||
/// interval's Good range (the cycling values alternate around 16), so the min/max family matches the SDK unmasked
|
||||
/// (SdkAggregateParityTests.DenseUncertain is the same data).
|
||||
/// </summary>
|
||||
private static (DateTime T, HistorianValue V, uint Status)[] DenseRows(bool uncertain = false) => Enumerable.Range(0, 45)
|
||||
.Select(i => uncertain && i % 5 == 0 && i != 13
|
||||
? (At(-10 + i * 2.5), HistorianValue.FromDouble(16), Uncertain)
|
||||
: (At(-10 + i * 2.5), HistorianValue.FromDouble(10 + (i * 7 % 13)), i == 13 ? Bad : Good))
|
||||
.ToArray();
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 80, 20)]
|
||||
[InlineData(80, 0, 20)]
|
||||
[InlineData(77, 3, 20)]
|
||||
[InlineData(3, 77, 20)]
|
||||
public async Task Per_sample_processed_reads_over_uncertain_samples_match_the_SDK(double from, double to, double intervalS)
|
||||
{
|
||||
// #684: the Good-only value columns through the real SQL against the SDK's calculators, over Dense with Uncertain
|
||||
// samples — values, timestamps, code bits and Partial, no mask.
|
||||
await SeedDenseAsync(uncertain: true);
|
||||
var rows = DenseRows(uncertain: true).Select(r => (B.Round(r.T), r.V, r.Status)).ToArray();
|
||||
var src = B.CreateDataSource();
|
||||
var interval = TimeSpan.FromSeconds(intervalS);
|
||||
var (start, end) = (B.Round(At(from)), B.Round(At(to)));
|
||||
foreach (var agg in new[]
|
||||
{
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
HistoryAggregateType.Count, HistoryAggregateType.Range, HistoryAggregateType.MinimumActualTime,
|
||||
HistoryAggregateType.MaximumActualTime,
|
||||
})
|
||||
{
|
||||
var ours = (await src.ReadProcessedAsync("PU", At(from), At(to), interval, agg, Ct)).Samples.ToList();
|
||||
SdkAggregateOracle.ShouldMatch(ours, SdkAggregateOracle.Run(agg, start, end, interval, rows),
|
||||
$"{agg} uncertain {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 80, 20)]
|
||||
[InlineData(80, 0, 20)]
|
||||
@@ -476,14 +510,70 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uncertain_samples_are_used_but_make_the_interval_uncertain()
|
||||
public async Task Uncertain_samples_are_left_out_of_the_value_and_degrade_the_status_per_Part_13()
|
||||
{
|
||||
// #684 (Part 13 §5.4.3.5/10-14; the SDK's calculators): Average / Minimum / Maximum / Range / *ActualTime use GOOD
|
||||
// values only. One interval [0,20): Good 6 @+2, Good 8 @+3, Uncertain 4 @+1 (below the Good minimum) and
|
||||
// Uncertain 7 @+4 (inside the Good range); a Good sample before (-5) and after (+30) the window keep the data range
|
||||
// around it (no Partial). Pre-#684 the backends averaged 6.25 and answered Minimum 4.
|
||||
await B.SeedAsync("U",
|
||||
(At(-5), HistorianValue.FromDouble(100), Good),
|
||||
(At(1), HistorianValue.FromDouble(4), Uncertain),
|
||||
(At(2), HistorianValue.FromDouble(6), Good));
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("U", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)).Samples.Single();
|
||||
s.Value.ShouldBe(5d);
|
||||
s.StatusCode.ShouldBe(UncertainCalc);
|
||||
(At(2), HistorianValue.FromDouble(6), Good),
|
||||
(At(3), HistorianValue.FromDouble(8), Good),
|
||||
(At(4), HistorianValue.FromDouble(7), Uncertain),
|
||||
(At(30), HistorianValue.FromDouble(100), Good));
|
||||
var src = B.CreateDataSource();
|
||||
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("U", At(0), At(20), Twenty, a, Ct)).Samples.Single();
|
||||
|
||||
// Average: value-based status — not every sample is Good ⇒ Uncertain.
|
||||
var avg = await One(HistoryAggregateType.Average);
|
||||
avg.Value.ShouldBe(7d);
|
||||
avg.StatusCode.ShouldBe(UncertainCalc);
|
||||
// Minimum: Uncertain 4 is below the Good minimum 6 ⇒ Uncertain (Part 13 Table 57), value still 6.
|
||||
var min = await One(HistoryAggregateType.Minimum);
|
||||
min.Value.ShouldBe(6d);
|
||||
min.StatusCode.ShouldBe(UncertainCalc);
|
||||
// Maximum: no Uncertain value above 8 and no Bad sample ⇒ Good.
|
||||
var max = await One(HistoryAggregateType.Maximum);
|
||||
max.Value.ShouldBe(8d);
|
||||
max.StatusCode.ShouldBe(GoodCalc);
|
||||
var range = await One(HistoryAggregateType.Range);
|
||||
range.Value.ShouldBe(2d);
|
||||
range.StatusCode.ShouldBe(UncertainCalc);
|
||||
var minAt = await One(HistoryAggregateType.MinimumActualTime);
|
||||
minAt.Value.ShouldBe(6d);
|
||||
minAt.SourceTimestampUtc.ShouldBe(B.Round(At(2)));
|
||||
minAt.StatusCode.ShouldBe(UncertainCalc);
|
||||
var maxAt = await One(HistoryAggregateType.MaximumActualTime);
|
||||
maxAt.Value.ShouldBe(8d);
|
||||
maxAt.SourceTimestampUtc.ShouldBe(B.Round(At(3)));
|
||||
maxAt.StatusCode.ShouldBe(GoodCalc);
|
||||
// Count (#680, unchanged): the two Good samples, Uncertain.
|
||||
(await One(HistoryAggregateType.Count)).Value.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_interval_of_only_uncertain_samples_is_BadNoData_for_the_good_value_aggregates()
|
||||
{
|
||||
// #684: Part 13 defines these over Good values; the SDK returns no-data when an interval has none.
|
||||
await B.SeedAsync("UO",
|
||||
(At(-5), HistorianValue.FromDouble(1), Good),
|
||||
(At(1), HistorianValue.FromDouble(4), Uncertain),
|
||||
(At(2), HistorianValue.FromDouble(6), Uncertain),
|
||||
(At(30), HistorianValue.FromDouble(1), Good));
|
||||
var src = B.CreateDataSource();
|
||||
foreach (var agg in new[]
|
||||
{
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
HistoryAggregateType.Range, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
||||
})
|
||||
{
|
||||
var v = (await src.ReadProcessedAsync("UO", At(0), At(20), Twenty, agg, Ct)).Samples.Single();
|
||||
v.Value.ShouldBeNull(agg.ToString());
|
||||
v.StatusCode.ShouldBe(NoDataCalc, agg.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+46
-1
@@ -107,10 +107,55 @@ public sealed class BucketRowMappingTests
|
||||
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(long));
|
||||
t.Columns.Add("num_value", typeof(double));
|
||||
t.Columns.Add("ts", typeof(DateTime));
|
||||
t.Rows.Add(0L, 1L, 1L, 1L, 0L, 0L, DBNull.Value, DBNull.Value);
|
||||
t.Columns.Add("unc_min", typeof(double));
|
||||
t.Columns.Add("unc_max", typeof(double));
|
||||
t.Rows.Add(0L, 1L, 1L, 1L, 0L, 0L, DBNull.Value, DBNull.Value, 4d, DBNull.Value);
|
||||
|
||||
var b = Map(t, AggregateFamily.ActualTime);
|
||||
b.Extreme.ShouldBeNull();
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.MaximumActualTime, b, T, T).StatusCode.ShouldBe(NoDataCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_simple_row_maps_the_good_value_columns_and_the_uncertain_extremes()
|
||||
{
|
||||
// #684: AvgValue/MinValue/MaxValue/SumValue are over Good samples; UncMin/UncMax (ordinals 10, 11) feed the Part 13
|
||||
// Minimum/Maximum/Range status rule.
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("b", typeof(long));
|
||||
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(long));
|
||||
foreach (var c in new[] { "avg", "min", "max", "sum", "uncmin", "uncmax" }) t.Columns.Add(c, typeof(double));
|
||||
t.Rows.Add(0L, 3L, 3L, 2L, 0L, 0L, 20d, 10d, 30d, 40d, 5d, 25d);
|
||||
|
||||
var b = Map(t, AggregateFamily.Simple);
|
||||
b.Avg.ShouldBe(20d);
|
||||
b.Min.ShouldBe(10d);
|
||||
b.Max.ShouldBe(30d);
|
||||
b.Sum.ShouldBe(40d);
|
||||
b.UncertainMin.ShouldBe(5d);
|
||||
b.UncertainMax.ShouldBe(25d);
|
||||
// Uncertain 5 is below the Good minimum ⇒ Minimum Uncertain; 25 is inside the Good range ⇒ Maximum Good.
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.Minimum, b, T, T).StatusCode.ShouldBe(0x40A40401u);
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.Maximum, b, T, T).StatusCode.ShouldBe(0x00000401u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_actual_time_row_maps_the_uncertain_extremes_after_the_picked_sample()
|
||||
{
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("b", typeof(long));
|
||||
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(long));
|
||||
t.Columns.Add("num_value", typeof(double));
|
||||
t.Columns.Add("ts", typeof(DateTime));
|
||||
t.Columns.Add("unc_min", typeof(double));
|
||||
t.Columns.Add("unc_max", typeof(double));
|
||||
t.Rows.Add(0L, 3L, 3L, 2L, 0L, 0L, 10d, T, 5d, 25d);
|
||||
|
||||
var b = Map(t, AggregateFamily.ActualTime);
|
||||
b.Extreme.ShouldBe(10d);
|
||||
b.ExtremeTime.ShouldBe(T);
|
||||
b.UncertainMin.ShouldBe(5d);
|
||||
b.UncertainMax.ShouldBe(25d);
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.MinimumActualTime, b, T, T).StatusCode.ShouldBe(0x40A40401u);
|
||||
}
|
||||
}
|
||||
|
||||
+89
-7
@@ -248,14 +248,96 @@ public sealed class AggregateEvaluatorTests
|
||||
ex.Aggregate.ShouldBe(HistoryAggregateType.TimeAverage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_uncertain_sample_in_a_bucket_degrades_the_aggregate_to_uncertain()
|
||||
/// <summary>
|
||||
/// #684: a bucket of Good 10 and Good 30 plus the given Uncertain sample(s) (no Bad). The value columns are the Good
|
||||
/// values only, which is what the backends' SQL now computes (the good flag, not the usable one).
|
||||
/// </summary>
|
||||
private static BucketRow GoodTenThirtyPlusUncertain(params double[] uncertain) => new()
|
||||
{
|
||||
// Two usable samples, one Good and one Uncertain, no Bad: the value is computed, the status is Uncertain.
|
||||
var row = new BucketRow { Total = 2, Usable = 2, Good = 1, Bad = 0, Avg = 15, Min = 10, Max = 20, Sum = 30 };
|
||||
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
||||
avg.Value.ShouldBe(15d);
|
||||
avg.StatusCode.ShouldBe(UncertainCalc);
|
||||
Total = 2 + uncertain.Length, Usable = 2 + uncertain.Length, Good = 2, Bad = 0,
|
||||
Avg = 20, Min = 10, Max = 30, Sum = 40,
|
||||
UncertainMin = uncertain.Length == 0 ? null : uncertain.Min(),
|
||||
UncertainMax = uncertain.Length == 0 ? null : uncertain.Max(),
|
||||
};
|
||||
|
||||
private static DataValueSnapshot Eval(HistoryAggregateType agg, BucketRow row)
|
||||
{
|
||||
if (agg is HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime)
|
||||
{
|
||||
row.Extreme = agg == HistoryAggregateType.MinimumActualTime ? row.Min : row.Max;
|
||||
row.ExtremeTime = At(5);
|
||||
}
|
||||
return AggregateEvaluator.Evaluate(agg, row, At(0), Now);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Uncertain 20 sits INSIDE the Good range [10, 30]: Part 13 (and the SDK's ComputeMinMax) leave Min/Max/Range Good;
|
||||
// Average is value-based (Good only when every sample is Good, the SDK's GetValueBasedStatusCode) => Uncertain.
|
||||
[InlineData(HistoryAggregateType.Average, new[] { 20d }, 20d, false)]
|
||||
[InlineData(HistoryAggregateType.Minimum, new[] { 20d }, 10d, true)]
|
||||
[InlineData(HistoryAggregateType.Maximum, new[] { 20d }, 30d, true)]
|
||||
[InlineData(HistoryAggregateType.Range, new[] { 20d }, 20d, true)]
|
||||
[InlineData(HistoryAggregateType.MinimumActualTime, new[] { 20d }, 10d, true)]
|
||||
[InlineData(HistoryAggregateType.MaximumActualTime, new[] { 20d }, 30d, true)]
|
||||
// Equal to an extreme: the Part 13 comparison is strict ("less than" / "greater than") => still Good.
|
||||
[InlineData(HistoryAggregateType.Minimum, new[] { 10d }, 10d, true)]
|
||||
[InlineData(HistoryAggregateType.Maximum, new[] { 30d }, 30d, true)]
|
||||
[InlineData(HistoryAggregateType.Range, new[] { 10d, 30d }, 20d, true)]
|
||||
// Below the Good minimum: Minimum / MinimumActualTime / Range Uncertain, Maximum untouched; the value is still the
|
||||
// Good extreme, never the Uncertain one (pre-#684 Minimum answered 5).
|
||||
[InlineData(HistoryAggregateType.Minimum, new[] { 5d }, 10d, false)]
|
||||
[InlineData(HistoryAggregateType.MinimumActualTime, new[] { 5d }, 10d, false)]
|
||||
[InlineData(HistoryAggregateType.Maximum, new[] { 5d }, 30d, true)]
|
||||
[InlineData(HistoryAggregateType.MaximumActualTime, new[] { 5d }, 30d, true)]
|
||||
[InlineData(HistoryAggregateType.Range, new[] { 5d }, 20d, false)]
|
||||
[InlineData(HistoryAggregateType.Average, new[] { 5d }, 20d, false)]
|
||||
// Above the Good maximum: the mirror.
|
||||
[InlineData(HistoryAggregateType.Maximum, new[] { 99d }, 30d, false)]
|
||||
[InlineData(HistoryAggregateType.MaximumActualTime, new[] { 99d }, 30d, false)]
|
||||
[InlineData(HistoryAggregateType.Minimum, new[] { 99d }, 10d, true)]
|
||||
[InlineData(HistoryAggregateType.MinimumActualTime, new[] { 99d }, 10d, true)]
|
||||
[InlineData(HistoryAggregateType.Range, new[] { 99d }, 20d, false)]
|
||||
public void Uncertain_samples_are_not_part_of_the_value_and_degrade_the_status_per_Part_13(HistoryAggregateType agg,
|
||||
double[] uncertain, double expected, bool good)
|
||||
{
|
||||
var v = Eval(agg, GoodTenThirtyPlusUncertain(uncertain));
|
||||
v.Value.ShouldBe(expected);
|
||||
v.StatusCode.ShouldBe(good ? GoodCalc : UncertainCalc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Range)]
|
||||
[InlineData(HistoryAggregateType.MinimumActualTime)]
|
||||
[InlineData(HistoryAggregateType.MaximumActualTime)]
|
||||
public void A_bad_sample_makes_the_min_max_family_uncertain_even_with_no_uncertain_value(HistoryAggregateType agg)
|
||||
{
|
||||
var row = GoodTenThirtyPlusUncertain();
|
||||
row.Total = 3;
|
||||
row.Bad = 1;
|
||||
Eval(agg, row).StatusCode.ShouldBe(UncertainCalc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average)]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Range)]
|
||||
[InlineData(HistoryAggregateType.MinimumActualTime)]
|
||||
[InlineData(HistoryAggregateType.MaximumActualTime)]
|
||||
public void An_interval_holding_only_uncertain_values_is_no_data(HistoryAggregateType agg)
|
||||
{
|
||||
// #684 (Part 13: these aggregates are defined over Good values; the SDK returns GetNoDataValue when none is Good).
|
||||
// The SQL's Good-only value columns are NULL; the Uncertain extremes are not a substitute.
|
||||
var row = new BucketRow { Total = 2, Usable = 2, Good = 0, UncertainMin = 4, UncertainMax = 6 };
|
||||
var v = AggregateEvaluator.Evaluate(agg, row, At(0), Now);
|
||||
v.Value.ShouldBeNull();
|
||||
v.StatusCode.ShouldBe(NoDataCalc);
|
||||
// Count is unchanged by #684: 0 Good samples, value-based Uncertain.
|
||||
var count = AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now);
|
||||
count.Value.ShouldBe(0);
|
||||
count.StatusCode.ShouldBe(UncertainCalc);
|
||||
}
|
||||
|
||||
// ---- Live-gate 8 F1: a row whose counts and picks disagree (a backend that read them from two table versions).
|
||||
|
||||
+105
-11
@@ -16,9 +16,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
||||
/// <remarks>
|
||||
/// Deliberate, documented divergences the datasets avoid (they are not anchoring questions):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Uncertain</b> samples in a per-sample aggregate: the SDK's Average/Min/Max use Good samples only,
|
||||
/// ours every usable (Good + Uncertain) sample (TreatUncertainAsBad = false applied consistently). Count matches
|
||||
/// the SDK since #680 (Good samples only);</item>
|
||||
/// <item><b>an Uncertain value beyond the Good extreme</b> in Minimum / Maximum / Range / *ActualTime: Part 13
|
||||
/// (Tables 57-62) makes the result <c>Uncertain_DataSubNormal</c>, ours follows it; the SDK's <c>ComputeMinMax</c>
|
||||
/// collects the Uncertain extremes and never reads them, so it answers Good. The value is the Good extreme in both
|
||||
/// (#684). <see cref="DenseUncertain"/> keeps its Uncertain values inside every interval's Good range so the
|
||||
/// per-sample comparison needs no mask; the divergence itself is pinned by
|
||||
/// <see cref="An_uncertain_value_beyond_the_good_extreme_is_Uncertain_per_Part_13_where_the_SDK_says_Good"/>.
|
||||
/// (Uncertain samples otherwise match since #684: Average / Min / Max / Range / *ActualTime / Count all use Good
|
||||
/// samples only, as the SDK does under the server's TreatUncertainAsBad = false);</item>
|
||||
/// <item>the per-sample <b>Calculated / MultipleValues</b> info bits (the SDK's MinMax sets Calculated only when the
|
||||
/// extreme is not at the interval start, and flags ties) — those comparisons check code bits + Partial only;</item>
|
||||
/// <item>a <b>truncated</b> interval: ours sets Partial on a non-Bad time-weighted result (Part 13 §5.3.3.2 Case 3
|
||||
@@ -66,6 +71,20 @@ public sealed class SdkAggregateParityTests
|
||||
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// #684: <see cref="Dense"/> with every fifth sample (from +2.5) Uncertain at 16 and the one Bad at +22.5. The cycling
|
||||
/// Good values alternate around 16 (every adjacent pair has one at or below it and one at or above it), so an
|
||||
/// Uncertain 16 lies inside the Good range of every interval of every window (each holds 5+ samples): Average /
|
||||
/// Minimum / Maximum / Range / *ActualTime must leave it out of the value (Good only — the SDK, Part 13), and the
|
||||
/// min/max family's status matches the SDK's without a mask.
|
||||
/// </summary>
|
||||
private static readonly StoredSample[] DenseUncertain = Enumerable.Range(0, 45)
|
||||
.Select(i => (t: -10 + i * 2.5, i))
|
||||
.Select(x => x.i % 5 == 0 && x.t != 22.5
|
||||
? S(x.t, HistorianValue.FromDouble(16), Uncertain)
|
||||
: S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
|
||||
.ToArray();
|
||||
|
||||
/// <summary>Tag A with Good bounds on both sides, plus an Uncertain sample and an outage marker further on.</summary>
|
||||
private static readonly StoredSample[] Bounded =
|
||||
[
|
||||
@@ -302,11 +321,12 @@ public sealed class SdkAggregateParityTests
|
||||
{
|
||||
var failures = new List<string>();
|
||||
foreach (var agg in PerSample)
|
||||
foreach (var (name, data) in new[] { ("Dense", Dense), ("DenseUncertain", DenseUncertain) })
|
||||
{
|
||||
try
|
||||
{
|
||||
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, Dense), Sdk(agg, from, to, intervalS, Dense),
|
||||
$"{agg} Dense {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false,
|
||||
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), Sdk(agg, from, to, intervalS, data),
|
||||
$"{agg} {name} {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false,
|
||||
compareValues: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -317,6 +337,75 @@ public sealed class SdkAggregateParityTests
|
||||
failures.ShouldBeEmpty(string.Join("\n", failures));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Windows))]
|
||||
public void The_uncertain_dataset_is_not_vacuous(double from, double to, double intervalS)
|
||||
{
|
||||
// #684: DenseUncertain must actually move the per-sample values the pre-#684 (Good + Uncertain) columns computed —
|
||||
// otherwise its parity proves nothing. Average over the usable samples differs from the SDK's in some interval of
|
||||
// every window.
|
||||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||||
var usableAverage = InMemoryRows(plan, DenseUncertain.Select(x => x.IsUsable && !x.IsGood
|
||||
? x with { Status = Good } : x))
|
||||
.Select(r => r.Avg).ToList();
|
||||
var sdk = Sdk(HistoryAggregateType.Average, from, to, intervalS, DenseUncertain).Select(r => r.Value).ToList();
|
||||
var ours = plan.Reversed ? Enumerable.Reverse(usableAverage).ToList() : usableAverage;
|
||||
ours.Zip(sdk).Any(p => p.Second is double d && p.First is double u && Math.Abs(d - u) > 1e-9)
|
||||
.ShouldBeTrue("an Uncertain sample must change some interval's Average when it is (wrongly) included");
|
||||
}
|
||||
|
||||
/// <summary>#684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6).</summary>
|
||||
private static readonly StoredSample[] UncertainBeyond =
|
||||
[
|
||||
S(-5, HistorianValue.FromDouble(20), Good),
|
||||
S(2, HistorianValue.FromDouble(10), Good),
|
||||
S(4, HistorianValue.FromDouble(5), Uncertain),
|
||||
S(6, HistorianValue.FromDouble(99), Uncertain),
|
||||
S(8, HistorianValue.FromDouble(30), Good),
|
||||
S(25, HistorianValue.FromDouble(20), Good),
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Minimum, 0, 20)]
|
||||
[InlineData(HistoryAggregateType.Minimum, 20, 0)]
|
||||
[InlineData(HistoryAggregateType.Maximum, 0, 20)]
|
||||
[InlineData(HistoryAggregateType.Maximum, 20, 0)]
|
||||
[InlineData(HistoryAggregateType.Range, 0, 20)]
|
||||
[InlineData(HistoryAggregateType.Range, 20, 0)]
|
||||
[InlineData(HistoryAggregateType.MinimumActualTime, 0, 20)]
|
||||
[InlineData(HistoryAggregateType.MaximumActualTime, 20, 0)]
|
||||
public void An_uncertain_value_beyond_the_good_extreme_is_Uncertain_per_Part_13_where_the_SDK_says_Good(
|
||||
HistoryAggregateType agg, double from, double to)
|
||||
{
|
||||
// #684, documented divergence (class remarks). Part 13 Tables 57-62: "If an Uncertain value is less than the
|
||||
// minimum Good value [greater than the maximum] the Status is Uncertain_DataSubNormal". The SDK's ComputeMinMax
|
||||
// tracks the Uncertain extremes and never reads them. Everything else — the value (the GOOD extreme, never 5 or
|
||||
// 99), the timestamp, the Partial bit — matches the SDK; only the code bits differ, and only as Part 13 says.
|
||||
var ours = Ours(agg, from, to, 0, UncertainBeyond).Single();
|
||||
var sdk = Sdk(agg, from, to, 0, UncertainBeyond).Single();
|
||||
(sdk.StatusCode & StatusBits.CodeBitsMask).ShouldBe(HistorianStatusCodes.Good, "the SDK ignores the Uncertain extreme");
|
||||
(ours.StatusCode & StatusBits.CodeBitsMask).ShouldBe(HistorianStatusCodes.UncertainDataSubNormal);
|
||||
SdkAggregateOracle.ShouldMatch([ours with { StatusCode = ours.StatusCode & ~StatusBits.CodeBitsMask }], [sdk],
|
||||
$"{agg} UncertainBeyond {from}→{to}", compareCalculatedInterpolatedBits: false);
|
||||
ours.Value.ShouldBe(agg switch
|
||||
{
|
||||
HistoryAggregateType.Minimum or HistoryAggregateType.MinimumActualTime => 10d,
|
||||
HistoryAggregateType.Maximum or HistoryAggregateType.MaximumActualTime => 30d,
|
||||
_ => 20d,
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 20)]
|
||||
[InlineData(20, 0)]
|
||||
public void Average_over_uncertain_values_beyond_the_good_range_matches_the_SDK_exactly(double from, double to)
|
||||
{
|
||||
// #684: Average has no extreme rule — Good values only, value-based status (Uncertain: not every sample is Good).
|
||||
SdkAggregateOracle.ShouldMatch(Ours(HistoryAggregateType.Average, from, to, 0, UncertainBeyond),
|
||||
Sdk(HistoryAggregateType.Average, from, to, 0, UncertainBeyond), $"Average UncertainBeyond {from}→{to}");
|
||||
Ours(HistoryAggregateType.Average, from, to, 0, UncertainBeyond).Single().Value.ShouldBe(20d);
|
||||
}
|
||||
|
||||
private static readonly HistoryAggregateType[] PerSample =
|
||||
[
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
@@ -544,8 +633,11 @@ public sealed class SdkAggregateParityTests
|
||||
var usable = all.Where(s => s.IsUsable).ToList();
|
||||
var numeric = usable.Where(s => s.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
|
||||
or HistorianValueKind.Boolean).ToList();
|
||||
// ActualTime: the extreme usable value, ties ⇒ earliest (the SQL's ROW_NUMBER order).
|
||||
var extreme = numeric.OrderBy(s => maximum ? -s.Value.Number : s.Value.Number).ThenBy(s => s.TimeUtc)
|
||||
// #684: the value columns are over Good numeric samples; the Uncertain extremes feed the Min/Max status rule.
|
||||
var good = numeric.Where(s => s.IsGood).ToList();
|
||||
var uncertain = numeric.Where(s => !s.IsGood).ToList();
|
||||
// ActualTime: the extreme Good value, ties ⇒ earliest (the SQL's ROW_NUMBER order).
|
||||
var extreme = good.OrderBy(s => maximum ? -s.Value.Number : s.Value.Number).ThenBy(s => s.TimeUtc)
|
||||
.Cast<StoredSample?>().FirstOrDefault();
|
||||
yield return new BucketRow
|
||||
{
|
||||
@@ -555,10 +647,12 @@ public sealed class SdkAggregateParityTests
|
||||
Good = all.Count(s => s.IsGood),
|
||||
Bad = all.Count - usable.Count,
|
||||
NonNumeric = usable.Count - numeric.Count,
|
||||
Avg = numeric.Count == 0 ? null : numeric.Average(s => s.Value.Number),
|
||||
Min = numeric.Count == 0 ? null : numeric.Min(s => s.Value.Number),
|
||||
Max = numeric.Count == 0 ? null : numeric.Max(s => s.Value.Number),
|
||||
Sum = numeric.Count == 0 ? null : numeric.Sum(s => s.Value.Number),
|
||||
Avg = good.Count == 0 ? null : good.Average(s => s.Value.Number),
|
||||
Min = good.Count == 0 ? null : good.Min(s => s.Value.Number),
|
||||
Max = good.Count == 0 ? null : good.Max(s => s.Value.Number),
|
||||
Sum = good.Count == 0 ? null : good.Sum(s => s.Value.Number),
|
||||
UncertainMin = uncertain.Count == 0 ? null : uncertain.Min(s => s.Value.Number),
|
||||
UncertainMax = uncertain.Count == 0 ? null : uncertain.Max(s => s.Value.Number),
|
||||
First = usable.Count == 0 ? HistorianValue.Empty : usable[0].Value,
|
||||
Last = usable.Count == 0 ? HistorianValue.Empty : usable[^1].Value,
|
||||
Extreme = extreme?.Value.Number,
|
||||
|
||||
+46
-1
@@ -103,10 +103,55 @@ public sealed class BucketRowMappingTests
|
||||
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int));
|
||||
t.Columns.Add("NumValue", typeof(double));
|
||||
t.Columns.Add("SourceTimeUtc", typeof(DateTime));
|
||||
t.Rows.Add(0L, 1, 1, 1, 0, 0, DBNull.Value, DBNull.Value);
|
||||
t.Columns.Add("UncMin", typeof(double));
|
||||
t.Columns.Add("UncMax", typeof(double));
|
||||
t.Rows.Add(0L, 1, 1, 1, 0, 0, DBNull.Value, DBNull.Value, 4d, DBNull.Value);
|
||||
|
||||
var b = Map(t, AggregateFamily.ActualTime);
|
||||
b.Extreme.ShouldBeNull();
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.MaximumActualTime, b, T, T).StatusCode.ShouldBe(NoDataCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_simple_row_maps_the_good_value_columns_and_the_uncertain_extremes()
|
||||
{
|
||||
// #684: AvgValue/MinValue/MaxValue/SumValue are over Good samples; UncMin/UncMax (ordinals 10, 11) feed the Part 13
|
||||
// Minimum/Maximum/Range status rule.
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("b", typeof(long));
|
||||
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int));
|
||||
foreach (var c in new[] { "avg", "min", "max", "sum", "uncmin", "uncmax" }) t.Columns.Add(c, typeof(double));
|
||||
t.Rows.Add(0L, 3, 3, 2, 0, 0, 20d, 10d, 30d, 40d, 5d, 25d);
|
||||
|
||||
var b = Map(t, AggregateFamily.Simple);
|
||||
b.Avg.ShouldBe(20d);
|
||||
b.Min.ShouldBe(10d);
|
||||
b.Max.ShouldBe(30d);
|
||||
b.Sum.ShouldBe(40d);
|
||||
b.UncertainMin.ShouldBe(5d);
|
||||
b.UncertainMax.ShouldBe(25d);
|
||||
// Uncertain 5 is below the Good minimum ⇒ Minimum Uncertain; 25 is inside the Good range ⇒ Maximum Good.
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.Minimum, b, T, T).StatusCode.ShouldBe(0x40A40401u);
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.Maximum, b, T, T).StatusCode.ShouldBe(0x00000401u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_actual_time_row_maps_the_uncertain_extremes_after_the_picked_sample()
|
||||
{
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("b", typeof(long));
|
||||
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int));
|
||||
t.Columns.Add("NumValue", typeof(double));
|
||||
t.Columns.Add("SourceTimeUtc", typeof(DateTime));
|
||||
t.Columns.Add("UncMin", typeof(double));
|
||||
t.Columns.Add("UncMax", typeof(double));
|
||||
t.Rows.Add(0L, 3, 3, 2, 0, 0, 10d, T, 5d, 25d);
|
||||
|
||||
var b = Map(t, AggregateFamily.ActualTime);
|
||||
b.Extreme.ShouldBe(10d);
|
||||
b.ExtremeTime.ShouldBe(T);
|
||||
b.UncertainMin.ShouldBe(5d);
|
||||
b.UncertainMax.ShouldBe(25d);
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.MinimumActualTime, b, T, T).StatusCode.ShouldBe(0x40A40401u);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user