mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 08:37:25 -05:00
* Uses same shared architecture and symbol processor pattern as other C4FM decoders. * Adds soft symbol processing and sync detection. * Removes RRC filter and replaces with improved channel filter. * Viterbi soft symbol decoding with demodulator reliability weighting * Improved sync reacquisition in low SNR * Improved handling for phase wrapping and interpolation * NAC Tracker updated to track by NAC count * Native buffers returning partial buffers * Message processor uses only valid LDU1 words * Reduced phantom TDU messages * Tuner/Channe frequency error corrections don't reset the symbol trackers and fix possible divide by zero error * LSM timing error detector now updates per symbol block size instead of sample buffer size - faster convergence * New unit tests * Resolves several issues, including a lagging sync detect offset issue in the recently updated P25P1 C4FM decoder. * Consolidate filter designers for each protocol back into the FilterFactory class. * DMR, P25 Phase 1 C4FM and LSM, and P25 Phase 2 decoders design the baseband filter once and share the taps across the I and Q filters. Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.buffer;
|
||||
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import io.github.dsheirer.sample.complex.InterleavedComplexSamples;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* JUnit tests for the float native buffer.
|
||||
*/
|
||||
public class FloatNativeBufferTest
|
||||
{
|
||||
private static final int SAMPLE_COUNT = 5000;
|
||||
|
||||
/**
|
||||
* Creates interleaved complex samples where each value is unique and non-zero.
|
||||
*/
|
||||
private static float[] getSamples()
|
||||
{
|
||||
float[] samples = new float[SAMPLE_COUNT * 2];
|
||||
|
||||
for(int x = 0; x < samples.length; x++)
|
||||
{
|
||||
samples[x] = x + 1;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the complex samples iterator returns every sample exactly once, with a final chunk that holds the remaining
|
||||
* samples rather than padding them to the full chunk length with zero valued samples.
|
||||
*/
|
||||
@Test
|
||||
void testComplexSamplesIterator()
|
||||
{
|
||||
float[] samples = getSamples();
|
||||
List<Integer> lengths = new ArrayList<>();
|
||||
int pointer = 0;
|
||||
|
||||
Iterator<ComplexSamples> iterator = new FloatNativeBuffer(samples, 0L, 0.0f).iterator();
|
||||
|
||||
while(iterator.hasNext())
|
||||
{
|
||||
ComplexSamples chunk = iterator.next();
|
||||
lengths.add(chunk.i().length);
|
||||
|
||||
for(int x = 0; x < chunk.i().length; x++)
|
||||
{
|
||||
assertEquals(samples[pointer++], chunk.i()[x], "Inphase sample " + (pointer / 2));
|
||||
assertEquals(samples[pointer++], chunk.q()[x], "Quadrature sample " + (pointer / 2));
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(samples.length, pointer, "Sample count");
|
||||
assertEquals(List.of(2048, 2048, 904), lengths, "Chunk lengths");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the interleaved complex samples iterator returns every sample exactly once, without zero padding.
|
||||
*/
|
||||
@Test
|
||||
void testInterleavedComplexSamplesIterator()
|
||||
{
|
||||
float[] samples = getSamples();
|
||||
List<Integer> lengths = new ArrayList<>();
|
||||
int pointer = 0;
|
||||
|
||||
Iterator<InterleavedComplexSamples> iterator = new FloatNativeBuffer(samples, 0L, 0.0f).iteratorInterleaved();
|
||||
|
||||
while(iterator.hasNext())
|
||||
{
|
||||
float[] chunk = iterator.next().samples();
|
||||
lengths.add(chunk.length / 2);
|
||||
|
||||
for(float sample : chunk)
|
||||
{
|
||||
assertEquals(samples[pointer], sample, "Sample " + pointer);
|
||||
pointer++;
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(samples.length, pointer, "Sample count");
|
||||
assertEquals(List.of(2048, 2048, 904), lengths, "Chunk lengths");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.dsp.fm;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* JUnit tests for the FM discriminator soft symbol reliability.
|
||||
*/
|
||||
public class FMDiscriminatorReliabilityTest
|
||||
{
|
||||
private static final int BUFFER_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Test: inverse power filter taps are the squared differences of the post-discriminator filter taps.
|
||||
*/
|
||||
@Test
|
||||
void testInversePowerFilterTaps()
|
||||
{
|
||||
float[] taps = FMDiscriminatorReliability.getInversePowerFilterTaps(new float[]{0.25f, 0.5f, 0.25f});
|
||||
assertArrayEquals(new float[]{0.0625f, 0.0625f, 0.0625f, 0.0625f}, taps, 1e-7f, "Taps");
|
||||
|
||||
//An integrate and dump (flat) filter depends only on the samples at either end of the filter span
|
||||
float[] flat = FMDiscriminatorReliability.getInversePowerFilterTaps(new float[]{0.2f, 0.2f, 0.2f, 0.2f, 0.2f});
|
||||
assertArrayEquals(new float[]{0.04f, 0f, 0f, 0f, 0f, 0.04f}, flat, 1e-7f, "Flat filter taps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: each reliability is 1 / sum(g[k]^2 / |x[n - k]|^2), including across the sample buffer boundary.
|
||||
*/
|
||||
@Test
|
||||
void testReliabilities()
|
||||
{
|
||||
float[] filter = {0.1f, 0.2f, 0.4f, 0.2f, 0.1f};
|
||||
float[] g2 = FMDiscriminatorReliability.getInversePowerFilterTaps(filter);
|
||||
FMDiscriminatorReliability reliability = new FMDiscriminatorReliability(filter);
|
||||
float[] amplitudes = new float[2 * BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < amplitudes.length; x++)
|
||||
{
|
||||
amplitudes[x] = 0.1f + 0.9f * (float)Math.abs(Math.sin(x * 0.37));
|
||||
}
|
||||
|
||||
float[] reliabilities = new float[amplitudes.length];
|
||||
|
||||
for(int buffer = 0; buffer < 2; buffer++)
|
||||
{
|
||||
float[] i = new float[BUFFER_LENGTH];
|
||||
float[] q = new float[BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < BUFFER_LENGTH; x++)
|
||||
{
|
||||
//Rotate the phase to show that the reliability depends only on the sample power
|
||||
double phase = x * 0.5;
|
||||
float amplitude = amplitudes[buffer * BUFFER_LENGTH + x];
|
||||
i[x] = (float)(amplitude * Math.cos(phase));
|
||||
q[x] = (float)(amplitude * Math.sin(phase));
|
||||
}
|
||||
|
||||
System.arraycopy(reliability.calculate(i, q), 0, reliabilities, buffer * BUFFER_LENGTH, BUFFER_LENGTH);
|
||||
}
|
||||
|
||||
for(int x = g2.length; x < amplitudes.length; x++)
|
||||
{
|
||||
double sum = 0.0;
|
||||
|
||||
for(int k = 0; k < g2.length; k++)
|
||||
{
|
||||
sum += g2[k] / (amplitudes[x - k] * amplitudes[x - k]);
|
||||
}
|
||||
|
||||
assertEquals(1.0 / sum, reliabilities[x], 1e-4 / sum, "Reliability at " + x);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.edac.trellis;
|
||||
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
import io.github.dsheirer.dsp.symbol.Dibit;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* JUnit tests for the P25 1/2 rate trellis soft decision Viterbi decoder.
|
||||
*/
|
||||
public class ViterbiDecoder_1_2_P25Test
|
||||
{
|
||||
private static final int INPUT_DIBITS = 48;
|
||||
//Faded symbol spacing for the reliability weighting test (ie one third of the symbols are faded)
|
||||
private static final int SPACING = 3;
|
||||
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
|
||||
|
||||
/**
|
||||
* Encodes the input dibit values (plus a flushing zero) into 98 ideal soft symbols.
|
||||
*/
|
||||
private static float[] encode(int[] inputs)
|
||||
{
|
||||
float[] symbols = new float[(inputs.length + 1) * 2];
|
||||
int state = 0;
|
||||
|
||||
for(int step = 0; step <= inputs.length; step++)
|
||||
{
|
||||
int input = step < inputs.length ? inputs[step] : 0;
|
||||
int output = P25_1_2_Node.TRANSITION_MATRIX[state][input];
|
||||
symbols[2 * step] = DIBITS[output >> 2].getIdealPhase();
|
||||
symbols[2 * step + 1] = DIBITS[output & 3].getIdealPhase();
|
||||
state = input;
|
||||
}
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the decoded message bits that differ from the input dibit values.
|
||||
*/
|
||||
private static int countBitErrors(int[] inputs, CorrectedBinaryMessage decoded)
|
||||
{
|
||||
int errors = 0;
|
||||
|
||||
for(int step = 0; step < inputs.length; step++)
|
||||
{
|
||||
if(decoded.get(2 * step) != ((inputs[step] & 2) == 2))
|
||||
{
|
||||
errors++;
|
||||
}
|
||||
|
||||
if(decoded.get(2 * step + 1) != ((inputs[step] & 1) == 1))
|
||||
{
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static int[] randomInputs(Random random)
|
||||
{
|
||||
int[] inputs = new int[INPUT_DIBITS];
|
||||
|
||||
for(int x = 0; x < inputs.length; x++)
|
||||
{
|
||||
inputs[x] = random.nextInt(4);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: ideal soft symbols decode without error in both the linear (C4FM) and phase wrapped (LSM) modes.
|
||||
*/
|
||||
@Test
|
||||
void testIdealSymbols()
|
||||
{
|
||||
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
|
||||
Random random = new Random(1);
|
||||
|
||||
for(int trial = 0; trial < 50; trial++)
|
||||
{
|
||||
int[] inputs = randomInputs(random);
|
||||
float[] symbols = encode(inputs);
|
||||
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols)), "Linear");
|
||||
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols, true)), "Phase wrapped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: phase wrapped decoding measures the soft symbol error around the circle, so outer symbols that noise
|
||||
* pushed across the +/- PI phase wrap are decoded correctly.
|
||||
*/
|
||||
@Test
|
||||
void testOuterSymbolsAcrossPhaseWrap()
|
||||
{
|
||||
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
|
||||
Random random = new Random(2);
|
||||
int linearErrors = 0;
|
||||
int wrappedErrors = 0;
|
||||
|
||||
for(int trial = 0; trial < 200; trial++)
|
||||
{
|
||||
int[] inputs = randomInputs(random);
|
||||
float[] symbols = encode(inputs);
|
||||
int outerCount = 0;
|
||||
|
||||
for(int x = 0; x < symbols.length; x++)
|
||||
{
|
||||
//Push every third outer (+/- 3PI/4) symbol 0.85 radians toward and across the phase wrap
|
||||
if(Math.abs(symbols[x]) > Math.PI / 2 && (outerCount++ % 3) == 0)
|
||||
{
|
||||
double pushed = symbols[x] + Math.signum(symbols[x]) * 0.85;
|
||||
symbols[x] = (float)(pushed - 2.0 * Math.PI * Math.round(pushed / (2.0 * Math.PI)));
|
||||
}
|
||||
}
|
||||
|
||||
linearErrors += countBitErrors(inputs, decoder.decodeSoft(symbols));
|
||||
wrappedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, true));
|
||||
}
|
||||
|
||||
assertEquals(0, wrappedErrors, "Phase wrapped bit errors");
|
||||
assertTrue(wrappedErrors <= linearErrors, "Phase wrapped decoding no worse than linear decoding");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: reliability weighting decodes corrupted symbols that have low reliability that unweighted soft decision
|
||||
* decoding fails to decode. The corrupted symbols are spaced across the block, the way the deinterleaver spreads
|
||||
* the symbols of a transmitted envelope fade.
|
||||
*/
|
||||
@Test
|
||||
void testReliabilityWeightedFade()
|
||||
{
|
||||
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
|
||||
Random random = new Random(4);
|
||||
int unweightedErrors = 0;
|
||||
int weightedErrors = 0;
|
||||
|
||||
for(int trial = 0; trial < 50; trial++)
|
||||
{
|
||||
int[] inputs = randomInputs(random);
|
||||
float[] symbols = encode(inputs);
|
||||
float[] reliabilities = new float[symbols.length];
|
||||
java.util.Arrays.fill(reliabilities, 1.0f);
|
||||
//Replace every SPACING symbol with a random phase (ie a deep fade) and mark it with low reliability
|
||||
for(int x = random.nextInt(SPACING); x < symbols.length; x += SPACING)
|
||||
{
|
||||
symbols[x] = (float)((random.nextDouble() * 2.0 - 1.0) * Math.PI);
|
||||
reliabilities[x] = 0.01f;
|
||||
}
|
||||
|
||||
unweightedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, true));
|
||||
weightedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, reliabilities, true));
|
||||
}
|
||||
|
||||
assertEquals(0, weightedErrors, "Reliability weighted bit errors");
|
||||
assertTrue(unweightedErrors > 0, "Unweighted decoding fails to decode the faded symbols");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: unavailable or unusable reliabilities (null, NaN or all zero) decode the same as unweighted decoding.
|
||||
*/
|
||||
@Test
|
||||
void testUnusableReliabilities()
|
||||
{
|
||||
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
|
||||
Random random = new Random(5);
|
||||
|
||||
for(int trial = 0; trial < 20; trial++)
|
||||
{
|
||||
int[] inputs = randomInputs(random);
|
||||
float[] symbols = encode(inputs);
|
||||
|
||||
for(int x = 0; x < symbols.length; x++)
|
||||
{
|
||||
symbols[x] += (float)(random.nextGaussian() * 0.5);
|
||||
}
|
||||
|
||||
CorrectedBinaryMessage unweighted = decoder.decodeSoft(symbols, true);
|
||||
float[] nan = new float[symbols.length];
|
||||
java.util.Arrays.fill(nan, 1.0f);
|
||||
nan[7] = Float.NaN;
|
||||
assertEquals(unweighted, decoder.decodeSoft(symbols, null, true), "Null reliabilities");
|
||||
assertEquals(unweighted, decoder.decodeSoft(symbols, nan, true), "NaN reliability");
|
||||
assertEquals(unweighted, decoder.decodeSoft(symbols, new float[symbols.length], true), "Zero reliabilities");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: soft symbols that are not phase wrapped (C4FM) with outer symbols beyond +/- PI decode correctly with
|
||||
* linear error measurement.
|
||||
*/
|
||||
@Test
|
||||
void testUnwrappedOuterSymbolsBeyondPi()
|
||||
{
|
||||
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
|
||||
Random random = new Random(3);
|
||||
|
||||
for(int trial = 0; trial < 50; trial++)
|
||||
{
|
||||
int[] inputs = randomInputs(random);
|
||||
float[] symbols = encode(inputs);
|
||||
|
||||
for(int x = 0; x < symbols.length; x++)
|
||||
{
|
||||
if(Math.abs(symbols[x]) > Math.PI / 2)
|
||||
{
|
||||
symbols[x] += (float)(Math.signum(symbols[x]) * 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols)), "Linear");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.p25.phase1;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* JUnit tests for the NAC tracker.
|
||||
*/
|
||||
public class NACTrackerTest
|
||||
{
|
||||
/**
|
||||
* Test: the dominant NAC needs the minimum number of observations.
|
||||
*/
|
||||
@Test
|
||||
void testDominantNAC()
|
||||
{
|
||||
NACTracker tracker = new NACTracker();
|
||||
assertEquals(0, tracker.getTrackedNAC(), "Empty");
|
||||
tracker.track(0x293);
|
||||
tracker.track(0x293);
|
||||
tracker.track(0x111);
|
||||
assertEquals(0, tracker.getTrackedNAC(), "Below the minimum observations");
|
||||
tracker.track(0x293);
|
||||
assertEquals(0x293, tracker.getTrackedNAC(), "Dominant");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: when a fourth NAC is observed, the least recently observed NAC is evicted, independent of the system clock.
|
||||
* All of the observations occur within the same millisecond, as they do when a recording decodes faster than real
|
||||
* time.
|
||||
*/
|
||||
@Test
|
||||
void testLeastRecentlyObservedEvicted()
|
||||
{
|
||||
for(int repeat = 0; repeat < 100; repeat++)
|
||||
{
|
||||
NACTracker tracker = new NACTracker();
|
||||
|
||||
//0x293 is the most observed, but the least recently observed when 0x444 arrives
|
||||
for(int x = 0; x < 5; x++)
|
||||
{
|
||||
tracker.track(0x293);
|
||||
}
|
||||
|
||||
for(int x = 0; x < 4; x++)
|
||||
{
|
||||
tracker.track(0x111);
|
||||
}
|
||||
|
||||
tracker.track(0x222);
|
||||
tracker.track(0x444);
|
||||
assertEquals(0x111, tracker.getTrackedNAC(), "Least recently observed NAC evicted, repeat " + repeat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.p25.phase1;
|
||||
|
||||
import io.github.dsheirer.dsp.symbol.Dibit;
|
||||
import io.github.dsheirer.edac.CRCP25;
|
||||
import io.github.dsheirer.edac.bch.BCH_63_16_23_P25;
|
||||
import io.github.dsheirer.edac.trellis.P25_1_2_Node;
|
||||
import io.github.dsheirer.message.IMessage;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.TSBKMessage;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import java.util.ArrayList;
|
||||
import java.util.BitSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* End-to-end JUnit tests for the P25 Phase 1 LSM decoder: a synthetic LSM (CQPSK) control channel carrying single
|
||||
* block TSBK frames is generated and decoded, and each valid decoded TSBK is checked against the transmitted TSBKs.
|
||||
*/
|
||||
public class P25P1DecoderLSMTest
|
||||
{
|
||||
private static final double SAMPLE_RATE = 48000.0;
|
||||
private static final double SYMBOL_RATE = 4800.0;
|
||||
private static final int FRAME_COUNT = 150;
|
||||
private static final int NAC = 0x293;
|
||||
//Single block TSBK frame: 180 dibits (360 bits) including a status dibit every 36th dibit and null padding
|
||||
private static final int FRAME_DIBITS = 180;
|
||||
private static final int TSBK_DUID = 7;
|
||||
private static final int OPCODE_RFSS_STATUS_BROADCAST = 58;
|
||||
//Raised cosine transmit pulse
|
||||
private static final double ROLLOFF = 0.2;
|
||||
private static final int PULSE_SPAN_SYMBOLS = 8;
|
||||
private static final int BUFFER_SIZE = 2048;
|
||||
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
|
||||
|
||||
/**
|
||||
* Test: a clean signal decodes every TSBK frame, with no invalid messages.
|
||||
*/
|
||||
@Test
|
||||
void testCleanSignal()
|
||||
{
|
||||
Result result = decode(0.0, 0.0, Double.NaN, 1, BUFFER_SIZE);
|
||||
assertEquals(0, result.invalid(), "Invalid messages");
|
||||
assertEquals(FRAME_COUNT, result.valid(), "Valid messages");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a carrier offset within the +/- 600 Hz fourth power offset estimator range and a 100 ppm symbol rate error
|
||||
* decode every TSBK frame.
|
||||
*/
|
||||
@Test
|
||||
void testCarrierOffsetAndSymbolRateError()
|
||||
{
|
||||
Result result = decode(300.0, 100.0, Double.NaN, 2, BUFFER_SIZE);
|
||||
assertEquals(0, result.invalid(), "Invalid messages");
|
||||
assertEquals(FRAME_COUNT, result.valid(), "Valid messages");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: carrier offsets beyond the fourth power offset estimator range are resolved by the offset ambiguity search,
|
||||
* after which every TSBK frame decodes. The search steps toward the adjacent offset range nearest to the estimate,
|
||||
* so offsets on either side acquire after one search dwell period (~11 frames).
|
||||
*/
|
||||
@Test
|
||||
void testOffsetAmbiguitySearch()
|
||||
{
|
||||
for(double carrierOffset : new double[]{700.0, -700.0, 900.0, -900.0})
|
||||
{
|
||||
Result result = decode(carrierOffset, -100.0, Double.NaN, 3, BUFFER_SIZE);
|
||||
assertEquals(0, result.invalid(), "Invalid messages at " + carrierOffset + " Hz");
|
||||
assertTrue(result.first() <= 16, "Acquisition at " + carrierOffset + " Hz: " + result.first() + " frames");
|
||||
assertEquals(0, result.missed(), "Missed frames after acquisition at " + carrierOffset + " Hz");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: acquisition of offsets near the edge of the fourth power offset estimator range is fast and independent of
|
||||
* the sample buffer size. The estimator previously updated once per buffer, so a -550 Hz offset took 36 frames to
|
||||
* acquire with 2048 sample buffers and 55 frames with 8192 sample buffers.
|
||||
*/
|
||||
@Test
|
||||
void testAcquisitionIndependentOfBufferSize()
|
||||
{
|
||||
for(int bufferSize : new int[]{512, 2048, 8192})
|
||||
{
|
||||
for(double carrierOffset : new double[]{550.0, -550.0})
|
||||
{
|
||||
Result result = decode(carrierOffset, 50.0, Double.NaN, 5, bufferSize);
|
||||
String condition = carrierOffset + " Hz with " + bufferSize + " sample buffers";
|
||||
assertEquals(0, result.invalid(), "Invalid messages at " + condition);
|
||||
assertTrue(result.first() <= 5, "Acquisition at " + condition + ": " + result.first() + " frames");
|
||||
assertEquals(0, result.missed(), "Missed frames after acquisition at " + condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: with additive white gaussian noise at 12 dB in-band (+/- 3 kHz) carrier to noise, nearly all TSBK frames
|
||||
* decode and every valid TSBK matches a transmitted TSBK.
|
||||
*/
|
||||
@Test
|
||||
void testNoise()
|
||||
{
|
||||
Result result = decode(200.0, 50.0, 12.0, 4, BUFFER_SIZE);
|
||||
assertTrue(result.valid() >= FRAME_COUNT - 5, "Valid messages: " + result.valid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and decodes the synthetic signal.
|
||||
* @param carrierOffset in Hertz
|
||||
* @param symbolRateError in parts per million
|
||||
* @param carrierToNoise in dB, in-band (+/- 3 kHz), or NaN for no noise
|
||||
* @param seed for the random TSBK payloads and noise
|
||||
* @param bufferSize of the sample buffers delivered to the decoder (even)
|
||||
* @return decoded message counts, the index of the first decoded TSBK frame (the frame count when
|
||||
* none decode) and the count of TSBK frames that
|
||||
* didn't decode after the first decoded TSBK frame.
|
||||
*/
|
||||
private static Result decode(double carrierOffset, double symbolRateError, double carrierToNoise, long seed,
|
||||
int bufferSize)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
List<BitSet> tsbks = new ArrayList<>();
|
||||
Set<BitSet> transmitted = new HashSet<>();
|
||||
List<Dibit> dibits = new ArrayList<>();
|
||||
|
||||
for(int frame = 0; frame < FRAME_COUNT; frame++)
|
||||
{
|
||||
BitSet tsbk = getTSBK(random);
|
||||
tsbks.add(tsbk);
|
||||
transmitted.add(tsbk);
|
||||
dibits.addAll(getFrame(tsbk));
|
||||
}
|
||||
|
||||
//Trailing frame that isn't checked: the decoder dispatches a message after its trailing symbols are processed
|
||||
dibits.addAll(getFrame(getTSBK(random)));
|
||||
|
||||
float[][] samples = modulate(dibits, carrierOffset, symbolRateError, carrierToNoise, random);
|
||||
|
||||
List<TSBKMessage> received = new ArrayList<>();
|
||||
P25P1DecoderLSM decoder = new P25P1DecoderLSM();
|
||||
decoder.setMessageListener((IMessage message) -> {
|
||||
if(message instanceof TSBKMessage tsbk)
|
||||
{
|
||||
received.add(tsbk);
|
||||
}
|
||||
});
|
||||
decoder.start();
|
||||
decoder.setSampleRate(SAMPLE_RATE);
|
||||
|
||||
for(int offset = 0; offset < samples[0].length; offset += bufferSize)
|
||||
{
|
||||
int length = Math.min(bufferSize, samples[0].length - offset);
|
||||
float[] i = new float[length];
|
||||
float[] q = new float[length];
|
||||
System.arraycopy(samples[0], offset, i, 0, length);
|
||||
System.arraycopy(samples[1], offset, q, 0, length);
|
||||
decoder.receive(new ComplexSamples(i, q, 0L));
|
||||
}
|
||||
|
||||
decoder.stop();
|
||||
|
||||
int valid = 0;
|
||||
int invalid = 0;
|
||||
Set<BitSet> decoded = new HashSet<>();
|
||||
|
||||
for(TSBKMessage tsbk : received)
|
||||
{
|
||||
if(tsbk.isValid())
|
||||
{
|
||||
BitSet bits = new BitSet();
|
||||
|
||||
for(int x = 0; x < 80; x++)
|
||||
{
|
||||
bits.set(x, tsbk.getMessage().get(x));
|
||||
}
|
||||
|
||||
//A valid TSBK that doesn't match a transmitted TSBK is a CRC false positive
|
||||
assertTrue(transmitted.contains(bits), "Decoded TSBK was not transmitted");
|
||||
assertTrue(decoded.add(bits), "Decoded TSBK was duplicated");
|
||||
valid++;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalid++;
|
||||
}
|
||||
}
|
||||
|
||||
int first = tsbks.size();
|
||||
int missed = 0;
|
||||
|
||||
for(int x = 0; x < tsbks.size(); x++)
|
||||
{
|
||||
if(decoded.contains(tsbks.get(x)))
|
||||
{
|
||||
first = Math.min(first, x);
|
||||
}
|
||||
else if(first < x)
|
||||
{
|
||||
missed++;
|
||||
}
|
||||
}
|
||||
|
||||
return new Result(valid, invalid, first, missed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a random single block (last block) TSBK, 80 message bits.
|
||||
*/
|
||||
private static BitSet getTSBK(Random random)
|
||||
{
|
||||
BitSet tsbk = new BitSet();
|
||||
tsbk.set(0); //Last block
|
||||
setValue(tsbk, 2, 6, OPCODE_RFSS_STATUS_BROADCAST);
|
||||
|
||||
for(int x = 16; x < 80; x++)
|
||||
{
|
||||
tsbk.set(x, random.nextBoolean());
|
||||
}
|
||||
|
||||
return tsbk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the frame dibits for the TSBK: sync, NID, interleaved trellis encoded TSBK and null padding, with a
|
||||
* status dibit at every 36th dibit.
|
||||
*/
|
||||
private static List<Dibit> getFrame(BitSet tsbk)
|
||||
{
|
||||
List<Dibit> data = new ArrayList<>();
|
||||
addBits(data, 0x5575F5FF77FFL, 48);
|
||||
addBits(data, BCH_63_16_23_P25.encode(NAC, TSBK_DUID) << 1, 64); //64th (parity) bit is zero
|
||||
data.addAll(encodeTSBK(tsbk));
|
||||
|
||||
List<Dibit> frame = new ArrayList<>();
|
||||
int pointer = 0;
|
||||
|
||||
for(int x = 1; x <= FRAME_DIBITS; x++)
|
||||
{
|
||||
if(x % 36 == 0)
|
||||
{
|
||||
frame.add(Dibit.D01_PLUS_3); //Outbound (repeater) status
|
||||
}
|
||||
else
|
||||
{
|
||||
frame.add(pointer < data.size() ? data.get(pointer++) : Dibit.D00_PLUS_1);
|
||||
}
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the CRC-CCITT checksum, 1/2 rate trellis encodes and interleaves the TSBK into 98 dibits.
|
||||
*/
|
||||
private static List<Dibit> encodeTSBK(BitSet tsbk)
|
||||
{
|
||||
BitSet message = (BitSet)tsbk.clone();
|
||||
int checksum = 0xFFFF;
|
||||
|
||||
for(int x = message.nextSetBit(0); x >= 0 && x < 80; x = message.nextSetBit(x + 1))
|
||||
{
|
||||
checksum ^= CRCP25.CCITT_80_CHECKSUMS[x];
|
||||
}
|
||||
|
||||
setValue(message, 80, 16, checksum);
|
||||
|
||||
//48 input dibits plus a flushing zero input produce 98 encoded dibits
|
||||
int[] encoded = new int[98];
|
||||
int state = 0;
|
||||
|
||||
for(int step = 0; step < 49; step++)
|
||||
{
|
||||
int input = step < 48 ? (message.get(2 * step) ? 2 : 0) + (message.get(2 * step + 1) ? 1 : 0) : 0;
|
||||
int output = P25_1_2_Node.TRANSITION_MATRIX[state][input];
|
||||
encoded[2 * step] = output >> 2;
|
||||
encoded[2 * step + 1] = output & 3;
|
||||
state = input;
|
||||
}
|
||||
|
||||
List<Dibit> interleaved = new ArrayList<>();
|
||||
|
||||
for(int dibit = 0; dibit < encoded.length; dibit++)
|
||||
{
|
||||
interleaved.add(DIBITS[encoded[P25P1Interleave.DATA_DEINTERLEAVE[2 * dibit] / 2]]);
|
||||
}
|
||||
|
||||
return interleaved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulates the dibits as LSM: differential QPSK with raised cosine pulse shaping.
|
||||
* @return i and q sample arrays
|
||||
*/
|
||||
private static float[][] modulate(List<Dibit> dibits, double carrierOffset, double symbolRateError,
|
||||
double carrierToNoise, Random random)
|
||||
{
|
||||
double[] symbolI = new double[dibits.size()];
|
||||
double[] symbolQ = new double[dibits.size()];
|
||||
double phase = 0.0;
|
||||
|
||||
for(int x = 0; x < dibits.size(); x++)
|
||||
{
|
||||
phase += dibits.get(x).getIdealPhase();
|
||||
symbolI[x] = Math.cos(phase);
|
||||
symbolQ[x] = Math.sin(phase);
|
||||
}
|
||||
|
||||
double samplesPerSymbol = SAMPLE_RATE / (SYMBOL_RATE * (1.0 + symbolRateError * 1e-6));
|
||||
//The decimation filter requires an even sample count
|
||||
int length = (int)((dibits.size() + PULSE_SPAN_SYMBOLS) * samplesPerSymbol) / 2 * 2;
|
||||
float[] i = new float[length];
|
||||
float[] q = new float[length];
|
||||
|
||||
//Unit power symbols: the in-band noise variance is the in-band fraction of the total noise variance
|
||||
double noise = Double.isNaN(carrierToNoise) ? 0.0 :
|
||||
Math.sqrt(Math.pow(10.0, -carrierToNoise / 10.0) * SAMPLE_RATE / 6000.0 / 2.0);
|
||||
|
||||
for(int n = 0; n < length; n++)
|
||||
{
|
||||
double t = n / samplesPerSymbol - PULSE_SPAN_SYMBOLS / 2.0; //Symbol periods
|
||||
double sumI = 0.0;
|
||||
double sumQ = 0.0;
|
||||
|
||||
for(int k = (int)Math.ceil(t - PULSE_SPAN_SYMBOLS / 2.0); k <= t + PULSE_SPAN_SYMBOLS / 2.0; k++)
|
||||
{
|
||||
if(k >= 0 && k < dibits.size())
|
||||
{
|
||||
double pulse = getRaisedCosine(t - k);
|
||||
sumI += symbolI[k] * pulse;
|
||||
sumQ += symbolQ[k] * pulse;
|
||||
}
|
||||
}
|
||||
|
||||
double carrier = 2.0 * Math.PI * carrierOffset * n / SAMPLE_RATE;
|
||||
double cos = Math.cos(carrier);
|
||||
double sin = Math.sin(carrier);
|
||||
i[n] = (float)(sumI * cos - sumQ * sin + noise * random.nextGaussian());
|
||||
q[n] = (float)(sumI * sin + sumQ * cos + noise * random.nextGaussian());
|
||||
}
|
||||
|
||||
return new float[][]{i, q};
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised cosine pulse value
|
||||
* @param t time in symbol periods
|
||||
*/
|
||||
private static double getRaisedCosine(double t)
|
||||
{
|
||||
double sinc = Math.abs(t) < 1e-9 ? 1.0 : Math.sin(Math.PI * t) / (Math.PI * t);
|
||||
double denominator = 1.0 - Math.pow(2.0 * ROLLOFF * t, 2.0);
|
||||
|
||||
if(Math.abs(denominator) < 1e-9)
|
||||
{
|
||||
return Math.PI / 4.0 * sinc;
|
||||
}
|
||||
|
||||
return sinc * Math.cos(Math.PI * ROLLOFF * t) / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the most significant bits first, as dibits.
|
||||
*/
|
||||
private static void addBits(List<Dibit> dibits, long value, int bitCount)
|
||||
{
|
||||
for(int bit = bitCount - 2; bit >= 0; bit -= 2)
|
||||
{
|
||||
dibits.add(DIBITS[(int)((value >> bit) & 3)]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value at the bit offset, most significant bit first.
|
||||
*/
|
||||
private static void setValue(BitSet bits, int offset, int bitCount, int value)
|
||||
{
|
||||
for(int x = 0; x < bitCount; x++)
|
||||
{
|
||||
bits.set(offset + x, ((value >> (bitCount - 1 - x)) & 1) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
private record Result(int valid, int invalid, int first, int missed) {}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.p25.phase1;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.FilterFactory;
|
||||
import io.github.dsheirer.dsp.symbol.Dibit;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* JUnit tests for the P25 Phase 1 LSM differential demodulator and differential integrator filter design.
|
||||
*/
|
||||
public class P25P1DifferentialDemodulatorLSMTest
|
||||
{
|
||||
private static final int SAMPLE_RATE = 24000;
|
||||
private static final int SAMPLES_PER_SYMBOL = 5;
|
||||
private static final int BUFFER_LENGTH = 1024;
|
||||
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
|
||||
|
||||
/**
|
||||
* Synthetic differential QPSK signal generator. Ramps the phase across each symbol period by the symbol's ideal
|
||||
* phase so that the phase change across each symbol period is the ideal symbol phase, plus a carrier offset.
|
||||
*/
|
||||
private static class SignalGenerator
|
||||
{
|
||||
private final Random mRandom = new Random(1);
|
||||
private final double mOffset; //Radians per sample
|
||||
private final double mNoise;
|
||||
private double mPhase = 0.0;
|
||||
private Dibit mSymbol = Dibit.D00_PLUS_1;
|
||||
private int mSampleCounter = 0;
|
||||
private Dibit mPreviousSymbol;
|
||||
|
||||
SignalGenerator(double offsetHertz, double noise)
|
||||
{
|
||||
mOffset = 2.0 * Math.PI * offsetHertz / SAMPLE_RATE;
|
||||
mNoise = noise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next buffer of samples, recording the symbol completed at each sample (or null).
|
||||
*/
|
||||
float[][] next(Dibit[] completedSymbols)
|
||||
{
|
||||
float[] i = new float[BUFFER_LENGTH];
|
||||
float[] q = new float[BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < BUFFER_LENGTH; x++)
|
||||
{
|
||||
if(mSampleCounter % SAMPLES_PER_SYMBOL == 0)
|
||||
{
|
||||
mPreviousSymbol = mSymbol;
|
||||
mSymbol = DIBITS[mRandom.nextInt(4)];
|
||||
completedSymbols[x] = mSampleCounter > 0 ? mPreviousSymbol : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
completedSymbols[x] = null;
|
||||
}
|
||||
|
||||
i[x] = (float)(Math.cos(mPhase) + mRandom.nextGaussian() * mNoise);
|
||||
q[x] = (float)(Math.sin(mPhase) + mRandom.nextGaussian() * mNoise);
|
||||
mPhase += mSymbol.getIdealPhase() / SAMPLES_PER_SYMBOL + mOffset;
|
||||
mSampleCounter++;
|
||||
}
|
||||
|
||||
return new float[][]{i, q};
|
||||
}
|
||||
}
|
||||
|
||||
private static double toHertz(float radiansPerSample)
|
||||
{
|
||||
return radiansPerSample * SAMPLE_RATE / (2.0 * Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the integrator taps span one symbol period with the fractional remainder in the final tap and unity DC gain.
|
||||
*/
|
||||
@Test
|
||||
void testDifferentialIntegratorDesign()
|
||||
{
|
||||
float[] taps = FilterFactory.getDifferentialIntegrator(5.2);
|
||||
assertEquals(6, taps.length, "Tap count");
|
||||
float sum = 0;
|
||||
|
||||
for(float tap : taps)
|
||||
{
|
||||
sum += tap;
|
||||
}
|
||||
|
||||
assertEquals(1.0, sum, 1e-5, "DC gain");
|
||||
assertEquals(1.0 / 5.2, taps[0], 1e-6, "Whole tap");
|
||||
assertEquals(0.2 / 5.2, taps[5], 1e-6, "Fractional tap");
|
||||
assertEquals(5, FilterFactory.getDifferentialIntegrator(5.0).length, "Integral samples per symbol");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the carrier offset estimate converges and each symbol period's demodulated sample, scaled to radians per
|
||||
* symbol, decides the transmitted symbol with the offset removed.
|
||||
*/
|
||||
@Test
|
||||
void testOffsetRemovalAndSymbolRecovery()
|
||||
{
|
||||
SignalGenerator generator = new SignalGenerator(300, 0.0);
|
||||
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
|
||||
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
Dibit[] completed = new Dibit[BUFFER_LENGTH];
|
||||
int symbolCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for(int buffer = 0; buffer < 400; buffer++)
|
||||
{
|
||||
float[][] samples = generator.next(completed);
|
||||
float[] demodulated = demodulator.demodulate(samples[0], samples[1], true);
|
||||
|
||||
//Check symbol decisions after the estimator converges
|
||||
if(buffer >= 300)
|
||||
{
|
||||
for(int x = 0; x < BUFFER_LENGTH; x++)
|
||||
{
|
||||
if(completed[x] != null)
|
||||
{
|
||||
symbolCount++;
|
||||
|
||||
if(Dibit.fromSample(demodulated[x] * SAMPLES_PER_SYMBOL) != completed[x])
|
||||
{
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(300.0, toHertz(demodulator.getFrequencyOffset()), 5.0, "Offset estimate");
|
||||
assertTrue(symbolCount > 10000, "Symbol count");
|
||||
assertEquals(0, errorCount, "Symbol errors");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: an offset beyond the +/- 600 Hz fourth power estimator range is found by the ambiguity search while
|
||||
* unsynchronized, and is held once synchronized.
|
||||
*/
|
||||
@Test
|
||||
void testAmbiguitySearch()
|
||||
{
|
||||
SignalGenerator generator = new SignalGenerator(-750, 0.1);
|
||||
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
|
||||
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
Dibit[] completed = new Dibit[BUFFER_LENGTH];
|
||||
boolean resolved = false;
|
||||
|
||||
for(int buffer = 0; buffer < 1000 && !resolved; buffer++)
|
||||
{
|
||||
float[][] samples = generator.next(completed);
|
||||
demodulator.demodulate(samples[0], samples[1], false);
|
||||
|
||||
//Emulate the symbol processor synchronizing once the offset is resolved
|
||||
resolved = Math.abs(toHertz(demodulator.getFrequencyOffset()) + 750) < 20;
|
||||
}
|
||||
|
||||
assertTrue(resolved, "Search resolves the offset");
|
||||
|
||||
for(int buffer = 0; buffer < 200; buffer++)
|
||||
{
|
||||
float[][] samples = generator.next(completed);
|
||||
demodulator.demodulate(samples[0], samples[1], true);
|
||||
}
|
||||
|
||||
assertEquals(-750.0, toHertz(demodulator.getFrequencyOffset()), 10.0, "Offset held while synchronized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the ambiguity search holds the resolved offset range across a gap with no signal while unsynchronized (ie
|
||||
* between transmissions on a traffic channel) so that the next transmission starts in the correct offset range.
|
||||
*/
|
||||
@Test
|
||||
void testSearchHoldsAcrossSignalGap()
|
||||
{
|
||||
SignalGenerator generator = new SignalGenerator(-750, 0.1);
|
||||
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
|
||||
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
Dibit[] completed = new Dibit[BUFFER_LENGTH];
|
||||
boolean resolved = false;
|
||||
|
||||
for(int buffer = 0; buffer < 1000 && !resolved; buffer++)
|
||||
{
|
||||
float[][] samples = generator.next(completed);
|
||||
demodulator.demodulate(samples[0], samples[1], false);
|
||||
resolved = Math.abs(toHertz(demodulator.getFrequencyOffset()) + 750) < 20;
|
||||
}
|
||||
|
||||
assertTrue(resolved, "Search resolves the offset");
|
||||
|
||||
//Emulate the symbol processor synchronizing to the signal and the framer decoding a valid NID once the offset is
|
||||
//resolved
|
||||
for(int buffer = 0; buffer < 20; buffer++)
|
||||
{
|
||||
float[][] samples = generator.next(completed);
|
||||
demodulator.demodulate(samples[0], samples[1], true);
|
||||
demodulator.offsetVerified();
|
||||
}
|
||||
|
||||
//Gap of ~20,000 symbols (10x the search dwell period) with no signal and no synchronization
|
||||
Random random = new Random(3);
|
||||
|
||||
for(int buffer = 0; buffer < 100; buffer++)
|
||||
{
|
||||
float[] i = new float[BUFFER_LENGTH];
|
||||
float[] q = new float[BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < BUFFER_LENGTH; x++)
|
||||
{
|
||||
i[x] = (float)random.nextGaussian();
|
||||
q[x] = (float)random.nextGaussian();
|
||||
}
|
||||
|
||||
demodulator.demodulate(i, q, false);
|
||||
}
|
||||
|
||||
assertEquals(-750.0, toHertz(demodulator.getFrequencyOffset()), 20.0, "Offset held across the gap");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: symbol processor synchronization without a valid NID decode (ie a false sync detection in an incorrect
|
||||
* offset range) doesn't hold the ambiguity search in the incorrect offset range for the longer dwell period, while
|
||||
* a verified offset range is held.
|
||||
*/
|
||||
@Test
|
||||
void testUnverifiedSynchronizationDoesNotHoldSearch()
|
||||
{
|
||||
SignalGenerator generatorUnverified = new SignalGenerator(-750, 0.1);
|
||||
SignalGenerator generatorVerified = new SignalGenerator(-750, 0.1);
|
||||
P25P1DifferentialDemodulatorLSM unverified = new P25P1DifferentialDemodulatorLSM();
|
||||
P25P1DifferentialDemodulatorLSM verified = new P25P1DifferentialDemodulatorLSM();
|
||||
unverified.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
verified.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
Dibit[] completed = new Dibit[BUFFER_LENGTH];
|
||||
|
||||
//Emulate a sync detection in the initial (incorrect) offset range, with and without a valid NID decode
|
||||
for(int buffer = 0; buffer < 5; buffer++)
|
||||
{
|
||||
float[][] samples = generatorUnverified.next(completed);
|
||||
unverified.demodulate(samples[0], samples[1], true);
|
||||
samples = generatorVerified.next(completed);
|
||||
verified.demodulate(samples[0], samples[1], true);
|
||||
verified.offsetVerified();
|
||||
}
|
||||
|
||||
//Unsynchronized for ~10,000 symbols: longer than the search dwell and shorter than the verified dwell
|
||||
for(int buffer = 0; buffer < 50; buffer++)
|
||||
{
|
||||
float[][] samples = generatorUnverified.next(completed);
|
||||
unverified.demodulate(samples[0], samples[1], false);
|
||||
samples = generatorVerified.next(completed);
|
||||
verified.demodulate(samples[0], samples[1], false);
|
||||
}
|
||||
|
||||
//The unverified search steps to an adjacent (1200 Hz) offset range while the verified search holds its range
|
||||
double difference = Math.abs(toHertz(unverified.getFrequencyOffset()) - toHertz(verified.getFrequencyOffset()));
|
||||
assertTrue(difference > 600, "Unverified offset range search stepped away [" + difference + " Hz]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: each sample's reliability is (|x[n]|^2 * |x[n - sps]|^2) / (|x[n]|^2 + |x[n - sps]|^2), including across the
|
||||
* sample buffer boundary.
|
||||
*/
|
||||
@Test
|
||||
void testReliabilities()
|
||||
{
|
||||
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
|
||||
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
float[] amplitudes = new float[2 * BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < amplitudes.length; x++)
|
||||
{
|
||||
amplitudes[x] = 0.1f + 0.9f * (float)Math.abs(Math.sin(x * 0.37));
|
||||
}
|
||||
|
||||
float[] reliabilities = new float[amplitudes.length];
|
||||
|
||||
for(int buffer = 0; buffer < 2; buffer++)
|
||||
{
|
||||
float[] i = new float[BUFFER_LENGTH];
|
||||
float[] q = new float[BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < BUFFER_LENGTH; x++)
|
||||
{
|
||||
//Rotate the phase to show that the reliability depends only on the sample power
|
||||
double phase = x * 0.5;
|
||||
float amplitude = amplitudes[buffer * BUFFER_LENGTH + x];
|
||||
i[x] = (float)(amplitude * Math.cos(phase));
|
||||
q[x] = (float)(amplitude * Math.sin(phase));
|
||||
}
|
||||
|
||||
demodulator.demodulate(i, q, true);
|
||||
System.arraycopy(demodulator.getReliabilities(), 0, reliabilities, buffer * BUFFER_LENGTH, BUFFER_LENGTH);
|
||||
}
|
||||
|
||||
for(int x = SAMPLES_PER_SYMBOL; x < amplitudes.length; x++)
|
||||
{
|
||||
double current = amplitudes[x] * amplitudes[x];
|
||||
double delayed = amplitudes[x - SAMPLES_PER_SYMBOL] * amplitudes[x - SAMPLES_PER_SYMBOL];
|
||||
assertEquals(current * delayed / (current + delayed), reliabilities[x], 1e-5, "Reliability at " + x);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the offset estimate doesn't drift when the input is noise.
|
||||
*/
|
||||
@Test
|
||||
void testNoiseDoesNotMoveEstimate()
|
||||
{
|
||||
Random random = new Random(2);
|
||||
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
|
||||
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
|
||||
for(int buffer = 0; buffer < 2000; buffer++)
|
||||
{
|
||||
float[] i = new float[BUFFER_LENGTH];
|
||||
float[] q = new float[BUFFER_LENGTH];
|
||||
|
||||
for(int x = 0; x < BUFFER_LENGTH; x++)
|
||||
{
|
||||
i[x] = (float)random.nextGaussian();
|
||||
q[x] = (float)random.nextGaussian();
|
||||
}
|
||||
|
||||
demodulator.demodulate(i, q, true);
|
||||
}
|
||||
|
||||
assertEquals(0.0, toHertz(demodulator.getFrequencyOffset()), 1e-3, "Offset estimate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.p25.phase1;
|
||||
|
||||
import io.github.dsheirer.dsp.symbol.Dibit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* JUnit tests for the P25 Phase 1 message assembler forced completion of place holder (failed NID) assemblies.
|
||||
*/
|
||||
public class P25P1MessageAssemblerTest
|
||||
{
|
||||
private static final P25P1DataUnitID TDU = P25P1DataUnitID.TERMINATOR_DATA_UNIT;
|
||||
private static final P25P1DataUnitID TDULC = P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL;
|
||||
private static final P25P1DataUnitID TSBK = P25P1DataUnitID.TRUNKING_SIGNALING_BLOCK_1;
|
||||
|
||||
/**
|
||||
* Creates a place holder assembly with the bit length.
|
||||
*/
|
||||
private static P25P1MessageAssembler getPlaceHolder(int bitLength)
|
||||
{
|
||||
P25P1MessageAssembler assembler = new P25P1MessageAssembler(0x293, P25P1DataUnitID.PLACE_HOLDER);
|
||||
|
||||
for(int x = 0; x < bitLength / 2; x++)
|
||||
{
|
||||
assembler.receive(Dibit.D01_PLUS_3);
|
||||
}
|
||||
|
||||
return assembler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces completion of a place holder with the bit length.
|
||||
* @return the identified data unit ID
|
||||
*/
|
||||
private static P25P1DataUnitID forceCompletion(int bitLength, P25P1DataUnitID previous, P25P1DataUnitID next)
|
||||
{
|
||||
P25P1MessageAssembler assembler = getPlaceHolder(bitLength);
|
||||
assembler.forceCompletion(previous, next);
|
||||
return assembler.getDataUnitID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a place holder that's shorter than a missed TDU frame (ie inter-frame padding or timing slack ahead of the
|
||||
* next sync) isn't identified as a TDU, even when followed by an LDU1 (HDU resolution) or a TSBK.
|
||||
*/
|
||||
@Test
|
||||
void testShortPlaceHolderDiscarded()
|
||||
{
|
||||
for(int length : new int[]{0, 2, 28, 100, 126})
|
||||
{
|
||||
P25P1MessageAssembler assembler = getPlaceHolder(length);
|
||||
assertEquals(0, assembler.forceCompletion(TDULC, TDULC), "Dropped bits at " + length);
|
||||
assertEquals(P25P1DataUnitID.PLACE_HOLDER, assembler.getDataUnitID(), "Length " + length);
|
||||
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(length, P25P1DataUnitID.HEADER_DATA_UNIT,
|
||||
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Length " + length + " before LDU1");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a place holder with the length of a single missed TDU frame (28 null bits plus the next frame's 112 sync and
|
||||
* NID bits) is identified as a TDU.
|
||||
*/
|
||||
@Test
|
||||
void testMissedTDU()
|
||||
{
|
||||
for(int length : new int[]{128, 136, 140})
|
||||
{
|
||||
assertEquals(TDU, forceCompletion(length, TDU, TDU), "Length " + length);
|
||||
assertEquals(TDU, forceCompletion(length, TDULC, TDU), "Length " + length);
|
||||
}
|
||||
|
||||
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(144, TDU, TDU), "Length 144");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a place holder with the length of a single missed TDULC frame (308 message and null bits plus the next
|
||||
* frame's 112 sync and NID bits) is identified as a TDULC.
|
||||
*/
|
||||
@Test
|
||||
void testMissedTDULC()
|
||||
{
|
||||
for(int length : new int[]{408, 416, 420})
|
||||
{
|
||||
assertEquals(TDULC, forceCompletion(length, TDULC, TDULC), "Length " + length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a place holder that doesn't match a missed frame isn't identified as a TDU.
|
||||
*/
|
||||
@Test
|
||||
void testUnidentifiedPlaceHolderDiscarded()
|
||||
{
|
||||
for(int length : new int[]{208, 276, 556, 696, 1000})
|
||||
{
|
||||
P25P1MessageAssembler assembler = getPlaceHolder(length);
|
||||
assertEquals(0, assembler.forceCompletion(TDULC, TDU), "Dropped bits at " + length);
|
||||
assertEquals(P25P1DataUnitID.PLACE_HOLDER, assembler.getDataUnitID(), "Length " + length);
|
||||
}
|
||||
|
||||
//A place holder between voice frames that is too short for an LDU
|
||||
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(1000, P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_2,
|
||||
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Length 1000 between LDUs");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: place holders are resolved from the next data unit ID before the missed frame lengths are applied, so a
|
||||
* place holder ahead of a TSBK is a TSBK, even when its length matches a missed TDULC frame.
|
||||
*/
|
||||
@Test
|
||||
void testResolvedFromNextDataUnit()
|
||||
{
|
||||
assertEquals(TSBK, forceCompletion(350, TSBK, TSBK), "Missed TSBK");
|
||||
assertEquals(TSBK, forceCompletion(416, TSBK, TSBK), "Missed TSBK with TDULC length");
|
||||
assertEquals(P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_2, forceCompletion(1560,
|
||||
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1, P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Missed LDU2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.p25.phase1;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* JUnit tests for the P25 Phase 1 LSM symbol processor.
|
||||
*/
|
||||
public class P25P1SymbolProcessorLSMTest
|
||||
{
|
||||
private static final float SAMPLES_PER_SYMBOL = 5.0f;
|
||||
private static final double TOLERANCE = 1e-5;
|
||||
|
||||
private static P25P1SymbolProcessorLSM getProcessor()
|
||||
{
|
||||
P25P1SymbolProcessorLSM processor = new P25P1SymbolProcessorLSM(new P25P1DecoderLSM(), new P25P1MessageFramer());
|
||||
processor.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
|
||||
return processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts radians per symbol to the radians per sample domain of the demodulated sample stream.
|
||||
*/
|
||||
private static float toSample(double radiansPerSymbol)
|
||||
{
|
||||
return (float)(radiansPerSymbol / SAMPLES_PER_SYMBOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: samples that don't straddle the phase wrap are linearly interpolated.
|
||||
*/
|
||||
@Test
|
||||
void testLinearInterpolation()
|
||||
{
|
||||
P25P1SymbolProcessorLSM processor = getProcessor();
|
||||
assertEquals(toSample(1.5), processor.interpolate(toSample(1.0), toSample(2.0), 0.5), TOLERANCE);
|
||||
assertEquals(toSample(-1.25), processor.interpolate(toSample(-1.0), toSample(-2.0), 0.25), TOLERANCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: samples that straddle the phase wrap near an outer symbol interpolate across the wrap instead of through
|
||||
* zero, and the result is wrapped to +/- PI radians per symbol.
|
||||
*/
|
||||
@Test
|
||||
void testInterpolationAcrossPhaseWrap()
|
||||
{
|
||||
P25P1SymbolProcessorLSM processor = getProcessor();
|
||||
|
||||
//+3.0 and -3.0 are 0.283 radians apart across the wrap at +/- PI.
|
||||
assertEquals(toSample(Math.PI), Math.abs(processor.interpolate(toSample(3.0), toSample(-3.0), 0.5)), TOLERANCE);
|
||||
|
||||
//A quarter of the way from +3.0 toward -3.0 (across the wrap) is still on the positive side of the wrap.
|
||||
double expected = 3.0 + 0.25 * (2.0 * Math.PI - 6.0);
|
||||
assertEquals(toSample(expected), processor.interpolate(toSample(3.0), toSample(-3.0), 0.25), TOLERANCE);
|
||||
|
||||
//Three quarters of the way crosses the wrap to the negative side.
|
||||
expected = 3.0 + 0.75 * (2.0 * Math.PI - 6.0) - 2.0 * Math.PI;
|
||||
assertEquals(toSample(expected), processor.interpolate(toSample(3.0), toSample(-3.0), 0.75), TOLERANCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.p25.phase1.message;
|
||||
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.P25P1DataUnitID;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.message.hdu.HDUMessage;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.message.tdu.TDULCMessage;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* JUnit tests for the message validity of P25 Phase 1 messages that decode their error protected content on demand.
|
||||
* The validity must reflect the error correction result on the first isValid() call, before any other accessor has
|
||||
* decoded the content.
|
||||
*/
|
||||
public class P25P1MessageValidityTest
|
||||
{
|
||||
/**
|
||||
* Creates a message with all-zero bits (a valid codeword for the linear Golay and Reed-Solomon codes).
|
||||
*/
|
||||
private static CorrectedBinaryMessage getZeroMessage(P25P1DataUnitID dataUnitID)
|
||||
{
|
||||
CorrectedBinaryMessage message = new CorrectedBinaryMessage(dataUnitID.getMessageLength());
|
||||
message.setSize(dataUnitID.getMessageLength());
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message with random bits.
|
||||
*/
|
||||
private static CorrectedBinaryMessage getRandomMessage(P25P1DataUnitID dataUnitID, long seed)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
CorrectedBinaryMessage message = getZeroMessage(dataUnitID);
|
||||
|
||||
for(int x = 0; x < message.size(); x++)
|
||||
{
|
||||
message.set(x, random.nextBoolean());
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a TDULC is valid when its link control word passes error correction.
|
||||
*/
|
||||
@Test
|
||||
void testValidTDULC()
|
||||
{
|
||||
TDULCMessage tdulc = new TDULCMessage(getZeroMessage(P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL),
|
||||
0x293, 0L);
|
||||
assertTrue(tdulc.isValid(), "TDULC");
|
||||
assertTrue(tdulc.getLinkControlWord().isValid(), "Link control word");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a TDULC is invalid when its link control word fails error correction.
|
||||
*/
|
||||
@Test
|
||||
void testInvalidTDULC()
|
||||
{
|
||||
for(long seed = 1; seed <= 10; seed++)
|
||||
{
|
||||
TDULCMessage tdulc = new TDULCMessage(getRandomMessage(
|
||||
P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL, seed), 0x293, 0L);
|
||||
assertFalse(tdulc.isValid(), "TDULC with random bits, seed " + seed);
|
||||
assertFalse(tdulc.getLinkControlWord().isValid(), "Link control word, seed " + seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: an HDU is valid when its header data passes error correction.
|
||||
*/
|
||||
@Test
|
||||
void testValidHDU()
|
||||
{
|
||||
HDUMessage hdu = new HDUMessage(getZeroMessage(P25P1DataUnitID.HEADER_DATA_UNIT), 0x293, 0L);
|
||||
assertTrue(hdu.isValid(), "HDU");
|
||||
assertTrue(hdu.getHeaderData().isValid(), "Header data");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: an HDU is invalid when its header data fails error correction, including when isValid() is invoked before
|
||||
* the header data is accessed.
|
||||
*/
|
||||
@Test
|
||||
void testInvalidHDU()
|
||||
{
|
||||
for(long seed = 1; seed <= 10; seed++)
|
||||
{
|
||||
HDUMessage hdu = new HDUMessage(getRandomMessage(P25P1DataUnitID.HEADER_DATA_UNIT, seed), 0x293, 0L);
|
||||
assertFalse(hdu.isValid(), "HDU with random bits, seed " + seed);
|
||||
assertFalse(hdu.getHeaderData().isValid(), "Header data, seed " + seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.source.tuner.frequency;
|
||||
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* JUnit tests for the channel frequency error manager.
|
||||
*/
|
||||
public class ChannelFrequencyErrorManagerTest
|
||||
{
|
||||
/**
|
||||
* Test: a tuner PPM change is removed from the channel's applied frequency correction, and the updated correction
|
||||
* is applied to the channel source.
|
||||
*/
|
||||
@Test
|
||||
void testTunerPPMChange()
|
||||
{
|
||||
TestChannelSource source = new TestChannelSource();
|
||||
ChannelFrequencyErrorManager manager = new ChannelFrequencyErrorManager(source, null);
|
||||
|
||||
manager.receive(-10);
|
||||
assertEquals(10, manager.getFrequencyError(), "Applied correction");
|
||||
assertEquals(10, source.mFrequencyCorrection, "Channel source correction");
|
||||
|
||||
manager.receive(25);
|
||||
assertEquals(-15, manager.getFrequencyError(), "Applied correction");
|
||||
assertEquals(-15, source.mFrequencyCorrection, "Channel source correction");
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel source that records the applied frequency correction.
|
||||
*/
|
||||
private static class TestChannelSource extends TunerChannelSource
|
||||
{
|
||||
private long mFrequencyCorrection = Long.MIN_VALUE;
|
||||
|
||||
public TestChannelSource()
|
||||
{
|
||||
super(null, new TunerChannel(460_000_000L, 12_500), "test", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFrequencyCorrection(long correction)
|
||||
{
|
||||
mFrequencyCorrection = correction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFrequency(long frequency)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setSampleRate(double sampleRate)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setListener(Listener<ComplexSamples> complexSamplesListener)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getSampleRate()
|
||||
{
|
||||
return 50_000.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFrequency()
|
||||
{
|
||||
return 460_000_000L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user