Skip to content

Four WCDMA Carriers — PSD, band_power, AccTrace

four WCDMA carriers measured with PSD and AccTrace

A multi-carrier monitoring scene: four WCDMA-like downlink carriers — QPSK at the 3.84 Mcps chip rate, one per 5 MHz channel — at deliberately different power levels (0, -3, -6, -10 dBFS), analysed end-to-end with the spectral-measurement suite. It answers the everyday spectrum-monitor question: is every carrier at the right power, and how clean is the channel?

What you're seeing

Top-left — the averaged PSD. PSD (Kaiser window, mean trace, 96 frames) resolves four flat-topped ~5 MHz channels with the sharp root-raised-cosine skirts of a real WCDMA signal. Each channel is shaded; the measured per-channel power and the noise_floor() line are annotated.

Top-right — trace averaging with AccTrace. The same power frames folded three ways: one raw periodogram (grey, ±10 dB of variance), the AccTrace mean (the variance collapses), and the AccTrace max-hold envelope (green). PSD is this pipeline — window → FFT → power → AccTrace — so the panel is a peek under its hood.

Bottom-left — per-channel power. PSD.band_power(edges) recovers the programmed 0 / -3 / -6 / -10 dB spacing exactly (shown relative to the strongest carrier); total_band_power gives the whole occupied span.

Bottom-right — the measurements. Per-channel in-band SNR (snr), the global occupied bandwidth (occupied_bw, 99 %), the noise floor, the adjacent-channel leakage ratio (ACLR, the strongest carrier vs. the empty guard channel beside it), and the PSD resolution bandwidth.

Building it

The carriers come from doppler's own waveform generator. WCDMA downlink is noise-like QPSK with root-raised-cosine pulse shaping (β = 0.22, the WCDMA roll-off), and the generator does exactly that — qpsk(pulse="rrc") (doppler#115) band-limits the chips in the C engine at unit transmit power, so each carrier is a single generator call; freq= places it at the channel centre:

import numpy as np

from doppler.spectral import PSD
from doppler.wfm import Composer, Segment, noise, qpsk

FS, SPS = 30.72e6, 8  # 8 samples / 3.84 Mcps chip


def rrc_carrier(fc, level, seed, n):
    """One RRC-shaped WCDMA carrier at offset ``fc``, scaled to ``level`` dBFS.

    Straight from doppler's waveform generator: ``qpsk(pulse="rrc")``
    band-limits the QPSK chips with a root-raised-cosine pulse (0.22 is
    the WCDMA roll-off) — the shaping the default sample-and-hold QPSK
    does not do — and ``freq=fc`` mixes the carrier to its channel
    centre, all in the C engine. The RRC taps are unit-transmit-power
    scaled, so the carrier is already unit power and the ``level``
    factor lets ``band_power`` read the level directly.
    """
    sig = (
        qpsk(
            sps=SPS,
            pulse="rrc",
            rrc_beta=0.22,
            rrc_span=8,
            freq=fc,
            fs=FS,
            seed=seed,
        )
        .steps(n)
        .astype(np.complex64)
    )
    return sig * 10.0 ** (level / 20.0)  # unit power -> level (dBFS)

Sum four of them at 5 MHz spacing over a composed AWGN floor, then measure with PSD:

NFFT, N_FRAMES = 4096, 96  # PSD frame size x frames to average
CARRIERS = [  # (channel centre Hz, level dBFS)
    (-7.5e6, 0.0),
    (-2.5e6, -3.0),
    (2.5e6, -6.0),
    (7.5e6, -10.0),
]

# Sum the four carriers into one scene, over a composed AWGN floor at
# -70 dBFS (well below the weakest carrier) so noise_floor()/snr() have
# a real floor to measure. The noise synth shares the generator's
# 0 dBFS = unit-power reference.
n = NFFT * N_FRAMES
scene = np.zeros(n, dtype=np.complex64)
for i, (fc, lvl) in enumerate(CARRIERS):
    scene += rrc_carrier(fc, lvl, 10 + i, n)
floor = Composer(
    Segment.sum(noise(level=-70.0), num_samples=n, fs=FS)
).compose()
scene = (scene + floor[:n]).astype(np.complex64)

w = PSD(n=NFFT, fs=FS, window="kaiser", beta=12.0, mode="mean")
w.accumulate(scene)  # folds all 96 frames into the average

edges = np.array(  # [lo0, hi0, lo1, hi1, ...] channel edges, Hz
    [-10e6, -5e6, -5e6, 0, 0, 5e6, 5e6, 10e6], dtype=np.float64
)
band_db = np.array(w.band_power(edges))  # per-channel power, dB
total_db = w.total_band_power(edges)  # whole occupied span, dB
nf = w.noise_floor()  # median dB level
snr0 = w.snr(-10e6, -5e6)  # in-channel SNR of carrier 0

Snapshot zero-copy results

psd_db() and band_power() return zero-copy views into PSD's internal buffers (the library's variable-output idiom). Wrap a result in np.array(...) if you need it to survive a later call to the same method — e.g. the ACLR band_power(guard) call would otherwise overwrite an earlier band_power(edges) view.

See also