Merge #821 live TimeAverage/Total late-start with Uncertain first sample matches HistoryRead (Part 13 partial interval)
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
+10
-8
@@ -6,10 +6,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// #821 / #809 facts, through the BOOTED server's runtime <c>AggregateManager</c> — the calculators a live
|
||||
/// AggregateFilter monitored item gets, under the server's own default configuration. The #821 Total / TimeAverage
|
||||
/// facts pin current behaviour for the owner decision; they do not choose. #809 is decided (2026-11-11): live
|
||||
/// PercentGood / DurationGood count Good time only, equal to HistoryRead. The HistoryRead (Part 13) answers and SDK
|
||||
/// 2.0.1's stock answers for the same input are pinned beside it in <c>SdkAggregateParityTests</c>.
|
||||
/// AggregateFilter monitored item gets, under the server's own default configuration. Both are decided (owner,
|
||||
/// 2026-11-11): live PercentGood / DurationGood count Good time only (#809), and live Total / TimeAverage integrate only
|
||||
/// the covered part of an interval the data starts inside, even with an Uncertain first sample (#821) — equal to
|
||||
/// HistoryRead. The HistoryRead (Part 13) answers, the untouched baseline's and SDK 2.0.1's stock answers for the same
|
||||
/// input are pinned beside it in <c>SdkAggregateParityTests</c>.
|
||||
/// </summary>
|
||||
public sealed class LiveAggregateLateStartFactsTests
|
||||
{
|
||||
@@ -41,11 +42,12 @@ public sealed class LiveAggregateLateStartFactsTests
|
||||
return d;
|
||||
}
|
||||
|
||||
// Part 13 (HistoryRead on SqlServer/QuestDB): Total = 73.5 + 88 + 32/13 = 163.96…, TimeAverage = Total ÷ 15 s.
|
||||
// The live calculators integrate the uncovered [0,5) as a ramp from 0 (+25) and divide by the whole 20 s.
|
||||
// #821: Part 13 (HistoryRead on SqlServer/QuestDB): Total = 73.5 + 88 + 32/13 = 163.96…, TimeAverage = Total ÷ 15 s
|
||||
// — live as in HistoryRead (before #821 the live calculator integrated the uncovered [0,5) as a ramp from 0 (+25)
|
||||
// and divided by the whole 20 s: 188.96… / 9.448…).
|
||||
const double part13Total = 73.5 + 88 + 32d / 13;
|
||||
One(ObjectIds.AggregateFunction_Total).ShouldBe(part13Total + 25, 1e-9); // 188.96…
|
||||
One(ObjectIds.AggregateFunction_TimeAverage).ShouldBe((part13Total + 25) / 20, 1e-9); // 9.448… (Part 13: 10.93…)
|
||||
One(ObjectIds.AggregateFunction_Total).ShouldBe(part13Total, 1e-9); // 163.96…
|
||||
One(ObjectIds.AggregateFunction_TimeAverage).ShouldBe(part13Total / 15, 1e-9); // 10.93…
|
||||
// #809: the Uncertain [5,12) is not Good — only [12,20) is, live as in HistoryRead (8 000 ms / 40 %; before #809
|
||||
// the live calculator counted the Uncertain time too: 15 000 ms / 75 %).
|
||||
One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(8_000d, 1e-9);
|
||||
|
||||
+133
-3
@@ -15,6 +15,13 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// case computed by the baseline SDK with <c>TreatUncertainAsBad = true</c> (its "twin": there 1.5.378.182 already
|
||||
/// counted Good regions only) — and each is pinned to that twin instead. The twin is baseline evidence, not output of
|
||||
/// the application's calculator, so the pin never regenerates expectations from the implementation.
|
||||
/// <para>
|
||||
/// #821 (owner-approved 2026-11-11): TimeAverage / Total no longer integrate the span before an Uncertain first value
|
||||
/// when there is no start bound. No fixture vector changes (every data set has a usable value at −10 s, before every
|
||||
/// window, so no fixture interval lacks a start bound) — all 384 TimeAverage / Total vectors stay held to their own
|
||||
/// output. The change is pinned instead over late-start variants of every data set against a baseline-derived oracle
|
||||
/// (<see cref="Issue821_late_start_with_an_uncertain_first_sample_integrates_only_the_covered_part"/>) and by hand.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Sdk2AggregateCompatibilityTests
|
||||
{
|
||||
@@ -22,6 +29,12 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
private const string DurationGood = "AggregateFunction_DurationGood";
|
||||
private const int Issue809PercentGoodExemptions = 38;
|
||||
private const int Issue809DurationGoodExemptions = 26;
|
||||
private const string TimeAverage = "AggregateFunction_TimeAverage";
|
||||
private const string Total = "AggregateFunction_Total";
|
||||
/// <summary>#821: fixture vectors changed — none (every data set has a usable value before the window; class remarks).</summary>
|
||||
private const int Issue821FixtureExemptions = 0;
|
||||
private const int Issue821Cut5Changed = 150;
|
||||
private const int Issue821Cut25Changed = 142;
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
@@ -44,6 +57,8 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37);
|
||||
exemptions.Keys.Count(x => x.Aggregate == PercentGood).ShouldBe(Issue809PercentGoodExemptions);
|
||||
exemptions.Keys.Count(x => x.Aggregate == DurationGood).ShouldBe(Issue809DurationGoodExemptions);
|
||||
// #821: no TimeAverage / Total fixture vector is exempt — all 384 are held to their own baseline output above.
|
||||
exemptions.Keys.Count(x => x.Aggregate is TimeAverage or Total).ShouldBe(Issue821FixtureExemptions);
|
||||
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing (#809 exemptions: the uncertain-as-bad twin).");
|
||||
}
|
||||
|
||||
@@ -97,6 +112,104 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
output.Select(x => double.Parse(x.Payload!, CultureInfo.InvariantCulture)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #821 pin over late-start variants of every fixture data set (the fixture itself has none — see the class remarks):
|
||||
/// each TimeAverage / Total vector's data set cut at <paramref name="cutSeconds"/> (no usable value before the data
|
||||
/// starts, so no start bound) with its first remaining non-Bad sample made Uncertain, run through the application's
|
||||
/// calculator under the vector's own direction, stepping, configuration and incremental draining.
|
||||
/// <para>
|
||||
/// The oracle is the untouched BASELINE, never the application's calculator: under TreatUncertainAsBad = false
|
||||
/// the baseline treats an Uncertain value exactly like a Good one except for statuses, and with a Good first
|
||||
/// value it already leaves the uncovered span out (the #821 control). So the expected value of each interval is
|
||||
/// the baseline's on the same variant with that first sample Good, and the expected status, timestamps and bits
|
||||
/// the baseline's on the variant itself (the region before the data is non-Good either way) — except an
|
||||
/// interval the Good twin answers Bad (nothing covered: only the uncovered span ever fed it), which is the twin
|
||||
/// whole. Stepped variables and TreatUncertainAsBad = true never reached the baseline's defect and must be the
|
||||
/// baseline byte for byte.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(5, Issue821Cut5Changed)]
|
||||
[InlineData(25, Issue821Cut25Changed)]
|
||||
public void Issue821_late_start_with_an_uncertain_first_sample_integrates_only_the_covered_part(double cutSeconds, int expectedChanged)
|
||||
{
|
||||
var baseline = LoadBaseline();
|
||||
var failures = new List<string>();
|
||||
var changed = 0;
|
||||
// Why the fixture needs no #821 exemption: every data set with a usable value has one before the window (−10 s), so
|
||||
// no fixture interval lacks a start bound; the variants below remove it.
|
||||
baseline.Data.Values.Where(d => d.Any(s => !StatusCode.IsBad(s.Status)))
|
||||
.ShouldAllBe(d => d.Any(s => s.Seconds < 0 && !StatusCode.IsBad(s.Status)));
|
||||
var vectors = baseline.Vectors.Where(v => v.Aggregate is TimeAverage or Total).ToList();
|
||||
foreach (var vector in vectors)
|
||||
{
|
||||
var (variant, goodTwin) = LateStartVariant(baseline.Data[vector.DataSet], cutSeconds);
|
||||
var asIs = Run(baseline, vector, rig: null, out var asIsError, variant, baselineAsIs: true);
|
||||
var twin = Run(baseline, vector, rig: null, out var twinError, goodTwin, baselineAsIs: true);
|
||||
var actual = Run(baseline, vector, rig: null, out var error, variant);
|
||||
asIsError.ShouldBeNull(Describe(vector));
|
||||
twinError.ShouldBeNull(Describe(vector));
|
||||
twin.Count.ShouldBe(asIs.Count, Describe(vector));
|
||||
|
||||
Value[] expected = EffectiveTreatUncertainAsBad(vector) || vector.Stepped
|
||||
? [.. asIs]
|
||||
: [.. asIs.Zip(twin, (a, g) =>
|
||||
{
|
||||
g.SourceTicks.ShouldBe(a.SourceTicks, Describe(vector));
|
||||
return StatusCode.IsBad(g.Status) ? g : a with { Type = g.Type, Payload = g.Payload };
|
||||
})];
|
||||
if (!expected.SequenceEqual(asIs)) changed++;
|
||||
if (error is not null || !actual.SequenceEqual(expected))
|
||||
failures.Add($"{Describe(vector)} cut {cutSeconds}: error {error}; expected {JsonSerializer.Serialize(expected)} actual {JsonSerializer.Serialize(actual.ToArray())}");
|
||||
}
|
||||
vectors.Count.ShouldBe(384);
|
||||
failures.ShouldBeEmpty("Live TimeAverage / Total must leave the span before an Uncertain first value out (Part 13 'No Start Bound: Calculate Partial Interval', #821).");
|
||||
changed.ShouldBe(expectedChanged, "the late-start variants the #821 override changes from the untouched baseline");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #821 hand-computed pin, independent of every calculator: the "uncertain" data set from +5 (Uncertain 100 at 5,
|
||||
/// Good 18 at 15, Bad at 25, Good 19 at 35, …), forward, sloped, [0,20). Covered [5,20): 100 → 18 over 10 s = 590,
|
||||
/// then 18 → the bound at 20 (sloped 18 → 19 across the Bad sample: 18.25) over 5 s = 90.625 ⇒ Total 680.625,
|
||||
/// TimeAverage 680.625 ÷ 15 = 45.375. The baseline added the ramp 0 → 100 over [0,5) (+250): 930.625 and 46.53125.
|
||||
/// Uncertain either way (an Uncertain value is used, a Bad one skipped, the interval not covered).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(Total, 680.625, 930.625)]
|
||||
[InlineData(TimeAverage, 45.375, 46.53125)]
|
||||
public void Issue821_late_start_hand_computed(string aggregate, double part13, double baselineValue)
|
||||
{
|
||||
var baseline = LoadBaseline();
|
||||
var vector = baseline.Vectors.Single(v => v.Aggregate == aggregate && v.DataSet == "uncertain" && !v.Reverse && !v.Stepped
|
||||
&& !v.UncertainAsBad && !v.Incremental && v.Configuration is null);
|
||||
var (variant, _) = LateStartVariant(baseline.Data["uncertain"], 5);
|
||||
variant[0].Status.ShouldBe(0x40A40000u, "the data set's own Uncertain 100 at +5");
|
||||
var output = Run(baseline, vector, rig: null, out var error, variant);
|
||||
var asIs = Run(baseline, vector, rig: null, out _, variant, baselineAsIs: true);
|
||||
error.ShouldBeNull();
|
||||
double.Parse(output[0].Payload!, CultureInfo.InvariantCulture).ShouldBe(part13, 1e-9);
|
||||
double.Parse(asIs[0].Payload!, CultureInfo.InvariantCulture).ShouldBe(baselineValue, 1e-9);
|
||||
(output[0].Status & 0xFFFF0000u).ShouldBe(StatusCodes.UncertainDataSubNormal.Code);
|
||||
output[0].Status.ShouldBe(asIs[0].Status);
|
||||
output.Skip(1).ShouldBe(asIs.Skip(1), "only the late-start interval changes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A data set cut at <paramref name="cutSeconds"/> with its first non-Bad sample Uncertain (kept Uncertain if it already
|
||||
/// is), and the same with that sample Good.
|
||||
/// </summary>
|
||||
private static (Sample[] Variant, Sample[] GoodTwin) LateStartVariant(Sample[] data, double cutSeconds)
|
||||
{
|
||||
var cut = data.Where(s => s.Seconds >= cutSeconds).OrderBy(s => s.Seconds).ToArray();
|
||||
var first = Array.FindIndex(cut, s => !StatusCode.IsBad(s.Status));
|
||||
if (first < 0) return (cut, cut);
|
||||
var variant = cut.ToArray();
|
||||
var good = cut.ToArray();
|
||||
variant[first] = cut[first] with { Status = StatusCode.IsUncertain(cut[first].Status) ? cut[first].Status : 0x40A40000u };
|
||||
good[first] = cut[first] with { Status = 0u };
|
||||
return (variant, good);
|
||||
}
|
||||
|
||||
private static Baseline LoadBaseline() =>
|
||||
JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
@@ -126,7 +239,8 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
: null;
|
||||
}
|
||||
|
||||
private static RunOutput Run(Baseline baseline, Vector vector, ServerEventRig? rig, out string? error)
|
||||
private static RunOutput Run(Baseline baseline, Vector vector, ServerEventRig? rig, out string? error,
|
||||
Sample[]? data = null, bool baselineAsIs = false)
|
||||
{
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc);
|
||||
@@ -142,13 +256,15 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
};
|
||||
var calculator = rig is not null
|
||||
? rig.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config)
|
||||
: CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
||||
: baselineAsIs
|
||||
? new BaselineAdapter(Aggregators.CreateStandardCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry)!)
|
||||
: CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
||||
calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}.");
|
||||
var output = new RunOutput();
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
|
||||
var samples = (data ?? baseline.Data[vector.DataSet]).OrderBy(x => x.Seconds);
|
||||
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
||||
{
|
||||
var time = epoch.AddSeconds(sample.Seconds);
|
||||
@@ -185,6 +301,20 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
|
||||
static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() };
|
||||
private sealed class RunOutput : List<Value> { public List<bool> Accepted { get; } = []; }
|
||||
|
||||
/// <summary>The untouched baseline calculator behind the SDK 2 protocol (what live served before #809 / #821).</summary>
|
||||
private sealed class BaselineAdapter(AggregateCalculator calculator) : Opc.Ua.Server.IAggregateCalculator
|
||||
{
|
||||
public NodeId AggregateId => calculator.AggregateId;
|
||||
public bool QueueRawValue(DataValue value) => calculator.QueueRawValue(LegacyDataValue.FromSdk(value));
|
||||
public bool HasEndTimePassed(DateTimeUtc currentTime) => calculator.HasEndTimePassed((DateTime)currentTime);
|
||||
public bool TryGetProcessedValue(bool returnPartial, out DataValue value)
|
||||
{
|
||||
var processed = calculator.GetProcessedValue(returnPartial);
|
||||
value = processed is null ? default : processed.ToSdk();
|
||||
return processed is not null;
|
||||
}
|
||||
}
|
||||
record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary<string,Sample[]> Data, Vector[] Vectors);
|
||||
record Sample(double Seconds,string Type,string? Payload,uint Status,ushort SourcePicoseconds = 0,ushort ServerPicoseconds = 0,long ServerOffsetTicks = 0);
|
||||
record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);
|
||||
|
||||
Reference in New Issue
Block a user