#2656 DMR Hamming and BPTC error detection and correction algorithm enhancements (#2657)

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-11-15 03:46:55 -05:00
committed by GitHub
parent 26a24fc6ad
commit be0966653b
15 changed files with 1970 additions and 766 deletions

View File

@@ -37,7 +37,8 @@ public class Hamming13
*
* @param message containing a Hamming protected word
* @param offset to the start of the protected word
* @return message index for an error bit or -1 if no errors are detected.
* @return message index for an error bit, -1 if no errors are detected, or 1000 if more than one bit error is
* detected.
*/
public static int getErrorIndex(BinaryMessage message, int offset)
{
@@ -45,7 +46,8 @@ public class Hamming13
if(syndrome > 0)
{
return offset + ERROR_INDEX[syndrome];
int errorIndex = ERROR_INDEX[syndrome];
return errorIndex >= 0 ? offset + errorIndex : IHamming.MULTIPLE_ERRORS;
}
return IHamming.NO_ERRORS;

View File

@@ -47,13 +47,6 @@ public class Hamming16 implements IHamming
return NO_ERRORS;
}
//If the syndrome indicates the error is in the final parity bit position, and we already have odd parity, then
//flag it as invalid for multiple errors.
if(syndrome == 1 && message.getSubMessage(offset, offset + 16).cardinality() % 2 == 1) //check final parity bit
{
return MULTIPLE_ERRORS;
}
for(int index = 0; index < CHECKSUMS.length; index++)
{
if(syndrome == CHECKSUMS[index])

View File

@@ -46,13 +46,6 @@ public class Hamming17 implements IHamming
return NO_ERRORS;
}
//If the syndrome indicates the error is in the final parity bit position, and we already have odd parity, then
//flag it as invalid for multiple errors.
if(syndrome == 1 && message.getSubMessage(offset, offset + 17).cardinality() % 2 == 1) //check final parity bit
{
return MULTIPLE_ERRORS;
}
for(int index = 0; index < CHECKSUMS.length; index++)
{
if(syndrome == CHECKSUMS[index])

View File

@@ -0,0 +1,421 @@
/*
* *****************************************************************************
* 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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.edac;
import io.github.dsheirer.bits.BinaryMessage;
import java.util.function.ToIntFunction;
/**
* Hard decision minimum distance decoder for binary product codes, where each row is a codeword of a short row code
* and each column is a codeword of a systematic column code with the column parity bits in the final rows.
*
* Finds the minimum weight error pattern that produces the received row and column syndromes, using an iterative
* deepening search over the error patterns (coset) of each information row syndrome. The column parity row errors
* are fully determined by the column syndromes and the information row error choices, and the column parity rows then
* also match their row syndromes, so only the information rows are searched.
*
* Corrects every error pattern up to half the product code minimum distance, and most patterns up to the maximum
* error count. When more than one error pattern has the minimum weight, the received word is equidistant from more
* than one codeword and is treated as uncorrectable.
*
* Row words are integers where bit i (LSB = 0) holds the bit in column i. Instances are thread safe.
*/
public class ProductCodeDecoder
{
public static final int UNCORRECTABLE = -1;
//Bounds the decode time for pathological inputs (~20 ms). Error injection testing with random error patterns of
//each weight: the largest search was 630k nodes (3 ms) for BPTC(196,96) with a maximum error count of 12.
private static final long MAXIMUM_SEARCH_NODES = 4_000_000;
private final int mColumnCount;
private final int mRowCount;
private final int mInformationRowCount;
private final int[] mRowChecks;
private final int[] mColumnChecks;
private final int mColumnCheckBits;
private final long mColumnGroupMask;
private final int mMaximumErrors;
private final int[][] mCosets;
private final int[] mMinimumWeights;
/**
* Constructs an instance.
* @param rowChecks parity check matrix column (syndrome of a single bit error) for each row code bit position, where
* the array length is the column count (maximum 20).
* @param columnChecks parity check matrix column for each column code bit position, where the array length is the
* row count. The column code must be systematic, with distinct single bit parity checks for the final (parity)
* rows. For a single (even) parity check column code, each value is 1.
* @param maximumErrors maximum number of bit errors to correct.
*/
public ProductCodeDecoder(int[] rowChecks, int[] columnChecks, int maximumErrors)
{
mColumnCount = rowChecks.length;
mRowCount = columnChecks.length;
mRowChecks = rowChecks.clone();
mColumnChecks = columnChecks.clone();
mMaximumErrors = maximumErrors;
mColumnCheckBits = bitLength(mColumnChecks);
mInformationRowCount = mRowCount - mColumnCheckBits;
if(mColumnCount < 1 || mColumnCount > 20)
{
throw new IllegalArgumentException("Column count must be in range 1 - 20");
}
if(mColumnCount * mColumnCheckBits > Long.SIZE || mInformationRowCount < 1)
{
throw new IllegalArgumentException("Unsupported column code dimensions");
}
//Verify the final rows are the systematic parity rows
int parityChecks = 0;
for(int row = mInformationRowCount; row < mRowCount; row++)
{
int check = mColumnChecks[row];
if(Integer.bitCount(check) != 1 || (parityChecks & check) != 0)
{
throw new IllegalArgumentException("Column code parity rows must have distinct single bit checks");
}
parityChecks |= check;
}
long groupMask = 0;
for(int column = 0; column < mColumnCount; column++)
{
groupMask |= 1L << (column * mColumnCheckBits);
}
mColumnGroupMask = groupMask;
//Coset lists: the error patterns for each row syndrome, up to the maximum error count, ordered by weight
int syndromeCount = 1 << bitLength(mRowChecks);
int maximumWeight = Math.min(maximumErrors, mColumnCount);
int wordCount = 1 << mColumnCount;
int[] syndromes = new int[wordCount];
int[] sizes = new int[syndromeCount];
for(int word = 0; word < wordCount; word++)
{
syndromes[word] = getSyndrome(word, mRowChecks);
if(Integer.bitCount(word) <= maximumWeight)
{
sizes[syndromes[word]]++;
}
}
mCosets = new int[syndromeCount][];
mMinimumWeights = new int[syndromeCount];
for(int syndrome = 0; syndrome < syndromeCount; syndrome++)
{
mCosets[syndrome] = new int[sizes[syndrome]];
}
int[] filled = new int[syndromeCount];
for(int weight = 0; weight <= maximumWeight; weight++)
{
for(int word = 0; word < wordCount; word++)
{
if(Integer.bitCount(word) == weight)
{
int syndrome = syndromes[word];
mCosets[syndrome][filled[syndrome]++] = word;
}
}
}
for(int syndrome = 0; syndrome < syndromeCount; syndrome++)
{
mMinimumWeights[syndrome] = mCosets[syndrome].length > 0 ? Integer.bitCount(mCosets[syndrome][0]) :
maximumWeight + 1;
}
}
/**
* Number of bits needed to represent the largest value.
*/
private static int bitLength(int[] values)
{
int combined = 0;
for(int value: values)
{
combined |= value;
}
return Integer.SIZE - Integer.numberOfLeadingZeros(combined);
}
/**
* Calculates the parity check matrix columns for a code from a syndrome calculator, as the syndrome of each single
* bit error.
* @param length of the code in bits
* @param syndrome calculator for a binary message of the code length.
* @return parity check matrix column for each bit position.
*/
public static int[] getParityChecks(int length, ToIntFunction<BinaryMessage> syndrome)
{
int[] checks = new int[length];
for(int i = 0; i < length; i++)
{
BinaryMessage message = new BinaryMessage(length);
message.set(i);
checks[i] = syndrome.applyAsInt(message);
}
return checks;
}
/**
* Calculates the syndrome of the word.
* @param word where bit i (LSB = 0) is code bit position i
* @param checks parity check matrix column for each bit position
* @return syndrome, zero for a valid codeword.
*/
public static int getSyndrome(int word, int[] checks)
{
int syndrome = 0;
while(word != 0)
{
syndrome ^= checks[Integer.numberOfTrailingZeros(word)];
word &= word - 1;
}
return syndrome;
}
/**
* Indicates if all rows and columns are valid codewords.
* @param rows to check
* @return true if valid.
*/
public boolean isValid(int[] rows)
{
long columnSyndromes = 0;
for(int row = 0; row < mRowCount; row++)
{
if(getSyndrome(rows[row], mRowChecks) != 0)
{
return false;
}
columnSyndromes ^= getColumnContribution(row, rows[row]);
}
return columnSyndromes == 0;
}
/**
* Corrects the rows to the nearest valid product codeword when the number of bit errors doesn't exceed the
* maximum error count and the nearest codeword is unique.
* @param rows to correct, where bit i of each row is the bit in column i. Unchanged when uncorrectable.
* @return number of corrected bit errors, or UNCORRECTABLE.
*/
public int decode(int[] rows)
{
if(rows.length != mRowCount)
{
throw new IllegalArgumentException("Expected " + mRowCount + " rows");
}
Search search = new Search();
boolean rowErrors = false;
for(int row = 0; row < mRowCount; row++)
{
search.mRowSyndromes[row] = getSyndrome(rows[row], mRowChecks);
rowErrors |= search.mRowSyndromes[row] != 0;
search.mColumnSyndromes ^= getColumnContribution(row, rows[row]);
}
if(!rowErrors && search.mColumnSyndromes == 0)
{
return 0;
}
//Lower bound on the error count of the remaining rows, including the column parity rows, at each search depth
int lowerBound = 0;
for(int row = mInformationRowCount; row < mRowCount; row++)
{
lowerBound += mMinimumWeights[search.mRowSyndromes[row]];
}
search.mLowerBounds[mInformationRowCount] = lowerBound;
for(int row = mInformationRowCount - 1; row >= 0; row--)
{
search.mLowerBounds[row] = search.mLowerBounds[row + 1] + mMinimumWeights[search.mRowSyndromes[row]];
}
int start = Math.max(search.mLowerBounds[0], getErrorColumnCount(search.mColumnSyndromes));
for(search.mBudget = start; search.mBudget <= mMaximumErrors; search.mBudget++)
{
search(search, 0, 0, 0L);
if(search.mAborted || search.mSolutionCount > 1)
{
return UNCORRECTABLE;
}
if(search.mSolutionCount == 1)
{
for(int row = 0; row < mInformationRowCount; row++)
{
rows[row] ^= search.mSolution[row];
}
long residual = search.mColumnSyndromes ^ search.mSolutionContribution;
for(int row = mInformationRowCount; row < mRowCount; row++)
{
int check = mColumnChecks[row];
for(int column = 0; column < mColumnCount; column++)
{
if(((residual >>> (column * mColumnCheckBits)) & check) != 0)
{
rows[row] ^= 1 << column;
}
}
}
return search.mBudget;
}
}
return UNCORRECTABLE;
}
/**
* Depth first search over the information row error patterns, for solutions that don't exceed the error budget.
* @param search state
* @param row to choose an error pattern for
* @param used error count of the error patterns chosen for the preceding rows
* @param contribution to the column syndromes from the error patterns chosen for the preceding rows
*/
private void search(Search search, int row, int used, long contribution)
{
if(++search.mNodes > MAXIMUM_SEARCH_NODES)
{
search.mAborted = true;
return;
}
//The remaining column syndromes must be resolved by the remaining rows
long residual = search.mColumnSyndromes ^ contribution;
if(row == mInformationRowCount)
{
//Each remaining column syndrome bit is a column parity row error
if(used + Long.bitCount(residual) <= search.mBudget && ++search.mSolutionCount == 1)
{
System.arraycopy(search.mChosen, 0, search.mSolution, 0, mInformationRowCount);
search.mSolutionContribution = contribution;
}
return;
}
if(used + Math.max(search.mLowerBounds[row], getErrorColumnCount(residual)) > search.mBudget)
{
return;
}
int remainingLowerBound = search.mLowerBounds[row + 1];
for(int error: mCosets[search.mRowSyndromes[row]])
{
int weight = Integer.bitCount(error);
if(used + weight + remainingLowerBound > search.mBudget)
{
break; //Error patterns are ordered by weight
}
search.mChosen[row] = error;
search(search, row + 1, used + weight, contribution ^ getColumnContribution(row, error));
if(search.mAborted || search.mSolutionCount > 1)
{
return;
}
}
}
/**
* Calculates the contribution of a row word to the column syndromes, where the syndrome for each column occupies
* a group of column check bits, starting with column 0 in the LSBs.
*/
private long getColumnContribution(int row, int word)
{
long contribution = 0;
long check = mColumnChecks[row];
while(word != 0)
{
contribution ^= check << (Integer.numberOfTrailingZeros(word) * mColumnCheckBits);
word &= word - 1;
}
return contribution;
}
/**
* Number of columns with a non-zero syndrome, where each requires at least one more bit error.
*/
private int getErrorColumnCount(long columnSyndromes)
{
long folded = columnSyndromes;
for(int shift = 1; shift < mColumnCheckBits; shift++)
{
folded |= columnSyndromes >>> shift;
}
return Long.bitCount(folded & mColumnGroupMask);
}
/**
* Search state for a single decode.
*/
private final class Search
{
private final int[] mRowSyndromes = new int[mRowCount];
private final int[] mLowerBounds = new int[mInformationRowCount + 1];
private final int[] mChosen = new int[mInformationRowCount];
private final int[] mSolution = new int[mInformationRowCount];
private long mColumnSyndromes;
private long mSolutionContribution;
private int mBudget;
private int mSolutionCount;
private long mNodes;
private boolean mAborted;
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 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
@@ -21,127 +21,222 @@ 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.IHamming;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import io.github.dsheirer.edac.ProductCodeDecoder;
import java.util.Arrays;
/**
* Block Product Turbo Code (BPTC) base implementation for BPTC codes using simple even parity column checks.
* Block Product Turbo Code (BPTC) base implementation for BPTC codes where each row is a Hamming codeword and each
* column has even parity (ie the final row is the column parity row). Bits are ordered row by row.
*
* Uses hard decision minimum distance decoding that corrects every error pattern up to half the product code minimum
* distance and most patterns up to the maximum error count. Optionally falls back to row erasure decoding when the
* minimum distance decoder can't correct the message.
*/
public abstract class BPTCBase
{
public static int ERRORS_NOT_CORRECTED = -2;
private IHamming mHamming;
private int mColumnCount;
private int mRowCount;
private final ProductCodeDecoder mDecoder;
private final int[] mRowChecks;
private final int[] mRowErrorColumns;
private final int mColumnCount;
private final int mRowCount;
private final int mFallbackMaximumErrors;
/**
* Constructs an instance
* @param hamming to provide row checking using the correct Hamming error detection and correction algorithm.
* @param column count
* @param row count
* @param rowChecks parity check matrix column for each bit position of the row (Hamming) code, where the array
* length is the column count.
* @param rowCount number of rows, including the column parity row.
* @param maximumErrors maximum number of bit errors to correct with minimum distance decoding.
* @param fallbackMaximumErrors maximum number of bit errors to correct with row erasure decoding when the minimum
* distance decoder can't correct the message, or 0 to disable the fallback.
*/
public BPTCBase(IHamming hamming, int columnCount, int rowCount)
public BPTCBase(int[] rowChecks, int rowCount, int maximumErrors, int fallbackMaximumErrors)
{
mHamming = hamming;
mColumnCount = columnCount;
mRowChecks = rowChecks.clone();
mColumnCount = rowChecks.length;
mRowCount = rowCount;
mFallbackMaximumErrors = fallbackMaximumErrors;
int[] columnChecks = new int[rowCount];
Arrays.fill(columnChecks, 1);
mDecoder = new ProductCodeDecoder(rowChecks, columnChecks, maximumErrors);
//Single bit error locator for the row code: column for each syndrome, or -1
int combined = 0;
for(int check: rowChecks)
{
combined |= check;
}
mRowErrorColumns = new int[Integer.highestOneBit(combined) << 1];
Arrays.fill(mRowErrorColumns, -1);
for(int column = 0; column < mColumnCount; column++)
{
mRowErrorColumns[rowChecks[column]] = column;
}
}
/**
* Used by the correct() method to employ an optional attempt to correct multiple row, two-bit errors where each
* set of row errors don't shadow each other based on a test where the column cardinality is twice the row
* cardinality. This is probably not mathematically correct and should only be enabled when the decoded message
* payload has a good CRC check after the extraction (ie. only use for FLC).
* @return true if enabled, false (default) otherwise
*/
protected boolean canCorrectMultiRow2BitErrors()
{
return false;
}
/**
* Performs error detection and correction.
* Performs error detection and correction. When corrected, the corrected bit count is incremented by the number
* of corrected bits. Otherwise, the message is unchanged and the corrected bit count is set to ERRORS_NOT_CORRECTED.
* @param message to correct
* @return true if the message was corrected or false if there are uncorrectable errors.
*/
public boolean correct(CorrectedBinaryMessage message)
{
BinaryMessage columns = getColumnErrors(message);
BinaryMessage rows = getRowErrors(message);
int[] rows = new int[mRowCount];
if(columns.isEmpty() && rows.isEmpty())
for(int row = 0; row < mRowCount; row++)
{
return true;
rows[row] = getRow(row, message);
}
List<Integer> correctedIndexes = new ArrayList<>();
int corrected = mDecoder.decode(rows);
//1: fix non-shadowed single bit errors in each row
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
if(corrected == ProductCodeDecoder.UNCORRECTABLE && mFallbackMaximumErrors > 0)
{
int index = correct1BitErrors(row, message, rows);
corrected = correctRowErasure(rows);
}
if(index >= 0)
if(corrected == ProductCodeDecoder.UNCORRECTABLE)
{
message.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
return false;
}
if(corrected > 0)
{
for(int row = 0; row < mRowCount; row++)
{
correctedIndexes.add(index);
}
}
//Refresh the column errors
columns = getColumnErrors(message);
if(columns.isEmpty() && rows.isEmpty())
{
message.incrementCorrectedBitCount(correctedIndexes.size());
return true;
}
//2: fix non-shadowed multi bit errors (up to 2) in each row
if(columns.cardinality() <= 2)
{
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
{
List<Integer> solution = correctMultiBitErrors(row, message, columns, rows);
if(solution.size() > 0)
for(int column = 0; column < mColumnCount; column++)
{
correctedIndexes.addAll(solution);
message.set(row * mColumnCount + column, (rows[row] & (1 << column)) != 0);
}
}
message.incrementCorrectedBitCount(corrected);
}
return true;
}
/**
* Row erasure decoding fallback for messages that the minimum distance decoder can't correct (more bit errors than
* the maximum, or equidistant from more than one codeword). Corrects single bit errors in each row and then
* recomputes one row from the column parity of the other rows, choosing the row that produces the nearest
* codeword. Real-world messages can have bit errors that this corrects beyond the minimum distance decoder maximum.
* @param rows to correct, unchanged when uncorrectable.
* @return number of corrected bit errors, or UNCORRECTABLE.
*/
private int correctRowErasure(int[] rows)
{
int[] decoded = rows.clone();
int failedRow = -1;
for(int row = 0; row < mRowCount; row++)
{
int syndrome = ProductCodeDecoder.getSyndrome(rows[row], mRowChecks);
if(syndrome != 0)
{
int column = mRowErrorColumns[syndrome];
if(column >= 0)
{
decoded[row] ^= 1 << column;
}
else if(failedRow >= 0)
{
return ProductCodeDecoder.UNCORRECTABLE; //More than one row with uncorrectable errors
}
else
{
failedRow = row;
}
}
}
//3: fix single row, multi-column errors
if(rows.cardinality() == 1 && !columns.isEmpty())
{
List<Integer> solution = correctMultiBitErrors(rows.nextSetBit(0), message, columns, rows);
int[] best = null;
int bestDistance = Integer.MAX_VALUE;
boolean tie = false;
if(!solution.isEmpty())
for(int erased = 0; erased < mRowCount; erased++)
{
if(failedRow >= 0 && erased != failedRow)
{
correctedIndexes.addAll(solution);
continue;
}
int[] candidate = decoded.clone();
int parity = 0;
for(int row = 0; row < mRowCount; row++)
{
if(row != erased)
{
parity ^= candidate[row];
}
}
candidate[erased] = parity;
int distance = 0;
for(int row = 0; row < mRowCount; row++)
{
distance += Integer.bitCount(candidate[row] ^ rows[row]);
}
if(distance < bestDistance)
{
best = candidate;
bestDistance = distance;
tie = false;
}
else if(distance == bestDistance && !Arrays.equals(candidate, best))
{
tie = true;
}
}
//4: fix one or more rows that each have 2 bit errors, not shadowing each other.
if(canCorrectMultiRow2BitErrors() && rows.cardinality() > 0 && columns.cardinality() / rows.cardinality() == 2)
if(tie || bestDistance > mFallbackMaximumErrors)
{
List<Integer> solution = correctMultipleRowTwoBitErrorsNotShadowing(columns, rows, message);
return ProductCodeDecoder.UNCORRECTABLE;
}
if(!solution.isEmpty())
System.arraycopy(best, 0, rows, 0, mRowCount);
return bestDistance;
}
/**
* Row word from the message, where bit i is the bit in column i.
*/
private int getRow(int row, BinaryMessage message)
{
int word = 0;
int offset = row * mColumnCount;
for(int column = 0; column < mColumnCount; column++)
{
if(message.get(offset + column))
{
correctedIndexes.addAll(solution);
word |= 1 << column;
}
}
if(columns.isEmpty() && rows.isEmpty())
{
message.incrementCorrectedBitCount(correctedIndexes.size());
return true;
}
return word;
}
message.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
return false;
/**
* Calculates the row (Hamming) syndrome.
* @param row to check
* @param message containing bits to check
* @return syndrome, zero when the row is a valid codeword.
*/
private int getRowSyndrome(int row, BinaryMessage message)
{
return ProductCodeDecoder.getSyndrome(getRow(row, message), mRowChecks);
}
/**
@@ -161,49 +256,6 @@ public abstract class BPTCBase
return correct;
}
/**
* Calculates the column from the bit index/offset
* @param index for a bit
* @return column that contains the index
*/
public int getColumn(int index)
{
return index % mColumnCount;
}
/**
* Calculates the index for a bit that exists at the row and column.
* @param column for the bit.
* @param row for the bit
* @return index for the bit.
*/
public int getIndex(int column, int row)
{
return row * mColumnCount + column;
}
/**
* Checks the row for bit errors.
* @param row to check
* @param message containing bits to check
* @return true if the row passes the Hamming check or false if not.
*/
private boolean isRowCorrect(int row, CorrectedBinaryMessage message)
{
return mHamming.getErrorIndex(message, row * mColumnCount) == IHamming.NO_ERRORS;
}
/**
* Calculates the index for the bit error in the message
* @param row to check
* @param message with bits
* @return error index in the range 0-195, or -1 if the message row has no bit errors.
*/
private int getRowErrorIndex(int row, CorrectedBinaryMessage message)
{
return mHamming.getErrorIndex(message, row * mColumnCount);
}
/**
* Returns a bit map indicating the row errors.
* @param message containing rows.
@@ -215,7 +267,7 @@ public abstract class BPTCBase
for(int row = 0; row < mRowCount; row++)
{
rows.set(row, !isRowCorrect(row, message));
rows.set(row, getRowSyndrome(row, message) != 0);
}
return rows;
@@ -238,191 +290,6 @@ public abstract class BPTCBase
return columns;
}
/**
* 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 List<Integer> getIntersectionIndices(BinaryMessage columns, BinaryMessage rows)
{
List<Integer> intersections = new ArrayList<>();
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
{
for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount; column = columns.nextSetBit(column + 1))
{
intersections.add(getIndex(column, row));
}
}
return intersections;
}
/**
* 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 List<Integer> getIntersectionIndices(BinaryMessage columns, int row)
{
List<Integer> intersections = new ArrayList<>();
for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount; column = columns.nextSetBit(column + 1))
{
intersections.add(getIndex(column, row));
}
return intersections;
}
/**
* Correct multiple rows with two-bit errors each where the error columns are not shadowing each other.
* @param columns error map
* @param rows error map
* @param message to correct
* @return corrected indices
*/
public List<Integer> correctMultipleRowTwoBitErrorsNotShadowing(BinaryMessage columns, BinaryMessage rows,
CorrectedBinaryMessage message)
{
List<Integer> solution = new ArrayList<>();
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
{
for(int column1 = columns.nextSetBit(0); column1 >= 0; column1 = columns.nextSetBit(column1 + 1))
{
for(int column2 = columns.nextSetBit(column1 + 1); column2 >= 0; column2 = columns.nextSetBit(column2 + 1))
{
int index1 = getIndex(column1, row);
int index2 = getIndex(column2, row);
message.flip(index1);
message.flip(index2);
if(isRowCorrect(row, message))
{
solution.add(index1);
solution.add(index2);
columns.clear(column1);
columns.clear(column2);
rows.clear(row);
column1 = mColumnCount;
column2 = mColumnCount;
}
else
{
message.flip(index1);
message.flip(index2);
}
}
}
}
return solution;
}
/**
* Corrects single bit errors in each row using only the Hamming error index.
* @param row to correct
* @param message with bits
* @param rows error flag set
* @return bit index that was corrected or -2 if the error was not corrected
*/
public int correct1BitErrors(int row, CorrectedBinaryMessage message, BinaryMessage rows)
{
int index = getRowErrorIndex(row, message);
if(index >= 0 && index < IHamming.MULTIPLE_ERRORS)
{
message.flip(index);
if(isRowCorrect(row, message))
{
rows.clear(row);
return index;
}
message.flip(index);
}
return ERRORS_NOT_CORRECTED;
}
/**
* Corrects multiple bit errors in a single row when all other rows are corrected first, by flipping the
* bits in the row that show errors in the columns and testing the solution.
* @param row to correct
* @param message with bits
* @param columns bit error matrix
* @param rows bit error matrix
* @return solution or an empty list if no solution.
*/
public List<Integer> correctMultiBitErrors(int row, CorrectedBinaryMessage message, BinaryMessage columns,
BinaryMessage rows)
{
//Test flipping bits in the row everywhere that we have a column error
List<Integer> 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");
}

View File

@@ -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));

View File

@@ -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.
* <p>
* Uses hard decision minimum distance decoding across both rows, which corrects up to 3 bit errors.
* <p>
* 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("-------------------------");
}
}

View File

@@ -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<Integer> getRowErrors(CorrectedBinaryMessage message)
{
List<Integer> 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<Integer> getColumnErrors(CorrectedBinaryMessage message)
{
List<Integer> 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<Integer> intersection(List<Integer> list1, List<Integer> list2)
{
List<Integer> 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<Integer> getIntersectionIndices(BinaryMessage columns, BinaryMessage rows)
{
List<Integer> 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<Integer> columnErrors = getColumnErrors(message);
List<Integer> rowErrors = getRowErrors(message);
if(columnErrors.isEmpty() && rowErrors.isEmpty())
{
return true;
}
//Fix the easy bits first.
List<Integer> flippedBits = new ArrayList<>();
List<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> correctColumn(int column, CorrectedBinaryMessage message, BinaryMessage columns,
BinaryMessage rows, int depth, boolean pursue, List<Integer> 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<Integer> 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<Integer> 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<Integer> correctRow(int row, CorrectedBinaryMessage message, BinaryMessage columns,
BinaryMessage rows, int depth, boolean pursue, List<Integer> 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<Integer> 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<Integer> 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);

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<Integer> columnErrors = getColumnErrors(message);
List<Integer> 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<Integer> 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<Integer> correctColumn(int column, CorrectedBinaryMessage message, BinaryMessage columns,
BinaryMessage rows, int depth, boolean pursue, List<Integer> 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<Integer> 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<Integer> 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<Integer> correctRow(int row, CorrectedBinaryMessage message, BinaryMessage columns,
BinaryMessage rows, int depth, boolean pursue, List<Integer> 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<Integer> 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<Integer> 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<Integer> getRowErrors(CorrectedBinaryMessage message)
{
List<Integer> 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<Integer> getColumnErrors(CorrectedBinaryMessage message)
{
List<Integer> 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<Integer> intersection(List<Integer> list1, List<Integer> list2)
{
List<Integer> 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<Integer> getIntersectionIndices(BinaryMessage columns, BinaryMessage rows)
{
List<Integer> 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;
}
}

View File

@@ -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);
}
/**

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<BinaryMessage> syndrome,
ToIntFunction<BinaryMessage> 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));
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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);
}
}

View File

@@ -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<Integer> errors = new HashSet<>();
while(errors.size() < 10)
while(errors.size() < 14)
{
errors.add(payloadDibits.get(random.nextInt(payloadDibits.size())));
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<CorrectedBinaryMessage> 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<CorrectedBinaryMessage> 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<CorrectedBinaryMessage> 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<CorrectedBinaryMessage> 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<CorrectedBinaryMessage> 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<CorrectedBinaryMessage> 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<CorrectedBinaryMessage> 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))));
}
}

View File

@@ -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++;
}