From c93d0997427ba87bb68f108a020d87850d6fdc5d Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Sun, 15 Nov 2026 04:04:34 -0500 Subject: [PATCH] #2658 DMR additional error detection and correction enhancements (#2659) Co-authored-by: denny --- .../decode/dmr/DMRMessageProcessor.java | 7 +- .../module/decode/dmr/message/voice/EMB.java | 108 +++++---- .../decode/dmr/bptc/BPTC_128_77SoftTest.java | 74 +----- .../decode/dmr/message/voice/EMBTest.java | 225 ++++++++++++++++++ 4 files changed, 289 insertions(+), 125 deletions(-) create mode 100644 src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/EMBTest.java diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java index 93d8ba68..e79a8df0 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2025 Dennis Sheirer + * Copyright (C) 2014-2026 Dennis Sheirer * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -181,7 +181,10 @@ public class DMRMessageProcessor implements Listener //Now that the message has been (potentially) enriched, dispatch it to the modules dispatch(message); - //Extract the Full Link Control message fragment from the Voice with embedded signalling message + //Extract the Full Link Control message fragment from the Voice with embedded signalling message. The LCSS is + //used even when the EMB is flagged invalid: the soft decision best estimate is usually correct and the + //assembled message is validated by its own BPTC and checksum, while discarding these fragments loses valid + //FLC messages on weak signals. if(message instanceof VoiceEMBMessage voice) { if(message.getTimeslot() == 1) diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java index 59fa9dd8..c85f9b0e 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2026 Dennis Sheirer * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,33 +22,25 @@ package io.github.dsheirer.module.decode.dmr.message.voice; import io.github.dsheirer.bits.CorrectedBinaryMessage; import io.github.dsheirer.module.decode.dmr.message.type.LCSS; import java.util.HashSet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * DMR Voice Frame B-F Embedded Signalling Chunk */ public class EMB { - private final static Logger mLog = LoggerFactory.getLogger(EMB.class); - private static final int[] VALID_WORDS = new int[]{ - 0x0000, 0x0273, 0x04E5, 0x0696, 0x09C9, 0x0BBA, 0x0D2C, 0x0F5F, 0x11E2, 0x1391, 0x1507, 0x1774, 0x182B, 0x1A58, - 0x1CCE, 0x1EBD, 0x21B7, 0x23C4, 0x2552, 0x2721, 0x287E, 0x2A0D, 0x2C9B, 0x2EE8, 0x3055, 0x3226, 0x34B0, 0x36C3, - 0x399C, 0x3BEF, 0x3D79, 0x3F0A, 0x411E, 0x436D, 0x45FB, 0x4788, 0x48D7, 0x4AA4, 0x4C32, 0x4E41, 0x50FC, 0x528F, - 0x5419, 0x566A, 0x5935, 0x5B46, 0x5DD0, 0x5FA3, 0x60A9, 0x62DA, 0x644C, 0x663F, 0x6960, 0x6B13, 0x6D85, 0x6FF6, - 0x714B, 0x7338, 0x75AE, 0x77DD, 0x7882, 0x7AF1, 0x7C67, 0x7E14, 0x804F, 0x823C, 0x84AA, 0x86D9, 0x8986, 0x8BF5, - 0x8D63, 0x8F10, 0x91AD, 0x93DE, 0x9548, 0x973B, 0x9864, 0x9A17, 0x9C81, 0x9EF2, 0xA1F8, 0xA38B, 0xA51D, 0xA76E, - 0xA831, 0xAA42, 0xACD4, 0xAEA7, 0xB01A, 0xB269, 0xB4FF, 0xB68C, 0xB9D3, 0xBBA0, 0xBD36, 0xBF45, 0xC151, 0xC322, - 0xC5B4, 0xC7C7, 0xC898, 0xCAEB, 0xCC7D, 0xCE0E, 0xD0B3, 0xD2C0, 0xD456, 0xD625, 0xD97A, 0xDB09, 0xDD9F, 0xDFEC, - 0xE0E6, 0xE295, 0xE403, 0xE670, 0xE92F, 0xEB5C, 0xEDCA, 0xEFB9, 0xF104, 0xF377, 0xF5E1, 0xF792, 0xF8CD, 0xFABE, - 0xFC28, 0xFE5B}; + //Quadratic Residue (17,9) generator polynomial g(x) = x^8 + x^5 + x^4 + x^3 + 1, shortened to (16,7) after + //appending an even parity bit. + private static final int GENERATOR = 0x139; + private static final int INFORMATION_WORD_COUNT = 128; + private static final int[] VALID_WORDS = new int[INFORMATION_WORD_COUNT]; private static final HashSet WORD_SET = new HashSet<>(); static { - for(int word: VALID_WORDS) + for(int information = 0; information < INFORMATION_WORD_COUNT; information++) { - WORD_SET.add(word); + VALID_WORDS[information] = encode(information); + WORD_SET.add(VALID_WORDS[information]); } } @@ -56,8 +48,9 @@ public class EMB private static final int[] COLOR_CODE = new int[]{0, 1, 2, 3}; private static final int ENCRYPTION_PI = 4; private static final int[] LINK_CONTROL_START_STOP = new int[]{5, 6}; - //Maximum bit errors between the hard decision and the soft decision (maximum likelihood) word for validity. - private static final int SOFT_DECISION_MAX_BIT_ERRORS = 2; + //Maximum correctable bit errors for a minimum Hamming distance 6 code: (6 - 1) / 2 = 2. Any decode that differs + //from the received word by more than this is ambiguous and is flagged invalid by both the hard and soft decoders. + private static final int MAX_CORRECTABLE_BIT_ERRORS = 2; private CorrectedBinaryMessage mMessage; private boolean mValid = true; @@ -93,9 +86,43 @@ public class EMB } } + /** + * Encodes the 7-bit information word (color code, privacy indicator and LCSS) into the 16-bit QR(16,7,6) + * codeword: 7 information bits, followed by the 8 bit remainder of the information word times x^8 divided by the + * generator polynomial, followed by an even parity bit over the preceding 15 bits. + * @param information word in the range 0-127 + * @return codeword + */ + static int encode(int information) + { + int remainder = information << 8; + + for(int bit = 14; bit >= 8; bit--) + { + if((remainder & (1 << bit)) != 0) + { + remainder ^= GENERATOR << (bit - 8); + } + } + + int word = (information << 8) | remainder; + return (word << 1) | (Integer.bitCount(word) & 1); + } + + /** + * Copy of the 128 valid codewords, indexed by information word. + */ + static int[] getValidWords() + { + return VALID_WORDS.clone(); + } + /** * Maximum likelihood soft decision decoding against all valid words. Selects the valid word with the lowest sum - * of soft bit magnitudes for the bit positions where the valid word differs from the hard decision. + * of soft bit magnitudes for the bit positions where the valid word differs from the hard decision. The message + * is always set to the selected word, but it is only flagged valid when the selected word is within the + * correctable bit error count. Beyond that count, the selected word is still the best estimate available and is + * a better source for the LCSS used to assemble the embedded full link control fragments than the received bits. * @param softBits for the 16 transmitted bits. */ private void decodeSoft(float[] softBits) @@ -132,18 +159,20 @@ public class EMB getMessage().setCorrectedBitCount(bitErrors); } - mValid = bitErrors <= SOFT_DECISION_MAX_BIT_ERRORS; + mValid = bitErrors <= MAX_CORRECTABLE_BIT_ERRORS; } /** * Checks the EMB word using the Quadratic Residue(16,7,6) code as detailed in TS 102 361-1 B.3.2. * * Implementation Notes: - * There are 7 information bits protected by 9 parity bits with a Hamming distance of 6 meaning that this code can - * correct up to 3 bit errors. The VALID_WORDS constant is a list of all possible (128) valid words precalculated - * with their corresponding QR checksum values. When performing a CRC check, if the complete 16-bit value matches - * any of these valid words, then the value has no errors. Otherwise, XOR the received value against all valid - * words and select any valid word that differs from the received word by no more than 3 bit position differences. + * There are 7 information bits protected by 9 parity bits with a minimum Hamming distance of 6 meaning that this + * code can correct up to 2 bit errors and detect 3 bit errors. The VALID_WORDS constant is a list of all + * possible (128) valid words. If the complete 16-bit value matches any of these valid words, then the value has + * no errors. Otherwise, select the valid word that differs from the received word by no more than 2 bit + * positions. The distance 6 guarantees that there is at most one such word. When there is no such word, the + * message is left unchanged and flagged invalid. Unlike the soft decoder, there is no unique best estimate at 3 + * or more bit errors, and the received bits are as likely to hold the correct LCSS as the nearest valid word. */ private void checkCRC() { @@ -155,17 +184,15 @@ public class EMB return; } - int bitErrors = 0; - for(int validWord: VALID_WORDS) { - bitErrors = Integer.bitCount(word ^ validWord); + int bitErrors = Integer.bitCount(word ^ validWord); - if(bitErrors <= 3) + if(bitErrors <= MAX_CORRECTABLE_BIT_ERRORS) { getMessage().setInt(validWord, WORD); getMessage().setCorrectedBitCount(bitErrors); - mValid = bitErrors <= 1; //Only accept up to 1 bit error for validity + mValid = true; return; } } @@ -223,23 +250,4 @@ public class EMB sb.append("CC:").append(getColorCode()); return sb.toString(); } - - public static void main(String[] args) - { - CorrectedBinaryMessage m = new CorrectedBinaryMessage(16); - - for(int x = 0; x < Math.pow(2, 16); x++) - { - m.setInt(x, WORD); - - EMB emb = new EMB(m); - - if(!emb.isValid()) - { - mLog.debug("Invalid: " + x); - } - } - - - } } diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java index 6255b75b..dc4922fd 100644 --- a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java +++ b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java @@ -23,23 +23,20 @@ import io.github.dsheirer.bits.BinaryMessage; import io.github.dsheirer.bits.CorrectedBinaryMessage; import io.github.dsheirer.edac.Hamming16; import io.github.dsheirer.edac.soft.SoftBlockCode; -import io.github.dsheirer.module.decode.dmr.message.voice.EMB; import java.util.HashSet; import java.util.Random; import java.util.Set; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * JUnit tests for DMR soft decision decoding of the embedded signalling BPTC(128,77) product code and the EMB. + * JUnit tests for DMR soft decision decoding of the embedded signalling BPTC(128,77) product code. */ public class BPTC_128_77SoftTest { - private static final int[] WORD = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; private static final SoftBlockCode ROW_CODE = SoftBlockCode.fromCodewordTest(16, word -> { BinaryMessage message = new BinaryMessage(16); @@ -166,73 +163,4 @@ public class BPTC_128_77SoftTest //Sanity check that this scenario exceeds the hard decision decoder capability assertTrue(hardFailures > 0, "Expected hard decision failures"); } - - /** - * Test: EMB soft decoding corrects 4 low reliability bit errors (beyond the hard decoder's 3-bit capability) and - * flags the result invalid since it exceeds the soft decision validity threshold. Also verifies that a single - * low reliability bit error is corrected and valid. - */ - @Test - void testEMBSoftDecode() - { - Random random = new Random(3); - CorrectedBinaryMessage reference = new CorrectedBinaryMessage(16); - - for(int trial = 0; trial < 100; trial++) - { - //Find a valid word by hard decoding a random word with no more than 1 bit error after correction - int word; - EMB referenceEMB; - - do - { - word = random.nextInt(1 << 16); - reference.setInt(word, WORD); - referenceEMB = new EMB(new CorrectedBinaryMessage(reference)); - } - while(!referenceEMB.isValid()); - - //Recover the corrected valid word - CorrectedBinaryMessage corrected = new CorrectedBinaryMessage(reference); - new EMB(corrected); - int validWord = corrected.getInt(0, 15); - - for(int errorCount: new int[]{1, 4}) - { - float[] softBits = new float[16]; - CorrectedBinaryMessage received = new CorrectedBinaryMessage(16); - received.setInt(validWord, WORD); - - for(int x = 0; x < 16; x++) - { - softBits[x] = received.get(x) ? 1.0f : -1.0f; - } - - Set errors = new HashSet<>(); - - while(errors.size() < errorCount) - { - errors.add(random.nextInt(16)); - } - - for(int error: errors) - { - received.flip(error); - softBits[error] = -0.2f * softBits[error]; - } - - EMB emb = new EMB(received, softBits); - assertEquals(validWord, received.getInt(0, 15), "Trial " + trial + " errors " + errorCount); - - if(errorCount == 1) - { - assertTrue(emb.isValid()); - } - else - { - assertFalse(emb.isValid()); - } - } - } - } } diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/EMBTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/EMBTest.java new file mode 100644 index 00000000..28f53108 --- /dev/null +++ b/src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/EMBTest.java @@ -0,0 +1,225 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.module.decode.dmr.message.voice; + +import io.github.dsheirer.bits.CorrectedBinaryMessage; +import io.github.dsheirer.module.decode.dmr.message.type.LCSS; +import java.util.HashSet; +import java.util.Random; +import java.util.Set; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * JUnit tests for the DMR EMB QR(16,7,6) codeword table and the hard and soft decision decoders. + */ +public class EMBTest +{ + private static final int[] WORD = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + //Codewords for the 7 single-bit information words (1, 2, 4, ... 64) from the TS 102 361-1 B.3.2 codeword table. + //Because the code is linear, these generator rows determine every other codeword. + private static final int[] GENERATOR_ROWS = new int[]{0x0273, 0x04E5, 0x09C9, 0x11E2, 0x21B7, 0x411E, 0x804F}; + + /** + * Creates an EMB message holding the 16-bit word. + */ + private static CorrectedBinaryMessage message(int word) + { + CorrectedBinaryMessage message = new CorrectedBinaryMessage(16); + message.setInt(word, WORD); + return message; + } + + /** + * Asserts that the EMB fields decode to the information word (color code, privacy indicator, LCSS). + */ + private static void assertFields(int information, EMB emb, String context) + { + assertEquals(information >> 3, emb.getColorCode(), context); + assertEquals(((information >> 2) & 1) == 1, emb.isEncrypted(), context); + assertEquals(LCSS.fromValue(information & 3), emb.getLCSS(), context); + } + + /** + * Test: the codeword table is the systematic, linear QR(16,7,6) code from the specification with minimum distance + * 6, so that every 1 and 2 bit error is uniquely correctable. + */ + @Test + void testCodewordTable() + { + int[] words = EMB.getValidWords(); + assertEquals(128, words.length); + + for(int bit = 0; bit < 7; bit++) + { + assertEquals(GENERATOR_ROWS[bit], words[1 << bit], "Generator row for information bit " + bit); + } + + int minimumDistance = Integer.MAX_VALUE; + + for(int a = 0; a < 128; a++) + { + assertEquals(a, words[a] >> 9, "Information bits are the leading 7 bits"); + + for(int b = 0; b < 128; b++) + { + assertEquals(words[a ^ b], words[a] ^ words[b], "Linearity " + a + " ^ " + b); + + if(a != b) + { + minimumDistance = Math.min(minimumDistance, Integer.bitCount(words[a] ^ words[b])); + } + } + } + + assertEquals(6, minimumDistance); + } + + /** + * Test: the hard decoder accepts every codeword and corrects every 1 and 2 bit error pattern on every codeword + * back to that codeword, with the corrected fields (including color codes 8-15). + */ + @Test + void testHardDecodeCorrectsUpToTwoBitErrors() + { + int[] words = EMB.getValidWords(); + + for(int information = 0; information < 128; information++) + { + int codeword = words[information]; + + EMB clean = new EMB(message(codeword)); + assertTrue(clean.isValid(), "Codeword " + information); + assertFields(information, clean, "Codeword " + information); + + for(int first = 0; first < 16; first++) + { + for(int second = first; second < 16; second++) + { + int errors = (1 << first) | (1 << second); + String context = "Codeword " + information + " errors " + Integer.toHexString(errors); + CorrectedBinaryMessage received = message(codeword ^ errors); + EMB emb = new EMB(received); + assertTrue(emb.isValid(), context); + assertEquals(codeword, received.getInt(WORD), context); + assertEquals(Integer.bitCount(errors), received.getCorrectedBitCount(), context); + assertFields(information, emb, context); + } + } + } + } + + /** + * Test: the hard decoder rejects every 3 bit error pattern on every codeword, which is beyond the correction + * capability of a distance 6 code, and leaves the received bits unchanged rather than miscorrecting them. + */ + @Test + void testHardDecodeRejectsThreeBitErrors() + { + int[] words = EMB.getValidWords(); + + for(int codeword: words) + { + for(int a = 0; a < 16; a++) + { + for(int b = a + 1; b < 16; b++) + { + for(int c = b + 1; c < 16; c++) + { + int receivedWord = codeword ^ (1 << a) ^ (1 << b) ^ (1 << c); + CorrectedBinaryMessage received = message(receivedWord); + EMB emb = new EMB(received); + String context = "Received " + Integer.toHexString(receivedWord); + assertFalse(emb.isValid(), context); + assertEquals(receivedWord, received.getInt(WORD), context); + assertEquals(0, received.getCorrectedBitCount(), context); + } + } + } + } + } + + /** + * Test: soft decoding corrects 1 and 2 low reliability bit errors and flags the result valid. With 4 low + * reliability bit errors, it still corrects to the transmitted word (best estimate, beyond the hard decoder's + * capability) but flags the result invalid since it exceeds the correction capability. + */ + @Test + void testSoftDecode() + { + int[] words = EMB.getValidWords(); + Random random = new Random(3); + + for(int trial = 0; trial < 1000; trial++) + { + int information = random.nextInt(128); + int codeword = words[information]; + + for(int errorCount: new int[]{1, 2, 4}) + { + CorrectedBinaryMessage received = message(codeword); + float[] softBits = new float[16]; + + for(int x = 0; x < 16; x++) + { + softBits[x] = received.get(x) ? 1.0f : -1.0f; + } + + Set errors = new HashSet<>(); + + while(errors.size() < errorCount) + { + errors.add(random.nextInt(16)); + } + + for(int error: errors) + { + received.flip(error); + softBits[error] = -0.2f * softBits[error]; + } + + EMB emb = new EMB(received, softBits); + String context = "Trial " + trial + " errors " + errorCount; + assertEquals(errorCount <= 2, emb.isValid(), context); + assertEquals(codeword, received.getInt(WORD), context); + assertEquals(errorCount, received.getCorrectedBitCount(), context); + assertFields(information, emb, context); + } + } + } + + /** + * Test: without soft bits, the soft decision constructor falls back to the hard decoder. + */ + @Test + void testNullSoftBitsUsesHardDecoder() + { + int codeword = EMB.getValidWords()[93]; + CorrectedBinaryMessage received = message(codeword ^ 0x0101); + EMB emb = new EMB(received, null); + assertTrue(emb.isValid()); + assertEquals(codeword, received.getInt(WORD)); + assertEquals(2, received.getCorrectedBitCount()); + assertFields(93, emb, "Null soft bits"); + } +}