Added support for weather data from FANET ground stations

pull/93/head
Konstantin Gründger 2020-10-09 09:23:10 +02:00
rodzic ffa6c8b1f0
commit 8855a4f097
10 zmienionych plików z 107 dodań i 17 usunięć

Wyświetl plik

@ -1,5 +1,6 @@
# CHANGELOG
## not released
- parser: Added support for weather data from FANET ground stations
- parser: Added support for latency in receiver messages (OGNSDR) (fixes #87)
- parser: Added support for reference_timestamp with tzinfo (fixes #84)
- parser: Fixed textual altitude part (fixes #81)

Wyświetl plik

@ -1,5 +1,5 @@
from ogn.parser.utils import FPM_TO_MS
from ogn.parser.pattern import PATTERN_FANET_POSITION_COMMENT
from ogn.parser.pattern import PATTERN_FANET_POSITION_COMMENT, PATTERN_FANET_STATUS_COMMENT
from .base import BaseParser
@ -7,10 +7,11 @@ from .base import BaseParser
class FanetParser(BaseParser):
def __init__(self):
self.beacon_type = 'fanet'
self.position_parser = PATTERN_FANET_POSITION_COMMENT
self.position_pattern = PATTERN_FANET_POSITION_COMMENT
self.status_pattern = PATTERN_FANET_STATUS_COMMENT
def parse_position(self, aprs_comment):
match = self.position_parser.match(aprs_comment)
match = self.position_pattern.match(aprs_comment)
result = {}
if match.group('details'):
result.update({
@ -21,3 +22,13 @@ class FanetParser(BaseParser):
})
if match.group('climb_rate'): result['climb_rate'] = int(match.group('climb_rate')) * FPM_TO_MS
return result
def parse_status(self, aprs_comment):
match = self.status_pattern.match(aprs_comment)
result = {}
if match.group('fanet_name'): result['fanet_name'] = match.group('fanet_name')
if match.group('signal_quality'): result['signal_quality'] = float(match.group('signal_quality'))
if match.group('frequency_offset'): result['frequency_offset'] = float(match.group('frequency_offset'))
if match.group('error_count'): result['error_count'] = int(match.group('error_count'))
return result

Wyświetl plik

@ -35,11 +35,11 @@ class ReceiverParser(BaseParser):
if match.group('senders'): result['senders_total'] = int(match.group('senders'))
if match.group('rf_correction_manual'): result['rec_crystal_correction'] = int(match.group('rf_correction_manual'))
if match.group('rf_correction_automatic'): result['rec_crystal_correction_fine'] = float(match.group('rf_correction_automatic'))
if match.group('signal_quality'): result['rec_input_noise'] = float(match.group('signal_quality'))
if match.group('senders_signal_quality'): result['senders_signal'] = float(match.group('senders_signal_quality'))
if match.group('senders_messages'): result['senders_messages'] = float(match.group('senders_messages'))
if match.group('good_senders_signal_quality'): result['good_senders_signal'] = float(match.group('good_senders_signal_quality'))
if match.group('good_senders'): result['good_senders'] = float(match.group('good_senders'))
if match.group('signal_quality'): result['rec_input_noise'] = float(match.group('signal_quality'))
if match.group('senders_signal_quality'): result['senders_signal'] = float(match.group('senders_signal_quality'))
if match.group('senders_messages'): result['senders_messages'] = float(match.group('senders_messages'))
if match.group('good_senders_signal_quality'): result['good_senders_signal'] = float(match.group('good_senders_signal_quality'))
if match.group('good_senders'): result['good_senders'] = float(match.group('good_senders'))
if match.group('good_and_bad_senders'): result['good_and_bad_senders'] = float(match.group('good_and_bad_senders'))
return result

Wyświetl plik

@ -1,8 +1,8 @@
import re
from datetime import datetime
from ogn.parser.utils import createTimestamp, parseAngle, KNOTS_TO_MS, KPH_TO_MS, FEETS_TO_METER
from ogn.parser.pattern import PATTERN_APRS, PATTERN_APRS_POSITION, PATTERN_APRS_STATUS, PATTERN_SERVER
from ogn.parser.utils import createTimestamp, parseAngle, KNOTS_TO_MS, KPH_TO_MS, FEETS_TO_METER, fahrenheit_to_celsius
from ogn.parser.pattern import PATTERN_APRS, PATTERN_APRS_POSITION, PATTERN_APRS_POSITION_WEATHER, PATTERN_APRS_STATUS, PATTERN_SERVER
from ogn.parser.exceptions import AprsParseError
from ogn.parser.aprs_comment.ogn_parser import OgnParser
@ -52,7 +52,6 @@ def parse_aprs(message, reference_timestamp=None):
result.update({
'comment': message,
'aprs_type': 'comment'})
else:
match = re.search(PATTERN_APRS, message)
if match:
@ -74,12 +73,42 @@ def parse_aprs(message, reference_timestamp=None):
'longitude': parseAngle(match_position.group('longitude') + (match_position.group('longitude_enhancement') or '0')) * # noqa: W504
(-1 if match_position.group('longitude_sign') == 'W' else 1),
'symbolcode': match_position.group('symbol'),
'track': int(match_position.group('course')) if match_position.group('course_extension') else None,
'ground_speed': int(match_position.group('ground_speed')) * KNOTS_TO_MS / KPH_TO_MS if match_position.group('ground_speed') else None,
'altitude': int(match_position.group('altitude')) * FEETS_TO_METER if match_position.group('altitude') else None,
'comment': match_position.group('comment') if match_position.group('comment') else ""})
else:
raise AprsParseError(message)
'comment': match_position.group('comment') if match_position.group('comment') else "",
})
return result
match_position_weather = re.search(PATTERN_APRS_POSITION_WEATHER, aprs_body)
if match_position_weather:
result.update({
'name': match.group('callsign'),
'dstcall': match.group('dstcall'),
'relay': match.group('relay') if match.group('relay') else None,
'receiver_name': match.group('receiver'),
'timestamp': createTimestamp(match_position_weather.group('time'), reference_timestamp),
'latitude': parseAngle('0' + match_position_weather.group('latitude')) * # noqa: W504
(-1 if match_position_weather.group('latitude_sign') == 'S' else 1),
'symboltable': match_position_weather.group('symbol_table'),
'longitude': parseAngle(match_position_weather.group('longitude')) * # noqa: W504
(-1 if match_position_weather.group('longitude_sign') == 'W' else 1),
'symbolcode': match_position_weather.group('symbol'),
'wind_direction': int(match_position_weather.group('wind_direction')) if match_position_weather.group('wind_direction') != '...' else None,
'wind_speed': int(match_position_weather.group('wind_speed')) * KNOTS_TO_MS / KPH_TO_MS if match_position_weather.group('wind_speed') != '...' else None,
'wind_speed_peak': int(match_position_weather.group('wind_speed_peak')) * KNOTS_TO_MS / KPH_TO_MS if match_position_weather.group('wind_speed_peak') != '...' else None,
'temperature': fahrenheit_to_celsius(float(match_position_weather.group('temperature'))) if match_position_weather.group('temperature') != '...' else None,
'humidity': int(match_position_weather.group('humidity')) * 0.01 if match_position_weather.group('humidity') else None,
'barometric_pressure': int(match_position_weather.group('barometric_pressure')) if match_position_weather.group('barometric_pressure') else None,
'comment': match_position_weather.group('comment') if match_position_weather.group('comment') else "",
})
return result
raise AprsParseError(message)
elif aprs_type == 'status':
match_status = re.search(PATTERN_APRS_STATUS, aprs_body)
if match_status:

Wyświetl plik

@ -2,6 +2,7 @@ import re
PATTERN_APRS = re.compile(r"^(?P<callsign>.+?)>(?P<dstcall>[A-Z0-9]+),((?P<relay>[A-Za-z0-9]+)\*)?.*,(?P<receiver>.+?):(?P<aprs_type>(.))(?P<aprs_body>.*)$")
PATTERN_APRS_POSITION = re.compile(r"^(?P<time>(([0-1]\d|2[0-3])[0-5]\d[0-5]\dh|([0-2]\d|3[0-1])([0-1]\d|2[0-3])[0-5]\dz))(?P<latitude>9000\.00|[0-8]\d{3}\.\d{2})(?P<latitude_sign>N|S)(?P<symbol_table>.)(?P<longitude>18000\.00|1[0-7]\d{3}\.\d{2}|0\d{4}\.\d{2})(?P<longitude_sign>E|W)(?P<symbol>.)(?P<course_extension>(?P<course>\d{3})/(?P<ground_speed>\d{3}))?(/A=(?P<altitude>(-\d{5}|\d{6})))?(?P<pos_extension>\s!W((?P<latitude_enhancement>\d)(?P<longitude_enhancement>\d))!)?(?:\s(?P<comment>.*))?$")
PATTERN_APRS_POSITION_WEATHER = re.compile(r"^(?P<time>(([0-1]\d|2[0-3])[0-5]\d[0-5]\dh|([0-2]\d|3[0-1])([0-1]\d|2[0-3])[0-5]\dz))(?P<latitude>9000\.00|[0-8]\d{3}\.\d{2})(?P<latitude_sign>N|S)(?P<symbol_table>.)(?P<longitude>18000\.00|1[0-7]\d{3}\.\d{2}|0\d{4}\.\d{2})(?P<longitude_sign>E|W)(?P<symbol>.)(?P<wind_direction>(\d{3}|\.{3}))/(?P<wind_speed>(\d{3}|\.{3}))g(?P<wind_speed_peak>(\d{3}|\.{3}))t(?P<temperature>(\d{3}|\.{3}))(h(?P<humidity>\d{2}))?(b(?P<barometric_pressure>\d{5}))?(?:\s(?P<comment>.*))?$")
PATTERN_APRS_STATUS = re.compile(r"^(?P<time>(([0-1]\d|2[0-3])[0-5]\d[0-5]\dh|([0-2]\d|3[0-1])([0-1]\d|2[0-3])[0-5]\dz))\s(?P<comment>.*)$")
PATTERN_SERVER = re.compile(r"^# aprsc (?P<version>[a-z0-9\.\-]+) (?P<timestamp>\d+ [A-Za-z]+ \d+ \d{2}:\d{2}:\d{2} GMT) (?P<server>[A-Z0-9]+) (?P<ip_address>\d+\.\d+\.\d+\.\d+):(?P<port>\d+)$")
@ -12,7 +13,7 @@ PATTERN_FANET_POSITION_COMMENT = re.compile(r"""
""", re.VERBOSE | re.MULTILINE)
PATTERN_FANET_STATUS_COMMENT = re.compile(r"""
(?:(Name=\"(?P<user_name>[^\"]*)\")\s?)?
(?:(Name=\"(?P<fanet_name>[^\"]*)\")\s?)?
(?:(?P<signal_quality>[\d.]+?)dB\s?)?
(?:(?P<frequency_offset>[+-][\d.]+?)kHz\s?)?
(?:(?P<error_count>\d+)e\s?)?

Wyświetl plik

@ -7,6 +7,10 @@ KPH_TO_MS = 0.27778 # ratio kph to m/s
HPM_TO_DEGS = 180 / 60 # ratio between half turn per minute and degrees/s
def fahrenheit_to_celsius(fahrenheit):
return (fahrenheit - 32.0) * 5.0 / 9.0
def parseAngle(dddmmhht):
return float(dddmmhht[:3]) + float(dddmmhht[3:]) / 60

Wyświetl plik

@ -2,7 +2,7 @@ import unittest
from datetime import datetime
from ogn.parser.utils import KNOTS_TO_MS, KPH_TO_MS, FEETS_TO_METER
from ogn.parser.utils import KNOTS_TO_MS, KPH_TO_MS, FEETS_TO_METER, fahrenheit_to_celsius
from ogn.parser.parse import parse_aprs
from ogn.parser.exceptions import AprsParseError
@ -70,6 +70,31 @@ class TestStringMethods(unittest.TestCase):
self.assertEqual(message['timestamp'].strftime('%d %H:%M'), "30 10:46")
def test_v028_fanet_position_weather(self):
# with v0.2.8 fanet devices can report weather data
raw_message = 'FNTFC9002>OGNFNT,qAS,LSXI2:/163051h4640.33N/00752.21E_187/004g007t075h78b63620 29.0dB -8.0kHz'
message = parse_aprs(raw_message)
self.assertEqual(message['wind_direction'], 187)
self.assertEqual(message['wind_speed'], 4 * KNOTS_TO_MS / KPH_TO_MS)
self.assertEqual(message['wind_speed_peak'], 7 * KNOTS_TO_MS / KPH_TO_MS)
self.assertEqual(message['temperature'], fahrenheit_to_celsius(75))
self.assertEqual(message['humidity'], 78 * 0.01)
self.assertEqual(message['barometric_pressure'], 63620)
self.assertEqual(message['comment'], '29.0dB -8.0kHz')
def test_v028_fanet_position_weather_empty(self):
raw_message = 'FNT010115>OGNFNT,qAS,DB7MJ:/065738h4727.72N/01012.83E_.../...g...t... 27.8dB -13.8kHz'
message = parse_aprs(raw_message)
self.assertIsNone(message['wind_direction'])
self.assertIsNone(message['wind_speed'])
self.assertIsNone(message['wind_speed_peak'])
self.assertIsNone(message['temperature'])
self.assertIsNone(message['humidity'])
self.assertIsNone(message['barometric_pressure'])
def test_negative_altitude(self):
# some devices can report negative altitudes
raw_message = "OGNF71F40>APRS,qAS,NAVITER:/080852h4414.37N/01532.06E'253/052/A=-00013 !W73! id1EF71F40 -060fpm +0.0rot"

Wyświetl plik

@ -19,6 +19,14 @@ class TestStringMethods(unittest.TestCase):
self.assertEqual(message, {})
def test_v028_status(self):
message = FanetParser().parse_status('Name="Juerg Zweifel" 15.0dB -17.1kHz 1e')
self.assertEqual(message['fanet_name'], "Juerg Zweifel")
self.assertEqual(message['signal_quality'], 15.0)
self.assertEqual(message['frequency_offset'], -17.1)
self.assertEqual(message['error_count'], 1)
if __name__ == '__main__':
unittest.main()

Wyświetl plik

@ -6,3 +6,14 @@ FNT1103CE>OGNFNT,qAS,FNB1103CE:/183731h5057.94N/00801.00Eg354/001/A=001042 !W10!
FNT1103CE>OGNFNT,qAS,FNB1103CE:/183734h5057.94N/00801.00Eg354/001/A=001042 !W30! id1E1103CE -10fpm
FNT1103CE>OGNFNT,qAS,FNB1103CE:/183736h5057.94N/00801.00Eg354/001/A=001042 !W40! id1E1103CE -02fpm
FNB1103CE>OGNFNT,TCPIP*,qAC,GLIDERN3:/183738h5057.95NI00801.00E&/A=001042
#
# With OGN software 0.2.8 we get weather data in the position message ...
#
FNTFC9002>OGNFNT,qAS,LSXI2:/163051h4640.33N/00752.21E_187/004g007t075h78b63620 29.0dB -8.0kHz
FNT051015>OGNFNT,qAS,LSXI2:/112540h4641.18N/00751.53E_097/007g008t082h54 28.8dB -8.3kHz
#
# ... and additional fanet data in the status message
#
FNT1122AE>OGNFNT,qAS,LIDH:>112528h Name="Juerg Zweifel" 15.0dB -17.1kHz 1e
FNT0728B8>OGNFNT,qAS,Huenenbg:>112533h Name="Huenenb2" 26.8dB +3.0kHz 6e
FNT111369>OGNFNT,qAS,LSXI2:>112535h Name="Zaugg Thomas" 18.3dB -15.4kHz

Wyświetl plik

@ -5,4 +5,4 @@ FLRDD98C6>OGFLR,qAS,LIDH:/115054h4543.21N/01132.80E'255/074/A=002535 !W83! id0AD
ICAA8CBA8>OGFLR,qAS,MontCAIO:/231150z4512.12N\01059.03E^192/106/A=009519 !W20! id21A8CBA8 -039fpm +0.0rot 3.5dB 2e -8.7kHz gps1x2 s6.09 h43 rDF0267
ICAA8CBA8>OGFLR,qAS,MontCAIO:/114949h4512.44N\01059.12E^190/106/A=009522 !W33! id21A8CBA8 -039fpm +0.1rot 4.5dB 1e -8.7kHz gps1x2 +14.3dBm
ICA3D1C35>OGFLR,qAS,Padova:/094220h4552.41N/01202.28E'110/099/A=003982 !W96! id053D1C35 -1187fpm +0.0rot 0.8dB 2e +4.5kHz gps1x2 s6.09 h32 rDD09D0
FLR200295>OGFLR,qAS,TT:/071005h4613.92N/01427.53Eg000/000/A=001313 !W00! id1E200295 +000fpm +0.0rot 37.0dB -1.8kHz gps3x5
FLR200295>OGFLR,qAS,TT:/071005h4613.92N/01427.53Eg000/000/A=001313 !W00! id1E200295 +000fpm +0.0rot 37.0dB -1.8kHz gps3x5