-
Notifications
You must be signed in to change notification settings - Fork 0
P2: primary secondary measurement context workflow #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
1745e39
Add versioned SV measurement context document
masarray 4d3ace8
Fix measurement context validation label
masarray 6f64b75
Expose primary and secondary display domains in decoded values
masarray fa3089c
Apply explicit CT VT context to SV displays
masarray ad6357b
Add CT VT measurement context editor UI
masarray 505f3fd
Implement CT VT measurement context editor
masarray 1de5e6a
Wire measurement context editor and JSON workflow
masarray a611cbe
Persist stream measurement context across live refreshes
masarray 892ec9c
Add responsive CT VT context toolbar actions
masarray 545216b
Add measurement context document regression tests
masarray 0de7186
Show primary secondary domain in decoded scale evidence
masarray 2ace4ea
Document primary secondary measurement context workflow
masarray 549a7b2
Import xUnit attributes for measurement context tests
masarray File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| # SV measurement context | ||
|
|
||
| ArSubsv separates three different measurement concepts: | ||
|
|
||
| 1. the raw protocol count, | ||
| 2. the profile-scaled wire engineering value, | ||
| 3. a primary or secondary-equivalent display value produced from explicit CT/VT context. | ||
|
|
||
| The Subscriber never derives a transformer ratio from vendor identity, signal amplitude, APPID, MAC address, or `svID` naming. | ||
|
|
||
| ## User workflow | ||
|
|
||
| 1. Select a discovered SV stream. | ||
| 2. Open **CT/VT** from the responsive toolbar. | ||
| 3. Declare whether the scaled value on the wire is interpreted as `PrimaryEngineering` or `SecondaryEquivalent`. | ||
| 4. Choose the preferred display domain. | ||
| 5. Enter current and/or voltage primary and secondary nominal values. | ||
| 6. Record the evidence source and reference. | ||
| 7. Apply the context. | ||
|
|
||
| Waveform, decoded engineering values, RMS, and phasors use the preferred domain only when the relevant ratio is valid. A missing current ratio does not prevent a verified voltage ratio from being used, and vice versa. Channels without sufficient context stay in their wire engineering domain. | ||
|
|
||
| ## JSON evidence document | ||
|
|
||
| Use **Ctx Export** to save configured contexts. Use **Ctx Import** to restore them or apply them before the corresponding stream is discovered. | ||
|
|
||
| ```json | ||
| { | ||
| "schemaVersion": 1, | ||
| "exportedAt": "2026-07-22T08:30:00Z", | ||
| "streams": [ | ||
| { | ||
| "streamKey": "logical-stream-key", | ||
| "svId": "MU01", | ||
| "wireDomain": "PrimaryEngineering", | ||
| "displayDomain": "SecondaryEquivalent", | ||
| "currentRatio": { | ||
| "primaryNominal": 1000, | ||
| "secondaryNominal": 1, | ||
| "unit": "A", | ||
| "source": "DeviceConfiguration", | ||
| "reference": "SMU current input configuration" | ||
| }, | ||
| "voltageRatio": { | ||
| "primaryNominal": 20000, | ||
| "secondaryNominal": 100, | ||
| "unit": "V", | ||
| "source": "DeviceConfiguration", | ||
| "reference": "SMU voltage input configuration" | ||
| }, | ||
| "notes": "Matched to the sanitized SCL and known-injection PCAP" | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ## Evidence rules | ||
|
|
||
| - `Manual` means the values were entered by an engineer and should be checked against the test record. | ||
| - `Scl` means the ratio was obtained from an explicit, reviewable SCL source. | ||
| - `DeviceConfiguration` means the ratio was read from a device configuration or setting export. | ||
| - `DeviceValidated` means the context has been checked against real-device capture and known-injection evidence. | ||
| - Empty ratio fields are preferable to guessed values. | ||
| - Context conversion changes presentation; it does not alter the captured bytes or raw protocol values. | ||
|
|
||
| ## Validation boundary | ||
|
|
||
| The context workflow makes primary/secondary interpretation explicit and repeatable. It does not turn a Windows PC into a calibrated test set, and it does not replace device-specific injection validation. |
160 changes: 160 additions & 0 deletions
160
src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| using System.Text.Json; | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace AR.Iec61850.SampledValues.Measurements; | ||
|
|
||
| /// <summary> | ||
| /// User-supplied measurement-domain context for one logical SV stream. | ||
| /// The stream key is the evidence identity used by the Subscriber, not a vendor name. | ||
| /// </summary> | ||
| public sealed record SvStreamMeasurementContext | ||
| { | ||
| public string StreamKey { get; init; } = string.Empty; | ||
| public string SvId { get; init; } = string.Empty; | ||
| public SvMeasurementValueDomain WireDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; | ||
| public SvMeasurementValueDomain DisplayDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; | ||
| public SvMeasurementRatio? CurrentRatio { get; init; } | ||
| public SvMeasurementRatio? VoltageRatio { get; init; } | ||
| public string Notes { get; init; } = string.Empty; | ||
| public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; | ||
|
|
||
| public SvMeasurementRatio? ResolveRatio(string kindOrChannel) | ||
| { | ||
| var text = kindOrChannel?.Trim() ?? string.Empty; | ||
| if (text.Contains("voltage", StringComparison.OrdinalIgnoreCase) || | ||
| text.StartsWith("V", StringComparison.OrdinalIgnoreCase) || | ||
| text.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || | ||
| text.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) | ||
| return VoltageRatio; | ||
|
|
||
| if (text.Contains("current", StringComparison.OrdinalIgnoreCase) || | ||
| text.StartsWith("I", StringComparison.OrdinalIgnoreCase) || | ||
| text.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || | ||
| text.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) | ||
| return CurrentRatio; | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| public string Summary | ||
| { | ||
| get | ||
| { | ||
| var current = CurrentRatio?.IsValid == true | ||
| ? $"I {CurrentRatio.PrimaryNominal:0.###}/{CurrentRatio.SecondaryNominal:0.###} {CurrentRatio.Unit}" | ||
| : "I ratio unset"; | ||
| var voltage = VoltageRatio?.IsValid == true | ||
| ? $"V {VoltageRatio.PrimaryNominal:0.###}/{VoltageRatio.SecondaryNominal:0.###} {VoltageRatio.Unit}" | ||
| : "V ratio unset"; | ||
| return $"wire {WireDomain} → display {DisplayDomain} · {current} · {voltage}"; | ||
| } | ||
| } | ||
|
|
||
| public IReadOnlyList<string> Validate() | ||
| { | ||
| var errors = new List<string>(); | ||
| if (string.IsNullOrWhiteSpace(StreamKey)) | ||
| errors.Add("StreamKey is required."); | ||
| if (WireDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) | ||
| errors.Add("WireDomain must be PrimaryEngineering or SecondaryEquivalent."); | ||
| if (DisplayDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) | ||
| errors.Add("DisplayDomain must be PrimaryEngineering or SecondaryEquivalent."); | ||
|
|
||
| ValidateRatio(CurrentRatio, "current", "A", errors); | ||
| ValidateRatio(VoltageRatio, "voltage", "V", errors); | ||
| return errors; | ||
| } | ||
|
|
||
| private static void ValidateRatio( | ||
| SvMeasurementRatio? ratio, | ||
| string label, | ||
| string expectedUnit, | ||
| ICollection<string> errors) | ||
| { | ||
| if (ratio is null) | ||
| return; | ||
| if (!ratio.IsValid) | ||
| errors.Add($"The {label} ratio requires positive finite primary and secondary nominal values."); | ||
| if (!string.Equals(ratio.Unit, expectedUnit, StringComparison.OrdinalIgnoreCase)) | ||
| errors.Add($"The {label} ratio unit must be {expectedUnit}."); | ||
| if (ratio.Source == SvRatioSource.Unknown) | ||
| errors.Add($"The {label} ratio source must be explicit."); | ||
| } | ||
| } | ||
|
|
||
| public sealed record SvMeasurementContextDocument | ||
| { | ||
| public const int CurrentSchemaVersion = 1; | ||
|
|
||
| public int SchemaVersion { get; init; } = CurrentSchemaVersion; | ||
| public DateTimeOffset ExportedAt { get; init; } = DateTimeOffset.UtcNow; | ||
| public IReadOnlyList<SvStreamMeasurementContext> Streams { get; init; } | ||
| = Array.Empty<SvStreamMeasurementContext>(); | ||
|
|
||
| public IReadOnlyList<string> Validate() | ||
| { | ||
| var errors = new List<string>(); | ||
| if (SchemaVersion != CurrentSchemaVersion) | ||
| errors.Add($"Unsupported measurement-context schema version {SchemaVersion}; expected {CurrentSchemaVersion}."); | ||
|
|
||
| var duplicateKeys = Streams | ||
| .Where(item => !string.IsNullOrWhiteSpace(item.StreamKey)) | ||
| .GroupBy(item => item.StreamKey, StringComparer.Ordinal) | ||
| .Where(group => group.Count() > 1) | ||
| .Select(group => group.Key) | ||
| .ToArray(); | ||
| errors.AddRange(duplicateKeys.Select(key => $"Duplicate measurement context for stream key '{key}'.")); | ||
|
|
||
| foreach (var stream in Streams) | ||
| { | ||
| var label = string.IsNullOrWhiteSpace(stream.SvId) ? stream.StreamKey : stream.SvId; | ||
| errors.AddRange(stream.Validate().Select(error => $"{label}: {error}")); | ||
| } | ||
| return errors; | ||
| } | ||
| } | ||
|
|
||
| public static class SvMeasurementContextSerializer | ||
| { | ||
| private static readonly JsonSerializerOptions Options = new() | ||
| { | ||
| PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||
| PropertyNameCaseInsensitive = true, | ||
| WriteIndented = true, | ||
| Converters = { new JsonStringEnumConverter() } | ||
| }; | ||
|
|
||
| public static string ToJson(SvMeasurementContextDocument document) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(document); | ||
| EnsureValid(document); | ||
| return JsonSerializer.Serialize(document, Options); | ||
| } | ||
|
|
||
| public static SvMeasurementContextDocument FromJson(string json) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(json)) | ||
| throw new InvalidDataException("Measurement-context JSON is empty."); | ||
|
|
||
| SvMeasurementContextDocument document; | ||
| try | ||
| { | ||
| document = JsonSerializer.Deserialize<SvMeasurementContextDocument>(json, Options) | ||
| ?? throw new InvalidDataException("Measurement-context JSON did not contain a document."); | ||
| } | ||
| catch (JsonException ex) | ||
| { | ||
| throw new InvalidDataException($"Measurement-context JSON is invalid: {ex.Message}", ex); | ||
| } | ||
|
|
||
| EnsureValid(document); | ||
| return document; | ||
| } | ||
|
|
||
| private static void EnsureValid(SvMeasurementContextDocument document) | ||
| { | ||
| var errors = document.Validate(); | ||
| if (errors.Count > 0) | ||
| throw new InvalidDataException("Measurement-context validation failed: " + string.Join(" ", errors)); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A syntactically valid import such as
{"streams":null}deserializes withStreamsset to null, then this dereference throwsNullReferenceException.FromJsononly convertsJsonExceptiontoInvalidDataException, and the UI import handler catches only IO, authorization, and invalid-data exceptions, so importing this malformed-but-valid JSON can escape the handler rather than show the intended validation error.Useful? React with 👍 / 👎.