feat(historian): advertise only the backend's aggregates under AggregateFunctions (#682)

IHistorianDataSource.SupportedAggregates (default: every HistoryAggregateType)
is the one authority for both ServerCapabilities/AggregateFunctions (i=2997) and
HistoryServerCapabilities/AggregateFunctions (i=11201) and for the up-front
BadAggregateNotSupported in HistoryRead-Processed. Gateway declares
AggregateModeMapper.Served (4), SqlServer/QuestDB AggregateFamilies.Served (17),
Null none. Only the folder references are trimmed; the SDK's calculator
factories stay so live AggregateFilter subscriptions keep working.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-10-12 08:52:47 -04:00
parent 8ebee2ac40
commit 6ea3ba44da
20 changed files with 654 additions and 58 deletions
@@ -55,6 +55,18 @@ public enum HistoryAggregateType
Total2,
}
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/> (#682).</summary>
public static class HistoryAggregateTypes
{
/// <summary>Every defined <see cref="HistoryAggregateType"/> — the interface default.</summary>
public static readonly IReadOnlySet<HistoryAggregateType> All =
System.Collections.Frozen.FrozenSet.ToFrozenSet(Enum.GetValues<HistoryAggregateType>());
/// <summary>No aggregates — a source with no history to aggregate (the Null historian).</summary>
public static readonly IReadOnlySet<HistoryAggregateType> None =
System.Collections.Frozen.FrozenSet<HistoryAggregateType>.Empty;
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
@@ -174,6 +174,23 @@ public interface IHistorianDataSource : IDisposable
/// </remarks>
long TimestampResolutionTicks => 1;
/// <summary>
/// The Part 13 aggregates this backend serves for a processed HistoryRead (Gitea #682) — the ONE authority for
/// both what the server advertises under <c>ServerCapabilities/AggregateFunctions</c> (i=2997) and
/// <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201), and what its HistoryRead-Processed answers
/// <c>BadAggregateNotSupported</c> up front without calling the backend.
/// </summary>
/// <remarks>
/// The default is <see cref="HistoryAggregateTypes.All"/> — "every aggregate the server can name; a member I cannot
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the pre-#682
/// behaviour, so a test fake or an out-of-tree source keeps working unchanged. It is a concrete set rather than a
/// <c>null</c> "all" sentinel so every consumer handles one shape. Every PRODUCTION backend declares its set itself
/// (a reflection guard pins that): a wrapper that forgot to forward this would otherwise advertise every aggregate.
/// The set is read once when the source is wired (the backend is fixed at boot by <c>ServerHistorian:Backend</c>)
/// and again on every processed read, so it must be cheap and stable — return a static frozen set.
/// </remarks>
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
/// <summary>
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11, Gitea #668):
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
@@ -74,6 +74,15 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
? throw new NotSupportedException("The null historian cannot read events newest-first (reverse).")
: Task.FromResult(EmptyEvents);
/// <inheritdoc />
/// <remarks>
/// EMPTY (Gitea #682): with no historian configured the server advertises no aggregate functions, and a processed
/// HistoryRead answers <c>BadAggregateNotSupported</c> per node rather than <c>GoodNoData</c> — an aggregate is a
/// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
/// historian misled capability discovery. Raw / AtTime / Events reads still return the empty <c>GoodNoData</c> result.
/// </remarks>
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
/// <inheritdoc />
public HistorianHealthSnapshot GetHealthSnapshot() => new(
TotalQueries: 0,
@@ -132,6 +132,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
/// </remarks>
public bool SupportsReverseEvents => false;
/// <inheritdoc />
/// <remarks>
/// <see cref="AggregateModeMapper.Served"/> — the aggregates the gateway has a retrieval mode for (#682); every
/// other member answers <c>BadAggregateNotSupported</c> and is not advertised.
/// </remarks>
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateModeMapper.Served;
/// <inheritdoc />
/// <remarks>
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> — the server reports
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -22,22 +23,38 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// </remarks>
internal static class AggregateModeMapper
{
/// <summary>
/// The aggregates the gateway serves — every defined member <see cref="TryMap"/> maps, derived from it so the
/// advertised set and the dispatch can never disagree. <see cref="GatewayHistorianDataSource.SupportedAggregates"/>
/// returns it (#682); it is the ONLY declaration of the gateway's served set.
/// </summary>
public static readonly IReadOnlySet<HistoryAggregateType> Served =
Enum.GetValues<HistoryAggregateType>().Where(a => TryMap(a) is not null).ToFrozenSet();
/// <summary>Maps an aggregate function to the gateway retrieval mode.</summary>
/// <param name="aggregate">The driver-agnostic aggregate function.</param>
/// <returns>The matching gateway <see cref="RetrievalMode"/>.</returns>
/// <exception cref="HistoryAggregateNotSupportedException">A defined member the gateway has no retrieval mode for.</exception>
/// <exception cref="ArgumentOutOfRangeException">An undefined enum value.</exception>
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate)
{
if (TryMap(aggregate) is { } mode) return mode;
// Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
// programming error, but a defined-yet-unserved member is a normal client request the server
// answers with BadAggregateNotSupported.
if (Enum.IsDefined(aggregate)) throw new HistoryAggregateNotSupportedException(aggregate);
throw new ArgumentOutOfRangeException(
nameof(aggregate), aggregate, "Unmapped HistoryAggregateType — add a RetrievalMode mapping.");
}
/// <summary>The single mapping table: the retrieval mode for a served aggregate, or null.</summary>
private static RetrievalMode? TryMap(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.TimeAverage => RetrievalMode.TimeWeightedAverage,
HistoryAggregateType.Minimum => RetrievalMode.MinimumWithTime,
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
HistoryAggregateType.Total => RetrievalMode.Integral,
// Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
// programming error, but a defined-yet-unserved member is a normal client request the server
// answers with BadAggregateNotSupported.
_ when Enum.IsDefined(aggregate) => throw new HistoryAggregateNotSupportedException(aggregate),
_ => throw new ArgumentOutOfRangeException(
nameof(aggregate), aggregate, "Unmapped HistoryAggregateType — add a RetrievalMode mapping."),
_ => null,
};
}
@@ -359,6 +359,10 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
public bool SupportsReverseEvents => true;
/// <inheritdoc />
/// <remarks>The seventeen <see cref="AggregateFamilies.Served"/> — shared with the other SQL backend (#682).</remarks>
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
/// <inheritdoc />
/// <remarks>
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
@@ -40,4 +41,12 @@ public static class AggregateFamilies
or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => AggregateFamily.TimeWeighted,
_ => null,
};
/// <summary>
/// The seventeen aggregates the SQL backends serve — every member <see cref="FamilyOf"/> assigns a family, derived
/// from it so the set and the dispatch can never disagree. Both <c>SqlServerHistorianDataSource</c> and
/// <c>QuestDbHistorianDataSource</c> return it as <see cref="IHistorianDataSource.SupportedAggregates"/> (#682).
/// </summary>
public static readonly IReadOnlySet<HistoryAggregateType> Served =
Enum.GetValues<HistoryAggregateType>().Where(a => FamilyOf(a) is not null).ToFrozenSet();
}
@@ -316,6 +316,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
public bool SupportsReverseEvents => true;
/// <inheritdoc />
/// <remarks>The seventeen <see cref="AggregateFamilies.Served"/> — shared with the other SQL backend (#682).</remarks>
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
/// <inheritdoc />
/// <remarks>
@@ -0,0 +1,91 @@
using System.Reflection;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Gitea #682 — make the two advertised aggregate-function folders, <c>ServerCapabilities/AggregateFunctions</c>
/// (i=2997) and <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201), list exactly the aggregates the wired
/// historian backend serves (<see cref="IHistorianDataSource.SupportedAggregates"/>), instead of the SDK's full
/// registered set.
/// </summary>
/// <remarks>
/// <para>
/// The SDK's <c>AggregateManager.RegisterFactory</c> both registers a calculator factory AND adds an
/// <c>Organizes</c> reference from each folder to the aggregate's object node. Only the REFERENCES are trimmed
/// here: the factories stay, because a live-data MonitoredItem with an <c>AggregateFilter</c> is validated
/// against (and computed by) the AggregateManager, not the historian — removing a factory would break live
/// aggregate subscriptions for every aggregate the historian happens not to serve.
/// </para>
/// <para>
/// The two folders are separate nodes that reference the same aggregate object nodes (both directions). The
/// aggregate nodes themselves are kept (they are standard type-system nodes a client may still Read); only the
/// folder ⇄ aggregate <c>Organizes</c> pair is removed or restored. Idempotent and reversible, so re-wiring a
/// different source re-syncs both folders.
/// </para>
/// </remarks>
internal static class AdvertisedAggregateFunctions
{
/// <summary>Every standard aggregate-function NodeId the SDK's nodeset knows (<c>ObjectIds.AggregateFunction_*</c>).</summary>
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.Select(f => (NodeId)f.GetValue(null)!)
.ToArray();
/// <summary>The two advertised folders (both are trimmed — they are separate nodes).</summary>
internal static readonly NodeId[] Folders =
[
ObjectIds.Server_ServerCapabilities_AggregateFunctions,
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
];
/// <summary>
/// Sync both folders to <paramref name="served"/>: an aggregate-function node is organized under a folder iff the
/// server maps it (<see cref="AggregateFunctionMap"/>) AND <paramref name="served"/> contains it.
/// </summary>
/// <param name="server">The running server.</param>
/// <param name="served">The backend's served aggregates.</param>
/// <returns>The advertised NodeIds, or <c>null</c> when the diagnostics node manager is not available yet.</returns>
public static IReadOnlyList<NodeId>? Apply(IServerInternal server, IReadOnlySet<HistoryAggregateType> served)
{
if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
var advertised = new List<NodeId>();
lock (diagnostics.Lock)
{
var folders = Folders
.Select(id => diagnostics.FindPredefinedNode(id, typeof(BaseObjectState)))
.Where(f => f is not null)
.ToList();
foreach (var aggregateId in StandardAggregateIds)
{
var aggregateNode = diagnostics.FindPredefinedNode(aggregateId, null);
if (aggregateNode is null) continue;
var advertise = AggregateFunctionMap.Map(aggregateId) is { } aggregate && served.Contains(aggregate);
if (advertise) advertised.Add(aggregateId);
foreach (var folder in folders)
{
var linked = folder!.ReferenceExists(ReferenceTypeIds.Organizes, false, aggregateId);
if (advertise && !linked)
{
folder.AddReference(ReferenceTypeIds.Organizes, false, aggregateId);
aggregateNode.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
else if (!advertise && linked)
{
folder.RemoveReference(ReferenceTypeIds.Organizes, false, aggregateId);
aggregateNode.RemoveReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
}
}
}
return advertised;
}
}
@@ -0,0 +1,55 @@
using System.Collections.Frozen;
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// The one table between OPC UA Part 13 standard aggregate-function NodeIds (<c>ObjectIds.AggregateFunction_*</c>)
/// and <see cref="HistoryAggregateType"/>. HistoryRead-Processed maps a request's aggregate through it, and the
/// advertised <c>AggregateFunctions</c> folders (#682) map a backend's served set back through it — one table, so a
/// NodeId the server can dispatch is exactly a NodeId it can advertise.
/// </summary>
internal static class AggregateFunctionMap
{
/// <summary>NodeId ⇒ aggregate, for every aggregate the server can name to a backend.</summary>
public static readonly FrozenDictionary<NodeId, HistoryAggregateType> ByNodeId = new Dictionary<NodeId, HistoryAggregateType>
{
[ObjectIds.AggregateFunction_Average] = HistoryAggregateType.Average,
[ObjectIds.AggregateFunction_Minimum] = HistoryAggregateType.Minimum,
[ObjectIds.AggregateFunction_Maximum] = HistoryAggregateType.Maximum,
[ObjectIds.AggregateFunction_Total] = HistoryAggregateType.Total,
[ObjectIds.AggregateFunction_Count] = HistoryAggregateType.Count,
[ObjectIds.AggregateFunction_Interpolative] = HistoryAggregateType.Interpolative,
[ObjectIds.AggregateFunction_TimeAverage] = HistoryAggregateType.TimeAverage,
[ObjectIds.AggregateFunction_Start] = HistoryAggregateType.Start,
[ObjectIds.AggregateFunction_End] = HistoryAggregateType.End,
[ObjectIds.AggregateFunction_Delta] = HistoryAggregateType.Delta,
[ObjectIds.AggregateFunction_Range] = HistoryAggregateType.Range,
[ObjectIds.AggregateFunction_MinimumActualTime] = HistoryAggregateType.MinimumActualTime,
[ObjectIds.AggregateFunction_MaximumActualTime] = HistoryAggregateType.MaximumActualTime,
[ObjectIds.AggregateFunction_StartBound] = HistoryAggregateType.StartBound,
[ObjectIds.AggregateFunction_EndBound] = HistoryAggregateType.EndBound,
[ObjectIds.AggregateFunction_DeltaBounds] = HistoryAggregateType.DeltaBounds,
[ObjectIds.AggregateFunction_DurationGood] = HistoryAggregateType.DurationGood,
[ObjectIds.AggregateFunction_DurationBad] = HistoryAggregateType.DurationBad,
[ObjectIds.AggregateFunction_PercentGood] = HistoryAggregateType.PercentGood,
[ObjectIds.AggregateFunction_PercentBad] = HistoryAggregateType.PercentBad,
[ObjectIds.AggregateFunction_WorstQuality] = HistoryAggregateType.WorstQuality,
[ObjectIds.AggregateFunction_NumberOfTransitions] = HistoryAggregateType.NumberOfTransitions,
[ObjectIds.AggregateFunction_AnnotationCount] = HistoryAggregateType.AnnotationCount,
[ObjectIds.AggregateFunction_StandardDeviationPopulation] = HistoryAggregateType.StandardDeviationPopulation,
[ObjectIds.AggregateFunction_VariancePopulation] = HistoryAggregateType.VariancePopulation,
[ObjectIds.AggregateFunction_TimeAverage2] = HistoryAggregateType.TimeAverage2,
[ObjectIds.AggregateFunction_Total2] = HistoryAggregateType.Total2,
}.ToFrozenDictionary();
/// <summary>
/// Map a request's aggregate-function NodeId to <see cref="HistoryAggregateType"/>; <c>null</c> for one the server
/// cannot name to any backend (the caller answers <c>BadAggregateNotSupported</c>).
/// </summary>
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
/// <returns>The mapped aggregate, or <c>null</c>.</returns>
public static HistoryAggregateType? Map(NodeId? aggregateNodeId) =>
aggregateNodeId is not null && ByNodeId.TryGetValue(aggregateNodeId, out var aggregate) ? aggregate : null;
}
@@ -344,11 +344,48 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
/// </para>
/// <para>
/// Gitea #682: assigning a source also re-syncs the advertised <c>AggregateFunctions</c> folders to its
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (see <see cref="SyncAdvertisedAggregates"/>).
/// </para>
/// </summary>
public IHistorianDataSource HistorianDataSource
{
get => _historianDataSource;
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
set
{
_historianDataSource = value ?? NullHistorianDataSource.Instance;
SyncAdvertisedAggregates();
}
}
/// <summary>
/// Gitea #682 — trim <c>ServerCapabilities/AggregateFunctions</c> (i=2997) and
/// <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
/// <see cref="IHistorianDataSource.SupportedAggregates"/>. Called on every <see cref="HistorianDataSource"/>
/// assignment and by <see cref="OtOpcUaSdkServer"/> right after the SDK registers its aggregate factories, so the
/// Null default is applied from boot. Only the folder references change — the AggregateManager's calculator
/// factories (live <c>AggregateFilter</c> subscriptions) are untouched. Never throws: a failure leaves the previous
/// advertisement in place and is logged, because HistoryRead's own up-front check still enforces the set.
/// </summary>
internal void SyncAdvertisedAggregates()
{
try
{
var source = _historianDataSource;
var advertised = AdvertisedAggregateFunctions.Apply(Server, source.SupportedAggregates);
if (advertised is not null)
{
_logger.LogInformation(
"OtOpcUaNodeManager: advertising {Count} aggregate function(s) for historian {Source}: {Aggregates}",
advertised.Count, source.GetType().Name,
string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString())));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to sync the advertised aggregate functions");
}
}
private volatile IHistorianDataUpdater? _historianDataUpdater;
@@ -5054,6 +5091,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// #677: a client AggregateConfiguration other than the server defaults is refused per node, never silently ignored.
var configurationServable = ServerAggregateConfiguration.IsServable(details.AggregateConfiguration);
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
var source = HistorianDataSource;
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
@@ -5064,8 +5102,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
// the base dispatcher). handle.Index is the node's position in that collection.
var aggregateNodeId = details.AggregateType[handle.Index];
var aggregate = MapAggregate(aggregateNodeId);
if (aggregate is null)
// #682: one authority — the source's SupportedAggregates (also what the AggregateFunctions folders
// advertise). An aggregate the server cannot name, or the backend does not serve, is refused up front
// without a backend call.
var aggregate = AggregateFunctionMap.Map(aggregateNodeId);
if (aggregate is null || !source.SupportedAggregates.Contains(aggregate.Value))
{
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
continue;
@@ -6478,45 +6519,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return new HistoryData { DataValues = values };
}
/// <summary>
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
/// <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
/// the caller can surface <c>BadAggregateNotSupported</c>.
/// </summary>
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
/// <returns>The mapped aggregate, or <c>null</c> when unsupported.</returns>
private static HistoryAggregateType? MapAggregate(NodeId aggregateNodeId)
{
if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage2) return HistoryAggregateType.TimeAverage2;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total2) return HistoryAggregateType.Total2;
return null;
}
/// <summary>
/// Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
/// SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
@@ -200,6 +200,21 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
/// <summary>
/// Gitea #682 — keep the SDK's full set of calculator factories (live-data <c>AggregateFilter</c> subscriptions
/// need them), then trim the advertised <c>AggregateFunctions</c> folders to the node manager's current historian
/// source. At this point that is the Null default (no aggregates); the Host's
/// <see cref="SetHistorianDataSource"/> re-syncs to the configured backend's set right after start.
/// </summary>
/// <inheritdoc />
protected override AggregateManager CreateAggregateManager(
IServerInternal server, ApplicationConfiguration configuration)
{
var manager = base.CreateAggregateManager(server, configuration);
_otOpcUaNodeManager?.SyncAdvertisedAggregates();
return manager;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)