Skip to content

Commit

Permalink
Merge pull request #51 from UMDBPP/develop
Browse files Browse the repository at this point in the history
fix case where float prediction fails after stop time; run descent-on…
zacharyburnett authored Mar 21, 2021

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents 0e0dadd + 801f953 commit 033fdbe
Showing 13 changed files with 251 additions and 77 deletions.
25 changes: 14 additions & 11 deletions client/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from argparse import ArgumentParser
from datetime import datetime
from datetime import datetime, timedelta
from getpass import getpass
from pathlib import Path
import sys
@@ -10,7 +10,7 @@
from client import DEFAULT_INTERVAL_SECONDS
from client.gui import PacketRavenGUI
from client.retrieve import retrieve_packets
from packetraven.connections import APRSDatabaseTable, APRSfi, APRSis, SerialTNC, TextFileTNC
from packetraven.connections import APRSDatabaseTable, APRSfi, APRSis, PacketGeoJSON, RawAPRSTextFile, SerialTNC
from packetraven.predicts import PredictionAPIURL, PredictionError, get_predictions
from packetraven.utilities import get_logger, read_configuration, repository_root
from packetraven.writer import write_packet_tracks
@@ -58,7 +58,7 @@ def main():
'--prediction-float-altitude', help='float altitude to use for prediction (m)'
)
args_parser.add_argument(
'--prediction-float-end-time', help='float end time to use for prediction'
'--prediction-float-duration', help='duration of float (s)'
)
args_parser.add_argument(
'--prediction-api',
@@ -181,8 +181,8 @@ def main():
if args.prediction_float_altitude is not None:
kwargs['prediction_float_altitude'] = float(args.prediction_descent_rate)

if args.prediction_float_end_time is not None:
kwargs['prediction_float_end_time'] = parse_date(args.prediction_float_end_time)
if args.prediction_float_duration is not None:
kwargs['prediction_float_duration'] = timedelta(seconds=float(args.prediction_float_duration))

if args.prediction_api is not None:
kwargs['prediction_api_url'] = args.prediction_api
@@ -219,7 +219,7 @@ def main():
tnc_location = tnc_location.strip()
try:
if Path(tnc_location).suffix in ['.txt', '.log']:
tnc_location = TextFileTNC(tnc_location, callsigns)
tnc_location = RawAPRSTextFile(tnc_location, callsigns)
LOGGER.info(f'reading file {tnc_location.location}')
connections.append(tnc_location)
else:
@@ -313,6 +313,13 @@ def main():
else:
database = None

if len(connections) == 0:
if output_filename is not None and output_filename.exists():
connections.append(PacketGeoJSON(output_filename))
else:
LOGGER.error(f'no connections started')
sys.exit(1)

if using_igate:
try:
aprs_is = APRSis(callsigns)
@@ -321,10 +328,6 @@ def main():
else:
aprs_is = None

if len(connections) == 0:
LOGGER.error(f'no connections started')
sys.exit(1)

filter_message = 'retrieving packets'
if start_date is not None and end_date is None:
filter_message += f' sent after {start_date:%Y-%m-%d %H:%M:%S}'
@@ -355,7 +358,7 @@ def main():
logger=LOGGER,
)

if prediction_filename is not None:
if prediction_filename is not None and len(new_packets) > 0:
try:
predictions = get_predictions(
packet_tracks,
17 changes: 10 additions & 7 deletions client/gui.py
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@
from client import DEFAULT_INTERVAL_SECONDS
from client.retrieve import retrieve_packets
from packetraven.base import available_serial_ports, next_open_serial_port
from packetraven.connections import APRSDatabaseTable, APRSfi, APRSis, SerialTNC, TextFileTNC
from packetraven.connections import APRSDatabaseTable, APRSfi, APRSis, PacketGeoJSON, RawAPRSTextFile, SerialTNC
from packetraven.packets import APRSPacket
from packetraven.plotting import LivePlot
from packetraven.predicts import PredictionError, get_predictions
@@ -63,7 +63,7 @@ def __init__(
'prediction_burst_altitude': None,
'prediction_sea_level_descent_rate': None,
'prediction_float_altitude': None,
'prediction_float_end_time': None,
'prediction_float_duration': None,
'prediction_api_url': None,
},
}
@@ -696,7 +696,7 @@ def toggle(self):
for tnc in tncs:
try:
if Path(tnc).suffix in ['.txt', '.log']:
tnc = TextFileTNC(tnc, self.callsigns)
tnc = RawAPRSTextFile(tnc, self.callsigns)
LOGGER.info(f'reading file {tnc.location}')
else:
tnc = SerialTNC(tnc, self.callsigns)
@@ -707,7 +707,7 @@ def toggle(self):
self.tncs = [
connection.location
for connection in self.__connections
if isinstance(connection, SerialTNC) or isinstance(connection, TextFileTNC)
if isinstance(connection, SerialTNC) or isinstance(connection, RawAPRSTextFile)
]

api_key = self.__configuration['aprs_fi']['aprs_fi_key']
@@ -833,8 +833,11 @@ def toggle(self):
self.aprs_is = None

if len(self.__connections) == 0:
connection_errors = '\n'.join(connection_errors)
raise ConnectionError(f'no connections started\n{connection_errors}')
if self.output_filename is not None and self.output_filename.exists():
self.__connections.append(PacketGeoJSON(self.output_filename))
else:
connection_errors = '\n'.join(connection_errors)
raise ConnectionError(f'no connections started\n{connection_errors}')

LOGGER.info(
f'listening for packets every {self.interval_seconds}s from {len(self.__connections)} '
@@ -912,7 +915,7 @@ def retrieve_packets(self):
logger=LOGGER,
)

if self.toggles['prediction_file']:
if self.toggles['prediction_file'] and len(new_packets) > 0:
try:
self.__predictions = get_predictions(
self.packet_tracks,
43 changes: 27 additions & 16 deletions client/retrieve.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
from datetime import datetime, timedelta
from logging import Logger
from os import PathLike
from pathlib import Path

from aprslib.packets.base import APRSPacket
import numpy

from packetraven import APRSDatabaseTable
from packetraven.base import APRSPacketSource
from packetraven.connections import TimeIntervalError
from packetraven.tracks import APRSTrack
from packetraven.base import PacketSource
from packetraven.connections import PacketDatabaseTable, PacketGeoJSON, TimeIntervalError
from packetraven.tracks import APRSTrack, LocationPacketTrack
from packetraven.utilities import get_logger
from packetraven.writer import write_packet_tracks

LOGGER = get_logger('packetraven')


def retrieve_packets(
connections: [APRSPacketSource],
packet_tracks: [APRSTrack],
database: APRSDatabaseTable = None,
connections: [PacketSource],
packet_tracks: [LocationPacketTrack],
database: PacketDatabaseTable = None,
output_filename: PathLike = None,
start_date: datetime = None,
end_date: datetime = None,
logger: Logger = None,
) -> {str: APRSPacket}:
if output_filename is not None:
if not isinstance(output_filename, Path):
output_filename = Path(output_filename)

if logger is None:
logger = LOGGER

@@ -96,29 +100,36 @@ def retrieve_packets(
f'{coordinate:.3f}°' for coordinate in packet_track.coordinates[-1, :2]
)
message += (
f'{callsign:8} #{len(packet_track)} ({coordinate_string}, {packet_track.coordinates[-1, 2]:.2f}m); '
f'{(current_time - packet_time) / timedelta(seconds=1):.2f}s old; '
f'{packet_track.intervals[-1]:.2f}s since last packet; '
f'{packet_track.overground_distances[-1]:.2f}m distance over ground ({packet_track.ground_speeds[-1]:.2f}m/s), '
f'{callsign:8} #{len(packet_track)} ({coordinate_string}, {packet_track.coordinates[-1, 2]:.2f}m)'
f'; {(current_time - packet_time) / timedelta(seconds=1):.2f}s old'
f'; {packet_track.intervals[-1]:.2f}s since last packet'
f'; {packet_track.overground_distances[-1]:.2f}m distance over ground ({packet_track.ground_speeds[-1]:.2f}m/s), '
f'{packet_track.ascents[-1]:.2f}m ascent ({packet_track.ascent_rates[-1]:.2f}m/s)'
)

if packet_track.time_to_ground >= timedelta(seconds=0):
current_time_to_ground = (
packet_time + packet_track.time_to_ground - current_time
)
current_time_to_ground = packet_time + packet_track.time_to_ground - current_time
message += (
f'; currently falling from max altitude of {packet_track.coordinates[:, 2].max():.3f} m; '
f'{current_time_to_ground / timedelta(seconds=1):.2f} s to the ground'
f'; {packet_track} descending from max altitude of {packet_track.coordinates[:, 2].max():.3f} m'
f'; {current_time_to_ground / timedelta(seconds=1):.2f} s to the ground'
)
except Exception as error:
LOGGER.exception(f'{error.__class__.__name__} - {error}')
finally:
logger.info(message)

packet_track.sort()

if output_filename is not None:
write_packet_tracks(
[packet_tracks[callsign] for callsign in updated_callsigns], output_filename
)

output_filename_index = None
for index, connection in enumerate(connections):
if isinstance(connection, PacketGeoJSON):
output_filename_index = index
if output_filename_index is not None:
connections.pop(output_filename_index)

return new_packets
4 changes: 2 additions & 2 deletions examples/read_text_file.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from packetraven import TextFileTNC
from packetraven import RawAPRSTextFile
from packetraven.tracks import APRSTrack

if __name__ == '__main__':
filename = 'http://bpp.umd.edu/archives/Launches/NS-95_2020-11-07/APRS/W3EAX-11/W3EAX-11_raw_NS95.txt'
raw_packet_text_file = TextFileTNC(filename)
raw_packet_text_file = RawAPRSTextFile(filename)

packets = raw_packet_text_file.packets

2 changes: 1 addition & 1 deletion packetraven/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from packetraven.connections import APRSDatabaseTable, APRSfi, SerialTNC, TextFileTNC
from packetraven.connections import APRSDatabaseTable, APRSfi, RawAPRSTextFile, SerialTNC
89 changes: 82 additions & 7 deletions packetraven/connections.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from datetime import datetime, timedelta
from os import PathLike
from pathlib import Path
import re
from time import sleep
from typing import Any, Sequence
from urllib.parse import urlparse

import aprslib
from dateutil.parser import parse as parse_date
import geojson
import requests
from serial import Serial
from shapely.geometry import Point
@@ -80,10 +80,10 @@ def close(self):
self.serial_connection.close()

def __repr__(self):
return f'{self.__class__.__name__}("{self.location}")'
return f'{self.__class__.__name__}({repr(self.location)}, {repr(self.callsigns)})'


class TextFileTNC(APRSPacketSource):
class RawAPRSTextFile(APRSPacketSource):
def __init__(self, filename: PathLike = None, callsigns: str = None):
"""
read APRS packets from a given text file where each line consists of the time sent (`YYYY-MM-DDTHH:MM:SS`) followed by
@@ -153,7 +153,82 @@ def close(self):
pass

def __repr__(self):
return f'{self.__class__.__name__}("{self.location}")'
return f'{self.__class__.__name__}({repr(self.location)}, {repr(self.callsigns)})'


class PacketGeoJSON(PacketSource):
def __init__(self, filename: PathLike = None):
"""
read location packets from a given GeoJSON file
:param filename: path to GeoJSON file
"""

if not urlparse(str(filename)).scheme in ['http', 'https', 'ftp', 'sftp']:
if not isinstance(filename, Path):
if isinstance(filename, str):
filename = filename.strip('"')
filename = Path(filename)
filename = str(filename)

super().__init__(filename)
self.__last_access_time = None

@property
def packets(self) -> [LocationPacket]:
if self.__last_access_time is not None and self.interval is not None:
interval = datetime.now() - self.__last_access_time
if interval < self.interval:
raise TimeIntervalError(
f'interval {interval} less than minimum interval {self.interval}'
)

if Path(self.location).exists():
with open(Path(self.location).expanduser().resolve()) as file_connection:
features = geojson.load(file_connection)
else:
response = requests.get(self.location, stream=True)
features = geojson.loads(response.text)

packets = []
for feature in features['features']:
if feature['geometry']['type'] == 'Point':
properties = feature['properties']
time = parse_date(properties['time'])
del properties['time']

if 'from' in properties:
from_callsign = properties['from']
to_callsign = properties['to']
del properties['from'], properties['to']

packet = APRSPacket(
from_callsign,
to_callsign,
time,
*feature['geometry']['coordinates'],
source=self.location,
**properties,
)
else:
packet = LocationPacket(
time,
*feature['geometry']['coordinates'],
source=self.location,
**properties,
)

packets.append(packet)

self.__last_access_time = datetime.now()

return packets

def close(self):
pass

def __repr__(self):
return f'{self.__class__.__name__}({repr(self.location)})'


class APRSfi(APRSPacketSource, NetworkConnection):
@@ -240,7 +315,7 @@ def close(self):
pass

def __repr__(self):
return f'{self.__class__.__name__}({repr(self.callsigns)}, {repr(re.sub(".", "*", self.api_key))})'
return f'{self.__class__.__name__}({repr(self.callsigns)}, {repr("****")})'


class PacketDatabaseTable(PostGresTable, PacketSource, PacketSink):
@@ -254,10 +329,10 @@ class PacketDatabaseTable(PostGresTable, PacketSource, PacketSink):
}

def __init__(self, hostname: str, database: str, table: str, **kwargs):
if 'fields' not in kwargs:
kwargs['fields'] = {}
if 'primary_key' not in kwargs:
kwargs['primary_key'] = 'time'
if 'fields' not in kwargs:
kwargs['fields'] = {}
kwargs['fields'] = {**self.__default_fields, **kwargs['fields']}
PostGresTable.__init__(
self, hostname=hostname, database=database, table_name=table, **kwargs
7 changes: 4 additions & 3 deletions packetraven/model.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import numpy

# `dh/dt` based on historical flight data
DESCENT_RATE = lambda altitude: -5.8e-08 * altitude ** 2 - 6.001
FREEFALL_DESCENT_RATE = lambda altitude: -5.8e-08 * altitude ** 2 - 6.001
FREEFALL_DESCENT_RATE_UNCERTAINTY = lambda altitude: 0.2 * FREEFALL_DESCENT_RATE(altitude)

# integration of `(1/(dh/dt)) dh` based on historical flight data
# TODO make this model better via ML
SECONDS_TO_GROUND = lambda altitude: 1695.02 * numpy.arctan(9.8311e-5 * altitude)
# TODO make this model better with ML
FREEFALL_SECONDS_TO_GROUND = lambda altitude: 1695.02 * numpy.arctan(9.8311e-5 * altitude)
2 changes: 1 addition & 1 deletion packetraven/plotting.py
Original file line number Diff line number Diff line change
@@ -65,7 +65,7 @@ def update(
lines = self.axis.plot(
getattr(packet_track, VARIABLES[self.variable]['x']),
getattr(packet_track, VARIABLES[self.variable]['y']),
linewidth=3,
linewidth=2,
marker='o',
label=packet_track.name,
)
84 changes: 77 additions & 7 deletions packetraven/predicts.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
from abc import ABC, abstractmethod
from datetime import datetime
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Optional, Tuple, Union

from dateutil.parser import parse as parse_date
import numpy
import pytz
import requests
from shapely.geometry import Point

from packetraven.packets import LocationPacket
from packetraven.tracks import LocationPacketTrack, PredictedTrajectory
from packetraven.utilities import get_logger

DEFAULT_ASCENT_RATE = 5.5
DEFAULT_BURST_ALTITUDE = 28000
DEFAULT_SEA_LEVEL_DESCENT_RATE = 9
UTC_TIMEZONE = pytz.utc

LOGGER = get_logger('predicts')


class PredictionAPIURL(Enum):
cusf = 'https://predict.cusf.co.uk/api/v1/'
@@ -39,6 +43,7 @@ def __init__(
float_altitude: float = None,
float_end_time: datetime = None,
name: str = None,
descent_only: bool = False,
):
"""
initialize a new balloon prediction API query
@@ -52,6 +57,7 @@ def __init__(
:param float_altitude: altitude of float (m)
:param float_end_time: date and time of float end
:param name: name of prediction track
:param descent_only: whether to query for descent only
"""

if not isinstance(launch_site, Point):
@@ -77,6 +83,7 @@ def __init__(
self.float_altitude = float_altitude
self.float_end_time = float_end_time
self.name = name
self.descent_only = descent_only

@property
@abstractmethod
@@ -115,6 +122,7 @@ def __init__(
float_end_time: datetime = None,
api_url: PredictionAPIURL = None,
name: str = None,
descent_only: bool = False,
):
if profile is None:
if float_altitude is not None or float_end_time is not None:
@@ -145,6 +153,7 @@ def __init__(
float_altitude,
float_end_time,
name,
descent_only,
)

# CUSF API requires longitude in 0-360 format
@@ -154,7 +163,7 @@ def __init__(
launch_coordinates.append(self.launch_site.z)
self.launch_site = Point(launch_coordinates)

self.profile = profile
self.profile = profile if not self.descent_only else FlightProfile.standard
self.version = version
self.dataset_time = dataset_time

@@ -229,6 +238,16 @@ def get(self) -> {str: Any}:
response['prediction'].append(stage)
break

if self.descent_only:
indices_to_remove = []
for index, stage in enumerate(response['prediction']):
# if a descent stage exists, we don't need to do anything
if stage['stage'] != 'descent':
indices_to_remove.append(index)
break
for index in indices_to_remove:
response['prediction'].pop(index)

return response
else:
raise PredictionError(response['error']['description'])
@@ -287,6 +306,7 @@ def __init__(
float_end_time: datetime = None,
api_url: str = None,
name: str = None,
descent_only: bool = False,
):
if api_url is None:
api_url = PredictionAPIURL.lukerenegar
@@ -307,6 +327,7 @@ def __init__(
float_end_time,
api_url,
name,
descent_only,
)

self.ascent_rate_standard_deviation = ascent_rate_standard_deviation
@@ -342,17 +363,37 @@ def get_predictions(
burst_altitude: float = None,
sea_level_descent_rate: float = None,
float_altitude: float = None,
float_end_time: datetime = None,
float_altitude_uncertainty: float = 500,
float_duration: timedelta = None,
api_url: str = None,
) -> [PredictedTrajectory]:
"""
Return location tracks detailing predicted trajectory of balloon flight(s) from current location.
:param packet_tracks: location packet tracks
:param ascent_rate: ascent rate (m/s)
:param burst_altitude: altitude at which balloon will burst (m)
:param sea_level_descent_rate: descent rate of payload at sea level (m/s)
:param float_altitude: altitude at which to float (m)
:param float_altitude_uncertainty: tolerance around which to consider the balloon "at float altitude"
:param float_duration: expected duration of float
:param api_url: URL of prediction API to use
"""

if api_url is None:
api_url = PredictionAPIURL.lukerenegar

if float_altitude is not None and float_duration is None:
raise ValueError('`float_duration` was not provided')

if float_duration is not None and float_altitude is None:
float_altitude = burst_altitude

prediction_tracks = {}
for name, packet_track in packet_tracks.items():
ascent_rates = packet_track.ascent_rates
if ascent_rate is None:
average_ascent_rate = ascent_rates[packet_track.ascent_rates > 0]
average_ascent_rate = ascent_rates[ascent_rates > 0]
if average_ascent_rate > 0:
ascent_rate = average_ascent_rate
else:
@@ -365,17 +406,46 @@ def get_predictions(
if len(ascent_rates) > 2 and all(ascent_rates[-2:] < 0):
burst_altitude = packet_track.altitudes[-1] + 1

prediction_start_location = packet_track[-1].coordinates
prediction_start_time = packet_track[-1].time

if float_altitude is not None and not packet_track.falling:
packets_at_float_altitude = packet_track[numpy.abs(float_altitude - packet_track.altitudes) < float_altitude_uncertainty]
if len(packets_at_float_altitude) > 0 and packets_at_float_altitude[-1].time == packet_track.times[-1]:
float_start_time = packets_at_float_altitude[0].time
descent_only = False
elif packet_track.ascent_rates[-1] > 0:
float_start_time = prediction_start_time + timedelta(seconds=(float_altitude - prediction_start_location[2]) / ascent_rate)
descent_only = False
else:
float_start_time = None
descent_only = True
if float_start_time is not None:
float_end_time = float_start_time + float_duration
else:
float_end_time = None
else:
float_end_time = None
descent_only = packet_track.falling or packet_track.ascent_rates[-1] < 0

prediction_query = CUSFBalloonPredictionQuery(
packet_track[-1].coordinates,
packet_track[-1].time,
launch_site=prediction_start_location,
launch_time=prediction_start_time,
ascent_rate=ascent_rate,
burst_altitude=burst_altitude,
sea_level_descent_rate=sea_level_descent_rate,
float_altitude=float_altitude,
float_end_time=float_end_time,
api_url=api_url,
name=name,
descent_only=descent_only,
)
prediction_tracks[name] = prediction_query.predict

prediction = prediction_query.predict

if packet_track.time_to_ground >= timedelta(seconds=0):
LOGGER.info(f'predicted landing location: {prediction.coordinates[-1]}')

prediction_tracks[name] = prediction

return prediction_tracks
40 changes: 26 additions & 14 deletions packetraven/tracks.py
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@
from pandas import DataFrame
from pyproj import CRS

from .model import SECONDS_TO_GROUND
from .model import FREEFALL_DESCENT_RATE, FREEFALL_DESCENT_RATE_UNCERTAINTY, FREEFALL_SECONDS_TO_GROUND
from .packets import APRSPacket, DEFAULT_CRS, LocationPacket
from .structures import DoublyLinkedList

@@ -149,7 +149,13 @@ def __getitem__(self, index: Union[int, Iterable[int], slice]) -> Union[Location
if isinstance(index, int):
return self.packets[index]
elif isinstance(index, Iterable) or isinstance(index, slice):
return self.__class__(self.name, self.packets[index], self.crs)
if isinstance(index, numpy.ndarray) and index.dtype == bool:
index = numpy.where(index)[0]
if isinstance(index, slice) or len(index) > 0:
packets = self.packets[index]
else:
packets = None
return self.__class__(self.name, packets, self.crs)
else:
raise ValueError(f'unrecognized index: {index}')

@@ -191,27 +197,33 @@ def dataframe(self) -> DataFrame:
class BalloonTrack(LocationPacketTrack):
def __init__(self, name: str, packets: [LocationPacket] = None, crs: CRS = None):
super().__init__(name, packets, crs)
self.__has_burst = False
self.__falling = False

@property
def time_to_ground(self) -> timedelta:
if self.has_burst:
# TODO implement landing location as the intersection of the predicted descent track with a local DEM
return timedelta(seconds=SECONDS_TO_GROUND(self.altitudes[-1]))
current_ascent_rate = self.ascent_rates[-1]
if current_ascent_rate < 0:
if self.falling:
return timedelta(seconds=FREEFALL_SECONDS_TO_GROUND(self.altitudes[-1]))
else:
current_altitude = self.altitudes[-1]
# TODO implement landing location as the intersection of the predicted descent track with a local DEM
return timedelta(seconds=current_altitude / -current_ascent_rate)
else:
return timedelta(seconds=-1)

@property
def has_burst(self) -> bool:
def falling(self) -> bool:
current_ascent_rate = self.ascent_rates[-1]
if current_ascent_rate > 0:
self.__has_burst = False
elif not self.__has_burst:
if current_ascent_rate >= 0:
self.__falling = False
elif not self.__falling:
current_altitude = self.altitudes[-1]
max_altitude = numpy.max(self.altitudes)
if current_ascent_rate < -2 and max_altitude > current_altitude:
self.__has_burst = True
return self.__has_burst
freefall_descent_rate = FREEFALL_DESCENT_RATE(current_altitude)
freefall_descent_rate_uncertainty = FREEFALL_DESCENT_RATE_UNCERTAINTY(current_altitude)
if numpy.abs(current_ascent_rate - freefall_descent_rate) < numpy.abs(freefall_descent_rate_uncertainty):
self.__falling = True
return self.__falling


class APRSTrack(BalloonTrack):
3 changes: 1 addition & 2 deletions packetraven/writer.py
Original file line number Diff line number Diff line change
@@ -45,8 +45,7 @@ def write_packet_tracks(packet_tracks: [LocationPacketTrack], output_filename: P
'ground_speed': ground_speeds[packet_index],
}

if isinstance(packet_track, APRSTrack):
properties['callsign'] = packet.from_callsign
properties.update(packet.attributes)

features.append(
geojson.Feature(
2 changes: 1 addition & 1 deletion tests/reference/test_output.geojson
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [-77.909211, 39.700356, 8201.8632]}, "properties": {"time": "20181111102013", "altitude": 8201.8632, "ascent_rate": 0.0, "ground_speed": 0.0, "callsign": "W3EAX-8"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-77.881058, 39.700099, 8645.652]}, "properties": {"time": "20181111102124", "altitude": 8645.652, "ascent_rate": 6.251, "ground_speed": 34.01, "callsign": "W3EAX-8"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-77.425253, 39.641857, 13130.784]}, "properties": {"time": "20190203143928", "altitude": 13130.784000000001, "ascent_rate": 0.001, "ground_speed": 0.005, "callsign": "W3EAX-8"}}, {"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[-77.909211, 39.700356, 8201.8632], [-77.881058, 39.700099, 8645.652], [-77.425253, 39.641857, 13130.784]]}, "properties": {"time": "20190203143928", "altitude": 13130.784000000001, "ascent_rate": 0.001, "ground_speed": 0.005, "seconds_to_ground": -1.0, "callsign": "W3EAX-8"}}]}
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [-77.909211, 39.700356, 8201.8632]}, "properties": {"time": "20181111102013", "altitude": 8201.8632, "ascent_rate": 0.0, "ground_speed": 0.0, "raw": "W3EAX-8>APRS,WIDE1-1,WIDE2-1,qAR,K3DO-11:!/:Gh=:j)#O /A=026909|!Q| /W3EAX,262,0,18'C,http://www.umd.edu", "path": ["WIDE1-1", "WIDE2-1", "qAR", "K3DO-11"], "via": "K3DO-11", "messagecapable": false, "format": "compressed", "gpsfixstatus": 1, "symbol": "O", "symbol_table": "/", "comment": "|!Q| /W3EAX,262,0,18'C,http://www.umd.edu", "from": "W3EAX-8", "to": "APRS"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-77.881058, 39.700099, 8645.652]}, "properties": {"time": "20181111102124", "altitude": 8645.652, "ascent_rate": 6.251, "ground_speed": 34.01, "raw": "W3EAX-8>APRS,N3TJJ-12,WIDE1*,WIDE2-1,qAR,N3FYI-2:!/:GiD:jcwO /A=028365|!R| /W3EAX,267,0,18'C,http://www.umd.edu", "path": ["N3TJJ-12", "WIDE1*", "WIDE2-1", "qAR", "N3FYI-2"], "via": "N3FYI-2", "messagecapable": false, "format": "compressed", "gpsfixstatus": 1, "symbol": "O", "symbol_table": "/", "comment": "|!R| /W3EAX,267,0,18'C,http://www.umd.edu", "from": "W3EAX-8", "to": "APRS"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-77.425253, 39.641857, 13130.784]}, "properties": {"time": "20190203143928", "altitude": 13130.784000000001, "ascent_rate": 0.001, "ground_speed": 0.005, "raw": "W3EAX-8>APRS,KC3FIT-1,WIDE1*,WIDE2-1,qAR,KC3AWP-10:!/:JL2:u4wO /A=043080|!j| /W3EAX,326,0,20'C,nearspace.umd.edu", "path": ["KC3FIT-1", "WIDE1*", "WIDE2-1", "qAR", "KC3AWP-10"], "via": "KC3AWP-10", "messagecapable": false, "format": "compressed", "gpsfixstatus": 1, "symbol": "O", "symbol_table": "/", "comment": "|!j| /W3EAX,326,0,20'C,nearspace.umd.edu", "from": "W3EAX-8", "to": "APRS"}}, {"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[-77.909211, 39.700356, 8201.8632], [-77.881058, 39.700099, 8645.652], [-77.425253, 39.641857, 13130.784]]}, "properties": {"time": "20190203143928", "altitude": 13130.784000000001, "ascent_rate": 0.001, "ground_speed": 0.005, "seconds_to_ground": -1.0, "callsign": "W3EAX-8"}}]}
10 changes: 5 additions & 5 deletions tests/test_packets.py
Original file line number Diff line number Diff line change
@@ -157,24 +157,24 @@ def test_time_to_ground():

track.append(packet_1)

assert not track.has_burst
assert not track.falling
assert track.time_to_ground == timedelta(seconds=-1)

track.append(packet_2)

assert track.has_burst
assert track.falling
assert track.time_to_ground == timedelta(seconds=1603.148748)

track.append(packet_3)

assert not track.has_burst
assert not track.falling
assert track.time_to_ground == timedelta(seconds=-1)

track.append(packet_4)
track.append(packet_5)

assert track.has_burst
assert track.time_to_ground == timedelta(seconds=1545.354922)
assert not track.falling
assert track.time_to_ground == timedelta(seconds=297.913704)


def test_sorting():

0 comments on commit 033fdbe

Please sign in to comment.