Compare commits

..

1 Commits

Author SHA1 Message Date
Christos Falas
381dcaa0bc
[BROKEN] Add CLI option for option
This is still slightly broken, as new processes are not able to access
the configuration.
2025-01-27 16:16:40 +00:00
77 changed files with 1074 additions and 15006 deletions

View File

@ -1,8 +0,0 @@
FROM ghcr.io/astral-sh/uv:latest
ADD pyproject.toml uv.lock /app
WORKDIR /app
RUN uv sync --frozen
CMD ["uv", "run", "python", "-m", "where_fi", "heatmap"]

View File

@ -1,58 +1,29 @@
receive_hosts:
- ["10.0.12.90", 8008]
- ["10.0.12.91", 8008]
transmit_host: ["10.0.12.64", 8008]
check_hosts: True
- ["10.0.12.62", 8008]
- ["10.0.12.64", 8008]
transmit_host: ["10.0.12.63", 8008]
antennas:
order:
- [['10.0.12.91', 8008], 0]
- [['10.0.12.91', 8008], 1]
- [['10.0.12.90', 8008], 0]
- [['10.0.12.90', 8008], 1]
# The spacing between antennas in the linear antenna array in meters
order: [
[["10.0.12.62", 8008], 0],
[["10.0.12.64", 8008], 1],
[["10.0.12.64", 8008], 0],
[["10.0.12.62", 8008], 1]]
spacing: 0.0285
collection_sample_rate: 20 # Hz
processing_sample_rate: 2 # Hz
sample_rate: 100 # Hz
# The central Wi-Fi channel to be used for data collection
# Should match one of the channels in the standard
# https://en.wikipedia.org/wiki/List_of_WLAN_channels
central_freq: 2442 # MHz
# The channel width in MHz. The options are 20, 40, 80, and 160 MHz
channel_width: 20
# frame_format: Can be HT, VHT, HE for the frame format used by
# 802.11n, 802.11ac, 802.11ax respectively
frame_format: VHT
central_freq: 6195 # MHz
channel_width: 20 # MHz
frame_format: HT
preprocessing:
subcarrier_step: 1 # Skip every xth subcarrier to improve performance
moving_average_alpha: 0.01
# The steps to be applied to the samples before denoising, in this order
steps:
- fill_pilots
- skip_subcarriers
- remove_agc
- remove_sfo
# Type of denoising to apply after preprocessing, when accessing
# the sample for the main application processing
denoising: median
# The duration of the window to use for the denoising step (in seconds)
denoising_window: 1
bandpass:
lowcut: 2
highcut: 40
music:
# The threshold to use to split the noise and signal subspaces
eigval_threshold: 100
# The resolution of the heatmap generated
heatmap:
theta_resolution: 100
tof_resolution: 100
tof_max: 2e-8
eigval_threshold: 10
window_size: 40

View File

@ -1,12 +0,0 @@
services:
envoy:
image: envoyproxy/envoy
ports:
- "8080:8080"
volumes:
- ./where_fi/visualise/envoy.yaml:/etc/envoy/envoy.yaml
backend:
build: .
volumes:
- ./where_fi:/app/where_fi
- ./config.yaml:/app/config

View File

@ -1,15 +0,0 @@
#!/bin/bash
for i in {1..80}; do
not_running=$(iperf3 -c 10.0.0.2 -d -t 10 -V | grep -Po '[0-9.]*(?= Mbits/sec)' | tail -n 1)
echo $not_running >> speedtest_not_running.log
echo "not running $not_running"
ssh cfalas@10.0.0.2 "ssh root@10.0.12.64 'ping 192.168.55.3 -c 1100 -i 0.01'" > /dev/null &
sleep 1s;
running=$(iperf3 -c 10.0.0.2 -d -t 10 -V | grep -Po '[0-9.]*(?= Mbits/sec)' | tail -n 1)
echo $running >> speedtest_running.log
echo "running $running"
sleep 1s;
done
wait

View File

@ -1,110 +0,0 @@
from itertools import product
from queue import Queue
from typing import cast
import numpy as np
from where_fi.application import CSIApplication
from where_fi.collection import CSIMatrix
from where_fi.collection.ingest import RealtimeCSIProducer
from where_fi.config import config
from where_fi.visualise import server as visualise
app = CSIApplication(visualise_raw=True)
visualise.figures.all_figures["median"] = visualise.figures.RandomVariable(
"Median Phase"
)
visualise.figures.all_figures["median_magn"] = visualise.figures.RandomVariable(
"Median Magnitude"
)
visualise.figures.all_figures["denoised"] = visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart("Denoised CSI Phase", "Subcarrier", "Phase"),
visualise.figures.SimpleLineChart(
"Denoised CSI Amplitude", "Subcarrier", "Amplitude"
),
],
[np.angle, np.abs],
)
measurements: list[np.complex64] = []
subcarriers = [0, 1]
rx_antenna = [0, 1]
tx_antenna = [0]
subcarrier_phase: dict[tuple[int, int, int], Queue[float]] = {
x: Queue(config.collection_sample_rate)
for x in product(subcarriers, rx_antenna, tx_antenna)
}
subcarrier_magn: dict[tuple[int, int, int], Queue[float]] = {
x: Queue(config.collection_sample_rate)
for x in product(subcarriers, rx_antenna, tx_antenna)
}
proc_phase: dict[tuple[int, int, int], Queue[float]] = {
x: Queue(config.collection_sample_rate)
for x in product(subcarriers, rx_antenna, tx_antenna)
}
proc_magn: dict[tuple[int, int, int], Queue[float]] = {
x: Queue(config.collection_sample_rate)
for x in product(subcarriers, rx_antenna, tx_antenna)
}
@app.on_sample
def _(sample: CSIMatrix) -> None:
for i in product(subcarriers, rx_antenna, tx_antenna):
phase = cast(float, np.angle(sample[i]))
magn = cast(float, np.abs(sample[i]))
if subcarrier_phase[i].full():
subcarrier_phase[i].get()
subcarrier_phase[i].put(phase)
if subcarrier_magn[i].full():
subcarrier_magn[i].get()
subcarrier_magn[i].put(magn)
cnt = 0
@app.on_process
def _(proc: CSIMatrix) -> None:
global cnt
global proc_phase
for i in product(subcarriers, rx_antenna, tx_antenna):
phase = cast(float, np.angle(proc[i]))
magn = cast(float, np.abs(proc[i]))
if proc_phase[i].full():
proc_phase[i].get()
proc_phase[i].put(phase)
if proc_magn[i].full():
proc_magn[i].get()
proc_magn[i].put(magn)
print(sum(proc_phase[0, 0, 0].queue))
app.visualise_data(
np.array([x.queue for x in proc_phase.values()]),
"median",
)
app.visualise_data(
np.array([x.queue for x in proc_magn.values()]),
"median_magn",
)
app.visualise_data(
np.array([x.queue for x in subcarrier_phase.values()]),
visualise.figures.Figure.PHASE_ANALYSIS,
)
app.visualise_data(
np.array([x.queue for x in subcarrier_magn.values()]),
visualise.figures.Figure.MAGN_ANALYSIS,
)
app.visualise_data(proc, "denoised")
cnt += 1
if __name__ == "__main__":
print("Starting app")
app.set_producer(RealtimeCSIProducer())
app.start()

View File

@ -1,86 +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
"""
import numpy as np
import numpy.typing as npt
from where_fi.application import CSIApplication
from where_fi.collection.ingest import RealtimeCSIProducer
from where_fi.config import config
from where_fi.visualise import server as visualise
# Connect to a FeitCSI host
app = CSIApplication(visualise_raw=True)
# Register visualisations
app.register_figure(
"magn-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart(
"Magnitude diff", "Subcarrier", "Magnitude"
),
],
[lambda x: x],
),
)
app.register_figure(
"phase-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart("Phase diff", "Subcarrier", "Phase"),
],
[lambda x: x],
),
)
MAGN_THRESHOLD = 10
PHASE_THRESHOLD = 1
QUEUE_SIZE = 200
# Stores historical data for each receiving antenna, for each subcarrier
historical = np.zeros(
(QUEUE_SIZE, config.subcarriers, config.antennas.count, 1), dtype=np.complex64
)
empty = np.load("data/empty.npy")
sample_position = 0
@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
"""
global sample_position
historical[sample_position] = sample
sample_position = (sample_position + 1) % QUEUE_SIZE
mean = np.mean(historical, axis=0)
magn_diff = np.abs(mean - sample)
phase_diff = np.abs(np.angle(mean) - np.angle(sample))
phase_diff = np.min(np.array([phase_diff, np.pi - phase_diff]), axis=0)
app.visualise_data(magn_diff, "magn-diff")
app.visualise_data(phase_diff, "phase-diff")
if (magn_diff > MAGN_THRESHOLD).any() or (phase_diff > PHASE_THRESHOLD).any():
print("Motion detected!")
else:
print("No motion detected!")
if __name__ == "__main__":
# Start the application
print("Starting app")
app.set_producer(RealtimeCSIProducer())
app.start()

View File

@ -1,123 +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
"""
import numpy as np
import numpy.typing as npt
import requests
from where_fi.application import CSIApplication
from where_fi.collection.ingest import RealtimeCSIProducer
from where_fi.config import config
from where_fi.visualise import server as visualise
# Connect to a FeitCSI host
app = CSIApplication(visualise_raw=True)
# Register visualisations
app.register_figure(
"magn-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart(
"Magnitude diff", "Subcarrier", "Magnitude"
),
],
[lambda x: x],
),
)
app.register_figure(
"phase-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart("Phase diff", "Subcarrier", "Phase"),
],
[lambda x: x],
),
)
MAGN_THRESHOLD = 0.2
PHASE_THRESHOLD = 0.02
QUEUE_SIZE = 20
# Stores historical data for each receiving antenna, for each subcarrier
historical = np.zeros(
(QUEUE_SIZE, config.subcarriers, config.antennas.count, 1), dtype=np.complex64
)
sample_position = 0
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, "device_class": "motion"},
}
print(f"Updating sensor {self.name} to {data}")
requests.post(self.url, json=data, headers=self.headers)
sensor = HomeAssistantBinarySensor("motion_detector", "Room Motion Detector")
@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
"""
global sample_position
historical[sample_position] = sample
sample_position = (sample_position + 1) % QUEUE_SIZE
mean = np.mean(historical, axis=0)
magn_diff = np.abs(mean - sample)
phase_diff = np.abs(np.angle(mean) - np.angle(sample))
app.visualise_data(magn_diff, "magn-diff")
app.visualise_data(phase_diff, "phase-diff")
if (magn_diff > MAGN_THRESHOLD).any() or (phase_diff > PHASE_THRESHOLD).any():
print("Motion detected!")
sensor.update(True)
else:
print("No motion detected!")
sensor.update(False)
if __name__ == "__main__":
# Start the application
print("Starting app")
app.set_producer(RealtimeCSIProducer())
app.start()

View File

@ -1,118 +0,0 @@
import logging
import numpy as np
import torch
from where_fi.application import CSIApplication
from where_fi.collection import CSIMatrix
from where_fi.collection.ingest import RealtimeCSIProducer
from where_fi.config import config
from where_fi.processing.aoa import AoA
from where_fi.visualise import server as visualise
app = CSIApplication(visualise_raw=True)
logging.basicConfig(level=logging.INFO)
T = 500
N_sub1 = config.antennas.count // 2
N_sub2 = config.subcarriers // 2
L2 = config.subcarriers - N_sub2 + 1
L1 = config.antennas.count - N_sub1 + 1
N_sensors = config.subcarriers * config.antennas.count
historical = torch.zeros(T, N_sensors, N_sensors, dtype=torch.complex64)
cnt = 0
@app.on_sample
def _(sample: CSIMatrix) -> None:
global cnt
sample_before_resize = sample[:, :, 0].T
sample_after = sample_before_resize.reshape(-1, 1)
sample_tensor = torch.tensor(sample_after)
historical[cnt] = sample_tensor @ torch.conj(sample_tensor).T
print(
f"{sample.shape} => {sample_before_resize.shape} => {sample_after.shape} "
f"=> {historical[cnt].shape}"
)
cnt = (cnt + 1) % T
def get_steering(theta: float, tau: float) -> torch.Tensor:
"""
Calculate the alpha value for the given angle and time delay.
"""
sub, ant = np.indices((N_sub1, N_sub2))
alpha = np.exp(
-1j
* (
2 * np.pi * (sub * config.delta_f * tau)
+ 2
* np.pi
* (
ant
* config.antennas.spacing
* np.sin(theta)
* 299_792_458
/ (config.central_freq_hz + (sub - 28) * config.delta_f)
)
)
)
alpha = sub + 1j * ant
alpha = alpha.reshape(-1, 1)
return torch.tensor(alpha, dtype=torch.complex64)
aoa = AoA()
@app.on_process
def _(_: CSIMatrix) -> None:
R: torch.Tensor = torch.mean(historical, axis=0)
# Rss = torch.zeros(N_sub1 * N_sub2, N_sub1 * N_sub2, dtype=torch.complex64)
# for i in range(L1):
# for j in range(L2):
# Rss += R[i : i + N_sub1 * N_sub2, j : j + N_sub1 * N_sub2]
# Rss /= L1 * L2
Rss = R
aoa.historical_autocorr = torch.unsqueeze(Rss, 0)
aoa.heatmap(app.visualise_data)
# eigvals, eigvecs = torch.linalg.eig(Rss)
# app.visualise_data(eigvals.numpy(), visualise.figures.Figure.MUSIC_EIGENVALUES)
# E_n = eigvecs[:, torch.abs(eigvals) < config.music.eigval_threshold]
# E_n_H = torch.conj(E_n.T)
# heatmap = np.zeros(
# (config.music.heatmap.tof_resolution, config.music.heatmap.theta_resolution)
# )
# for i_theta, theta in enumerate(
# np.linspace(0, np.pi, config.music.heatmap.theta_resolution)
# ):
# for i_tau, tau in enumerate(
# np.linspace(
# 0, config.music.heatmap.tof_max, config.music.heatmap.tof_resolution
# )
# ):
# steering = get_steering(theta, tau)
# steering_h = torch.conj(steering.T)
# c = 1 / (steering_h @ E_n @ E_n_H @ steering)
# heatmap[i_tau, i_theta] = torch.abs(c)
# print(steering)
# app.visualise_data(heatmap, visualise.figures.Figure.AOA_HEATMAP)
# eigvals, eigvecs = torch.linalg.eig(Rss)
# app.visualise_data(eigvals.numpy(), visualise.figures.Figure.MUSIC_EIGENVALUES)
# E_n = eigvecs[:, torch.abs(eigvals) < config.music.eigval_threshold]
# print(E_n)
# c: torch.Tensor = 1 / (steering_h @ E_n @ E_n_H @ steering)
# return torch.abs(c)[:, 0, 0]
if __name__ == "__main__":
producer = RealtimeCSIProducer()
app.set_producer(producer)
app.start()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

View File

@ -1,24 +0,0 @@
\relax
\providecommand\hyper@newdestlabel[2]{}
\providecommand\HyField@AuxAddToFields[1]{}
\providecommand\HyField@AuxAddToCoFields[2]{}
\abx@aux@refcontext{nty/global//global/global}
\abx@aux@cite{0}{feitcsi:project}
\abx@aux@segm{0}{0}{feitcsi:project}
\@writefile{toc}{\contentsline {section}{\numberline {1}Summary}{1}{section.1}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {2}Multi-NIC setup}{1}{section.2}\protected@file@percent }
\@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces Network/hardware setup for multi-NIC CSI collection}}{2}{figure.1}\protected@file@percent }
\newlabel{fig:multi-nic}{{1}{2}{Network/hardware setup for multi-NIC CSI collection}{figure.1}{}}
\@writefile{lof}{\contentsline {figure}{\numberline {2}{\ignorespaces Custom antenna mount}}{2}{figure.2}\protected@file@percent }
\newlabel{fig:antenna-mount}{{2}{2}{Custom antenna mount}{figure.2}{}}
\abx@aux@cite{0}{Hsu2024}
\abx@aux@segm{0}{0}{Hsu2024}
\@writefile{toc}{\contentsline {section}{\numberline {3}Data Ingestion}{3}{section.3}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {4}Data Preprocessing}{3}{section.4}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {5}Angle of Arrival Estimation}{3}{section.5}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {6}Dataset Collection}{3}{section.6}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {7}Difficulties}{4}{section.7}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {8}Next Steps}{4}{section.8}\protected@file@percent }
\abx@aux@read@bbl@mdfivesum{nohash}
\abx@aux@read@bblrerun
\gdef \@abspage@last{4}

View File

@ -1,95 +0,0 @@
% $ biblatex auxiliary file $
% $ biblatex bbl format version 3.2 $
% Do not modify the above lines!
%
% This is an auxiliary file used by the 'biblatex' package.
% This file may safely be deleted. It will be recreated by
% biber as required.
%
\begingroup
\makeatletter
\@ifundefined{ver@biblatex.sty}
{\@latex@error
{Missing 'biblatex' package}
{The bibliography requires the 'biblatex' package.}
\aftergroup\endinput}
{}
\endgroup
\refsection{0}
\datalist[entry]{nty/global//global/global}
\entry{Hsu2024}{inproceedings}{}
\name{author}{2}{}{%
{{hash=d5c2fb414951d03e3ec15a764a03592c}{%
family={Hsu},
familyi={H\bibinitperiod},
given={Ting-Wei},
giveni={T\bibinithyphendelim W\bibinitperiod}}}%
{{hash=1957109c91b54744e81c1e75d0d1e719}{%
family={Hsieh},
familyi={H\bibinitperiod},
given={Hung-Yun},
giveni={H\bibinithyphendelim Y\bibinitperiod}}}%
}
\strng{namehash}{cf444c64db6e8a2ac5a3d0f08cea6519}
\strng{fullhash}{cf444c64db6e8a2ac5a3d0f08cea6519}
\strng{bibnamehash}{cf444c64db6e8a2ac5a3d0f08cea6519}
\strng{authorbibnamehash}{cf444c64db6e8a2ac5a3d0f08cea6519}
\strng{authornamehash}{cf444c64db6e8a2ac5a3d0f08cea6519}
\strng{authorfullhash}{cf444c64db6e8a2ac5a3d0f08cea6519}
\field{sortinit}{H}
\field{sortinithash}{23a3aa7c24e56cfa16945d55545109b5}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{booktitle}{ICC 2024 - IEEE International Conference on Communications}
\field{title}{Robust Multi-User Pose Estimation Based on Spatial and Temporal Features from WiFi CSI}
\field{year}{2024}
\field{pages}{1600\bibrangedash 1605}
\range{pages}{6}
\verb{doi}
\verb 10.1109/ICC51166.2024.10623053
\endverb
\keyw{Heating systems;Doppler shift;Time-frequency analysis;Pose estimation;Feature extraction;Robustness;Frequency estimation}
\endentry
\entry{feitcsi:project}{online}{}
\name{author}{3}{}{%
{{hash=f7ddbbc0fd5053139c0492b03c89ce6b}{%
family={Hutar},
familyi={H\bibinitperiod},
given={Miroslav},
giveni={M\bibinitperiod}}}%
{{hash=64e0fb04b292022aee82ce3ae6ec84f9}{%
family={Brida},
familyi={B\bibinitperiod},
given={Peter},
giveni={P\bibinitperiod}}}%
{{hash=c94ebc034cee4b9c81a21d40bf16345e}{%
family={Machaj},
familyi={M\bibinitperiod},
given={Juraj},
giveni={J\bibinitperiod}}}%
}
\strng{namehash}{6e810c4ddea2920152d10133a21f0f88}
\strng{fullhash}{6e810c4ddea2920152d10133a21f0f88}
\strng{bibnamehash}{6e810c4ddea2920152d10133a21f0f88}
\strng{authorbibnamehash}{6e810c4ddea2920152d10133a21f0f88}
\strng{authornamehash}{6e810c4ddea2920152d10133a21f0f88}
\strng{authorfullhash}{6e810c4ddea2920152d10133a21f0f88}
\field{sortinit}{H}
\field{sortinithash}{23a3aa7c24e56cfa16945d55545109b5}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{FeitCSI, the 802.11 CSI tool}
\field{year}{2023}
\verb{urlraw}
\verb https://feitcsi.kuskosoft.com
\endverb
\verb{url}
\verb https://feitcsi.kuskosoft.com
\endverb
\endentry
\enddatalist
\endrefsection
\endinput

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
[0] Config.pm:307> INFO - This is Biber 2.19
[0] Config.pm:310> INFO - Logfile is 'progress.blg'
[34] biber:340> INFO - === Fri Feb 7, 2025, 00:59:00
[40] Biber.pm:419> INFO - Reading 'progress.bcf'
[67] Biber.pm:979> INFO - Found 2 citekeys in bib section 0
[74] Biber.pm:4419> INFO - Processing section 0
[78] Biber.pm:4610> INFO - Looking for bibtex file 'refs.bib' for section 0
[78] bibtex.pm:1713> INFO - LaTeX decoding ...
[80] bibtex.pm:1519> INFO - Found BibTeX data source 'refs.bib'
[96] UCollate.pm:68> INFO - Overriding locale 'en-US' defaults 'normalization = NFD' with 'normalization = prenormalized'
[96] UCollate.pm:68> INFO - Overriding locale 'en-US' defaults 'variable = shifted' with 'variable = non-ignorable'
[96] Biber.pm:4239> INFO - Sorting list 'nty/global//global/global' of type 'entry' with template 'nty' and locale 'en-US'
[96] Biber.pm:4245> INFO - No sort tailoring available for locale 'en-US'
[99] bbl.pm:660> INFO - Writing 'progress.bbl' with encoding 'UTF-8'
[99] bbl.pm:763> INFO - Output to progress.bbl

View File

@ -1,238 +0,0 @@
# Fdb version 4
["biber progress"] 1738890288.3654 "progress.bcf" "progress.bbl" "progress" 1738890288.36679 2
"progress.bcf" 1738890288.29345 107827 959b91c72f79a8e2ef56af97216763cc "lualatex"
"refs.bib" 1738889590.55706 686 b716c9640c4b4ad02a2cce84c5facbaa ""
(generated)
"progress.bbl"
"progress.blg"
(rewritten before read)
["lualatex"] 1738890287.21984 "progress.tex" "progress.pdf" "progress" 1738890288.36683 0
"/home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman10-bold.luc" 1737304252.15225 128397 4fa287d2565a98a75146f9efc01cf4d6 ""
"/home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman10-italic.luc" 1737304255.01897 136299 b8c58bb5f6e5457acdea1271317b68b7 ""
"/home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman10-regular.luc" 1737304251.85225 127314 32f9e61637e26814006c325175759c95 ""
"/home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman12-bold.luc" 1737304254.82563 128286 d012e848f630e37591e3db2d7055274f ""
"/home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman12-regular.luc" 1737304254.75896 127657 6bad1bc2543754cae9ba9c6746b71bd5 ""
"/usr/share/texmf-dist/fonts/opentype/public/lm/lmroman10-bold.otf" 1736268207 111240 0af0b64d6d3df41bead3f9de314afbd4 ""
"/usr/share/texmf-dist/fonts/opentype/public/lm/lmroman10-italic.otf" 1736268207 118828 4d461c73423fe2666dad2ff0dfc3ca68 ""
"/usr/share/texmf-dist/fonts/opentype/public/lm/lmroman10-regular.otf" 1736268207 111536 ae9d1b331000d544f47e5223081b7b54 ""
"/usr/share/texmf-dist/fonts/opentype/public/lm/lmroman12-bold.otf" 1736268207 110496 b9c8767d4cc3bf3f4b21f676bf89aa78 ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmex10.tfm" 1736268207 992 662f679a0b3d2d53c1b94050fdaa3f50 ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm" 1736268207 1528 abec98dbc43e172678c11b3b9031252a ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmmi5.tfm" 1736268207 1508 3b32edd0d68f6498a5a375e78f9edc5e ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmmi7.tfm" 1736268207 1528 e2423ae06dc7dee599cceb79d1c9dc32 ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmr10.tfm" 1736268207 1296 45809c5a464d5f32c8f98ba97c1bb47f ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmr5.tfm" 1736268207 1220 ad296dff3c8796c18053ab7b9f86ad7c ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmr7.tfm" 1736268207 1300 53d07721103816e093902637bc167021 ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmsy10.tfm" 1736268207 1124 6c73e740cf17375f03eec0ee63599741 ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmsy5.tfm" 1736268207 1112 14d5d5f6bd3c949edecb5b872f295553 ""
"/usr/share/texmf-dist/fonts/tfm/public/cm/cmsy7.tfm" 1736268207 1120 2b3f9b25605010c69bc328bea6ac000f ""
"/usr/share/texmf-dist/tex/context/base/mkii/supp-pdf.mkii" 1736268207 71627 94eb9990bed73c364d7f53f960cc8c5b ""
"/usr/share/texmf-dist/tex/generic/bigintcalc/bigintcalc.sty" 1736268207 40635 c40361e206be584d448876bba8a64a3b ""
"/usr/share/texmf-dist/tex/generic/bitset/bitset.sty" 1736268207 33961 6b5c75130e435b2bfdb9f480a09a39f9 ""
"/usr/share/texmf-dist/tex/generic/gettitlestring/gettitlestring.sty" 1736268207 8371 9d55b8bd010bc717624922fb3477d92e ""
"/usr/share/texmf-dist/tex/generic/iftex/iftex.sty" 1736268207 7237 bdd120a32c8fdb4b433cf9ca2e7cd98a ""
"/usr/share/texmf-dist/tex/generic/infwarerr/infwarerr.sty" 1736268207 8356 7bbb2c2373aa810be568c29e333da8ed ""
"/usr/share/texmf-dist/tex/generic/intcalc/intcalc.sty" 1736268207 31769 002a487f55041f8e805cfbf6385ffd97 ""
"/usr/share/texmf-dist/tex/generic/kvdefinekeys/kvdefinekeys.sty" 1736268207 5412 d5a2436094cd7be85769db90f29250a6 ""
"/usr/share/texmf-dist/tex/generic/ltxcmds/ltxcmds.sty" 1736268207 17865 1a9bd36b4f98178fa551aca822290953 ""
"/usr/share/texmf-dist/tex/generic/pdfescape/pdfescape.sty" 1736268207 19007 15924f7228aca6c6d184b115f4baa231 ""
"/usr/share/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.lua" 1736268207 9447 5e9f52f1871707a5d27dea360afbe4cb ""
"/usr/share/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.sty" 1736268207 20089 80423eac55aa175305d35b49e04fe23b ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcore.code.tex" 1736268207 1016 1c2b89187d12a2768764b83b4945667c ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorearrows.code.tex" 1736268207 43820 1fef971b75380574ab35a0d37fd92608 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreexternal.code.tex" 1736268207 19324 f4e4c6403dd0f1605fd20ed22fa79dea ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoregraphicstate.code.tex" 1736268207 6038 ccb406740cc3f03bbfb58ad504fe8c27 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreimage.code.tex" 1736268207 6911 f6d4cf5a3fef5cc879d668b810e82868 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorelayers.code.tex" 1736268207 4883 42daaf41e27c3735286e23e48d2d7af9 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreobjects.code.tex" 1736268207 2544 8c06d2a7f0f469616ac9e13db6d2f842 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathconstruct.code.tex" 1736268207 44195 5e390c414de027626ca5e2df888fa68d ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathprocessing.code.tex" 1736268207 17311 2ef6b2e29e2fc6a2fc8d6d652176e257 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathusage.code.tex" 1736268207 21302 788a79944eb22192a4929e46963a3067 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepatterns.code.tex" 1736268207 9691 3d42d89522f4650c2f3dc616ca2b925e ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepoints.code.tex" 1736268207 33335 dd1fa4814d4e51f18be97d88bf0da60c ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorequick.code.tex" 1736268207 2965 4c2b1f4e0826925746439038172e5d6f ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorerdf.code.tex" 1736268207 5196 2cc249e0ee7e03da5f5f6589257b1e5b ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorescopes.code.tex" 1736268207 20821 7579108c1e9363e61a0b1584778804aa ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreshade.code.tex" 1736268207 35249 abd4adf948f960299a4b3d27c5dddf46 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoretransformations.code.tex" 1736268207 22012 81b34a0aa8fa1a6158cc6220b00e4f10 ""
"/usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoretransparency.code.tex" 1736268207 8893 e851de2175338fdf7c17f3e091d94618 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibrarycalc.code.tex" 1736268207 15929 463535aa2c4268fead6674a75c0e8266 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryfit.code.tex" 1736268207 3626 2d87dc681257fa32d07a8b3934b10f88 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.arrows.code.tex" 1736268207 410 048d1174dabde96757a5387b8f23d968 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.callouts.code.tex" 1736268207 1201 8bd51e254d3ecf0cd2f21edd9ab6f1bb ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.code.tex" 1736268207 494 8de62576191924285b021f4fc4292e16 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.geometric.code.tex" 1736268207 339 be0fe46d92a80e3385dd6a83511a46f2 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.misc.code.tex" 1736268207 329 ba6d5440f8c16779c2384e0614158266 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.multipart.code.tex" 1736268207 923 c7a223b32ffdeb1c839d97935eee61ff ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.symbols.code.tex" 1736268207 475 4b4056fe07caa0603fede9a162fe666d ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibrarytopaths.code.tex" 1736268207 11518 738408f795261b70ce8dd47459171309 ""
"/usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/tikz.code.tex" 1736268207 186782 af500404a9edec4d362912fe762ded92 ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryfpu.code.tex" 1736268207 85938 8e4ba97c5906e1c0d158aea81fe29af7 ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryintersections.code.tex" 1736268207 44571 38ac24c171fb8fa1a13adc8ce7eb94c5 ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryplothandlers.code.tex" 1736268207 32995 ac577023e12c0e4bd8aa420b2e852d1a ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.arrows.code.tex" 1736268207 91587 d9b31a3e308b08833e4528a7b4484b4a ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.callouts.code.tex" 1736268207 33336 427c354e28a4802ffd781da22ae9f383 ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.geometric.code.tex" 1736268207 161011 76ab54df0aa1a9d3b27a94864771d38d ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.misc.code.tex" 1736268207 46249 d1f322c52d26cf506b4988f31902cd5d ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.multipart.code.tex" 1736268207 62281 aff261ef10ba6cbe8e3c872a38c05a61 ""
"/usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.symbols.code.tex" 1736268207 90521 9d46d4504c2ffed28ff5ef3c43d15f21 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfint.code.tex" 1736268207 3063 8c415c68a0f3394e45cfeca0b65f6ee6 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmath.code.tex" 1736268207 949 cea70942e7b7eddabfb3186befada2e6 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathcalc.code.tex" 1736268207 13270 2e54f2ce7622437bf37e013d399743e3 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfloat.code.tex" 1736268207 104717 9b2393fbf004a0ce7fa688dbce423848 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.base.code.tex" 1736268207 10165 cec5fa73d49da442e56efc2d605ef154 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.basic.code.tex" 1736268207 28178 41c17713108e0795aac6fef3d275fbca ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.code.tex" 1736268207 9649 85779d3d8d573bfd2cd4137ba8202e60 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.comparison.code.tex" 1736268207 3865 ac538ab80c5cf82b345016e474786549 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.integerarithmetics.code.tex" 1736268207 3177 27d85c44fbfe09ff3b2cf2879e3ea434 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.misc.code.tex" 1736268207 11024 0179538121bc2dba172013a3ef89519f ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.random.code.tex" 1736268207 7890 0a86dbf4edfd88d022e0d889ec78cc03 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.round.code.tex" 1736268207 3379 781797a101f647bab82741a99944a229 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.trigonometric.code.tex" 1736268207 92405 f515f31275db273f97b9d8f52e1b0736 ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathparser.code.tex" 1736268207 37466 97b0a1ba732e306a1a2034f5a73e239f ""
"/usr/share/texmf-dist/tex/generic/pgf/math/pgfmathutil.code.tex" 1736268207 8471 c2883569d03f69e8e1cabfef4999cfd7 ""
"/usr/share/texmf-dist/tex/generic/pgf/modules/pgfmodulematrix.code.tex" 1736268207 21211 1e73ec76bd73964d84197cc3d2685b01 ""
"/usr/share/texmf-dist/tex/generic/pgf/modules/pgfmoduleplot.code.tex" 1736268207 16121 346f9013d34804439f7436ff6786cef7 ""
"/usr/share/texmf-dist/tex/generic/pgf/modules/pgfmoduleshapes.code.tex" 1736268207 44792 271e2e1934f34c759f4dedb1e14a5015 ""
"/usr/share/texmf-dist/tex/generic/pgf/pgf.revision.tex" 1736268207 114 e6d443369d0673933b38834bf99e422d ""
"/usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgf.cfg" 1736268207 926 2963ea0dcf6cc6c0a770b69ec46a477b ""
"/usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-common-pdf.def" 1736268207 5542 32f75a31ea6c3a7e1148cd6d5e93dbb7 ""
"/usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-luatex.def" 1736268207 13255 83878f3f820beccc0dd1c2683dabc65e ""
"/usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys.code.tex" 1736268207 61351 bc5f86e0355834391e736e97a61abced ""
"/usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsysprotocol.code.tex" 1736268207 1896 b8e0ca0ac371d74c0ca05583f6313c91 ""
"/usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsyssoftpath.code.tex" 1736268207 7778 53c8b5623d80238f6a20aa1df1868e63 ""
"/usr/share/texmf-dist/tex/generic/pgf/utilities/pgffor.code.tex" 1736268207 24033 d8893a1ec4d1bfa101b172754743d340 ""
"/usr/share/texmf-dist/tex/generic/pgf/utilities/pgfkeys.code.tex" 1736268207 39784 414c54e866ebab4b801e2ad81d9b21d8 ""
"/usr/share/texmf-dist/tex/generic/pgf/utilities/pgfkeyslibraryfiltered.code.tex" 1736268207 37433 940bc6d409f1ffd298adfdcaf125dd86 ""
"/usr/share/texmf-dist/tex/generic/pgf/utilities/pgfrcs.code.tex" 1736268207 4385 510565c2f07998c8a0e14f0ec07ff23c ""
"/usr/share/texmf-dist/tex/generic/pgf/utilities/pgfutil-common.tex" 1736268207 29239 22e8c7516012992a49873eff0d868fed ""
"/usr/share/texmf-dist/tex/generic/pgf/utilities/pgfutil-latex.def" 1736268207 6950 8524a062d82b7afdc4a88a57cb377784 ""
"/usr/share/texmf-dist/tex/generic/stringenc/stringenc.sty" 1736268207 21514 b7557edcee22835ef6b03ede1802dad4 ""
"/usr/share/texmf-dist/tex/generic/unicode-data/UnicodeData.txt" 1736268207 1914200 c94d3d92f1c66e50f750fe84b8055939 ""
"/usr/share/texmf-dist/tex/generic/uniquecounter/uniquecounter.sty" 1736268207 7008 f92eaa0a3872ed622bbf538217cd2ab7 ""
"/usr/share/texmf-dist/tex/latex/auxhook/auxhook.sty" 1736268207 3935 57aa3c3e203a5c2effb4d2bd2efbc323 ""
"/usr/share/texmf-dist/tex/latex/base/article.cls" 1736268207 20144 147463a6a579f4597269ef9565205cfe ""
"/usr/share/texmf-dist/tex/latex/base/atbegshi-ltx.sty" 1736268207 3045 273c666a54e60b9f730964f431a56c1b ""
"/usr/share/texmf-dist/tex/latex/base/atveryend-ltx.sty" 1736268207 2462 6bc53756156dbd71c1ad550d30a3b93f ""
"/usr/share/texmf-dist/tex/latex/base/ifthen.sty" 1736268207 5319 2b738d02ce36ada6dcdd9534940db0ee ""
"/usr/share/texmf-dist/tex/latex/base/ltluatex.lua" 1736268207 24543 44e225ecc61706b060f0189a96e1cc1d ""
"/usr/share/texmf-dist/tex/latex/base/size10.clo" 1736268207 8448 dbc0dbf4156c0bb9ba01a1c685d3bad0 ""
"/usr/share/texmf-dist/tex/latex/base/ts1cmr.fd" 1736268207 2430 fce77d7e103eb98b6c4d486ae1d466c0 ""
"/usr/share/texmf-dist/tex/latex/biblatex/bbx/numeric.bbx" 1736268207 1818 9ed166ac0a9204a8ebe450ca09db5dde ""
"/usr/share/texmf-dist/tex/latex/biblatex/bbx/standard.bbx" 1736268207 25680 409c3f3d570418bc545e8065bebd0688 ""
"/usr/share/texmf-dist/tex/latex/biblatex/biblatex.cfg" 1736268207 69 249fa6df04d948e51b6d5c67bea30c42 ""
"/usr/share/texmf-dist/tex/latex/biblatex/biblatex.def" 1736268207 92527 8f6b3a677f74ea525477a813f33c4e65 ""
"/usr/share/texmf-dist/tex/latex/biblatex/biblatex.sty" 1736268207 528517 7eed285c714f532e12ae48b360c080f8 ""
"/usr/share/texmf-dist/tex/latex/biblatex/blx-case-expl3.sty" 1736268207 8433 72f8188742e7214b7068f345cd0287ac ""
"/usr/share/texmf-dist/tex/latex/biblatex/blx-compat.def" 1736268207 13919 5426dbe90e723f089052b4e908b56ef9 ""
"/usr/share/texmf-dist/tex/latex/biblatex/blx-dm.def" 1736268207 32455 8d3e554836db11aab80a8e11be62e1b1 ""
"/usr/share/texmf-dist/tex/latex/biblatex/blx-unicode.def" 1736268207 3786 1f89d14780f0ad89ab94652b37f4e9b8 ""
"/usr/share/texmf-dist/tex/latex/biblatex/cbx/numeric.cbx" 1736268207 4629 cda468e8a0b1cfa0f61872e171037a4b ""
"/usr/share/texmf-dist/tex/latex/biblatex/lbx/english.lbx" 1736268207 39965 48ce9ce3350aba9457f1020b1deba5cf ""
"/usr/share/texmf-dist/tex/latex/elocalloc/elocalloc.sty" 1736268207 1428 7d469063535b93044f827bfdb1b0a130 ""
"/usr/share/texmf-dist/tex/latex/environ/environ.sty" 1736268207 4378 f429f0da968c278653359293040a8f52 ""
"/usr/share/texmf-dist/tex/latex/epstopdf-pkg/epstopdf-base.sty" 1736268207 13886 d1306dcf79a944f6988e688c1785f9ce ""
"/usr/share/texmf-dist/tex/latex/etoolbox/etoolbox.sty" 1736268207 46845 3b58f70c6e861a13d927bff09d35ecbc ""
"/usr/share/texmf-dist/tex/latex/forest/forest.sty" 1736268207 350382 5acb55040bcf8080692df2e32715e5ea ""
"/usr/share/texmf-dist/tex/latex/graphics-cfg/color.cfg" 1736268207 1213 620bba36b25224fa9b7e1ccb4ecb76fd ""
"/usr/share/texmf-dist/tex/latex/graphics-cfg/graphics.cfg" 1736268207 1224 978390e9c2234eab29404bc21b268d1e ""
"/usr/share/texmf-dist/tex/latex/graphics-def/luatex.def" 1736268207 19478 ff26a264ed286e649e9023efac2574b2 ""
"/usr/share/texmf-dist/tex/latex/graphics/graphics.sty" 1736268207 18387 8f900a490197ebaf93c02ae9476d4b09 ""
"/usr/share/texmf-dist/tex/latex/graphics/graphicx.sty" 1736268207 8010 a8d949cbdbc5c983593827c9eec252e1 ""
"/usr/share/texmf-dist/tex/latex/graphics/keyval.sty" 1736268207 2671 7e67d78d9b88c845599a85b2d41f2e39 ""
"/usr/share/texmf-dist/tex/latex/graphics/mathcolor.ltx" 1736268207 2885 9c645d672ae17285bba324998918efd8 ""
"/usr/share/texmf-dist/tex/latex/graphics/trig.sty" 1736268207 4023 293ea1c16429fc0c4cf605f4da1791a9 ""
"/usr/share/texmf-dist/tex/latex/hycolor/hycolor.sty" 1736268207 17914 4c28a13fc3d975e6e81c9bea1d697276 ""
"/usr/share/texmf-dist/tex/latex/hyperref/hluatex.def" 1736268207 51074 bb8178880b603a1e9b88baf1805e8c5a ""
"/usr/share/texmf-dist/tex/latex/hyperref/hyperref.sty" 1736268207 220920 fd3cbb5f1a2bc9b8f451b8b7d8171264 ""
"/usr/share/texmf-dist/tex/latex/hyperref/nameref.sty" 1736268207 11026 182c63f139a71afd30a28e5f1ed2cd1c ""
"/usr/share/texmf-dist/tex/latex/hyperref/pd1enc.def" 1736268207 14249 e67cb186717b7ab18d14a4875e7e98b5 ""
"/usr/share/texmf-dist/tex/latex/hyperref/puenc.def" 1736268207 117112 05831178ece2cad4d9629dcf65099b11 ""
"/usr/share/texmf-dist/tex/latex/inlinedef/inlinedef.sty" 1736268207 10102 e5ccd67aecac6cb4bf5de2b491ef79b5 ""
"/usr/share/texmf-dist/tex/latex/kvoptions/kvoptions.sty" 1736268207 22555 6d8e155cfef6d82c3d5c742fea7c992e ""
"/usr/share/texmf-dist/tex/latex/kvsetkeys/kvsetkeys.sty" 1736268207 13815 760b0c02f691ea230f5359c4e1de23a7 ""
"/usr/share/texmf-dist/tex/latex/l3backend/l3backend-luatex.def" 1736268207 30427 925bbc87643c4ebec807605b70ff6deb ""
"/usr/share/texmf-dist/tex/latex/l3backend/l3backend-luatex.lua" 1736268207 3839 b2e3045c3d6386724a7a4977b109956f ""
"/usr/share/texmf-dist/tex/latex/l3kernel/expl3.lua" 1736268207 16940 41c3e6d9ad450f4dc67cddec0e70ecf4 ""
"/usr/share/texmf-dist/tex/latex/l3kernel/expl3.sty" 1736268207 6565 10e89ed128ccd59431746bbdd82129fc ""
"/usr/share/texmf-dist/tex/latex/l3packages/xparse/xparse.sty" 1736268207 9327 11bedad2ac38f92e405a38ed18489a03 ""
"/usr/share/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg" 1736268207 678 4792914a8f45be57bb98413425e4c7af ""
"/usr/share/texmf-dist/tex/latex/logreq/logreq.def" 1736268207 1620 fb1c32b818f2058eca187e5c41dfae77 ""
"/usr/share/texmf-dist/tex/latex/logreq/logreq.sty" 1736268207 6187 b27afc771af565d3a9ff1ca7d16d0d46 ""
"/usr/share/texmf-dist/tex/latex/pgf/basiclayer/pgf.sty" 1736268207 1090 bae35ef70b3168089ef166db3e66f5b2 ""
"/usr/share/texmf-dist/tex/latex/pgf/basiclayer/pgfcore.sty" 1736268207 373 00b204b1d7d095b892ad31a7494b0373 ""
"/usr/share/texmf-dist/tex/latex/pgf/compatibility/pgfcomp-version-0-65.sty" 1736268207 21013 f4ff83d25bb56552493b030f27c075ae ""
"/usr/share/texmf-dist/tex/latex/pgf/compatibility/pgfcomp-version-1-18.sty" 1736268207 989 c49c8ae06d96f8b15869da7428047b1e ""
"/usr/share/texmf-dist/tex/latex/pgf/frontendlayer/tikz.sty" 1736268207 339 c2e180022e3afdb99c7d0ea5ce469b7d ""
"/usr/share/texmf-dist/tex/latex/pgf/math/pgfmath.sty" 1736268207 306 c56a323ca5bf9242f54474ced10fca71 ""
"/usr/share/texmf-dist/tex/latex/pgf/systemlayer/pgfsys.sty" 1736268207 443 8c872229db56122037e86bcda49e14f3 ""
"/usr/share/texmf-dist/tex/latex/pgf/utilities/pgffor.sty" 1736268207 348 ee405e64380c11319f0e249fed57e6c5 ""
"/usr/share/texmf-dist/tex/latex/pgf/utilities/pgfkeys.sty" 1736268207 274 5ae372b7df79135d240456a1c6f2cf9a ""
"/usr/share/texmf-dist/tex/latex/pgf/utilities/pgfrcs.sty" 1736268207 325 f9f16d12354225b7dd52a3321f085955 ""
"/usr/share/texmf-dist/tex/latex/pgfopts/pgfopts.sty" 1736268207 5540 d5c60cf09c59da351aa4023ed084e4eb ""
"/usr/share/texmf-dist/tex/latex/refcount/refcount.sty" 1736268207 9878 9e94e8fa600d95f9c7731bb21dfb67a4 ""
"/usr/share/texmf-dist/tex/latex/rerunfilecheck/rerunfilecheck.sty" 1736268207 9714 ba3194bd52c8499b3f1e3eb91d409670 ""
"/usr/share/texmf-dist/tex/latex/tex-ini-files/lualatexquotejobname.lua" 1736268207 1021 ae37ae5e20605f170274bb33ea1a0e3d ""
"/usr/share/texmf-dist/tex/latex/titling/titling.sty" 1736268207 7358 95ac619994bd30d405a74f3eca431c84 ""
"/usr/share/texmf-dist/tex/latex/trimspaces/trimspaces.sty" 1736268207 1380 971a51b00a14503ddf754cab24c3f209 ""
"/usr/share/texmf-dist/tex/latex/url/url.sty" 1736268207 12796 8edb7d69a20b857904dd0ea757c14ec9 ""
"/usr/share/texmf-dist/tex/latex/xcolor/xcolor.sty" 1736268207 55487 80a65caedd3722f4c20a14a69e785d8f ""
"/usr/share/texmf-dist/tex/luatex/lua-uni-algos/lua-uni-case.lua" 1736268207 1921 020c660fdc1d4c83ffebb059fead7b51 ""
"/usr/share/texmf-dist/tex/luatex/lua-uni-algos/lua-uni-normalize.lua" 1736268207 21269 eb4154856f0afe9e8d886dbf6922dcc6 ""
"/usr/share/texmf-dist/tex/luatex/lua-uni-algos/lua-uni-parse.lua" 1736268207 2115 596f0e8384e97c26c78a8e88c65a7843 ""
"/usr/share/texmf-dist/tex/luatex/lualibs/lualibs-basic-merged.lua" 1736268207 131460 7015f38db4e78c4821318144c6980394 ""
"/usr/share/texmf-dist/tex/luatex/lualibs/lualibs-basic.lua" 1736268207 2685 2fd4fa3426f4dda85135669993283e9b ""
"/usr/share/texmf-dist/tex/luatex/lualibs/lualibs-compat.lua" 1736268207 603 398583cb619d20952d67edcedae41608 ""
"/usr/share/texmf-dist/tex/luatex/lualibs/lualibs-extended-merged.lua" 1736268207 140360 1ec312f1a70c43b37e53af03b4bd7323 ""
"/usr/share/texmf-dist/tex/luatex/lualibs/lualibs-extended.lua" 1736268207 5000 964f0ac2a95b6856d92913f68e0689a7 ""
"/usr/share/texmf-dist/tex/luatex/lualibs/lualibs.lua" 1736268207 3780 3cfe6b013a0ab68c61b751da491afaac ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/fontloader-2023-12-28.lua" 1736268207 885747 f33a8946b4d6a005e76276706db83059 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/fontloader-basics-gen.lua" 1736268207 12788 578c251c496bed783075709e94c2be26 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-arabic.lua" 1736268207 5364 49a6ddd634bdb37bacb08c17d1550f50 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-auxiliary.lua" 1736268207 34239 6dbe1a2a3f36cf57845e54c1927ccfee ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-bcp47.lua" 1736268207 3674 ad13e5d6b82aff53a36384fd239c5d6c ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-case.lua" 1736268207 16116 3d55feb55802d40830807a7459cb345a ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-colors.lua" 1736268207 18372 cda35651d19ec5ae24d18f3b0e1cfeec ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-configuration.lua" 1736268207 34054 0abab568ba9c0082c9ad3e1b75897d49 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-database.lua" 1736268207 135377 0e2f26fead21ffdb36f36a5090220f8d ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-embolden.lua" 1736268207 1004 6b15c8ae9e49cfb4a5e319aadf1ef3ac ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-fallback.lua" 1736268207 4047 3b1569703b22fabe9661f2188fa0dbd3 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-features.lua" 1736268207 35386 756bc857a0d63bf1a6abbd1406e85844 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-define.lua" 1736268207 19482 f1319af7f3837fbda6db470b77627c71 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-plug.lua" 1736268207 42681 201bbdd109fa1f0b02068e59f2f5384d ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-var-cff2.lua" 1736268207 14126 969cfafc9b19f07aa3e945dd2d3bd6ca ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-var-t2-writer.lua" 1736268207 4050 f57f854058ea50d565be9558f3ee2647 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-var-ttf.lua" 1736268207 20523 f40bb0362e93dec1cb185536de4be47d ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-init.lua" 1736268207 19746 5808f9c69b6a266fef8b923aefaecdc3 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-letterspace.lua" 1736268207 20268 45fe7d691be75f6ef7f0c058c40113d9 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-loaders.lua" 1736268207 10733 80907f5cf87e2d5f946936a1ef08b6a8 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-log.lua" 1736268207 11323 b0cd5d77653fae19eceb1c1f195d9b77 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-main.lua" 1736268207 275 b19c9cc34cf1d676c39f872cfb41aef6 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-multiscript.lua" 1736268207 15068 1415dc53c2ad56f6dc892212090d7c03 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-notdef.lua" 1736268207 12258 920854a37e695604457cb1fcad35a814 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-parsers.lua" 1736268207 30436 73f67f95af947fe741c931e6f60b84cb ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-realpath.lua" 1736268207 4692 f3b5a8bf6d49e81bc8637547ad67334d ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-resolvers.lua" 1736268207 11209 0f87b7c297120189ddbd31f6ee324a9f ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-scripts.lua" 1736268207 2506 2026f40b86c3e93eba401524424e71d9 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-status.lua" 1736268207 6317 d7e4e397c1c9279d55136ae8a79c077e ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-suppress.lua" 1736268207 2582 1cbd162cbdc589adec03aa82e2ba0048 ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-szss.lua" 1736268207 6309 bdf184e24947478dc89307adf866a3ca ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-tounicode.lua" 1736268207 7528 245271ce518c136c96f22fee6cb810ab ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-unicode.lua" 1736268207 7917 cebc6454abe34f2fe1c1248f8b2453da ""
"/usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload.lua" 1736268207 13565 50482dad4c221a79dbdbb982122baf8c ""
"/var/lib/texmf/fonts/map/pdftex/updmap/pdftex.map" 1736329123.12051 5357452 9c63374643a5082f15b788fe1e348f57 ""
"/var/lib/texmf/web2c/luahbtex/lualatex.fmt" 1736329099 12260934 4441f94dd3f43b779629a3bc79355f00 ""
"images/antenna_mount.png" 1738840144.9769 654857 ec62560db93f2318ba8918fa02f9b186 ""
"progress.aux" 1738890288.29345 1753 374bf98cb56ed7ec81c2992c620f6852 "lualatex"
"progress.bbl" 0 -1 0 "biber progress"
"progress.out" 1738890288.29345 973 dae26985e86cfaf58f64b40b92ed794c "lualatex"
"progress.run.xml" 1738890288.29345 2362 e7fd92e99d11b5a90a062c3752e062da "lualatex"
"progress.tex" 1738890285.49012 9209 ce48269f8048b7468882ac06a82b3c0f ""
(generated)
"progress.aux"
"progress.bcf"
"progress.log"
"progress.out"
"progress.pdf"
"progress.run.xml"
(rewritten before read)

View File

@ -1,249 +0,0 @@
PWD /home/cfalas/software/dissertation/progress_report
INPUT /var/lib/texmf/web2c/luahbtex/lualatex.fmt
INPUT ./progress.tex
OUTPUT progress.log
INPUT /usr/share/texmf-dist/tex/latex/tex-ini-files/lualatexquotejobname.lua
INPUT /usr/share/texmf-dist/tex/latex/base/ltluatex.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-main.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-init.lua
INPUT /usr/share/texmf-dist/tex/luatex/lualibs/lualibs.lua
INPUT /usr/share/texmf-dist/tex/luatex/lualibs/lualibs-basic.lua
INPUT /usr/share/texmf-dist/tex/luatex/lualibs/lualibs-basic-merged.lua
INPUT /usr/share/texmf-dist/tex/luatex/lualibs/lualibs-compat.lua
INPUT /usr/share/texmf-dist/tex/luatex/lualibs/lualibs-extended.lua
INPUT /usr/share/texmf-dist/tex/luatex/lualibs/lualibs-extended-merged.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-log.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/fontloader-basics-gen.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-parsers.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-configuration.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-status.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/fontloader-2023-12-28.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-fallback.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-multiscript.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-scripts.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-loaders.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-database.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-unicode.lua
INPUT /usr/share/texmf-dist/tex/luatex/lua-uni-algos/lua-uni-case.lua
INPUT /usr/share/texmf-dist/tex/luatex/lua-uni-algos/lua-uni-parse.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-realpath.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-colors.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-resolvers.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-features.lua
INPUT /usr/share/texmf-dist/tex/luatex/lua-uni-algos/lua-uni-normalize.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-arabic.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-define.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-var-cff2.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-var-t2-writer.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-var-ttf.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-harf-plug.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-letterspace.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-embolden.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-notdef.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-suppress.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-szss.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-auxiliary.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-tounicode.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-case.lua
INPUT /usr/share/texmf-dist/tex/luatex/luaotfload/luaotfload-bcp47.lua
INPUT /usr/share/texmf-dist/tex/latex/l3kernel/expl3.lua
INPUT /usr/share/texmf-dist/tex/latex/base/article.cls
INPUT /usr/share/texmf-dist/tex/latex/base/size10.clo
INPUT /home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman10-regular.luc
INPUT /usr/share/texmf-dist/tex/latex/titling/titling.sty
INPUT /usr/share/texmf-dist/tex/latex/graphics/graphicx.sty
INPUT /usr/share/texmf-dist/tex/latex/graphics/keyval.sty
INPUT /usr/share/texmf-dist/tex/latex/graphics/graphics.sty
INPUT /usr/share/texmf-dist/tex/latex/graphics/trig.sty
INPUT /usr/share/texmf-dist/tex/latex/graphics-cfg/graphics.cfg
INPUT /usr/share/texmf-dist/tex/latex/graphics-def/luatex.def
INPUT /usr/share/texmf-dist/tex/latex/forest/forest.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/frontendlayer/tikz.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/basiclayer/pgf.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/utilities/pgfrcs.sty
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgfutil-common.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgfutil-latex.def
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgfrcs.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/pgf.revision.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/pgf.revision.tex
INPUT /usr/share/texmf-dist/tex/latex/pgf/basiclayer/pgfcore.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/systemlayer/pgfsys.sty
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgfkeys.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgfkeyslibraryfiltered.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgf.cfg
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-luatex.def
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-luatex.def
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-common-pdf.def
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsyssoftpath.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/systemlayer/pgfsysprotocol.code.tex
INPUT /usr/share/texmf-dist/tex/latex/xcolor/xcolor.sty
INPUT /usr/share/texmf-dist/tex/latex/graphics-cfg/color.cfg
INPUT /usr/share/texmf-dist/tex/latex/graphics/mathcolor.ltx
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcore.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmath.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathutil.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathparser.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.basic.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.trigonometric.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.random.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.comparison.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.base.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.round.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.misc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.integerarithmetics.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathcalc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmathfloat.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfint.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepoints.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathconstruct.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathusage.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorescopes.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoregraphicstate.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoretransformations.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorequick.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreobjects.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathprocessing.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorearrows.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreshade.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreimage.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreexternal.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorelayers.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcoretransparency.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepatterns.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/basiclayer/pgfcorerdf.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/modules/pgfmoduleshapes.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/modules/pgfmoduleplot.code.tex
INPUT /usr/share/texmf-dist/tex/latex/pgf/compatibility/pgfcomp-version-0-65.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/compatibility/pgfcomp-version-1-18.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/utilities/pgffor.sty
INPUT /usr/share/texmf-dist/tex/latex/pgf/utilities/pgfkeys.sty
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgfkeys.code.tex
INPUT /usr/share/texmf-dist/tex/latex/pgf/math/pgfmath.sty
INPUT /usr/share/texmf-dist/tex/generic/pgf/math/pgfmath.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/utilities/pgffor.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/tikz.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryplothandlers.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryplothandlers.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/modules/pgfmodulematrix.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibrarytopaths.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibrarytopaths.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.geometric.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.geometric.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.geometric.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.geometric.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.misc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.misc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.misc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.misc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.symbols.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.symbols.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.symbols.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.symbols.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.arrows.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.arrows.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.arrows.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.arrows.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.callouts.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.callouts.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.callouts.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.callouts.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.multipart.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryshapes.multipart.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.multipart.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/shapes/pgflibraryshapes.multipart.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryfit.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibraryfit.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibrarycalc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibrarycalc.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryintersections.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryintersections.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryfpu.code.tex
INPUT /usr/share/texmf-dist/tex/generic/pgf/libraries/pgflibraryfpu.code.tex
INPUT /usr/share/texmf-dist/tex/latex/pgfopts/pgfopts.sty
INPUT /usr/share/texmf-dist/tex/latex/etoolbox/etoolbox.sty
INPUT /usr/share/texmf-dist/tex/latex/elocalloc/elocalloc.sty
INPUT /usr/share/texmf-dist/tex/latex/environ/environ.sty
INPUT /usr/share/texmf-dist/tex/latex/trimspaces/trimspaces.sty
INPUT /usr/share/texmf-dist/tex/latex/l3packages/xparse/xparse.sty
INPUT /usr/share/texmf-dist/tex/latex/l3kernel/expl3.sty
INPUT /usr/share/texmf-dist/tex/latex/l3backend/l3backend-luatex.def
INPUT /usr/share/texmf-dist/tex/latex/l3backend/l3backend-luatex.lua
INPUT /usr/share/texmf-dist/tex/latex/inlinedef/inlinedef.sty
INPUT /usr/share/texmf-dist/tex/latex/hyperref/hyperref.sty
INPUT /usr/share/texmf-dist/tex/generic/iftex/iftex.sty
INPUT /usr/share/texmf-dist/tex/latex/kvsetkeys/kvsetkeys.sty
INPUT /usr/share/texmf-dist/tex/generic/kvdefinekeys/kvdefinekeys.sty
INPUT /usr/share/texmf-dist/tex/generic/pdfescape/pdfescape.sty
INPUT /usr/share/texmf-dist/tex/generic/ltxcmds/ltxcmds.sty
INPUT /usr/share/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.sty
INPUT /usr/share/texmf-dist/tex/generic/infwarerr/infwarerr.sty
INPUT /usr/share/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.lua
INPUT /usr/share/texmf-dist/tex/latex/hycolor/hycolor.sty
INPUT /usr/share/texmf-dist/tex/latex/auxhook/auxhook.sty
INPUT /usr/share/texmf-dist/tex/latex/hyperref/nameref.sty
INPUT /usr/share/texmf-dist/tex/latex/refcount/refcount.sty
INPUT /usr/share/texmf-dist/tex/generic/gettitlestring/gettitlestring.sty
INPUT /usr/share/texmf-dist/tex/latex/kvoptions/kvoptions.sty
INPUT /usr/share/texmf-dist/tex/latex/hyperref/pd1enc.def
INPUT /usr/share/texmf-dist/tex/generic/intcalc/intcalc.sty
INPUT /usr/share/texmf-dist/tex/latex/hyperref/puenc.def
INPUT /usr/share/texmf-dist/tex/latex/url/url.sty
INPUT /usr/share/texmf-dist/tex/generic/bitset/bitset.sty
INPUT /usr/share/texmf-dist/tex/generic/bigintcalc/bigintcalc.sty
INPUT /usr/share/texmf-dist/tex/latex/base/atbegshi-ltx.sty
INPUT /usr/share/texmf-dist/tex/latex/hyperref/hluatex.def
INPUT /usr/share/texmf-dist/tex/generic/stringenc/stringenc.sty
INPUT /usr/share/texmf-dist/tex/latex/base/atveryend-ltx.sty
INPUT /usr/share/texmf-dist/tex/latex/rerunfilecheck/rerunfilecheck.sty
INPUT /usr/share/texmf-dist/tex/generic/uniquecounter/uniquecounter.sty
INPUT /usr/share/texmf-dist/tex/latex/biblatex/biblatex.sty
INPUT /usr/share/texmf-dist/tex/latex/logreq/logreq.sty
INPUT /usr/share/texmf-dist/tex/latex/logreq/logreq.def
INPUT /usr/share/texmf-dist/tex/latex/base/ifthen.sty
INPUT /usr/share/texmf-dist/tex/latex/biblatex/blx-dm.def
INPUT /usr/share/texmf-dist/tex/latex/biblatex/blx-unicode.def
INPUT /usr/share/texmf-dist/tex/generic/unicode-data/UnicodeData.txt
INPUT /usr/share/texmf-dist/tex/latex/biblatex/blx-compat.def
INPUT /usr/share/texmf-dist/tex/latex/biblatex/biblatex.def
INPUT /usr/share/texmf-dist/tex/latex/biblatex/bbx/numeric.bbx
INPUT /usr/share/texmf-dist/tex/latex/biblatex/bbx/standard.bbx
INPUT /usr/share/texmf-dist/tex/latex/biblatex/cbx/numeric.cbx
INPUT /usr/share/texmf-dist/tex/latex/biblatex/biblatex.cfg
INPUT /usr/share/texmf-dist/tex/latex/biblatex/blx-case-expl3.sty
INPUT ./progress.aux
OUTPUT progress.aux
INPUT /usr/share/texmf-dist/tex/latex/base/ts1cmr.fd
INPUT /usr/share/texmf-dist/tex/context/base/mkii/supp-pdf.mkii
INPUT /usr/share/texmf-dist/tex/latex/epstopdf-pkg/epstopdf-base.sty
INPUT /usr/share/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg
INPUT ./progress.out
INPUT ./progress.out
OUTPUT progress.out
OUTPUT progress.pdf
INPUT /usr/share/texmf-dist/tex/latex/biblatex/lbx/english.lbx
OUTPUT progress.bcf
INPUT /home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman10-bold.luc
INPUT /home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman10-italic.luc
INPUT /home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman12-regular.luc
INPUT /home/cfalas/.cache/texlive/texmf-var/luatex-cache/generic/fonts/otl/lmroman12-bold.luc
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmr10.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmr7.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmr5.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmmi7.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmmi5.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmsy10.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmsy7.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmsy5.tfm
INPUT /usr/share/texmf-dist/fonts/tfm/public/cm/cmex10.tfm
INPUT /var/lib/texmf/fonts/map/pdftex/updmap/pdftex.map
INPUT ./images/antenna_mount.png
INPUT ./images/antenna_mount.png
INPUT ./progress.aux
INPUT ./progress.run.xml
OUTPUT progress.run.xml

View File

@ -1,8 +0,0 @@
\BOOKMARK [1][-]{section.1}{\376\377\000S\000u\000m\000m\000a\000r\000y}{}% 1
\BOOKMARK [1][-]{section.2}{\376\377\000M\000u\000l\000t\000i\000-\000N\000I\000C\000\040\000s\000e\000t\000u\000p}{}% 2
\BOOKMARK [1][-]{section.3}{\376\377\000D\000a\000t\000a\000\040\000I\000n\000g\000e\000s\000t\000i\000o\000n}{}% 3
\BOOKMARK [1][-]{section.4}{\376\377\000D\000a\000t\000a\000\040\000P\000r\000e\000p\000r\000o\000c\000e\000s\000s\000i\000n\000g}{}% 4
\BOOKMARK [1][-]{section.5}{\376\377\000A\000n\000g\000l\000e\000\040\000o\000f\000\040\000A\000r\000r\000i\000v\000a\000l\000\040\000E\000s\000t\000i\000m\000a\000t\000i\000o\000n}{}% 5
\BOOKMARK [1][-]{section.6}{\376\377\000D\000a\000t\000a\000s\000e\000t\000\040\000C\000o\000l\000l\000e\000c\000t\000i\000o\000n}{}% 6
\BOOKMARK [1][-]{section.7}{\376\377\000D\000i\000f\000f\000i\000c\000u\000l\000t\000i\000e\000s}{}% 7
\BOOKMARK [1][-]{section.8}{\376\377\000N\000e\000x\000t\000\040\000S\000t\000e\000p\000s}{}% 8

Binary file not shown.

View File

@ -1,86 +0,0 @@
<?xml version="1.0" standalone="yes"?>
<!-- logreq request file -->
<!-- logreq version 1.0 / dtd version 1.0 -->
<!-- Do not edit this file! -->
<!DOCTYPE requests [
<!ELEMENT requests (internal | external)*>
<!ELEMENT internal (generic, (provides | requires)*)>
<!ELEMENT external (generic, cmdline?, input?, output?, (provides | requires)*)>
<!ELEMENT cmdline (binary, (option | infile | outfile)*)>
<!ELEMENT input (file)+>
<!ELEMENT output (file)+>
<!ELEMENT provides (file)+>
<!ELEMENT requires (file)+>
<!ELEMENT generic (#PCDATA)>
<!ELEMENT binary (#PCDATA)>
<!ELEMENT option (#PCDATA)>
<!ELEMENT infile (#PCDATA)>
<!ELEMENT outfile (#PCDATA)>
<!ELEMENT file (#PCDATA)>
<!ATTLIST requests
version CDATA #REQUIRED
>
<!ATTLIST internal
package CDATA #REQUIRED
priority (9) #REQUIRED
active (0 | 1) #REQUIRED
>
<!ATTLIST external
package CDATA #REQUIRED
priority (1 | 2 | 3 | 4 | 5 | 6 | 7 | 8) #REQUIRED
active (0 | 1) #REQUIRED
>
<!ATTLIST provides
type (static | dynamic | editable) #REQUIRED
>
<!ATTLIST requires
type (static | dynamic | editable) #REQUIRED
>
<!ATTLIST file
type CDATA #IMPLIED
>
]>
<requests version="1.0">
<internal package="biblatex" priority="9" active="1">
<generic>latex</generic>
<provides type="dynamic">
<file>progress.bcf</file>
</provides>
<requires type="dynamic">
<file>progress.bbl</file>
</requires>
<requires type="static">
<file>blx-dm.def</file>
<file>blx-unicode.def</file>
<file>blx-compat.def</file>
<file>biblatex.def</file>
<file>standard.bbx</file>
<file>numeric.bbx</file>
<file>numeric.cbx</file>
<file>biblatex.cfg</file>
<file>english.lbx</file>
</requires>
</internal>
<external package="biblatex" priority="5" active="1">
<generic>biber</generic>
<cmdline>
<binary>biber</binary>
<infile>progress</infile>
</cmdline>
<input>
<file>progress.bcf</file>
</input>
<output>
<file>progress.bbl</file>
</output>
<provides type="dynamic">
<file>progress.bbl</file>
</provides>
<requires type="dynamic">
<file>progress.bcf</file>
</requires>
<requires type="editable">
<file>refs.bib</file>
</requires>
</external>
</requests>

Binary file not shown.

View File

@ -1,132 +0,0 @@
\documentclass{article}
\usepackage{titling}
\usepackage{graphicx} % Required for inserting images
\usepackage{forest}
\usepackage{hyperref}
\usepackage[backend=biber]{biblatex}
\addbibresource{refs.bib}
\begin{document}
% \maketitle
\noindent\textbf{Progress Report:} \textit{Human Presence Detection using Wi-Fi Channel State Information}
\noindent\textbf{Author:} Christos Falas (\href{mailto:cf575@cam.ac.uk}{cf575@cam.ac.uk})
\noindent\textbf{Supervisor:} Markus Kuhn
\noindent\textbf{Project Checkers:} Pietro Lio (pl219) and Jeremy Yallop (jdy22)
\noindent\textbf{Date:} \today
\vspace{1cm}
\section{Summary}
Overall, I believe that the project is going well, and a lot of progress has been made. However, due to easier logistics, I decided to re-arrange the work plan slightly, so that some tasks that I wasn't planning on doing until a bit later are already done, aand others that were supposed to be done by now are not.
In the next sections, I try to explain these decisions, as well s give some context on what I've done, and what is left to be done.
Due to this change in work plan, it is hard to estimate whether my project is behind or ahead of schedule. I would imagine I am roughtly on track (since the extra work I've done is slightly more than the work I skipped). In order to not have this problem, at the end I devised a new work plan.
\section{Multi-NIC setup}
Per my initial plan, I was supposed to currently only be working using a single Wi-Fi NIC on the receiving end. However, this only allows me to use CSI data from 2 antennas, which is quite limiting (and would lead to an under-constrained solution in most cases). In order to combat this, I re-arranged my work plan, and moved the work to set up the multi-NIC system to an earlier stage.
In order to easily work with multiple NICs, I had to use multiple computers, since I was limited by the number of PCIe slots on each computer (this would not be a limit on commercial access point hardware). Along with my main desktop computer, I am using two identical small-form-factor machines, each of which have 2 PCIe ports.
Per my original proposal, I am using FeitCSI \cite{feitcsi:project} to control the Wi-Fi NICs over the network. However, FeitCSI does not currently support multiple NICs on the same machine. Therefore, in order to use both PCIe slots on the machine for NICs, I had to set up different virtual machines, so that each VM would only detect a single NIC. For ease of management, I configured the VMs to boot from the network, off of a single disk image, such that I only have to make changes in one place. The overall setup is as shown in Figure \ref{fig:multi-nic}.
While the setup is not realistic for a commercial deployment, it makes working with multiple NICs much easier, is a rough approximation of the desired setup that allows me to collect the same data. This collection process should be a lot easier with newer Wi-Fi 7 hardware.
\begin{figure}[h]
\begin{forest}
for tree={draw, align=center, l sep=1cm}
[Network Switch, s sep=1cm
[Main Machine
[Hypervisor
[TFTP \& NFS Server, name=tftp, yshift=0.8cm]
[Development Machine, name=dev] % Assigning a name to reference later
[FeitCSI, name=feit0 [NIC 0, edge=green]]
]
]
[Machine 1, tikz={\node [draw, inner sep=2mm, fit=()(!111)(!lll), rounded corners, dashed]{};}
[Hypervisor, name=hyper1
[FeitCSI, name=feit11 [NIC 1, name=nic1, edge=green]]
[FeitCSI, name=feit12 [NIC 2, edge=green]]
]
]
[Machine 2, tikz={\node [draw, inner sep=2mm, fit=()(!111)(!lll), rounded corners, dashed]{};}
[Hypervisor, name=hyper2
[FeitCSI, name=feit21 [NIC 3, edge=green]]
[FeitCSI, name=feit22 [NIC 4, edge=green]]
]
]
]
\draw[blue, bend left] (tftp.north) to[out=20, in=160] (feit21.north);
\draw[blue, bend left] (tftp.north) to[out=20, in=160] (feit22.north);
\draw[blue, bend left] (tftp.north) to[out=20, in=160] (feit11.north);
\draw[blue, bend left] (tftp.north) to[out=20, in=160] (feit12.north);
\draw[blue, bend left] (tftp.north) to[out=20, in=160] (feit0.north);
\end{forest}
\caption{Network/hardware setup for multi-NIC CSI collection}
\label{fig:multi-nic}
\end{figure}
To ensure consistency in the spacing between antennas, I built a simple custom mount for the 8 antennas (2 per NIC) that I am using, out of a tin can. This mount is shown in Figure \ref{fig:antenna-mount}.
\begin{figure}[h]
\includegraphics[scale=0.5]{images/antenna_mount.png}
\centering
\caption{Custom antenna mount}
\label{fig:antenna-mount}
\end{figure}
\section{Data Ingestion}
I have implemented a simple data ingestion pipeline, which connects to each of the FeitCSI instances over the network, and collects the CSI data from each of the NICs in realtime (at around 100Hz). The data is then merged using the correct antenna order, and then streamed to downstream consumers (which could save the data to a file for later processing, or process it in realtime).
Additionally, I implemented a simple visualisation framework which streams data to the browser in realtime, which allows me to see the data as it is being collected and processed. This is useful for debugging, and for understanding the data better.
\section{Data Preprocessing}
Data preprocessing was one of my first milestones per my original work plan. Even though I completed it initially in schedule, I believe the implementation is not optimal. Some of the math I used from the original paper I am replicating \cite{Hsu2024} is not very clear, which makes the preprocessing method used not as effective. Therefore, I am currently working on implementing a few different methods for data preprocessing, to see what works best.
\section{Angle of Arrival Estimation}
One of the main techniques I was planning to use was angle of arrival estimation, using the MUSIC algorithm. I have implemented this per the original work plan.
This allows me to estimate a 2-dimensional probability density function, estimating the response of the signal at a specific angle and time of flight. In theory, plotting a heatmap of this on a polar plane should give a rough indication of where a person is in the room.
I am currently in the process of evaluating the results from this, to see how well it performs under different conditions.
\section{Dataset Collection}
Over the weekend 1st-2nd February, I collected data using multiple different configurations, with both the empty room, with one or more people walking around the room (after going through the necessary ethics review). Using this data instead of the realtime data allows for more repeatable experiments, which will allow me to improve my method more effectively, and hence was done earlier than scheduled in the original work plan.
\section{Difficulties}
One of the main problems that I had which caused me to lose a lot of time was some hardware issues. The CPU in the main computer I was using had a hardware issue, causing my program to crash within a few seconds of running (but did not show any significant problems with other software running). This was quite difficult to diagnose, since I immediately assumed that this was a bug in my code. After a lot of debugging, running the program on different machines and environments, I realised that this is a hardware issue.
Even after diagnosing this, I had to wait for a long time (~3 weeks) for Intel to send me a replacement CPU, which caused me to not be able to do as much work over the Christmas vacation as I would have liked.
\section{Next Steps}
Since there have been many changes to the original work plan, causing me to both be ahead and behind simultaneously, here is a new work plan which I am planning to follow until the end of my project:
\begin{enumerate}
\item Evaluate different data preprocessing methods, and see which one works best and when. This will lead to improved accuracy in the heatmaps, so that the hotspots are more obvious. I aim to complete this by 21st February.
\item Automatically detect peaks/hotspots in the heatmap, to get an automatic counter for the number of people in the room (and evaluate prediction accuracy against the collected dataset). This was originally planned for January, but I believe it can now be done by 7th March.
\item Train a neural network to detect the exact position and poses of the people in the scene. I already have everything I need for this, so I believe it can be done by 21st March. This will be done simultaneously with writing a draft of the dissertation.
\item Evaluate whether using MIMO (Multiple Input, Multiple Output) will improve the accuracy of the system, and/or allow us to use less receiving antenna. This will also be done simultaneously with writing the dissertation, and should be completed by 4th April.
\item Finalise dissertation. This should give sufficient amount of time for editing, as well as for any unexpected issues that may arise, or for any other extensions I would like to add. This will likely be continuous until the submission deadline.
\end{enumerate}
\printbibliography
\end{document}

View File

@ -1,18 +0,0 @@
@INPROCEEDINGS{Hsu2024,
author={Hsu, Ting-Wei and Hsieh, Hung-Yun},
booktitle={ICC 2024 - IEEE International Conference on Communications},
title={Robust Multi-User Pose Estimation Based on Spatial and Temporal Features from WiFi CSI},
year={2024},
volume={},
number={},
pages={1600-1605},
keywords={Heating systems;Doppler shift;Time-frequency analysis;Pose estimation;Feature extraction;Robustness;Frequency estimation},
doi={10.1109/ICC51166.2024.10623053}}
@electronic{feitcsi:project,
author = {Hutar, Miroslav and Brida, Peter and Machaj, Juraj},
title = {FeitCSI, the 802.11 CSI tool},
url = {https://feitcsi.kuskosoft.com},
year = {2023}
}

View File

@ -4,7 +4,9 @@ version = "0.1.0"
description = "Use Wi-Fi Channel State Information to locate human presence"
requires-python = ">=3.11"
dependencies = [
"flask",
"numpy>=2.0.0",
"flask-sock",
"pytest",
"matplotlib",
"scipy",
@ -13,18 +15,10 @@ dependencies = [
"h5py>=3.12.1",
"pyyaml>=6.0.2",
"pydantic>=2.10.6",
"torch",
"grpcio>=1.70.0",
"requests>=2.32.3",
]
[build-system]
requires = ["setuptools", "wheel", "grpcio-tools"]
build-backend = "setuptools.build_meta"
[project.scripts]
where-fi = "where_fi.cli:cli"
where-fi = "where_fi.cli:app"
[tool.ruff]
@ -48,13 +42,3 @@ package = true
[tool.setuptools]
packages = ["where_fi"]
include-package-data = true
[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",
]

1402
uv.lock

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.2.0/p5.min.js"></script>
<meta charset="utf-8">
</head>
<body>
<script src="sketch.js"></script>
</body>
</html>

View File

@ -1,87 +0,0 @@
let target = [150, 30];
let Tx = [300, 350];
let Rx = [50, 300];
let tx_target_distance;
let rx_target_distance;
let v_target = [6, 5];
let v_target_vec;
let target_speed;
let framerate = 60;
function setup() {
createCanvas(400, 400);
frameRate(framerate);
target_speed = sqrt(sq(v_target[0]) + sq(v_target[1]));
v_target_vec = createVector(v_target[0], v_target[1]);
}
let toTarget = [];
let toRx = [];
let f = 2; // Hz
let c = 50; // pixels/sec
let lambda = c / f;
let dt = 1 / framerate;
let distance_per_tick = (c / f) * dt;
let time = 0;
function draw() {
time += dt;
target[0] += v_target[0] * dt;
target[1] += v_target[1] * dt;
tx_target_distance = dist(target[0], target[1], Tx[0], Tx[1]);
rx_target_distance = dist(target[0], target[1], Rx[0], Rx[1]);
background(220);
strokeWeight(10);
stroke("black");
noFill();
point(target[0], target[1]);
point(Tx[0], Tx[1]);
point(Rx[0], Rx[1]);
if (time - int(time) < dt) {
toTarget.push(0);
}
strokeWeight(1);
stroke("blue");
for (let x in toTarget) {
toTarget[x] += distance_per_tick;
circle(Tx[0], Tx[1], 2 * toTarget[x]);
if (toTarget[x] > tx_target_distance) {
toTarget.shift();
toRx.push([target[0], target[1], 0]);
}
}
stroke("green");
for (let x of toRx) {
circle(x[0], x[1], 2 * x[2]);
x[2] += distance_per_tick;
if (dist(x[0], x[1], Rx[0], Rx[1]) < x[2]) {
toRx.shift();
}
}
stroke("red");
line(
target[0],
target[1],
target[0] + 100 * v_target[0],
target[1] + 100 * v_target[1],
);
line(target[0], target[1], Rx[0], Rx[1]);
line(target[0], target[1], Tx[0], Tx[1]);
let phi_T = v_target_vec.angleBetween(
createVector(Tx[0] - target[0], Tx[1] - target[1]),
);
let phi_R = v_target_vec.angleBetween(
createVector(Rx[0] - target[0], Rx[1] - target[1]),
);
let psi = target_speed * (cos(phi_R) + cos(phi_T));
let apparent_freq = psi / lambda;
print(apparent_freq);
}

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,240 +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, NoopCSIProducer, 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
from where_fi.visualise.server import figures
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, visualise_raw: bool = False) -> None:
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.producer = NoopCSIProducer()
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()
self.custom_figures: dict[
visualise.figures.FigureId, visualise.figures.SpecificFigure
] = {}
def set_producer(self, producer: CSIProducer) -> None:
"""
Set the producer for the application. This is used to change the data source
at runtime.
"""
self.producer = producer
def register_figure(
self,
figure_id: visualise.figures.FigureId,
figure: visualise.figures.SpecificFigure,
) -> None:
self.custom_figures[figure_id] = figure
figures.all_figures[figure_id] = figure
def visualise_data(
self, data: npt.NDArray[Any], dtype: visualise.figures.FigureId
) -> None:
"""
Update a visualisation with the given data. The available visualisations are as
per visualise.server.all_figures.
"""
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:
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, sample.frames, visualiser=self.visualise_data
)
# 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()
def get_proc_sample() -> None:
sample = self.preprocessor.last_sample
if self.visualise_raw and sample is not None:
self.visualise_data(
sample,
visualise.figures.Figure.PROCESSED_CSI,
)
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,23 +1,21 @@
import importlib.util
import logging
from pathlib import Path
import multiprocessing as mp
import numpy as np
import numpy.typing as npt
import torch
import typer
from where_fi.application import CSIApplication
from where_fi.processing.aoa import AoA
from .. import visualise
from ..config import config
from ..processing.aoa import AoA
from ..processing.preprocess import Preprocessor
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
@ -28,65 +26,32 @@ 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()
manager = mp.Manager()
webapp_queue: "mp.Queue[AoA]" = manager.Queue(config.sample_rate)
# Start webapp in background process
webapp = mp.Process(target=visualise.start, args=(webapp_queue,))
webapp.start()
@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)
def callback(antenna_data: npt.NDArray[np.complex128]) -> None:
logger.info(f"Got final CSI data with shape {antenna_data.shape}")
processed = preprocessor.preprocess(antenna_data)
# visualise.add_data(all_data, processed)
aoa.update(processed)
if not webapp_queue.full():
webapp_queue.put(aoa)
app.start()
globals.csi_producer(csi_callback=callback)
logger.info("Finished processing CSI data")
@cli.command()
def run(file: Path, app_name: str = "app") -> None:
"""
Run the application with the given file.
Can be used for running arbitrary CSI applications with non-default data streams
(e.g. from file or environment simulation).
"""
print(file.absolute())
if not file.exists():
print(f"Error: File '{file}' does not exist.")
raise typer.Exit(1)
module_name = file.stem
spec = importlib.util.spec_from_file_location(module_name, str(file))
if spec is None:
print(f"Could not load spec from {file}")
raise typer.Exit(1)
module = importlib.util.module_from_spec(spec)
if spec.loader is None:
print(f"Could not load module from {file}")
raise typer.Exit(1)
try:
spec.loader.exec_module(module)
except Exception as e:
print(f"Failed to execute {file}: {e}")
raise typer.Exit(1) from e
if not hasattr(module, app_name):
print(f"Error: '{app_name}' not defined in the module.")
raise typer.Exit(1)
if not isinstance(module.app, CSIApplication):
print(f"Error: '{app_name}' is not a CSIApplication.")
raise typer.Exit(1)
module.app.producer = globals.csi_producer
module.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,52 +1,28 @@
import logging
import multiprocessing as mp
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
import h5py
import numpy as np
import numpy.typing as npt
import typer
from . import globals
app = typer.Typer()
logger = logging.getLogger(__name__)
def write_to_file(path: Path, queue: "mp.Queue[Any]") -> None:
import h5py
with h5py.File(path, "w") as file:
logger.info("Starting writer")
while True:
data = queue.get()
if data is None:
break
logger.info("Writing data to file")
file.create_dataset(datetime.now().isoformat(), data=data)
logger.info("Writer stopped")
@app.command()
def capture(output_path: Path) -> None:
"""Capture CSI data to a file"""
logger.info(f"Capturing data to {output_path}")
queue: "mp.Queue[None | npt.NDArray[np.complex128]]" = mp.Queue()
writer = threading.Thread(
target=write_to_file,
args=(
output_path,
queue,
),
)
writer.start()
with h5py.File(output_path, "w") as file:
def callback(antenna_data: npt.NDArray[np.complex128]) -> None:
logger.info(f"Got final CSI data with shape {antenna_data.shape}")
queue.put(antenna_data)
file.create_dataset(datetime.now().isoformat(), data=antenna_data)
globals.csi_producer(csi_callback=callback)
queue.put(None)

View File

@ -1,20 +1,32 @@
from functools import partial
from pathlib import Path
from typing import Annotated
import typer
from .. import collection
from ..collection import file, ingest, raytracing
from .. import config as config_mod
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, from_environment: Path | None = None) -> None:
global csi_producer, is_live
if from_file and from_environment:
raise ValueError("Cannot specify both a data file and an environment file")
def main(
from_file: Annotated[
Path | None,
typer.Option(help="Read CSI data from file. Uses live data if not specified"),
] = None,
config: Annotated[
Path, typer.Option(help="Specify path to config file", exists=True)
] = Path("config.yaml"),
) -> None:
global csi_producer, is_live, config_path
if from_file:
csi_producer = file.FileCSIPRoducer(path=from_file)
elif from_environment:
environment = raytracing.Environment.from_config(from_environment)
csi_producer = raytracing.SimulatedCSIProducer(environment)
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
config_mod.load(config)

View File

@ -1,24 +1,9 @@
from typing import Iterator
import numpy as np
import numpy.typing as npt
from . import file, ingest, raytracing
from .protocols import CSIProducer, MergedCSI
from . import file, ingest
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", "raytracing", "NoopCSIProducer", "CSIProducer"]
__all__ = ["file", "ingest", "noop", "CSIProducer", "CSICallback"]

View File

@ -43,8 +43,8 @@ class CSIHeader:
self.num_rx = data[46]
self.num_tx = data[47]
self.num_subcarriers = struct.unpack("I", data[52:56])[0]
self.rssi1: int = struct.unpack("I", data[60:64])[0]
self.rssi2: int = struct.unpack("I", data[64:68])[0]
self.rssi1 = struct.unpack("I", data[60:64])[0]
self.rssi2 = struct.unpack("I", data[64:68])[0]
self.source_mac = struct.unpack("BBBBBB", data[68:74])
self.source_mac_string = "%02x:%02x:%02x:%02x:%02x:%02x" % struct.unpack(
"BBBBBB", data[68:74]
@ -93,14 +93,14 @@ class CSIHeader:
class CSI:
@staticmethod
def parseCsiData(data: bytes, header: CSIHeader) -> npt.NDArray[np.complex64]:
csi_matrix: npt.NDArray[np.complex64] = np.zeros(
def parseCsiData(data: bytes, header: CSIHeader) -> npt.NDArray[np.complex128]:
csi_matrix: npt.NDArray[np.complex128] = np.zeros(
(
header.num_subcarriers,
header.num_rx,
header.num_tx,
),
dtype=np.complex64,
dtype=np.complex128,
)
pos = 0
for j in range(header.num_rx):

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,20 +10,12 @@ 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:
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:
datetime.fromisoformat(key)
@ -37,13 +28,7 @@ class FileCSIPRoducer:
target_offset = datetime.now() - start_time
for key in file:
logger.debug(f"Sending data from {key} at {datetime.now().isoformat()}")
# TODO: add raw frames
yield protocols.MergedCSI(
frames={},
matrix=file[key][:],
)
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}")
@ -52,9 +37,3 @@ class FileCSIPRoducer:
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")

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=3,
)
return feitcsi_status.returncode == 0
except subprocess.TimeoutExpired:
return False
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
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)
# 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)
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()
for receiver in self.receivers:
receiver.register(selector)
if callback:
callback(all_data)
def process_forever(
self,
callback: CSICallback | None = None,
pre_merge_callback: Callable[[dict[Host, CSI]], None] | None = None,
) -> None:
try:
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
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]"
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
self.buffer = CSIAntennaArray(self.receivers)
self.is_live = True
processor = CSIProcessor({r.ip: r.queue for r in receivers})
def __call__(self) -> Iterator[MergedCSI]:
return self.buffer.process_forever()
receiver_processes = [
threading.Thread(target=r.receiver.listen, args=(r.queue,)) for r in receivers
]
def stop(self) -> None:
for r in self.receivers:
r.active = False
self.transmitter.active = False
self.buffer.active = False
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.complex128]], 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

@ -1,259 +0,0 @@
import logging
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterator
import numpy as np
import numpy.typing as npt
from where_fi.collection.protocols import CSIProducer, MergedCSI
from where_fi.config import config
C = 299_792_458
MAX_DEPTH = 2
LOSS_EXPONENT = 0.8
logger = logging.getLogger(__name__)
@dataclass
class PathComponent:
delay: float
phase_offset: float
attenuation: float
class ChannelImpulseResponse:
def __init__(self, path_components: list[PathComponent]) -> None:
self.path_components = path_components
@staticmethod
def delayed(
other: "ChannelImpulseResponse", delay: float, reflect: bool = False
) -> "ChannelImpulseResponse":
new_path_components: list[PathComponent] = []
distance = delay * C
for component in other.path_components:
new_path_components.append(
PathComponent(
delay=component.delay + delay,
phase_offset=component.phase_offset + (np.pi if reflect else 0),
attenuation=component.attenuation
* (1 / (1 + distance) ** LOSS_EXPONENT),
# attenuation=component.attenuation
# * (np.exp(-distance * LOSS_EXPONENT)),
)
)
return ChannelImpulseResponse(new_path_components)
def __add__(self, other: "ChannelImpulseResponse") -> "ChannelImpulseResponse":
return ChannelImpulseResponse(self.path_components + other.path_components)
class PathObject:
def __init__(self, x: float, y: float, z: float) -> None:
self.x = x
self.y = y
self.z = z
self.cir = ChannelImpulseResponse([])
def distance(self, other: "PathObject") -> float:
return (
(self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2
) ** 0.5
def reflect(
self,
incoming_cir: ChannelImpulseResponse,
target: list["PathObject"],
depth: int = 0,
) -> None:
self.cir = self.cir + incoming_cir
if depth > MAX_DEPTH:
return
for obj in target:
if id(obj) == id(self):
continue
distance = self.distance(obj)
assert distance > 0
delay = distance / C
obj.reflect(
ChannelImpulseResponse.delayed(incoming_cir, delay, reflect=True),
target,
depth + 1,
)
class Transmitter(PathObject):
def __init__(self, x: float, y: float, z: float) -> None:
super().__init__(x, y, z)
DELTA_T = 10
GAMMA = np.pi / 4
class Receiver(PathObject):
def __init__(self, x: float, y: float, z: float, ideal: bool = True) -> None:
super().__init__(x, y, z)
self.delta_t = 0 if ideal else DELTA_T
self.gamma = 0 if ideal else GAMMA
def get_cfr(self) -> npt.NDArray[np.complex64]:
"""
Calculate the CSI matrix for this rx-tx pair. This is computed by the Fourier
Transform of the Channel Impulse Response (CIR). The CIR is calculated as in
[1], [2].
To get the FT of the CIR, we use the sifting property of the Dirac delta
[1] - https://tns.thss.tsinghua.edu.cn/wst/docs/pre
[2] - https://dl.acm.org/doi/10.1145/2543581.2543592, Equation 4
"""
cfr = np.zeros(len(config.subcarrier_frequencies), dtype=np.complex64)
for i_sub, f_sub in enumerate(config.subcarrier_frequencies):
cfr[i_sub] = sum(
[
component.attenuation
* np.exp(1j * component.phase_offset)
* np.exp(-1j * 2 * np.pi * f_sub * component.delay)
for component in self.cir.path_components
]
) * np.exp(
1j
* (
2
* np.pi
* (i_sub / len(config.subcarrier_frequencies))
* self.delta_t
+ self.gamma
)
)
return cfr
class Environment:
def __init__(self, objects: list[PathObject]) -> None:
self.transmitters = [obj for obj in objects if isinstance(obj, Transmitter)]
self.receivers = [obj for obj in objects if isinstance(obj, Receiver)]
self.objects = [
obj for obj in objects if obj not in self.transmitters + self.receivers
]
self.count = 0
def move(self) -> None:
self.count += 1
if self.count > 4000:
print("Moving objects")
for obj in self.objects:
obj.x += np.sin(self.count / 1000 * 2 * np.pi) * 2
@staticmethod
def from_config(filename: Path) -> "Environment":
"""
Read a config file that includes a scene description and create an envionment
based on that
The config file should be a text file where each line corresponds to an object.
The first word of each line should be the type of object (TX/RX/OBJ), followed
by the x and y coordinates of the object.
"""
objects: list[PathObject] = []
with open(filename, "r") as f:
for line in f.readlines():
if line.startswith("#"):
continue
parts = line.split(" ")
x, y, z = map(float, parts[1:])
if parts[0] == "TX":
objects.append(Transmitter(x, y, z))
elif parts[0] == "RX":
objects.append(Receiver(x, y, z))
else:
objects.append(PathObject(x, y, z))
return Environment(objects)
def add_awgn(
self, signal: npt.NDArray[np.complex64], snr_dB: float
) -> npt.NDArray[np.complex64]:
signal_power = np.mean(np.abs(signal) ** 2)
snr_linear = 10 ** (snr_dB / 10)
noise_power = signal_power / snr_linear
noise = np.sqrt(noise_power / 2) * (
np.random.randn(*signal.shape) + 1j * np.random.randn(*signal.shape)
)
return signal + noise.astype(np.complex64)
def get_csi(self) -> npt.NDArray[np.complex64]:
"""
Calculate the Channel State Information (CSI) matrix for the simulated
environment.
The returned matrix is of shape (num_subcarriers, num_receivers,
num_transmitters)
This is calculated by finding all paths leading to each receiver, and
calculating the CFR evaluated at each subcarrier.
"""
csi = np.zeros(
(
len(config.subcarrier_frequencies),
len(self.receivers),
len(self.transmitters),
),
dtype=np.complex64,
)
for i_tx, transmitter in enumerate(self.transmitters):
for obj in self.objects + self.receivers + self.transmitters:
obj.cir = ChannelImpulseResponse([])
transmitter.reflect(
ChannelImpulseResponse(
[
PathComponent(
delay=0,
phase_offset=0,
# attenuation=100000000,
attenuation=100,
)
]
),
self.objects + self.receivers,
)
for i_rx, receiver in enumerate(self.receivers):
logger.debug(f"RX {i_rx} paths: {len(receiver.cir.path_components)}")
csi[:, i_rx, i_tx] = self.add_awgn(receiver.get_cfr(), snr_dB=30)
return csi
class SimulatedCSIProducer(CSIProducer):
pass
def __init__(self, environment: Environment) -> None:
self.environment = environment
self.active = True
def __call__(self) -> Iterator[MergedCSI]:
while self.active:
self.environment.move()
start = datetime.now()
csi = self.environment.get_csi()
yield MergedCSI(
frames={},
matrix=csi,
)
time.sleep(
max(
0,
1 / config.collection_sample_rate
- (datetime.now() - start).total_seconds(),
)
)
def stop(self) -> None:
self.active = False

View File

@ -1,5 +1,6 @@
import logging
import sys
from pathlib import Path
import pydantic
import yaml
@ -8,17 +9,22 @@ from . import models
logger = logging.getLogger(__name__)
try:
config = yaml.safe_load(open("config.yaml"))
except FileNotFoundError:
config: models.Config | None = None
def load(path: Path) -> None:
global config
try:
config_yaml = yaml.safe_load(open(path))
except FileNotFoundError:
logger.error(
"No config.yaml file found. Make sure to copy the "
"config.example.yaml file to config.yaml"
)
sys.exit(1)
try:
config = models.Config(**config)
except pydantic.ValidationError as e:
try:
config = models.Config(**config_yaml)
except pydantic.ValidationError as e:
logger.error(f"Invalid config.yaml file: {e}")
sys.exit(1)

View File

@ -1,4 +1,3 @@
from functools import cached_property
from typing import Literal, Self
from pydantic import BaseModel, model_validator
@ -7,6 +6,8 @@ Host = tuple[str, int]
class Preprocessing(BaseModel):
moving_average_alpha: float
class Bandpass(BaseModel):
lowcut: int
highcut: int
@ -15,66 +16,18 @@ class Preprocessing(BaseModel):
def bounds(self) -> tuple[int, int]:
return (self.lowcut, self.highcut)
bandpass: Bandpass | None = None
subcarrier_step: int = 1
denoising: Literal["none", "median", "mean"] = "median"
denoising_period: float = 1
steps: list[
Literal[
"fill_pilots",
"skip_subcarriers",
"remove_agc",
"remove_sfo",
"remove_sto",
"bandpass",
]
] = ["fill_pilots", "skip_subcarriers", "remove_agc", "remove_sfo"]
@model_validator(mode="after")
def skip_in_steps(self) -> Self:
if "skip_subcarriers" not in self.steps and self.subcarrier_step != 1:
raise ValueError(
"subcarrier_step must be 1 if skip_subcarriers is not in steps"
)
return self
@model_validator(mode="after")
def bandpass_in_steps(self) -> Self:
if "bandpass" not in self.steps and self.bandpass is not None:
raise ValueError(
"bandpass must be in preprocessing steps if bandpass configuration is "
"provided"
)
if "bandpass" in self.steps and self.bandpass is None:
raise ValueError(
"bandpass configuration must be provided if bandpass is in "
"preprocessing steps"
)
return self
bandpass: Bandpass
class MUSIC(BaseModel):
eigval_threshold: float
eigval_threshold: int
window_size: int
class Heatmap(BaseModel):
theta_resolution: int
tof_resolution: int
tof_max: float
heatmap: Heatmap
class Antennas(BaseModel):
spacing: float
order: list[tuple[Host, int]]
@property
def count(self) -> int:
return len(self.order)
class Config(BaseModel):
receive_hosts: list[Host]
@ -82,8 +35,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"]
@ -91,11 +43,11 @@ class Config(BaseModel):
preprocessing: Preprocessing
music: MUSIC
@cached_property
@property
def central_freq_hz(self) -> int:
return self.central_freq * 1_000_000
@cached_property
@property
def band(self) -> Literal["2.4", "5", "6"]:
if self.central_freq in range(2412, 2484):
return "2.4"
@ -105,50 +57,12 @@ class Config(BaseModel):
return "6"
raise ValueError(f"{self.central_freq} is not a valid Wi-Fi channel")
@cached_property
def num_guards(self) -> int:
if self.channel_width == 20:
return 7
return 11
@cached_property
def subcarriers(self) -> int:
nulls = {
20: 1,
40: 3,
}
return len(self.subcarrier_frequencies) + nulls[self.channel_width]
@cached_property
def _delta_f_no_skipping(self) -> int:
@property
def delta_f(self) -> int:
if self.frame_format == "HESU":
return 78_125
return 312_500
@cached_property
def delta_f(self) -> int:
return self._delta_f_no_skipping * self.preprocessing.subcarrier_step
@cached_property
def _subcarriers_no_skipping(self) -> list[int]:
used = {
20: (1, 29),
40: (2, 59),
}
subcarrier_indices = list(
range(-used[self.channel_width][1] + 1, -used[self.channel_width][0] + 1)
) + list(range(used[self.channel_width][0], used[self.channel_width][1]))
print(subcarrier_indices)
return [
self.central_freq_hz + i * self._delta_f_no_skipping
for i in subcarrier_indices
]
@cached_property
def subcarrier_frequencies(self) -> list[int]:
return self._subcarriers_no_skipping[:: self.preprocessing.subcarrier_step]
@model_validator(mode="after")
def channels(self) -> Self:
band_start = 2412 if self.band == "2.4" else 5180 if self.band == "5" else 5955

View File

@ -1,29 +1,23 @@
import logging
from datetime import datetime
from typing import Any, Callable
import numpy as np
import numpy.typing as npt
import torch
from ..config import config
from ..visualise import server as visualise
logger = logging.getLogger(__name__)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
torch.set_default_device(device)
class AoA:
def __init__(self) -> None:
self.historical_autocorr = torch.tensor([], dtype=torch.complex64)
self.N_subcarriers = config.subcarriers
self.N_rx = config.antennas.count
self.historical_autocorr = np.array([])
self.N_subcarriers = -1
self.N_rx = -1
self.timestamp = datetime.now()
pass
def smooth(self, data: torch.Tensor) -> torch.Tensor:
def smooth(self, data: npt.NDArray[np.complex128]) -> npt.NDArray[np.complex128]:
assert len(data.shape) == 3
M = data.shape[0] # Number of subcarriers
@ -38,164 +32,99 @@ class AoA:
# This only works with 1 TX antenna (i.e. no MIMO) - see #4 for more details
assert T == 1, "The current implementation only supports 1 TX antenna"
H_n = torch.zeros((N, M // 2, M // 2 + 1), dtype=torch.complex64)
H_n = np.zeros((N, M // 2, M // 2 + 1), dtype=np.complex128)
for i in range(N):
for j in range(M // 2):
H_n[i, j] = data[j : j + M // 2 + 1, i, 0]
H_sm_rows = [torch.hstack(list(H_n[i : i + N // 2 + 1])) for i in range(N // 2)]
H_sm = torch.vstack(H_sm_rows)
H_sm_rows = [np.hstack(H_n[i : i + N // 2 + 1]) for i in range(N // 2)]
H_sm = np.vstack(H_sm_rows)
logger.debug(f"Smoothed: {H_sm.shape}")
return H_sm
def update(self, data: torch.Tensor) -> None:
def update(self, data: npt.NDArray[np.complex128]) -> None:
self.timestamp = datetime.now()
H_sm = self.smooth(data)
logger.debug(f"Calculated smoothed CSI matrix: {H_sm.shape}")
auto_corr = H_sm @ torch.conj(H_sm).T
auto_corr = np.matmul(H_sm, np.conj(H_sm).T)
# This matrix is by definition Hermitian.
# Therefore, all of its eigenvectors are orthogonal.
if len(self.historical_autocorr.shape) <= 1:
self.historical_autocorr = torch.unsqueeze(auto_corr, 0)
if self.historical_autocorr.size == 0:
self.historical_autocorr = np.expand_dims(auto_corr, 0)
else:
self.historical_autocorr = torch.cat(
(self.historical_autocorr, torch.unsqueeze(auto_corr, 0))
self.historical_autocorr = np.append(
self.historical_autocorr, np.expand_dims(auto_corr, 0), axis=0
)
WINDOW_SIZE = config.music.window_size
if self.historical_autocorr.shape[0] > WINDOW_SIZE:
self.historical_autocorr = self.historical_autocorr[-WINDOW_SIZE:]
logger.debug("Finished updating autocorrelation matrix")
# Is the moving average also Hermitian?
R = np.mean(self.historical_autocorr, axis=0)
def steering_vector(self, theta: torch.Tensor, tof: torch.Tensor) -> torch.Tensor:
assert theta.shape == tof.shape
assert len(theta.shape) == 1
N = theta.shape[0]
# The smallest eigenvectors span the noise subspace,
# and the largest span the signal subspace.
eigvals, eigvecs = np.linalg.eigh(R)
self.E_n = eigvecs[:, np.abs(eigvals) < config.music.eigval_threshold]
omega_t: torch.Tensor = torch.exp(-2j * np.pi * config.delta_f * tof)
phi_theta: torch.Tensor = torch.exp(
def steering_vector(
self, theta: float, tof: float
) -> npt.NDArray[np.complexfloating]:
omega_t: npt.NDArray[np.complex128] = np.exp(-2j * np.pi * config.delta_f * tof)
phi_theta: npt.NDArray[np.complex128] = np.exp(
2j
* np.pi
* config.central_freq_hz
* config.antennas.spacing
* (torch.sin(theta))
* (1 - np.cos(theta))
/ 299_792_458
)
assert omega_t.shape == phi_theta.shape == (N,)
omega_t = torch.unsqueeze(omega_t, dim=-1)
phi_theta = torch.unsqueeze(phi_theta, dim=-1)
omega_t = np.expand_dims(omega_t, axis=-1)
phi_theta = np.expand_dims(phi_theta, axis=-1)
assert omega_t.shape == phi_theta.shape == (N, 1)
antenna_v = omega_t ** np.arange(self.N_subcarriers // 2)
phis = phi_theta ** np.arange(self.N_rx // 2)
antenna_v = np.expand_dims(antenna_v, axis=-1)
steering = antenna_v * phis
return steering.T.reshape(-1)
antenna_v = omega_t ** torch.arange(self.N_subcarriers, dtype=torch.float32)
phis = phi_theta ** torch.arange(self.N_rx, dtype=torch.float32)
assert antenna_v.shape == (N, self.N_subcarriers)
assert phis.shape == (N, self.N_rx)
antenna_v = torch.unsqueeze(antenna_v, dim=1)
phis = torch.unsqueeze(phis, dim=-1)
assert antenna_v.shape == (N, 1, self.N_subcarriers)
assert phis.shape == (N, self.N_rx, 1)
steering = torch.bmm(phis, antenna_v)
assert steering.shape == (N, self.N_rx, self.N_subcarriers)
return steering.reshape(N, -1)
def evaluate(
self,
theta: torch.Tensor,
tof: torch.Tensor,
visualiser: None
| Callable[[npt.NDArray[Any], visualise.figures.Figure], None] = None,
) -> torch.Tensor:
R = torch.mean(self.historical_autocorr, dim=0)
# The smallest eigenvectors span the noise subspace,
# and the largest span the signal subspace.
logger.debug(f"Calculating eigenvectors of R: {R.shape}")
eigvals, eigvecs = torch.linalg.eig(R)
if visualiser:
visualiser(eigvals.numpy(), visualise.figures.Figure.MUSIC_EIGENVALUES)
assert isinstance(eigvals, torch.Tensor)
assert isinstance(eigvecs, torch.Tensor)
logger.debug(f"Eigenvalues: {eigvals}")
E_n = eigvecs[:, torch.abs(eigvals) < config.music.eigval_threshold]
logger.info(f"Signal subspace: {E_n.shape}")
steering = torch.unsqueeze(self.steering_vector(theta, tof), dim=-1)
print(steering.shape)
steering_h = torch.conj(steering).permute(0, 2, 1)
E_n = E_n.unsqueeze(0)
E_n_H = torch.conj(E_n).permute(0, 2, 1)
logger.debug(
f"Heatmap multiplication: {steering_h.shape}, {E_n.shape}, "
f"{E_n_H.shape}, {steering.shape}"
)
c: torch.Tensor = 1 / (steering_h @ E_n @ E_n_H @ steering)
return torch.abs(c)[:, 0, 0]
def heatmap(
self,
visualiser: None | Callable[[npt.NDArray[Any], visualise.figures.Figure], None],
) -> npt.NDArray[np.float32]:
thetas = np.linspace(
0, np.pi, config.music.heatmap.theta_resolution, dtype=np.float32
)
tofs = np.linspace(
0,
config.music.heatmap.tof_max,
config.music.heatmap.tof_resolution,
dtype=np.float32,
)
thetas_mesh, tofs_mesh = np.meshgrid(thetas, tofs)
logger.debug(
f"Calculating heatmap with {thetas_mesh.shape} and {tofs_mesh.shape}"
)
evaluated = self.evaluate(
torch.tensor(thetas_mesh.reshape(-1)),
torch.tensor(tofs_mesh.reshape(-1)),
visualiser=visualiser,
)
logger.debug(f"Evaluated heatmap: {evaluated.shape}")
heatmap: npt.NDArray[np.float32] = evaluated.reshape(
config.music.heatmap.tof_resolution,
config.music.heatmap.theta_resolution,
).numpy(force=True)
if visualiser:
visualiser(heatmap, visualise.figures.Figure.AOA_HEATMAP)
return heatmap
def evaluate(self, theta: float, tof: float) -> float:
try:
steering = self.steering_vector(theta, tof)
steering_h = np.conj(steering).T
except Exception as e:
logger.exception(e)
return 0
E_n = self.E_n
E_n_H = np.conj(E_n).T
c = 1 / (0.001 + (steering_h @ E_n @ E_n_H @ steering))
return np.abs(c.real)
def test_smoothing() -> None:
row, col = np.indices((6, 4))
row, col = np.indices((4, 2))
data = row + 1j * col
data = np.expand_dims(data, axis=2)
np.set_printoptions(linewidth=200)
print(data.shape)
aoa = AoA()
aoa.N_subcarriers = 6
aoa.N_rx = 4
smoothed = aoa.smooth(data)
print(smoothed)
H_0 = np.array([[0 + 0j, 0 + 1j, 0 + 2j], [0 + 1j, 0 + 2j, 0 + 3j]])
H_01 = np.vstack([H_0, H_0 + 1])
H_12 = np.vstack([H_0 + 1, H_0 + 2])
expected = np.hstack([H_01, H_12])
print(expected)
assert np.allclose(smoothed, expected)
def test_steering_vector() -> None:
aoa = AoA()
aoa.N_subcarriers = 10
aoa.N_rx = 4
tau = torch.Tensor([1, 0])
theta = torch.Tensor([0, 1])
aoa.N_rx = 2
tau = 1
theta = 0
print(aoa.steering_vector(theta, tau))
assert False

View File

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

View File

@ -1,43 +1,17 @@
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
logger = logging.getLogger(__name__)
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
}
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
}
antenna_average: dict[AntennaIdentifier, float] = {}
RSSI_THRESHOLD = 10
RSSI_THRESHOLD = 8
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
@ -53,18 +27,18 @@ def callback(antenna_data: dict[ingest.Host, ingest.CSI]) -> None:
)
)
for host, csi in antenna_data.items():
long_antenna_average[(host, 0)].append(csi.header.rssi1)
long_antenna_average[(host, 1)].append(csi.header.rssi2)
short_antenna_average[(host, 0)].append(csi.header.rssi1)
short_antenna_average[(host, 1)].append(csi.header.rssi2)
for antenna in range(2):
if (
average(short_antenna_average[(host, antenna)])
> average(long_antenna_average[(host, antenna)]) + RSSI_THRESHOLD
):
unplugged.add((host, antenna))
antenna_average[(host, 0)] = (
antenna_average.get((host, 0), csi.header.rssi1) * 0.99
+ csi.header.rssi1 * 0.01
)
antenna_average[(host, 1)] = (
antenna_average.get((host, 1), csi.header.rssi2) * 0.99
+ csi.header.rssi2 * 0.01
)
if csi.header.rssi1 > antenna_average[(host, 0)] + RSSI_THRESHOLD:
unplugged.add((host, 0))
if csi.header.rssi2 > antenna_average[(host, 1)] + RSSI_THRESHOLD:
unplugged.add((host, 1))
if prev_unplugged != unplugged:
if len(prev_unplugged) > len(unplugged):
logger.info(f"Antenna plugged in: {prev_unplugged - unplugged}")
@ -79,4 +53,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 +0,0 @@
generated
frontend/src/grpc

View File

@ -1,11 +0,0 @@
all: 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/
frontend/src/grpc: protos/*.proto
mkdir -p frontend/src/grpc
cd frontend && find ../protos/ -name "*.proto" | xargs npx protoc --ts_out=src/grpc -I../protos/

View File

@ -0,0 +1,149 @@
import io
import logging
import multiprocessing as mp
import time
from datetime import datetime
from typing import Generator
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
from flask import Flask, Response, render_template, request
from flask_sock import Sock
from simple_websocket import Server
from .. import config
from ..processing.aoa import AoA
matplotlib.use("agg")
app = Flask(__name__)
sock = Sock(app)
aoa_queue = None
logger = logging.getLogger(__name__)
data: npt.NDArray[np.complex128] = np.array([], dtype=complex)
aoa: AoA = AoA()
@app.route("/preprocessed")
def preprocessed() -> str:
return render_template("preprocessed.html")
Subscriber = Server
subscriber_settings: dict[Subscriber, tuple[int, int, int]] = {}
@sock.route("/data")
def get_data(sock: Subscriber) -> None:
while True:
msg = sock.receive()
if len(msg.split()) != 3:
break
subcarrier, rx, tx = map(int, msg.split())
subscriber_settings[sock] = (subcarrier, rx, tx)
def add_data(
raw_data: npt.NDArray[np.complex128], new_data: npt.NDArray[np.complex128]
) -> None:
if config.VISUALISE_RAW:
magn = np.abs(raw_data)
phase = np.angle(raw_data)
new_mag = np.abs(new_data)
new_phase = np.angle(new_data)
fig, axs = plt.subplots(2, 2)
axs[0, 0].plot(magn[:, 0, 0], c="b")
axs[0, 0].plot(magn[:, 1, 0], c="orange")
axs[1, 0].plot(new_mag[:, 0, 0], c="b")
axs[1, 0].plot(new_mag[:, 1, 0], c="orange")
axs[0, 1].plot(phase[:, 0, 0], c="b")
axs[0, 1].plot(phase[:, 1, 0], c="orange")
axs[1, 1].plot(new_phase[:, 0, 0], c="b")
axs[1, 1].plot(new_phase[:, 1, 0], c="orange")
fig.savefig("/tmp/plot.png")
plt.close(fig)
global data
if data.size == 0:
data = np.expand_dims(new_data, axis=0)
else:
data = np.concat([data, np.expand_dims(new_data, axis=0)], axis=0)
# Only keep latest 100 entries
if data.shape[0] > 100:
data = data[-100:]
to_remove: list[Subscriber] = []
for subscriber in subscriber_settings:
try:
subcarrier, rx, tx = subscriber_settings[subscriber]
subscriber.send(new_data.real[subcarrier, rx, tx])
except Exception as e:
to_remove.append(subscriber)
print(e)
for subscriber in to_remove:
del subscriber_settings[subscriber]
def make_heatmap(aoa: AoA, max_tof: float) -> io.BytesIO:
logger.info(f"Making heatmap with aoa of {aoa.timestamp}")
fig = plt.figure()
ax = fig.add_axes([0, 0, 1, 1], polar=True)
r = np.linspace(0, max_tof, 100) # Radius values
theta = np.linspace(0, np.pi, 50) # Angle values
R, Theta = np.meshgrid(r, theta) # Create a 2D grid of r and theta
# Compute the function values
Z = np.log(np.vectorize(aoa.evaluate)(Theta, R))
ax.pcolormesh(Theta, R, Z, edgecolors="face")
buf = io.BytesIO()
fig.savefig(buf, format="jpeg")
plt.close(fig)
buf.seek(0)
return buf
def gather_aoa(max_tof: float) -> Generator[bytes, None, None]:
assert aoa_queue is not None
prev_frame = datetime.now()
while True:
while (datetime.now() - prev_frame).total_seconds() < 1 / config.HEATMAP_FPS:
time.sleep(0.01)
while not aoa_queue.empty():
logger.debug("Receiving from aoa pipe")
aoa = aoa_queue.get()
prev_frame = datetime.now()
logger.debug(f"Generating heatmap of time {aoa.timestamp}")
buf = make_heatmap(aoa, max_tof)
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + buf.read() + b"\r\n")
buf.close()
@app.route("/aoa_tof")
def aoa_tof() -> Response:
max_tof_str = request.args.get("max_tof")
try:
max_tof = float(max_tof_str)
except Exception:
max_tof = 5e-8
return Response(
gather_aoa(max_tof), mimetype="multipart/x-mixed-replace; boundary=frame"
)
def start(conn: "mp.Queue[AoA]") -> None:
global app, aoa_queue
aoa_queue = conn
app.run(debug=True, use_reloader=False, host="0.0.0.0")
logger.info("Visualisation server shut down")

View File

@ -1,66 +0,0 @@
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: echo_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: echo_service
connect_timeout: 0.25s
type: logical_dns
# HTTP/2 support
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
lb_policy: round_robin
load_assignment:
cluster_name: cluster_0
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 50051

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

@ -1,9 +0,0 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

View File

@ -1 +0,0 @@
* text=auto eol=lf

View File

@ -1,31 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
.vite/
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@ -1,7 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -1,24 +0,0 @@
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Where-Fi Visualizer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +0,0 @@
{
"name": "visualise",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@protobuf-ts/grpcweb-transport": "^2.9.4",
"buffer": "^6.0.3",
"google-protobuf": "^3.21.4",
"grpc-web": "^1.5.0",
"plotly.js-dist": "^3.0.1",
"plotly.js-dist-min": "^3.0.1",
"vue": "^3.5.13",
"vuetify": "^3.7.12"
},
"devDependencies": {
"@protobuf-ts/plugin": "^2.9.4",
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.13.1",
"@types/plotly.js": "^2.35.2",
"@types/plotly.js-dist-min": "^2.3.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"jiti": "^2.4.2",
"npm-run-all2": "^7.0.2",
"prettier": "^3.4.2",
"typescript": "~5.7.3",
"vite": "^6.0.11",
"vite-plugin-vue-devtools": "^7.7.1",
"vue-tsc": "^2.2.0"
}
}

View File

@ -1,42 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import PlotList from './components/PlotList.vue'
import Figure from './components/Figure.vue'
import { Figure as FigureType } from './grpc/figure'
var plots = ref<FigureType[]>([])
var paused = ref(false)
function addPlot(fig: FigureType) {
console.log('Plot added', fig)
plots.value.push(fig)
console.log(plots)
}
function removePlot(fig: FigureType) {
console.log('Plot removed', fig)
plots.value = plots.value.filter((x) => x.uuid !== fig.uuid)
console.log(plots)
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$props: {
onClick?: (e: MouseEvent) => void
}
}
}
</script>
<template>
<v-app>
<PlotList @figure-selected="addPlot" @figure-deselected="removePlot" />
<v-main>
<v-container fluid>
<Figure v-for="plot in plots" :key="plot.uuid" :figure="plot" :paused="paused" />
</v-container>
<v-fab location="right bottom" color="secondary" @click="paused = !paused" icon app>
<v-icon :icon="paused ? 'mdi-play' : 'mdi-pause'"></v-icon>
</v-fab>
</v-main>
</v-app>
</template>

View File

@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -1,133 +0,0 @@
<script setup lang="ts">
import * as Plotly from 'plotly.js-dist-min'
import { Figure, FigureData } from '../grpc/figure'
import { FigureServiceClient } from '../grpc/figure.client'
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'
import { host } from '../connect'
import { onMounted, ref } from 'vue'
import { onBeforeUnmount } from 'vue'
import { computed } from 'vue'
const { figure, paused = false } = defineProps<{ figure: Figure; paused?: boolean }>()
const cancel = ref<boolean>(false)
function reshape(data: number[], width: number) {
if (data.length % width != 0) {
throw new Error('Data length is not divisible by width')
}
const height = data.length / width
const result: number[][] = new Array(height)
for (let i = 0; i < height; i++) {
result[i] = data.slice(i * width, (i + 1) * width)
}
return result
}
const data = ref<FigureData | null>(null)
const plotData = computed(() => {
if (data.value) {
switch (data.value.figure.oneofKind) {
case 'line':
return data.value.figure.line.lines.map((line) => ({
x: line.x.length == 0 ? undefined : line.x,
y: line.y,
type: 'scatter' as const,
name: line.label,
color: line.color ? line.color : undefined,
}))
case 'heatmap':
const xmin = data.value.figure.heatmap.xMin ?? 0
const xmax = data.value.figure.heatmap.xMax ?? data.value.figure.heatmap.width
const ymin = data.value.figure.heatmap.yMin ?? 0
const ymax = data.value.figure.heatmap.yMax ?? data.value.figure.heatmap.height
const x = Array(data.value.figure.heatmap.width)
.fill(0)
.map((_, i) => xmin + ((xmax - xmin) * i) / data.value.figure.heatmap.width)
const y = Array(data.value.figure.heatmap.height)
.fill(0)
.map((_, i) => ymin + ((ymax - ymin) * i) / data.value.figure.heatmap.height)
return [
{
z: reshape(data.value.figure.heatmap.data, data.value.figure.heatmap.width),
x: x,
y: y,
type: 'heatmap' as const,
colorscale: 'Blues',
reversescale: true,
},
]
case 'histogram':
const bin_start = data.value.figure.histogram.bins.slice(0, -1)
const bin_end = data.value.figure.histogram.bins.slice(1)
const bin_center = bin_start.map((start, i) => (start + bin_end[i]) / 2)
const bin_width = bin_start.map((start, i) => bin_end[i] - start)
console.log('Histogram data:', bin_center, bin_width)
console.log(
'Sizes:',
bin_center.length,
bin_width.length,
data.value.figure.histogram.data.length,
)
return data.value.figure.histogram.data.map((series) => ({
x: bin_center,
y: series.data,
width: bin_width,
type: 'bar' as const,
}))
}
}
return []
})
const layout = {
title: { text: figure.title },
xaxis: { title: { text: figure.xLabel }, type: figure.logx ? 'log' : undefined },
yaxis: { title: { text: figure.yLabel }, type: figure.logy ? 'log' : undefined },
height: 700,
}
function updateGraph() {
console.log('Updating graph with', plotData.value)
Plotly.newPlot(figure.uuid, plotData.value, layout, { responsive: true })
}
onMounted(async () => {
console.log('Mounted')
updateGraph()
const transport = new GrpcWebFetchTransport({ baseUrl: host })
const figureService = new FigureServiceClient(transport)
const stream = figureService.getFigureUpdate({ uuid: figure.uuid })
for await (const response of stream.responses) {
if (cancel.value) {
console.log('Stopping stream...')
break
}
console.log(`Got new value for ${figure.uuid}:`, response)
if (!paused) {
data.value = response
updateGraph()
}
}
})
onBeforeUnmount(() => {
console.log('Component is about to unmount, stopping stream...')
cancel.value = true
})
</script>
<template>
<v-row class="w-100">
<v-col cols="12">
<v-card class="w-100">
<v-card-title>{{ figure.title }}</v-card-title>
<v-card-text>
<div :id="figure.uuid" class="w-100" />
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>

View File

@ -1,83 +0,0 @@
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { Figure } from '../grpc/figure'
import { FigureServiceClient } from '../grpc/figure.client'
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'
import { host } from '../connect'
const plots = ref<Figure[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
var emit = (_: any, ...args: any[]) => {
console.log('No emit function set', args)
}
export default defineComponent({
emits: ['figure-selected', 'figure-deselected'],
data() {
return {
selected: [],
loading: loading,
plots: plots,
error,
}
},
setup(_, ctx) {
console.log('Setup')
emit = ctx.emit
},
async mounted() {
const transport = new GrpcWebFetchTransport({ baseUrl: host })
const figureService = new FigureServiceClient(transport)
const stream = figureService.getFigure({})
for await (const response of stream.responses) {
console.log('Got new figure', response)
plots.value.push(response)
}
console.log('Done fetching figures')
loading.value = false
},
watch: {
selected(newVal: string[], oldVal: string[]) {
const added = newVal.filter((x) => !oldVal.includes(x))
const removed = oldVal.filter((x) => !newVal.includes(x))
for (const uuid of added) {
console.log('Selected', uuid)
const plot = plots.value.find((x) => x.uuid === uuid)
emit('figure-selected', plot)
}
for (const uuid of removed) {
console.log('Deselected', uuid)
const plot = plots.value.find((x) => x.uuid === uuid)
emit('figure-deselected', plot)
}
},
},
})
</script>
<template>
<v-navigation-drawer permanent width="250">
<v-list>
<v-list-item title="Available figures" subtitle="Choose some of the figures below" />
</v-list>
<v-divider />
<v-list v-model:selected="selected" select-strategy="leaf" nav>
<v-list-item v-for="plot in plots" :key="plot.uuid" :value="plot.uuid">
<v-list-item-title>{{ plot.title }}</v-list-item-title>
<v-list-item-subtitle class="text-high-emphasis"
>{{ plot.xLabel }} w/ {{ plot.yLabel }}</v-list-item-subtitle
>
</v-list-item>
<v-skeleton-loader
type="list-item-two-line"
v-for="n in 3"
:key="n"
v-if="loading"
></v-skeleton-loader>
</v-list>
</v-navigation-drawer>
</template>
<style scoped></style>

View File

@ -1 +0,0 @@
export const host = 'http://localhost:8080'

View File

@ -1,24 +0,0 @@
import App from './App.vue'
import { createApp } from 'vue'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
const vuetify = createVuetify({
components,
directives,
theme: { defaultTheme: 'dark' },
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
})
createApp(App).use(vuetify).mount('#app')

View File

@ -1,16 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
},
"vueCompilerOptions": {
"fallthroughAttributes": true,
"strictTemplates": true
}
}

View File

@ -1,11 +0,0 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -1,19 +0,0 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@ -1,15 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})

View File

@ -1,32 +0,0 @@
syntax = "proto3";
import "figure_type/line.proto";
import "figure_type/heatmap.proto";
import "figure_type/histogram.proto";
message Figure {
string uuid = 1;
string title = 2;
string x_label = 3;
string y_label = 4;
bool logx = 5;
bool logy = 6;
}
message FigureData {
string uuid = 1;
oneof figure {
LineChartData line = 2;
HeatmapData heatmap = 3;
HistogramData histogram = 4;
}
}
message FigureRequest {}
message FigureDataRequest { string uuid = 1; }
service FigureService {
rpc GetFigure(FigureRequest) returns (stream Figure) {}
rpc GetFigureUpdate(FigureDataRequest) returns (stream FigureData) {}
}

View File

@ -1,13 +0,0 @@
syntax = "proto3";
message HeatmapData {
string uuid = 1;
repeated float data = 2;
uint32 width = 3;
uint32 height = 4;
string cmap = 5;
float x_min = 6;
float x_max = 7;
float y_min = 8;
float y_max = 9;
}

View File

@ -1,9 +0,0 @@
syntax = "proto3";
message HistogramSeries { repeated float data = 1; }
message HistogramData {
string uuid = 1;
repeated HistogramSeries data = 2;
repeated float bins = 3;
}

View File

@ -1,18 +0,0 @@
syntax = "proto3";
message LineChartData {
message Line {
string uuid = 1;
repeated float x = 2;
repeated float y = 3;
string label = 4;
string color = 5;
}
repeated Line lines = 1;
}
message LineChartPoint {
string uuid = 1;
float x = 2;
float y = 3;
}

View File

@ -1,109 +0,0 @@
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
import grpc
import numpy as np
import numpy.typing as npt
from ..generated import figure_pb2, figure_pb2_grpc
from . import figures
@dataclass
class VisualiserData:
data: npt.NDArray[Any]
dtype: figures.FigureId
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.all_figures.values():
for figure in figure_group.figures:
yield figure
def GetFigureUpdate(
self, request: figure_pb2.FigureDataRequest, context: grpc.ServicerContext
) -> Generator[figure_pb2.FigureData, None, None]:
self.logger.info(
f"Received request for figure data stream for figure {request.uuid}"
)
with self.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)
try:
while context.is_active():
try:
yield q.get(timeout=1)
except queue.Empty:
pass
finally:
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.figure_server = FigureServer()
def add_data(
self, dtype: figures.FigureId, new_data: npt.NDArray[np.complex128]
) -> None:
self.logger.debug(f"Adding data to figure server {dtype}")
if dtype not in figures.all_figures:
self.logger.error(f"Figure {dtype} not found")
return
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.1)
self.add_data(data.dtype, data.data)
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

@ -1,316 +0,0 @@
import uuid
from abc import ABC, abstractmethod
from enum import Enum
from functools import reduce
from typing import Any, Callable, Sequence, cast
import numpy as np
import numpy.typing as npt
from ...config import config
from .generated import figure_pb2
from .generated.figure_type import heatmap_pb2, histogram_pb2, line_pb2
FigureUpdate = dict[str, figure_pb2.FigureData]
class SpecificFigure(ABC):
"""
This is a base class for all figures that can be visualised through the
visualisation server.
"""
figures: Sequence[figure_pb2.Figure]
@abstractmethod
def __init__(self) -> None:
raise NotImplementedError
@abstractmethod
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
"""
Update the figure with new data.
The exact shape and format of the data passed as an argument will differ
depending on the exact figure being plotted.
The return value should be a dictionary with the UUID of the figure as the key
and the new data as the value.
This allows one class to update multiple figures at once (e.g. a figure plotting
the phase and amplitude of a signal).
"""
raise NotImplementedError
class SimpleLineChart:
"""Helper class to create a simple line chart with one or more lines.
This allows generalising the creation of line charts, e.g. as in PerAntennaFigure.
"""
def __init__(self, title: str, x_label: str, y_label: str) -> None:
self.figure = figure_pb2.Figure(
uuid=str(uuid.uuid4()),
title=title,
x_label=x_label,
y_label=y_label,
)
def update(
self,
new_data: npt.NDArray[np.complex128],
labels: Sequence[str],
x_values: None | npt.NDArray[np.float64] = None,
) -> 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,
)
for i in range(new_data.shape[0])
]
return {
self.figure.uuid: figure_pb2.FigureData(
uuid=self.figure.uuid,
line=line_pb2.LineChartData(lines=lines),
)
}
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 LabelledMultiLineChart(SpecificFigure):
def __init__(
self,
figures: Sequence[SimpleLineChart],
funcs: list[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
labels: Sequence[str],
) -> None:
self.charts = figures
self.figures = [figure.figure for figure in figures]
self.funcs = funcs
self.labels = labels
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
data_by_antenna = new_data[:, :, 0].T
updates = [
figure.update(func(data_by_antenna), labels=self.labels)
for func, figure in zip(self.funcs, self.charts, strict=True)
]
return reduce((lambda a, b: a | b), updates)
class PerAntennaFigure(LabelledMultiLineChart):
"""A figure that plots data for each antenna separately.
This allows creating multiple figures, each having one line per antenna.
Each figure can have a different function that is used to transform the data before
plotting. For example, can be used to generate plots for the phase and amplitude of
a signal.
"""
def __init__(
self,
figures: Sequence[SimpleLineChart],
funcs: list[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
) -> None:
super().__init__(
figures, funcs, [f"Antenna {i + 1}" for i in range(config.antennas.count)]
)
class MusicEigenvalueHistogram(SpecificFigure):
def __init__(self) -> None:
self.figure = figure_pb2.Figure(
uuid=str(uuid.uuid4()),
title="AoA Eigenvalues",
x_label="Eigenvalue",
y_label="Frequency of occurrence",
logx=True,
)
self.figures = [self.figure]
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
magn = np.abs(new_data)
# The frontend library used for plotting doesn't support logarithmic
# binning[1], so we have to do it manually.
# Using base 10 log for the bins to make the plots easier to comprehend.
# [1] - https://github.com/plotly/plotly.js/issues/1844
bins = cast(
npt.NDArray[np.float32],
np.logspace(np.log10(magn.min()), np.log10(magn.max()), 10),
)
hist, _ = np.histogram(magn, bins=bins)
series = [histogram_pb2.HistogramSeries(data=hist)]
histogram = histogram_pb2.HistogramData(data=series, bins=bins)
return {self.figure.uuid: figure_pb2.FigureData(histogram=histogram)}
class HeatmapFigure(SpecificFigure):
def __init__(self) -> None:
self.figure = figure_pb2.Figure(
uuid=str(uuid.uuid4()),
title="Angle of arrival Heatmap",
x_label="Angle of arrival",
y_label="Time of Flight",
)
self.figures = [self.figure]
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
heatmap = heatmap_pb2.HeatmapData(
uuid=self.figure.uuid,
data=new_data.flatten(),
width=new_data.shape[1],
height=new_data.shape[0],
x_min=0,
x_max=np.pi,
y_min=0,
y_max=config.music.heatmap.tof_max,
)
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
MAGN_ANALYSIS = 6
def figure_class(self) -> SpecificFigure:
return all_figures[self]
FigureId = str | Figure
all_figures: dict[FigureId, SpecificFigure] = {
Figure.RAW_CSI: PerAntennaFigure(
[
SimpleLineChart("Raw CSI Phase", "Subcarrier", "Phase"),
SimpleLineChart("Raw CSI Amplitude", "Subcarrier", "Amplitude"),
],
[np.angle, np.abs],
),
Figure.UNWRAPPED_PHASE: PerAntennaFigure(
[SimpleLineChart("Unwrapped CSI Phase", "Subcarrier", "Phase")], [lambda x: x]
),
Figure.PROCESSED_CSI: PerAntennaFigure(
[
SimpleLineChart("Processed CSI Phase", "Subcarrier", "Phase"),
SimpleLineChart("Processed CSI Amplitude", "Subcarrier", "Amplitude"),
],
[np.angle, np.abs],
),
Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(),
Figure.AOA_HEATMAP: HeatmapFigure(),
Figure.PHASE_ANALYSIS: RandomVariable("Phase"),
Figure.MAGN_ANALYSIS: RandomVariable("Magnitude"),
}

View File

@ -0,0 +1,42 @@
const plotElement = document.getElementById("plot");
const layout = {
title: "Real-Time Complex Data Visualization",
xaxis: { title: "Time (ms)" },
yaxis: { title: "Value" },
};
Plotly.newPlot(
plotElement,
[
{
x: [],
y: [],
type: "scatter",
mode: "lines+markers", // Line + markers
},
],
layout,
);
const socket = new WebSocket("ws://" + location.host + "/data");
socket.onopen = function () {
console.log("Connected to the server");
socket.send("0 0 0");
};
socket.addEventListener("message", function (msg) {
Plotly.extendTraces(
plotElement,
{
x: [[msg.timeStamp]],
y: [[msg.data]],
},
[0],
300,
);
});
function changeSubcarrier() {
const subcarrier = document.getElementById("subcarrier").value;
socket.send(subcarrier + " 0 0");
}

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>
</head>
<body>
<div class="container">
<h1>Preprocessing</h1>
<div id="plot"></div>
<script src="{{url_for('static', filename='plot.js')}}"></script>
<input type="number" id="subcarrier" class="form-control" placeholder="Choose subcarrier" onchange="changeSubcarrier()" value="0">
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>