Skip to content

Commit

Permalink
makes decoding/encoding of turn values consistent (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
M0r13n authored Oct 13, 2022
1 parent 02176d5 commit 7fe2d07
Show file tree
Hide file tree
Showing 7 changed files with 72 additions and 39 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
!.idea/
.idea/*
.fleet/
!.idea/codeStyleSettings.xml
*.bak
*.egg
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
====================
pyais CHANGELOG
====================
-------------------------------------------------------------------------------
Version 2.2.2 13 Oct 2022
-------------------------------------------------------------------------------
* Closes https://github.com/M0r13n/pyais/issues/86
* ensure that the payload is always identical - even for multiple encode/decode roundtrips
* the `turn` field can now never be `None` and is instead an instance of the newly created `TurnRate` enum
-------------------------------------------------------------------------------
Version 2.2.1 03 Oct 2022
-------------------------------------------------------------------------------
* Closes https://github.com/M0r13n/pyais/issues/81
* Closes https://github.com/M0r13n/pyais/issues/81
* ensure that the NMEA sentence length does not exceed 82 characters
-------------------------------------------------------------------------------
Version 2.2.0 02 Oct 2022
Expand Down
2 changes: 1 addition & 1 deletion pyais/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pyais.decode import decode

__license__ = 'MIT'
__version__ = '2.2.1'
__version__ = '2.2.2'
__author__ = 'Leon Morten Richter'

__all__ = (
Expand Down
10 changes: 10 additions & 0 deletions pyais/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
ANSI_RESET = '\x1b[0m'


class TurnRate(IntEnum):
# Source: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a
# turning right at more than 5deg/30s (No TI available)
NO_TI_RIGHT = 127
# turning left at more than 5deg/30s (No TI available)
NO_TI_LEFT = -127
# 80 hex) indicates no turn information available (default)
NO_TI_DEFAULT = -128


class TalkerID(str, Enum):
""" Enum of all NMEA talker IDs.
See: https://gpsd.gitlab.io/gpsd/AIVDM.html#_talker_ids"""
Expand Down
28 changes: 18 additions & 10 deletions pyais/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from bitarray import bitarray

from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType, NavAid, StationType, \
TransmitMode, StationIntervals
TransmitMode, StationIntervals, TurnRate
from pyais.exceptions import InvalidNMEAMessageException, UnknownMessageException, UnknownPartNoException, \
InvalidDataTypeException
from pyais.util import decode_into_bit_array, compute_checksum, get_itdma_comm_state, get_sotdma_comm_state, int_to_bin, str_to_bin, \
Expand Down Expand Up @@ -233,6 +233,9 @@ def __getitem__(self, item: str) -> Union[int, str, bytes, bitarray]:
else:
raise TypeError(f"Index must be str, not {type(item).__name__}")

def __hash__(self) -> int:
return hash(self.raw)

def asdict(self) -> Dict[str, Any]:
"""
Convert the class to dict.
Expand Down Expand Up @@ -367,7 +370,7 @@ def fields(cls) -> typing.Tuple[typing.Any]:

def to_bitarray(self) -> bitarray:
"""
Convert a payload to binary.
Convert all attributes of a given Payload/Message to binary.
"""
out = bitarray()
for field in self.fields():
Expand Down Expand Up @@ -538,18 +541,23 @@ def from_mmsi(v: typing.Union[str, int]) -> int:
return int(v)


def to_turn(turn: typing.Union[int, float]) -> typing.Optional[float]:
def to_turn(turn: typing.Union[int, float]) -> typing.Union[float, int, TurnRate]:
if not turn:
return 0.0
elif abs(turn) == 127 or abs(turn) == 128:
return None
else:
return math.copysign(int((turn / 4.733) ** 2), turn)
elif abs(turn) == 127:
return TurnRate(int(turn))
elif abs(turn) == 128:
return TurnRate.NO_TI_DEFAULT

return math.copysign(int((turn / 4.733) ** 2), turn)


def from_turn(turn: typing.Optional[typing.Union[int, float]]) -> int:
if turn is None:
def from_turn(turn: typing.Optional[typing.Union[int, float, TurnRate]]) -> int:
if not turn:
return 0
elif abs(turn) == 127 or abs(turn) == 128:
return int(turn)

return int(math.copysign(round(4.733 * math.sqrt(abs(turn))), turn))


Expand Down Expand Up @@ -617,7 +625,7 @@ class MessageType1(Payload, CommunicationStateMixin):
repeat = bit_field(2, int, default=0, signed=False)
mmsi = bit_field(30, int, from_converter=from_mmsi)
status = bit_field(4, int, default=0, converter=NavigationStatus.from_value, signed=False)
turn = bit_field(8, float, default=0, signed=True, to_converter=to_turn, from_converter=from_turn)
turn = bit_field(8, float, default=TurnRate.NO_TI_DEFAULT, signed=True, to_converter=to_turn, from_converter=from_turn)
speed = bit_field(10, float, from_converter=from_speed, to_converter=to_speed, default=0, signed=False)
accuracy = bit_field(1, bool, default=0, signed=False)
lon = bit_field(28, float, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0, signed=True)
Expand Down
49 changes: 26 additions & 23 deletions tests/test_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,22 @@
from pyais.ais_types import AISType
from pyais.constants import (EpfdType, ManeuverIndicator, NavAid,
NavigationStatus, ShipType, StationType, SyncState,
TransmitMode)
TransmitMode, TurnRate)
from pyais.decode import decode
from pyais.exceptions import InvalidNMEAChecksum, UnknownMessageException
from pyais.messages import (MSG_CLASS, MessageType5, MessageType6,
MessageType18, MessageType22Addressed,
MessageType22Broadcast, MessageType24PartA,
MessageType24PartB,
MessageType25AddressedStructured,
MessageType25AddressedUnstructured,
MessageType25BroadcastStructured,
MessageType25BroadcastUnstructured,
MessageType26AddressedStructured,
MessageType26BroadcastStructured,
MessageType26BroadcastUnstructured, from_turn,
to_turn)
from pyais.messages import (
MSG_CLASS, MessageType5, MessageType6,
MessageType18, MessageType22Addressed,
MessageType22Broadcast, MessageType24PartA,
MessageType24PartB,
MessageType25AddressedStructured,
MessageType25AddressedUnstructured,
MessageType25BroadcastStructured,
MessageType25BroadcastUnstructured,
MessageType26AddressedStructured,
MessageType26BroadcastStructured,
MessageType26BroadcastUnstructured
)
from pyais.stream import ByteStream
from pyais.util import b64encode_str, bits2bytes, bytes2bits

Expand Down Expand Up @@ -61,7 +62,7 @@ def test_to_json(self):
"repeat": 0,
"mmsi": 367533950,
"status": 0,
"turn": null,
"turn": -128.0,
"speed": 0.0,
"accuracy": true,
"lon": -122.408232,
Expand Down Expand Up @@ -120,7 +121,7 @@ def test_msg_type_1_b(self):
assert msg['mmsi'] == 367533950
assert msg['repeat'] == 0
assert msg['status'] == NavigationStatus.UnderWayUsingEngine
assert msg['turn'] is None
assert msg['turn'] == TurnRate.NO_TI_DEFAULT
assert msg['speed'] == 0
assert msg['accuracy'] == 1
assert round(msg['lat'], 4) == 37.8084
Expand Down Expand Up @@ -152,7 +153,7 @@ def test_decode_pos_1_2_3(self):

assert content['repeat'] == 2
assert content['mmsi'] == 211512520
assert content['turn'] is None
assert content['turn'] == TurnRate.NO_TI_DEFAULT
assert content['speed'] == 0.3
assert round(content['lat'], 4) == 53.5427
assert round(content['lon'], 4) == 9.9794
Expand Down Expand Up @@ -1232,13 +1233,6 @@ def test_msg_type_6_json_reverse(self):
assert data['data'] == '6y8Rj3/x'
assert base64.b64decode(data['data']) == b'\xeb/\x11\x8f\x7f\xf1'

def test_turn_is_none_for_127_or_128(self):
self.assertIsNone(to_turn(127), None)
self.assertIsNone(to_turn(-127), None)
self.assertIsNone(to_turn(128), None)

self.assertEqual(0, from_turn(None))

def test_rot_encode_yields_expected_values(self):
encoded = encode_dict({'msg_type': 1, 'mmsi': 123, 'turn': 25.0})[0]
assert encoded == "!AIVDO,1,1,,A,10000Nh600000000000000000000,0*05"
Expand Down Expand Up @@ -1448,3 +1442,12 @@ def test_decode_does_raise_an_error_if_error_if_checksum_invalid_is_true(self):
raw = b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*FF"
with self.assertRaises(InvalidNMEAChecksum):
_ = decode(raw, error_if_checksum_invalid=True)

def test_that_the_payload_does_not_change_when_encoding_decoding(self):
"""Refer to https://github.com/M0r13n/pyais/issues/86"""
nmea = NMEAMessage(b'!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23')
ais = nmea.decode()
orig_bits = nmea.bit_array.to01()
actual_bits = ais.to_bitarray().to01()

self.assertEqual(orig_bits, actual_bits)
13 changes: 9 additions & 4 deletions tests/test_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,7 @@ def test_encode_type_1_default():
"""
data = {'mmsi': 123456789, 'type': 1}
encoded = encode_dict(data)[0]
assert encoded == "!AIVDO,1,1,,A,11mg=5@000000000000000000000,0*56"
assert encoded == "!AIVDO,1,1,,A,11mg=5@P00000000000000000000,0*36"


def test_encode_type_1():
Expand Down Expand Up @@ -1017,14 +1017,19 @@ def test_encode_type_1():
def test_mmsi_too_long():
msg = MessageType1.create(mmsi=1 << 35)
encoded = encode_msg(msg)
assert encoded[0] == "!AIVDO,1,1,,A,1?wwwwh000000000000000000000,0*72"
decoded = decode(encoded[0])

assert encoded[0] == "!AIVDO,1,1,,A,1?wwwwhP00000000000000000000,0*12"
assert decoded.mmsi == 1073741823


def test_lon_too_large():
msg = MessageType1.create(mmsi="123", lon=1 << 30)
encoded = encode_msg(msg)
print(encoded)
assert encoded[0] == "!AIVDO,1,1,,A,10000Nh000Owwwv0000000000000,0*7D"
decoded = decode(encoded[0])

assert encoded[0] == "!AIVDO,1,1,,A,10000NhP00Owwwv0000000000000,0*1D"
assert decoded.lon == -2e-06


def test_ship_name_too_lon():
Expand Down

0 comments on commit 7fe2d07

Please sign in to comment.