kopia lustrzana https://github.com/projecthorus/radiosonde_auto_rx
1129 wiersze
38 KiB
Python
1129 wiersze
38 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# radiosonde_auto_rx - Utility Classes & Functions
|
|
#
|
|
# Copyright (C) 2018 Mark Jessop <vk5qi@rfhead.net>
|
|
# Released under MIT License
|
|
#
|
|
|
|
from __future__ import division, print_function
|
|
import codecs
|
|
import fcntl
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import requests
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import numpy as np
|
|
import semver
|
|
import shutil
|
|
from dateutil.parser import parse
|
|
from datetime import datetime, timedelta
|
|
from math import radians, degrees, sin, cos, atan2, sqrt, pi
|
|
from queue import Queue
|
|
from . import __version__ as auto_rx_version
|
|
|
|
|
|
# List of binaries we check for on startup
|
|
REQUIRED_RS_UTILS = [
|
|
"dft_detect",
|
|
"dfm09mod",
|
|
"m10mod",
|
|
"rs41mod",
|
|
"rs92mod",
|
|
"fsk_demod",
|
|
"mk2a1680mod",
|
|
"lms6Xmod",
|
|
"meisei100mod",
|
|
"imet54mod",
|
|
"mp3h1mod",
|
|
"m20mod",
|
|
"imet4iq",
|
|
"mts01mod",
|
|
"iq_dec",
|
|
"weathex301d"
|
|
]
|
|
|
|
_timeout_cmd = None
|
|
|
|
def timeout_cmd():
|
|
global _timeout_cmd
|
|
if not _timeout_cmd:
|
|
t=shutil.which("gtimeout")
|
|
if t:
|
|
_timeout_cmd = "gtimeout -k 30 "
|
|
else:
|
|
if not shutil.which("timeout"):
|
|
logging.critical("timeout command-line tool not present in system. try installing gtimeout.")
|
|
sys.exit(1)
|
|
else:
|
|
_timeout_cmd = "timeout -k 30 "
|
|
return _timeout_cmd
|
|
|
|
def check_rs_utils(config):
|
|
""" Check the required RS decoder binaries exist
|
|
Currently we just check there is a file present - we don't check functionality.
|
|
"""
|
|
for _file in REQUIRED_RS_UTILS:
|
|
if not os.path.isfile(_file):
|
|
logging.critical("Binary %s does not exist - did you run build.sh?" % _file)
|
|
return False
|
|
_ = timeout_cmd()
|
|
|
|
return True
|
|
|
|
|
|
AUTORX_MAIN_VERSION_URL = "https://raw.githubusercontent.com/projecthorus/radiosonde_auto_rx/master/auto_rx/autorx/__init__.py"
|
|
AUTORX_TESTING_VERSION_URL = "https://raw.githubusercontent.com/projecthorus/radiosonde_auto_rx/testing/auto_rx/autorx/__init__.py"
|
|
|
|
|
|
def get_autorx_version(version_url=AUTORX_MAIN_VERSION_URL):
|
|
""" Parse an auto_rx __init__ file and return the version """
|
|
try:
|
|
_r = requests.get(version_url, timeout=5)
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Version - Error determining version from URL {version_url}: {str(e)}"
|
|
)
|
|
return None
|
|
|
|
try:
|
|
for _line in _r.text.split("\n"):
|
|
if _line.startswith("__version__"):
|
|
_main_version = _line.split("=")[1]
|
|
_main_version = _main_version.replace('"', "").strip()
|
|
return _main_version
|
|
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Version - Error extracting version from url {version_url}: {str(e)}."
|
|
)
|
|
return None
|
|
|
|
|
|
def check_autorx_versions(current_version=auto_rx_version):
|
|
"""
|
|
Check the current auto_rx version against the latest main and testing branches.
|
|
Returns a string 'Latest' if this is the latest version, or the newer version if
|
|
there is an update available. Returns 'Unknown' if the version could not be determined.
|
|
"""
|
|
|
|
# Grab the current versions
|
|
_main_branch_version = get_autorx_version(AUTORX_MAIN_VERSION_URL)
|
|
_testing_branch_version = get_autorx_version(AUTORX_TESTING_VERSION_URL)
|
|
|
|
if (_main_branch_version is None) or (_testing_branch_version is None):
|
|
logging.error("Version - Could not determine latest versions.")
|
|
return "Unknown"
|
|
|
|
# First, determine if the user is on a main or beta (testing) version
|
|
# We use the presence of a '-' in the version name to figure this out.
|
|
if "-" in current_version:
|
|
# User is on a testing branch version.
|
|
# Compare against the testing branch version - when a release is made, the testing
|
|
# branch will have the same version as the main branch, then will advance.
|
|
if semver.compare(_testing_branch_version, current_version) == 1:
|
|
# Newer testing version available.
|
|
return _testing_branch_version
|
|
else:
|
|
# User is on latest testing branch version.
|
|
return "Latest"
|
|
else:
|
|
# User is running the main branch
|
|
if semver.compare(_main_branch_version, current_version) == 1:
|
|
return _main_branch_version
|
|
else:
|
|
return "Latest"
|
|
|
|
# Should never get here.
|
|
return "Unknown"
|
|
|
|
|
|
def version_startup_check():
|
|
""" Helper function to check version on startup """
|
|
_newer_version = check_autorx_versions()
|
|
if _newer_version == "Latest":
|
|
logging.info(f"Version - Local Version: {auto_rx_version} - Up to date!")
|
|
elif _newer_version == "Unknown":
|
|
# An error will have already been printed out for this case.
|
|
pass
|
|
else:
|
|
logging.info(
|
|
f"Version - Local Version: {auto_rx_version} - Newer Version Available! ({_newer_version})"
|
|
)
|
|
|
|
|
|
def strip_sonde_serial(serial):
|
|
""" Strip off any leading sonde type that may be present in a serial number """
|
|
|
|
# Look for serials with prefixes matching the following known sonde types.
|
|
_re = re.compile("^(DFM|M10|M20|IMET|IMET5|IMET54|MRZ|LMS6|IMS100|RS11G|MTS01|WXR)-")
|
|
|
|
# If we have a match, return the trailing part of the serial, re-adding
|
|
# any - separators if they exist.
|
|
if _re.match(serial):
|
|
return "-".join(serial.split("-")[1:])
|
|
else:
|
|
# Otherwise, it's probably a RS41 or RS92
|
|
return serial
|
|
|
|
|
|
def short_type_lookup(type_name):
|
|
""" Lookup a short type name to a more descriptive name """
|
|
|
|
if type_name.startswith("RS41"):
|
|
if type_name == "RS41":
|
|
return "Vaisala RS41"
|
|
else:
|
|
return "Vaisala " + type_name
|
|
elif type_name.startswith("RS92"):
|
|
if type_name == "RS92":
|
|
return "Vaisala RS92"
|
|
else:
|
|
return "Vaisala " + type_name
|
|
elif type_name.startswith("DFM"):
|
|
return "Graw " + type_name
|
|
elif type_name.startswith("M10"):
|
|
return "Meteomodem M10"
|
|
elif type_name.startswith("M20"):
|
|
return "Meteomodem M20"
|
|
elif type_name == "LMS6":
|
|
return "Lockheed Martin LMS6-403"
|
|
elif type_name == "MK2LMS":
|
|
return "Lockheed Martin LMS6-1680"
|
|
elif type_name == "IMET":
|
|
return "Intermet Systems iMet-1/4"
|
|
elif type_name == "IMET-XDATA":
|
|
return "Intermet Systems iMet-1/4 + XDATA"
|
|
elif type_name == "IMET5":
|
|
return "Intermet Systems iMet-5x"
|
|
elif type_name == "MEISEI":
|
|
return "Meisei iMS-100/RS-11"
|
|
elif type_name == "IMS100":
|
|
return "Meisei iMS-100"
|
|
elif type_name == "RS11G":
|
|
return "Meisei RS-11G"
|
|
elif type_name == "MRZ":
|
|
return "Meteo-Radiy MRZ"
|
|
elif type_name == "MTS01":
|
|
return "Meteosis MTS01"
|
|
elif type_name == "WXR301":
|
|
return "Weathex WxR-301D"
|
|
else:
|
|
return "Unknown"
|
|
|
|
def short_short_type_lookup(type_name):
|
|
""" Lookup a short type name to a more descriptive, but short name """
|
|
|
|
if type_name.startswith("RS41"):
|
|
if type_name == "RS41":
|
|
return "RS41"
|
|
else:
|
|
return type_name
|
|
elif type_name.startswith("RS92"):
|
|
if type_name == "RS92":
|
|
return "RS92"
|
|
else:
|
|
return type_name
|
|
elif type_name.startswith("DFM"):
|
|
return type_name
|
|
elif type_name.startswith("M10"):
|
|
return "M10"
|
|
elif type_name.startswith("M20"):
|
|
return "M20"
|
|
elif type_name == "LMS6":
|
|
return "LMS6-403"
|
|
elif type_name == "MK2LMS":
|
|
return "LMS6-1680"
|
|
elif type_name == "IMET":
|
|
return "iMet-1/4"
|
|
elif type_name == "IMET-XDATA":
|
|
return "iMet-1/4"
|
|
elif type_name == "IMET5":
|
|
return "iMet-5x"
|
|
elif type_name == "MEISEI":
|
|
return "iMS-100"
|
|
elif type_name == "IMS100":
|
|
return "iMS-100"
|
|
elif type_name == "RS11G":
|
|
return "RS-11G"
|
|
elif type_name == "MRZ":
|
|
return "MRZ"
|
|
elif type_name == "MTS01":
|
|
return "MTS01"
|
|
elif type_name == "WXR301":
|
|
return "WXR301"
|
|
else:
|
|
return "Unknown"
|
|
|
|
|
|
def generate_aprs_id(sonde_data):
|
|
""" Generate an APRS-compatible object name based on the radiosonde type and ID. """
|
|
|
|
_object_name = None
|
|
|
|
# Use the radiosonde ID as the object ID
|
|
if ("RS92" in sonde_data["type"]) or ("RS41" in sonde_data["type"]):
|
|
# We can use the Vaisala sonde ID directly.
|
|
_object_name = sonde_data["id"].strip()
|
|
elif "DFM" in sonde_data["type"]:
|
|
# As per agreement with other radiosonde decoding software developers, we will now
|
|
# use the DFM serial number verbatim in the APRS ID, prefixed with 'D'.
|
|
# For recent DFM sondes, this will result in a object ID of: Dyynnnnnn
|
|
# Where yy is the manufacture year, and nnnnnn is a sequential serial.
|
|
# Older DFMs may have only a 6-digit ID of Dnnnnnn.
|
|
# Mark J - 2019-12-29
|
|
|
|
# Split out just the serial number part of the ID, and cast it to an int
|
|
# This acts as another check that we have been provided with a numeric serial.
|
|
_dfm_id = int(sonde_data["id"].split("-")[-1])
|
|
|
|
# Create the object name
|
|
_object_name = "D%d" % _dfm_id
|
|
|
|
# Convert to upper-case hex, and take the last 5 nibbles.
|
|
_id_suffix = hex(_dfm_id).upper()[-5:]
|
|
|
|
elif "M10" in sonde_data["type"]:
|
|
# Use the generated id same as dxlAPRS
|
|
_object_name = sonde_data["aprsid"]
|
|
|
|
elif "M20" in sonde_data["type"]:
|
|
# Generate the M20 ID based on the first two hex digits of the
|
|
# raw hexadecimal id, followed by the last decimal section.
|
|
# Why we do this and not just use the three hex bytes, nobody knows...
|
|
if 'rawid' in sonde_data:
|
|
_object_name = "ME" + sonde_data['rawid'].split('_')[1][:2] + sonde_data["id"].split("-")[-1]
|
|
else:
|
|
_object_name = None
|
|
|
|
elif "IMET" in sonde_data["type"]:
|
|
# Use the last 5 characters of the unique ID we have generated.
|
|
_object_name = "IMET" + sonde_data["id"][-5:]
|
|
|
|
|
|
elif "LMS" in sonde_data["type"]:
|
|
# Use the last 5 hex digits of the sonde ID.
|
|
_id_suffix = int(sonde_data["id"].split("-")[1])
|
|
_id_hex = hex(_id_suffix).upper()
|
|
_object_name = "LMS6" + _id_hex[-5:]
|
|
|
|
elif "WXR" in sonde_data["type"]:
|
|
# Use the last 6 hex digits of the sonde ID.
|
|
_id_suffix = int(sonde_data["id"].split("-")[1])
|
|
_id_hex = hex(_id_suffix).upper()
|
|
_object_name = "WXR" + _id_hex[-6:]
|
|
|
|
elif "MEISEI" in sonde_data["type"] or "IMS100" in sonde_data["type"] or "RS11G" in sonde_data["type"]:
|
|
# Convert the serial number to an int
|
|
_meisei_id = int(sonde_data["id"].split("-")[-1])
|
|
_id_suffix = hex(_meisei_id).upper().split("0X")[1]
|
|
# Clip to 6 hex digits, in case we end up with more for some reason.
|
|
if len(_id_suffix) > 6:
|
|
_id_suffix = _id_suffix[-6:]
|
|
_object_name = "IMS" + _id_suffix
|
|
|
|
elif "MRZ" in sonde_data["type"]:
|
|
# Concatenate the two portions of the serial number, convert to an int,
|
|
# then take the 6 least-significant hex digits as our ID, prefixed with 'MRZ'.
|
|
# e.g. MRZ-5667-39155 -> 566739155 -> 21C7C0D3 -> MRZC7C0D3
|
|
_mrz_id_parts = sonde_data["id"].split("-")
|
|
_mrz_id = int(_mrz_id_parts[1] + _mrz_id_parts[2])
|
|
_id_hex = "%06x" % _mrz_id
|
|
if len(_id_hex) > 6:
|
|
_id_hex = _id_hex[-6:]
|
|
_object_name = "MRZ" + _id_hex.upper()
|
|
|
|
elif "MTS01" in sonde_data["type"]:
|
|
# Split out just the serial number part of the ID, and cast it to an int
|
|
# This acts as another check that we have been provided with a numeric serial.
|
|
_mts_id = int(sonde_data["id"].split("-")[-1])
|
|
|
|
# Convert to upper-case hex, and take the last 6 nibbles.
|
|
_id_suffix = hex(_mts_id).upper()[-6:]
|
|
|
|
# Create the object name
|
|
_object_name = "MTS" + _id_suffix
|
|
|
|
|
|
# New Sonde types will be added in here.
|
|
else:
|
|
# Unknown sonde type, don't know how to handle this yet.
|
|
_object_name = None
|
|
|
|
# Pad or clip to 9 characters
|
|
if len(_object_name) > 9:
|
|
_object_name = _object_name[:9]
|
|
elif len(_object_name) < 9:
|
|
_object_name = _object_name + " " * (9 - len(_object_name))
|
|
|
|
return _object_name
|
|
|
|
|
|
def readable_timedelta(duration: timedelta):
|
|
"""
|
|
Convert a timedelta into a readable string.
|
|
From: https://codereview.stackexchange.com/a/245215
|
|
"""
|
|
data = {}
|
|
data["months"], remaining = divmod(duration.total_seconds(), 2_592_000)
|
|
data["days"], remaining = divmod(remaining, 86_400)
|
|
data["hours"], remaining = divmod(remaining, 3_600)
|
|
data["minutes"], _foo = divmod(remaining, 60)
|
|
|
|
time_parts = [f"{round(value)} {name}" for name, value in data.items() if value > 0]
|
|
if time_parts:
|
|
return " ".join(time_parts)
|
|
else:
|
|
return "below 1 second"
|
|
|
|
|
|
class AsynchronousFileReader(threading.Thread):
|
|
""" Asynchronous File Reader
|
|
Helper class to implement asynchronous reading of a file
|
|
in a separate thread. Pushes read lines on a queue to
|
|
be consumed in another thread.
|
|
see https://github.com/soxofaan/asynchronousfilereader
|
|
MIT License
|
|
Copyright (c) 2014 Stefaan Lippens
|
|
"""
|
|
|
|
def __init__(self, fd, queue=None, autostart=True):
|
|
self._fd = fd
|
|
if queue is None:
|
|
queue = Queue()
|
|
self.queue = queue
|
|
self.running = True
|
|
|
|
threading.Thread.__init__(self)
|
|
|
|
if autostart:
|
|
self.start()
|
|
|
|
def run(self):
|
|
"""
|
|
The body of the thread: read lines and put them on the queue.
|
|
"""
|
|
while self.running:
|
|
line = self._fd.readline()
|
|
if not line:
|
|
break
|
|
self.queue.put(line)
|
|
|
|
def eof(self):
|
|
"""
|
|
Check whether there is no more content to expect.
|
|
"""
|
|
return not self.is_alive() and self.queue.empty()
|
|
|
|
def stop(self):
|
|
"""
|
|
Stop the running thread.
|
|
"""
|
|
self.running = False
|
|
|
|
def readlines(self):
|
|
"""
|
|
Get currently available lines.
|
|
"""
|
|
while not self.queue.empty():
|
|
yield self.queue.get()
|
|
|
|
|
|
#
|
|
# Peak Search Utilities, used by the sonde scanning functions.
|
|
#
|
|
|
|
|
|
def detect_peaks(
|
|
x,
|
|
mph=None,
|
|
mpd=1,
|
|
threshold=0,
|
|
edge="rising",
|
|
kpsh=False,
|
|
valley=False,
|
|
show=False,
|
|
ax=None,
|
|
):
|
|
|
|
"""Detect peaks in data based on their amplitude and other features.
|
|
|
|
Author: Marcos Duarte, https://github.com/demotu/BMC
|
|
|
|
Parameters
|
|
----------
|
|
x : 1D array_like
|
|
data.
|
|
mph : {None, number}, optional (default = None)
|
|
detect peaks that are greater than minimum peak height.
|
|
mpd : positive integer, optional (default = 1)
|
|
detect peaks that are at least separated by minimum peak distance (in
|
|
number of data).
|
|
threshold : positive number, optional (default = 0)
|
|
detect peaks (valleys) that are greater (smaller) than `threshold`
|
|
in relation to their immediate neighbors.
|
|
edge : {None, 'rising', 'falling', 'both'}, optional (default = 'rising')
|
|
for a flat peak, keep only the rising edge ('rising'), only the
|
|
falling edge ('falling'), both edges ('both'), or don't detect a
|
|
flat peak (None).
|
|
kpsh : bool, optional (default = False)
|
|
keep peaks with same height even if they are closer than `mpd`.
|
|
valley : bool, optional (default = False)
|
|
if True (1), detect valleys (local minima) instead of peaks.
|
|
show : bool, optional (default = False)
|
|
if True (1), plot data in matplotlib figure.
|
|
ax : a matplotlib.axes.Axes instance, optional (default = None).
|
|
|
|
Returns
|
|
-------
|
|
ind : 1D array_like
|
|
indeces of the peaks in `x`.
|
|
|
|
Notes
|
|
-----
|
|
The detection of valleys instead of peaks is performed internally by simply
|
|
negating the data: `ind_valleys = detect_peaks(-x)`
|
|
|
|
The function can handle NaN's
|
|
|
|
See this IPython Notebook [1]_.
|
|
|
|
References
|
|
----------
|
|
.. [1] http://nbviewer.ipython.org/github/demotu/BMC/blob/master/notebooks/DetectPeaks.ipynb
|
|
|
|
Examples
|
|
--------
|
|
>>> from detect_peaks import detect_peaks
|
|
>>> x = np.random.randn(100)
|
|
>>> x[60:81] = np.nan
|
|
>>> # detect all peaks and plot data
|
|
>>> ind = detect_peaks(x, show=True)
|
|
>>> print(ind)
|
|
|
|
>>> x = np.sin(2*np.pi*5*np.linspace(0, 1, 200)) + np.random.randn(200)/5
|
|
>>> # set minimum peak height = 0 and minimum peak distance = 20
|
|
>>> detect_peaks(x, mph=0, mpd=20, show=True)
|
|
|
|
>>> x = [0, 1, 0, 2, 0, 3, 0, 2, 0, 1, 0]
|
|
>>> # set minimum peak distance = 2
|
|
>>> detect_peaks(x, mpd=2, show=True)
|
|
|
|
>>> x = np.sin(2*np.pi*5*np.linspace(0, 1, 200)) + np.random.randn(200)/5
|
|
>>> # detection of valleys instead of peaks
|
|
>>> detect_peaks(x, mph=0, mpd=20, valley=True, show=True)
|
|
|
|
>>> x = [0, 1, 1, 0, 1, 1, 0]
|
|
>>> # detect both edges
|
|
>>> detect_peaks(x, edge='both', show=True)
|
|
|
|
>>> x = [-2, 1, -2, 2, 1, 1, 3, 0]
|
|
>>> # set threshold = 2
|
|
>>> detect_peaks(x, threshold = 2, show=True)
|
|
"""
|
|
|
|
x = np.atleast_1d(x).astype("float64")
|
|
if x.size < 3:
|
|
return np.array([], dtype=int)
|
|
if valley:
|
|
x = -x
|
|
# find indices of all peaks
|
|
dx = x[1:] - x[:-1]
|
|
# handle NaN's
|
|
indnan = np.where(np.isnan(x))[0]
|
|
if indnan.size:
|
|
x[indnan] = np.inf
|
|
dx[np.where(np.isnan(dx))[0]] = np.inf
|
|
ine, ire, ife = np.array([[], [], []], dtype=int)
|
|
if not edge:
|
|
ine = np.where((np.hstack((dx, 0)) < 0) & (np.hstack((0, dx)) > 0))[0]
|
|
else:
|
|
if edge.lower() in ["rising", "both"]:
|
|
ire = np.where((np.hstack((dx, 0)) <= 0) & (np.hstack((0, dx)) > 0))[0]
|
|
if edge.lower() in ["falling", "both"]:
|
|
ife = np.where((np.hstack((dx, 0)) < 0) & (np.hstack((0, dx)) >= 0))[0]
|
|
ind = np.unique(np.hstack((ine, ire, ife)))
|
|
# handle NaN's
|
|
if ind.size and indnan.size:
|
|
# NaN's and values close to NaN's cannot be peaks
|
|
ind = ind[
|
|
np.in1d(
|
|
ind, np.unique(np.hstack((indnan, indnan - 1, indnan + 1))), invert=True
|
|
)
|
|
]
|
|
# first and last values of x cannot be peaks
|
|
if ind.size and ind[0] == 0:
|
|
ind = ind[1:]
|
|
if ind.size and ind[-1] == x.size - 1:
|
|
ind = ind[:-1]
|
|
# remove peaks < minimum peak height
|
|
if ind.size and mph is not None:
|
|
ind = ind[x[ind] >= mph]
|
|
# remove peaks - neighbors < threshold
|
|
if ind.size and threshold > 0:
|
|
dx = np.min(np.vstack([x[ind] - x[ind - 1], x[ind] - x[ind + 1]]), axis=0)
|
|
ind = np.delete(ind, np.where(dx < threshold)[0])
|
|
# detect small peaks closer than minimum peak distance
|
|
if ind.size and mpd > 1:
|
|
ind = ind[np.argsort(x[ind])][::-1] # sort ind by peak height
|
|
idel = np.zeros(ind.size, dtype=bool)
|
|
for i in range(ind.size):
|
|
if not idel[i]:
|
|
# keep peaks with the same height if kpsh is True
|
|
idel = idel | (ind >= ind[i] - mpd) & (ind <= ind[i] + mpd) & (
|
|
x[ind[i]] > x[ind] if kpsh else True
|
|
)
|
|
idel[i] = 0 # Keep current peak
|
|
# remove the small peaks and sort back the indices by their occurrence
|
|
ind = np.sort(ind[~idel])
|
|
|
|
if show:
|
|
if indnan.size:
|
|
x[indnan] = np.nan
|
|
if valley:
|
|
x = -x
|
|
peak_plot(x, mph, mpd, threshold, edge, valley, ax, ind)
|
|
|
|
return ind
|
|
|
|
|
|
def peak_plot(x, mph, mpd, threshold, edge, valley, ax, ind):
|
|
"""Plot results of the detect_peaks function, see its help."""
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
except ImportError:
|
|
print("matplotlib is not available.")
|
|
else:
|
|
if ax is None:
|
|
_, ax = plt.subplots(1, 1, figsize=(8, 4))
|
|
|
|
ax.plot(x, "b", lw=1)
|
|
if ind.size:
|
|
label = "valley" if valley else "peak"
|
|
label = label + "s" if ind.size > 1 else label
|
|
ax.plot(
|
|
ind,
|
|
x[ind],
|
|
"+",
|
|
mfc=None,
|
|
mec="r",
|
|
mew=2,
|
|
ms=8,
|
|
label="%d %s" % (ind.size, label),
|
|
)
|
|
ax.legend(loc="best", framealpha=0.5, numpoints=1)
|
|
ax.set_xlim(-0.02 * x.size, x.size * 1.02 - 1)
|
|
ymin, ymax = x[np.isfinite(x)].min(), x[np.isfinite(x)].max()
|
|
yrange = ymax - ymin if ymax > ymin else 1
|
|
ax.set_ylim(ymin - 0.1 * yrange, ymax + 0.1 * yrange)
|
|
ax.set_xlabel("Data #", fontsize=14)
|
|
ax.set_ylabel("Amplitude", fontsize=14)
|
|
mode = "Valley detection" if valley else "Peak detection"
|
|
ax.set_title(
|
|
"%s (mph=%s, mpd=%d, threshold=%s, edge='%s')"
|
|
% (mode, str(mph), mpd, str(threshold), edge)
|
|
)
|
|
# plt.grid()
|
|
plt.show()
|
|
|
|
|
|
#
|
|
# RTLSDR Utility Functions
|
|
#
|
|
|
|
# Regexes to help parse lsusb's output
|
|
_INDENTATION_RE = re.compile(r"^( *)")
|
|
_LSUSB_BUS_DEVICE_RE = re.compile(r"^Bus (\d{3}) Device (\d{3}):")
|
|
_LSUSB_ENTRY_RE = re.compile(r"^ *([^ ]+) +([^ ]+) *([^ ].*)?$")
|
|
_LSUSB_GROUP_RE = re.compile(r"^ *([^ ]+.*):$")
|
|
|
|
# USB Reset ioctl argument
|
|
_USBDEVFS_RESET = ord("U") << 8 | 20
|
|
|
|
# List of known RTLSDR-Compatible devices, taken from
|
|
# https://github.com/steve-m/librtlsdr/blob/master/src/librtlsdr.c#L313
|
|
KNOWN_RTLSDR_DEVICES = [
|
|
["0x0bda", "0x2832", "Generic RTL2832U"],
|
|
["0x0bda", "0x2838", "Generic RTL2832U OEM"],
|
|
["0x0413", "0x6680", "DigitalNow Quad DVB-T PCI-E card"],
|
|
["0x0413", "0x6f0f", "Leadtek WinFast DTV Dongle mini D"],
|
|
["0x0458", "0x707f", "Genius TVGo DVB-T03 USB dongle (Ver. B)"],
|
|
["0x0ccd", "0x00a9", "Terratec Cinergy T Stick Black (rev 1)"],
|
|
["0x0ccd", "0x00b3", "Terratec NOXON DAB/DAB+ USB dongle (rev 1)"],
|
|
["0x0ccd", "0x00b4", "Terratec Deutschlandradio DAB Stick"],
|
|
["0x0ccd", "0x00b5", "Terratec NOXON DAB Stick - Radio Energy"],
|
|
["0x0ccd", "0x00b7", "Terratec Media Broadcast DAB Stick"],
|
|
["0x0ccd", "0x00b8", "Terratec BR DAB Stick"],
|
|
["0x0ccd", "0x00b9", "Terratec WDR DAB Stick"],
|
|
["0x0ccd", "0x00c0", "Terratec MuellerVerlag DAB Stick"],
|
|
["0x0ccd", "0x00c6", "Terratec Fraunhofer DAB Stick"],
|
|
["0x0ccd", "0x00d3", "Terratec Cinergy T Stick RC (Rev.3)"],
|
|
["0x0ccd", "0x00d7", "Terratec T Stick PLUS"],
|
|
["0x0ccd", "0x00e0", "Terratec NOXON DAB/DAB+ USB dongle (rev 2)"],
|
|
["0x1554", "0x5020", "PixelView PV-DT235U(RN)"],
|
|
["0x15f4", "0x0131", "Astrometa DVB-T/DVB-T2"],
|
|
["0x15f4", "0x0133", "HanfTek DAB+FM+DVB-T"],
|
|
["0x185b", "0x0620", "Compro Videomate U620F"],
|
|
["0x185b", "0x0650", "Compro Videomate U650F"],
|
|
["0x185b", "0x0680", "Compro Videomate U680F"],
|
|
["0x1b80", "0xd393", "GIGABYTE GT-U7300"],
|
|
["0x1b80", "0xd394", "DIKOM USB-DVBT HD"],
|
|
["0x1b80", "0xd395", "Peak 102569AGPK"],
|
|
["0x1b80", "0xd397", "KWorld KW-UB450-T USB DVB-T Pico TV"],
|
|
["0x1b80", "0xd398", "Zaapa ZT-MINDVBZP"],
|
|
["0x1b80", "0xd39d", "SVEON STV20 DVB-T USB & FM"],
|
|
["0x1b80", "0xd3a4", "Twintech UT-40"],
|
|
["0x1b80", "0xd3a8", "ASUS U3100MINI_PLUS_V2"],
|
|
["0x1b80", "0xd3af", "SVEON STV27 DVB-T USB & FM"],
|
|
["0x1b80", "0xd3b0", "SVEON STV21 DVB-T USB & FM"],
|
|
["0x1d19", "0x1101", "Dexatek DK DVB-T Dongle (Logilink VG0002A)"],
|
|
["0x1d19", "0x1102", "Dexatek DK DVB-T Dongle (MSI DigiVox mini II V3.0)"],
|
|
["0x1d19", "0x1103", "Dexatek Technology Ltd. DK 5217 DVB-T Dongle"],
|
|
["0x1d19", "0x1104", "MSI DigiVox Micro HD"],
|
|
["0x1f4d", "0xa803", "Sweex DVB-T USB"],
|
|
["0x1f4d", "0xb803", "GTek T803"],
|
|
["0x1f4d", "0xc803", "Lifeview LV5TDeluxe"],
|
|
["0x1f4d", "0xd286", "MyGica TD312"],
|
|
["0x1f4d", "0xd803", "PROlectrix DV107669"],
|
|
]
|
|
|
|
|
|
def lsusb():
|
|
"""Call lsusb and return the parsed output.
|
|
|
|
Returns:
|
|
(list): List of dictionaries containing the device information for each USB device.
|
|
"""
|
|
try:
|
|
FNULL = open(os.devnull, "w")
|
|
lsusb_raw_output = subprocess.check_output(["lsusb", "-v"], stderr=FNULL)
|
|
FNULL.close()
|
|
# Convert from bytes.
|
|
lsusb_raw_output = lsusb_raw_output.decode("utf8")
|
|
except Exception as e:
|
|
logging.error("lsusb parse error - %s" % str(e))
|
|
return
|
|
|
|
device = None
|
|
devices = []
|
|
depth_stack = []
|
|
for line in lsusb_raw_output.splitlines():
|
|
if not line:
|
|
if device:
|
|
devices.append(device)
|
|
device = None
|
|
continue
|
|
|
|
if not device:
|
|
m = _LSUSB_BUS_DEVICE_RE.match(line)
|
|
if m:
|
|
device = {"bus": m.group(1), "device": m.group(2)}
|
|
depth_stack = [device]
|
|
continue
|
|
|
|
indent_match = _INDENTATION_RE.match(line)
|
|
if not indent_match:
|
|
continue
|
|
|
|
depth = 1 + len(indent_match.group(1)) / 2
|
|
if depth > len(depth_stack):
|
|
logging.debug('lsusb parsing error: unexpected indentation: "%s"', line)
|
|
continue
|
|
|
|
while depth < len(depth_stack):
|
|
depth_stack.pop()
|
|
|
|
cur = depth_stack[-1]
|
|
m = _LSUSB_GROUP_RE.match(line)
|
|
|
|
if m:
|
|
new_group = {}
|
|
cur[m.group(1)] = new_group
|
|
depth_stack.append(new_group)
|
|
continue
|
|
|
|
m = _LSUSB_ENTRY_RE.match(line)
|
|
if m:
|
|
new_entry = {
|
|
"_value": m.group(2),
|
|
"_desc": m.group(3),
|
|
}
|
|
cur[m.group(1)] = new_entry
|
|
depth_stack.append(new_entry)
|
|
continue
|
|
|
|
logging.debug('lsusb parsing error: unrecognized line: "%s"', line)
|
|
|
|
if device:
|
|
devices.append(device)
|
|
|
|
return devices
|
|
|
|
|
|
def is_not_linux():
|
|
"""
|
|
Attempt to detect a non-native-Linux system (e.g. OSX or WSL),
|
|
where lsusb isn't going to work.
|
|
"""
|
|
# Basic check for non-Linux platforms (e.g. Darwin or Windows)
|
|
if platform.system() != "Linux":
|
|
return True
|
|
|
|
# Second check for the existence of '-Microsoft' in the uname release field.
|
|
# This is a good check that we are running in WSL.
|
|
if "Microsoft" in platform.uname().release:
|
|
return True
|
|
|
|
# Else, we're probably in native Linux!
|
|
return False
|
|
|
|
|
|
def reset_usb(bus, device):
|
|
"""Reset the USB device with the given bus and device."""
|
|
usb_file_path = "/dev/bus/usb/%03d/%03d" % (bus, device)
|
|
with open(usb_file_path, "w") as usb_file:
|
|
# logging.debug('fcntl.ioctl(%s, %d)', usb_file_path, _USBDEVFS_RESET)
|
|
try:
|
|
fcntl.ioctl(usb_file, _USBDEVFS_RESET)
|
|
|
|
# This was just catching IOError, just catch everything and print.
|
|
except Exception as e:
|
|
logging.error(f"RTLSDR - USB Reset Failed - {str(e)}")
|
|
|
|
|
|
|
|
def is_rtlsdr(vid, pid):
|
|
""" Check if a device with given VID/PID is a known RTLSDR """
|
|
for _dev in KNOWN_RTLSDR_DEVICES:
|
|
_vid = _dev[0]
|
|
_pid = _dev[1]
|
|
if (vid == _vid) and (pid == _pid):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def reset_rtlsdr_by_serial(serial):
|
|
""" Attempt to reset a RTLSDR with a provided serial number """
|
|
|
|
# If not Linux, raise exception and let auto_rx.py convert it to exit status code.
|
|
if is_not_linux():
|
|
logging.debug("RTLSDR - Not a native Linux system, skipping reset attempt.")
|
|
raise SystemError("SDR unresponsive")
|
|
|
|
lsusb_info = lsusb()
|
|
bus_num = None
|
|
device_num = None
|
|
|
|
for device in lsusb_info:
|
|
try:
|
|
device_serial = device["Device Descriptor"]["iSerial"]["_desc"]
|
|
device_product = device["Device Descriptor"]["iProduct"]["_desc"]
|
|
device_pid = device["Device Descriptor"]["idProduct"]["_value"]
|
|
device_vid = device["Device Descriptor"]["idVendor"]["_value"]
|
|
except:
|
|
# If we hit an exception, the device likely doesn't have one of the required fields.
|
|
continue
|
|
|
|
if (device_serial == serial) and is_rtlsdr(device_vid, device_pid):
|
|
bus_num = int(device["bus"])
|
|
device_num = int(device["device"])
|
|
|
|
if bus_num and device_num:
|
|
logging.info(
|
|
"RTLSDR - Attempting to reset: /dev/bus/usb/%03d/%03d"
|
|
% (bus_num, device_num)
|
|
)
|
|
reset_usb(bus_num, device_num)
|
|
else:
|
|
logging.error("RTLSDR - Could not find RTLSDR with serial %s!" % serial)
|
|
return False
|
|
|
|
|
|
def find_rtlsdr(serial=None):
|
|
""" Search through lsusb and see if an RTLSDR exists """
|
|
|
|
# If not Linux, return immediately, and assume the RTLSDR exists..
|
|
if is_not_linux():
|
|
logging.debug("RTLSDR - Not a native Linux system, skipping RTLSDR search.")
|
|
return True
|
|
|
|
lsusb_info = lsusb()
|
|
bus_num = None
|
|
device_num = None
|
|
|
|
for device in lsusb_info:
|
|
try:
|
|
device_serial = device["Device Descriptor"]["iSerial"]["_desc"]
|
|
device_product = device["Device Descriptor"]["iProduct"]["_desc"]
|
|
device_pid = device["Device Descriptor"]["idProduct"]["_value"]
|
|
device_vid = device["Device Descriptor"]["idVendor"]["_value"]
|
|
except:
|
|
# If we hit an exception, the device likely doesn't have one of the required fields.
|
|
continue
|
|
|
|
if is_rtlsdr(device_vid, device_pid):
|
|
# We have found a RTLSDR! If we're not looking for a particular serial number, we can just quit now.
|
|
if serial == None:
|
|
return True
|
|
else:
|
|
if device_serial == serial:
|
|
bus_num = int(device["bus"])
|
|
device_num = int(device["device"])
|
|
|
|
if bus_num and device_num:
|
|
# We have found an RTLSDR with this serial number!
|
|
return True
|
|
|
|
else:
|
|
# Otherwise, nope.
|
|
return False
|
|
|
|
|
|
def reset_all_rtlsdrs():
|
|
""" Reset all RTLSDR devices found in the lsusb tree """
|
|
|
|
# If not Linux, raise exception and let auto_rx.py convert it to exit status code.
|
|
if is_not_linux():
|
|
logging.debug("RTLSDR - Not a native Linux system, skipping reset attempt.")
|
|
raise SystemError("SDR unresponsive")
|
|
|
|
lsusb_info = lsusb()
|
|
bus_num = None
|
|
device_num = None
|
|
|
|
for device in lsusb_info:
|
|
try:
|
|
device_product = device["Device Descriptor"]["iProduct"]["_desc"]
|
|
device_pid = device["Device Descriptor"]["idProduct"]["_value"]
|
|
device_vid = device["Device Descriptor"]["idVendor"]["_value"]
|
|
except:
|
|
# If we hit an exception, the device likely doesn't have one of the required fields.
|
|
continue
|
|
|
|
if is_rtlsdr(device_vid, device_pid):
|
|
bus_num = int(device["bus"])
|
|
device_num = int(device["device"])
|
|
|
|
logging.info(
|
|
"RTLSDR - Attempting to reset: Bus: %d Device: %d"
|
|
% (bus_num, device_num)
|
|
)
|
|
reset_usb(bus_num, device_num)
|
|
|
|
if device_num is None:
|
|
logging.error("RTLSDR - Could not find any RTLSDR devices to reset!")
|
|
|
|
|
|
def rtlsdr_test(device_idx="0", rtl_sdr_path="rtl_sdr", retries=5):
|
|
""" Test that a RTLSDR with supplied device ID is accessible.
|
|
|
|
This function attempts to read a small set of samples from a rtlsdr using rtl-sdr.
|
|
The exit code from rtl-sdr indicates if the attempt was successful, and hence shows if the rtlsdr is usable.
|
|
|
|
Args:
|
|
device_idx (int or str): Device index or serial number of the RTLSDR to test. Defaults to 0.
|
|
rtl_sdr_path (str): Path to the rtl_sdr utility. Defaults to 'rtl_sdr' (i.e. look on the system path)
|
|
|
|
Returns:
|
|
bool: True if the RTLSDR device is accessible, False otherwise.
|
|
"""
|
|
|
|
# Immediately return true for any SDR with a device ID that starts with TCP,
|
|
# as this indicates this is not actually a RTLSDR, but a client connecting to some other
|
|
# SDR server.
|
|
if device_idx.startswith("TCP"):
|
|
logging.debug("RTLSDR - TCP Device, skipping RTLSDR test step.")
|
|
return True
|
|
|
|
_rtl_cmd = "%s 5 %s -d %s -f 400000000 -n 200000 - > /dev/null" % (
|
|
timeout_cmd(),
|
|
rtl_sdr_path,
|
|
str(device_idx),
|
|
)
|
|
|
|
# First, check if the RTLSDR with a provided serial number is present.
|
|
if device_idx == "0":
|
|
# Check for the presence of any RTLSDRs.
|
|
_rtl_exists = find_rtlsdr()
|
|
|
|
else:
|
|
# Otherwise, look for a particular RTLSDR
|
|
_rtl_exists = find_rtlsdr(device_idx)
|
|
|
|
if not _rtl_exists:
|
|
logging.error(
|
|
"RTLSDR - RTLSDR with serial #%s is not present!" % str(device_idx)
|
|
)
|
|
return False
|
|
|
|
# So now we know the rtlsdr we are attempting to test does exist.
|
|
# We make an attempt to read samples from it:
|
|
|
|
_rtlsdr_retries = retries
|
|
|
|
while _rtlsdr_retries > 0:
|
|
try:
|
|
FNULL = open(os.devnull, "w") # Inhibit stderr output
|
|
_ret_code = subprocess.check_call(_rtl_cmd, shell=True, stderr=FNULL)
|
|
FNULL.close()
|
|
except subprocess.CalledProcessError as e:
|
|
# This exception means the subprocess has returned an error code of one.
|
|
# This indicates either the RTLSDR doesn't exist, or some other error.
|
|
if e.returncode == 127:
|
|
# 127 = File not found
|
|
logging.critical("rtl_sdr utilities (rtl_sdr, rtl_fm, rtl_power) not found!")
|
|
return False
|
|
else:
|
|
logging.warning(f"rtl_sdr test call resulted in return code of {e.returncode}.")
|
|
pass
|
|
else:
|
|
# rtl-sdr returned OK. We can return True now.
|
|
time.sleep(1)
|
|
return True
|
|
|
|
# If we get here, it means we failed to read any samples from the RTLSDR.
|
|
# So, we attempt to reset it.
|
|
if device_idx == "0":
|
|
reset_all_rtlsdrs()
|
|
else:
|
|
reset_rtlsdr_by_serial(device_idx)
|
|
|
|
# Decrement out retry count, then wait a bit before looping
|
|
_rtlsdr_retries -= 1
|
|
time.sleep(5)
|
|
|
|
# If we run out of retries, clearly the RTLSDR isn't working.
|
|
logging.error(
|
|
"RTLSDR - RTLSDR with serial #%s was not recovered after %d reset attempts."
|
|
% (str(device_idx), retries)
|
|
)
|
|
return False
|
|
|
|
|
|
# Earthmaths code by Daniel Richman (thanks!)
|
|
# Copyright 2012 (C) Daniel Richman; GNU GPL 3
|
|
def position_info(listener, balloon):
|
|
"""
|
|
Calculate and return information from 2 (lat, lon, alt) tuples
|
|
|
|
Returns a dict with:
|
|
|
|
- angle at centre
|
|
- great circle distance
|
|
- distance in a straight line
|
|
- bearing (azimuth or initial course)
|
|
- elevation (altitude)
|
|
|
|
Input and output latitudes, longitudes, angles, bearings and elevations are
|
|
in degrees, and input altitudes and output distances are in meters.
|
|
"""
|
|
|
|
# Earth:
|
|
radius = 6371000.0
|
|
|
|
(lat1, lon1, alt1) = listener
|
|
(lat2, lon2, alt2) = balloon
|
|
|
|
lat1 = radians(lat1)
|
|
lat2 = radians(lat2)
|
|
lon1 = radians(lon1)
|
|
lon2 = radians(lon2)
|
|
|
|
# Calculate the bearing, the angle at the centre, and the great circle
|
|
# distance using Vincenty's_formulae with f = 0 (a sphere). See
|
|
# http://en.wikipedia.org/wiki/Great_circle_distance#Formulas and
|
|
# http://en.wikipedia.org/wiki/Great-circle_navigation and
|
|
# http://en.wikipedia.org/wiki/Vincenty%27s_formulae
|
|
d_lon = lon2 - lon1
|
|
sa = cos(lat2) * sin(d_lon)
|
|
sb = (cos(lat1) * sin(lat2)) - (sin(lat1) * cos(lat2) * cos(d_lon))
|
|
bearing = atan2(sa, sb)
|
|
aa = sqrt((sa ** 2) + (sb ** 2))
|
|
ab = (sin(lat1) * sin(lat2)) + (cos(lat1) * cos(lat2) * cos(d_lon))
|
|
angle_at_centre = atan2(aa, ab)
|
|
great_circle_distance = angle_at_centre * radius
|
|
|
|
# Armed with the angle at the centre, calculating the remaining items
|
|
# is a simple 2D triangley circley problem:
|
|
|
|
# Use the triangle with sides (r + alt1), (r + alt2), distance in a
|
|
# straight line. The angle between (r + alt1) and (r + alt2) is the
|
|
# angle at the centre. The angle between distance in a straight line and
|
|
# (r + alt1) is the elevation plus pi/2.
|
|
|
|
# Use sum of angle in a triangle to express the third angle in terms
|
|
# of the other two. Use sine rule on sides (r + alt1) and (r + alt2),
|
|
# expand with compound angle formulae and solve for tan elevation by
|
|
# dividing both sides by cos elevation
|
|
ta = radius + alt1
|
|
tb = radius + alt2
|
|
ea = (cos(angle_at_centre) * tb) - ta
|
|
eb = sin(angle_at_centre) * tb
|
|
elevation = atan2(ea, eb)
|
|
|
|
# Use cosine rule to find unknown side.
|
|
distance = sqrt((ta ** 2) + (tb ** 2) - 2 * tb * ta * cos(angle_at_centre))
|
|
|
|
# Give a bearing in range 0 <= b < 2pi
|
|
if bearing < 0:
|
|
bearing += 2 * pi
|
|
|
|
return {
|
|
"listener": listener,
|
|
"balloon": balloon,
|
|
"listener_radians": (lat1, lon1, alt1),
|
|
"balloon_radians": (lat2, lon2, alt2),
|
|
"angle_at_centre": degrees(angle_at_centre),
|
|
"angle_at_centre_radians": angle_at_centre,
|
|
"bearing": degrees(bearing),
|
|
"bearing_radians": bearing,
|
|
"great_circle_distance": great_circle_distance,
|
|
"straight_distance": distance,
|
|
"elevation": degrees(elevation),
|
|
"elevation_radians": elevation,
|
|
}
|
|
|
|
|
|
def peak_decimation(freq, power, factor):
|
|
""" Peak-preserving Decimation.
|
|
|
|
Args:
|
|
freq (list): Frequency Data.
|
|
power (list): Power data.
|
|
factor (int): Decimation factor.
|
|
|
|
Returns:
|
|
tuple: (freq, power)
|
|
"""
|
|
|
|
_out_len = len(freq) // factor
|
|
|
|
_freq_out = []
|
|
_power_out = []
|
|
|
|
try:
|
|
for i in range(_out_len):
|
|
_f_slice = freq[i * factor : i * factor + factor]
|
|
_p_slice = power[i * factor : i * factor + factor]
|
|
|
|
_freq_out.append(_f_slice[np.argmax(_p_slice)])
|
|
_power_out.append(_p_slice.max())
|
|
except:
|
|
pass
|
|
|
|
return (_freq_out, _power_out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
logging.basicConfig(
|
|
format="%(asctime)s %(levelname)s:%(message)s", level=logging.DEBUG
|
|
)
|
|
check_autorx_version()
|