add Correlated Double Sampling (CDS) support

Implements CDS to reduce noise by taking multiple measurements at
different source PLL phase offsets and combining with cosine weighting.

Firmware changes (VNA.cpp, Protocol.hpp):
- Add cdsPhases field to SweepSettings (0=disabled, 2-7=phase count)
- Configure N internal sweep points per user point with phase offsets
- Accumulate weighted samples: result = Σ(sample[k] × cos(2π×k/N))
- Per-stage accumulators for multi-stage measurements

PC application changes:
- Add "CDS" checkbox to VNA acquisition toolbar
- When enabled, sets cdsPhases=2 for 180° differential measurement
- Tooltip explains the feature

With 180° CDS (2 samples):
- Sample at 0°: weight = cos(0°) = 1
- Sample at 180°: weight = cos(180°) = -1
- Combined result = Sample₀ - Sample₁₈₀

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Roger Henderson 2026-01-31 23:47:02 +13:00
parent 6c06293179
commit 0b571688a9
6 changed files with 145 additions and 5 deletions

View file

@ -512,6 +512,7 @@ bool LibreVNADriver::setVNA(const DeviceDriver::VNASettings &s, std::function<vo
p.settings.suppressPeaks = VNASuppressInvalidPeaks ? 1 : 0;
p.settings.fixedPowerSetting = VNAAdjustPowerLevel || s.dBmStart != s.dBmStop ? 0 : 1;
p.settings.logSweep = s.logSweep ? 1 : 0;
p.settings.cdsPhases = s.cds ? 2 : 0; // CDS with 180° phase offset (2 samples)
zerospan = (s.freqStart == s.freqStop) && (s.dBmStart == s.dBmStop);
p.settings.port1Stage = find(s.excitedPorts.begin(), s.excitedPorts.end(), 1) - s.excitedPorts.begin();

View file

@ -269,6 +269,8 @@ public:
std::vector<int> excitedPorts;
// amount of time the source stays at each point before taking measurements. Ignore if not supported
double dwellTime;
// Correlated Double Sampling: if true, take 2 samples at 0° and 180° phase and combine
bool cds;
};
class VNAMeasurement {

View file

@ -482,6 +482,14 @@ VNA::VNA(AppWindow *window, QString name)
connect(this, &VNA::dwellTimeChanged, acquisitionDwellTime, &SIUnitEdit::setValueQuiet);
tb_acq->addWidget(acquisitionDwellTime);
auto cbCDS = new QCheckBox("CDS");
cbCDS->setToolTip("Correlated Double Sampling: Take 2 measurements at 180° phase offset to reduce noise");
connect(cbCDS, &QCheckBox::toggled, this, [=](bool checked){
settings.cds = checked;
SettingsChanged();
});
tb_acq->addWidget(cbCDS);
tb_acq->addWidget(new QLabel("Averaging:"));
lAverages = new QLabel("0/");
tb_acq->addWidget(lAverages);
@ -2032,6 +2040,7 @@ void VNA::ConfigureDevice(bool resetTraces, std::function<void(bool)> cb)
s.logSweep = false;
}
s.dwellTime = settings.dwellTime;
s.cds = settings.cds;
if(window->getDevice() && isActive) {
window->getDevice()->setVNA(s, [=](bool res){
// device received command, reset traces now

View file

@ -81,6 +81,7 @@ public:
int activeSegment;
bool zerospan;
double firstPointTime; // timestamp of the first point in the sweep, only use when zerospan is used
bool cds; // Correlated Double Sampling (180° phase shift)
};

View file

@ -181,6 +181,7 @@ using SweepSettings = struct _sweepSettings {
int16_t cdbm_excitation_stop; // in 1/100 dbm
uint16_t dwell_time; // in us
uint8_t cdsPhases; // Correlated Double Sampling: 0=disabled, 2-7=number of phase samples
};
using ReferenceSettings = struct _referenceSettings {

View file

@ -54,6 +54,16 @@ static uint32_t PLLRefFreqs[] = {HW::PLLRef, HW::PLLRef - 1000000};
static constexpr uint8_t PLLRefFreqsNum = sizeof(PLLRefFreqs)/sizeof(PLLRefFreqs[0]);
static uint8_t sourceRefIndex, LO1RefIndex;
// Correlated Double Sampling (CDS) state
static uint8_t cdsPhaseCount; // Number of phase samples (0 or 2-7)
// CDS accumulators for weighted samples, per stage (max 8 stages)
// I and Q for each receiver: Port1, Port2, Reference
static double cdsAccumP1I[8], cdsAccumP1Q[8];
static double cdsAccumP2I[8], cdsAccumP2Q[8];
static double cdsAccumRefI[8], cdsAccumRefQ[8];
// Precomputed cosine weights for CDS
static float cdsWeights[7]; // Max 7 phases
using namespace HWHAL;
static uint64_t getPointFrequency(uint16_t pointNum) {
@ -165,8 +175,36 @@ bool VNA::Setup(Protocol::SweepSettings s) {
settings = s;
// calculate factor between adjacent points for log sweep for faster calculation when sweeping
logMultiplier = pow((double) settings.f_stop / settings.f_start, 1.0 / (settings.points-1));
// Initialize Correlated Double Sampling (CDS)
cdsPhaseCount = settings.cdsPhases >= 2 ? settings.cdsPhases : 0;
// Clear per-stage accumulators
for(uint8_t stg = 0; stg < 8; stg++) {
cdsAccumP1I[stg] = cdsAccumP1Q[stg] = 0;
cdsAccumP2I[stg] = cdsAccumP2Q[stg] = 0;
cdsAccumRefI[stg] = cdsAccumRefQ[stg] = 0;
}
// Precompute cosine weights: cos(2*pi*k/N)
if(cdsPhaseCount >= 2) {
for(uint8_t k = 0; k < cdsPhaseCount; k++) {
cdsWeights[k] = cosf(2.0f * M_PI * k / cdsPhaseCount);
}
}
// Calculate internal point count (multiply by CDS phases if enabled)
uint16_t internalPoints = settings.points;
if(cdsPhaseCount >= 2) {
internalPoints = settings.points * cdsPhaseCount;
if(internalPoints > FPGA::MaxPoints) {
// Reduce user points to fit
settings.points = FPGA::MaxPoints / cdsPhaseCount;
internalPoints = settings.points * cdsPhaseCount;
LOG_WARN("CDS: reduced points to %u to fit FPGA limit", settings.points);
}
}
// Configure sweep
FPGA::SetNumberOfPoints(settings.points);
FPGA::SetNumberOfPoints(internalPoints);
uint32_t samplesPerPoint = (HW::getADCRate() / s.if_bandwidth);
// round up to next multiple of 16 (16 samples are spread across 5 IF2 periods)
if(samplesPerPoint%16) {
@ -286,9 +324,33 @@ bool VNA::Setup(Protocol::SweepSettings s) {
needs_halt = true;
}
FPGA::WriteSweepConfig(i, lowband, Source.GetRegisters(),
LO1.GetRegisters(), attenuator, freq,
FPGA::Samples::SPPRegister, needs_halt);
// Write sweep config(s) for this user point
if(cdsPhaseCount >= 2) {
// CDS enabled: write N configs with different phases
// Extract Source M from PLL registers for phase calculation
uint32_t* sourceRegs = Source.GetRegisters();
uint16_t Source_M = (sourceRegs[1] & 0x00007FF8) >> 3;
for(uint8_t k = 0; k < cdsPhaseCount; k++) {
uint16_t internalPointNum = i * cdsPhaseCount + k;
// Calculate phase: sourcePhase = M * k / N
// This gives phase = k * 360 / N degrees
uint16_t sourcePhase = (uint16_t)((uint32_t)Source_M * k / cdsPhaseCount);
// Only halt on first CDS phase of each point (if needed)
bool pointHalt = (k == 0) ? needs_halt : false;
FPGA::WriteSweepConfig(internalPointNum, lowband, sourceRegs,
LO1.GetRegisters(), attenuator, freq,
FPGA::Samples::SPPRegister, pointHalt, FPGA::LowpassFilter::Auto,
sourcePhase);
}
} else {
// No CDS: single config per point
FPGA::WriteSweepConfig(i, lowband, Source.GetRegisters(),
LO1.GetRegisters(), attenuator, freq,
FPGA::Samples::SPPRegister, needs_halt);
}
last_lowband = lowband;
}
// reset a possibly changed PLL reference index
@ -371,7 +433,71 @@ bool VNA::MeasurementDone(const FPGA::SamplingResult &result) {
FPGA::AbortSweep();
return false;
}
// normal sweep mode
if(cdsPhaseCount >= 2) {
// CDS mode: accumulate weighted samples per stage
// Internal point mapping: user_point = pointCnt / cdsPhaseCount
// cds_phase = pointCnt % cdsPhaseCount
uint16_t userPoint = pointCnt / cdsPhaseCount;
uint8_t cdsPhase = pointCnt % cdsPhaseCount;
float weight = cdsWeights[cdsPhase];
// Accumulate weighted values into per-stage accumulators
cdsAccumP1I[stageCnt] += result.P1I * weight;
cdsAccumP1Q[stageCnt] += result.P1Q * weight;
cdsAccumP2I[stageCnt] += result.P2I * weight;
cdsAccumP2Q[stageCnt] += result.P2Q * weight;
cdsAccumRefI[stageCnt] += result.RefI * weight;
cdsAccumRefQ[stageCnt] += result.RefQ * weight;
// Check if all stages for this internal point are complete
stageCnt++;
if(stageCnt > settings.stages) {
stageCnt = 0;
// Check if this was the last CDS phase for this user point
if(cdsPhase == cdsPhaseCount - 1) {
// All CDS phases complete - add combined values to data
for(uint8_t stg = 0; stg <= settings.stages; stg++) {
data.addValue((int64_t)cdsAccumP1I[stg], (int64_t)cdsAccumP1Q[stg], stg, (int) Protocol::Source::Port1);
data.addValue((int64_t)cdsAccumP2I[stg], (int64_t)cdsAccumP2Q[stg], stg, (int) Protocol::Source::Port2);
data.addValue((int64_t)cdsAccumRefI[stg], (int64_t)cdsAccumRefQ[stg], stg, (int) Protocol::Source::Port1 | (int) Protocol::Source::Port2 | (int) Protocol::Source::Reference);
// Reset accumulators for next user point
cdsAccumP1I[stg] = cdsAccumP1Q[stg] = 0;
cdsAccumP2I[stg] = cdsAccumP2Q[stg] = 0;
cdsAccumRefI[stg] = cdsAccumRefQ[stg] = 0;
}
data.pointNum = userPoint;
if(zerospan) {
uint64_t timestamp = HW::getLastISRTimestamp();
if(firstPoint) {
data.us = 0;
firstPointTime = timestamp;
firstPoint = false;
} else {
data.us = timestamp - firstPointTime;
}
} else {
data.frequency = getPointFrequency(userPoint);
data.cdBm = settings.cdbm_excitation_start + (settings.cdbm_excitation_stop - settings.cdbm_excitation_start) * userPoint / (settings.points - 1);
}
// Send data for this user point
STM::DispatchToInterrupt(PassOnData);
// Check if sweep is complete
if(userPoint >= settings.points - 1) {
pointCnt = 0;
return true; // End of sweep
}
}
pointCnt++;
}
return false;
}
// Non-CDS mode: original behavior
data.addValue(result.P1I, result.P1Q, stageCnt, (int) Protocol::Source::Port1);
data.addValue(result.P2I, result.P2Q, stageCnt, (int) Protocol::Source::Port2);
data.addValue(result.RefI, result.RefQ, stageCnt, (int) Protocol::Source::Port1 | (int) Protocol::Source::Port2 | (int) Protocol::Source::Reference);