Compare commits

..
3 Commits
Author SHA1 Message Date
Xavier Olive c62b3b48fc remove cache for windows 2022-12-29 18:53:11 +01:00
Xavier Olive ae01f95ff5 fix typing 2022-12-29 18:46:43 +01:00
Xavier Olive c3839d861c minimal attempt for 2.4M demodulation 2022-12-28 00:13:03 +01:00
14 changed files with 544 additions and 65 deletions
+3
View File
@@ -0,0 +1,3 @@
[flake8]
max-line-length = 80
extend-ignore = E203, E302
+9 -43
View File
@@ -1,63 +1,29 @@
# This workflows will upload a Python Package using Twine when a release is created # This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: publish name: PyPI Publish
on: on:
release: release:
types: [created] types: [created]
workflow_dispatch:
env:
POETRY_VERSION: "1.3.1"
PYTHON_VERSION: "3.10"
jobs: jobs:
deploy: deploy:
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v2
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v2
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: "3.x"
- name: Install and configure Poetry
uses: snok/install-poetry@v1.3.3
with:
version: ${{ env.POETRY_VERSION }}
virtualenvs-create: true
virtualenvs-in-project: true
- name: Display Python version
run: poetry run python -c "import sys; print(sys.version)"
- name: Build packages
run: poetry build
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry run pip install --upgrade pip python -m pip install --upgrade pip
poetry run pip install twine pip install setuptools wheel twine
- name: Build and publish - name: Build and publish
env: env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: | run: |
poetry run twine upload dist/*.whl python setup.py sdist bdist_wheel
twine upload dist/*
- name: Build and publish (source)
if: "startsWith(runner.os, 'ubuntu') and env.PYTHON_VERSION == '3.10'"
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
poetry run twine upload dist/*.tar.gz
+11 -8
View File
@@ -27,14 +27,19 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
# Poetry cache depends on OS, Python version and Poetry version.
- name: Cache Poetry cache
uses: actions/cache@v3
with:
path: ~/.cache/pypoetry
key: poetry-cache-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ env.POETRY_VERSION }}
# virtualenv cache should depends on OS, Python version and `poetry.lock` (and optionally workflow files). # virtualenv cache should depends on OS, Python version and `poetry.lock` (and optionally workflow files).
- name: Cache Packages - name: Cache Packages
uses: actions/cache@v3 uses: actions/cache@v3
if: runner.os != 'windows-latest' if: matrix.os != 'windows-latest'
with: with:
path: | path: ~/.local
~/.local
.venv
key: poetry-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }} key: poetry-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }}
- name: Add poetry to windows path - name: Add poetry to windows path
@@ -49,14 +54,12 @@ jobs:
virtualenvs-create: true virtualenvs-create: true
virtualenvs-in-project: true virtualenvs-in-project: true
- name: Display Python version
run: poetry run python -c "import sys; print(sys.version)"
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install poetry install
- name: Type checking - name: Type checking
if: ${{ env.PYTHON_VERSION != '3.7' }}
run: | run: |
poetry run mypy pyModeS poetry run mypy pyModeS
@@ -65,7 +68,7 @@ jobs:
poetry run pytest tests --cov --cov-report term-missing poetry run pytest tests --cov --cov-report term-missing
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request_target' }} if: ${{ github.event_name != 'pull_request_target' && env.PYTHON_VERSION == '3.10' }}
uses: codecov/codecov-action@v2 uses: codecov/codecov-action@v2
with: with:
env_vars: PYTHON_VERSION env_vars: PYTHON_VERSION
+6
View File
@@ -5,6 +5,11 @@ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
# Cython
pyModeS/decoder/flarm/decode.c
pyModeS/extra/demod2400/core.c
pyModeS/c_common.c
# C extensions # C extensions
*.so *.so
@@ -64,3 +69,4 @@ target/
.venv .venv
env/ env/
venv/ venv/
+10 -10
View File
@@ -37,16 +37,16 @@ def build() -> None:
extra_compile_args=compile_args, extra_compile_args=compile_args,
include_dirs=["pyModeS/decoder/flarm"], include_dirs=["pyModeS/decoder/flarm"],
), ),
# Extension( Extension(
# "pyModeS.extra.demod2400.core", "pyModeS.extra.demod2400.core",
# [ [
# "pyModeS/extra/demod2400/core.pyx", "pyModeS/extra/demod2400/core.pyx",
# "pyModeS/extra/demod2400/demod2400.c", "pyModeS/extra/demod2400/demod2400.c",
# ], ],
# extra_compile_args=compile_args, extra_compile_args=compile_args,
# include_dirs=["pyModeS/extra/demod2400"], include_dirs=["pyModeS/extra/demod2400"],
# libraries=["m"], libraries=["m"],
# ), ),
] ]
ext_modules = cythonize( ext_modules = cythonize(
+3
View File
@@ -0,0 +1,3 @@
from .core import demod2400
__all__ = ["demod2400"]
+4
View File
@@ -0,0 +1,4 @@
import numpy as np
import numpy.typing as npt
def demod2400(data: npt.NDArray[np.uint16], timestamp: float): ...
+49
View File
@@ -0,0 +1,49 @@
from libc.stdint cimport uint16_t, uint8_t
import numpy as np
from ...c_common cimport crc, df
cdef extern from "demod2400.h":
int demodulate2400(uint16_t *data, uint8_t *msg, int len_data, int* len_msg)
def demod2400(uint16_t[:] data, float timestamp):
cdef uint8_t[:] msg_bin
cdef int i = 0, j, length, crc_msg = 1
cdef long size = data.shape[0]
msg_bin = np.zeros(14, dtype=np.uint8)
while i < size:
j = demodulate2400(&data[i], &msg_bin[0], size-i, &length)
if j == 0:
yield dict(
# 1 sample data = 2 IQ samples (hence 2*)
timestamp=timestamp + 2.*i/2400000.,
payload=None,
crc=None,
index=i,
)
return
i += j
msg_clip = np.asarray(msg_bin)[:length]
msg = "".join(f"{elt:02X}" for elt in msg_clip)
crc_msg = crc(msg)
# if df(msg) != 17 or crc_msg == 0:
if crc_msg == 0:
yield dict(
# 1 sample data = 2 IQ samples (hence 2*)
timestamp=timestamp + 2.*i/2400000.,
payload=msg,
crc=crc_msg,
index=i,
)
yield dict(
# 1 sample data = 2 IQ samples (hence 2*)
timestamp=timestamp + 2.*i/2400000.,
payload=None,
crc=None,
index=i,
)
return
+256
View File
@@ -0,0 +1,256 @@
#include "demod2400.h"
static inline int slice_phase0(uint16_t *m)
{
return 5 * m[0] - 3 * m[1] - 2 * m[2];
}
static inline int slice_phase1(uint16_t *m)
{
return 4 * m[0] - m[1] - 3 * m[2];
}
static inline int slice_phase2(uint16_t *m)
{
return 3 * m[0] + m[1] - 4 * m[2];
}
static inline int slice_phase3(uint16_t *m)
{
return 2 * m[0] + 3 * m[1] - 5 * m[2];
}
static inline int slice_phase4(uint16_t *m)
{
return m[0] + 5 * m[1] - 5 * m[2] - m[3];
}
int demodulate2400(uint16_t *mag, uint8_t *msg, int len_mag, int *len_msg)
{
uint32_t j;
for (j = 0; j < len_mag / 2 - 300; j++)
{ // SALE
uint16_t *preamble = &mag[j];
int high;
uint32_t base_signal, base_noise;
// quick check: we must have a rising edge 0->1 and a falling edge 12->13
if (!(preamble[0] < preamble[1] && preamble[12] > preamble[13]))
continue;
if (preamble[1] > preamble[2] && // 1
preamble[2] < preamble[3] && preamble[3] > preamble[4] && // 3
preamble[8] < preamble[9] && preamble[9] > preamble[10] && // 9
preamble[10] < preamble[11])
{ // 11-12
// peaks at 1,3,9,11-12: phase 3
high = (preamble[1] + preamble[3] + preamble[9] + preamble[11] + preamble[12]) / 4;
base_signal = preamble[1] + preamble[3] + preamble[9];
base_noise = preamble[5] + preamble[6] + preamble[7];
}
else if (preamble[1] > preamble[2] && // 1
preamble[2] < preamble[3] && preamble[3] > preamble[4] && // 3
preamble[8] < preamble[9] && preamble[9] > preamble[10] && // 9
preamble[11] < preamble[12])
{ // 12
// peaks at 1,3,9,12: phase 4
high = (preamble[1] + preamble[3] + preamble[9] + preamble[12]) / 4;
base_signal = preamble[1] + preamble[3] + preamble[9] + preamble[12];
base_noise = preamble[5] + preamble[6] + preamble[7] + preamble[8];
}
else if (preamble[1] > preamble[2] && // 1
preamble[2] < preamble[3] && preamble[4] > preamble[5] && // 3-4
preamble[8] < preamble[9] && preamble[10] > preamble[11] && // 9-10
preamble[11] < preamble[12])
{ // 12
// peaks at 1,3-4,9-10,12: phase 5
high = (preamble[1] + preamble[3] + preamble[4] + preamble[9] + preamble[10] + preamble[12]) / 4;
base_signal = preamble[1] + preamble[12];
base_noise = preamble[6] + preamble[7];
}
else if (preamble[1] > preamble[2] && // 1
preamble[3] < preamble[4] && preamble[4] > preamble[5] && // 4
preamble[9] < preamble[10] && preamble[10] > preamble[11] && // 10
preamble[11] < preamble[12])
{ // 12
// peaks at 1,4,10,12: phase 6
high = (preamble[1] + preamble[4] + preamble[10] + preamble[12]) / 4;
base_signal = preamble[1] + preamble[4] + preamble[10] + preamble[12];
base_noise = preamble[5] + preamble[6] + preamble[7] + preamble[8];
}
else if (preamble[2] > preamble[3] && // 1-2
preamble[3] < preamble[4] && preamble[4] > preamble[5] && // 4
preamble[9] < preamble[10] && preamble[10] > preamble[11] && // 10
preamble[11] < preamble[12])
{ // 12
// peaks at 1-2,4,10,12: phase 7
high = (preamble[1] + preamble[2] + preamble[4] + preamble[10] + preamble[12]) / 4;
base_signal = preamble[4] + preamble[10] + preamble[12];
base_noise = preamble[6] + preamble[7] + preamble[8];
}
else
{
// no suitable peaks
continue;
}
// Check for enough signal
if (base_signal * 2 < 3 * base_noise) // about 3.5dB SNR
continue;
// Check that the "quiet" bits 6,7,15,16,17 are actually quiet
if (preamble[5] >= high ||
preamble[6] >= high ||
preamble[7] >= high ||
preamble[8] >= high ||
preamble[14] >= high ||
preamble[15] >= high ||
preamble[16] >= high ||
preamble[17] >= high ||
preamble[18] >= high)
{
continue;
}
// // try all phases
// Modes.stats_current.demod_preambles++;
// bestmsg = NULL; bestscore = -2; bestphase = -1;
for (int try_phase = 4; try_phase <= 8; ++try_phase)
{
uint16_t *pPtr;
int phase, i, bytelen;
// Decode all the next 112 bits, regardless of the actual message
// size. We'll check the actual message type later
pPtr = &mag[j + 19] + (try_phase / 5);
phase = try_phase % 5;
bytelen = MODES_LONG_MSG_BYTES;
for (i = 0; i < bytelen; ++i)
{
uint8_t theByte = 0;
switch (phase)
{
case 0:
theByte =
(slice_phase0(pPtr) > 0 ? 0x80 : 0) |
(slice_phase2(pPtr + 2) > 0 ? 0x40 : 0) |
(slice_phase4(pPtr + 4) > 0 ? 0x20 : 0) |
(slice_phase1(pPtr + 7) > 0 ? 0x10 : 0) |
(slice_phase3(pPtr + 9) > 0 ? 0x08 : 0) |
(slice_phase0(pPtr + 12) > 0 ? 0x04 : 0) |
(slice_phase2(pPtr + 14) > 0 ? 0x02 : 0) |
(slice_phase4(pPtr + 16) > 0 ? 0x01 : 0);
phase = 1;
pPtr += 19;
break;
case 1:
theByte =
(slice_phase1(pPtr) > 0 ? 0x80 : 0) |
(slice_phase3(pPtr + 2) > 0 ? 0x40 : 0) |
(slice_phase0(pPtr + 5) > 0 ? 0x20 : 0) |
(slice_phase2(pPtr + 7) > 0 ? 0x10 : 0) |
(slice_phase4(pPtr + 9) > 0 ? 0x08 : 0) |
(slice_phase1(pPtr + 12) > 0 ? 0x04 : 0) |
(slice_phase3(pPtr + 14) > 0 ? 0x02 : 0) |
(slice_phase0(pPtr + 17) > 0 ? 0x01 : 0);
phase = 2;
pPtr += 19;
break;
case 2:
theByte =
(slice_phase2(pPtr) > 0 ? 0x80 : 0) |
(slice_phase4(pPtr + 2) > 0 ? 0x40 : 0) |
(slice_phase1(pPtr + 5) > 0 ? 0x20 : 0) |
(slice_phase3(pPtr + 7) > 0 ? 0x10 : 0) |
(slice_phase0(pPtr + 10) > 0 ? 0x08 : 0) |
(slice_phase2(pPtr + 12) > 0 ? 0x04 : 0) |
(slice_phase4(pPtr + 14) > 0 ? 0x02 : 0) |
(slice_phase1(pPtr + 17) > 0 ? 0x01 : 0);
phase = 3;
pPtr += 19;
break;
case 3:
theByte =
(slice_phase3(pPtr) > 0 ? 0x80 : 0) |
(slice_phase0(pPtr + 3) > 0 ? 0x40 : 0) |
(slice_phase2(pPtr + 5) > 0 ? 0x20 : 0) |
(slice_phase4(pPtr + 7) > 0 ? 0x10 : 0) |
(slice_phase1(pPtr + 10) > 0 ? 0x08 : 0) |
(slice_phase3(pPtr + 12) > 0 ? 0x04 : 0) |
(slice_phase0(pPtr + 15) > 0 ? 0x02 : 0) |
(slice_phase2(pPtr + 17) > 0 ? 0x01 : 0);
phase = 4;
pPtr += 19;
break;
case 4:
theByte =
(slice_phase4(pPtr) > 0 ? 0x80 : 0) |
(slice_phase1(pPtr + 3) > 0 ? 0x40 : 0) |
(slice_phase3(pPtr + 5) > 0 ? 0x20 : 0) |
(slice_phase0(pPtr + 8) > 0 ? 0x10 : 0) |
(slice_phase2(pPtr + 10) > 0 ? 0x08 : 0) |
(slice_phase4(pPtr + 12) > 0 ? 0x04 : 0) |
(slice_phase1(pPtr + 15) > 0 ? 0x02 : 0) |
(slice_phase3(pPtr + 17) > 0 ? 0x01 : 0);
phase = 0;
pPtr += 20;
break;
}
msg[i] = theByte;
if (i == 0)
{
switch (msg[0] >> 3)
{
case 0:
case 4:
case 5:
case 11:
bytelen = MODES_SHORT_MSG_BYTES;
*len_msg = MODES_SHORT_MSG_BYTES;
break;
case 16:
case 17:
case 18:
case 20:
case 21:
case 24:
*len_msg = MODES_LONG_MSG_BYTES;
break;
default:
bytelen = 1; // unknown DF, give up immediately
break;
}
}
}
return j + 1;
}
// Score the mode S message and see if it's any good.
// score = scoreModesMessage(msg, i*8);
// if (score > bestscore) {
// // new high score!
// bestmsg = msg;
// bestscore = score;
// bestphase = try_phase;
// // swap to using the other buffer so we don't clobber our demodulated data
// // (if we find a better result then we'll swap back, but that's OK because
// // we no longer need this copy if we found a better one)
// msg = (msg == msg1) ? msg2 : msg1;
// }
}
return 0;
}
+11
View File
@@ -0,0 +1,11 @@
#ifndef __DEMOD_2400_H__
#define __DEMOD_2400_H__
#define MODES_LONG_MSG_BYTES 14
#define MODES_SHORT_MSG_BYTES 7
#include <stdint.h>
int demodulate2400(uint16_t *mag, uint8_t *msg, int len_mag, int* len_msg);
#endif
+133
View File
@@ -0,0 +1,133 @@
import time
import traceback
import numpy as np
import pyModeS as pms
from pyModeS.extra.demod2400 import demod2400
try:
import rtlsdr # type: ignore
except ImportError:
print(
"------------------------------------------------------------------------"
)
print(
"! Warning: pyrtlsdr not installed (required for using RTL-SDR devices) !"
)
print(
"------------------------------------------------------------------------"
)
modes_frequency = 1090e6
sampling_rate = 2.4e6
buffer_size = 16 * 16384
read_size = buffer_size / 2
class RtlReader(object):
def __init__(self, **kwargs):
super(RtlReader, self).__init__()
self.signal_buffer = [] # amplitude of the sample only
self.sdr = rtlsdr.RtlSdr()
self.sdr.sample_rate = sampling_rate
self.sdr.center_freq = modes_frequency
self.sdr.gain = "auto"
self.debug = kwargs.get("debug", False)
self.raw_pipe_in = None
self.stop_flag = False
self.exception_queue = None
def _process_buffer(self):
"""process raw IQ data in the buffer"""
# Mode S messages
messages = []
data = (np.array(self.signal_buffer) * 65535).astype(np.uint16)
for s in demod2400(data, self.timestamp):
if s["payload"] is None:
idx = s["index"]
# reset the buffer
self.signal_buffer = self.signal_buffer[idx:]
self.timestamp = s["timestamp"]
break
if self._check_msg(s["payload"]):
messages.append([s["payload"], time.time()]) # s["timestamp"]])
if self.debug:
self._debug_msg(s["payload"])
self.timestamp = s["timestamp"]
return messages
def _check_msg(self, msg):
df = pms.df(msg)
msglen = len(msg)
if df == 17 and msglen == 28:
if pms.crc(msg) == 0:
return True
elif df in [20, 21] and msglen == 28:
return True
elif df in [4, 5, 11] and msglen == 14:
return True
def _debug_msg(self, msg):
df = pms.df(msg)
msglen = len(msg)
if df == 17 and msglen == 28:
print(msg, pms.icao(msg), df, pms.crc(msg))
print(pms.tell(msg))
elif df in [20, 21] and msglen == 28:
print(msg, pms.icao(msg), df)
elif df in [4, 5, 11] and msglen == 14:
print(msg, pms.icao(msg), df)
else:
# print("[*]", msg)
pass
def _read_callback(self, data, rtlsdr_obj):
amp = np.absolute(data)
self.signal_buffer.extend(amp.tolist())
if len(self.signal_buffer) >= buffer_size:
messages = self._process_buffer()
self.handle_messages(messages)
def handle_messages(self, messages):
"""re-implement this method to handle the messages"""
for msg, t in messages:
# print("%15.9f %s" % (t, msg))
pass
def stop(self, *args, **kwargs):
self.sdr.close()
def run(self, raw_pipe_in=None, stop_flag=None, exception_queue=None):
self.raw_pipe_in = raw_pipe_in
self.exception_queue = exception_queue
self.stop_flag = stop_flag
try:
# raise RuntimeError("test exception")
self.timestamp = time.time()
while True:
data = self.sdr.read_samples(read_size)
self._read_callback(data, None)
except Exception as e:
tb = traceback.format_exc()
if self.exception_queue is not None:
self.exception_queue.put(tb)
raise e
if __name__ == "__main__":
import signal
rtl = RtlReader()
signal.signal(signal.SIGINT, rtl.stop)
rtl.debug = True
rtl.run()
+3 -3
View File
@@ -9,7 +9,7 @@ import signal
import multiprocessing import multiprocessing
from pyModeS.streamer.decode import Decode from pyModeS.streamer.decode import Decode
from pyModeS.streamer.screen import Screen from pyModeS.streamer.screen import Screen
from pyModeS.streamer.source import NetSource, RtlSdrSource # , RtlSdrSource24 from pyModeS.streamer.source import NetSource, RtlSdrSource, RtlSdrSource24
def main(): def main():
@@ -100,8 +100,8 @@ def main():
source = NetSource(host=SERVER, port=PORT, rawtype=DATATYPE) source = NetSource(host=SERVER, port=PORT, rawtype=DATATYPE)
elif SOURCE == "rtlsdr": elif SOURCE == "rtlsdr":
source = RtlSdrSource() source = RtlSdrSource()
# elif SOURCE == "rtlsdr24": elif SOURCE == "rtlsdr24":
# source = RtlSdrSource24() source = RtlSdrSource24()
recv_process = multiprocessing.Process( recv_process = multiprocessing.Process(
target=source.run, args=(raw_pipe_in, stop_flag, exception_queue) target=source.run, args=(raw_pipe_in, stop_flag, exception_queue)
+45
View File
@@ -1,6 +1,7 @@
import pyModeS as pms import pyModeS as pms
from pyModeS.extra.tcpclient import TcpClient from pyModeS.extra.tcpclient import TcpClient
from pyModeS.extra.rtlreader import RtlReader from pyModeS.extra.rtlreader import RtlReader
from pyModeS.extra.demod2400.rtlreader import RtlReader as RtlReader24
class NetSource(TcpClient): class NetSource(TcpClient):
@@ -89,3 +90,47 @@ class RtlSdrSource(RtlReader):
} }
) )
self.reset_local_buffer() self.reset_local_buffer()
class RtlSdrSource24(RtlReader24):
def __init__(self):
super(RtlSdrSource24, self).__init__()
self.reset_local_buffer()
def reset_local_buffer(self):
self.local_buffer_adsb_msg = []
self.local_buffer_adsb_ts = []
self.local_buffer_commb_msg = []
self.local_buffer_commb_ts = []
def handle_messages(self, messages):
if self.stop_flag.value is True:
self.stop()
return
for msg, t in messages:
if len(msg) < 28: # only process long messages
continue
df = pms.df(msg)
if df == 17 or df == 18:
self.local_buffer_adsb_msg.append(msg)
self.local_buffer_adsb_ts.append(t)
elif df == 20 or df == 21:
self.local_buffer_commb_msg.append(msg)
self.local_buffer_commb_ts.append(t)
else:
continue
if len(self.local_buffer_adsb_msg) > 1:
self.raw_pipe_in.send(
{
"adsb_ts": self.local_buffer_adsb_ts,
"adsb_msg": self.local_buffer_adsb_msg,
"commb_ts": self.local_buffer_commb_ts,
"commb_msg": self.local_buffer_commb_msg,
}
)
self.reset_local_buffer()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "pyModeS" name = "pyModeS"
version = "2.12" version = "2.11"
description = "Python Mode-S and ADS-B Decoder" description = "Python Mode-S and ADS-B Decoder"
authors = ["Junzi Sun <j.sun-1@tudelft.nl>"] authors = ["Junzi Sun <j.sun-1@tudelft.nl>"]
license = "GNU GPL v3" license = "GNU GPL v3"