Migrate historian from SQL to aahClientManaged SDK and resolve all OPC UA Part 11 gaps

Replace direct SQL queries against Historian Runtime database with the Wonderware
Historian managed SDK (ArchestrA.HistorianAccess). Add HistoryServerCapabilities node,
AggregateFunctions folder, continuation points, ReadAtTime interpolation, ReturnBounds,
ReadModified rejection, HistoricalDataConfiguration per node, historical event access,
and client-side StandardDeviation aggregate support. Remove screenshot tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-06 16:38:00 -04:00
parent 5c89a44255
commit 41f0e9ec4c
35 changed files with 1858 additions and 536 deletions

View File

@@ -28,6 +28,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly bool _anonymousCanWrite;
private readonly AutoResetEvent _dataChangeSignal = new(false);
private readonly Dictionary<int, List<string>> _gobjectToTagRefs = new();
private readonly HistoryContinuationPointManager _historyContinuations = new();
private readonly HistorianDataSource? _historianDataSource;
private readonly PerformanceMetrics _metrics;
@@ -896,6 +897,44 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
variable.AccessLevel = accessLevel;
variable.UserAccessLevel = accessLevel;
variable.Historizing = attr.IsHistorized;
if (attr.IsHistorized)
{
var histConfigNodeId = new NodeId(nodeIdString + ".HAConfiguration", NamespaceIndex);
var histConfig = new BaseObjectState(variable)
{
NodeId = histConfigNodeId,
BrowseName = new QualifiedName("HAConfiguration", NamespaceIndex),
DisplayName = "HA Configuration",
TypeDefinitionId = ObjectTypeIds.HistoricalDataConfigurationType
};
var steppedProp = new PropertyState<bool>(histConfig)
{
NodeId = new NodeId(nodeIdString + ".HAConfiguration.Stepped", NamespaceIndex),
BrowseName = BrowseNames.Stepped,
DisplayName = "Stepped",
Value = false,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead
};
histConfig.AddChild(steppedProp);
var definitionProp = new PropertyState<string>(histConfig)
{
NodeId = new NodeId(nodeIdString + ".HAConfiguration.Definition", NamespaceIndex),
BrowseName = BrowseNames.Definition,
DisplayName = "Definition",
Value = "Wonderware Historian",
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead
};
histConfig.AddChild(definitionProp);
variable.AddChild(histConfig);
AddPredefinedNode(SystemContext, histConfig);
}
variable.Value = NormalizePublishedValue(attr.FullTagReference, null);
variable.StatusCode = StatusCodes.BadWaitingForInitialData;
variable.Timestamp = DateTime.UtcNow;
@@ -1390,6 +1429,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
foreach (var handle in nodesToProcess)
{
var idx = handle.Index;
// Handle continuation point resumption
if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0)
{
var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint);
if (remaining == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid);
continue;
}
ReturnHistoryPage(remaining, details.NumValuesPerNode, results, errors, idx);
continue;
}
var nodeIdStr = handle.NodeId?.Identifier as string;
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
{
@@ -1403,6 +1457,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
continue;
}
if (details.IsReadModified)
{
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
continue;
}
try
{
var maxValues = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0;
@@ -1410,15 +1470,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
tagRef, details.StartTime, details.EndTime, maxValues)
.GetAwaiter().GetResult();
var historyData = new HistoryData();
historyData.DataValues.AddRange(dataValues);
if (details.ReturnBounds)
AddBoundingValues(dataValues, details.StartTime, details.EndTime);
results[idx] = new HistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(historyData)
};
errors[idx] = ServiceResult.Good;
ReturnHistoryPage(dataValues, details.NumValuesPerNode, results, errors, idx);
}
catch (Exception ex)
{
@@ -1442,6 +1497,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
foreach (var handle in nodesToProcess)
{
var idx = handle.Index;
// Handle continuation point resumption
if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0)
{
var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint);
if (remaining == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid);
continue;
}
ReturnHistoryPage(remaining, 0, results, errors, idx);
continue;
}
var nodeIdStr = handle.NodeId?.Identifier as string;
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
{
@@ -1476,6 +1546,58 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
details.ProcessingInterval, column)
.GetAwaiter().GetResult();
ReturnHistoryPage(dataValues, 0, results, errors, idx);
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef);
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
/// <inheritdoc />
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<HistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
foreach (var handle in nodesToProcess)
{
var idx = handle.Index;
var nodeIdStr = handle.NodeId?.Identifier as string;
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
{
errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown);
continue;
}
if (_historianDataSource == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
continue;
}
if (details.ReqTimes == null || details.ReqTimes.Count == 0)
{
errors[idx] = new ServiceResult(StatusCodes.BadInvalidArgument);
continue;
}
try
{
var timestamps = new DateTime[details.ReqTimes.Count];
for (var i = 0; i < details.ReqTimes.Count; i++)
timestamps[i] = details.ReqTimes[i];
var dataValues = _historianDataSource.ReadAtTimeAsync(tagRef, timestamps)
.GetAwaiter().GetResult();
var historyData = new HistoryData();
historyData.DataValues.AddRange(dataValues);
@@ -1488,12 +1610,149 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef);
Log.Warning(ex, "HistoryRead at-time failed for {TagRef}", tagRef);
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
/// <inheritdoc />
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<HistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
foreach (var handle in nodesToProcess)
{
var idx = handle.Index;
var nodeIdStr = handle.NodeId?.Identifier as string;
if (_historianDataSource == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
continue;
}
// Resolve the source name for event filtering.
// Alarm condition nodes end with ".Condition" — strip to get the source tag.
// Area/object nodes filter by Source_Name matching the browse name.
string? sourceName = null;
if (nodeIdStr != null)
{
if (nodeIdStr.EndsWith(".Condition"))
{
var baseTag = nodeIdStr.Substring(0, nodeIdStr.Length - ".Condition".Length);
sourceName = baseTag;
}
else if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
{
sourceName = tagRef;
}
}
try
{
var maxEvents = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0;
var events = _historianDataSource.ReadEventsAsync(
sourceName, details.StartTime, details.EndTime, maxEvents)
.GetAwaiter().GetResult();
var historyEvent = new HistoryEvent();
foreach (var evt in events)
{
// Build the standard event field list per OPC UA Part 11
// Fields: EventId, EventType, SourceNode, SourceName, Time, ReceiveTime,
// Message, Severity
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(evt.Id.ToByteArray()));
fields.EventFields.Add(new Variant(ObjectTypeIds.AlarmConditionType));
fields.EventFields.Add(new Variant(
nodeIdStr != null ? new NodeId(nodeIdStr, NamespaceIndex) : NodeId.Null));
fields.EventFields.Add(new Variant(evt.Source ?? ""));
fields.EventFields.Add(new Variant(
DateTime.SpecifyKind(evt.EventTime, DateTimeKind.Utc)));
fields.EventFields.Add(new Variant(
DateTime.SpecifyKind(evt.ReceivedTime, DateTimeKind.Utc)));
fields.EventFields.Add(new Variant(new LocalizedText(evt.DisplayText ?? "")));
fields.EventFields.Add(new Variant((ushort)evt.Severity));
historyEvent.Events.Add(fields);
}
results[idx] = new HistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(historyEvent)
};
errors[idx] = ServiceResult.Good;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead events failed for {NodeId}", nodeIdStr);
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
private void ReturnHistoryPage(List<DataValue> dataValues, uint numValuesPerNode,
IList<HistoryReadResult> results, IList<ServiceResult> errors, int idx)
{
var pageSize = numValuesPerNode > 0 ? (int)numValuesPerNode : dataValues.Count;
var historyData = new HistoryData();
byte[]? continuationPoint = null;
if (dataValues.Count > pageSize)
{
historyData.DataValues.AddRange(dataValues.GetRange(0, pageSize));
var remainder = dataValues.GetRange(pageSize, dataValues.Count - pageSize);
continuationPoint = _historyContinuations.Store(remainder);
}
else
{
historyData.DataValues.AddRange(dataValues);
}
results[idx] = new HistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
ContinuationPoint = continuationPoint
};
errors[idx] = ServiceResult.Good;
}
private static void AddBoundingValues(List<DataValue> dataValues, DateTime startTime, DateTime endTime)
{
// Insert start bound if first sample doesn't match start time
if (dataValues.Count == 0 || dataValues[0].SourceTimestamp != startTime)
{
dataValues.Insert(0, new DataValue
{
Value = Variant.Null,
SourceTimestamp = startTime,
ServerTimestamp = startTime,
StatusCode = StatusCodes.BadBoundNotFound
});
}
// Append end bound if last sample doesn't match end time
if (dataValues.Count == 0 || dataValues[dataValues.Count - 1].SourceTimestamp != endTime)
{
dataValues.Add(new DataValue
{
Value = Variant.Null,
SourceTimestamp = endTime,
ServerTimestamp = endTime,
StatusCode = StatusCodes.BadBoundNotFound
});
}
}
#endregion
#region Subscription Delivery