testFlips = new ArrayList<>();
-
- for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount;
- column = columns.nextSetBit(column + 1))
- {
- int toFlip = getIndex(column, row);
- message.flip(toFlip);
- testFlips.add(toFlip);
- }
-
- if(!isRowCorrect(row, message))
- {
- int index = getRowErrorIndex(row, message);
-
- if(index >= 0 && index < IHamming.MULTIPLE_ERRORS)
- {
- message.flip(index);
-
- if(testFlips.contains(index))
- {
- testFlips.removeIf(value -> value == index);
- }
- else
- {
- testFlips.add(index);
- }
- }
- }
-
- if(isRowCorrect(row, message))
- {
- //Check the columns also
-
- boolean columnsCorrect = true;
-
- for(Integer flip: testFlips)
- {
- columnsCorrect &= isColumnCorrect(getColumn(flip), message);
- }
-
- if(columnsCorrect)
- {
- rows.clear(row);
-
- for(Integer flip: testFlips)
- {
- columns.clear(getColumn(flip));
- }
-
- return testFlips;
- }
- }
-
- for(Integer flip: testFlips)
- {
- message.flip(flip);
- }
-
- return Collections.emptyList();
- }
-
public void logErrorMap(CorrectedBinaryMessage message)
{
StringBuilder sb = new StringBuilder();
@@ -456,15 +323,10 @@ public abstract class BPTCBase
int offset = row * mColumnCount;
sb.append(" Row ").append(row).append(": ");
sb.append(message.getSubMessage(offset, offset + mColumnCount));
- int index = getRowErrorIndex(row, message);
- if(index != IHamming.NO_ERRORS)
+ int syndrome = getRowSyndrome(row, message);
+ if(syndrome != 0)
{
- sb.append(" < Index:").append(index);
-
- if(index == IHamming.MULTIPLE_ERRORS)
- {
- sb.append(" (2+ bit errors)");
- }
+ sb.append(" < Syndrome:0x").append(Integer.toHexString(syndrome).toUpperCase());
}
sb.append("\n");
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java
index 890efcf4..4d5cac99 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java
@@ -22,6 +22,7 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming16;
+import io.github.dsheirer.edac.ProductCodeDecoder;
import io.github.dsheirer.edac.soft.SoftBlockCode;
import io.github.dsheirer.edac.soft.SoftProductCodeDecoder;
@@ -39,10 +40,12 @@ public class BPTC_128_77 extends BPTCBase
//require correcting high reliability bits are rejected to avoid false decodes. Noise injection testing: all
//soft decisions with a cost up to 3.0 were correct, whereas most of those with a higher cost were false decodes.
private static final float SOFT_MAX_CORRECTION_COST = 3.0f;
- //Maximum corrected bit count for a hard decision result. Hard decisions that correct more bits are treated as
- //uncorrectable to avoid false decodes, given the weak 5-bit checksum. Noise injection testing: all hard
- //decisions with more than 6 corrected bits were false decodes.
- private static final int HARD_MAX_CORRECTED_BITS = 6;
+ //Maximum number of bit errors corrected by the hard decision minimum distance decoder. The product code minimum
+ //distance of 8 guarantees correction of 3 bit errors. Correcting more increases the miscorrection rate, and the
+ //embedded link control is only protected by a 5-bit checksum, with soft decision decoding only applied when hard
+ //decision decoding fails. AWGN simulation of the hard + soft decoding sequence: a maximum of 5 decoded more
+ //messages than the previous hard decision decoder at each noise level, with 3-5x fewer false decodes.
+ private static final int HARD_MAXIMUM_ERRORS = 5;
//Soft decision decoder for the Hamming(16,11,4) rows and even parity columns
private static final SoftProductCodeDecoder SOFT_DECODER = new SoftProductCodeDecoder(
SoftBlockCode.fromCodewordTest(COLUMN_COUNT, word -> Hamming16.getSyndrome(toMessage(word), 0) == 0),
@@ -68,16 +71,9 @@ public class BPTC_128_77 extends BPTCBase
public BPTC_128_77()
{
- super(new Hamming16(), 16, 8);
- }
-
- /**
- * Allow the base class to attempt to correct multiple rows of 2-bit errors each
- */
- @Override
- protected boolean canCorrectMultiRow2BitErrors()
- {
- return true;
+ //No row erasure fallback, given the weak 5-bit checksum. Recording testing: no messages were lost without it.
+ super(ProductCodeDecoder.getParityChecks(COLUMN_COUNT, message -> Hamming16.getSyndrome(message, 0)), ROW_COUNT,
+ HARD_MAXIMUM_ERRORS, 0);
}
/**
@@ -91,9 +87,8 @@ public class BPTC_128_77 extends BPTCBase
}
/**
- * Performs error detection and correction and extracts the payload from the BPTC encoded message. Hard decision
- * results that exceed the maximum corrected bit count are treated as uncorrectable. When the hard decision
- * decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding.
+ * Performs error detection and correction and extracts the payload from the BPTC encoded message. When the hard
+ * decision decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding.
* @param message with BPTC encoding.
* @param softBits for the 128 (interleaved) bits of the message where a positive value favors a 1 bit, or null.
* @return decoded message.
@@ -103,11 +98,6 @@ public class BPTC_128_77 extends BPTCBase
CorrectedBinaryMessage deinterleaved = deinterleave(message);
correct(deinterleaved);
- if(deinterleaved.getCorrectedBitCount() > HARD_MAX_CORRECTED_BITS)
- {
- deinterleaved.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
- }
-
if(softBits != null && softBits.length == LENGTH && deinterleaved.getCorrectedBitCount() < 0)
{
CorrectedBinaryMessage softDecoded = decodeSoft(deinterleave(softBits));
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java
index 3bae9c2d..d47402cc 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java
@@ -22,9 +22,14 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming16;
+import io.github.dsheirer.edac.ProductCodeDecoder;
/**
- * Block Product Turbo Code 16/2 for decoding a DMR Voice Frame F payload from the 32-bit EMB field.
+ * Block Product Turbo Code 16/2 for decoding a DMR Voice Frame F payload from the 32-bit EMB field. Each of the two
+ * rows is the same Hamming(16,11,4) codeword, where the second row is inverted (odd column parity) for the reverse
+ * channel, forming a code with a minimum distance of 8.
+ *
+ * Uses hard decision minimum distance decoding across both rows, which corrects up to 3 bit errors.
*
* See ETSI TS 102 361-1 B.2.2.1 and B.2.2.2
*/
@@ -32,63 +37,116 @@ public class BPTC_16_2
{
private static final int[] DEINTERLEAVE = new int[]{0, 24, 1, 25, 2, 26, 3, 27, 4, 28, 5, 29, 6, 30, 7, 31, 8, 16,
9, 17, 10, 18, 11, 19, 12, 20, 13, 21, 14, 22, 15, 23};
+ private static final int ROW_LENGTH = 16;
+ private static final int ROW_MASK = 0xFFFF;
+ //Maximum number of bit errors to correct. This is the guaranteed correction capability for the minimum distance of
+ //8, so the nearest codeword is always unique. Error injection testing: corrects all 1-3 bit error patterns and
+ //miscorrects 3.9% of 5-bit error patterns. At a 3% bit error rate, corrects 98.5% of messages versus 93% with a
+ //maximum of 2.
+ private static final int MAXIMUM_ERRORS = 3;
+ private static final int[] CODEWORDS = getCodewords();
+
+ /**
+ * Identifies the Hamming(16,11,4) codewords where bit i of each codeword is message bit i.
+ */
+ private static int[] getCodewords()
+ {
+ int[] checks = ProductCodeDecoder.getParityChecks(ROW_LENGTH, message -> Hamming16.getSyndrome(message, 0));
+ int[] codewords = new int[1 << 11];
+ int count = 0;
+
+ for(int word = 0; word <= ROW_MASK; word++)
+ {
+ if(ProductCodeDecoder.getSyndrome(word, checks) == 0)
+ {
+ codewords[count++] = word;
+ }
+ }
+
+ return codewords;
+ }
/**
* Unscramble and perform FEC checks per paragraph B.2.2.1 for Non-Reverse Channel Single Burst
*
* @param message with 32 interleaved bits.
- * @return descrambled and error checked message or null if the process fails or there are too many errors.
+ * @return descrambled and error corrected message or null if there are too many errors.
*/
public static CorrectedBinaryMessage decodeShortBurst(CorrectedBinaryMessage message)
{
- CorrectedBinaryMessage deinterleaved = deinterleave(message);
- int fec = Hamming16.checkAndCorrect(deinterleaved, 0);
-
- if(fec == 2) //0 or 1 is good, 2 = uncorrectable errors
- {
- return null;
- }
-
- //Check for even parity. Bits 0-15 should be the same as bits 16-31.
- for(int x = 0; x < 16; x++)
- {
- if(deinterleaved.get(x) ^ deinterleaved.get(x + 16))
- {
- return null;
- }
- }
-
- return deinterleaved;
+ return decode(deinterleave(message), false);
}
/**
* Unscramble and perform FEC checks per paragraph B.2.2.2 for Reverse Channel Single Burst
*
- * @param binaryMessage with 32 interleaved bits.
- * @return descrambled and error checked message or null if the process fails or there are too many errors.
+ * @param message with 32 interleaved bits.
+ * @return descrambled and error corrected message or null if there are too many errors.
*/
public static CorrectedBinaryMessage decodeReverseChannel(CorrectedBinaryMessage message)
{
- CorrectedBinaryMessage deinterleaved = deinterleave(message);
- System.out.println(" DEINTER: " + deinterleaved.toHexString());
- int fec = Hamming16.checkAndCorrect(deinterleaved, 0);
- System.out.println(" DECODED: " + deinterleaved.toHexString());
- System.out.println("FEC:" + fec);
- if(fec == 2) //0 or 1 is good, 2 = uncorrectable errors
+ return decode(deinterleave(message), true);
+ }
+
+ /**
+ * Decodes the deinterleaved message to the nearest codeword.
+ * @param deinterleaved message
+ * @param oddParity true when the second row is the inverse of the first row (reverse channel).
+ * @return corrected message with the corrected bit count set, or null if there are too many errors.
+ */
+ private static CorrectedBinaryMessage decode(CorrectedBinaryMessage deinterleaved, boolean oddParity)
+ {
+ int row1 = 0;
+ int row2 = 0;
+
+ for(int x = 0; x < ROW_LENGTH; x++)
+ {
+ if(deinterleaved.get(x))
+ {
+ row1 |= 1 << x;
+ }
+
+ if(deinterleaved.get(x + ROW_LENGTH))
+ {
+ row2 |= 1 << x;
+ }
+ }
+
+ if(oddParity)
+ {
+ row2 ^= ROW_MASK;
+ }
+
+ int bestDistance = Integer.MAX_VALUE;
+ int bestCodeword = 0;
+
+ for(int codeword: CODEWORDS)
+ {
+ int distance = Integer.bitCount(codeword ^ row1) + Integer.bitCount(codeword ^ row2);
+
+ if(distance < bestDistance)
+ {
+ bestDistance = distance;
+ bestCodeword = codeword;
+ }
+ }
+
+ if(bestDistance > MAXIMUM_ERRORS)
{
return null;
}
- //Check for odd parity. Bits 0-15 should be opposite of bits 16-31.
- for(int x = 0; x < 16; x++)
+ int second = oddParity ? bestCodeword ^ ROW_MASK : bestCodeword;
+ CorrectedBinaryMessage corrected = new CorrectedBinaryMessage(ROW_LENGTH * 2);
+
+ for(int x = 0; x < ROW_LENGTH; x++)
{
- if(deinterleaved.get(x) == deinterleaved.get(x + 16))
- {
- return null;
- }
+ corrected.set(x, (bestCodeword & (1 << x)) != 0);
+ corrected.set(x + ROW_LENGTH, (second & (1 << x)) != 0);
}
- return deinterleaved;
+ corrected.setCorrectedBitCount(bestDistance);
+ return corrected;
}
/**
@@ -120,6 +178,8 @@ public class BPTC_16_2
CorrectedBinaryMessage original = new CorrectedBinaryMessage(BinaryMessage.loadHex(msg));
System.out.println("ORIGINAL: " + original.toHexString());
CorrectedBinaryMessage decoded = decodeReverseChannel(original);
+ System.out.println(" DECODED: " + (decoded != null ? decoded.toHexString() + " corrected bits:" +
+ decoded.getCorrectedBitCount() : "uncorrectable"));
System.out.println("-------------------------");
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java
index 61e3a77a..2f2aa8c3 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java
@@ -25,10 +25,8 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming13;
import io.github.dsheirer.edac.Hamming15;
import io.github.dsheirer.edac.IHamming;
+import io.github.dsheirer.edac.ProductCodeDecoder;
import io.github.dsheirer.edac.soft.SoftBlockCode;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
/**
* Block Product Turbo Code (BPTC) 196/96 Support
@@ -42,7 +40,6 @@ public class BPTC_196_96
public static final int MESSAGE_COLUMN_COUNT = 12; //Should be 11, but adjusted for the first pad bit
public static final int CHECKSUM_COLUMN_COUNT = 4;
public static final int MESSAGE_START_INDEX = 4;
- public static final int MAXIMUM_RECURSION_TURBO_DEPTH = 20;
private static final int ROW_COUNT = 13;
private static final int ROW_LENGTH = 15;
private static final int COLUMN_LENGTH = 13;
@@ -59,6 +56,16 @@ public class BPTC_196_96
private static final SoftBlockCode COLUMN_CODE = SoftBlockCode.fromCodewordTest(COLUMN_LENGTH,
word -> Hamming13.getSyndrome(toMessage(word, COLUMN_LENGTH), COLUMN_WORD_INDEXES) == 0);
+ //Maximum number of bit errors corrected by the hard decision minimum distance decoder. The product code minimum
+ //distance of 9 guarantees correction of 4 bit errors. Miscorrections are rejected by the message CRC check, which
+ //then retries with soft decision decoding. Error injection testing: corrects 99.7% of 8-bit error patterns and the
+ //largest search for random error patterns took 3 ms. Messages with more bit errors fall back to turbo decoding.
+ private static final int HARD_MAXIMUM_ERRORS = 12;
+ private static final ProductCodeDecoder HARD_DECODER = new ProductCodeDecoder(
+ ProductCodeDecoder.getParityChecks(ROW_LENGTH, message -> Hamming15.getSyndrome(message, 0)),
+ ProductCodeDecoder.getParityChecks(COLUMN_LENGTH,
+ message -> Hamming13.getSyndrome(message, COLUMN_WORD_INDEXES)), HARD_MAXIMUM_ERRORS);
+
/**
* DMR De-interleaving indices
*/
@@ -151,8 +158,7 @@ public class BPTC_196_96
CorrectedBinaryMessage message = deinterleave(original);
//Perform error detection and correction
- boolean pursueShadows = true;
- correct(message, pursueShadows);
+ correct(message);
if(softBits != null && softBits.length == BPTC_LENGTH && message.getCorrectedBitCount() < 0)
{
@@ -414,80 +420,6 @@ public class BPTC_196_96
return deinterleaved;
}
- /**
- * Identifies the error indices from the row level Hamming(15,11,3) protected words
- * @param message containing protected words in rows
- * @return list of zero or more error indices
- */
- private static List getRowErrors(CorrectedBinaryMessage message)
- {
- List rowErrors = new ArrayList<>();
-
- int offset = 0;
- int index = -1;
-
- for(int row = 0; row < 13; row++)
- {
- //The first bit of the message is a pad/reserved bit and is ignored.
- offset = (row * 15) + 1;
- index = Hamming15.getErrorIndex(message, offset);
-
- if(index >= 0 && index < 1000)
- {
- rowErrors.add(index);
- }
- }
-
- //Note: row errors should already be in order and don't need sorted
- return rowErrors;
- }
-
- /**
- * Identifies the error indices from the column level Hamming(13,9,3) protected words
- * @param message containing protected words in columns
- * @return list of zero or more error indices
- */
- private static List getColumnErrors(CorrectedBinaryMessage message)
- {
- List columnErrors = new ArrayList<>();
-
- int index = -1;
-
- for(int column = 0; column < 15; column++)
- {
- index = Hamming13.getErrorIndex(message, COLUMN_INDEXES[column]);
-
- if(index >= 0 && index < 1000)
- {
- columnErrors.add(index);
- }
- }
-
- Collections.sort(columnErrors);
- return columnErrors;
- }
-
- /**
- * Creates a list of the values that intersect between list 1 and list 2.
- * @param list1 to compare
- * @param list2 to compare
- * @return intersection
- */
- private static List intersection(List list1, List list2)
- {
- List intersection = new ArrayList<>();
-
- for(Integer i : list1)
- {
- if(list2.contains(i))
- {
- intersection.add(i);
- }
- }
-
- return intersection;
- }
-
private static void logDiagnostic(CorrectedBinaryMessage message)
{
StringBuilder sb = new StringBuilder();
@@ -555,321 +487,83 @@ public class BPTC_196_96
}
/**
- * Calculates the indices that make up the intersections of the row and column errors.
- * @param rows bitmap indicating rows with errors
- * @param columns bitmap indicating columns with errors
- * @return set of intersection indices.
- */
- public static List getIntersectionIndices(BinaryMessage columns, BinaryMessage rows)
- {
- List intersections = new ArrayList<>();
-
- for(int row = rows.nextSetBit(0); row >= 0 && row < 13; row = rows.nextSetBit(row + 1))
- {
- for(int column = columns.nextSetBit(0); column >= 0 && column < 15; column = columns.nextSetBit(column + 1))
- {
- intersections.add(getIndex(column, row));
- }
- }
-
- return intersections;
- }
-
- /**
- * Performs turbo decode of the message by testing multiple paths across rows and columns to identify paths
- * that resolve errors validated by the Hamming checks. Can also detect and often correct shadowing where the bit
- * errors occur in a 2x2, 2x3, 3x2 or 3x3 column/row grid and can't normally be corrected by the Hamming code.
- * When corrected, the message corrected bit count is set to the number of bits that differ from the received
- * message.
+ * Performs hard decision error detection and correction of the de-interleaved message, using minimum distance
+ * decoding, followed by turbo decoding when the minimum distance decoder can't correct the message. When
+ * corrected, the corrected bit count is set to the number of corrected bits. Otherwise, the message is unchanged
+ * and the corrected bit count is set to ERRORS_NOT_CORRECTED. Note: the leading pad bit is not checked.
*
* @param message to correct.
- * @param pursueShadows to test intersection flipping for double/triple shadows.
* @return true if the message was corrected, false otherwise.
*/
- public static boolean correct(CorrectedBinaryMessage message, boolean pursueShadows)
+ public static boolean correct(CorrectedBinaryMessage message)
{
- CorrectedBinaryMessage received = new CorrectedBinaryMessage(message);
- boolean corrected = correctTurbo(message, pursueShadows);
+ int[] rows = getRows(message);
+ int corrected = HARD_DECODER.decode(rows);
- if(corrected)
+ if(corrected == ProductCodeDecoder.UNCORRECTABLE)
{
- //The turbo decoder's incremental accounting can count a bit more than once, so measure the corrections
- received.xor(message);
- message.setCorrectedBitCount(received.cardinality());
- }
+ //Fallback: real-world messages can have more bit errors than the minimum distance decoder maximum, spread
+ //across the rows and columns, which the turbo decoder can often correct. Recording testing: recovered 29
+ //of 30 CRC verified messages lost by the minimum distance decoder alone.
+ CorrectedBinaryMessage turbo = new CorrectedBinaryMessage(message);
- return corrected;
- }
-
- /**
- * Turbo decode implementation for the correct() method. Note: the corrected bit count set by this method is
- * not accurate and is replaced by the correct() method.
- * @param message to correct.
- * @param pursueShadows to test intersection flipping for double/triple shadows.
- * @return true if the message was corrected, false otherwise.
- */
- private static boolean correctTurbo(CorrectedBinaryMessage message, boolean pursueShadows)
- {
- List columnErrors = getColumnErrors(message);
- List rowErrors = getRowErrors(message);
-
- if(columnErrors.isEmpty() && rowErrors.isEmpty())
- {
- return true;
- }
-
- //Fix the easy bits first.
- List flippedBits = new ArrayList<>();
- List intersection = intersection(columnErrors, rowErrors);
-
- if(!intersection.isEmpty())
- {
- for(Integer i : intersection)
+ if(new BPTC_196_96TurboDecoder().correct(turbo))
{
- message.flip(i);
- flippedBits.add(i);
- }
+ rows = getRows(turbo);
- message.incrementCorrectedBitCount(intersection.size());
- }
-
- //Set of flags for columns with errors
- BinaryMessage columns = new BinaryMessage(15);
- for(int column = 0; column < 15; column++)
- {
- columns.set(column, !isColumnCorrect(column, message));
- }
-
- //Set of flags for rows with errors
- BinaryMessage rows = new BinaryMessage(13);
- for(int row = 0; row < 13; row++)
- {
- rows.set(row, !isRowCorrect(row, message));
- }
-
- if(columns.isEmpty() && rows.isEmpty())
- {
- return true; //No (more) errors
- }
-
- //1: fix up to 2-bit errors per column or row without pursuing multi-column or multi-row paths
- boolean pursue = false;
-
- for(int column = columns.nextSetBit(0); column >= 0 && column < 15; column = columns.nextSetBit(column + 1))
- {
- List solution = correctColumn(column, message, columns, rows, 0, pursue, new ArrayList<>());
- flippedBits.addAll(solution);
- }
-
- if(!columns.isEmpty() || !rows.isEmpty())
- {
- for(int row = rows.nextSetBit(0); row >= 0 && row < 13; row = rows.nextSetBit(row + 1))
- {
- List solution = correctRow(row, message, columns, rows, 0, pursue, new ArrayList<>());
- flippedBits.addAll(solution);
- }
- }
-
- if(!columns.isEmpty() || !rows.isEmpty())
- {
- pursue = true;
-
- for(int column = columns.nextSetBit(0); column >= 0 && column < 15; column = columns.nextSetBit(column + 1))
- {
- List solution = correctColumn(column, message, columns, rows, 0, pursue, new ArrayList<>());
- flippedBits.addAll(solution);
- }
-
- if(!columns.isEmpty() || !rows.isEmpty())
- {
- for(int row = rows.nextSetBit(0); row >= 0 && row < 13; row = rows.nextSetBit(row + 1))
+ if(HARD_DECODER.isValid(rows))
{
- List solution = correctRow(row, message, columns, rows, 0, pursue, new ArrayList<>());
- flippedBits.addAll(solution);
+ int[] received = getRows(message);
+ corrected = 0;
+
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ corrected += Integer.bitCount(rows[row] ^ received[row]);
+ }
}
}
}
- //Detect double/triple row/column bit errors that are shadowing each other and preventing the
- // Hamming codes from giving accurate bit error locations. Flip all of their intersections and
- // then attempt to correct the message again. We use a flag 'pursueShadows' to ensure that we
- // only chase the shadow once, not recursively, since this parent method can be called recursively.
- if(pursueShadows && ((columns.cardinality() == 2 || columns.cardinality() == 3) &&
- (rows.cardinality() == 2 || rows.cardinality() == 3)))
- {
- List intersectionIndices = getIntersectionIndices(columns, rows);
- for(Integer intersectionIndex: intersectionIndices)
- {
- message.flip(intersectionIndex);
- }
-
- //Set pursue shadows to false so we don't recursively chase shadows
- pursueShadows = false;
-
- if(correctTurbo(message, pursueShadows))
- {
- columns.clear();
- rows.clear();
- int correctedBits = intersectionIndices.size() - message.getCorrectedBitCount();
- message.incrementCorrectedBitCount(correctedBits);
- }
- else
- {
- for(Integer intersectionIndex: intersectionIndices)
- {
- message.flip(intersectionIndex);
- }
- }
- }
-
- if(columns.isEmpty() && rows.isEmpty())
- {
- Collections.sort(flippedBits);
- message.incrementCorrectedBitCount(flippedBits.size());
- }
- else
+ if(corrected == ProductCodeDecoder.UNCORRECTABLE)
{
message.setCorrectedBitCount(BPTCBase.ERRORS_NOT_CORRECTED);
return false;
}
+ if(corrected > 0)
+ {
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ for(int column = 0; column < ROW_LENGTH; column++)
+ {
+ message.set(getIndex(column, row), (rows[row] & (1 << column)) != 0);
+ }
+ }
+ }
+
+ message.setCorrectedBitCount(corrected);
return true;
}
/**
- * Recursive method ...
- * @param column
- * @param message
- * @param columns
- * @param rows
- * @return
+ * Row words of the de-interleaved message, where bit i of each row is the bit in column i.
*/
- public static List correctColumn(int column, CorrectedBinaryMessage message, BinaryMessage columns,
- BinaryMessage rows, int depth, boolean pursue, List path)
+ private static int[] getRows(CorrectedBinaryMessage message)
{
- //Don't let the recursive call exceed threshold
- if(depth > MAXIMUM_RECURSION_TURBO_DEPTH)
+ int[] rows = new int[ROW_COUNT];
+
+ for(int row = 0; row < ROW_COUNT; row++)
{
- return Collections.emptyList();
- }
-
- int index = getColumnErrorIndex(column, message);
- int row = getRow(index);
-
- //If the candidate index is in range and the row for the candidate has an error, proceed
- if(0 <= index && index <= 195 && rows.get(row) && !path.contains(index))
- {
- message.flip(index);
-
- if(isColumnCorrect(column, message))
+ for(int column = 0; column < ROW_LENGTH; column++)
{
- path.add(index);
-
- //If the row is now correct, stop here.
- if(isRowCorrect(row, message))
+ if(message.get(getIndex(column, row)))
{
- columns.clear(column);
- rows.clear(row);
- return path;
+ rows[row] |= 1 << column;
}
-
- //Recursively test bit flipping in the row. A non-empty list is the solution set.
- List solution = correctRow(row, message, columns, rows, depth + 1, pursue, path);
-
- //If our candidate index is part of the discovered solution, fail, otherwise return it as part of
- // the solution.
- if(solution.size() > 0)
- {
- columns.clear(column);
- return solution;
- }
- else if(pursue) //Pursue additional column flips
- {
- for(int column2 = columns.nextSetBit(column + 1); column2 >= 0 && column2 < 15; column2 = columns.nextSetBit(column2 + 1))
- {
- List solution2 = correctColumn(column2, message, columns, rows, depth + 1, pursue, path);
-
- if(solution2.size() > 0)
- {
- columns.clear(column);
- rows.clear(row);
- return solution2;
- }
- }
- }
-
- path.removeLast();
}
-
- //Revert the change
- message.flip(index);
}
- return Collections.emptyList();
- }
-
- public static List correctRow(int row, CorrectedBinaryMessage message, BinaryMessage columns,
- BinaryMessage rows, int depth, boolean pursue, List path)
- {
- //Don't let the recursive call exceed threshold
- if(depth > MAXIMUM_RECURSION_TURBO_DEPTH)
- {
- return Collections.emptyList();
- }
-
- int index = getRowErrorIndex(row, message);
- int column = getColumn(index);
-
- //If the candidate index is in range and the column for the candidate has an error, proceed
- if(0 <= index && index <= 195 && columns.get(column) && !path.contains(index))
- {
- message.flip(index);
-
- if(isRowCorrect(row, message))
- {
- path.add(index);
-
- //If the column is now correct, stop here and return the path as the solution
- if(isColumnCorrect(column, message))
- {
- columns.clear(column);
- rows.clear(row);
- return path;
- }
-
- //Recursively test bit flipping in the column. A non-empty list is the solution set.
- List solution = correctColumn(column, message, columns, rows, depth + 1, pursue, path);
-
- //If our candidate index is part of the discovered solution, fail, otherwise return it as part of
- // the solution.
- if(solution.size() > 0)
- {
- rows.clear(row);
- return solution;
- }
- else if(pursue) //Pursue additional row flips
- {
- for(int row2 = rows.nextSetBit(row + 1); row2 >= 0 && row2 < 13; row2 = rows.nextSetBit(row2 + 1))
- {
- List solution2 = correctRow(row2, message, columns, rows, depth + 1, pursue, path);
-
- if(solution2.size() > 0 && !solution2.contains(index))
- {
- columns.clear(column);
- rows.clear(row);
- return solution2;
- }
- }
- }
-
- path.removeLast();
- }
-
- //Revert the change
- message.flip(index);
- }
-
- return Collections.emptyList();
+ return rows;
}
public static int getIndex(int column, int row)
@@ -921,16 +615,6 @@ public class BPTC_196_96
return Hamming13.getErrorIndex(message, COLUMN_INDEXES[column]);
}
- public static int getColumn(int index)
- {
- return (index - 1) % 15;
- }
-
- public static int getRow(int index)
- {
- return (index - 1) / 15;
- }
-
public static void main(String[] args)
{
String raw = "0000101111101000000100001010100001010000001000000011100000000000100000000000001000000000001000000000000010000000001111001010000100001000000001100000100001000111110101011111011111101001111011100010";
@@ -940,8 +624,7 @@ public class BPTC_196_96
// correct2(refMessage);
- boolean pursueShadows = true;
- correct(rawMessage, pursueShadows);
+ correct(rawMessage);
System.out.println("RAW: " + raw);
System.out.println("COR: " + rawMessage);
System.out.println("REF: " + ref);
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96TurboDecoder.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96TurboDecoder.java
new file mode 100644
index 00000000..78827595
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96TurboDecoder.java
@@ -0,0 +1,404 @@
+/*
+ * *****************************************************************************
+ * 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.bptc;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.edac.Hamming13;
+import io.github.dsheirer.edac.Hamming15;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Turbo (iterative path search) hard decision decoder for the de-interleaved BPTC(196,96) message. Used as a fallback
+ * for error patterns that the minimum distance decoder can't correct. Real-world messages can have many bit errors
+ * spread across the rows and columns, which this decoder can often correct by following paths of row and column
+ * Hamming error indices that resolve each other. Can also detect and often correct shadowing where the bit errors
+ * occur in a 2x2, 2x3, 3x2 or 3x3 column/row grid.
+ *
+ * The search effort is bounded. The message is modified even when it can't be corrected and the decoder can report
+ * success for an invalid codeword, so the caller must use a copy of the message and verify the result.
+ *
+ * Instances are not thread safe. Use a new instance for each message.
+ */
+class BPTC_196_96TurboDecoder
+{
+ //Maximum recursive search calls per message. Testing with real-world (CRC verified) messages that exceeded the
+ //minimum distance decoder error count: 29 of 30 needed fewer than 1,500 calls, whereas random noise needed a median
+ //of 2,700 and up to 12 million calls. This limit bounds random noise decoding to under 1 ms.
+ private static final int MAXIMUM_SEARCH_CALLS = 3000;
+ private static final int MAXIMUM_RECURSION_DEPTH = 20;
+ private static final int ROW_COUNT = 13;
+ private static final int COLUMN_COUNT = 15;
+ private int mSearchCalls;
+
+ /**
+ * Performs turbo decoding of the de-interleaved message.
+ * @param message to correct, modified even when uncorrectable.
+ * @return true if the decoder reports the message as corrected.
+ */
+ public boolean correct(CorrectedBinaryMessage message)
+ {
+ return correct(message, true);
+ }
+
+ /**
+ * Turbo decode implementation.
+ * @param message to correct.
+ * @param pursueShadows to test intersection flipping for double/triple shadows. Shadows are only pursued once.
+ * @return true if the message was corrected, false otherwise.
+ */
+ private boolean correct(CorrectedBinaryMessage message, boolean pursueShadows)
+ {
+ List columnErrors = getColumnErrors(message);
+ List rowErrors = getRowErrors(message);
+
+ if(columnErrors.isEmpty() && rowErrors.isEmpty())
+ {
+ return true;
+ }
+
+ //Fix the easy bits first, where the row and column error indices agree
+ for(Integer index: intersection(columnErrors, rowErrors))
+ {
+ message.flip(index);
+ }
+
+ //Set of flags for columns with errors
+ BinaryMessage columns = new BinaryMessage(COLUMN_COUNT);
+ for(int column = 0; column < COLUMN_COUNT; column++)
+ {
+ columns.set(column, !isColumnCorrect(column, message));
+ }
+
+ //Set of flags for rows with errors
+ BinaryMessage rows = new BinaryMessage(ROW_COUNT);
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ rows.set(row, !isRowCorrect(row, message));
+ }
+
+ if(columns.isEmpty() && rows.isEmpty())
+ {
+ return true; //No (more) errors
+ }
+
+ //1: fix up to 2-bit errors per column or row without pursuing multi-column or multi-row paths
+ boolean pursue = false;
+
+ for(int column = columns.nextSetBit(0); column >= 0 && column < COLUMN_COUNT;
+ column = columns.nextSetBit(column + 1))
+ {
+ correctColumn(column, message, columns, rows, 0, pursue, new ArrayList<>());
+ }
+
+ if(!columns.isEmpty() || !rows.isEmpty())
+ {
+ for(int row = rows.nextSetBit(0); row >= 0 && row < ROW_COUNT; row = rows.nextSetBit(row + 1))
+ {
+ correctRow(row, message, columns, rows, 0, pursue, new ArrayList<>());
+ }
+ }
+
+ //2: pursue multi-column and multi-row paths
+ if(!columns.isEmpty() || !rows.isEmpty())
+ {
+ pursue = true;
+
+ for(int column = columns.nextSetBit(0); column >= 0 && column < COLUMN_COUNT;
+ column = columns.nextSetBit(column + 1))
+ {
+ correctColumn(column, message, columns, rows, 0, pursue, new ArrayList<>());
+ }
+
+ if(!columns.isEmpty() || !rows.isEmpty())
+ {
+ for(int row = rows.nextSetBit(0); row >= 0 && row < ROW_COUNT; row = rows.nextSetBit(row + 1))
+ {
+ correctRow(row, message, columns, rows, 0, pursue, new ArrayList<>());
+ }
+ }
+ }
+
+ //3: Detect double/triple row/column bit errors that are shadowing each other and preventing the Hamming codes
+ //from giving accurate bit error locations. Flip all of their intersections and then attempt to correct the
+ //message again, once.
+ if(pursueShadows && ((columns.cardinality() == 2 || columns.cardinality() == 3) &&
+ (rows.cardinality() == 2 || rows.cardinality() == 3)))
+ {
+ List intersectionIndices = getIntersectionIndices(columns, rows);
+
+ for(Integer intersectionIndex: intersectionIndices)
+ {
+ message.flip(intersectionIndex);
+ }
+
+ if(correct(message, false))
+ {
+ columns.clear();
+ rows.clear();
+ }
+ else
+ {
+ for(Integer intersectionIndex: intersectionIndices)
+ {
+ message.flip(intersectionIndex);
+ }
+ }
+ }
+
+ return columns.isEmpty() && rows.isEmpty();
+ }
+
+ /**
+ * Recursively tests flipping the bit indicated by the column error index, followed by the bit indicated by the
+ * resulting row error index, and so on, until the path resolves the row and column errors.
+ * @return solution path, or an empty list.
+ */
+ private List correctColumn(int column, CorrectedBinaryMessage message, BinaryMessage columns,
+ BinaryMessage rows, int depth, boolean pursue, List path)
+ {
+ if(++mSearchCalls > MAXIMUM_SEARCH_CALLS || depth > MAXIMUM_RECURSION_DEPTH)
+ {
+ return Collections.emptyList();
+ }
+
+ int index = Hamming13.getErrorIndex(message, BPTC_196_96.COLUMN_INDEXES[column]);
+ int row = getRow(index);
+
+ //If the candidate index is in range and the row for the candidate has an error, proceed
+ if(0 <= index && index <= 195 && rows.get(row) && !path.contains(index))
+ {
+ message.flip(index);
+
+ if(isColumnCorrect(column, message))
+ {
+ path.add(index);
+
+ //If the row is now correct, stop here.
+ if(isRowCorrect(row, message))
+ {
+ columns.clear(column);
+ rows.clear(row);
+ return path;
+ }
+
+ //Recursively test bit flipping in the row. A non-empty list is the solution set.
+ List solution = correctRow(row, message, columns, rows, depth + 1, pursue, path);
+
+ if(!solution.isEmpty())
+ {
+ columns.clear(column);
+ return solution;
+ }
+ else if(pursue) //Pursue additional column flips
+ {
+ for(int column2 = columns.nextSetBit(column + 1); column2 >= 0 && column2 < COLUMN_COUNT;
+ column2 = columns.nextSetBit(column2 + 1))
+ {
+ List solution2 = correctColumn(column2, message, columns, rows, depth + 1, pursue,
+ path);
+
+ if(!solution2.isEmpty())
+ {
+ columns.clear(column);
+ rows.clear(row);
+ return solution2;
+ }
+ }
+ }
+
+ path.removeLast();
+ }
+
+ //Revert the change
+ message.flip(index);
+ }
+
+ return Collections.emptyList();
+ }
+
+ /**
+ * Recursively tests flipping the bit indicated by the row error index, followed by the bit indicated by the
+ * resulting column error index, and so on, until the path resolves the row and column errors.
+ * @return solution path, or an empty list.
+ */
+ private List correctRow(int row, CorrectedBinaryMessage message, BinaryMessage columns,
+ BinaryMessage rows, int depth, boolean pursue, List path)
+ {
+ if(++mSearchCalls > MAXIMUM_SEARCH_CALLS || depth > MAXIMUM_RECURSION_DEPTH)
+ {
+ return Collections.emptyList();
+ }
+
+ int index = Hamming15.getErrorIndex(message, (row * COLUMN_COUNT) + 1);
+ int column = getColumn(index);
+
+ //If the candidate index is in range and the column for the candidate has an error, proceed
+ if(0 <= index && index <= 195 && columns.get(column) && !path.contains(index))
+ {
+ message.flip(index);
+
+ if(isRowCorrect(row, message))
+ {
+ path.add(index);
+
+ //If the column is now correct, stop here and return the path as the solution
+ if(isColumnCorrect(column, message))
+ {
+ columns.clear(column);
+ rows.clear(row);
+ return path;
+ }
+
+ //Recursively test bit flipping in the column. A non-empty list is the solution set.
+ List solution = correctColumn(column, message, columns, rows, depth + 1, pursue, path);
+
+ if(!solution.isEmpty())
+ {
+ rows.clear(row);
+ return solution;
+ }
+ else if(pursue) //Pursue additional row flips
+ {
+ for(int row2 = rows.nextSetBit(row + 1); row2 >= 0 && row2 < ROW_COUNT;
+ row2 = rows.nextSetBit(row2 + 1))
+ {
+ List solution2 = correctRow(row2, message, columns, rows, depth + 1, pursue, path);
+
+ if(!solution2.isEmpty() && !solution2.contains(index))
+ {
+ columns.clear(column);
+ rows.clear(row);
+ return solution2;
+ }
+ }
+ }
+
+ path.removeLast();
+ }
+
+ //Revert the change
+ message.flip(index);
+ }
+
+ return Collections.emptyList();
+ }
+
+ /**
+ * Identifies the error indices from the row level Hamming(15,11,3) protected words
+ */
+ private static List getRowErrors(CorrectedBinaryMessage message)
+ {
+ List rowErrors = new ArrayList<>();
+
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ //The first bit of the message is a pad/reserved bit and is ignored.
+ int index = Hamming15.getErrorIndex(message, (row * COLUMN_COUNT) + 1);
+
+ if(index >= 0 && index < 1000)
+ {
+ rowErrors.add(index);
+ }
+ }
+
+ return rowErrors;
+ }
+
+ /**
+ * Identifies the error indices from the column level Hamming(13,9,3) protected words
+ */
+ private static List getColumnErrors(CorrectedBinaryMessage message)
+ {
+ List columnErrors = new ArrayList<>();
+
+ for(int column = 0; column < COLUMN_COUNT; column++)
+ {
+ int index = Hamming13.getErrorIndex(message, BPTC_196_96.COLUMN_INDEXES[column]);
+
+ if(index >= 0 && index < 1000)
+ {
+ columnErrors.add(index);
+ }
+ }
+
+ Collections.sort(columnErrors);
+ return columnErrors;
+ }
+
+ /**
+ * Values from list 1 that are also in list 2.
+ */
+ private static List intersection(List list1, List list2)
+ {
+ List intersection = new ArrayList<>();
+
+ for(Integer i: list1)
+ {
+ if(list2.contains(i))
+ {
+ intersection.add(i);
+ }
+ }
+
+ return intersection;
+ }
+
+ /**
+ * Indices at the intersections of the row and column errors.
+ */
+ private static List getIntersectionIndices(BinaryMessage columns, BinaryMessage rows)
+ {
+ List intersections = new ArrayList<>();
+
+ for(int row = rows.nextSetBit(0); row >= 0 && row < ROW_COUNT; row = rows.nextSetBit(row + 1))
+ {
+ for(int column = columns.nextSetBit(0); column >= 0 && column < COLUMN_COUNT;
+ column = columns.nextSetBit(column + 1))
+ {
+ intersections.add(BPTC_196_96.getIndex(column, row));
+ }
+ }
+
+ return intersections;
+ }
+
+ private static boolean isRowCorrect(int row, CorrectedBinaryMessage message)
+ {
+ return Hamming15.getSyndrome(message, (row * COLUMN_COUNT) + 1) == 0;
+ }
+
+ private static boolean isColumnCorrect(int column, CorrectedBinaryMessage message)
+ {
+ return Hamming13.getSyndrome(message, BPTC_196_96.COLUMN_INDEXES[column]) == 0;
+ }
+
+ private static int getColumn(int index)
+ {
+ return (index - 1) % COLUMN_COUNT;
+ }
+
+ private static int getRow(int index)
+ {
+ return (index - 1) / COLUMN_COUNT;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java
index 1a27c144..5bf697cd 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java
@@ -22,6 +22,7 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming17;
+import io.github.dsheirer.edac.ProductCodeDecoder;
import io.github.dsheirer.edac.soft.SoftBlockCode;
import io.github.dsheirer.edac.soft.SoftProductCodeDecoder;
@@ -33,6 +34,15 @@ public class BPTC_68_36 extends BPTCBase
private static final int LENGTH = 68;
private static final int ROW_COUNT = 4;
private static final int COLUMN_COUNT = 17;
+ //Maximum number of bit errors corrected by the hard decision minimum distance decoder. The product code minimum
+ //distance of 6 guarantees correction of 2 bit errors. Miscorrections are rejected by the CRC-8 check, which then
+ //retries with soft decision decoding.
+ private static final int HARD_MAXIMUM_ERRORS = 4;
+ //Maximum number of bit errors corrected by the row erasure fallback decoder, when the minimum distance decoder
+ //can't correct the message. Recording testing: recovered 19 of 22 CRC verified messages (at 6 dB SNR) lost by the
+ //minimum distance decoder alone, which soft decision decoding didn't recover. Random noise acceptance before the
+ //CRC-8 check: 1.4% versus 34% without a maximum.
+ private static final int FALLBACK_MAXIMUM_ERRORS = 7;
//Maximum correction cost (sum of the normalized channel soft bit magnitudes for the corrected bits) for a soft
//decision result. No limit is applied since the CRC-8 check rejects most false decodes. Noise injection testing:
//754 correct and 2 false soft decisions, where the false decisions had costs within the range of the correct
@@ -63,7 +73,8 @@ public class BPTC_68_36 extends BPTCBase
public BPTC_68_36()
{
- super(new Hamming17(), 17, 4);
+ super(ProductCodeDecoder.getParityChecks(COLUMN_COUNT, message -> Hamming17.getSyndrome(message, 0)), ROW_COUNT,
+ HARD_MAXIMUM_ERRORS, FALLBACK_MAXIMUM_ERRORS);
}
/**
diff --git a/src/test/java/io/github/dsheirer/edac/HammingTest.java b/src/test/java/io/github/dsheirer/edac/HammingTest.java
new file mode 100644
index 00000000..a801ba09
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/edac/HammingTest.java
@@ -0,0 +1,145 @@
+/*
+ * *****************************************************************************
+ * 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.edac;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import java.util.function.ToIntFunction;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * JUnit tests for the DMR Hamming codes: every single bit error in every codeword is located.
+ */
+public class HammingTest
+{
+ private static final int[] INDICES_13 = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
+
+ private static BinaryMessage toMessage(int word, int length)
+ {
+ BinaryMessage message = new BinaryMessage(length);
+
+ for(int i = 0; i < length; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * Asserts the code has the expected number of codewords and that the locator finds each single bit error.
+ */
+ private static void assertSingleErrorsLocated(int length, int informationLength, ToIntFunction syndrome,
+ ToIntFunction locator)
+ {
+ int[] checks = ProductCodeDecoder.getParityChecks(length, syndrome);
+ int[] codewords = IntStream.range(0, 1 << length)
+ .filter(word -> ProductCodeDecoder.getSyndrome(word, checks) == 0).toArray();
+ assertEquals(1 << informationLength, codewords.length);
+
+ for(int codeword: codewords)
+ {
+ assertEquals(IHamming.NO_ERRORS, locator.applyAsInt(toMessage(codeword, length)));
+
+ for(int i = 0; i < length; i++)
+ {
+ assertEquals(i, locator.applyAsInt(toMessage(codeword ^ (1 << i), length)),
+ "Codeword " + Integer.toHexString(codeword) + " bit " + i);
+ }
+ }
+ }
+
+ @Test
+ void testHamming13()
+ {
+ assertSingleErrorsLocated(13, 9, m -> Hamming13.getSyndrome(m, INDICES_13),
+ m -> Hamming13.getErrorIndex(m, INDICES_13));
+ assertSingleErrorsLocated(13, 9, m -> Hamming13.getSyndrome(m, INDICES_13), m -> Hamming13.getErrorIndex(m, 0));
+ }
+
+ /**
+ * Test: the two syndromes that don't match a single bit error are flagged as multiple errors.
+ */
+ @Test
+ void testHamming13MultipleErrors()
+ {
+ int[] checks = ProductCodeDecoder.getParityChecks(13, m -> Hamming13.getSyndrome(m, INDICES_13));
+ int flagged = 0;
+
+ for(int a = 0; a < 13; a++)
+ {
+ for(int b = a + 1; b < 13; b++)
+ {
+ int syndrome = checks[a] ^ checks[b];
+
+ if(syndrome == 9 || syndrome == 13)
+ {
+ BinaryMessage message = toMessage((1 << a) | (1 << b), 13);
+ assertEquals(IHamming.MULTIPLE_ERRORS, Hamming13.getErrorIndex(message, 0));
+ assertEquals(IHamming.MULTIPLE_ERRORS, Hamming13.getErrorIndex(message, INDICES_13));
+ flagged++;
+ }
+ }
+ }
+
+ assertEquals(12, flagged);
+ }
+
+ @Test
+ void testHamming15()
+ {
+ assertSingleErrorsLocated(15, 11, m -> Hamming15.getSyndrome(m, 0), m -> Hamming15.getErrorIndex(m, 0));
+ }
+
+ /**
+ * Test: single bit errors are located in every position, including the final parity bit, and every double bit
+ * error is detected.
+ */
+ @Test
+ void testHamming16()
+ {
+ Hamming16 hamming = new Hamming16();
+ assertSingleErrorsLocated(16, 11, m -> Hamming16.getSyndrome(m, 0), m -> hamming.getErrorIndex(m, 0));
+
+ for(int a = 0; a < 16; a++)
+ {
+ for(int b = a + 1; b < 16; b++)
+ {
+ assertEquals(IHamming.MULTIPLE_ERRORS, hamming.getErrorIndex(toMessage((1 << a) | (1 << b), 16), 0));
+ }
+ }
+ }
+
+ /**
+ * Test: single bit errors are located in every position, including the final parity bit for both even and odd
+ * weight codewords.
+ */
+ @Test
+ void testHamming17()
+ {
+ Hamming17 hamming = new Hamming17();
+ assertSingleErrorsLocated(17, 12, m -> Hamming17.getSyndrome(m, 0), m -> hamming.getErrorIndex(m, 0));
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/edac/ProductCodeDecoderTest.java b/src/test/java/io/github/dsheirer/edac/ProductCodeDecoderTest.java
new file mode 100644
index 00000000..b1430cc3
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/edac/ProductCodeDecoderTest.java
@@ -0,0 +1,80 @@
+/*
+ * *****************************************************************************
+ * 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.edac;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for the product code minimum distance decoder. The DMR BPTC tests cover error correction capability.
+ */
+public class ProductCodeDecoderTest
+{
+ //Hamming(7,4,3) rows
+ private static final int[] HAMMING_7 = new int[]{0x3, 0x5, 0x6, 0x7, 0x4, 0x2, 0x1};
+
+ /**
+ * Test: column codes whose final rows are not distinct single bit (systematic) parity checks are rejected.
+ */
+ @Test
+ void testColumnCodeValidation()
+ {
+ assertThrows(IllegalArgumentException.class, () -> new ProductCodeDecoder(HAMMING_7, new int[]{1, 3}, 2));
+ assertThrows(IllegalArgumentException.class, () -> new ProductCodeDecoder(HAMMING_7, new int[]{3, 1, 1}, 2));
+ assertThrows(IllegalArgumentException.class, () -> new ProductCodeDecoder(HAMMING_7, new int[]{1}, 2));
+ }
+
+ /**
+ * Test: Hamming(7,4,3) rows with even parity columns (minimum distance 6) corrects 2 bit errors and leaves the
+ * rows unchanged when uncorrectable.
+ */
+ @Test
+ void testDecode()
+ {
+ ProductCodeDecoder decoder = new ProductCodeDecoder(HAMMING_7, new int[]{1, 1, 1}, 3);
+ //Row codewords 0x61 (0x3 ^ 0x2 ^ 0x1 = 0) and 0x52 (0x5 ^ 0x4 ^ 0x1 = 0) with the column parity row 0x33
+ int[] codeword = new int[]{0x61, 0x52, 0x33};
+ assertTrue(decoder.isValid(codeword));
+ assertEquals(0, decoder.decode(codeword.clone()));
+
+ //Two errors in the same column: no column parity errors
+ int[] received = new int[]{0x61 ^ 0x01, 0x52 ^ 0x01, 0x33};
+ assertFalse(decoder.isValid(received));
+ assertEquals(2, decoder.decode(received));
+ assertArrayEquals(codeword, received);
+
+ //Two errors in the same row, beyond the row code correction capability
+ received = new int[]{0x61, 0x52, 0x33 ^ 0x30};
+ assertEquals(2, decoder.decode(received));
+ assertArrayEquals(codeword, received);
+
+ //Uncorrectable within the maximum error count: unchanged
+ ProductCodeDecoder limited = new ProductCodeDecoder(HAMMING_7, new int[]{1, 1, 1}, 1);
+ received = new int[]{0x61 ^ 0x01, 0x52 ^ 0x01, 0x33};
+ int[] copy = received.clone();
+ assertEquals(ProductCodeDecoder.UNCORRECTABLE, limited.decode(received));
+ assertArrayEquals(copy, received);
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java
index 6aec55ad..c0a21aad 100644
--- a/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java
@@ -113,10 +113,10 @@ public class DMRMessageFramerTest
boolean[] burst = createBurst(bptc, COLOR_CODE, DataType.SLOT_IDLE);
float[] softSymbols = toSoftSymbols(burst);
- //Add 10 low reliability payload symbol errors
+ //Add 14 low reliability payload symbol errors, which usually exceed the hard decision decoder capability
Set errors = new HashSet<>();
- while(errors.size() < 10)
+ while(errors.size() < 14)
{
errors.add(payloadDibits.get(random.nextInt(payloadDibits.size())));
}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCHardDecisionTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCHardDecisionTest.java
new file mode 100644
index 00000000..accbf1f3
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCHardDecisionTest.java
@@ -0,0 +1,560 @@
+/*
+ * *****************************************************************************
+ * 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.bptc;
+
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.edac.Hamming13;
+import io.github.dsheirer.edac.Hamming15;
+import io.github.dsheirer.edac.Hamming16;
+import io.github.dsheirer.edac.Hamming17;
+import io.github.dsheirer.edac.ProductCodeDecoder;
+import java.util.Arrays;
+import java.util.Random;
+import java.util.function.Predicate;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Error injection tests for the DMR BPTC hard decision decoders: every error pattern up to the guaranteed correction
+ * capability (half the product code minimum distance) is corrected. For the codes with an even minimum distance,
+ * error patterns with one more bit error are corrected or detected (message unchanged), but never miscorrected. Also
+ * tests the fallback decoders for error patterns that exceed the minimum distance decoder maximum.
+ */
+public class BPTCHardDecisionTest
+{
+ private static final int[] H15 = ProductCodeDecoder.getParityChecks(15, m -> Hamming15.getSyndrome(m, 0));
+ private static final int[] H13 = ProductCodeDecoder.getParityChecks(13,
+ m -> Hamming13.getSyndrome(m, new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}));
+ private static final int[] H16 = ProductCodeDecoder.getParityChecks(16, m -> Hamming16.getSyndrome(m, 0));
+ private static final int[] H17 = ProductCodeDecoder.getParityChecks(17, m -> Hamming17.getSyndrome(m, 0));
+
+ /**
+ * Valid codewords for the row code.
+ */
+ private static int[] getCodewords(int[] checks)
+ {
+ return java.util.stream.IntStream.range(0, 1 << checks.length)
+ .filter(word -> ProductCodeDecoder.getSyndrome(word, checks) == 0).toArray();
+ }
+
+ /**
+ * Creates a random product codeword as row words: random row codewords for the information rows and column parity
+ * rows from the systematic column code.
+ */
+ private static int[] createRows(Random random, int[] rowChecks, int[] columnChecks, int parityRows)
+ {
+ int[] codewords = getCodewords(rowChecks);
+ int[] rows = new int[columnChecks.length];
+ int informationRows = columnChecks.length - parityRows;
+
+ for(int row = 0; row < informationRows; row++)
+ {
+ rows[row] = codewords[random.nextInt(codewords.length)];
+ }
+
+ for(int column = 0; column < rowChecks.length; column++)
+ {
+ int columnSyndrome = 0;
+
+ for(int row = 0; row < informationRows; row++)
+ {
+ if((rows[row] & (1 << column)) != 0)
+ {
+ columnSyndrome ^= columnChecks[row];
+ }
+ }
+
+ for(int row = informationRows; row < rows.length; row++)
+ {
+ if((columnSyndrome & columnChecks[row]) != 0)
+ {
+ rows[row] |= 1 << column;
+ }
+ }
+ }
+
+ return rows;
+ }
+
+ /**
+ * Converts row words to a message where row r column c is bit offset + (r * columns) + c.
+ */
+ private static CorrectedBinaryMessage toMessage(int[] rows, int columns, int offset)
+ {
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(offset + rows.length * columns);
+
+ for(int row = 0; row < rows.length; row++)
+ {
+ for(int column = 0; column < columns; column++)
+ {
+ if((rows[row] & (1 << column)) != 0)
+ {
+ message.set(offset + row * columns + column);
+ }
+ }
+ }
+
+ return message;
+ }
+
+ private static int[] range(int from, int to)
+ {
+ return java.util.stream.IntStream.range(from, to).toArray();
+ }
+
+ private static CorrectedBinaryMessage withErrors(CorrectedBinaryMessage codeword, int[] errors)
+ {
+ CorrectedBinaryMessage received = new CorrectedBinaryMessage(codeword);
+
+ for(int error: errors)
+ {
+ received.flip(error);
+ }
+
+ return received;
+ }
+
+ /**
+ * Checks the decoder outcome for a received message: corrected to the codeword with the corrected bit count set to
+ * the error count, or (when allowed) uncorrectable with the message unchanged and the corrected bit count set to
+ * ERRORS_NOT_CORRECTED.
+ */
+ private static void check(Predicate decoder, CorrectedBinaryMessage codeword, int[] errors,
+ boolean mustCorrect)
+ {
+ CorrectedBinaryMessage received = withErrors(codeword, errors);
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(received);
+
+ if(decoder.test(message))
+ {
+ if(!message.equals(codeword))
+ {
+ fail("Miscorrected errors at " + Arrays.toString(errors));
+ }
+
+ assertEquals(errors.length, message.getCorrectedBitCount(), "Corrected bit count for " +
+ Arrays.toString(errors));
+ }
+ else
+ {
+ if(mustCorrect)
+ {
+ fail("Failed to correct errors at " + Arrays.toString(errors));
+ }
+
+ assertEquals(received, message, "Uncorrectable message was changed");
+ assertEquals(BPTCBase.ERRORS_NOT_CORRECTED, message.getCorrectedBitCount());
+ }
+ }
+
+ /**
+ * Checks every error pattern of the specified weight over the positions.
+ */
+ private static void checkAll(Predicate decoder, CorrectedBinaryMessage codeword,
+ int[] positions, int weight, boolean mustCorrect)
+ {
+ int[] pattern = new int[weight];
+ int[] errors = new int[weight];
+
+ for(int i = 0; i < weight; i++)
+ {
+ pattern[i] = i;
+ }
+
+ while(true)
+ {
+ for(int i = 0; i < weight; i++)
+ {
+ errors[i] = positions[pattern[i]];
+ }
+
+ check(decoder, codeword, errors, mustCorrect);
+
+ //Advance to the next combination
+ int i = weight - 1;
+
+ while(i >= 0 && pattern[i] == positions.length - weight + i)
+ {
+ i--;
+ }
+
+ if(i < 0)
+ {
+ return;
+ }
+
+ pattern[i]++;
+
+ for(int j = i + 1; j < weight; j++)
+ {
+ pattern[j] = pattern[j - 1] + 1;
+ }
+ }
+ }
+
+ /**
+ * Checks random error patterns of the specified weight over the positions.
+ */
+ private static void checkRandom(Random random, Predicate decoder,
+ CorrectedBinaryMessage codeword, int[] positions, int weight, int trials,
+ boolean mustCorrect)
+ {
+ for(int trial = 0; trial < trials; trial++)
+ {
+ int[] errors = random.ints(0, positions.length).distinct().limit(weight).map(i -> positions[i]).toArray();
+ check(decoder, codeword, errors, mustCorrect);
+ }
+ }
+
+ /**
+ * Test: BPTC(196,96) has a minimum distance of 9 and corrects every 1-4 bit error pattern. Exhaustive for 1-3 bit
+ * errors, all 2x2 row/column squares and random patterns for 4 bit errors. Bit 0 is a pad bit.
+ */
+ @Test
+ void testBPTC_196_96()
+ {
+ Random random = new Random(1);
+ CorrectedBinaryMessage codeword = toMessage(createRows(random, H15, H13, 4), 15, 1);
+ Predicate decoder = BPTC_196_96::correct;
+ int[] positions = range(1, 196);
+
+ check(decoder, codeword, new int[0], true);
+
+ for(int weight = 1; weight <= 3; weight++)
+ {
+ checkAll(decoder, codeword, positions, weight, true);
+ }
+
+ //Errors at the corners of every row/column square, which shadow each other for row and column decoding
+ for(int row1 = 0; row1 < 13; row1++)
+ {
+ for(int row2 = row1 + 1; row2 < 13; row2++)
+ {
+ for(int column1 = 0; column1 < 15; column1++)
+ {
+ for(int column2 = column1 + 1; column2 < 15; column2++)
+ {
+ check(decoder, codeword, new int[]{BPTC_196_96.getIndex(column1, row1),
+ BPTC_196_96.getIndex(column2, row1), BPTC_196_96.getIndex(column1, row2),
+ BPTC_196_96.getIndex(column2, row2)}, true);
+ }
+ }
+ }
+ }
+
+ checkRandom(random, decoder, codeword, positions, 4, 100000, true);
+
+ //Beyond the guaranteed correction capability, most patterns are corrected. Error injection testing: 99.7% of 8
+ //bit error patterns are corrected.
+ ProductCodeDecoder validator = new ProductCodeDecoder(H15, H13, 0);
+
+ for(int weight = 5; weight <= 8; weight++)
+ {
+ int corrected = 0;
+ int trials = 2000;
+
+ for(int trial = 0; trial < trials; trial++)
+ {
+ CorrectedBinaryMessage other = toMessage(createRows(random, H15, H13, 4), 15, 1);
+ int[] errors = random.ints(0, positions.length).distinct().limit(weight).map(i -> positions[i])
+ .toArray();
+ CorrectedBinaryMessage received = withErrors(other, errors);
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(received);
+
+ if(BPTC_196_96.correct(message))
+ {
+ assertTrue(validator.isValid(getRows196(message)), "Corrected message is not a valid codeword");
+
+ if(message.equals(other))
+ {
+ corrected++;
+ }
+ }
+ else
+ {
+ assertEquals(received, message, "Uncorrectable message was changed");
+ }
+ }
+
+ assertTrue(corrected >= trials * 0.99, "Corrected " + corrected + "/" + trials + " " + weight +
+ " bit error patterns");
+ }
+ }
+
+ /**
+ * Row words of the de-interleaved BPTC(196,96) message.
+ */
+ private static int[] getRows196(CorrectedBinaryMessage message)
+ {
+ int[] rows = new int[13];
+
+ for(int row = 0; row < 13; row++)
+ {
+ for(int column = 0; column < 15; column++)
+ {
+ if(message.get(BPTC_196_96.getIndex(column, row)))
+ {
+ rows[row] |= 1 << column;
+ }
+ }
+ }
+
+ return rows;
+ }
+
+ /**
+ * Test: BPTC(196,96) turbo decoding fallback corrects spread error patterns that exceed the minimum distance decoder
+ * maximum: one bit error in each row, in distinct columns (13 bit errors).
+ */
+ @Test
+ void testBPTC_196_96SpreadErrors()
+ {
+ Random random = new Random(6);
+
+ for(int trial = 0; trial < 200; trial++)
+ {
+ CorrectedBinaryMessage codeword = toMessage(createRows(random, H15, H13, 4), 15, 1);
+ int[] columns = random.ints(0, 15).distinct().limit(13).toArray();
+ int[] errors = new int[13];
+
+ for(int row = 0; row < 13; row++)
+ {
+ errors[row] = BPTC_196_96.getIndex(columns[row], row);
+ }
+
+ check(BPTC_196_96::correct, codeword, errors, true);
+ }
+ }
+
+ /**
+ * Test: BPTC(196,96) random words are either uncorrectable (unchanged) or corrected to a valid codeword.
+ */
+ @Test
+ void testBPTC_196_96RandomWords()
+ {
+ Random random = new Random(2);
+ ProductCodeDecoder validator = new ProductCodeDecoder(H15, H13, 0);
+
+ for(int trial = 0; trial < 2000; trial++)
+ {
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(196);
+
+ for(int i = 0; i < 196; i++)
+ {
+ message.set(i, random.nextBoolean());
+ }
+
+ CorrectedBinaryMessage received = new CorrectedBinaryMessage(message);
+
+ if(BPTC_196_96.correct(message))
+ {
+ assertTrue(validator.isValid(getRows196(message)), "Corrected message is not a valid codeword");
+ }
+ else
+ {
+ assertEquals(received, message);
+ }
+ }
+ }
+
+ /**
+ * Test: BPTC(128,77) has a minimum distance of 8, corrects every 1-3 bit error pattern and never miscorrects a 4 bit
+ * error pattern.
+ */
+ @Test
+ void testBPTC_128_77()
+ {
+ Random random = new Random(3);
+ int[] columnChecks = new int[8];
+ Arrays.fill(columnChecks, 1);
+ BPTC_128_77 bptc = new BPTC_128_77();
+ Predicate decoder = bptc::correct;
+ int[] positions = range(0, 128);
+
+ for(int codewordCount = 0; codewordCount < 2; codewordCount++)
+ {
+ CorrectedBinaryMessage codeword = toMessage(createRows(random, H16, columnChecks, 1), 16, 0);
+ check(decoder, codeword, new int[0], true);
+
+ for(int weight = 1; weight <= 3; weight++)
+ {
+ checkAll(decoder, codeword, positions, weight, true);
+ }
+
+ checkRandom(random, decoder, codeword, positions, 4, 100000, false);
+ }
+ }
+
+ /**
+ * Test: BPTC(128,77) treats a received word that is equidistant from two codewords as uncorrectable.
+ */
+ @Test
+ void testBPTC_128_77Equidistant()
+ {
+ //A minimum weight (8) product codeword: a weight 4 row codeword repeated in two rows. Received with half of
+ //its bits set, the word is 4 bits from both this codeword and the all-zero codeword.
+ int rowCodeword = Arrays.stream(getCodewords(H16)).filter(word -> Integer.bitCount(word) == 4).findFirst()
+ .orElseThrow();
+ int[] rows = new int[8];
+ rows[2] = rowCodeword & -rowCodeword;
+ rows[2] |= Integer.lowestOneBit(rowCodeword ^ rows[2]);
+ rows[5] = rowCodeword ^ rows[2];
+ CorrectedBinaryMessage received = toMessage(rows, 16, 0);
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(received);
+ assertFalse(new BPTC_128_77().correct(message));
+ assertEquals(received, message);
+ }
+
+ /**
+ * Test: BPTC(68,36) has a minimum distance of 6, corrects every 1-2 bit error pattern and never miscorrects a 3 bit
+ * error pattern.
+ */
+ @Test
+ void testBPTC_68_36()
+ {
+ Random random = new Random(4);
+ int[] columnChecks = new int[]{1, 1, 1, 1};
+ BPTC_68_36 bptc = new BPTC_68_36();
+ Predicate decoder = bptc::correct;
+ int[] positions = range(0, 68);
+
+ //Multiple codewords, since the previous Hamming(17,12,3) decoder failed for some codewords and not others
+ for(int codewordCount = 0; codewordCount < 8; codewordCount++)
+ {
+ CorrectedBinaryMessage codeword = toMessage(createRows(random, H17, columnChecks, 1), 17, 0);
+ check(decoder, codeword, new int[0], true);
+
+ for(int weight = 1; weight <= 2; weight++)
+ {
+ checkAll(decoder, codeword, positions, weight, true);
+ }
+
+ checkAll(decoder, codeword, positions, 3, false);
+ }
+ }
+
+ /**
+ * Test: BPTC(68,36) row erasure fallback corrects most 5-7 bit error patterns in a single row, which exceed the
+ * minimum distance decoder maximum, and never miscorrects them. The patterns where the row decodes to a codeword
+ * that is equidistant with the transmitted codeword are uncorrectable.
+ */
+ @Test
+ void testBPTC_68_36RowErrors()
+ {
+ Random random = new Random(7);
+ int[] columnChecks = new int[]{1, 1, 1, 1};
+ BPTC_68_36 bptc = new BPTC_68_36();
+ int trials = 3000;
+ int corrected = 0;
+
+ for(int trial = 0; trial < trials; trial++)
+ {
+ CorrectedBinaryMessage codeword = toMessage(createRows(random, H17, columnChecks, 1), 17, 0);
+ int row = random.nextInt(4);
+ int[] errors = random.ints(0, 17).distinct().limit(5 + trial % 3).map(column -> row * 17 + column)
+ .toArray();
+ check(bptc::correct, codeword, errors, false);
+
+ CorrectedBinaryMessage message = withErrors(codeword, errors);
+
+ if(bptc.correct(message))
+ {
+ corrected++;
+ }
+ }
+
+ //Error injection testing: corrects 78%
+ assertTrue(corrected >= trials * 0.7, "Corrected " + corrected + "/" + trials);
+ }
+
+ /**
+ * Interleaves a 32-bit single burst deinterleaved message, using the BPTC_16_2 deinterleave mapping.
+ */
+ private static CorrectedBinaryMessage interleave16_2(CorrectedBinaryMessage deinterleaved)
+ {
+ CorrectedBinaryMessage interleaved = new CorrectedBinaryMessage(32);
+
+ for(int x = 0; x < 32; x++)
+ {
+ CorrectedBinaryMessage unit = new CorrectedBinaryMessage(32);
+ unit.set(x);
+ interleaved.set(x, deinterleaved.get(BPTC_16_2.deinterleave(unit).nextSetBit(0)));
+ }
+
+ return interleaved;
+ }
+
+ /**
+ * Test: BPTC(16,2) single burst (non-reverse and reverse channel) has a minimum distance of 8, corrects every 1-3
+ * bit error pattern and never miscorrects a 4 bit error pattern.
+ */
+ @Test
+ void testBPTC_16_2()
+ {
+ Random random = new Random(5);
+ int[] codewords = getCodewords(H16);
+
+ for(boolean reverseChannel: new boolean[]{false, true})
+ {
+ for(int codewordCount = 0; codewordCount < 4; codewordCount++)
+ {
+ int row = codewords[random.nextInt(codewords.length)];
+ CorrectedBinaryMessage codeword = toMessage(new int[]{row, reverseChannel ? row ^ 0xFFFF : row}, 16, 0);
+ Predicate decoder = message -> {
+ CorrectedBinaryMessage interleaved = interleave16_2(message);
+ CorrectedBinaryMessage decoded = reverseChannel ? BPTC_16_2.decodeReverseChannel(interleaved) :
+ BPTC_16_2.decodeShortBurst(interleaved);
+
+ if(decoded == null)
+ {
+ message.setCorrectedBitCount(BPTCBase.ERRORS_NOT_CORRECTED);
+ return false;
+ }
+
+ message.clear();
+ message.xor(decoded);
+ message.setCorrectedBitCount(decoded.getCorrectedBitCount());
+ return true;
+ };
+
+ check(decoder, codeword, new int[0], true);
+
+ for(int weight = 1; weight <= 3; weight++)
+ {
+ checkAll(decoder, codeword, range(0, 32), weight, true);
+ }
+
+ checkAll(decoder, codeword, range(0, 32), 4, false);
+ }
+ }
+
+ //Non-reverse channel decoder rejects a reverse channel message and vice versa
+ int row = codewords[17];
+ assertNull(BPTC_16_2.decodeShortBurst(interleave16_2(toMessage(new int[]{row, row ^ 0xFFFF}, 16, 0))));
+ assertNotNull(BPTC_16_2.decodeReverseChannel(interleave16_2(toMessage(new int[]{row, row ^ 0xFFFF}, 16, 0))));
+ assertNull(BPTC_16_2.decodeReverseChannel(interleave16_2(toMessage(new int[]{row, row}, 16, 0))));
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java
index fdebdd99..0e4210eb 100644
--- a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java
@@ -134,7 +134,7 @@ public class BPTC_196_96SoftTest
Random random = new Random(2);
CorrectedBinaryMessage codeword = createCodeword(random);
CorrectedBinaryMessage copy = new CorrectedBinaryMessage(codeword);
- assertTrue(BPTC_196_96.correct(copy, true));
+ assertTrue(BPTC_196_96.correct(copy));
assertEquals(codeword, copy);
float[] softBits = new float[196];
@@ -185,7 +185,7 @@ public class BPTC_196_96SoftTest
hard.flip(error);
}
- if(!BPTC_196_96.correct(hard, true) || !hard.equals(codeword))
+ if(!BPTC_196_96.correct(hard) || !hard.equals(codeword))
{
hardFailures++;
}