From 775cb8084f316eadea6d1990f2df34fd3d012994 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 17 Mar 2026 18:25:39 -0400 Subject: [PATCH] feat: data-sourced attributes start with uncertain quality before first DCL value Attributes bound to data connections now initialize with "Uncertain" quality, distinguishing "never received a value" from "known good" or "connection lost." Quality is tracked per attribute and included in GetAttributeResponse. --- Component-DataConnectionLayer.md | 2 +- Component-SiteRuntime.md | 7 ++- HighLevelReqs.md | 1 + .../Messages/Instance/GetAttributeRequest.cs | 5 +- .../Actors/InstanceActor.cs | 10 +++- .../Actors/InstanceActorTests.cs | 57 +++++++++++++++++++ 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/Component-DataConnectionLayer.md b/Component-DataConnectionLayer.md index 3a5886c..1a5033a 100644 --- a/Component-DataConnectionLayer.md +++ b/Component-DataConnectionLayer.md @@ -54,7 +54,7 @@ Additional protocols can be added by implementing this interface. ### Common Value Type -Both protocols produce the same value tuple consumed by Instance Actors: +Both protocols produce the same value tuple consumed by Instance Actors. Before the first value update arrives from the DCL, data-sourced attributes are held at **uncertain** quality by the Instance Actor (see Site Runtime — Initialization): | Concept | ScadaLink Design | LmxProxy SDK (`Vtq`) | |---|---|---| diff --git a/Component-SiteRuntime.md b/Component-SiteRuntime.md index e929017..e85ef78 100644 --- a/Component-SiteRuntime.md +++ b/Component-SiteRuntime.md @@ -87,9 +87,10 @@ Deployment Manager Singleton (Cluster Singleton) ### Initialization 1. Load all attribute values from the flattened configuration (static defaults). -2. Register data source references with the Data Connection Layer for subscriptions. -3. Create child Script Actors (one per script defined on the instance). -4. Create child Alarm Actors (one per alarm defined on the instance). +2. Set quality to **uncertain** for all attributes that have a data source reference. Static attributes (no data source reference) have quality **good**. The uncertain quality persists until the first value update arrives from the Data Connection Layer, distinguishing "not yet received" from "known good" or "connection lost." +3. Register data source references with the Data Connection Layer for subscriptions. +4. Create child Script Actors (one per script defined on the instance). +5. Create child Alarm Actors (one per alarm defined on the instance). ### Attribute Value Updates - Receives tag value updates from the Data Connection Layer for attributes with data source references. diff --git a/HighLevelReqs.md b/HighLevelReqs.md index f838f78..4f13f73 100644 --- a/HighLevelReqs.md +++ b/HighLevelReqs.md @@ -60,6 +60,7 @@ - Both protocols implement a **common interface** supporting: connect, subscribe to tag paths, receive value updates, and write values. - Additional protocols can be added by implementing the common interface. - The Data Connection Layer is a **clean data pipe** — it publishes tag value updates to Instance Actors but performs no evaluation of triggers or alarm conditions. +- **Initial attribute quality**: Attributes bound to a data connection start with **uncertain** quality when the Instance Actor initializes. The quality remains uncertain until the first value update is received from the Data Connection Layer. This distinguishes "never received a value" from "received a known-good value" or "connection lost" (bad quality). ### 2.5 Scale - Approximately **10 sites**. diff --git a/src/ScadaLink.Commons/Messages/Instance/GetAttributeRequest.cs b/src/ScadaLink.Commons/Messages/Instance/GetAttributeRequest.cs index 5632646..d0caaa8 100644 --- a/src/ScadaLink.Commons/Messages/Instance/GetAttributeRequest.cs +++ b/src/ScadaLink.Commons/Messages/Instance/GetAttributeRequest.cs @@ -11,7 +11,9 @@ public record GetAttributeRequest( DateTimeOffset Timestamp); /// -/// Response containing the current value of an attribute. +/// Response containing the current value and quality of an attribute. +/// Quality is "Good", "Bad", or "Uncertain". +/// Data-sourced attributes start at "Uncertain" until the first DCL value update arrives. /// public record GetAttributeResponse( string CorrelationId, @@ -19,4 +21,5 @@ public record GetAttributeResponse( string AttributeName, object? Value, bool Found, + string Quality, DateTimeOffset Timestamp); diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index 565d738..89be901 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -38,6 +38,7 @@ public class InstanceActor : ReceiveActor private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly Dictionary _attributes = new(); + private readonly Dictionary _attributeQualities = new(); private readonly Dictionary _alarmStates = new(); private readonly Dictionary _scriptActors = new(); private readonly Dictionary _alarmActors = new(); @@ -75,11 +76,15 @@ public class InstanceActor : ReceiveActor _configuration = JsonSerializer.Deserialize(configJson); // Load default attribute values from the flattened configuration + // Data-sourced attributes start with Uncertain quality until the first DCL value arrives. + // Static attributes start with Good quality. if (_configuration != null) { foreach (var attr in _configuration.Attributes) { _attributes[attr.CanonicalName] = attr.Value; + _attributeQualities[attr.CanonicalName] = + string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; } } @@ -169,12 +174,14 @@ public class InstanceActor : ReceiveActor private void HandleGetAttribute(GetAttributeRequest request) { var found = _attributes.TryGetValue(request.AttributeName, out var value); + _attributeQualities.TryGetValue(request.AttributeName, out var quality); Sender.Tell(new GetAttributeResponse( request.CorrelationId, _instanceUniqueName, request.AttributeName, value, found, + quality ?? "Good", DateTimeOffset.UtcNow)); } @@ -249,6 +256,7 @@ public class InstanceActor : ReceiveActor { // WP-24: State mutation serialized through this actor _attributes[changed.AttributeName] = changed.Value; + _attributeQualities[changed.AttributeName] = changed.Quality; PublishAndNotifyChildren(changed); } @@ -345,7 +353,7 @@ public class InstanceActor : ReceiveActor kvp.Key, kvp.Key, kvp.Value, - "Good", + _attributeQualities.GetValueOrDefault(kvp.Key, "Good"), DateTimeOffset.UtcNow)).ToList(); var alarmStates = _alarmStates.Select(kvp => new AlarmStateChanged( diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs index 8d4511a..20b5254 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -222,4 +222,61 @@ public class InstanceActorTests : TestKit, IDisposable var response = ExpectMsg(); Assert.Equal("98.6", response.Value?.ToString()); } + + [Fact] + public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Temperature", + Value = "0", + DataType = "Double", + DataSourceReference = "/Motor/Temperature", + BoundDataConnectionName = "OpcServer1" + } + ] + }; + + var actor = CreateInstanceActor("Pump1", config); + + actor.Tell(new GetAttributeRequest( + "corr-quality-1", "Pump1", "Temperature", DateTimeOffset.UtcNow)); + + var response = ExpectMsg(); + Assert.True(response.Found); + Assert.Equal("Uncertain", response.Quality); + } + + [Fact] + public void InstanceActor_StaticAttribute_StartsWithGoodQuality() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Label", + Value = "Main Pump", + DataType = "String" + // No DataSourceReference — static attribute + } + ] + }; + + var actor = CreateInstanceActor("Pump1", config); + + actor.Tell(new GetAttributeRequest( + "corr-quality-2", "Pump1", "Label", DateTimeOffset.UtcNow)); + + var response = ExpectMsg(); + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + } }