Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs
T
Joseph Doherty 0d206a0884 fix(historian): #680 review — recovery/only-sample TA2/Total2 value, outage-last + Boolean parity, docs
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.
2026-10-11 16:02:22 -04:00

718 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
}
}