0d206a0884
TimeAverage2/Total2: the interval the data ends inside with no non-Bad time before its last sample (comms-loss recovery, a single sample) answers that sample's value (Total2 0), Uncertain + Partial, instead of Bad/BadNoData — as the SDK. Parity datasets RecoveryEnds, OutageLast, BoolSwitch with documented masks; ProcessedConformance's reverse TA/Total mask narrowed to the later-edge-sample interval. Docs: report-by-exception consequence, outage-last and Boolean differences; CLAUDE.md historian bullet.
718 lines
38 KiB
C#
718 lines
38 KiB
C#
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
||
|
||
/// <summary>
|
||
/// The Part 13 time-weighted aggregates (#671) over hand-built sample streams. Every literal is hand-computed in the
|
||
/// comment above it (areas in value·seconds; "raw" = the simple-bounding signal, "bridged" = interpolated bounding).
|
||
/// </summary>
|
||
public sealed class TimeWeightedAggregatorTests
|
||
{
|
||
private static readonly DateTime Now = At(3600);
|
||
private const uint GoodRaw = 0x00000000u;
|
||
private const uint NoData = 0x809B0000u;
|
||
private const uint InvalidInputs = 0x80D60000u;
|
||
private const uint BadCalc = 0x80000401u; // generic Bad | Calculated (an all-outage interval)
|
||
private const uint GoodCalcPartial = 0x00000405u; // Good | Calculated | Partial (truncated last interval)
|
||
private const uint UncertainCalcPartial = 0x40A40405u; // UncertainDataSubNormal | Calculated | Partial
|
||
|
||
private static IReadOnlyList<DataValueSnapshot> Run(HistoryAggregateType agg, double from, double to, double intervalS,
|
||
params StoredSample[] samples) =>
|
||
TimeWeightedAggregator.Evaluate(agg, BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS)), samples, Now);
|
||
|
||
private static void Values(IReadOnlyList<DataValueSnapshot> s, params double?[] expected)
|
||
{
|
||
s.Count.ShouldBe(expected.Length);
|
||
for (var i = 0; i < expected.Length; i++)
|
||
{
|
||
if (expected[i] is { } e) ((double)s[i].Value!).ShouldBe(e, 1e-9, $"bucket {i}");
|
||
else s[i].Value.ShouldBeNull($"bucket {i}");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- tag A: +0=10 G, +10=20 G, +20=Bad, +30=40 G, +40=50 G
|
||
// Window [0,80), 20 s buckets; nothing after +40 (no end bound: the data ENDS at +40). Raw: [0,10) 10→20 Good (150);
|
||
// [10,20) 20 held into the outage, Uncertain (200); [20,30) Bad; [30,40) 40→50 Good (450); after +40 nothing (#680:
|
||
// Part 13 "After End of Data" — the simple-bounding signal is not extrapolated).
|
||
// Bridged: [0,10) 150; [10,30) 20→40 = 250 in B0 + 350 in B1; [30,40) 450; [40,80) 50 held (extrapolated).
|
||
|
||
[Fact]
|
||
public void TimeAverage_bridges_the_outage_on_tag_A()
|
||
{
|
||
// B0 (150+250)/20 = 20; B1 (350+450)/20 = 40; B2, B3 = 50. Status (#680, Part 13 + the SDK's interpolated-bounds
|
||
// rule): Uncertain when a Bad value was skipped or the value was extrapolated — B0 and B1 hold the segment bridged
|
||
// across the Bad +20; B2 and B3 are extrapolated past the last sample.
|
||
var s = Run(HistoryAggregateType.TimeAverage, 0, 80, 20, A);
|
||
Values(s, 20, 40, 50, 50);
|
||
s.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalc, UncertainCalc, UncertainCalc]);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Total_is_TimeAverage_times_interval_seconds_on_tag_A()
|
||
{
|
||
// Fully covered intervals: 20×20, 40×20, 50×20, 50×20 (the integral of the interpolated-bounding signal).
|
||
Values(Run(HistoryAggregateType.Total, 0, 80, 20, A), 400, 800, 1000, 1000);
|
||
}
|
||
|
||
[Fact]
|
||
public void TimeAverage2_excludes_the_bad_interval_and_stops_at_the_end_of_data_on_tag_A()
|
||
{
|
||
// B0 350/20 = 17.5; B1 450/10 = 45 (the Bad [20,30) excluded). B2 [40,60) holds the end of the data (+40, on its
|
||
// start): no covered time, so the value is the last sample's (50), Good, Partial (#680; the SDK answers the same).
|
||
// B3 is after the end of the data ⇒ BadNoData (Part 13 "After End of Data").
|
||
var s = Run(HistoryAggregateType.TimeAverage2, 0, 80, 20, A);
|
||
Values(s, 17.5, 45, 50, null);
|
||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalcPartial, NoDataCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Total2_integrates_only_the_non_bad_data_on_tag_A()
|
||
{
|
||
// B0 150+200 = 350; B1 450; B2 nothing covered after the last sample ⇒ 0, Partial; B3 BadNoData.
|
||
var s = Run(HistoryAggregateType.Total2, 0, 80, 20, A);
|
||
Values(s, 350, 450, 0, null);
|
||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalcPartial, NoDataCalc]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- #680: end of data, live tags, outages, Total coverage
|
||
|
||
/// <summary>−5 = 1, +3 = 2, +25 = 3 (all Good), nothing later: the data ends at +25.</summary>
|
||
private static readonly StoredSample[] EndsInside =
|
||
[
|
||
S(-5, HistorianValue.FromDouble(1), Good), S(3, HistorianValue.FromDouble(2), Good),
|
||
S(25, HistorianValue.FromDouble(3), Good),
|
||
];
|
||
|
||
[Fact]
|
||
public void TimeAverage2_and_Total2_stop_at_the_end_of_data_with_Partial_then_BadNoData()
|
||
{
|
||
// Window [0,40), 10 s. Segment +3..+25 slopes 2→3 (1/22 per s). [20,30): covered only [20,25): 2+17/22 → 3, area
|
||
// 5 × (2.772727… + 3)/2 = 14.431818…; TimeAverage2 = 2.886363…, Good + Partial (the SDK: 14.4348 for Total2 —
|
||
// its last region carries a 1 ms artifact). [30,40) is after the end of the data ⇒ BadNoData.
|
||
var t2 = Run(HistoryAggregateType.Total2, 0, 40, 10, EndsInside);
|
||
((double)t2[2].Value!).ShouldBe(5 * ((2 + 17d / 22) + 3) / 2, 1e-9);
|
||
t2[2].StatusCode.ShouldBe(GoodCalcPartial);
|
||
t2[3].StatusCode.ShouldBe(NoDataCalc);
|
||
t2[3].Value.ShouldBeNull();
|
||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 40, 10, EndsInside);
|
||
((double)ta2[2].Value!).ShouldBe(((2 + 17d / 22) + 3) / 2, 1e-9);
|
||
ta2.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void TimeAverage_and_Total_extrapolate_past_the_end_of_data_as_Uncertain_without_Partial()
|
||
{
|
||
// The interpolated-bounding signal holds the last value to the window end (stepped extrapolation) — Part 13 and
|
||
// the SDK make that Uncertain; the SDK sets no end-of-data Partial on an extrapolated result.
|
||
// [20,30): [20,25) 2.772727…→3 + [25,30) 3 ⇒ (14.431818… + 15) / 10; [30,40): 3.
|
||
var ta = Run(HistoryAggregateType.TimeAverage, 0, 40, 10, EndsInside);
|
||
((double)ta[2].Value!).ShouldBe((5 * ((2 + 17d / 22) + 3) / 2 + 15) / 10, 1e-9);
|
||
((double)ta[3].Value!).ShouldBe(3d);
|
||
ta.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, UncertainCalc, UncertainCalc]);
|
||
Values(Run(HistoryAggregateType.Total, 30, 40, 0, EndsInside), 30);
|
||
}
|
||
|
||
[Fact]
|
||
public void The_latest_interval_of_a_live_tag_is_Uncertain()
|
||
{
|
||
// A live tag: −10 = 10, +5 = 20, +12 = 30, nothing after yet. [10,20) is extrapolated from +12 ⇒ Uncertain
|
||
// (#680 — it used to read Good). [0,10) is bridged Good→Good with no skip ⇒ Good. Values as the SDK:
|
||
// [0,10) 20.952380…, [10,20) 29.714285….
|
||
StoredSample[] live =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.FromDouble(20), Good),
|
||
S(12, HistorianValue.FromDouble(30), Good),
|
||
];
|
||
var s = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, live);
|
||
Values(s, 20.952380952380953, 29.71428571428571);
|
||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc]);
|
||
Run(HistoryAggregateType.Total, 0, 20, 10, live)[1].StatusCode.ShouldBe(UncertainCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_uncertain_sample_makes_TimeAverage_and_Total_uncertain()
|
||
{
|
||
// Part 13: "Uncertain if ... Uncertain values are used". −10 = 10 G, +5 = 20 U, +15 = 30 G, +40 = 30 G; [0,20):
|
||
// the value 24.583333… (SDK), Uncertain.
|
||
StoredSample[] unc =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.FromDouble(20), Uncertain),
|
||
S(15, HistorianValue.FromDouble(30), Good), S(40, HistorianValue.FromDouble(30), Good),
|
||
];
|
||
var ta = Run(HistoryAggregateType.TimeAverage, 0, 20, 0, unc).Single();
|
||
((double)ta.Value!).ShouldBe(24.583333333333332, 1e-9);
|
||
ta.StatusCode.ShouldBe(UncertainCalc);
|
||
Run(HistoryAggregateType.Total, 0, 20, 0, unc).Single().StatusCode.ShouldBe(UncertainCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_segment_bridged_across_a_bad_sample_is_uncertain_in_every_interval_it_touches()
|
||
{
|
||
// −10 = 10 G, +10 Bad, +15 = 20 G, +40 = 20 G: the bridged segment −10..+15 skips the Bad +10, so [0,10) — which
|
||
// holds no Bad sample itself — is Uncertain too (the SDK agrees).
|
||
StoredSample[] edge =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(10), Good), S(10, HistorianValue.FromDouble(30), Bad),
|
||
S(15, HistorianValue.FromDouble(20), Good), S(40, HistorianValue.FromDouble(20), Good),
|
||
];
|
||
var s = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, edge);
|
||
Values(s, 16, 19.5);
|
||
s.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Total_over_an_interval_the_data_starts_inside_integrates_only_the_covered_part()
|
||
{
|
||
// +5 = 10, +15 = 10, +40 = 10, nothing before: [0,20) is covered from +5 only ⇒ Total = 10 × 15 s = 150 (Part 13
|
||
// partial-interval width; the SDK agrees). TimeAverage is still 10. Uncertain (no data for [0,5)) + Partial.
|
||
StoredSample[] late =
|
||
[
|
||
S(5, HistorianValue.FromDouble(10), Good), S(15, HistorianValue.FromDouble(10), Good),
|
||
S(40, HistorianValue.FromDouble(10), Good),
|
||
];
|
||
var total = Run(HistoryAggregateType.Total, 0, 20, 0, late).Single();
|
||
((double)total.Value!).ShouldBe(150d);
|
||
total.StatusCode.ShouldBe(UncertainCalcPartial);
|
||
((double)Run(HistoryAggregateType.TimeAverage, 0, 20, 0, late).Single().Value!).ShouldBe(10d);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_recovery_sample_after_an_outage_that_ends_the_data_is_its_own_value_Uncertain_and_Partial()
|
||
{
|
||
// #680 review (the comms-loss recovery shape): −10 = 10 G, +5 outage, +25 = 20 G (the recovery), nothing after.
|
||
// [0,20): 10 held into the outage over [0,5) ⇒ TimeAverage2 10 (Total2 50), Uncertain. [20,40) holds the end of the
|
||
// data but no non-Bad time before it ⇒ the sample's value 20 (Total2 0), Uncertain + Partial — never Bad/null (the
|
||
// SDK answers the same). [40,60) is after the end of the data ⇒ BadNoData.
|
||
StoredSample[] s =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.Empty, BadNoCommunication),
|
||
S(25, HistorianValue.FromDouble(20), Good),
|
||
];
|
||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 60, 20, s);
|
||
Values(ta2, 10, 20, null);
|
||
ta2.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalcPartial, NoDataCalc]);
|
||
var t2 = Run(HistoryAggregateType.Total2, 0, 60, 20, s);
|
||
Values(t2, 50, 0, null);
|
||
t2.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalcPartial, NoDataCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_single_sample_is_its_own_value_Uncertain_and_Partial()
|
||
{
|
||
// #680 review: the tag's only sample, +25 = 20. One interval [0,60) ⇒ 20 (Total2 0), Uncertain + Partial (time
|
||
// before the data is Bad). At 20 s: [0,20) before the data ⇒ BadNoData; [20,40) the sample ⇒ 20, Uncertain +
|
||
// Partial; [40,60) after the end of the data ⇒ BadNoData.
|
||
StoredSample[] one = [S(25, HistorianValue.FromDouble(20), Good)];
|
||
var whole = Run(HistoryAggregateType.TimeAverage2, 0, 60, 0, one).Single();
|
||
((double)whole.Value!).ShouldBe(20d);
|
||
whole.StatusCode.ShouldBe(UncertainCalcPartial);
|
||
var total = Run(HistoryAggregateType.Total2, 0, 60, 0, one).Single();
|
||
((double)total.Value!).ShouldBe(0d);
|
||
total.StatusCode.ShouldBe(UncertainCalcPartial);
|
||
var split = Run(HistoryAggregateType.TimeAverage2, 0, 60, 20, one);
|
||
Values(split, null, 20, null);
|
||
split.Select(x => x.StatusCode).ShouldBe([NoDataCalc, UncertainCalcPartial, NoDataCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_outage_marker_as_the_last_sample_runs_Bad_to_the_window_end()
|
||
{
|
||
// #680 review, a deliberate difference from the SDK (which treats the data as ended at the marker): −10 = 10 G,
|
||
// +25 = 20 G, +30 outage, nothing after. TimeAverage2 [20,40): [20,25) 18.5714…→20 (96.43) + [25,30) 20 held into
|
||
// the outage (100) over 10 s = 19.642857…, Uncertain, NOT Partial (the data did not end — the tag is in an
|
||
// outage); [40,60) inside the still-open outage ⇒ generic Bad. PercentBad [40,60) = 100, Good. TimeAverage holds
|
||
// 20 to the window end — extrapolated (no Good value follows) ⇒ Uncertain.
|
||
StoredSample[] s =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(10), Good), S(25, HistorianValue.FromDouble(20), Good),
|
||
S(30, HistorianValue.Empty, BadNoCommunication),
|
||
];
|
||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 60, 20, s);
|
||
((double)ta2[1].Value!).ShouldBe(19.642857142857142, 1e-9);
|
||
ta2.Select(x => x.StatusCode).Skip(1).ShouldBe([UncertainCalc, BadCalc]);
|
||
var pb = Run(HistoryAggregateType.PercentBad, 0, 60, 20, s);
|
||
Values(pb, 0, 50, 100);
|
||
pb.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||
Run(HistoryAggregateType.TimeAverage, 0, 60, 20, s).Select(x => x.StatusCode).Skip(1)
|
||
.ShouldBe([UncertainCalc, UncertainCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Boolean_percent_regions_take_the_stepped_bound_status()
|
||
{
|
||
// #680 review: −10 = true G, +15 = true U, +30 = false G, +50 = false G. [0,20): the bound at +0 is stepped from the
|
||
// Good −10 (a Boolean is held, not sloped toward the Uncertain +15) ⇒ [0,15) Good, [15,20) Uncertain ⇒ PercentGood
|
||
// 75. (The SDK slopes Booleans, so it calls the bound Uncertain and answers 0.) [20,40): [20,30) Uncertain (bound
|
||
// stepped from the Uncertain +15), [30,40) Good ⇒ 50.
|
||
StoredSample[] s =
|
||
[
|
||
S(-10, HistorianValue.FromBoolean(true), Good), S(15, HistorianValue.FromBoolean(true), Uncertain),
|
||
S(30, HistorianValue.FromBoolean(false), Good), S(50, HistorianValue.FromBoolean(false), Good),
|
||
];
|
||
Values(Run(HistoryAggregateType.PercentGood, 0, 40, 20, s), 75, 50);
|
||
// TimeAverage over the same Booleans is stepped: [20,40) = 1 over [20,30), 0 over [30,40) ⇒ 0.5 (the SDK: 1).
|
||
Values(Run(HistoryAggregateType.TimeAverage, 0, 40, 20, s), 1, 0.5);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- #680: PercentGood / PercentBad are duration-based
|
||
|
||
[Fact]
|
||
public void Percent_aggregates_are_durations_on_tag_A()
|
||
{
|
||
// Stepped regions, each with the status of the sample that starts it. B0 [0,20) Good throughout; B1 [20,30) Bad
|
||
// (the Bad +20), [30,40) Good ⇒ 50 / 50; B2 holds the end of the data (+40, Good) ⇒ its region runs to the interval
|
||
// end (Part 13), Partial (forward end-of-data, as the SDK); B3 is after the end of the data ⇒ BadNoData.
|
||
var good = Run(HistoryAggregateType.PercentGood, 0, 80, 20, A);
|
||
Values(good, 100, 50, 100, null);
|
||
good.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]);
|
||
var bad = Run(HistoryAggregateType.PercentBad, 0, 80, 20, A);
|
||
Values(bad, 0, 50, 0, null);
|
||
bad.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void With_no_end_bound_the_last_region_runs_to_the_interval_end()
|
||
{
|
||
// 0 = 10 G, +5 outage, +10 = 30 G, nothing after; [0,20) one interval: [0,5) Good, [5,10) Bad, [10,20) Good
|
||
// (Part 13: the last region extends to the interval end — the SDK instead gives it 1 ms, ≈ 49.995 % Bad).
|
||
StoredSample[] s =
|
||
[
|
||
S(0, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.Empty, BadNoCommunication),
|
||
S(10, HistorianValue.FromDouble(30), Good),
|
||
];
|
||
var bad = Run(HistoryAggregateType.PercentBad, 0, 20, 0, s).Single();
|
||
((double)bad.Value!).ShouldBe(25d, 1e-9);
|
||
bad.StatusCode.ShouldBe(GoodCalcPartial);
|
||
((double)Run(HistoryAggregateType.PercentGood, 0, 20, 0, s).Single().Value!).ShouldBe(75d, 1e-9);
|
||
}
|
||
|
||
[Fact]
|
||
public void Percent_regions_use_the_simple_bound_at_the_interval_start_and_count_time_before_the_data_as_Bad()
|
||
{
|
||
// −3 = 10 G, +4 outage, +6 = 30 U, +12 = 3 G, +30 = 3 G; [0,20), 10 s. The simple bound at +0 lies on a segment
|
||
// that runs into the outage ⇒ Uncertain for [0,4) (the SDK's simple bounding value); [4,6) Bad; [6,10) Uncertain.
|
||
// B0: Good 0 %, Bad 20 %. B1: [10,12) Uncertain (bound between the Uncertain +6 and +12), [12,20) Good ⇒ 80 / 0.
|
||
StoredSample[] s =
|
||
[
|
||
S(-3, HistorianValue.FromDouble(10), Good), S(4, HistorianValue.Empty, BadNoCommunication),
|
||
S(6, HistorianValue.FromDouble(30), Uncertain), S(12, HistorianValue.FromDouble(3), Good),
|
||
S(30, HistorianValue.FromDouble(3), Good),
|
||
];
|
||
Values(Run(HistoryAggregateType.PercentGood, 0, 20, 10, s), 0, 80);
|
||
Values(Run(HistoryAggregateType.PercentBad, 0, 20, 10, s), 20, 0);
|
||
// The data starts at +10, exactly on the end of [0,10): all of [0,10) is before the data ⇒ 100 % Bad (the SDK
|
||
// agrees); data starting at +5 ⇒ [0,5) Bad, Partial.
|
||
StoredSample[] late = [S(10, HistorianValue.FromDouble(1), Good), S(30, HistorianValue.FromDouble(2), Good)];
|
||
Values(Run(HistoryAggregateType.PercentBad, 0, 20, 10, late), 100, 0);
|
||
StoredSample[] mid = [S(5, HistorianValue.FromDouble(1), Good), S(30, HistorianValue.FromDouble(2), Good)];
|
||
var m = Run(HistoryAggregateType.PercentBad, 0, 20, 0, mid).Single();
|
||
((double)m.Value!).ShouldBe(25d, 1e-9);
|
||
m.StatusCode.ShouldBe(GoodCalcPartial);
|
||
// Nothing at all ⇒ BadNoData.
|
||
Run(HistoryAggregateType.PercentGood, 0, 20, 10).ShouldAllBe(x => x.StatusCode == NoDataCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public void Interpolative_on_tag_A()
|
||
{
|
||
// +0 exact Good 10 (raw status); +20 sits on the Bad sample ⇒ bridged 20→40 at the midpoint = 30, Uncertain;
|
||
// +40 exact Good 50; +60 past the last sample ⇒ 50 extrapolated, Uncertain.
|
||
var s = Run(HistoryAggregateType.Interpolative, 0, 80, 20, A);
|
||
Values(s, 10, 30, 50, 50);
|
||
s.Select(x => x.StatusCode).ShouldBe([GoodRaw, UncertainInterpolated, GoodRaw, UncertainInterpolated]);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
|
||
s.ShouldAllBe(x => x.ServerTimestampUtc == Now);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- bounding values on both sides, crossing segments
|
||
|
||
private static readonly StoredSample[] Bounded =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(0), Good), // bound before the window
|
||
S(10, HistorianValue.FromDouble(20), Good),
|
||
S(30, HistorianValue.FromDouble(20), Good), // bound after the window
|
||
];
|
||
|
||
[Theory]
|
||
// Window [0,20), 10 s. Segment -10..10 (0→20) crosses the window start: B0 [0,10) = 10→20, mean 15.
|
||
// B1 [10,20) = 20 flat (crossing the window end into the bound). All Good.
|
||
[InlineData(HistoryAggregateType.TimeAverage, 15d, 20d)]
|
||
[InlineData(HistoryAggregateType.TimeAverage2, 15d, 20d)]
|
||
[InlineData(HistoryAggregateType.Total, 150d, 200d)]
|
||
[InlineData(HistoryAggregateType.Total2, 150d, 200d)]
|
||
public void Bounding_values_before_and_after_the_window_are_used(HistoryAggregateType agg, double b0, double b1)
|
||
{
|
||
var s = Run(agg, 0, 20, 10, Bounded);
|
||
Values(s, b0, b1);
|
||
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public void Interpolative_interpolates_from_the_bound_before_the_window()
|
||
{
|
||
// +0: 0 + (20−0)·(10/20) = 10, Good both ends; +10 exact Good 20.
|
||
var s = Run(HistoryAggregateType.Interpolative, 0, 20, 10, Bounded);
|
||
Values(s, 10, 20);
|
||
s.Select(x => x.StatusCode).ShouldBe([GoodInterpolated, GoodRaw]);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_segment_spanning_several_buckets_is_split_at_every_edge()
|
||
{
|
||
// One segment 0..40 (0→40), window [0,40), 10 s: bucket means 5, 15, 25, 35; Total2 50, 150, 250, 350.
|
||
StoredSample[] line = [S(0, HistorianValue.FromDouble(0), Good), S(40, HistorianValue.FromDouble(40), Good)];
|
||
Values(Run(HistoryAggregateType.TimeAverage2, 0, 40, 10, line), 5, 15, 25, 35);
|
||
Values(Run(HistoryAggregateType.Total2, 0, 40, 10, line), 50, 150, 250, 350);
|
||
Values(Run(HistoryAggregateType.Interpolative, 0, 40, 10, line), 0, 10, 20, 30);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- empty buckets between bounds
|
||
|
||
[Fact]
|
||
public void An_empty_bucket_with_bounds_on_both_sides_is_interpolated_not_no_data()
|
||
{
|
||
// -5 = 0, +25 = 30 (slope 1/s); window [0,20), 10 s holds no sample at all.
|
||
StoredSample[] sparse = [S(-5, HistorianValue.FromDouble(0), Good), S(25, HistorianValue.FromDouble(30), Good)];
|
||
var interp = Run(HistoryAggregateType.Interpolative, 0, 20, 10, sparse);
|
||
Values(interp, 5, 15);
|
||
interp.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
|
||
// B0 5→15 mean 10; B1 15→25 mean 20.
|
||
var avg = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, sparse);
|
||
Values(avg, 10, 20);
|
||
avg.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||
Values(Run(HistoryAggregateType.Total, 0, 20, 10, sparse), 100, 200);
|
||
}
|
||
|
||
[Fact]
|
||
public void Past_the_last_sample_the_value_is_extrapolated_stepped()
|
||
{
|
||
// -5 = 7 and nothing later: TimeAverage holds 7 over [0,10), Uncertain (extrapolated, #680). TimeAverage2 does not
|
||
// extrapolate: [0,10) is after the end of the data ⇒ BadNoData. The Interpolative point is Uncertain.
|
||
StoredSample[] one = [S(-5, HistorianValue.FromDouble(7), Good)];
|
||
var s = Run(HistoryAggregateType.TimeAverage, 0, 10, 0, one).Single();
|
||
((double)s.Value!).ShouldBe(7d);
|
||
s.StatusCode.ShouldBe(UncertainCalc);
|
||
Run(HistoryAggregateType.TimeAverage2, 0, 10, 0, one).Single().StatusCode.ShouldBe(NoDataCalc);
|
||
var i = Run(HistoryAggregateType.Interpolative, 0, 10, 0, one).Single();
|
||
((double)i.Value!).ShouldBe(7d);
|
||
i.StatusCode.ShouldBe(UncertainInterpolated);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||
[InlineData(HistoryAggregateType.TimeAverage2)]
|
||
[InlineData(HistoryAggregateType.Total)]
|
||
[InlineData(HistoryAggregateType.Total2)]
|
||
public void No_data_at_all_is_BadNoData_with_the_calculated_bit(HistoryAggregateType agg)
|
||
{
|
||
var s = Run(agg, 0, 20, 10);
|
||
s.ShouldAllBe(x => x.StatusCode == NoDataCalc && x.Value == null);
|
||
}
|
||
|
||
[Fact]
|
||
public void Interpolative_with_no_data_is_plain_BadNoData()
|
||
{
|
||
// The only sample is at +15: nothing at or before either bucket start (+0, +10).
|
||
var s = Run(HistoryAggregateType.Interpolative, 0, 20, 10, S(15, HistorianValue.FromDouble(1), Good));
|
||
s.ShouldAllBe(x => x.StatusCode == NoData && x.Value == null);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Bad intervals: TimeAverage vs TimeAverage2/Total2
|
||
|
||
private static readonly StoredSample[] Outage =
|
||
[
|
||
S(0, HistorianValue.FromDouble(10), Good),
|
||
S(5, HistorianValue.Empty, BadNoCommunication),
|
||
S(15, HistorianValue.FromDouble(30), Good),
|
||
S(25, HistorianValue.FromDouble(30), Good), // bound after the window
|
||
];
|
||
|
||
[Fact]
|
||
public void TimeAverage2_and_Total2_exclude_the_bad_interval()
|
||
{
|
||
// Window [0,20) one bucket. Raw: [0,5) 10 held into the outage (50, Uncertain); [5,15) Bad; [15,20) 30 Good (150).
|
||
// TimeAverage2 = 200 / 10 s = 20; Total2 = 200.
|
||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 20, 0, Outage).Single();
|
||
((double)ta2.Value!).ShouldBe(20d);
|
||
ta2.StatusCode.ShouldBe(UncertainCalc);
|
||
((double)Run(HistoryAggregateType.Total2, 0, 20, 0, Outage).Single().Value!).ShouldBe(200d);
|
||
}
|
||
|
||
[Fact]
|
||
public void TimeAverage_and_Total_interpolate_across_the_bad_interval()
|
||
{
|
||
// Bridged: [0,15) 10→30, mean 20 ⇒ 300; [15,20) 30 ⇒ 150. TimeAverage = 450/20 = 22.5; Total = 22.5 × 20 = 450.
|
||
var ta = Run(HistoryAggregateType.TimeAverage, 0, 20, 0, Outage).Single();
|
||
((double)ta.Value!).ShouldBe(22.5d);
|
||
ta.StatusCode.ShouldBe(UncertainCalc);
|
||
((double)Run(HistoryAggregateType.Total, 0, 20, 0, Outage).Single().Value!).ShouldBe(450d);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_interval_entirely_inside_an_outage_bridges_for_TimeAverage_but_is_Bad_for_TimeAverage2()
|
||
{
|
||
// Window [10,15): the interpolated-bounding signal (10→30 over [0,15)) bridges it — 23.333… → 30, mean 26.666…,
|
||
// Uncertain (a Bad value skipped; #680, Part 13 + the SDK). Total = 26.666… × 5 s. The simple-bounding signal is
|
||
// Bad throughout ⇒ TimeAverage2/Total2 generic Bad (Part 13's PercentData rule; the SDK answers BadNoData).
|
||
// Interpolative at +10 = 10 + 20·(10/15).
|
||
var ta = Run(HistoryAggregateType.TimeAverage, 10, 15, 0, Outage).Single();
|
||
((double)ta.Value!).ShouldBe((10d + 20d * 10d / 15d + 30d) / 2, 1e-9);
|
||
ta.StatusCode.ShouldBe(UncertainCalc);
|
||
((double)Run(HistoryAggregateType.Total, 10, 15, 0, Outage).Single().Value!)
|
||
.ShouldBe((10d + 20d * 10d / 15d + 30d) / 2 * 5, 1e-9);
|
||
Run(HistoryAggregateType.TimeAverage2, 10, 15, 0, Outage).Single().StatusCode.ShouldBe(BadCalc);
|
||
Run(HistoryAggregateType.Total2, 10, 15, 0, Outage).Single().StatusCode.ShouldBe(BadCalc);
|
||
var i = Run(HistoryAggregateType.Interpolative, 10, 15, 0, Outage).Single();
|
||
((double)i.Value!).ShouldBe(10d + 20d * 10d / 15d, 1e-9);
|
||
i.StatusCode.ShouldBe(UncertainInterpolated);
|
||
}
|
||
|
||
[Fact]
|
||
public void Interpolative_on_a_segment_that_runs_into_an_outage_is_uncertain()
|
||
{
|
||
// +2 lies between the Good +0 and the Bad +5: bridged 10→30 over [0,15) ⇒ 10 + 20·(2/15); the simple-bounding
|
||
// signal there is held into an outage, so the point is Uncertain even though both bridging ends are Good.
|
||
var i = Run(HistoryAggregateType.Interpolative, 2, 20, 0, Outage).Single();
|
||
((double)i.Value!).ShouldBe(10d + 20d * 2d / 15d, 1e-9);
|
||
i.StatusCode.ShouldBe(UncertainInterpolated);
|
||
}
|
||
|
||
[Fact]
|
||
public void The_latest_usable_bound_is_found_behind_a_bad_bound()
|
||
{
|
||
// -10 = 0 G, -5 Bad, +10 = 20 G, +30 = 20 G. Window [0,20) one bucket.
|
||
// Bridged: -10..10 0→20 ⇒ [0,10) 10→20 (150); [10,20) 20 (200) ⇒ TimeAverage = 17.5.
|
||
// Raw: [-5,10) Bad; [10,20) 20 Good (200) ⇒ TimeAverage2 = 20. Good 10 s of 20 ⇒ Uncertain.
|
||
StoredSample[] s =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(0), Good), S(-5, HistorianValue.Empty, BadNoCommunication),
|
||
S(10, HistorianValue.FromDouble(20), Good), S(30, HistorianValue.FromDouble(20), Good),
|
||
];
|
||
((double)Run(HistoryAggregateType.TimeAverage, 0, 20, 0, s).Single().Value!).ShouldBe(17.5d);
|
||
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 20, 0, s).Single();
|
||
((double)ta2.Value!).ShouldBe(20d);
|
||
ta2.StatusCode.ShouldBe(UncertainCalc);
|
||
var i = Run(HistoryAggregateType.Interpolative, 0, 20, 0, s).Single();
|
||
((double)i.Value!).ShouldBe(10d); // inside the outage, bridged -10..10 at 0
|
||
i.StatusCode.ShouldBe(UncertainInterpolated);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- stepped Boolean, Uncertain, invalid inputs
|
||
|
||
private static readonly StoredSample[] Switch =
|
||
[
|
||
S(0, HistorianValue.FromBoolean(true), Good),
|
||
S(5, HistorianValue.FromBoolean(false), Good),
|
||
S(15, HistorianValue.FromBoolean(true), Good),
|
||
S(25, HistorianValue.FromBoolean(true), Good), // bound after the window
|
||
];
|
||
|
||
[Fact]
|
||
public void Booleans_are_stepped_not_sloped()
|
||
{
|
||
// Window [0,20), 10 s. B0: 1 over [0,5), 0 over [5,10) ⇒ 0.5 (sloped would give 0.375). B1: 0 over [10,15),
|
||
// 1 over [15,20) ⇒ 0.5. All Good. Total: 5 and 5.
|
||
var s = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, Switch);
|
||
Values(s, 0.5, 0.5);
|
||
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||
Values(Run(HistoryAggregateType.Total, 0, 20, 10, Switch), 5, 5);
|
||
// Interpolative at +10: stepped from +5 (false) ⇒ 0, Good.
|
||
var i = Run(HistoryAggregateType.Interpolative, 0, 20, 10, Switch);
|
||
Values(i, 1, 0);
|
||
i.Select(x => x.StatusCode).ShouldBe([GoodRaw, GoodInterpolated]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Uncertain_time_counts_as_good_time()
|
||
{
|
||
// TreatUncertainAsBad = false (the SDK's time-based status rule): Uncertain time counts toward PercentDataGood.
|
||
// 0 = 4 Uncertain, 10 = 6 Good, 20 = 6 Good: [0,10) sloped 4→6 ⇒ mean 5; [10,20) 6. Window [0,20), 10 s.
|
||
StoredSample[] s =
|
||
[
|
||
S(0, HistorianValue.FromDouble(4), Uncertain), S(10, HistorianValue.FromDouble(6), Good),
|
||
S(20, HistorianValue.FromDouble(6), Good),
|
||
];
|
||
var r = Run(HistoryAggregateType.TimeAverage2, 0, 20, 10, s);
|
||
Values(r, 5, 6);
|
||
r.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Int64_samples_are_sloped_like_doubles()
|
||
{
|
||
StoredSample[] s = [S(0, HistorianValue.FromInt64(0), Good), S(10, HistorianValue.FromInt64(10), Good)];
|
||
Values(Run(HistoryAggregateType.TimeAverage2, 0, 10, 0, s), 5);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||
[InlineData(HistoryAggregateType.TimeAverage2)]
|
||
[InlineData(HistoryAggregateType.Total)]
|
||
[InlineData(HistoryAggregateType.Total2)]
|
||
public void A_usable_string_sample_makes_the_interval_invalid_inputs(HistoryAggregateType agg)
|
||
{
|
||
StoredSample[] s = [S(0, HistorianValue.FromString("a"), Good), S(10, HistorianValue.FromString("b"), Good)];
|
||
var r = Run(agg, 0, 20, 0, s).Single();
|
||
r.StatusCode.ShouldBe(InvalidInputsCalc);
|
||
r.Value.ShouldBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void Interpolative_on_a_string_is_invalid_inputs()
|
||
{
|
||
StoredSample[] s = [S(0, HistorianValue.FromString("a"), Good), S(10, HistorianValue.FromString("b"), Good)];
|
||
Run(HistoryAggregateType.Interpolative, 0, 20, 10, s).ShouldAllBe(x => x.StatusCode == InvalidInputs);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- plan shape
|
||
|
||
[Fact]
|
||
public void A_reverse_window_is_returned_latest_first_stamped_with_later_edges()
|
||
{
|
||
// #677: the four whole intervals are the same spans as forward, but each is stamped with its later edge. The data
|
||
// ends at +40 (#680): the latest interval is after it ⇒ BadNoData; the next holds it on its earlier edge ⇒ 50, with
|
||
// no end-of-data Partial on a reverse read (the SDK answers the same).
|
||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.TimeAverage2,
|
||
BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)), A, Now);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
|
||
Values(s, null, 50, 45, 17.5);
|
||
s.Select(x => x.StatusCode).ShouldBe([NoDataCalc, GoodCalc, UncertainCalc, GoodCalc]);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_reverse_window_anchors_at_its_later_time_and_truncates_the_earliest_interval()
|
||
{
|
||
// Tag A, At(50) → At(0), 20 s ⇒ (30,50], (10,30], (0,10]. Raw signal: [0,10) 10→20 Good (150); [10,20) 20 held
|
||
// into the outage, Uncertain (200); [20,30) Bad; [30,40) 40→50 Good (450); [40,50) 50 extrapolated (500).
|
||
// (30,50]: the data ends at +40 (#680: no extrapolation) ⇒ 450/10 = 45, Good; the latest sample lies strictly
|
||
// inside it, where the reverse read's data starts ⇒ Partial (the SDK's m_startOfData keyed on the first value it
|
||
// is fed); (10,30]: 200/10 = 20, the Bad 10 s ⇒ Uncertain; (0,10]: 150/10 = 15, truncated ⇒
|
||
// Partial. The old layout would have answered [0,20), [20,40), [40,50) stamped 0/20/40, partial at the LATEST end.
|
||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.TimeAverage2,
|
||
BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)), A, Now);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(50), At(30), At(10)]);
|
||
Values(s, 45, 20, 15);
|
||
s.Select(x => x.StatusCode).ShouldBe([GoodCalcPartial, UncertainCalc, GoodCalcPartial]);
|
||
}
|
||
|
||
[Fact]
|
||
public void The_interval_the_data_starts_inside_is_Partial()
|
||
{
|
||
// #677 (SDK m_startOfData): no sample before +5 at all ⇒ [0,10) holds the start of the data ⇒ Partial (its first
|
||
// 5 s are no-data time ⇒ Uncertain); [10,20) is fully covered ⇒ no Partial.
|
||
StoredSample[] late = [S(5, HistorianValue.FromDouble(10), Good), S(30, HistorianValue.FromDouble(10), Good)];
|
||
var s = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, late);
|
||
Values(s, 10, 10);
|
||
s.Select(x => x.StatusCode).ShouldBe([UncertainCalc | 0x4u, GoodCalc]);
|
||
// A bound before the window (the data started earlier) ⇒ no Partial.
|
||
StoredSample[] early = [S(-5, HistorianValue.FromDouble(10), Good), S(30, HistorianValue.FromDouble(10), Good)];
|
||
Run(HistoryAggregateType.TimeAverage, 0, 20, 10, early).ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||
// Data starting exactly on an interval edge covers that interval ⇒ no Partial.
|
||
StoredSample[] edge = [S(10, HistorianValue.FromDouble(10), Good), S(30, HistorianValue.FromDouble(10), Good)];
|
||
Run(HistoryAggregateType.TimeAverage, 0, 20, 10, edge)[1].StatusCode.ShouldBe(GoodCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_truncated_last_bucket_uses_its_own_duration_for_Total_and_is_Partial()
|
||
{
|
||
// Window [0,50), 20 s: B2 = [40,50) is 10 s of the extrapolated 50 ⇒ TimeAverage 50, Total 500.
|
||
var s = Run(HistoryAggregateType.Total, 0, 50, 20, A);
|
||
Values(s, 400, 800, 500);
|
||
// Only the truncated interval carries the Partial bit (Part 13 §5.3.3.2 Case 3 — the SDK does not set it; ours
|
||
// does, deliberately). Every interval is Uncertain (bridged across the Bad +20, or extrapolated).
|
||
s.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalc, UncertainCalcPartial]);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_outage_with_no_later_sample_is_Bad_to_the_window_end_not_no_data()
|
||
{
|
||
// 0 = 10 G, then an outage marker at +5 and nothing after: [10,20) lies wholly inside the still-open outage.
|
||
StoredSample[] open = [S(0, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.Empty, BadNoCommunication)];
|
||
Run(HistoryAggregateType.TimeAverage2, 10, 20, 0, open).Single().StatusCode.ShouldBe(BadCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_bad_or_no_data_truncated_interval_carries_no_partial_bit()
|
||
{
|
||
// Window [0,15), 10 s: B1 = [10,15) is inside the outage ⇒ Bad without Partial.
|
||
Run(HistoryAggregateType.TimeAverage2, 0, 15, 10, Outage)[1].StatusCode.ShouldBe(BadCalc);
|
||
Run(HistoryAggregateType.TimeAverage2, 0, 15, 10)[1].StatusCode.ShouldBe(NoDataCalc);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- reverse Interpolative (Part 13: anchored at the later time)
|
||
|
||
[Fact]
|
||
public void A_reverse_Interpolative_read_is_anchored_at_its_later_time()
|
||
{
|
||
// Request At(20) → At(0), 20 s: one point, at At(20) (not At(0)). Bounded: segment 10..30 is 20 flat, Good.
|
||
// A forward read of the same window would give the value at At(0) = 10.
|
||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.Interpolative,
|
||
BucketPlan.Create(At(20), At(0), TimeSpan.FromSeconds(20)), Bounded, Now);
|
||
s.Single().SourceTimestampUtc.ShouldBe(At(20));
|
||
((double)s.Single().Value!).ShouldBe(20d);
|
||
s.Single().StatusCode.ShouldBe(GoodInterpolated);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_reverse_Interpolative_read_steps_back_from_the_later_time_latest_first()
|
||
{
|
||
// Tag A, At(80) → At(0), 20 s: points 80, 60, 40, 20 (latest first). 80 and 60 are past the last sample ⇒ 50
|
||
// extrapolated, Uncertain; 40 exact Good 50; 20 sits on the Bad sample ⇒ bridged 30, Uncertain.
|
||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.Interpolative,
|
||
BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)), A, Now);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
|
||
Values(s, 50, 50, 50, 30);
|
||
s.Select(x => x.StatusCode).ShouldBe([UncertainInterpolated, UncertainInterpolated, GoodRaw, UncertainInterpolated]);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_reverse_Interpolative_read_with_a_partial_interval_ends_inside_the_window()
|
||
{
|
||
// At(25) → At(0), 10 s: three intervals ⇒ points 25, 15, 5 (never before the window start).
|
||
// Bounded: -10..10 slope 1/s from 0 ⇒ +5 = 15; 10..30 flat 20 ⇒ +15 = 20, +25 = 20.
|
||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.Interpolative,
|
||
BucketPlan.Create(At(25), At(0), TimeSpan.FromSeconds(10)), Bounded, Now);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(25), At(15), At(5)]);
|
||
Values(s, 20, 20, 15);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- raw-row cap helpers
|
||
|
||
[Theory]
|
||
[InlineData(1_000_000, 1_000_001)]
|
||
[InlineData(1, 2)]
|
||
[InlineData(0, int.MaxValue)]
|
||
[InlineData(-5, int.MaxValue)]
|
||
[InlineData(int.MaxValue, int.MaxValue)]
|
||
public void The_window_fetch_limit_is_one_past_the_cap(int cap, int expected) =>
|
||
TimeWeightedAggregator.WindowFetchLimit(cap).ShouldBe(expected);
|
||
|
||
[Fact]
|
||
public void The_window_row_check_fails_only_past_a_positive_cap()
|
||
{
|
||
TimeWeightedAggregator.CheckWindowRows(4, 4, "T");
|
||
TimeWeightedAggregator.CheckWindowRows(5_000_000, 0, "T");
|
||
var ex = Should.Throw<HistoryReadLimitExceededException>(() => TimeWeightedAggregator.CheckWindowRows(5, 4, "T"));
|
||
ex.Limit.ShouldBe(4);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_duplicate_bound_is_ignored()
|
||
{
|
||
// The same row fetched as both "latest usable" and "latest any" bound.
|
||
var dup = new[] { Bounded[0], Bounded[0], Bounded[1], Bounded[2], Bounded[2] };
|
||
Values(Run(HistoryAggregateType.TimeAverage2, 0, 20, 10, dup), 15, 20);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_non_time_weighted_aggregate_is_refused()
|
||
{
|
||
Should.Throw<HistoryAggregateNotSupportedException>(() =>
|
||
new TimeWeightedAggregator(HistoryAggregateType.Average, BucketPlan.Create(At(0), At(10), TimeSpan.Zero)));
|
||
}
|
||
}
|