diff --git a/where_fi/visualise/.gitignore b/where_fi/visualise/.gitignore index 75b5c8d..2142184 100644 --- a/where_fi/visualise/.gitignore +++ b/where_fi/visualise/.gitignore @@ -1,2 +1,2 @@ -server/generated +generated frontend/src/grpc diff --git a/where_fi/visualise/Makefile b/where_fi/visualise/Makefile index a359e89..22acc51 100644 --- a/where_fi/visualise/Makefile +++ b/where_fi/visualise/Makefile @@ -1,9 +1,10 @@ -all: server/generated frontend/src/grpc +all: generated frontend/src/grpc -server/generated: protos/*.proto - mkdir -p server/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 protol --create-package --in-place --python-out server/generated/ protoc --proto-path=protos/ +generated: protos/*.proto + mkdir -p 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 generated/ protoc --proto-path=protos/ frontend/src/grpc: protos/*.proto mkdir -p frontend/src/grpc diff --git a/where_fi/visualise/extract/__init__.py b/where_fi/visualise/extract/__init__.py new file mode 100644 index 0000000..4d85c74 --- /dev/null +++ b/where_fi/visualise/extract/__init__.py @@ -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() diff --git a/where_fi/visualise/extract/__main__.py b/where_fi/visualise/extract/__main__.py new file mode 100644 index 0000000..3b39343 --- /dev/null +++ b/where_fi/visualise/extract/__main__.py @@ -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() diff --git a/where_fi/visualise/server/__init__.py b/where_fi/visualise/server/__init__.py index ddac0e8..9437fd8 100644 --- a/where_fi/visualise/server/__init__.py +++ b/where_fi/visualise/server/__init__.py @@ -11,8 +11,8 @@ import grpc import numpy as np import numpy.typing as npt +from ..generated import figure_pb2, figure_pb2_grpc from . import figures -from .generated import figure_pb2, figure_pb2_grpc @dataclass @@ -22,6 +22,11 @@ class VisualiserData: 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( self, request: figure_pb2.FigureRequest, context: grpc.ServicerContext ) -> Generator[figure_pb2.Figure, None, None]: @@ -32,15 +37,15 @@ class FigureServer(figure_pb2_grpc.FigureServiceServicer): def GetFigureUpdate( self, request: figure_pb2.FigureDataRequest, context: grpc.ServicerContext ) -> Generator[figure_pb2.FigureData, None, None]: - logger.info( + self.logger.info( 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() - if request.uuid not in clients: - clients[request.uuid] = [] - clients[request.uuid].append(q) + if request.uuid not in self.clients: + self.clients[request.uuid] = [] + self.clients[request.uuid].append(q) try: while context.is_active(): @@ -49,24 +54,22 @@ class FigureServer(figure_pb2_grpc.FigureServiceServicer): except queue.Empty: pass finally: - with clients_lock: - clients[request.uuid].remove(q) + with self.clients_lock: + self.clients[request.uuid].remove(q) class Webapp: def __init__(self) -> None: self.logger = logging.getLogger(__name__) self.active = True - - self.clients: dict[str, list[queue.Queue[figure_pb2.FigureData]]] = {} - self.clients_lock = threading.Lock() + 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) for fig_id, update in updates.items(): - for client in self.clients.get(fig_id, []): + for client in self.figure_server.clients.get(fig_id, []): client.put(update) def listen_for_data(self, data_queue: "mp.Queue[VisualiserData]") -> None: @@ -82,12 +85,14 @@ class Webapp: data = data_queue.get() def start(self, 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") + 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") - server.start() + grpc_server.start() self.logger.info("Server started") data_thread = threading.Thread(target=self.listen_for_data, args=(data_queue,)) @@ -96,5 +101,5 @@ class Webapp: while self.active: time.sleep(1) self.logger.debug("Server is running") - server.stop(0.5) + grpc_server.stop(0.5) data_thread.join() diff --git a/where_fi/visualise/server/figures.py b/where_fi/visualise/server/figures.py index 70c125b..14103dc 100644 --- a/where_fi/visualise/server/figures.py +++ b/where_fi/visualise/server/figures.py @@ -79,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): """A figure that plots data for each antenna separately. @@ -165,19 +189,81 @@ class HeatmapFigure(SpecificFigure): class EmpiricalCDF(SpecificFigure): """Helper class to create a graph of the empirical cumulative distribution function - of a set of observations. + 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.complex128]) -> FigureUpdate: - new_data = np.expand_dims(np.sort(new_data), 0) - y_values = np.expand_dims(np.linspace(0, 1, new_data.size), 0) + 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): RAW_CSI = 0 UNWRAPPED_PHASE = 1 @@ -210,5 +296,5 @@ all_figures = { ), Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(), Figure.AOA_HEATMAP: HeatmapFigure(), - Figure.PHASE_ANALYSIS: EmpiricalCDF("Phase Analysis", "Phase"), + Figure.PHASE_ANALYSIS: RandomVariable("Phase"), }