Compare commits

..

No commits in common. "990baee908ebf5a562c26cfbdfe1439b2f8b84aa" and "8fe5995c5f8c3a833ddc4a2848f3a29958284fae" have entirely different histories.

21 changed files with 795 additions and 1716 deletions

View File

@ -1,72 +0,0 @@
"""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

@ -1,109 +0,0 @@
"""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,7 +15,6 @@ dependencies = [
"pydantic>=2.10.6",
"torch",
"grpcio>=1.70.0",
"requests>=2.32.3",
]
[build-system]
@ -51,9 +50,6 @@ packages = ["where_fi"]
[dependency-groups]
dev = [
"grpc-stubs>=1.53.0.5",
"grpcio-tools>=1.70.0",
"matplotlib-stubs>=0.1.0",
"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,10 +2,11 @@ import logging
from . import cli
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)-50s %(levelname)-8s %(message)s",
)
if __name__ == "__main__":
cli.cli()
cli.app()

View File

@ -1,217 +0,0 @@
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,26 +1,24 @@
import logging
from queue import Queue
from typing import cast
import multiprocessing as mp
from typing import Any
import numpy as np
import numpy.typing as npt
import torch
import typer
from where_fi.collection import CSIMatrix
from ..application import CSIApplication
from ..config import config
from ..processing.aoa import AoA
from ..processing.preprocess import Preprocessor
from ..visualise import server as visualise
from . import file, globals
cli = typer.Typer(callback=globals.main)
app = typer.Typer(callback=globals.main)
logger = logging.getLogger(__name__)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
@cli.command()
@app.command()
def antennas() -> None:
"""Utility to help determine the order in which antennas are plugged in
@ -31,66 +29,38 @@ def antennas() -> None:
"""
from ..utils import antenna_order
if not globals.csi_producer.is_live:
raise NotImplementedError("This command only works with live data")
if not globals.is_live:
raise ValueError("This command only works with live data")
antenna_order.main()
@cli.command()
@app.command()
def heatmap() -> None:
app = CSIApplication(globals.csi_producer, visualise_raw=True)
preprocessor = Preprocessor()
aoa = AoA()
@app.on_sample
def _(sample: npt.NDArray[np.complex64]) -> None:
processed_tensor = torch.tensor(sample, device=device)
aoa.update(processed_tensor)
aoa.heatmap(visualiser=app.visualise_data)
# Start webapp in background process
webapp_queue: "mp.Queue[visualise.VisualiserData]" = mp.Queue(config.sample_rate)
webapp = mp.Process(target=visualise.start, args=(webapp_queue,))
webapp.start()
app.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.heatmap(visualiser=visualise_data)
globals.csi_producer(csi_callback=callback)
logger.info("Finished processing CSI data")
@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")
app.add_typer(file.app, name="file", help="Commands for working with CSI files")

View File

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

View File

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

View File

@ -2,7 +2,6 @@ import logging
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Iterator
import h5py
@ -11,50 +10,30 @@ from . import protocols
logger = logging.getLogger(__name__)
class FileCSIPRoducer:
is_live = False
def __init__(self, path: Path) -> None:
self.path = path
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:
for key in file:
datetime.fromisoformat(key)
except ValueError as e:
logger.exception(
"The file provided was not generated using this software", e
)
start_time = datetime.fromisoformat(list(file.keys())[0])
target_offset = datetime.now() - start_time
def start_processing(
file_path: Path,
csi_callback: protocols.CSICallback | None = None,
) -> None:
logger.info(f"Replaying CSI data from {file_path}")
with h5py.File(file_path, "r") as file:
try:
for key in file:
logger.debug(f"Sending data from {key} at {datetime.now().isoformat()}")
datetime.fromisoformat(key)
except ValueError as e:
logger.exception(
"The file provided was not generated using this software", e
)
# TODO: add raw frames
yield protocols.MergedCSI(
frames={},
matrix=file[key][:],
start_time = datetime.fromisoformat(list(file.keys())[0])
target_offset = datetime.now() - start_time
for key in file:
logger.debug(f"Sending data from {key} at {datetime.now().isoformat()}")
csi_callback(file[key][:])
curr_time_virtual = datetime.fromisoformat(key)
new_offset = datetime.now() - curr_time_virtual
logger.debug(f"New offset {new_offset}, target is {target_offset}")
if new_offset > target_offset + timedelta(seconds=1):
logger.warning(
f"Data is {new_offset - target_offset} behind, lagging behind..."
)
curr_time_virtual = datetime.fromisoformat(key)
new_offset = datetime.now() - curr_time_virtual
logger.debug(f"New offset {new_offset}, target is {target_offset}")
if new_offset > target_offset + timedelta(seconds=1):
logger.warning(
f"Data is {new_offset - target_offset} behind, lagging behind..."
)
time.sleep(max(0, (target_offset - new_offset).total_seconds()))
def stop(self) -> None:
"""
Stop the producer.
"""
logger.info("Stopping file CSI producer")
time.sleep(max(0, (target_offset - new_offset).total_seconds()))

View File

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

View File

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

View File

@ -6,107 +6,44 @@ 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 ..config import config
from ..visualise import server as visualise
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
np.seterr(invalid="ignore")
class Preprocessor:
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.long_term_avg = np.zeros((1,), dtype=np.complex64)
# self.filter = butter(
# 5,
# config.preprocessing.bandpass.bounds,
# fs=config.sample_rate,
# btype="band",
# 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
self.filter = butter(
5,
config.preprocessing.bandpass.bounds,
fs=config.sample_rate,
btype="band",
output="sos",
)
def preprocess(
self,
h: CSIMatrix,
h: npt.NDArray[np.complex64],
visualiser: None
| Callable[[npt.NDArray[Any], visualise.figures.Figure], None] = None,
) -> CSIMatrix:
| Callable[[npt.NDArray[Any], visualise.DataType], None] = None,
) -> npt.NDArray[np.complex64]:
# CSI data is not available for pilot subcarriers.
h_hat: CSIMatrix = np.where(
h_hat: npt.NDArray[np.complex64] = np.where(
np.expand_dims(h[:, 0, 0] == 0, axis=(1, 2)),
correlate(h, [[[1 / 2]], [[0]], [[1 / 2]]], mode="same"),
h,
)
logger.debug(f"CSI shape: {h_hat.shape}")
# Skip subcarrierss per config
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.nan_to_num(h_hat)
@ -114,13 +51,27 @@ class Preprocessor:
# 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 = self.remove_sfo(h_hat, visualiser=visualiser)
# Skip subcarrierss per config
h_hat = h_hat[:: config.preprocessing.subcarrier_step, :, :]
# h_hat = self.remove_sto(h_hat)
# Unwrap phase and remove linear fit
print(h_hat.shape)
unwrapped = np.unwrap(np.angle(h_hat[:, :, 0]), axis=0).reshape(
h_hat.shape[0], h_hat.shape[1], 1
)
if visualiser:
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
if self.long_term_avg.shape != h_hat.shape:
self.long_term_avg = np.zeros(h_hat.shape, dtype=np.complex64)
@ -130,10 +81,7 @@ class Preprocessor:
+ h_hat * config.preprocessing.moving_average_alpha
)
self._last_sample = h_hat
# Remove long term average, to remove static paths
return h_hat
h_hat -= self.long_term_avg
# Apply bandpass filter to remove low and high frequency noise

View File

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

View File

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

View File

@ -1,10 +1,9 @@
all: generated frontend/src/grpc
all: server/generated frontend/src/grpc
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/
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/
frontend/src/grpc: protos/*.proto
mkdir -p frontend/src/grpc

View File

@ -1,20 +0,0 @@
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

@ -1,89 +0,0 @@
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,7 +2,6 @@ import logging
import multiprocessing as mp
import queue
import threading
import time
from concurrent import futures
from dataclasses import dataclass
from typing import Any, Generator
@ -11,8 +10,13 @@ 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
logger = logging.getLogger(__name__)
clients: dict[str, list[queue.Queue[figure_pb2.FigureData]]] = {}
clients_lock = threading.Lock()
@dataclass
@ -22,30 +26,25 @@ 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]:
for figure_group in figures.Figure:
for figure in figure_group.figure_class().figures:
for figure in figures.all_figures[figure_group].figures:
yield figure
def GetFigureUpdate(
self, request: figure_pb2.FigureDataRequest, context: grpc.ServicerContext
) -> Generator[figure_pb2.FigureData, None, None]:
self.logger.info(
logger.info(
f"Received request for figure data stream for figure {request.uuid}"
)
with self.clients_lock:
with clients_lock:
q: queue.Queue[figure_pb2.FigureData] = queue.Queue()
if request.uuid not in self.clients:
self.clients[request.uuid] = []
self.clients[request.uuid].append(q)
if request.uuid not in clients:
clients[request.uuid] = []
clients[request.uuid].append(q)
try:
while context.is_active():
@ -54,52 +53,33 @@ class FigureServer(figure_pb2_grpc.FigureServiceServicer):
except queue.Empty:
pass
finally:
with self.clients_lock:
self.clients[request.uuid].remove(q)
with clients_lock:
clients[request.uuid].remove(q)
class Webapp:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
self.active = True
self.figure_server = FigureServer()
def add_data(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 clients.get(fig_id, []):
client.put(update)
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.figure_server.clients.get(fig_id, []):
client.put(update)
def listen_for_data(self, data_queue: "mp.Queue[VisualiserData]") -> None:
while self.active:
self.logger.debug("Listening for data")
try:
data = data_queue.get(timeout=0.5)
self.add_data(data.dtype, data.data)
except queue.Empty:
pass
def listen_for_data(data_queue: "mp.Queue[VisualiserData]") -> None:
while True:
data = data_queue.get()
add_data(data.dtype, data.data)
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()
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:
server.wait_for_termination()
except KeyboardInterrupt:
logger.info("Exiting visualisation server")

View File

@ -58,17 +58,10 @@ class SimpleLineChart:
)
def update(
self,
new_data: npt.NDArray[np.complex128],
labels: Sequence[str],
x_values: None | npt.NDArray[np.float64] = None,
self, new_data: npt.NDArray[np.complex128], labels: Sequence[str]
) -> FigureUpdate:
lines = [
line_pb2.LineChartData.Line(
y=new_data[i],
label=labels[i],
x=x_values[i] if x_values is not None else None,
)
line_pb2.LineChartData.Line(y=new_data[i], label=labels[i])
for i in range(new_data.shape[0])
]
return {
@ -79,30 +72,6 @@ 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.
@ -187,90 +156,12 @@ class HeatmapFigure(SpecificFigure):
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):
RAW_CSI = 0
UNWRAPPED_PHASE = 1
PROCESSED_CSI = 2
MUSIC_EIGENVALUES = 3
AOA_HEATMAP = 4
PHASE_ANALYSIS = 5
def figure_class(self) -> SpecificFigure:
return all_figures[self]
@ -296,5 +187,4 @@ all_figures = {
),
Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(),
Figure.AOA_HEATMAP: HeatmapFigure(),
Figure.PHASE_ANALYSIS: RandomVariable("Phase"),
}