import logging from typing import Any, Callable import numpy as np import numpy.typing as npt from scipy.signal import butter, correlate, sosfilt, sosfilt_zi from where_fi.collection import CSIMatrix from where_fi.collection.csi_frame import CSI from where_fi.collection.protocols import CSIHost from where_fi.config import config from ..visualise import server as visualise logger = logging.getLogger(__name__) np.seterr(invalid="ignore") class Preprocessor: def __init__(self) -> None: self.short_term_avg = np.zeros((1,), dtype=np.complex64) self.long_term_avg = np.zeros((1,), dtype=np.complex64) self._last_sample = None self.denoising_samples = int( config.preprocessing.denoising_period * config.collection_sample_rate ) self.circular_buffer = np.zeros( ( self.denoising_samples, # Number of samples config.subcarriers, # Number of subcarriers config.antennas.count, # Number of RX antennas 1, # Number of TX antennas ), dtype=np.complex64, ) self.sample_index = 0 @property def last_sample(self) -> None | CSIMatrix: """ The last sample of the preprocessor. This is used for low frequency processing """ match config.preprocessing.denoising: case "none": return self._last_sample case "median": # Return the median of the last samples median_abs = np.median(np.abs(self.circular_buffer), axis=0) median_angle = np.median(np.angle(self.circular_buffer), axis=0) ans = median_abs * np.exp(1j * median_angle) return ans case "mean": # Return the mean of the last samples return np.mean(self.circular_buffer, axis=0) case _: raise ValueError( f"Invalid denoising method: {config.preprocessing.denoising}" ) def remove_sto(self, csi: CSIMatrix) -> CSIMatrix: """ Remove sampling time offsets caused by: - Sampling frequency offset - Packet detection delay This is done by multiplying the CSI matrices of consecutive antennas in the array. According to [1]: > Conjugate multiplication and division are the only two methods to > eliminate the SFO and PDD. No citation or explanation is provided, so not sure why/whether it works. Something similar is also done in [2] without explanation. [1] - https://tns.thss.tsinghua.edu.cn/wst/docs/sanitization [2] - https://doi.org/10.1109/ICC51166.2024.10623053 """ csi_remove_sto = np.zeros_like(csi) for antenna in range(csi.shape[1]): antenna_nxt = (antenna + 1) % csi.shape[1] csi_remove_sto[:, antenna, :] = np.multiply( csi[:, antenna, :], csi[:, antenna_nxt, :].conj() ) return csi_remove_sto def remove_sfo( self, csi: CSIMatrix, visualiser: None | Callable[[npt.NDArray[Any], visualise.figures.Figure], None] = None, ) -> CSIMatrix: """ Remove sampling frequency offsets by linear regression. This is caused by the difference in sampling frequency between the transmitter and receiver, and this is linear in frequency. We can estimate it using linear regression and compensate for it. """ N_st, N_rx, _ = csi.shape unwrapped = np.unwrap(np.angle(csi[:, :, 0]), axis=0).reshape(N_st, N_rx, 1) if visualiser: visualiser(unwrapped, visualise.figures.Figure.UNWRAPPED_PHASE) X = np.vstack([np.arange(N_st), np.ones(N_st)]).T for antenna in range(csi.shape[1]): tau, rho = np.linalg.lstsq(X, unwrapped[:, antenna, 0])[0] csi[:, antenna, 0] = np.abs(csi[:, antenna, 0]) * np.exp( 1j * (np.angle(csi[:, antenna, 0]) - (tau * np.arange(csi.shape[0]) + rho)) ) return csi def remove_agc(self, csi: CSIMatrix, frames: dict[CSIHost, CSI]) -> CSIMatrix: """ Normalise the magnitude of the CSI data to compensate for the effect of the Automatic Gain Control (AGC) of the receiver References: [1] - """ rssi = [ frames[host].header.rssi1 if antenna == 0 else frames[host].header.rssi2 for host, antenna in config.antennas.order ] rssi_linear = np.reshape(10 ** (np.array(rssi) / 10), (1, -1, 1)) csi_power = np.sum(np.abs(csi) ** 2) return csi * np.sqrt(rssi_linear / csi_power) def skip_subcarriers(self, csi: CSIMatrix) -> CSIMatrix: """ Sometimes beccause of the large amount of processing, we need to skip some subcarriers for the processing to be able to run in real time. The subcarriers to skip are defined in the config file, per subcarrier_step. If subcarrier_step is set to 1 (default), no subcarriers are skipped. """ return csi[:: config.preprocessing.subcarrier_step, :, :] def fill_pilots(self, csi: CSIMatrix) -> CSIMatrix: """ Fill the pilot subcarriers with the average of the surrounding subcarriers. This is done by averaging the subcarriers before and after the pilot subcarriers. Pilots are detected by checking that the value is exactly 0. Also, it adds placeholders for the middle null subcarriers. """ num_middle = 1 if config.channel_width == 20 else 3 assert csi.shape[0] + num_middle == config.subcarriers with_middle = np.zeros( (csi.shape[0] + num_middle, csi.shape[1], csi.shape[2]), dtype=np.complex64 ) with_middle[: csi.shape[0] // 2, :, :] = csi[: csi.shape[0] // 2, :, :] with_middle[csi.shape[0] // 2 + num_middle :, :, :] = csi[ csi.shape[0] // 2 :, :, : ] if num_middle == 3: with_middle[csi.shape[0] // 2 + 1, :, :] = ( csi[csi.shape[0] // 2 - 1, :, :] + csi[csi.shape[0] // 2, :, :] ) / 2 return np.where( np.expand_dims(with_middle[:, 0, 0] == 0, axis=(1, 2)), correlate(with_middle, [[[1 / 2]], [[0]], [[1 / 2]]], mode="same"), with_middle, ) def bandpass(self, csi: CSIMatrix) -> CSIMatrix: """ Apply a Butterworth bandpass filter to the CSI data. Remove low-frequency noise (caused by static paths) and high-frequency noise (measurement variance). """ if not hasattr(self, "filter"): assert config.preprocessing.bandpass is not None self.filter = butter( 5, config.preprocessing.bandpass.bounds, fs=config.collection_sample_rate, btype="band", output="sos", ) if not hasattr(self, "filter_zi"): self.filter_zi = ( np.expand_dims(sosfilt_zi(self.filter), axis=(-1, -2, -3)) * csi ) h_hat_filt, self.filter_zi = sosfilt( self.filter, [csi], zi=self.filter_zi, axis=0 ) return h_hat_filt[0] def preprocess( self, h: CSIMatrix, frames: dict[CSIHost, CSI], visualiser: None | Callable[[npt.NDArray[Any], visualise.figures.Figure], None] = None, ) -> CSIMatrix: # CSI data is not available for pilot subcarriers. h_hat = h for step in config.preprocessing.steps: match step: case "skip_subcarriers": h_hat = self.skip_subcarriers(h_hat) case "remove_agc": h_hat = self.remove_agc(h_hat, frames) case "remove_sfo": h_hat = self.remove_sfo(h_hat, visualiser=visualiser) case "remove_sto": h_hat = self.remove_sto(h_hat) case "fill_pilots": h_hat = self.fill_pilots(h_hat) case "bandpass": h_hat = self.bandpass(h_hat) logger.debug(f"CSI shape: {h_hat.shape}") self._last_sample = h_hat if config.preprocessing.denoising != "none": self.circular_buffer[self.sample_index, : h_hat.shape[0]] = h_hat self.sample_index = (self.sample_index + 1) % self.denoising_samples return h_hat