diff --git a/src/main/java/io/github/dsheirer/edac/Hamming13.java b/src/main/java/io/github/dsheirer/edac/Hamming13.java index c0b4e1a6..9b755c1a 100644 --- a/src/main/java/io/github/dsheirer/edac/Hamming13.java +++ b/src/main/java/io/github/dsheirer/edac/Hamming13.java @@ -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; diff --git a/src/main/java/io/github/dsheirer/edac/Hamming16.java b/src/main/java/io/github/dsheirer/edac/Hamming16.java index deae6408..4ffe7390 100644 --- a/src/main/java/io/github/dsheirer/edac/Hamming16.java +++ b/src/main/java/io/github/dsheirer/edac/Hamming16.java @@ -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]) diff --git a/src/main/java/io/github/dsheirer/edac/Hamming17.java b/src/main/java/io/github/dsheirer/edac/Hamming17.java index 7b756cf5..fb9108ba 100644 --- a/src/main/java/io/github/dsheirer/edac/Hamming17.java +++ b/src/main/java/io/github/dsheirer/edac/Hamming17.java @@ -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]) diff --git a/src/main/java/io/github/dsheirer/edac/ProductCodeDecoder.java b/src/main/java/io/github/dsheirer/edac/ProductCodeDecoder.java new file mode 100644 index 00000000..5d13400b --- /dev/null +++ b/src/main/java/io/github/dsheirer/edac/ProductCodeDecoder.java @@ -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 + * **************************************************************************** + */ + +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 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; + } +} diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCBase.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCBase.java index 3cf4625f..79223070 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCBase.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTCBase.java @@ -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 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 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 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 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 getIntersectionIndices(BinaryMessage columns, BinaryMessage rows) - { - List 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 getIntersectionIndices(BinaryMessage columns, int row) - { - List 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 correctMultipleRowTwoBitErrorsNotShadowing(BinaryMessage columns, BinaryMessage rows, - CorrectedBinaryMessage message) - { - List 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 correctMultiBitErrors(int row, CorrectedBinaryMessage message, BinaryMessage columns, - BinaryMessage rows) - { - //Test flipping bits in the row everywhere that we have a column error - List testFlips = new ArrayList<>(); - - for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount; - column = columns.nextSetBit(column + 1)) - { - int toFlip = getIndex(column, row); - message.flip(toFlip); - testFlips.add(toFlip); - } - - if(!isRowCorrect(row, message)) - { - int index = getRowErrorIndex(row, message); - - if(index >= 0 && index < IHamming.MULTIPLE_ERRORS) - { - message.flip(index); - - if(testFlips.contains(index)) - { - testFlips.removeIf(value -> value == index); - } - else - { - testFlips.add(index); - } - } - } - - if(isRowCorrect(row, message)) - { - //Check the columns also - - boolean columnsCorrect = true; - - for(Integer flip: testFlips) - { - columnsCorrect &= isColumnCorrect(getColumn(flip), message); - } - - if(columnsCorrect) - { - rows.clear(row); - - for(Integer flip: testFlips) - { - columns.clear(getColumn(flip)); - } - - return testFlips; - } - } - - for(Integer flip: testFlips) - { - message.flip(flip); - } - - return Collections.emptyList(); - } - public void logErrorMap(CorrectedBinaryMessage message) { StringBuilder sb = new StringBuilder(); @@ -456,15 +323,10 @@ public abstract class BPTCBase int offset = row * mColumnCount; sb.append(" Row ").append(row).append(": "); sb.append(message.getSubMessage(offset, offset + mColumnCount)); - int index = getRowErrorIndex(row, message); - if(index != IHamming.NO_ERRORS) + int syndrome = getRowSyndrome(row, message); + if(syndrome != 0) { - sb.append(" < Index:").append(index); - - if(index == IHamming.MULTIPLE_ERRORS) - { - sb.append(" (2+ bit errors)"); - } + sb.append(" < Syndrome:0x").append(Integer.toHexString(syndrome).toUpperCase()); } sb.append("\n"); } diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java index 890efcf4..4d5cac99 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java @@ -22,6 +22,7 @@ package io.github.dsheirer.module.decode.dmr.bptc; import io.github.dsheirer.bits.BinaryMessage; import io.github.dsheirer.bits.CorrectedBinaryMessage; import io.github.dsheirer.edac.Hamming16; +import io.github.dsheirer.edac.ProductCodeDecoder; import io.github.dsheirer.edac.soft.SoftBlockCode; import io.github.dsheirer.edac.soft.SoftProductCodeDecoder; @@ -39,10 +40,12 @@ public class BPTC_128_77 extends BPTCBase //require correcting high reliability bits are rejected to avoid false decodes. Noise injection testing: all //soft decisions with a cost up to 3.0 were correct, whereas most of those with a higher cost were false decodes. private static final float SOFT_MAX_CORRECTION_COST = 3.0f; - //Maximum corrected bit count for a hard decision result. Hard decisions that correct more bits are treated as - //uncorrectable to avoid false decodes, given the weak 5-bit checksum. Noise injection testing: all hard - //decisions with more than 6 corrected bits were false decodes. - private static final int HARD_MAX_CORRECTED_BITS = 6; + //Maximum number of bit errors corrected by the hard decision minimum distance decoder. The product code minimum + //distance of 8 guarantees correction of 3 bit errors. Correcting more increases the miscorrection rate, and the + //embedded link control is only protected by a 5-bit checksum, with soft decision decoding only applied when hard + //decision decoding fails. AWGN simulation of the hard + soft decoding sequence: a maximum of 5 decoded more + //messages than the previous hard decision decoder at each noise level, with 3-5x fewer false decodes. + private static final int HARD_MAXIMUM_ERRORS = 5; //Soft decision decoder for the Hamming(16,11,4) rows and even parity columns private static final SoftProductCodeDecoder SOFT_DECODER = new SoftProductCodeDecoder( SoftBlockCode.fromCodewordTest(COLUMN_COUNT, word -> Hamming16.getSyndrome(toMessage(word), 0) == 0), @@ -68,16 +71,9 @@ public class BPTC_128_77 extends BPTCBase public BPTC_128_77() { - super(new Hamming16(), 16, 8); - } - - /** - * Allow the base class to attempt to correct multiple rows of 2-bit errors each - */ - @Override - protected boolean canCorrectMultiRow2BitErrors() - { - return true; + //No row erasure fallback, given the weak 5-bit checksum. Recording testing: no messages were lost without it. + super(ProductCodeDecoder.getParityChecks(COLUMN_COUNT, message -> Hamming16.getSyndrome(message, 0)), ROW_COUNT, + HARD_MAXIMUM_ERRORS, 0); } /** @@ -91,9 +87,8 @@ public class BPTC_128_77 extends BPTCBase } /** - * Performs error detection and correction and extracts the payload from the BPTC encoded message. Hard decision - * results that exceed the maximum corrected bit count are treated as uncorrectable. When the hard decision - * decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding. + * Performs error detection and correction and extracts the payload from the BPTC encoded message. When the hard + * decision decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding. * @param message with BPTC encoding. * @param softBits for the 128 (interleaved) bits of the message where a positive value favors a 1 bit, or null. * @return decoded message. @@ -103,11 +98,6 @@ public class BPTC_128_77 extends BPTCBase CorrectedBinaryMessage deinterleaved = deinterleave(message); correct(deinterleaved); - if(deinterleaved.getCorrectedBitCount() > HARD_MAX_CORRECTED_BITS) - { - deinterleaved.setCorrectedBitCount(ERRORS_NOT_CORRECTED); - } - if(softBits != null && softBits.length == LENGTH && deinterleaved.getCorrectedBitCount() < 0) { CorrectedBinaryMessage softDecoded = decodeSoft(deinterleave(softBits)); diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java index 3bae9c2d..d47402cc 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_16_2.java @@ -22,9 +22,14 @@ package io.github.dsheirer.module.decode.dmr.bptc; import io.github.dsheirer.bits.BinaryMessage; import io.github.dsheirer.bits.CorrectedBinaryMessage; import io.github.dsheirer.edac.Hamming16; +import io.github.dsheirer.edac.ProductCodeDecoder; /** - * Block Product Turbo Code 16/2 for decoding a DMR Voice Frame F payload from the 32-bit EMB field. + * Block Product Turbo Code 16/2 for decoding a DMR Voice Frame F payload from the 32-bit EMB field. Each of the two + * rows is the same Hamming(16,11,4) codeword, where the second row is inverted (odd column parity) for the reverse + * channel, forming a code with a minimum distance of 8. + *

+ * Uses hard decision minimum distance decoding across both rows, which corrects up to 3 bit errors. *

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