Compare commits

..

9 Commits

Author SHA1 Message Date
Christos Falas
990baee908
fix antenna order post refactor 2025-05-02 01:12:46 +01:00
Christos Falas
66c893b50b
motion detection example home assistant 2025-05-02 01:12:05 +01:00
Christos Falas
6f454c6190
add figure extractor
Used to store the protobuffer messages into files, so that they can be
plotted as part of the dissertation
2025-05-01 15:58:28 +01:00
Christos Falas
6be12beb49
add motion detector example
This demonstrates a simple example of how to use the framework
2025-05-01 15:50:41 +01:00
Christos Falas
a7a0071486
move preprocessing to CSIApplication 2025-05-01 15:50:24 +01:00
Christos Falas
2d16d5cf34
add type stubs 2025-05-01 14:22:10 +01:00
Christos Falas
581aa64ee3
refactor csi producers 2025-05-01 14:22:00 +01:00
Christos Falas
cc27972d81
refactor into separate CSIApplication framework 2025-04-26 11:28:17 +01:00
Christos Falas
7c9075c9eb
add phase analysis visualisation 2025-04-02 15:00:37 +01:00
21 changed files with 1716 additions and 795 deletions

View File

@ -0,0 +1,72 @@
"""Motion Detection example
This example shows how to use the CSI framework to connect to a FeitCSI host and
detect changes in the environment
"""
from queue import Queue
import numpy as np
import numpy.typing as npt
from where_fi.application import CSIApplication
from where_fi.collection.ingest import RealtimeCSIProducer
# Connect to a FeitCSI host
producer = RealtimeCSIProducer()
app = CSIApplication(producer)
# Stores historical data for each receiving antenna, for each subcarrier
historical: dict[tuple[int, int], Queue[np.complex64]] = {}
MAGN_THRESHOLD = 20
PHASE_THRESHOLD = 0.5
QUEUE_SIZE = 2000
@app.on_process
def _(sample: npt.NDArray[np.complex64]) -> None:
"""
Process the CSI data and detect changes in the environment.
This function is called by the framework at a fixed interval, with the latest CSI
sample received.
It is used to detect changes in the environment caused by motion, by comparing each
entry in the matrix with a moving average
"""
change = False
for antenna in range(sample.shape[1]):
for subcarrier in range(sample.shape[0]):
# Get the current subcarrier data
current = sample[subcarrier, antenna, 0]
# Get the historical data for this antenna and subcarrier
if (antenna, subcarrier) not in historical:
historical[(antenna, subcarrier)] = Queue(maxsize=QUEUE_SIZE)
historical_data = historical[(antenna, subcarrier)]
# Calculate the average of the historical data for this antenna and
# subcarrier
mean = np.mean(historical_data.queue)
# If we have enough historical data, compare it with the current data
if historical_data.full():
historical_data.get()
# Add the current sample to the historical data
historical_data.put(current)
# Compare the current data with the historical data
if (
np.abs(mean - current) > MAGN_THRESHOLD
or np.abs(np.angle(mean) - np.angle(current)) > PHASE_THRESHOLD
):
change = True
if change:
print("Motion detected!")
else:
print("No motion detected!")
app.start()

View File

@ -0,0 +1,109 @@
"""Motion Detection example
This example shows how to use the CSI framework to connect to a FeitCSI host and
detect changes in the environment
Once motion is detected, the application will log the change to Home Assistant
through an HTTP request.
"""
import os
from queue import Queue
import numpy as np
import numpy.typing as npt
import requests
from where_fi.application import CSIApplication
from where_fi.collection.ingest import RealtimeCSIProducer
# Connect to a FeitCSI host
producer = RealtimeCSIProducer()
app = CSIApplication(producer)
class HomeAssistantBinarySensor:
"""
Represents a binary sensor in Home Assistant.
Uses the HTTP API [1] to update the state of the sensor.
[1] - https://www.home-assistant.io/integrations/http/#binary-sensor
"""
def __init__(self, id: str, name: str) -> None:
self.id = id
self.name = name
BASE_URL = os.getenv("HOME_ASSISTANT_URL")
API_KEY = os.getenv("HOME_ASSISTANT_API_KEY")
self.url = f"{BASE_URL}/api/states/binary_sensor.{self.id}"
self.headers = {"Authorization": f"Bearer {API_KEY}"}
self.state = False
def update(self, state: bool) -> None:
if self.state == state:
return
self.state = state
data = {
"state": "on" if state else "off",
"attributes": {"friendly_name": self.name},
}
requests.post(self.url, json=data, headers=self.headers)
# Stores historical data for each receiving antenna, for each subcarrier
historical: dict[tuple[int, int], Queue[np.complex64]] = {}
sensor = HomeAssistantBinarySensor("motion_detector", "Motion Detector")
MAGN_THRESHOLD = 20
PHASE_THRESHOLD = 0.5
QUEUE_SIZE = 2000
@app.on_process
def _(sample: npt.NDArray[np.complex64]) -> None:
"""
Process the CSI data and detect changes in the environment.
This function is called by the framework at a fixed interval, with the latest CSI
sample received.
It is used to detect changes in the environment caused by motion, by comparing each
entry in the matrix with a moving average
"""
change = False
for antenna in range(sample.shape[1]):
for subcarrier in range(sample.shape[0]):
# Get the current subcarrier data
current = sample[subcarrier, antenna, 0]
# Get the historical data for this antenna and subcarrier
if (antenna, subcarrier) not in historical:
historical[(antenna, subcarrier)] = Queue(maxsize=QUEUE_SIZE)
historical_data = historical[(antenna, subcarrier)]
# Calculate the average of the historical data for this antenna and
# subcarrier
mean = np.mean(historical_data.queue)
# If we have enough historical data, compare it with the current data
if historical_data.full():
historical_data.get()
# Add the current sample to the historical data
historical_data.put(current)
# Compare the current data with the historical data
if (
np.abs(mean - current) > MAGN_THRESHOLD
or np.abs(np.angle(mean) - np.angle(current)) > PHASE_THRESHOLD
):
change = True
sensor.update(change)
app.start()

View File

@ -15,6 +15,7 @@ dependencies = [
"pydantic>=2.10.6", "pydantic>=2.10.6",
"torch", "torch",
"grpcio>=1.70.0", "grpcio>=1.70.0",
"requests>=2.32.3",
] ]
[build-system] [build-system]
@ -50,6 +51,9 @@ packages = ["where_fi"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"grpc-stubs>=1.53.0.5",
"grpcio-tools>=1.70.0", "grpcio-tools>=1.70.0",
"matplotlib-stubs>=0.1.0",
"protoletariat>=3.3.9", "protoletariat>=3.3.9",
"types-requests>=2.32.0.20250328",
] ]

1212
uv.lock

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,10 @@ import logging
from . import cli from . import cli
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(name)-50s %(levelname)-8s %(message)s", format="%(asctime)s %(name)-50s %(levelname)-8s %(message)s",
) )
if __name__ == "__main__": if __name__ == "__main__":
cli.app() cli.cli()

217
where_fi/application.py Normal file
View File

@ -0,0 +1,217 @@
import logging
import multiprocessing as mp
import threading
import time
from typing import Any, Callable, NamedTuple
import numpy.typing as npt
from where_fi.collection import CSIMatrix, ingest
from where_fi.collection.csi_frame import CSI
from where_fi.collection.protocols import CSIProducer, MergedCSI
from where_fi.config import config
from where_fi.processing.preprocess import Preprocessor
from where_fi.visualise import server as visualise
class Receiver(NamedTuple):
ip: ingest.Host
receiver: ingest.FeitReceiver
thread: threading.Thread
class Scheduler:
"""
A simple scheduler that runs a function at a given rate.
"""
def __init__(self, rate: float, func: Callable[[], None]) -> None:
self.rate = rate
self.func = func
self.active = True
def run(self) -> None:
"""
Run the scheduler in a loop, calling the function at the given rate.
"""
while self.active:
self.func()
time.sleep(1 / self.rate)
def start(self) -> None:
"""
Start the scheduler in a separate thread.
"""
self.thread = threading.Thread(target=self.run)
self.thread.start()
def stop(self) -> None:
"""
Stop the scheduler.
"""
self.active = False
class CSIApplication:
"""
A high-level application that manages CSI data ingestion and processing.
This allows a simple interface to be used for the main CLI logic, without having to
deal with the threads and queues directly.
It can be used using decortors to register callbacks for different stages of the
processing pipeline:
- `on_pre_merge`: Called with the raw CSI data (including headers) from each
receiver, before it is merged into a single matrix.
- `on_sample`: Called once per sample with the merged CSI data.
- `on_process`: Called at the processing sample rate, without any data (this is
mostly used as a scheduler).
"""
def __init__(self, producer: CSIProducer, visualise_raw: bool = False) -> None:
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.producer = producer
self.webapp_queue: "mp.Queue[visualise.VisualiserData]" = mp.Queue(
config.processing_sample_rate
)
self.webapp = visualise.Webapp()
self.webapp_thread = threading.Thread(
target=self.webapp.start, args=(self.webapp_queue,)
)
self.visualise_raw = visualise_raw
self.pre_merge_callback = None
self.raw_csi_callback = None
self.preprocessed_csi_callback = None
self.processing_callback = None
self.preprocessor = Preprocessor()
def visualise_data(
self, data: npt.NDArray[Any], dtype: visualise.figures.Figure
) -> None:
"""
Update a visualisation with the given data. The available visualisations are as
per visualise.server.all_figures.
"""
if not self.webapp_queue.full():
self.webapp_queue.put(visualise.VisualiserData(data, dtype))
def on_raw(self, func: Callable[[CSIMatrix], None]) -> Callable[[CSIMatrix], None]:
"""
Decorator to register a callback for the sample preprocessing.
"""
def decorator(data: CSIMatrix) -> None:
func(data)
self.raw_csi_callback = decorator
return decorator
def on_sample(
self, func: Callable[[CSIMatrix], None]
) -> Callable[[CSIMatrix], None]:
"""
Decorator to register a callback for the sample preprocessing.
"""
def decorator(data: CSIMatrix) -> None:
self.visualise_data(data, visualise.figures.Figure.RAW_CSI)
func(data)
self.preprocessed_csi_callback = decorator
return decorator
def on_pre_merge(
self, func: Callable[[dict[ingest.Host, CSI]], None]
) -> Callable[[dict[ingest.Host, CSI]], None]:
"""
Decorator to register a callback for the samples before merging.
"""
self.pre_merge_callback = func
return func
def on_process(
self, func: Callable[[CSIMatrix], None]
) -> Callable[[CSIMatrix], None]:
"""
Decorator to register a callback for the sample processing.
"""
self.processing_callback = func
return func
def process_sample(self, sample: MergedCSI) -> None:
"""Data pipeline for processing a sample.
This is the entry point for a sample being received from the producer. Depending
on the callbacks which are registered, this will call the appropriate functions
to pre-process and visualise the sample.
"""
if self.visualise_raw:
self.visualise_data(sample.matrix, visualise.figures.Figure.RAW_CSI)
if self.raw_csi_callback is not None:
self.raw_csi_callback(sample.matrix)
if self.pre_merge_callback is not None:
self.pre_merge_callback(sample.frames)
processed = self.preprocessor.preprocess(sample.matrix)
if self.visualise_raw:
self.visualise_data(processed, visualise.figures.Figure.PROCESSED_CSI)
if self.preprocessed_csi_callback is not None:
self.preprocessed_csi_callback(processed)
def listen(self) -> None:
"""
Listen for incoming data from the selected ingestor, and call the appropriate
callback.
"""
data_gen = self.producer()
while True:
try:
sample = next(data_gen)
self.process_sample(sample)
except StopIteration:
break
except Exception as e:
self.logger.error(f"Error processing sample: {e}", exc_info=True)
break
def start(self) -> None:
# for receiver in self.receivers:
# receiver.thread.start()
self.buffer_thread = threading.Thread(
target=self.listen,
)
self.buffer_thread.start()
self.webapp_thread.start()
if self.processing_callback is not None:
def get_proc_sample() -> None:
sample = self.preprocessor.last_sample
if sample is not None and self.processing_callback is not None:
self.processing_callback(sample)
else:
self.logger.warning("No samples to process")
self.scheduler = Scheduler(config.processing_sample_rate, get_proc_sample)
self.scheduler.start()
try:
self.webapp_thread.join()
except KeyboardInterrupt:
self.stop()
def stop(self) -> None:
self.logger.info("Stopping application")
self.producer.stop()
if hasattr(self, "scheduler"):
self.scheduler.stop()
self.scheduler.thread.join()
self.buffer_thread.join()
self.webapp.active = False
self.webapp_thread.join()
self.logger.info("Application stopped")

View File

@ -1,24 +1,26 @@
import logging import logging
import multiprocessing as mp from queue import Queue
from typing import Any from typing import cast
import numpy as np import numpy as np
import numpy.typing as npt import numpy.typing as npt
import torch import torch
import typer import typer
from where_fi.collection import CSIMatrix
from ..application import CSIApplication
from ..config import config from ..config import config
from ..processing.aoa import AoA from ..processing.aoa import AoA
from ..processing.preprocess import Preprocessor
from ..visualise import server as visualise from ..visualise import server as visualise
from . import file, globals from . import file, globals
app = typer.Typer(callback=globals.main) cli = typer.Typer(callback=globals.main)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
@app.command() @cli.command()
def antennas() -> None: def antennas() -> None:
"""Utility to help determine the order in which antennas are plugged in """Utility to help determine the order in which antennas are plugged in
@ -29,38 +31,66 @@ def antennas() -> None:
""" """
from ..utils import antenna_order from ..utils import antenna_order
if not globals.is_live: if not globals.csi_producer.is_live:
raise ValueError("This command only works with live data") raise NotImplementedError("This command only works with live data")
antenna_order.main() antenna_order.main()
@app.command() @cli.command()
def heatmap() -> None: def heatmap() -> None:
preprocessor = Preprocessor() app = CSIApplication(globals.csi_producer, visualise_raw=True)
aoa = AoA() aoa = AoA()
# Start webapp in background process @app.on_sample
webapp_queue: "mp.Queue[visualise.VisualiserData]" = mp.Queue(config.sample_rate) def _(sample: npt.NDArray[np.complex64]) -> None:
webapp = mp.Process(target=visualise.start, args=(webapp_queue,)) processed_tensor = torch.tensor(sample, device=device)
webapp.start()
def visualise_data(data: npt.NDArray[Any], dtype: visualise.figures.Figure) -> None:
if not webapp_queue.full():
webapp_queue.put(visualise.VisualiserData(data, dtype))
def callback(antenna_data: npt.NDArray[np.complex64]) -> None:
logger.info(f"Got final CSI data with shape {antenna_data.shape}")
visualise_data(antenna_data, visualise.figures.Figure.RAW_CSI)
processed = preprocessor.preprocess(antenna_data, visualiser=visualise_data)
visualise_data(processed, visualise.figures.Figure.PROCESSED_CSI)
logger.info(f"Processed CSI data with shape {processed.shape}")
processed_tensor = torch.tensor(processed, device=device)
aoa.update(processed_tensor) aoa.update(processed_tensor)
aoa.heatmap(visualiser=visualise_data) aoa.heatmap(visualiser=app.visualise_data)
globals.csi_producer(csi_callback=callback) app.start()
logger.info("Finished processing CSI data") logger.info("Finished processing CSI data")
app.add_typer(file.app, name="file", help="Commands for working with CSI files") @cli.command()
def phase_analysis(
subcarriers: list[int] = [0], rx_antenna: int = 0, tx_antenna: int = 0
) -> None:
"""
Visualise the phase information in the CSI data received from the antennas.
The data goes through the same preprocessing steps as the heatmap command, but
instead of going through the AoA estimation, we simply analyse the phase of the
selected subcarrier and antenna.
"""
app = CSIApplication(globals.csi_producer)
if isinstance(subcarriers, int):
subcarriers = [subcarriers]
print("Starting phase analysis on subcarriers: ", subcarriers)
subcarrier_phase: dict[int, Queue[float]] = {
x: Queue(config.collection_sample_rate) for x in subcarriers
}
@app.on_sample
def _(sample: CSIMatrix) -> None:
for subcarrier in subcarriers:
phase = cast(float, np.angle(sample[subcarrier, rx_antenna, tx_antenna]))
if subcarrier_phase[subcarrier].full():
subcarrier_phase[subcarrier].get()
subcarrier_phase[subcarrier].put(phase)
@app.on_process
def _(_: CSIMatrix) -> None:
logger.info("Updating phase visualisation")
app.visualise_data(
np.array([x.queue for x in subcarrier_phase.values()]),
visualise.figures.Figure.PHASE_ANALYSIS,
)
app.start()
cli.add_typer(file.app, name="file", help="Commands for working with CSI files")

View File

@ -1,18 +1,15 @@
from functools import partial
from pathlib import Path from pathlib import Path
from .. import collection from .. import collection
from ..collection import file, ingest from ..collection import file, ingest
csi_producer: collection.CSIProducer = collection.noop csi_producer: collection.CSIProducer = collection.NoopCSIProducer()
is_live = True is_live = True
def main(from_file: Path | None = None) -> None: def main(from_file: Path | None = None) -> None:
global csi_producer, is_live global csi_producer, is_live
if from_file: if from_file:
csi_producer = partial(file.start_processing, file_path=from_file) csi_producer = file.FileCSIPRoducer(path=from_file)
is_live = False
else: else:
csi_producer = ingest.start_processing csi_producer = ingest.RealtimeCSIProducer()
is_live = True

View File

@ -1,9 +1,24 @@
from typing import Iterator
import numpy as np
import numpy.typing as npt
from . import file, ingest from . import file, ingest
from .protocols import CSICallback, CSIProducer from .protocols import CSIProducer, MergedCSI
def noop(csi_callback: CSICallback | None = None) -> None: class NoopCSIProducer:
del csi_callback """A no-op CSI producer that does nothing"""
is_live = False
def __call__(self) -> Iterator[MergedCSI]:
return iter([])
def stop(self) -> None:
pass
__all__ = ["file", "ingest", "noop", "CSIProducer", "CSICallback"] CSIMatrix = npt.NDArray[np.complex64]
__all__ = ["file", "ingest", "NoopCSIProducer", "CSIProducer"]

View File

@ -2,6 +2,7 @@ import logging
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Iterator
import h5py import h5py
@ -10,12 +11,20 @@ from . import protocols
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def start_processing( class FileCSIPRoducer:
file_path: Path, is_live = False
csi_callback: protocols.CSICallback | None = None,
) -> None: def __init__(self, path: Path) -> None:
logger.info(f"Replaying CSI data from {file_path}") self.path = path
with h5py.File(file_path, "r") as file: if not self.path.exists():
raise FileNotFoundError(f"File {self.path} does not exist")
def __call__(self) -> Iterator[protocols.MergedCSI]:
"""
Replay the CSI data from the file.
"""
logger.info(f"Replaying CSI data from {self.path}")
with h5py.File(self.path, "r") as file:
try: try:
for key in file: for key in file:
datetime.fromisoformat(key) datetime.fromisoformat(key)
@ -28,7 +37,13 @@ def start_processing(
target_offset = datetime.now() - start_time target_offset = datetime.now() - start_time
for key in file: for key in file:
logger.debug(f"Sending data from {key} at {datetime.now().isoformat()}") logger.debug(f"Sending data from {key} at {datetime.now().isoformat()}")
csi_callback(file[key][:])
# TODO: add raw frames
yield protocols.MergedCSI(
frames={},
matrix=file[key][:],
)
curr_time_virtual = datetime.fromisoformat(key) curr_time_virtual = datetime.fromisoformat(key)
new_offset = datetime.now() - curr_time_virtual new_offset = datetime.now() - curr_time_virtual
logger.debug(f"New offset {new_offset}, target is {target_offset}") logger.debug(f"New offset {new_offset}, target is {target_offset}")
@ -37,3 +52,9 @@ def start_processing(
f"Data is {new_offset - target_offset} behind, lagging behind..." f"Data is {new_offset - target_offset} behind, lagging behind..."
) )
time.sleep(max(0, (target_offset - new_offset).total_seconds())) time.sleep(max(0, (target_offset - new_offset).total_seconds()))
def stop(self) -> None:
"""
Stop the producer.
"""
logger.info("Stopping file CSI producer")

View File

@ -1,18 +1,18 @@
import logging import logging
import multiprocessing as mp import multiprocessing as mp
import selectors
import socket import socket
import struct
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Callable, NamedTuple from typing import Iterator
import numpy as np import numpy as np
from ..config import config from ..config import config
from .csi_frame import CSI from .csi_frame import CSI
from .protocols import CSICallback from .protocols import MergedCSI
Host = tuple[str, int] Host = tuple[str, int]
@ -27,21 +27,30 @@ class FeitHost:
self.active = True self.active = True
self.checker = threading.Thread(target=self.check_continuous) self.checker = threading.Thread(target=self.check_continuous)
self.checker.start() self.checker.start()
self.selectors: list[selectors.BaseSelector] = []
def check_connection(self) -> bool: def check_connection(self) -> bool:
try:
feitcsi_status = subprocess.run( feitcsi_status = subprocess.run(
f"ssh root@{self.host[0]} pgrep feitcsi", f"ssh root@{self.host[0]} pgrep feitcsi",
check=False, check=False,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
shell=True, shell=True,
timeout=1,
) )
return feitcsi_status.returncode == 0 return feitcsi_status.returncode == 0
except subprocess.TimeoutExpired:
return False
def connect(self) -> None: def connect(self) -> None:
self.server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.server.connect(self.host) self.server.connect(self.host)
self.server.send(b"stop\n") self.server.send(b"stop\n")
self.server.send(self.command.encode()) self.server.send(self.command.encode())
for selector in self.selectors:
selector.register(self.server, selectors.EVENT_READ, data=self)
self.logger.info(f"Connected to {self.host}") self.logger.info(f"Connected to {self.host}")
def check_continuous(self) -> None: def check_continuous(self) -> None:
@ -54,6 +63,7 @@ class FeitHost:
""" """
last_status = False last_status = False
while self.active: while self.active:
self.logger.debug(f"Checking connection to {self.host[0]}")
if not self.check_connection(): if not self.check_connection():
self.logger.error(f"FeitCSI is not running on {self.host[0]}") self.logger.error(f"FeitCSI is not running on {self.host[0]}")
last_status = False last_status = False
@ -72,12 +82,17 @@ class FeitTransmitter(FeitHost):
f"--channel-width {config.channel_width} " f"--channel-width {config.channel_width} "
f"--format {config.frame_format} " f"--format {config.frame_format} "
f"--mode inject -s 1 --verbose " f"--mode inject -s 1 --verbose "
f"--inject-delay {1_000_000 // config.sample_rate}" f"--inject-delay {1_000_000 // config.collection_sample_rate}"
) )
super().__init__(config.transmit_host, command) super().__init__(config.transmit_host, command)
class FeitReceiver(FeitHost): class FeitReceiver(FeitHost):
"""
A class used to connect to the host running FeitCSI and receive CSI data over a UDP
socket.
"""
def __init__(self, host: Host) -> None: def __init__(self, host: Host) -> None:
command = ( command = (
f"feitcsi --frequency {config.central_freq} " f"feitcsi --frequency {config.central_freq} "
@ -87,38 +102,51 @@ class FeitReceiver(FeitHost):
) )
super().__init__(host, command) super().__init__(host, command)
def listen(self, queue: "mp.Queue[CSI]") -> None: def recv(self) -> CSI:
prev_time = datetime.now() """
self.logger.info("Listening for CSI data") Block until a CSI frame is received and return it.
while self.active: """
while not hasattr(self, "server"):
time.sleep(0.1) # Receive the data from the socket
# This is the max size of a UDP packet. The size of the actual CSI
# packet will depend on the frame format and channel width, which
# changes the number of subcarriers
data = self.server.recv(65535) data = self.server.recv(65535)
try: # Decode the data using the CSI frame format
csidata = CSI(data) csi = CSI(data)
self.logger.debug( return csi
f"Received CSI data after {datetime.now() - prev_time}"
) def register(self, selector: selectors.BaseSelector) -> None:
prev_time = datetime.now() """
queue.put(csidata) Register the receiver as a selector. This will allow us to multiplex the I/O
except struct.error: operations on a single thread.
self.logger.error("Failed to parse CSI data")
self.logger.info("Stopping CSI receiver") This allows us to block until any of the receivers receive data
"""
self.selectors.append(selector)
if hasattr(self, "server"):
selector.register(self.server, selectors.EVENT_READ, data=self)
class CSIProcessor: class CSIAntennaArray:
def __init__( """
self, Represents a set of receiver hosts that are used to receive CSI data, assumed to be
receiver_connections: dict[Host, "mp.Queue[CSI]"], part of a linear antenna array.
) -> None:
This class is used to buffer a set of CSI data from multiple receivers, and once a
set of readings is ready for each receiver, it will be merged into a single sample
using the antenna order in the configuration.
When the data is ready, the following callbacks are called:
- `pre_merge_callback`: Called with the data before merging. This is useful for
per-antenna analysis, e.g. finding the correct antenna order
- `sample_callback`: Called with the merged data
"""
def __init__(self, receivers: list[FeitReceiver]) -> None:
self.pending_data: dict[Host, tuple[datetime, CSI]] = {} self.pending_data: dict[Host, tuple[datetime, CSI]] = {}
self.pending_data_lock = mp.Lock() self.pending_data_lock = mp.Lock()
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.last_processed = datetime.now() self.last_processed = datetime.now()
self.connections = receiver_connections self.receivers = receivers
self.active = True self.active = True
def add_data(self, host: Host, data: CSI) -> None: def add_data(self, host: Host, data: CSI) -> None:
@ -142,16 +170,13 @@ class CSIProcessor:
return False return False
return True return True
def process_data( def process_data(self) -> None | MergedCSI:
self, if not self.is_ready():
callback: CSICallback | None = None, self.logger.debug("Not all data is ready")
pre_merge_callback: Callable[[dict[Host, CSI]], None] | None = None, return None
) -> None:
self.last_processed = datetime.now() self.last_processed = datetime.now()
if pre_merge_callback is not None:
pre_merge_callback( frames = {host: data[1] for host, data in self.pending_data.items()}
{host: data[1] for host, data in self.pending_data.items()}
)
antenna_data = [ antenna_data = [
np.expand_dims(self.pending_data[ip][1].matrix[:, antenna], axis=2) np.expand_dims(self.pending_data[ip][1].matrix[:, antenna], axis=2)
for ip, antenna in config.antennas.order for ip, antenna in config.antennas.order
@ -159,66 +184,45 @@ class CSIProcessor:
# We have data from all servers # We have data from all servers
all_data = np.concat(antenna_data, axis=1) all_data = np.concat(antenna_data, axis=1)
return MergedCSI(frames=frames, matrix=all_data)
if callback: def process_forever(self) -> Iterator[MergedCSI]:
callback(all_data) # Register the receivers with the selector
self.logger.info("Starting to process data from receivers")
selector = selectors.DefaultSelector()
for receiver in self.receivers:
receiver.register(selector)
def process_forever(
self,
callback: CSICallback | None = None,
pre_merge_callback: Callable[[dict[Host, CSI]], None] | None = None,
) -> None:
try:
while self.active: while self.active:
for ip, queue in self.connections.items(): events = selector.select(timeout=0.1)
while not queue.empty(): if not events:
self.add_data(ip, queue.get()) self.logger.warning("No events received in the last 0.1 seconds")
if self.is_ready(): continue
self.process_data(callback, pre_merge_callback=pre_merge_callback)
else: for key, _ in events:
self.logger.debug("Not all data is ready") receiver = key.data
time.sleep(0.0005) self.add_data(receiver.host, receiver.recv())
except KeyboardInterrupt:
self.logger.info("Exiting CSI processing") sample = self.process_data()
if sample is not None:
yield sample
class Receiver(NamedTuple): class RealtimeCSIProducer:
ip: Host def __init__(self) -> None:
receiver: FeitReceiver self.receivers = [FeitReceiver(ip) for ip in config.receive_hosts]
queue: "mp.Queue[CSI]" self.transmitter = FeitTransmitter()
def start_processing(
csi_callback: CSICallback | None = None,
pre_merge_callback: Callable[[dict[Host, CSI]], None] | None = None,
) -> None:
receivers = [
Receiver(ip, FeitReceiver(ip), mp.Queue(config.sample_rate))
for ip in config.receive_hosts
]
transmitter = FeitTransmitter()
# Start injecting CSI frames # Start injecting CSI frames
processor = CSIProcessor({r.ip: r.queue for r in receivers}) self.buffer = CSIAntennaArray(self.receivers)
self.is_live = True
receiver_processes = [ def __call__(self) -> Iterator[MergedCSI]:
threading.Thread(target=r.receiver.listen, args=(r.queue,)) for r in receivers return self.buffer.process_forever()
]
for proc in receiver_processes: def stop(self) -> None:
proc.start() for r in self.receivers:
r.active = False
processing_thread = mp.Process( self.transmitter.active = False
target=processor.process_forever, args=(csi_callback, pre_merge_callback) self.buffer.active = False
)
processing_thread.start()
try:
while True:
time.sleep(100)
except KeyboardInterrupt:
for r in receivers:
r.receiver.active = False
transmitter.active = False
processor.active = False
return

View File

@ -1,10 +1,22 @@
from typing import Callable, Protocol from typing import Iterator, NamedTuple, Protocol
import numpy as np import numpy as np
import numpy.typing as npt import numpy.typing as npt
CSICallback = Callable[[npt.NDArray[np.complex64]], None] from .csi_frame import CSI
CSIHost = tuple[str, int]
class MergedCSI(NamedTuple):
"""Merged CSI data from all antennas"""
frames: dict[CSIHost, CSI]
matrix: npt.NDArray[np.complex64]
class CSIProducer(Protocol): class CSIProducer(Protocol):
def __call__(self, csi_callback: CSICallback | None = None) -> None: ... is_live: bool
def __call__(self) -> Iterator[MergedCSI]: ...
def stop(self) -> None: ...

View File

@ -43,7 +43,8 @@ class Config(BaseModel):
antennas: Antennas antennas: Antennas
sample_rate: int collection_sample_rate: int
processing_sample_rate: int
central_freq: int central_freq: int
channel_width: Literal[20, 40, 80, 160] channel_width: Literal[20, 40, 80, 160]
frame_format: Literal["NOHT", "HT", "VHT", "HESU"] frame_format: Literal["NOHT", "HT", "VHT", "HESU"]

View File

@ -6,44 +6,107 @@ import numpy as np
import numpy.typing as npt import numpy.typing as npt
from scipy.signal import butter, correlate, sosfilt, sosfilt_zi from scipy.signal import butter, correlate, sosfilt, sosfilt_zi
from where_fi.collection import CSIMatrix
from ..config import config from ..config import config
from ..visualise import server as visualise from ..visualise import server as visualise
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
np.seterr(invalid="ignore") np.seterr(invalid="ignore")
class Preprocessor: class Preprocessor:
def __init__(self) -> None: def __init__(self) -> None:
self.prev_entries: Queue[npt.NDArray[np.complex64]] = Queue(maxsize=100)
self.short_term_avg = np.zeros((1,), dtype=np.complex64) self.short_term_avg = np.zeros((1,), dtype=np.complex64)
self.long_term_avg = np.zeros((1,), dtype=np.complex64) self.long_term_avg = np.zeros((1,), dtype=np.complex64)
self.filter = butter( # self.filter = butter(
5, # 5,
config.preprocessing.bandpass.bounds, # config.preprocessing.bandpass.bounds,
fs=config.sample_rate, # fs=config.sample_rate,
btype="band", # btype="band",
output="sos", # output="sos",
# )
self._last_sample = None
@property
def last_sample(self) -> None | CSIMatrix:
"""
The last sample of the preprocessor. This is used for low frequency processing
"""
return self._last_sample
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 preprocess( def preprocess(
self, self,
h: npt.NDArray[np.complex64], h: CSIMatrix,
visualiser: None visualiser: None
| Callable[[npt.NDArray[Any], visualise.DataType], None] = None, | Callable[[npt.NDArray[Any], visualise.figures.Figure], None] = None,
) -> npt.NDArray[np.complex64]: ) -> CSIMatrix:
# CSI data is not available for pilot subcarriers. # CSI data is not available for pilot subcarriers.
h_hat: npt.NDArray[np.complex64] = np.where( h_hat: CSIMatrix = np.where(
np.expand_dims(h[:, 0, 0] == 0, axis=(1, 2)), np.expand_dims(h[:, 0, 0] == 0, axis=(1, 2)),
correlate(h, [[[1 / 2]], [[0]], [[1 / 2]]], mode="same"), correlate(h, [[[1 / 2]], [[0]], [[1 / 2]]], mode="same"),
h, h,
) )
# Skip subcarrierss per config logger.debug(f"CSI shape: {h_hat.shape}")
h_hat = h_hat[:: config.preprocessing.subcarrier_step, :, :]
# logger.info(f"CSI shape: {h_hat.shape}")
# h_hat = np.multiply(h_hat, h_hat.conj() / abs(h_hat.conj())) # h_hat = np.multiply(h_hat, h_hat.conj() / abs(h_hat.conj()))
h_hat = np.nan_to_num(h_hat) h_hat = np.nan_to_num(h_hat)
@ -51,27 +114,13 @@ class Preprocessor:
# h_hat = correlate(h_hat, np.ones((3, 1, 1)) / 3) # h_hat = correlate(h_hat, np.ones((3, 1, 1)) / 3)
# h_hat = correlate(h_hat, [[[1 / 4]], [[1 / 2]], [[1 / 4]]], mode="valid") # h_hat = correlate(h_hat, [[[1 / 4]], [[1 / 2]], [[1 / 4]]], mode="valid")
# Unwrap phase and remove linear fit h_hat = self.remove_sfo(h_hat, visualiser=visualiser)
print(h_hat.shape)
unwrapped = np.unwrap(np.angle(h_hat[:, :, 0]), axis=0).reshape( # Skip subcarrierss per config
h_hat.shape[0], h_hat.shape[1], 1 h_hat = h_hat[:: config.preprocessing.subcarrier_step, :, :]
)
if visualiser: # h_hat = self.remove_sto(h_hat)
visualiser(unwrapped, visualise.DataType.UNWRAPPED_PHASE)
for antenna in range(h_hat.shape[1]):
tau, rho = np.linalg.lstsq(
np.vstack([np.arange(h_hat.shape[0]), np.ones(h_hat.shape[0])]).T,
unwrapped[:, antenna, 0],
)[0]
h_hat[:, antenna, 0] = np.abs(h_hat[:, antenna, 0]) * np.exp(
1j
* (
np.angle(h_hat[:, antenna, 0])
- (tau * np.arange(h_hat.shape[0]) + rho)
)
)
return h_hat
# Assume that all csi matrices will have the same shape # Assume that all csi matrices will have the same shape
if self.long_term_avg.shape != h_hat.shape: if self.long_term_avg.shape != h_hat.shape:
self.long_term_avg = np.zeros(h_hat.shape, dtype=np.complex64) self.long_term_avg = np.zeros(h_hat.shape, dtype=np.complex64)
@ -81,7 +130,10 @@ class Preprocessor:
+ h_hat * config.preprocessing.moving_average_alpha + h_hat * config.preprocessing.moving_average_alpha
) )
self._last_sample = h_hat
# Remove long term average, to remove static paths # Remove long term average, to remove static paths
return h_hat
h_hat -= self.long_term_avg h_hat -= self.long_term_avg
# Apply bandpass filter to remove low and high frequency noise # Apply bandpass filter to remove low and high frequency noise

View File

@ -2,6 +2,8 @@ import logging
from collections import deque from collections import deque
from typing import Any, Collection from typing import Any, Collection
from where_fi.application import CSIApplication
from ..collection import ingest from ..collection import ingest
from ..config import config from ..config import config
@ -11,11 +13,19 @@ AntennaIdentifier = tuple[ingest.Host, int]
order: list[AntennaIdentifier] = [] order: list[AntennaIdentifier] = []
prev_unplugged: set[AntennaIdentifier] = set() prev_unplugged: set[AntennaIdentifier] = set()
long_antenna_average: dict[AntennaIdentifier, deque[int]] = { long_antenna_average: dict[AntennaIdentifier, deque[int]] = {
(host, 0): deque(maxlen=15 * config.sample_rate) for host in config.receive_hosts (host, 0): deque(maxlen=15 * config.collection_sample_rate)
} | {(host, 1): deque(maxlen=15 * config.sample_rate) for host in config.receive_hosts} for host in config.receive_hosts
} | {
(host, 1): deque(maxlen=15 * config.collection_sample_rate)
for host in config.receive_hosts
}
short_antenna_average: dict[AntennaIdentifier, deque[int]] = { short_antenna_average: dict[AntennaIdentifier, deque[int]] = {
(host, 0): deque(maxlen=2 * config.sample_rate) for host in config.receive_hosts (host, 0): deque(maxlen=2 * config.collection_sample_rate)
} | {(host, 1): deque(maxlen=2 * config.sample_rate) for host in config.receive_hosts} for host in config.receive_hosts
} | {
(host, 1): deque(maxlen=2 * config.collection_sample_rate)
for host in config.receive_hosts
}
RSSI_THRESHOLD = 10 RSSI_THRESHOLD = 10
@ -24,6 +34,10 @@ def average(data: Collection[Any]) -> float:
return sum(data) / len(data) return sum(data) / len(data)
app = CSIApplication(ingest.RealtimeCSIProducer())
@app.on_pre_merge
def callback(antenna_data: dict[ingest.Host, ingest.CSI]) -> None: def callback(antenna_data: dict[ingest.Host, ingest.CSI]) -> None:
global prev_unplugged global prev_unplugged
global antenna_average global antenna_average
@ -65,4 +79,4 @@ def callback(antenna_data: dict[ingest.Host, ingest.CSI]) -> None:
def main() -> None: def main() -> None:
ingest.start_processing(pre_merge_callback=callback) app.start()

View File

@ -1,2 +1,2 @@
server/generated generated
frontend/src/grpc frontend/src/grpc

View File

@ -1,9 +1,10 @@
all: server/generated frontend/src/grpc all: generated frontend/src/grpc
server/generated: protos/*.proto generated: protos/*.proto
mkdir -p server/generated mkdir -p generated
find protos/ -type f -name "*.proto" | xargs uv run python -m grpc_tools.protoc -Iprotos --python_out=server/generated --pyi_out=server/generated --grpc_python_out=server/generated find protos/ -type f -name "*.proto" | xargs uv run python -m grpc_tools.protoc -Iprotos --python_out=generated --pyi_out=generated --grpc_python_out=generated --mypy_grpc_out=generated
find protos/ -type f -name "*.proto" | xargs uv run protol --create-package --in-place --python-out server/generated/ protoc --proto-path=protos/
find protos/ -type f -name "*.proto" | xargs uv run protol --create-package --in-place --python-out generated/ protoc --proto-path=protos/
frontend/src/grpc: protos/*.proto frontend/src/grpc: protos/*.proto
mkdir -p frontend/src/grpc mkdir -p frontend/src/grpc

View File

@ -0,0 +1,20 @@
import logging
import grpc
from ..generated import figure_pb2, figure_pb2_grpc
def run() -> None:
# NOTE(gRPC Python Team): .close() is possible on a channel and should be
# used in circumstances in which the with statement does not fit the needs
# of the code.
with grpc.insecure_channel("localhost:50051") as channel:
stub = figure_pb2_grpc.FigureServiceStub(channel)
for figure in stub.GetFigure(figure_pb2.FigureRequest()):
print(f"Figure: {figure}")
if __name__ == "__main__":
logging.basicConfig()
run()

View File

@ -0,0 +1,89 @@
from pathlib import Path
from typing import cast
import grpc
import matplotlib.pyplot as plt
import numpy as np
import typer
from ..generated import figure_pb2, figure_pb2_grpc
app = typer.Typer()
@app.command()
def list() -> None:
"""
List all figures from the gRPC service.
"""
print("Connecting to the server...")
with grpc.insecure_channel("localhost:50051") as channel:
stub = figure_pb2_grpc.FigureServiceStub(channel)
print("Retrieving available figures...\n")
figures = [x for x in stub.GetFigure(figure_pb2.FigureRequest())]
if not figures:
print("No figures found.")
else:
for i, figure in enumerate(figures, 1):
print(f"{i}. Figure: {figure.title} (ID: {figure.uuid})")
choice = typer.prompt("Which figure to extract?", type=int)
assert isinstance(choice, int)
if 1 <= choice <= len(figures):
selected_figure = figures[choice - 1]
print(f"You selected: {selected_figure.title}")
extract(selected_figure.uuid)
else:
print("Invalid selection. Exiting.")
def get_figure(uuid: str) -> tuple[figure_pb2.Figure, figure_pb2.FigureData]:
"""
Get the figure data for a specific UUID.
"""
print(f"Connecting to the server to extract data for UUID: {uuid}...")
with grpc.insecure_channel("localhost:50051") as channel:
stub = figure_pb2_grpc.FigureServiceStub(channel)
all_figures = {x.uuid: x for x in stub.GetFigure(figure_pb2.FigureRequest())}
figure_data = stub.GetFigureUpdate(figure_pb2.FigureDataRequest(uuid=uuid))
for data in figure_data:
return (all_figures[uuid], data)
raise ValueError(f"Figure with UUID {uuid} not found.")
@app.command()
def extract(uuid: str, output: Path | None = None) -> None:
"""
Extract data for a specific figure identified by its UUID.
"""
_, figure = get_figure(uuid)
if not output:
output = cast(Path, typer.prompt("Enter output file path:", type=Path))
with open(output, "wb") as f:
f.write(figure.SerializeToString())
@app.command()
def plot(uuid: str) -> None:
"""
Plot the figure data for a specific UUID.
"""
figure, data = get_figure(uuid)
if data.line:
for line in data.line.lines:
plt.plot(
line.x if line.x else np.arange(len(line.y)), line.y, label=line.label
)
plt.xlabel(figure.x_label)
plt.ylabel(figure.y_label)
plt.title(figure.title)
elif figure.heatmap:
plt.imshow(figure.heatmap.data, cmap="hot", interpolation="nearest")
elif figure.histogram:
plt.hist(figure.histogram.data, bins=figure.histogram.bins)
plt.show()
if __name__ == "__main__":
app()

View File

@ -2,6 +2,7 @@ import logging
import multiprocessing as mp import multiprocessing as mp
import queue import queue
import threading import threading
import time
from concurrent import futures from concurrent import futures
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Generator from typing import Any, Generator
@ -10,13 +11,8 @@ import grpc
import numpy as np import numpy as np
import numpy.typing as npt import numpy.typing as npt
from ..generated import figure_pb2, figure_pb2_grpc
from . import figures from . import figures
from .generated import figure_pb2, figure_pb2_grpc
logger = logging.getLogger(__name__)
clients: dict[str, list[queue.Queue[figure_pb2.FigureData]]] = {}
clients_lock = threading.Lock()
@dataclass @dataclass
@ -26,25 +22,30 @@ class VisualiserData:
class FigureServer(figure_pb2_grpc.FigureServiceServicer): class FigureServer(figure_pb2_grpc.FigureServiceServicer):
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
self.clients: dict[str, list[queue.Queue[figure_pb2.FigureData]]] = {}
self.clients_lock = threading.Lock()
def GetFigure( def GetFigure(
self, request: figure_pb2.FigureRequest, context: grpc.ServicerContext self, request: figure_pb2.FigureRequest, context: grpc.ServicerContext
) -> Generator[figure_pb2.Figure, None, None]: ) -> Generator[figure_pb2.Figure, None, None]:
for figure_group in figures.Figure: for figure_group in figures.Figure:
for figure in figures.all_figures[figure_group].figures: for figure in figure_group.figure_class().figures:
yield figure yield figure
def GetFigureUpdate( def GetFigureUpdate(
self, request: figure_pb2.FigureDataRequest, context: grpc.ServicerContext self, request: figure_pb2.FigureDataRequest, context: grpc.ServicerContext
) -> Generator[figure_pb2.FigureData, None, None]: ) -> Generator[figure_pb2.FigureData, None, None]:
logger.info( self.logger.info(
f"Received request for figure data stream for figure {request.uuid}" f"Received request for figure data stream for figure {request.uuid}"
) )
with clients_lock: with self.clients_lock:
q: queue.Queue[figure_pb2.FigureData] = queue.Queue() q: queue.Queue[figure_pb2.FigureData] = queue.Queue()
if request.uuid not in clients: if request.uuid not in self.clients:
clients[request.uuid] = [] self.clients[request.uuid] = []
clients[request.uuid].append(q) self.clients[request.uuid].append(q)
try: try:
while context.is_active(): while context.is_active():
@ -53,33 +54,52 @@ class FigureServer(figure_pb2_grpc.FigureServiceServicer):
except queue.Empty: except queue.Empty:
pass pass
finally: finally:
with clients_lock: with self.clients_lock:
clients[request.uuid].remove(q) self.clients[request.uuid].remove(q)
def add_data(dtype: figures.Figure, new_data: npt.NDArray[np.complex128]) -> None: class Webapp:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
self.active = True
self.figure_server = FigureServer()
def add_data(
self, dtype: figures.Figure, new_data: npt.NDArray[np.complex128]
) -> None:
updates = figures.all_figures[dtype].update(new_data) updates = figures.all_figures[dtype].update(new_data)
for fig_id, update in updates.items(): for fig_id, update in updates.items():
for client in clients.get(fig_id, []): for client in self.figure_server.clients.get(fig_id, []):
client.put(update) client.put(update)
def listen_for_data(self, data_queue: "mp.Queue[VisualiserData]") -> None:
def listen_for_data(data_queue: "mp.Queue[VisualiserData]") -> None: while self.active:
while True: self.logger.debug("Listening for data")
data = data_queue.get()
add_data(data.dtype, data.data)
def start(data_queue: "mp.Queue[VisualiserData]") -> None:
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
figure_pb2_grpc.add_FigureServiceServicer_to_server(FigureServer(), server)
server.add_insecure_port("[::]:50051")
server.add_insecure_port("0.0.0.0:50051")
logger.info("Starting server on port 50051")
server.start()
logger.info("Server started")
threading.Thread(target=listen_for_data, args=(data_queue,), daemon=True).start()
try: try:
server.wait_for_termination() data = data_queue.get(timeout=0.5)
except KeyboardInterrupt: self.add_data(data.dtype, data.data)
logger.info("Exiting visualisation server") except queue.Empty:
pass
while not data_queue.empty():
data = data_queue.get()
def start(self, data_queue: "mp.Queue[VisualiserData]") -> None:
grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
figure_pb2_grpc.add_FigureServiceServicer_to_server(
self.figure_server, grpc_server
)
grpc_server.add_insecure_port("[::]:50051")
grpc_server.add_insecure_port("0.0.0.0:50051")
self.logger.info("Starting server on port 50051")
grpc_server.start()
self.logger.info("Server started")
data_thread = threading.Thread(target=self.listen_for_data, args=(data_queue,))
data_thread.start()
while self.active:
time.sleep(1)
self.logger.debug("Server is running")
grpc_server.stop(0.5)
data_thread.join()

View File

@ -58,10 +58,17 @@ class SimpleLineChart:
) )
def update( def update(
self, new_data: npt.NDArray[np.complex128], labels: Sequence[str] self,
new_data: npt.NDArray[np.complex128],
labels: Sequence[str],
x_values: None | npt.NDArray[np.float64] = None,
) -> FigureUpdate: ) -> FigureUpdate:
lines = [ lines = [
line_pb2.LineChartData.Line(y=new_data[i], label=labels[i]) line_pb2.LineChartData.Line(
y=new_data[i],
label=labels[i],
x=x_values[i] if x_values is not None else None,
)
for i in range(new_data.shape[0]) for i in range(new_data.shape[0])
] ]
return { return {
@ -72,6 +79,30 @@ class SimpleLineChart:
} }
class PerSubcarrierFigure(SpecificFigure):
def __init__(
self,
figures: Sequence[SimpleLineChart],
funcs: Sequence[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
) -> None:
self.charts = figures
self.figures = [figure.figure for figure in figures]
self.funcs = funcs
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
"""
Update the figure with new data.
The data is expected to be in the shape (subcarriers, data).
"""
subcarrier_labels = [f"Subcarrier {i}" for i in range(new_data.shape[0])]
updates = [
figure.update(func(new_data), labels=subcarrier_labels)
for func, figure in zip(self.funcs, self.charts, strict=True)
]
return reduce((lambda a, b: a | b), updates)
class PerAntennaFigure(SpecificFigure): class PerAntennaFigure(SpecificFigure):
"""A figure that plots data for each antenna separately. """A figure that plots data for each antenna separately.
@ -156,12 +187,90 @@ class HeatmapFigure(SpecificFigure):
return {self.figure.uuid: figure_pb2.FigureData(heatmap=heatmap)} return {self.figure.uuid: figure_pb2.FigureData(heatmap=heatmap)}
class EmpiricalCDF(SpecificFigure):
"""Helper class to create a graph of the empirical cumulative distribution function
and probability density functions of a set of observations.
"""
def __init__(self, title: str, x_label: str) -> None:
self.chart = SimpleLineChart(title, x_label, "Cumulative Probability")
self.figures = [self.chart.figure]
def update(self, new_data: npt.NDArray[np.float64]) -> FigureUpdate:
new_data = np.sort(new_data, axis=-1)
if new_data.ndim == 1:
new_data = np.expand_dims(new_data, 0)
y_values = np.repeat(
np.linspace(0, 1, new_data.shape[-1])[np.newaxis, :],
new_data.shape[0],
axis=0,
)
return self.chart.update(y_values, labels=["Frequency"], x_values=new_data)
class RandomVariable(SpecificFigure):
def get_cdf(
self, new_data: npt.NDArray[np.float64]
) -> tuple[npt.NDArray[np.floating[Any]], npt.NDArray[np.floating[Any]]]:
"""Get the cumulative distribution function of the data.
The data is expected to be in the shape (lines, data) or (data,) for single-line
charts.
"""
new_data = np.sort(new_data, axis=-1)
if new_data.ndim == 1:
new_data = np.expand_dims(new_data, 0)
y_values = np.expand_dims(np.linspace(0, 1, new_data.size), 0)
else:
y_values = np.repeat(
np.linspace(0, 1, new_data.shape[-1])[np.newaxis, :],
new_data.shape[0],
axis=0,
)
return new_data, y_values
def __init__(self, x_label: str, line_type: str = "Subcarrier") -> None:
self.charts = [
# SimpleLineChart(
# f"{x_label} Probability Density", x_label, "Probability Density"
# ),
SimpleLineChart(
f"{x_label} Cumulative Distribution", x_label, "Cumulative Probability"
),
]
self.funcs = [
# self.get_pdf,
self.get_cdf
]
self.figures = [figure.figure for figure in self.charts]
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
"""
Update the figure with new data.
The data is expected to be in the shape (subcarriers, data).
"""
subcarrier_labels = [f"Subcarrier {i}" for i in range(new_data.shape[0])]
updates: list[FigureUpdate] = []
for func, figure in zip(self.funcs, self.charts, strict=True):
x, y = func(new_data)
updates.append(
figure.update(
y,
labels=subcarrier_labels,
x_values=x,
)
)
return reduce((lambda a, b: a | b), updates)
class Figure(Enum): class Figure(Enum):
RAW_CSI = 0 RAW_CSI = 0
UNWRAPPED_PHASE = 1 UNWRAPPED_PHASE = 1
PROCESSED_CSI = 2 PROCESSED_CSI = 2
MUSIC_EIGENVALUES = 3 MUSIC_EIGENVALUES = 3
AOA_HEATMAP = 4 AOA_HEATMAP = 4
PHASE_ANALYSIS = 5
def figure_class(self) -> SpecificFigure: def figure_class(self) -> SpecificFigure:
return all_figures[self] return all_figures[self]
@ -187,4 +296,5 @@ all_figures = {
), ),
Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(), Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(),
Figure.AOA_HEATMAP: HeatmapFigure(), Figure.AOA_HEATMAP: HeatmapFigure(),
Figure.PHASE_ANALYSIS: RandomVariable("Phase"),
} }