add figure extractor

Used to store the protobuffer messages into files, so that they can be
plotted as part of the dissertation
This commit is contained in:
Christos Falas 2025-05-01 15:58:28 +01:00
parent 6be12beb49
commit 6f454c6190
No known key found for this signature in database
6 changed files with 230 additions and 29 deletions

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

@ -11,8 +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
@dataclass @dataclass
@ -22,6 +22,11 @@ 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]:
@ -32,15 +37,15 @@ class FigureServer(figure_pb2_grpc.FigureServiceServicer):
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():
@ -49,24 +54,22 @@ 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)
class Webapp: class Webapp:
def __init__(self) -> None: def __init__(self) -> None:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.active = True self.active = True
self.figure_server = FigureServer()
self.clients: dict[str, list[queue.Queue[figure_pb2.FigureData]]] = {}
self.clients_lock = threading.Lock()
def add_data( def add_data(
self, dtype: figures.Figure, new_data: npt.NDArray[np.complex128] self, dtype: figures.Figure, new_data: npt.NDArray[np.complex128]
) -> None: ) -> 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 self.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(self, data_queue: "mp.Queue[VisualiserData]") -> None:
@ -82,12 +85,14 @@ class Webapp:
data = data_queue.get() data = data_queue.get()
def start(self, data_queue: "mp.Queue[VisualiserData]") -> None: def start(self, data_queue: "mp.Queue[VisualiserData]") -> None:
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
figure_pb2_grpc.add_FigureServiceServicer_to_server(FigureServer(), server) figure_pb2_grpc.add_FigureServiceServicer_to_server(
server.add_insecure_port("[::]:50051") self.figure_server, grpc_server
server.add_insecure_port("0.0.0.0:50051") )
grpc_server.add_insecure_port("[::]:50051")
grpc_server.add_insecure_port("0.0.0.0:50051")
self.logger.info("Starting server on port 50051") self.logger.info("Starting server on port 50051")
server.start() grpc_server.start()
self.logger.info("Server started") self.logger.info("Server started")
data_thread = threading.Thread(target=self.listen_for_data, args=(data_queue,)) data_thread = threading.Thread(target=self.listen_for_data, args=(data_queue,))
@ -96,5 +101,5 @@ class Webapp:
while self.active: while self.active:
time.sleep(1) time.sleep(1)
self.logger.debug("Server is running") self.logger.debug("Server is running")
server.stop(0.5) grpc_server.stop(0.5)
data_thread.join() data_thread.join()

View File

@ -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): class PerAntennaFigure(SpecificFigure):
"""A figure that plots data for each antenna separately. """A figure that plots data for each antenna separately.
@ -165,19 +189,81 @@ class HeatmapFigure(SpecificFigure):
class EmpiricalCDF(SpecificFigure): class EmpiricalCDF(SpecificFigure):
"""Helper class to create a graph of the empirical cumulative distribution function """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: def __init__(self, title: str, x_label: str) -> None:
self.chart = SimpleLineChart(title, x_label, "Cumulative Probability") self.chart = SimpleLineChart(title, x_label, "Cumulative Probability")
self.figures = [self.chart.figure] self.figures = [self.chart.figure]
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate: def update(self, new_data: npt.NDArray[np.float64]) -> FigureUpdate:
new_data = np.expand_dims(np.sort(new_data), 0) new_data = np.sort(new_data, axis=-1)
y_values = np.expand_dims(np.linspace(0, 1, new_data.size), 0) 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) 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
@ -210,5 +296,5 @@ all_figures = {
), ),
Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(), Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(),
Figure.AOA_HEATMAP: HeatmapFigure(), Figure.AOA_HEATMAP: HeatmapFigure(),
Figure.PHASE_ANALYSIS: EmpiricalCDF("Phase Analysis", "Phase"), Figure.PHASE_ANALYSIS: RandomVariable("Phase"),
} }