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:
@@ -1,26 +1,29 @@
|
||||
# Historical Data Access
|
||||
|
||||
`LmxNodeManager` exposes OPC UA historical data access (HDA) by querying the Wonderware Historian Runtime database. The `HistorianDataSource` class translates OPC UA history requests into SQL queries against the Historian's `History` and `AnalogSummaryHistory` views, and the node manager overrides wire the results back into the OPC UA response.
|
||||
`LmxNodeManager` exposes OPC UA historical data access (HDA) by querying the Wonderware Historian via the `aahClientManaged` SDK. The `HistorianDataSource` class translates OPC UA history requests into SDK queries using the `ArchestrA.HistorianAccess` API, and the node manager overrides wire the results back into the OPC UA response.
|
||||
|
||||
## Wonderware Historian Runtime Database
|
||||
## Wonderware Historian SDK
|
||||
|
||||
The Historian stores time-series data in a SQL Server database named `Runtime`. Two views are relevant:
|
||||
The server uses the AVEVA Historian managed SDK (`aahClientManaged.dll`) to query historical data. The SDK provides a cursor-based query API through `ArchestrA.HistorianAccess`, replacing direct SQL queries against the Historian Runtime database. Two query types are used:
|
||||
|
||||
- **`Runtime.dbo.History`** -- Raw historical samples with columns `DateTime`, `Value` (numeric), `vValue` (string), and `Quality`.
|
||||
- **`Runtime.dbo.AnalogSummaryHistory`** -- Pre-computed aggregates bucketed by `wwResolution` (milliseconds), with columns like `Average`, `Minimum`, `Maximum`, `ValueCount`, `First`, `Last`, `StdDev`.
|
||||
- **`HistoryQuery`** -- Raw historical samples with timestamp, value (numeric or string), and OPC quality.
|
||||
- **`AnalogSummaryQuery`** -- Pre-computed aggregates with properties for Average, Minimum, Maximum, ValueCount, First, Last, StdDev, and more.
|
||||
|
||||
Both views require `TagName` in the `WHERE` clause. This is a Historian constraint -- the views are optimized for tag-scoped queries and do not support efficient cross-tag scans.
|
||||
The SDK DLLs are located in `lib/` and originate from `C:\Program Files (x86)\Wonderware\Historian\`.
|
||||
|
||||
## Configuration
|
||||
|
||||
`HistorianConfiguration` controls the historian connection:
|
||||
`HistorianConfiguration` controls the SDK connection:
|
||||
|
||||
```csharp
|
||||
public class HistorianConfiguration
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
public string ConnectionString { get; set; } =
|
||||
"Server=localhost;Database=Runtime;Integrated Security=true;";
|
||||
public string ServerName { get; set; } = "localhost";
|
||||
public bool IntegratedSecurity { get; set; } = true;
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public int Port { get; set; } = 32568;
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
public int MaxValuesPerRead { get; set; } = 10000;
|
||||
}
|
||||
@@ -28,46 +31,59 @@ public class HistorianConfiguration
|
||||
|
||||
When `Enabled` is `false`, the `HistorianDataSource` is not instantiated and the node manager returns `BadHistoryOperationUnsupported` for history read requests.
|
||||
|
||||
### Connection Properties
|
||||
|
||||
| Property | Default | Description |
|
||||
|---|---|---|
|
||||
| `ServerName` | `localhost` | Historian server hostname |
|
||||
| `IntegratedSecurity` | `true` | Use Windows authentication |
|
||||
| `UserName` | `null` | Username when `IntegratedSecurity` is false |
|
||||
| `Password` | `null` | Password when `IntegratedSecurity` is false |
|
||||
| `Port` | `32568` | Historian TCP port |
|
||||
| `CommandTimeoutSeconds` | `30` | SDK packet timeout in seconds |
|
||||
| `MaxValuesPerRead` | `10000` | Maximum values per history read request |
|
||||
|
||||
## Connection Lifecycle
|
||||
|
||||
`HistorianDataSource` maintains a persistent connection to the Historian server via `ArchestrA.HistorianAccess`:
|
||||
|
||||
1. **Lazy connect** -- The connection is established on the first query via `EnsureConnected()`.
|
||||
2. **Connection reuse** -- Subsequent queries reuse the same connection.
|
||||
3. **Auto-reconnect** -- On connection failure, the connection is disposed and re-established on the next query.
|
||||
4. **Clean shutdown** -- `Dispose()` closes the connection when the service stops.
|
||||
|
||||
The connection is opened with `ReadOnly = true` and `ConnectionType = Process`.
|
||||
|
||||
## Raw Reads
|
||||
|
||||
`HistorianDataSource.ReadRawAsync` queries the `History` view for individual samples within a time range:
|
||||
`HistorianDataSource.ReadRawAsync` uses a `HistoryQuery` to retrieve individual samples within a time range:
|
||||
|
||||
```sql
|
||||
SELECT TOP (@MaxValues) DateTime, Value, vValue, Quality
|
||||
FROM Runtime.dbo.History
|
||||
WHERE TagName = @TagName
|
||||
AND DateTime >= @StartTime AND DateTime <= @EndTime
|
||||
ORDER BY DateTime
|
||||
```
|
||||
1. Create a `HistoryQuery` via `_connection.CreateHistoryQuery()`
|
||||
2. Configure `HistoryQueryArgs` with `TagNames`, `StartDateTime`, `EndDateTime`, and `RetrievalMode = Full`
|
||||
3. Iterate: `StartQuery` -> `MoveNext` loop -> `EndQuery`
|
||||
|
||||
The `TOP` clause is included only when `maxValues > 0` (the OPC UA client specified `NumValuesPerNode`). Each row is converted to an OPC UA `DataValue`:
|
||||
Each result row is converted to an OPC UA `DataValue`:
|
||||
|
||||
- `Value` column (double) takes priority over `vValue` (string). If both are null, the value is null.
|
||||
- `SourceTimestamp` and `ServerTimestamp` are both set to the `DateTime` column.
|
||||
- `StatusCode` is mapped from the Historian `Quality` byte via `QualityMapper` (the same OPC DA quality byte mapping used for live MXAccess data).
|
||||
- `QueryResult.Value` (double) takes priority; `QueryResult.StringValue` is used as fallback for string-typed tags.
|
||||
- `SourceTimestamp` and `ServerTimestamp` are both set to `QueryResult.StartDateTime`.
|
||||
- `StatusCode` is mapped from the `QueryResult.OpcQuality` (UInt16) via `QualityMapper` (the same OPC DA quality byte mapping used for live MXAccess data).
|
||||
|
||||
## Aggregate Reads
|
||||
|
||||
`HistorianDataSource.ReadAggregateAsync` queries the `AnalogSummaryHistory` view for pre-computed aggregates:
|
||||
`HistorianDataSource.ReadAggregateAsync` uses an `AnalogSummaryQuery` to retrieve pre-computed aggregates:
|
||||
|
||||
```sql
|
||||
SELECT StartDateTime, [{aggregateColumn}]
|
||||
FROM Runtime.dbo.AnalogSummaryHistory
|
||||
WHERE TagName = @TagName
|
||||
AND StartDateTime >= @StartTime AND StartDateTime <= @EndTime
|
||||
AND wwResolution = @Resolution
|
||||
ORDER BY StartDateTime
|
||||
```
|
||||
|
||||
The `aggregateColumn` is interpolated directly into the SQL (it comes from the controlled `MapAggregateToColumn` mapping, not from user input). The `wwResolution` parameter maps from the OPC UA `ProcessingInterval` in milliseconds.
|
||||
1. Create an `AnalogSummaryQuery` via `_connection.CreateAnalogSummaryQuery()`
|
||||
2. Configure `AnalogSummaryQueryArgs` with `TagNames`, `StartDateTime`, `EndDateTime`, and `Resolution` (milliseconds)
|
||||
3. Iterate the same `StartQuery` -> `MoveNext` -> `EndQuery` pattern
|
||||
4. Extract the requested aggregate from named properties on `AnalogSummaryQueryResult`
|
||||
|
||||
Null aggregate values return `BadNoData` status rather than `Good` with a null variant.
|
||||
|
||||
## Quality Mapping
|
||||
|
||||
The Historian stores standard OPC DA quality bytes, the same format used by MXAccess at runtime. The quality byte is passed through the shared `QualityMapper` pipeline (`MapFromMxAccessQuality` → `MapToOpcUaStatusCode`), which maps the OPC DA quality families to OPC UA status codes:
|
||||
The Historian SDK returns standard OPC DA quality values in `QueryResult.OpcQuality` (UInt16). The low byte is passed through the shared `QualityMapper` pipeline (`MapFromMxAccessQuality` -> `MapToOpcUaStatusCode`), which maps the OPC DA quality families to OPC UA status codes:
|
||||
|
||||
| Historian Quality Byte | OPC DA Family | OPC UA StatusCode |
|
||||
| OPC Quality Byte | OPC DA Family | OPC UA StatusCode |
|
||||
|---|---|---|
|
||||
| 0-63 | Bad | `Bad` (with sub-code when an exact enum match exists) |
|
||||
| 64-191 | Uncertain | `Uncertain` (with sub-code when an exact enum match exists) |
|
||||
@@ -77,9 +93,9 @@ See `Domain/QualityMapper.cs` and `Domain/Quality.cs` for the full mapping table
|
||||
|
||||
## Aggregate Function Mapping
|
||||
|
||||
`MapAggregateToColumn` translates OPC UA aggregate NodeIds to Historian column names:
|
||||
`MapAggregateToColumn` translates OPC UA aggregate NodeIds to `AnalogSummaryQueryResult` property names:
|
||||
|
||||
| OPC UA Aggregate | Historian Column |
|
||||
| OPC UA Aggregate | Result Property |
|
||||
|---|---|
|
||||
| `AggregateFunction_Average` | `Average` |
|
||||
| `AggregateFunction_Minimum` | `Minimum` |
|
||||
@@ -100,28 +116,14 @@ Unsupported aggregates return `null`, which causes the node manager to return `B
|
||||
3. Call `ReadRawAsync` with the time range and `NumValuesPerNode` from the `ReadRawModifiedDetails`.
|
||||
4. Pack the resulting `DataValue` list into a `HistoryData` object and wrap it in an `ExtensionObject` for the `HistoryReadResult`.
|
||||
|
||||
```csharp
|
||||
var dataValues = _historianDataSource.ReadRawAsync(
|
||||
tagRef, details.StartTime, details.EndTime, maxValues)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var historyData = new HistoryData();
|
||||
historyData.DataValues.AddRange(dataValues);
|
||||
results[idx] = new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(historyData)
|
||||
};
|
||||
```
|
||||
|
||||
## HistoryReadProcessed Override
|
||||
|
||||
`HistoryReadProcessed` handles aggregate history requests with additional validation:
|
||||
|
||||
1. Resolve the node and check historian availability (same as raw).
|
||||
2. Validate that `AggregateType` is present in the `ReadProcessedDetails`. Return `BadAggregateListMismatch` if empty.
|
||||
3. Map the requested aggregate to a Historian column via `MapAggregateToColumn`. Return `BadAggregateNotSupported` if unmapped.
|
||||
4. Call `ReadAggregateAsync` with the time range, `ProcessingInterval`, and column name.
|
||||
3. Map the requested aggregate to a result property via `MapAggregateToColumn`. Return `BadAggregateNotSupported` if unmapped.
|
||||
4. Call `ReadAggregateAsync` with the time range, `ProcessingInterval`, and property name.
|
||||
5. Return results in the same `HistoryData` / `ExtensionObject` format.
|
||||
|
||||
## Historizing Flag and AccessLevel
|
||||
|
||||
Reference in New Issue
Block a user