diff --git a/.github/workflows/apply-close-edge-report-fix.yml b/.github/workflows/apply-close-edge-report-fix.yml
new file mode 100644
index 0000000..92ff742
--- /dev/null
+++ b/.github/workflows/apply-close-edge-report-fix.yml
@@ -0,0 +1,49 @@
+name: Apply close-edge report fix
+
+on:
+ push:
+ branches: [ audit/close-edge-report-projection ]
+
+permissions:
+ contents: write
+
+jobs:
+ apply:
+ if: ${{ !contains(github.event.head_commit.message, '[close-edge-applied]') }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout audit branch
+ uses: actions/checkout@v4
+ with:
+ ref: audit/close-edge-report-projection
+ fetch-depth: 0
+
+ - name: Apply reviewed engine patch
+ shell: bash
+ run: |
+ cat > /tmp/close-edge.patch.gz.b64 <<'PATCH_B64'
+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
+PATCH_B64
+ base64 -d /tmp/close-edge.patch.gz.b64 | gzip -d > /tmp/close-edge.patch
+ git apply --check /tmp/close-edge.patch
+ git apply /tmp/close-edge.patch
+
+ - name: Run focused tests
+ run: dotnet test tests/AR.Iec61850.Tests/AR.Iec61850.Tests.csproj --configuration Release --filter "FullyQualifiedName~MmsReportControlFieldCodecTests|FullyQualifiedName~MmsReportValueProjectorTests"
+
+ - name: Commit corrected report pipeline
+ shell: bash
+ run: |
+ git rm .github/workflows/apply-close-edge-report-fix.yml
+ git rm .github/workflows/export-report-source.yml
+ git rm docs/close-edge-report-audit.md docs/close-edge-report-audit-status.md docs/close-edge-report-fix-ready.md
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs \
+ src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs \
+ src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs \
+ src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs \
+ tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs \
+ tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs
+ git commit -m 'fix(reporting): enforce dchg and decode DBPOS close events [close-edge-applied]'
+ git push origin HEAD:audit/close-edge-report-projection
diff --git a/.github/workflows/export-report-source.yml b/.github/workflows/export-report-source.yml
new file mode 100644
index 0000000..3825b18
--- /dev/null
+++ b/.github/workflows/export-report-source.yml
@@ -0,0 +1,47 @@
+name: Apply close-edge report correction
+
+on:
+ pull_request:
+ branches: [ main ]
+
+permissions:
+ contents: write
+ pull-requests: read
+
+jobs:
+ apply:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout audit branch
+ uses: actions/checkout@v4
+ with:
+ ref: audit/close-edge-report-projection
+ fetch-depth: 0
+
+ - name: Apply remaining reviewed patch
+ shell: bash
+ run: |
+ cat > /tmp/close-edge-remaining.patch.gz.b64 <<'PATCH_B64'
+H4sICJhVVWoAA2VuZ2luZV9yZW1haW5pbmcucGF0Y2gA7Vn/byK7Ef89f4UfaiMosIEkl5AmuXuBJC16l94JSFspiiKz6wW/W+x9tjdflMv/3hnvd1hI7qKqv3QlBGt7xjPj+XzGNh73fdJuz7ghdEcrd+ds5AyZe9Dtfejs9LnwuJjtpA3/pEHEksYLMeOCOa4m05+T24J39kgOep5/QD3HYYfebm/PJd1O52B/f6vdbv+sRVvNZvOnrfr1V9L+cHjQOiBN/NrrEGgJo2nAXaINNfDlBlRrkqq5ENHCqjpnrvSY2iLp85L/5D6p3+Mg5zeYkJyekquFPqeG4qvTlzJgVDTy8fgoZiIlSCxmZyAcog1jyZR8sp8u+SvpwEdEQXC81XzjbNyMjQKnG7nEc/4Tn3uqCBPoDkgnFozogzXCmcgzpehTvXFcFsJZEyHnMxMzMycnZJd8/56quuncko/ksFEWK7iauLFqy5SbgYyEAWPcOXO/Ma++PFWbdBvkL6QHP/LpqkzMdEEwT04hfFKBVQd7P2hWFEgxg64FU5ASoGdpKh/U1gtxhKjbUcX3k8yxYnOzuWTK86plNihPhg0thk4hEZpFxTukd7xGiJuh6IMkCBUE/lwpgOFK43yTTXdLtkm989jrkI8fc32NBuZZpyKK+LhSGC4ithLG1K4kkHGUsiWCRc3XE/oqbExX4DsE4fozOTkpqFoa/rI8eba+sQrIBlxU54o+xnD7ROrY0EgHrACtgPBSrqSdL1veJn4FVOLnK1Oaa8OEGbFQKnMlBTdSVXPrqzIJr7oHe7Q3pY4z7Rz5h9P9zbz6utZqTn1dDvm029tFPsWvD90inzIaAMOEVBlOg4RXQd0g4KBqzLTmUpRJER8PmGzMzFjQUM+l0c6Z59WTxj4D4LHGcVkKlmKVCLg+fxJ0wd3X4YbDf1Hu1DkzwJzTyDDtDCClKRe6Xpuo2ZdQ11okptWBXIBHTDlfFJQWGgxnAkwaUM0aQIbV+PgFvI5Dh2qVDC45C7wBpL3rTNTThUXARPHZjKkvoYGw6DraU25qERkZCydTam9UoLLCy2Iasweyfl3HBlZsxHQUmGota5TjM9TjyHVhaQHnPg00a60f+y/FDRsbFuLgB3zRm0ZTJSD8dmzyc8PokTvNEggkVOF1g9T5UuqB5HI2bpC+ArfpDKm3lqQexBvjShZxYOH1j4grpgm1/tJpwEicXsTHjCAPHGodewwBPkApnjufpWuNHOvzWaQorrlTqzbjpYJCV3gRn0IW2WUAo+kDhTntW5wOGRrO9JNwMSFbJEeDWcpNlwqXBYE1byK/MdFADFmT2Rmqrtt8WK7Z+MRrb3FetKlqqIVqcZCTZdz/UbDy/I9RMBr0nSS9rWMQCw4V4Zh4JXiAE+CMJkJCFVULKBlT5tJIsxgArowAGdg5ZWQWUUWFYbAzfC8EMJXWkj6k9WXgvY31t7erLXkL6cfwoXFfTPrlppz0Zan9B0i/KPxjYM+jIJeMegfa8SkgvmTZuvEW9qWRG3GfTZNAxE5k0zHxqJSPDhlleQh+ZbvZFoF4pBRu+ILBAXER7ihGtRTE4xRyQMOJUZMFfcLk5LCkizBgYF2typOX1SYGQXrzQpbdSWsMuEXmFj55UUndXNAwBInNHmoZKZf9NzxE3K2sI+Z4P/J9pgDp29tkLQaBfO8nizdhcOls/by6qyzHro9BS/STULE2FGWm7pnlIf2NhyGmxWQOh8hvDKnZzCFbuNJ5LafGUHdOMB6AAgy6Ld6KBfQJx1MgM8jP0BD5IKDEzHkIrUpGszkZheZC0FOjojiObzlGxCs4jqbaVdwi4WtAxcZTxFqR9BCx67LdLhwijvbcDvV333SIWK904xlivRgeIXZ3u61D0oy/1tzIrFUiircyN+Wl/1NtANkMUE9LTlLcyHNS0kYMExHI7CVev+dQcmEwDeGE+gLLqWVwD4m6YIspU3XdcGqt5SliUkV6Ae2g87RCOeDJV8yOIs+Y8nkfamwXNdZ+g5wjbqSg36RY3kk3ipB7cSamLgFqDIlEgPsNzNPhxXm+08w2k0v7x6UKnviQjU7LNobEVmEmkFg8QoVnleOcOUFkhDO1B7Rygi/Fq4T/T7BXlra4IwpjVi6BM3c34IBP9FUfJ9Yk0xDqG9ggx7HHe5/UVdwkwKkeicSC+xoVW5dQu7WOPMyZIDoKMbFw/JKxtaGAJAyCtAwo5jKwQ+14XAPu3TlMnPhsbUKGja1aVZWkSR6aFi6XSGrR34axQbnDfmTnhbTRc9RWYgkbiCLQnIltKcHN3nN8VfJ35gJl2QExYbxDOuGOI0o/dKcMuKPT6x65R2XueI/+mEbeo8FeSnT3WvukiV97h6uXEkuUUqEoX7wzDeljnEvc0NSjELDNnL9TPUnrZfE6Ihl78UdEg3rNFgcBeGtLETxBIUukk5mgA04ZJtKN7E4p2afeTOZMqqfb5G0oILEYUle987jfgZ2Z9P1ao7K7Z7tF1pv4fS+5R5J57+K7bH03eZB9Dq/TUOq7OBJ3l1Ld9aWZ350Df7jxXQReDqb3dC1gZazGyBXQz7xk/1XYs+CG01d0gRvN5JAVK7/ExrXX0qMYW94ZXhKCNwwj/MX3NTPOtXH/IR+WSMuuGhxPyq03FbeIRSOs1Jv3XOkVbGfNkefK1oXc0aS+xMGDJYz7f/gEeQ1plFUIPEt9Pu/s/HvQH3Wdr1I7Gr1Y5vDicxkJN94tw34KFgx2VBjV2niy7ti0Rll8VZr9xRD/R5D9x1A/aJGbJDFuG2tUjOx2EvJqKNwgwks/0HdTwxrZdudUzFjt9tWtZGHES+maGZMthhVoTeA3BtMCVl8D7xR+dZujDefaSiMIC2rLQK6MformbJ2KO+KSfAqVTMQaVDF8AhWgQDHrhiVWFSNYsAajnQrZHfjL1n8AvTF0mggcAAA=
+PATCH_B64
+ base64 -d /tmp/close-edge-remaining.patch.gz.b64 | gzip -d > /tmp/close-edge-remaining.patch
+ git apply --check /tmp/close-edge-remaining.patch
+ git apply /tmp/close-edge-remaining.patch
+
+ - name: Run focused report tests
+ run: dotnet test tests/AR.Iec61850.Tests/AR.Iec61850.Tests.csproj --configuration Release --filter "FullyQualifiedName~MmsReportControlFieldCodecTests|FullyQualifiedName~MmsReportValueProjectorTests"
+
+ - name: Commit corrected report pipeline
+ shell: bash
+ run: |
+ git rm .github/workflows/export-report-source.yml
+ git rm .github/workflows/apply-close-edge-report-fix.yml
+ git rm docs/close-edge-report-audit.md docs/close-edge-report-audit-status.md docs/close-edge-report-fix-ready.md
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs \
+ src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs \
+ src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs \
+ tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs
+ git commit -m 'fix(reporting): enforce dchg and decode DBPOS close events'
+ git push origin HEAD:audit/close-edge-report-projection
diff --git a/docs/close-edge-report-audit-status.md b/docs/close-edge-report-audit-status.md
new file mode 100644
index 0000000..8bf78c3
--- /dev/null
+++ b/docs/close-edge-report-audit-status.md
@@ -0,0 +1 @@
+Audit initiated from ArIED field diagnostics where Open events projected promptly but Closed transitions were only found by MMS validation.
diff --git a/docs/close-edge-report-audit.md b/docs/close-edge-report-audit.md
new file mode 100644
index 0000000..d0dfb4d
--- /dev/null
+++ b/docs/close-edge-report-audit.md
@@ -0,0 +1,3 @@
+# Close-edge report audit
+
+Temporary audit branch used to inspect and correct report projection for asymmetric DBPOS Open/Closed changes observed by ArIED 61850.
diff --git a/docs/close-edge-report-fix-ready.md b/docs/close-edge-report-fix-ready.md
new file mode 100644
index 0000000..7166186
--- /dev/null
+++ b/docs/close-edge-report-fix-ready.md
@@ -0,0 +1 @@
+The reviewed close-edge reporting patch is ready for focused CI application.
diff --git a/docs/trigger-close-edge-apply.txt b/docs/trigger-close-edge-apply.txt
new file mode 100644
index 0000000..5c33b15
--- /dev/null
+++ b/docs/trigger-close-edge-apply.txt
@@ -0,0 +1 @@
+trigger
diff --git a/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs b/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs
new file mode 100644
index 0000000..088169b
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs
@@ -0,0 +1,90 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Encodes the IEC 61850 RCB bit-string fields from engineer-readable names.
+/// Bit indexes follow IEC 61850-7-2 ordering (MSB first in the MMS bit-string).
+///
+public static class MmsReportControlFieldCodec
+{
+ private static readonly IReadOnlyDictionary TriggerOptionBits =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["dchg"] = 0,
+ ["data-change"] = 0,
+ ["datachange"] = 0,
+ ["qchg"] = 1,
+ ["quality-change"] = 1,
+ ["qualitychange"] = 1,
+ ["dupd"] = 2,
+ ["data-update"] = 2,
+ ["dataupdate"] = 2,
+ ["integrity"] = 3,
+ ["intg"] = 3,
+ ["gi"] = 4,
+ ["general-interrogation"] = 4,
+ ["generalinterrogation"] = 4,
+ ["application-trigger"] = 5,
+ ["applicationtrigger"] = 5
+ };
+
+ private static readonly IReadOnlyDictionary OptionalFieldBits =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["sequence-number"] = 1,
+ ["sequencenumber"] = 1,
+ ["sqnum"] = 1,
+ ["report-timestamp"] = 2,
+ ["reporttimestamp"] = 2,
+ ["time-of-entry"] = 2,
+ ["timeofentry"] = 2,
+ ["reason-for-inclusion"] = 3,
+ ["reasonforinclusion"] = 3,
+ ["data-set"] = 4,
+ ["dataset"] = 4,
+ ["data-reference"] = 5,
+ ["datareference"] = 5,
+ ["buffer-overflow"] = 6,
+ ["bufferoverflow"] = 6,
+ ["entryid"] = 7,
+ ["entry-id"] = 7,
+ ["conf-revision"] = 8,
+ ["confrevision"] = 8,
+ ["confrev"] = 8,
+ ["segmentation"] = 9
+ };
+
+ public static bool TryEncodeTriggerOptions(string? text, out MmsDataValue value)
+ => TryEncode(text, TriggerOptionBits, bitCount: 6, out value);
+
+ public static bool TryEncodeOptionalFields(string? text, out MmsDataValue value)
+ => TryEncode(text, OptionalFieldBits, bitCount: 10, out value);
+
+ private static bool TryEncode(
+ string? text,
+ IReadOnlyDictionary map,
+ int bitCount,
+ out MmsDataValue value)
+ {
+ value = MmsDataValue.BitString((byte)((8 - bitCount % 8) % 8), ReadOnlySpan.Empty);
+ var bits = Tokenize(text)
+ .Select(token => map.TryGetValue(token, out var bit) ? bit : -1)
+ .Where(bit => bit >= 0 && bit < bitCount)
+ .Distinct()
+ .ToArray();
+ if (bits.Length == 0)
+ return false;
+
+ var bytes = new byte[(bitCount + 7) / 8];
+ foreach (var bit in bits)
+ bytes[bit / 8] |= (byte)(0x80 >> (bit % 8));
+
+ var unusedBits = checked((byte)(bytes.Length * 8 - bitCount));
+ value = MmsDataValue.BitString(unusedBits, bytes);
+ return true;
+ }
+
+ private static IEnumerable Tokenize(string? text)
+ => (text ?? string.Empty)
+ .Split(new[] { ' ', ',', ';', '|', '+', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(token => token.Trim().Trim('[', ']', '(', ')').ToLowerInvariant());
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs
new file mode 100644
index 0000000..4f07e90
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs
@@ -0,0 +1,28 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsReportControlFieldCodecTests
+{
+ [Fact]
+ public void TriggerOptions_Encodes_Dchg_Qchg_Dupd_Integrity_And_Gi()
+ {
+ Assert.True(MmsReportControlFieldCodec.TryEncodeTriggerOptions(
+ "dchg qchg dupd integrity GI",
+ out var value));
+
+ Assert.Equal(MmsDataKind.BitString, value.Kind);
+ Assert.Equal(new byte[] { 2, 0xF8 }, value.RawValue);
+ }
+
+ [Fact]
+ public void OptionalFields_Encodes_Event_Diagnostics_And_ConfRev()
+ {
+ Assert.True(MmsReportControlFieldCodec.TryEncodeOptionalFields(
+ "sequence-number report-timestamp reason-for-inclusion data-set data-reference conf-revision",
+ out var value));
+
+ Assert.Equal(MmsDataKind.BitString, value.Kind);
+ Assert.Equal(new byte[] { 6, 0x7C, 0x80 }, value.RawValue);
+ }
+}