A CFAR Burst Detector on Hardware

A detector that calibrates itself, on STM32F4 and ESP32-S3, with its false-alarm rate designed on paper and counted on the bench

Put a microphone or a piezo element on an ADC and ask the classic monitoring question: did something just happen? A tap on the housing, a valve click, a dropped part, a voice in a quiet room. The main page showed why the obvious answer, a fixed energy threshold, is a trap: its false-alarm rate is exponentially sensitive to a noise floor you do not control, and a one-decibel drift turns a once-per-day alarm into a once-per-minute one. It also showed the fix: compare each block’s energy to a scaled average of its recent past, so the noise level cancels exactly.

That fix is unusually kind to embedded hardware. There is no calibration constant anywhere in the chain, because the CFAR ratio cancels the microphone sensitivity, the amplifier gain, the ADC reference, and the LSB size all at once: the same firmware, with the same threshold factor, holds the same false-alarm rate on any board at any gain setting. The one number the design does contain, the threshold factor \(\alpha\), comes from an F-distribution on paper, and the false-alarm rate it promises can be counted on the bench. Both default platforms per ADR-005: NUCLEO-F446RE (Cortex-M4F) and ESP32-S3, float where it is convenient, integers where it is not.

Build provenance: portable C with budget numbers, tied to the tested Python model by checks that run on this page (ADR-005 section 7).


The design, in numbers

Fix the operating point first; every code block below just implements it.

  • Sample rate \(f_s = 8\) kHz, as on the other audio-adjacent embedded pages.
  • Block length \(M = 64\) samples (8 ms): short enough that a hand tap or click lands mostly inside one block, long enough that the block energy has a well-behaved distribution. 125 decisions per second.
  • Block energy with the block mean removed: \(E = \sum x^2 - (\sum x)^2/M\), so the ADC’s mid-rail DC pedestal (and any slow drift) contributes nothing. Removing the mean costs exactly one chi-square degree of freedom, so every distribution below uses \(M - 1 = 63\), not 64; the main page’s module documents this, and getting it wrong misses the design \(P_{fa}\) by about 20%.
  • Reference window \(N_{\text{ref}} = 16\) blocks (128 ms of noise history) behind \(N_{\text{guard}} = 2\) guard blocks (16 ms), so a burst that straddles a block boundary cannot leak into its own noise estimate.
  • False-alarm rate \(P_{fa} = 10^{-4}\) per block: one false alarm per 80 seconds of quiet input, a rate you can verify by sitting still for ten minutes (below).
Show the code
M, n_ref, n_guard, fs = 64, 16, 2, 8000
dof = M - 1
pfa_d = 1e-4
alpha = cfar_factor(pfa_d, dof, n_ref)

enr_db = np.linspace(8, 26, 150)
enr = 10**(enr_db / 10)
pd_cfar = [cfar_pd(pfa_d, e, dof, n_ref) for e in enr]
pd_known = [energy_detector_pd(pfa_d, e, dof) for e in enr]

fig, ax = plt.subplots(figsize=(7.5, 3.8))
ax.plot(enr_db, pd_known, 'C0--', lw=1.2, label='noise level known (clairvoyant)')
ax.plot(enr_db, pd_cfar, 'C3', lw=1.5,
        label=f'CFAR, $N_{{ref}}$ = {n_ref} (self-calibrating)')
from scipy.optimize import brentq
enr_90 = brentq(lambda e: cfar_pd(pfa_d, e, dof, n_ref) - 0.9, 10, 500)
ax.plot(10 * np.log10(enr_90), 0.9, 'ko', ms=6)
ax.annotate(f'design point:\n{10*np.log10(enr_90):.1f} dB for $P_d$ = 0.9',
            (10 * np.log10(enr_90) + 0.5, 0.78), fontsize=8)
ax.set_xlabel('per-block ENR $E_s/\\sigma^2$ [dB]')
ax.set_ylabel('$P_d$ at $P_{fa} = 10^{-4}$')
ax.legend(fontsize=8, loc='upper left'); ax.grid(True, alpha=0.3)
fig.tight_layout(); plt.show()

enr_known = brentq(lambda e: energy_detector_pd(pfa_d, e, dof) - 0.9, 10, 500)
loss_db = 10 * np.log10(enr_90 / enr_known)
amp_over_sigma = np.sqrt(enr_90 / (M / 2))
print(f"alpha = F^-1({pfa_d}; {dof}, {n_ref*dof}) = {alpha:.4f}")
print(f"false-alarm interval: {1 / (pfa_d * fs / M):.0f} s at {fs/M:.0f} blocks/s")
print(f"Pd = 0.9 at block ENR {enr_90:.1f} ({10*np.log10(enr_90):.2f} dB); "
      f"tone amplitude {amp_over_sigma:.2f} sigma; CFAR loss {loss_db:.2f} dB")
assert 1.84 < alpha < 1.86
assert abs(1 / (pfa_d * fs / M) - 80) < 0.5
assert 0.2 < loss_db < 0.3, "the quoted 0.24 dB self-calibration price"
assert 1.5 < amp_over_sigma < 1.7
assert abs(10 * np.log10(enr_90) - 19.1) < 0.05, "the caption's design point"
Figure 1: The design point on paper, before any hardware: detection probability against per-block ENR for the CFAR detector (dof 63, 16 reference blocks, Pfa = 10⁻⁴ per block), with the clairvoyant known-noise-level detector alongside. The CFAR loss at this operating point is 0.24 dB: the entire price of never needing calibration. Pd = 0.9 needs a block ENR of 19.1 dB, which for a tone burst filling one block is amplitude 1.6 times the noise RMS: a blip you would strain to see on a scope trace.
alpha = F^-1(0.0001; 63, 1008) = 1.8487
false-alarm interval: 80 s at 125 blocks/s
Pd = 0.9 at block ENR 80.8 (19.08 dB); tone amplitude 1.59 sigma; CFAR loss 0.24 dB

The portable core

Two small state machines, both platform-independent: one accumulates a block’s energy, one runs the CFAR comparison. The energy accumulator works on raw ADC counts with no scaling of any kind, because the CFAR ratio makes volts-per-count irrelevant; the integer widths are chosen from the worst case, not from optimism.

#include <stdint.h>

#define M_BLOCK   64      /* samples per block: 8 ms at 8 kHz            */
#define N_REF     16      /* reference blocks: 128 ms of noise history   */
#define N_GUARD   2       /* guard blocks between reference and cell     */
#define ALPHA_Q8  474     /* F^-1(1e-4; 63, 1008) = 1.8487, times 256,   */
                          /* rounded UP so rounding errs toward fewer    */
                          /* false alarms (473 would round the other way)*/

/* Application hook, called once per detected block; supplied by you. */
void burst_report(float energy, float threshold);

/* Block energy with the block mean removed:
       E = ( M * sum(x^2) - (sum x)^2 ) / M.
   Worst case for 12-bit counts: sum(x^2) <= 64 * 4095^2 < 2^31 (fits
   the uint32 accumulator); M * sum(x^2) and (sum x)^2 need 36 bits,
   hence the int64 numerator.  No scaling, no calibration, no float
   until the very end. */
typedef struct { uint32_t n; int32_t sum; uint32_t sumsq; } energy_acc_t;

static int energy_push(energy_acc_t *a, uint16_t x, float *energy)
{
    a->sum   += x;
    a->sumsq += (uint32_t)x * x;
    if (++a->n < M_BLOCK)
        return 0;
    int64_t num = (int64_t)a->sumsq * M_BLOCK
                - (int64_t)a->sum * (int64_t)a->sum;
    *energy = (float)num / (float)M_BLOCK;
    a->n = 0; a->sum = 0; a->sumsq = 0;
    return 1;
}

/* CFAR over the block-energy stream.  hist[] holds the most recent
   N_GUARD + N_REF energies, newest first; the reference window is the
   N_REF entries beyond the guards.  No verdict until the history has
   filled once: a CFAR detector is blind during warm-up (the first
   18 blocks = 144 ms here), exactly like the sliding-window outlier
   detectors. */
static float    hist[N_GUARD + N_REF];
static uint32_t hist_fill = 0;

static int cfar_push(float e_cut, float *threshold)
{
    float ref_sum = 0.0f;
    for (int k = N_GUARD; k < N_GUARD + N_REF; k++)
        ref_sum += hist[k];

    int ready = (hist_fill >= N_GUARD + N_REF);
    *threshold = (float)ALPHA_Q8 * ref_sum / (256.0f * (float)N_REF);
    int detect = ready && (e_cut > *threshold);

    /* Shift the history down and admit the new block.  17 float moves
       125 times a second: clarity wins over a ring buffer here. */
    for (int k = N_GUARD + N_REF - 1; k > 0; k--)
        hist[k] = hist[k - 1];
    hist[0] = e_cut;
    if (hist_fill < N_GUARD + N_REF)
        hist_fill++;
    return detect;
}

Note what the code does not do: it does not censor detected blocks from the history. That matches the Python module’s ca_cfar (so the C can be simulated against it, below), and it means a sustained loud event will raise the threshold against itself once it outlives the guards, the self-masking limit the main page discusses. Excluding flagged blocks from the reference, or replacing the reference mean with a median (an order-statistic CFAR, the Hampel move), are the standard upgrades when events can be long; both perturb the exact \(P_{fa}\) calibration slightly, so re-verify by counting if you add them.

Show the code
# The C path, simulated exactly: 12-bit quantised samples, integer
# energy accumulation, Q8 threshold factor, shared history shift.  The
# measured false-alarm rate must sit on the design value at ANY gain,
# which is the whole point.  (Pfa raised to 1e-2 here so 60k blocks
# measure it to a few percent; the F-calibration is the same formula
# at any pfa, and the tests cover more of the tail.)
rng = np.random.default_rng(31)
pfa_chk = 1e-2
alpha_q8 = np.round(f_dist.isf(pfa_chk, M - 1, n_ref * (M - 1)) * 256)
n_blocks = 60000
for sigma_lsb, mid in ((9.0, 2048), (230.0, 1900)):   # two "gain settings"
    x = np.clip(np.round(mid + sigma_lsb * rng.standard_normal(n_blocks * M)),
                0, 4095).astype(np.int64)
    blocks = x.reshape(n_blocks, M)
    ssum = blocks.sum(axis=1)
    ssq = (blocks**2).sum(axis=1)
    e = (ssq * M - ssum**2) / M                       # the C formula, exact
    ref = np.array([e[k - n_guard - n_ref:k - n_guard].mean()
                    for k in range(n_ref + n_guard, n_blocks)])
    det = e[n_ref + n_guard:] > (alpha_q8 / 256.0) * ref
    print(f"sigma = {sigma_lsb:5.0f} LSB, mid-rail {mid}: measured Pfa "
          f"{det.mean():.4f} (design {pfa_chk})")
    assert abs(det.mean() - pfa_chk) < 0.15 * pfa_chk
sigma =     9 LSB, mid-rail 2048: measured Pfa 0.0096 (design 0.01)
sigma =   230 LSB, mid-rail 1900: measured Pfa 0.0092 (design 0.01)

The same firmware constant, a 25-fold gain change, a shifted mid-rail, and the measured false-alarm rate does not move: that is the CFAR property surviving quantisation, integer arithmetic, and the Q8 rounding of \(\alpha\) intact. One honest boundary: at the quiet gain setting the noise still spans several LSB. If the analogue noise shrinks well below one LSB the block energies stop being chi-square (the quantiser’s staircase takes over) and the calibration drifts; the dither and ADC-noise pages explain why a front end that clean needs help before any of this theory applies.


On the STM32F4 (NUCLEO-F446RE)

The wiring is the proven chain from the lock-in photometer: TIM3’s update event routed to TRGO, the ADC triggered by it, DMA in circular mode. One pleasant coincidence does real work here: with the DMA buffer sized at \(2M\), each half-transfer callback delivers exactly one block, so the block boundary and the interrupt boundary are the same thing and the ISR-side code is four lines.

#include "stm32f4xx_hal.h"

static uint16_t adc_buf[2 * M_BLOCK];

/* TIM3 update -> TRGO -> ADC1 -> circular DMA, at 8 kHz. */
void burst_detector_start(TIM_HandleTypeDef *tim3, ADC_HandleTypeDef *adc1,
                          uint32_t timer_bus_hz)
{
    tim3->Init.Prescaler = timer_bus_hz / 1000000u - 1u;  /* 1 MHz base */
    tim3->Init.Period    = 125u - 1u;                     /* 8 kHz      */
    HAL_TIM_Base_Init(tim3);

    /* Route the update event to TRGO: this IS the ADC trigger. */
    TIM_MasterConfigTypeDef ms = {0};
    ms.MasterOutputTrigger = TIM_TRGO_UPDATE;
    HAL_TIMEx_MasterConfigSynchronization(tim3, &ms);

    /* ADC1: 12-bit, one channel, converting only on the TRGO edge. */
    adc1->Init.Resolution            = ADC_RESOLUTION_12B;
    adc1->Init.ExternalTrigConv      = ADC_EXTERNALTRIGCONV_T3_TRGO;
    adc1->Init.ExternalTrigConvEdge  = ADC_EXTERNALTRIGCONVEDGE_RISING;
    adc1->Init.ContinuousConvMode    = DISABLE;
    adc1->Init.DMAContinuousRequests = ENABLE;
    HAL_ADC_Init(adc1);

    ADC_ChannelConfTypeDef ch = {0};
    ch.Channel      = ADC_CHANNEL_0;             /* PA0: mic/AC input  */
    ch.Rank         = 1;
    ch.SamplingTime = ADC_SAMPLETIME_56CYCLES;
    HAL_ADC_ConfigChannel(adc1, &ch);

    /* DMA armed before the trigger source, so no pulse is lost. */
    HAL_ADC_Start_DMA(adc1, (uint32_t *)adc_buf, 2 * M_BLOCK);
    HAL_TIM_Base_Start(tim3);
}

/* Each callback owns exactly one M_BLOCK-sample block. */
static void process(const uint16_t *s)
{
    static energy_acc_t acc;
    float e, thr;
    for (int n = 0; n < M_BLOCK; n++)
        if (energy_push(&acc, s[n], &e))
            if (cfar_push(e, &thr))
                burst_report(e, thr);            /* your event hook    */
}

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *h) { process(adc_buf); }
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h)     { process(adc_buf + M_BLOCK); }

Per sample the work is one add, one multiply-accumulate; per block, 17 float moves, 16 float adds, and one compare. On a 180 MHz M4F this rounds to zero, and there is no cycle-budget table because nothing here is near a budget. The analogue side is equally modest: an electret capsule or piezo disc, AC-coupled and biased to mid-rail (the classic two-resistor divider plus series capacitor), with enough amplifier gain that the quiet-room noise floor spans a handful of LSB, which the calibration cell above showed is all the linearity this detector asks of the converter.


On the ESP32-S3

The same design maps onto ESP-IDF’s continuous ADC driver, with the frame size set to one block so the driver’s own delivery unit is the detector’s decision unit, mirroring the STM32’s half-buffer trick.

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_adc/adc_continuous.h"

#define FS_HZ        8000
#define FRAME_BYTES  (M_BLOCK * SOC_ADC_DIGI_RESULT_BYTES)

static adc_continuous_handle_t adc;

void burst_adc_init(void)
{
    adc_continuous_handle_cfg_t hcfg = {
        .max_store_buf_size = 4 * FRAME_BYTES,
        .conv_frame_size    = FRAME_BYTES,       /* one block per frame */
    };
    adc_continuous_new_handle(&hcfg, &adc);

    adc_digi_pattern_config_t pattern = {
        .atten     = ADC_ATTEN_DB_12,
        .channel   = ADC_CHANNEL_3,              /* the mic input       */
        .unit      = ADC_UNIT_1,
        .bit_width = ADC_BITWIDTH_12,
    };
    adc_continuous_config_t ccfg = {
        .pattern_num    = 1,
        .adc_pattern    = &pattern,
        .sample_freq_hz = FS_HZ,
        .conv_mode      = ADC_CONV_SINGLE_UNIT_1,
        .format         = ADC_DIGI_OUTPUT_FORMAT_TYPE2,
    };
    adc_continuous_config(adc, &ccfg);
    adc_continuous_start(adc);
}

void burst_task(void *arg)
{
    static uint8_t frame[FRAME_BYTES];
    static energy_acc_t acc;
    uint32_t got;
    float e, thr;
    for (;;) {
        if (adc_continuous_read(adc, frame, sizeof frame, &got,
                                portMAX_DELAY) != ESP_OK)
            continue;
        adc_digi_output_data_t *p = (adc_digi_output_data_t *)frame;
        for (uint32_t n = 0; n < got / SOC_ADC_DIGI_RESULT_BYTES; n++)
            if (energy_push(&acc, p[n].type2.data, &e))
                if (cfar_push(e, &thr))
                    burst_report(e, thr);
    }
}

The ESP32-S3’s ADC is the noisier and less linear of the two converters, its effective full scale wanders with the attenuator setting, and none of that appears in any constant above: raw type2.data counts go straight into the integer accumulator, and the ratio does the rest. On most embedded pages the vendor ADC’s gain error is a paragraph of caveats; here it cancels, which is the cleanest demonstration this workshop has of designing the arithmetic so the unknowns divide out. (The timing story is simpler than the lock-in’s, too: the detector needs no phase coherence, only even sampling, so the driver’s internal clocking needs no verification ritual.)


Validation by counting: the bench protocol

The lock-in photometer verified its clock chain by watching a phase; a detector is verified by counting false alarms. The design says \(P_{fa} = 10^{-4}\) per 8 ms block: 125 blocks per second, so quiet input should produce alarms as a Poisson process at 45 per hour, one per 80 seconds. Leave the device in a quiet room for ten minutes and count:

Show the code
# What ten quiet minutes should yield if the design Pfa is real.
lam = 1e-4 * (8000 / 64) * 600
lo, hi = poisson.ppf(0.005, lam), poisson.ppf(0.995, lam)
print(f"expected alarms in 10 min: {lam:.1f}")
print(f"99% acceptance band: {lo:.0f} to {hi:.0f} alarms")
print(f"P[zero alarms] = {poisson.pmf(0, lam):.4f}: "
      f"a silent run means the threshold is NOT at the design point")
assert abs(lam - 7.5) < 1e-9
assert poisson.pmf(0, lam) < 1e-3
expected alarms in 10 min: 7.5
99% acceptance band: 2 to 15 alarms
P[zero alarms] = 0.0006: a silent run means the threshold is NOT at the design point

Both tails of that band matter. Too many alarms means the noise is not white at block time scales (a fan’s hum, mains pickup, a colored floor), and the honest response is to measure and adjust: raise \(\alpha\) until the counted rate matches the target, accepting that the F-distribution’s exactness was a white-noise promise. But zero alarms in ten minutes is equally a failure, with probability \(6 \times 10^{-4}\) under the design: it means the threshold sits far above where the mathematics put it, the detector has quietly become deaf, and the \(P_d\) curve of the sizing figure is no longer being delivered. A detector that never cries wolf is not a good detector; it is a broken one. This is the operational face of the ROC: you cannot move one error rate without moving the other, so a working detector must false-alarm at its design rate, and the alarm counter is the cheapest self-test the field device can run on itself.


When there is no FPU

Strip the two floats and the core runs on anything. The block energy is already integer; keep it as the int64 numerator \(M\sum x^2 - (\sum x)^2\) (skip the division by \(M\), it is common to both sides), hold the history as int64, and compare

\[256 \cdot N_{\text{ref}} \cdot E_{\text{cut}} \;>\; \text{ALPHA\_Q8} \cdot \sum E_{\text{ref}}\]

in 64-bit integers: the left side peaks near \(2^{48}\), the right near \(2^{49}\), both decades inside range, so the comparison is exact with no rounding beyond the one deliberate Q8 rounding of \(\alpha\), whose direction was chosen once, upward, toward fewer false alarms. No square roots, no logarithms, no per-stage scaling: an ATmega328P runs this at 8 kHz with the ADC in free-running mode, which puts a calibrated-false-alarm-rate detector one capability-ladder rung below anything else in this workshop’s embedded pages. As with the lock-in, the arithmetic was never the hard part; here, even the clock discipline barely is.

References