8a0032bfdf
When the data starts inside an interval (no start bound) and its first value is
Uncertain, the frozen 1.5.378.182 AverageAggregateCalculator integrated the
uncovered span as a ramp from 0 and divided TimeAverage by the whole interval:
GetRegionsInValueSet (ignoreBadData, sloped) overwrites the BadNoData start
region's status with UncertainDataSubNormal in its one branch without a not-Bad
guard. Live AggregateFilter therefore disagreed with HistoryRead and Part 13
§5.4.3.6/§5.4.3.8 ("No Start Bound: Calculate Partial Interval"); QP: Total
188.96 vs 163.96, TimeAverage 9.448 vs 10.931. SDK 2.0.1 stock does the same.
Owner-approved 2026-11-11.
- New app-owned CoveredTimeAverageAggregateCalculator subclasses the baseline
and recomputes only TimeAverage/Total, restoring the Bad status of a region
that starts at a Bad point (only the start bound can). Only such an interval's
value changes; status, timestamps, type and bits are the baseline's. Stepped
and TreatUncertainAsBad=true never reached the defect. CompatibilityAggregate-
Factory routes exactly those two IDs; copied SDK files untouched.
- Golden test: 0 fixture vectors change (every data set has a usable value at
-10 s), asserted. New sweep over late-start variants of every data set (cut
+5 s / +25 s, first non-Bad sample Uncertain, all directions/stepping/configs/
incremental): 150 / 142 of 384 change, each pinned to a baseline-derived
oracle (untouched baseline's value with that sample Good, its status/bits on
the variant itself) - never our calculator. Plus a hand-computed case.
- Live-vs-HistoryRead parity (TimeWeightedAggregator) forward + reverse, no
masks, with discrimination against the pre-#821 baseline; QualityMix now
compares QP's TimeAverage/Total with the live oracle on both SQL backends;
booted-runtime facts test expects 163.96 / 10.93.
- README recorded decision, ServerAggregateConfiguration remarks, oracle
remarks, docs/Historian.md and .claude/rules/historian.md updated.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
324 lines
22 KiB
C#
324 lines
22 KiB
C#
using System.Globalization;
|
||
using System.Text.Json;
|
||
using Opc.Ua;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||
|
||
/// <summary>Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors.</summary>
|
||
/// <remarks>
|
||
/// #809 (owner-approved 2026-11-11): PercentGood / DurationGood no longer count Uncertain time as Good. The baseline
|
||
/// vectors that changes are exempted EXACTLY — a PercentGood / DurationGood vector whose output differs from the same
|
||
/// 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
|
||
{
|
||
private const string PercentGood = "AggregateFunction_PercentGood";
|
||
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)]
|
||
[InlineData(true)]
|
||
public async Task All_37_factories_preserve_the_6144_baseline_vectors(bool throughRuntimeManager)
|
||
{
|
||
await using var rig = throughRuntimeManager
|
||
? await ServerEventRig.StartAsync("AggregateBaseline", encrypted: false, TestContext.Current.CancellationToken)
|
||
: null;
|
||
var baseline = LoadBaseline();
|
||
var exemptions = Issue809Exemptions(baseline);
|
||
var failures = new List<string>();
|
||
foreach (var vector in baseline.Vectors)
|
||
{
|
||
// #809: an exempted case is held to its baseline uncertain-as-bad twin; every other case to its own vector.
|
||
var expected = exemptions.TryGetValue(vector, out var twin) ? twin.Output : vector.Output;
|
||
if (Check(baseline, vector, expected, rig) is { } failure) failures.Add(failure);
|
||
}
|
||
baseline.Vectors.Length.ShouldBe(6144);
|
||
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).");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #809 pin: the exemptions are exactly the PercentGood / DurationGood cases that counted Uncertain as Good, and
|
||
/// every PercentGood / DurationGood case — exempt or not, whatever its configuration — now answers its baseline
|
||
/// uncertain-as-bad twin (Good regions only).
|
||
/// </summary>
|
||
[Fact]
|
||
public void Issue809_percent_and_duration_good_count_only_good_time()
|
||
{
|
||
var baseline = LoadBaseline();
|
||
var exemptions = Issue809Exemptions(baseline);
|
||
var failures = new List<string>();
|
||
var checkedCount = 0;
|
||
var twinned = 0;
|
||
foreach (var vector in baseline.Vectors.Where(v => v.Aggregate is PercentGood or DurationGood))
|
||
{
|
||
// A data set with no uncertain-as-bad twin (the numeric-width edge sets) has no Uncertain region to move.
|
||
var twin = UncertainAsBadTwin(baseline, vector);
|
||
if (exemptions.ContainsKey(vector))
|
||
EffectiveTreatUncertainAsBad(vector).ShouldBeFalse($"{Describe(vector)}: only an Uncertain-as-Good case may change.");
|
||
if (Check(baseline, vector, twin?.Output ?? vector.Output, rig: null) is { } failure) failures.Add(failure);
|
||
checkedCount++;
|
||
if (twin is not null) twinned++;
|
||
}
|
||
checkedCount.ShouldBe(352);
|
||
twinned.ShouldBeGreaterThan(exemptions.Count);
|
||
exemptions.Count.ShouldBe(Issue809PercentGoodExemptions + Issue809DurationGoodExemptions);
|
||
failures.ShouldBeEmpty("PercentGood / DurationGood must count Good regions only (Part 13 §5.4.3.31/§5.4.3.33, #809).");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #809 hand-computed pin, independent of the expected outputs in any fixture: the "uncertain" data set (0 s Good,
|
||
/// 5 s Uncertain, 15 s Good, 25 s Bad, 35 s Good, 50 s Good) over 20 s intervals from 0 to 70 s, sloped, forward.
|
||
/// [0,20): Good 0–5 and 15–20 = 10 s (the baseline said 20 s); [20,40): the bound at 20 slopes into the Bad sample
|
||
/// (Uncertain), 25–35 Bad, 35–40 Good = 5 s (baseline 10 s); [40,60): 20 s; [60,70): all 10 s of the partial
|
||
/// interval = 100 %.
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(DurationGood, new[] { 10000d, 5000d, 20000d, 10000d })]
|
||
[InlineData(PercentGood, new[] { 50d, 25d, 100d, 100d })]
|
||
public void Issue809_uncertain_time_is_not_good_hand_computed(string aggregate, double[] expected)
|
||
{
|
||
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 output = Run(baseline, vector, rig: null, out var error);
|
||
error.ShouldBeNull();
|
||
output.ShouldAllBe(x => x.Type == "Double");
|
||
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 })!;
|
||
|
||
private static bool EffectiveTreatUncertainAsBad(Vector vector) => vector.Configuration?.TreatUncertainAsBad ?? vector.UncertainAsBad;
|
||
|
||
/// <summary>The same case computed by the baseline SDK with <c>TreatUncertainAsBad = true</c> and no explicit profile.</summary>
|
||
private static Vector? UncertainAsBadTwin(Baseline baseline, Vector vector) =>
|
||
baseline.Vectors.SingleOrDefault(v => v.Aggregate == vector.Aggregate && v.DataSet == vector.DataSet && v.Reverse == vector.Reverse
|
||
&& v.Stepped == vector.Stepped && v.Incremental == vector.Incremental && v.UncertainAsBad && v.Configuration is null);
|
||
|
||
/// <summary>#809: the PercentGood / DurationGood vectors whose baseline output differs from their uncertain-as-bad twin.</summary>
|
||
private static Dictionary<Vector, Vector> Issue809Exemptions(Baseline baseline) =>
|
||
baseline.Vectors
|
||
.Where(v => v.Aggregate is PercentGood or DurationGood)
|
||
.Select(v => (Vector: v, Twin: UncertainAsBadTwin(baseline, v)))
|
||
.Where(x => x.Twin is not null && !x.Vector.Output.SequenceEqual(x.Twin.Output))
|
||
.ToDictionary<(Vector Vector, Vector? Twin), Vector, Vector>(x => x.Vector, x => x.Twin!, System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||
|
||
private static string Describe(Vector vector) =>
|
||
$"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}/config={JsonSerializer.Serialize(vector.Configuration)}";
|
||
|
||
private static string? Check(Baseline baseline, Vector vector, Value[] expected, ServerEventRig? rig)
|
||
{
|
||
var output = Run(baseline, vector, rig, out var error);
|
||
return error != vector.Exception || !output.SequenceEqual(expected) || (vector.QueueAccepted is { } expectedAccepted && !output.Accepted.SequenceEqual(expectedAccepted))
|
||
? $"{Describe(vector)}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(expected)} actual {JsonSerializer.Serialize(output.ToArray())}"
|
||
: null;
|
||
}
|
||
|
||
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);
|
||
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
|
||
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
|
||
var profile = vector.Configuration;
|
||
var config = new AggregateConfiguration
|
||
{
|
||
TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad,
|
||
PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100,
|
||
UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false,
|
||
UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false,
|
||
};
|
||
var calculator = rig is not null
|
||
? rig.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config)
|
||
: 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 = (data ?? baseline.Data[vector.DataSet]).OrderBy(x => x.Seconds);
|
||
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
||
{
|
||
var time = epoch.AddSeconds(sample.Seconds);
|
||
output.Accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds)));
|
||
if (vector.Incremental) Drain(false);
|
||
}
|
||
Drain(true);
|
||
}
|
||
catch (Exception ex) { error = ex.GetType().FullName; }
|
||
return output;
|
||
|
||
void Drain(bool partial)
|
||
{
|
||
for (var guard = 0; guard < 100; guard++)
|
||
{
|
||
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
|
||
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
|
||
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds));
|
||
}
|
||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||
}
|
||
}
|
||
|
||
// UA FILETIME zero is the old CLR unset sentinel, not a different historical instant.
|
||
private static long LegacyTimestampTicks(DateTimeUtc time) => time.IsNull ? DateTime.MinValue.Ticks : ((DateTime)time).Ticks;
|
||
static object? Payload(Sample sample) => sample.Type switch { "SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||
"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);
|
||
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception, Configuration? Configuration = null,bool[]? QueueAccepted = null);
|
||
record Configuration(bool TreatUncertainAsBad,byte PercentDataBad,byte PercentDataGood,bool UseSlopedExtrapolation,bool UseServerCapabilitiesDefaults);
|
||
}
|