In the previous notebook, we have used the Schmidl&Cox metric to estimate and compensate a carrier frequency offset. However, given that there is noise, the S&C CFO estimation can never be perfect and a residual CFO remains.
As a rough approximation, a small frequency offset essentially creates a time-variant channel, where the phase of the channel changes over time. In this notebook, we will look at methods to compensate this time-variant behaviour of the channel, which is not achievable with the block-type estimation.
Let us quickly go through some mathematical fundamentals. From the previous notebook we know that a residual CFO $\Delta_f$ can be modeled as
$$y(t)=x(t)\exp\left(j2\pi \Delta_f t\right), $$
i.e. the received signal is multiplied by complex exponential. Now, let's assume that $\Delta_f T$ is very small, where $T$ is the length of one OFDM symbol. Hence, we assume that over the duration of one OFDM symbol, the channel is assumed to be static. However, when multiple OFDM symbols occur after each other, each OFDM symbol experiences a different starting phase, as illustrated below:
delta_f = 0.01
T = 1; Fs = 1000; # ofdm symbol duration and sampling frequency
t = np.linspace(0, 4*T, 4*Fs*T)
phase = t*delta_f # the phase component in y(t)=x(t)\exp(j2\pi*phase(t))
plt.figure(figsize=(8,3))
plt.plot(t, phase, label='Actual phase', lw=2, color='r')
for n in range(4):
plt.axvline(n, color='k', lw=2)
plt.text(x=n+0.5, y=0.05, s='OFDM symbol %d' % n, ha='center')
mean_phase = np.mean(phase[int(n*T*Fs)+np.arange(int(T*Fs))])
plt.plot([n, n+1], [mean_phase, mean_phase], lw=2, color='b')
plt.text(n+0.5, mean_phase, 'mean phase', ha='center', va='bottom')
plt.ylim((-0.01, 0.06))
plt.legend(loc='lower right'); plt.grid(True)
As we see, adjacent OFDM symbols experience a different average phase rotation due to a small CFO. In the following we will look at methods how to compensate this phase shift.
Let's start with some common imports:
# Source code has been redacted in online version
# Omitting 7 lines of source code
Also, we import OFDM functional blocks including the CFO extraction and compesation blocks that we defined in the previous notebook:
# Source code has been redacted in online version
# Omitting 5 lines of source code
Let us quickly recap the block-type channel estimation: In the block-type pilot scheme, the channel was estimated at the beginning of the block and then used for equalization in every following OFDM symbol. Hence, it could not represent any time-variance. Below, we will look a bit more closly at what happens with a residual CFO and block-type pilots.
Let's write a generic function for the OFDM transmission that we will use throughout this notebook.
# Source code has been redacted in online version
# Omitting 49 lines of source code
The function expects functions to create the transmitter, channel and OFDM demodulator objects and then creates a visualization of the signals and connects all blocks.
For subsequent simulations, we define a common OFDM parameter structure:
ofdm = OFDM(K=256, Kon=201, CP=64, Npayload=5, qam=16)
ofdm.frameLen = (256+64)*(7)
Let's run it with block-type channel estimation with a residual CFO. We simulat the residual CFO by applying a small CFO and bypassing the CFO correction:
runTransmissionWithCFOCorrection(ofdm,
BlockPilotRandomDataOFDMTransmitter,
lambda env: SimulatedChannel(env, MultipathChannelEffect(taps=[(0,1), (0.0005, 0.9)])),
lambda env, ofdm: BlockPilotOFDMReceiver(env, ofdm, BlockPilotLSChannelEstimator(ofdm)),
B=441*5*4, Fc=10000, cfo=0.4, bypassCFO=True)
As we see, the constellation appears rotated. Examining more closely, we see that the points with the same colors (indicating the different OFDM symbols) gather in clusters. This confirms that each OFDM symbol experiences a different average phase, and hence the constellation is rotated according to this average phase. Now, let's look at what happens when we use the audio channel:
runTransmissionWithCFOCorrection(ofdm,
BlockPilotRandomDataOFDMTransmitter,
AudioChannel,
lambda env, ofdm: BlockPilotOFDMReceiver(env, ofdm, BlockPilotLSChannelEstimator(ofdm)),
B=441*5*4, Fc=8000, bypassCFO=False)
Depending on your actual soundcard quality and speakers (above was obtained with a cable between speaker output and microphone input), you will see sometimes phase rotations of the constellation diagram that look quite similar to our simulation before. Again, we see that it should be beneficial to apply a dedicated equalization to each OFDM symbol seperately.
Below, we will examine two techniques for dedicated treatment of each block. First, we start with a single pilot for phase correction, but keep the block-type pilot insertion. Second, we will switch over to real comb-type pilot insertions.
We have seen before, that each OFDM symbol experiences a different phase shift. Hence, if we would rotate the constellation symbols for each OFDM symbol by this phase shift, we will end up with correct constellation diagrams. To know the phase shift, we simply insert a single pilot carrier into each OFDM symbol and examine its phase rotation. For simplicity, we take the value $1+0j$ as the pilot value. Let's write an according transmit block:
# Source code has been redacted in online version
# Omitting 6 lines of source code
Let's look at the transmitted constellation diagram:
# Source code has been redacted in online version
# Omitting 11 lines of source code
OK, there's the pilot appearing at the value $1+0j$ marked in the constellation diagram. Now, let's artificially apply a small CFO to the signal and look where the pilots occurs:
# Source code has been redacted in online version
# Omitting 15 lines of source code
Ah, the pilot occurs phase rotated from $1+0j$ to $0.95+0.39j$. With this information, we can now multiply the constellation diagram by the corresponding inverse phase to obtain a nice constellation diagram:
# Source code has been redacted in online version
# Omitting 7 lines of source code
Clearly, the equalized (red) constellation has the correct rotation now. Now, that we have checked the technique offline, let's integrate the single pilot into the transmission chain. As we have already defined the transmitter above, we can directly move on to the demodulator:
# Source code has been redacted in online version
# Omitting 22 lines of source code
Let's use this receiver in a simulation where we have a residual CFO:
runTransmissionWithCFOCorrection(ofdm,
BlockPilotPilotRandomData1PilotOFDMTransmitter,
lambda env: SimulatedChannel(env, MultipathChannelEffect(taps=[(0,1), (0.0005, 0.9)])),
lambda env, ofdm: BlockPilot1PilotOFDMReceiver(env, ofdm, BlockPilotLSChannelEstimator(ofdm)),
B=11025, Fc=10000, cfo=0.4, bypassCFO=True)
Great! Even though we have a small CFO, we still obtain correctly positioned constellation points due to the single pilot used for the common phase correction.
Now, let's move on and check out the behaviour in the real audio channel:
# Source code has been redacted in online version
# Omitting 5 lines of source code
Also in the audio channel, we see the pilot is doing its job (note that we have added an artificial CFO). Moreover, in case it happens that the constellation gets messed up due to the soundcard impairments, still the constellation points occur as arcs centered at the correct constellation points. Now, that we have already integrated one pilot into each OFDM symbol, the question arises, why we wouldn't put the full pilot information into each symbol, such that we can actually calculate a channel estimate for each OFDM symbol. This comb-type pilot scheme is discussed in the next section.
With comp-type pilots, each OFDM symbol contains both data and pilots, where the pilots occur on every $x$th carrier. With this method, the pilots experience exactly the same channel conditions as the data, and hence the channel estimate is most up-to-date. This is in large contrast to block-type pilots, where the channel is estimated once and then used for every subsequent OFDM symbol.
Let's go ahead and first implement the OFDM transmitter with comb pilots. First, we create a utility function that calculates the data and pilot carrier index given the OFDM parameter structure:
# Source code has been redacted in online version
# Omitting 9 lines of source code
Let's try this function with some dummy data:
pilotCarriers, dataCarriers = calcPilotAndDataCarriers(OFDM(K=16, Kon=9, pilotSpacing=4))
print ("Pilots:", pilotCarriers)
print ("Data :", dataCarriers)
fig = plt.figure(figsize=(8,1))
plt.plot(pilotCarriers, np.zeros(len(pilotCarriers)), 'ro', ms=14)
plt.plot(dataCarriers, np.zeros(len(dataCarriers)), 'go', ms=14)
plt.xlim((-0.5, 9.5)); plt.axis('off');
OK, the function works and returns the expected pilot and data carrier allocation. For the above example, we have 9 carriers and a pilot spacing of 4, which inserts a pilot every 4 carriers and leaves 3 data carriers between the pilots.
We can now go ahead and implement the comb-type transmitter. As the pilot values we again use the Zadoff-Chu sequence as we used for the block-type pilots.
# Source code has been redacted in online version
# Omitting 25 lines of source code
At the receiver side, we implement a simple receiver that applied ZF equalization. However, the actual channel estimation is delegated to a dedicated object which we will develop below. For now, let's just assume it's already there:
# Source code has been redacted in online version
# Omitting 43 lines of source code
Now, given that we have already implemented the receiver, we can focus on the actual channel estimation algorithm. To understand the comb-type estimation technique, we'll do some offline-experiments. First, we generate an artificial receive signal and plot the channel frequency response and the received spectrum:
# create a comb-type pilots OFDM transmit signal
ofdm = OFDM(K=256, Kon=201, CP=64, Npayload=5, pilotSpacing=10)
tx = CombPilotRandomDataOFDMTransmitter(None, ofdm)
ofdm_signal = tx._generateSignal()
# apply a multipath-channel
h = np.array([1, 0, 0, 0.9])
ofdm_signal_rx = np.convolve(ofdm_signal, h) # apply a fake channel
# plot spectrum and signal
plt.figure(figsize=(8,3))
RX_spectrum = np.fft.fftshift(abs(np.fft.fft(ofdm_signal_rx))); RX_spectrum /= abs(RX_spectrum).max()
f = np.linspace(-256/2, 256/2, len(RX_spectrum))
plt.plot(f, RX_spectrum, label='Rx spectrum')
H = abs(np.fft.fftshift(np.fft.fft(h, len(RX_spectrum))))
plt.plot(f, H, label='$|H|$');
plt.legend(); plt.xlabel('Subcarriers'); plt.grid(True);
Clearly, we experience a frequency-selective channel. Let's now look at the channel estimation at the pilot carriers:
# Source code has been redacted in online version
# Omitting 17 lines of source code
OK, the channel is correctly estimated at the pilots. However, we are actually interested in the channel at the data carriers. Hence, we have to use some interpolation technique to interpolate between the measurements at the pilot carriers. Below, we write a function to illustrate the interpolation in different domains.
# Source code has been redacted in online version
# Omitting 39 lines of source code
Let's show the interpolation effect using a linear interpolation with our channel. For now, let's assume the synchronization was exact (i.e. t_offset=0):
showChannelInterpolation(ofdm, ofdm_signal_rx, h=h, interpolation='linear', t_offset=0)
How can we interpret above plots? First, let's look at the magnitude plot: We see a strange behaviour of the interpolation: Somehow, it occurs that the channel is estimated too small? For the phase estimation it looks better.
The reason for this behaviour can be seen in the right-most plot. There, the locus of the actual channel is plotted in the complex plane. Moreover, the red dots indicate the pilot measurements and the green line shows the interpolation values. There, we clearly see that the linear interpolation between the pilots does not really reflect the actual channel values. Let's try a different, more complex, interpolation:
showChannelInterpolation(ofdm, ofdm_signal_rx, h=h, interpolation='quadratic')
Great! With the quadratic interpolation (i.e. 2nd order spline), the estimated and interpolated channel is perfectly on top of the real channel. So, let's use quadratic interpolation and go ahead and implement the first version for the chanel estimator:
from scipy.interpolate import interp1d
class CombPilotChannelEstimator(object):
def __init__(self, ofdm):
self._ofdm = ofdm
# pre-calculate pilot values and positions
self._pilotCarriers, _ = calcPilotAndDataCarriers(ofdm)
self._pilotVals = ZadoffChu(length=len(self._pilotCarriers), order=1)
def estimate(self, un_eq):
# extract the pilots
rxPilots = un_eq[self._pilotCarriers]
# estimation and interpolation
Hest = rxPilots / self._pilotVals # estimation at pilot carriers
# interpolation between the carriers
Hest_interp = interp1d(self._pilotCarriers, Hest, kind='quadratic')
k = np.arange(self._ofdm.Kon)
return Hest_interp(k)
That's pretty straigt-forward, isn't it? Let's try out our chain with the comb-type pilots. First, we work with the simulated channel:
ofdm2 = OFDM(K=256, Kon=201, CP=64, Npayload=5, pilotSpacing=10, qam=16)
ofdm2.frameLen = (256+64)*(1+ofdm2.Npayload)
runTransmissionWithCFOCorrection(ofdm2,
CombPilotRandomDataOFDMTransmitter,
lambda env: SimulatedChannel(env, MultipathChannelEffect(taps=[(0,1), (0.0005, 0.3)])),
#AudioChannel,
lambda env, ofdm: CombPilotOFDMReceiver(env, ofdm, CombPilotChannelEstimator(ofdm)),
B=441*5*4, Fc=8000, cfo=0.4, bypassCFO=True)
It works! Even though there's a slight CFO the constellation points are at the correct positions. However, when we look closely at the constellation diagram we see that it gets messier, the more the phase of the channel changes over the carriers. As we learned from the block-type channel estimation, multiple phase wrap-arounds correspond to a misdetection of the synchronization, i.e. starting the symbol some samples too early. It's the job of the channel estimation to perform the fine time synchronization.
Let's see how our channel estimation behaves when the synchronization is not perfect:
showChannelInterpolation(ofdm, ofdm_signal_rx, h=h, interpolation='quadratic', t_offset=5)
Uh, there is a significant deviation between the actual and estimated channel. Hm, maybe quadratic interpolation is not accurate enough? Let's try 5th order spline interpolation:
showChannelInterpolation(ofdm, ofdm_signal_rx, h=h, interpolation=5, t_offset=5)
Indeed, the interpolation becomes better, however when looking at the edges of the spectrum we see a significant ringing which is caused by the high-order polynomials used for the 5th order interpolation. Hence, increasing the interpolation order is not solving the problem. Looking at the interpolation result in the complex plane we can find the problem: Due to significant phase change between pilots the points become far apart in the complex plane and hence the interpolation between them becomes harder.
However, we have seen that the quadratic interpolation works nicely, when the synchronization is accurate, i.e. the phase change between the pilots is not too big. So, the idea is to split the channel estimation procedure into two phases: First, estimate the fine timing offset and second, perform the channel interpolation? Or in other words, can we first estimate the average phase rotation between pilots, remove this treand and then perform the interpolation?
Exactly this technique is pursued in the code below. First, we estimate the phase rotation between adjacent pilots, take the mean of the rotation and apply an inverse rotation to the pilot carriers, such that the common phase rotation is removed. Then, after channel interpolation, we re-apply the phase-rotation:
def showChannelInterpolationWithPhaseDetrending(ofdm, ofdm_signal, h, t_offset=0, interpolation='linear'):
# extract time-domain symbol and carrier indices
symbol0 = ofdm_signal[ofdm.CP+ofdm.K+ ofdm.CP +np.arange(ofdm.K) - t_offset]
usefulCarriers = get_carrierIndices(ofdm)
pilotCarriers, dataCarriers = calcPilotAndDataCarriers(ofdm)
S_uneq = np.fft.fftshift(np.fft.fft(symbol0))[usefulCarriers] / np.sqrt(ofdm.K)
rx_pilots = S_uneq[pilotCarriers]
# Calculate true channel frequency response
H_true = np.fft.fftshift(np.fft.fft(np.hstack([np.zeros(t_offset), h]), ofdm.K))[usefulCarriers] / 2
# channel estimation and interpolation
Hest_at_pilots = rx_pilots / tx._pilotVals
# Calculate phase shift between adjacent pilots
phase_diffs = Hest_at_pilots[:-1] / Hest_at_pilots[1:];
phase_diffs /= abs(phase_diffs)
# mean phase shift between pilots
mean_phase = np.mean(phase_diffs)
# calculate the expected phase shift that each pilot experiences
pilot_backRotation = mean_phase**np.arange(len(Hest_at_pilots))
# perform interpolation with the common phase rotation removed
Hest_detrended = Hest_at_pilots*pilot_backRotation
Hest_fun = interp1d(pilotCarriers, Hest_detrended, kind=interpolation)
Hest = Hest_fun(np.arange(ofdm.Kon))
# re-apply the rotation to the estimated channel
data_backRotation = mean_phase**(np.arange(ofdm.Kon)/ofdm.pilotSpacing)
Hest_backrotated = Hest / data_backRotation
# for reference, the channel interpolation without phase detrending
Hest_fun_no_detrend = interp1d(pilotCarriers, Hest_at_pilots, kind=interpolation)
Hest_no_detrend = Hest_fun_no_detrend(np.arange(ofdm.Kon))
# plotting of results
plt.figure(figsize=(10,8))
plt.subplot(221); plt.title('Original interpolation problem'); plt.grid(True)
plt.plot(H_true.real, H_true.imag);
plt.plot(Hest_at_pilots.real, Hest_at_pilots.imag, 'ro')
plt.plot(Hest_no_detrend.real, Hest_no_detrend.imag)
plt.subplot(222); plt.title('Transformed interpolation problem'); plt.grid(True)
plt.plot((H_true*data_backRotation).real, (H_true*data_backRotation).imag)
plt.plot((Hest_at_pilots*pilot_backRotation).real, (Hest_at_pilots*pilot_backRotation).imag, 'ro')
plt.plot(Hest.real, Hest.imag)
plt.subplot(425); plt.title('Absolute value')
plt.stem(pilotCarriers, abs(Hest_at_pilots), label='H estimated at pilots')
plt.plot(abs(H_true), label='H')
plt.plot(abs(Hest_backrotated), label='H estimated and interpolated')
plt.grid(True); plt.legend(loc='best');
plt.subplot(427); plt.title('Phase')
plt.stem(pilotCarriers, np.angle(Hest_at_pilots), label='H estimated at pilots')
plt.plot(np.angle(H_true), label='H')
plt.plot(np.angle(Hest_backrotated), label='H estimated and interpolated')
plt.grid(True); plt.legend(loc='best');
plt.subplot(224); plt.title('In complex plane')
plt.plot(Hest_at_pilots.real, Hest_at_pilots.imag, 'ro', label='H estimated at pilots')
plt.plot(H_true.real, H_true.imag, label='H')
plt.plot(Hest_backrotated.real, Hest_backrotated.imag, label='H estimated and interpolated')
plt.grid(True); plt.legend();
plt.tight_layout()
Let's try this out with some synchronization error:
showChannelInterpolationWithPhaseDetrending(ofdm, ofdm_signal_rx, h=h, interpolation='quadratic', t_offset=5)
As we see, the interpolation problem is transformed into a much simpler problem by removing the common phase factor for each pilot. After re-applying this phase rotation to the interpolated channel, we perfectly estimate the channel.
Let's go ahead and implement this technique into another channel estimation class:
# Source code has been redacted in online version
# Omitting 28 lines of source code
Let's try the new channel estimation in the simulated channel first:
ofdm2 = OFDM(K=256, Kon=201, CP=64, Npayload=5, pilotSpacing=10, qam=16)
ofdm2.frameLen = (256+64)*(1+ofdm2.Npayload)
runTransmissionWithCFOCorrection(ofdm2,
CombPilotRandomDataOFDMTransmitter,
lambda env: SimulatedChannel(env, MultipathChannelEffect(taps=[(0,1), (0.0005, 0.3)])),
#AudioChannel,
lambda env, ofdm: CombPilotOFDMReceiver(env, ofdm, CombPilotChannelEstimatorWithExtraPhaseCorrection(ofdm)),
B=441*5*4, Fc=8000, cfo=0.0, bypassCFO=True)
Yes, now the tightness of the constellation points does not depend on the synchronization error anymore (as long as we are in reasonable bounds). Let's finally try this out in the audio channel:
ofdm2 = OFDM(K=256, Kon=201, CP=64, Npayload=5, pilotSpacing=10, qam=256)
ofdm2.frameLen = (256+64)*(1+ofdm2.Npayload)
runTransmissionWithCFOCorrection(ofdm2,
CombPilotRandomDataOFDMTransmitter,
AudioChannel,
lambda env, ofdm: CombPilotOFDMReceiver(env, ofdm, CombPilotChannelEstimatorWithExtraPhaseCorrection(ofdm)),
B=441*5*4, Fc=8000, cfo=0.0, bypassCFO=True)
Yes! It also works in the audio channel nicely. Even, it could often remove the smearing of the constellation points into arcs that we experienced with the block-type channel estimation method. Even, when using a cable I was able to transmit with 256-QAM and obtain a reasonable constellation diagram. However, note that depending on the sound card and speakers this might not be achievable.
In this notebook, we have examined comb-type pilot channel estimation schemes. Comb-type estimation is useful for time-varying channels, as they occur either when transmitter or receiver are moving, or a residual frequency offset is present. We have used a single pilot for correcting a common phase rotation, and subsequently used real comb-type scheme to take over the channel estimation completely. We have seen how to improve the channel interpolation by extracting the common phase shift before interpolation and re-applying it after interpolation and could eventually achieve nice constellation diagrams.
However, during the simulations we have observed some problem with the timing synchronization by not finding the frame at the correct position. In the next notebook, we will tackle this problem by creating a feedback loop between the channel estimator and the synchronization block.
Copyright (C) 2025 - dspillustrations.com