From 7fe2d07abca627be7cf27d0332058c77a361cdfb Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Thu, 13 Oct 2022 16:57:58 +0200 Subject: [PATCH] makes decoding/encoding of turn values consistent (#87) --- .gitignore | 1 + CHANGELOG.txt | 8 +++++++- pyais/__init__.py | 2 +- pyais/constants.py | 10 +++++++++ pyais/messages.py | 28 ++++++++++++++++--------- tests/test_decode.py | 49 +++++++++++++++++++++++--------------------- tests/test_encode.py | 13 ++++++++---- 7 files changed, 72 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index becc171..533528f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ !.idea/ .idea/* +.fleet/ !.idea/codeStyleSettings.xml *.bak *.egg diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 1b7561a..0d05478 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/pyais/__init__.py b/pyais/__init__.py index 6d86925..4f3b822 100644 --- a/pyais/__init__.py +++ b/pyais/__init__.py @@ -4,7 +4,7 @@ from pyais.decode import decode __license__ = 'MIT' -__version__ = '2.2.1' +__version__ = '2.2.2' __author__ = 'Leon Morten Richter' __all__ = ( diff --git a/pyais/constants.py b/pyais/constants.py index bbfbe6d..1641a1f 100644 --- a/pyais/constants.py +++ b/pyais/constants.py @@ -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""" diff --git a/pyais/messages.py b/pyais/messages.py index 5354153..9c372bc 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -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, \ @@ -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. @@ -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(): @@ -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)) @@ -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) diff --git a/tests/test_decode.py b/tests/test_decode.py index d50e35c..1c32cb9 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -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 @@ -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, @@ -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 @@ -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 @@ -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" @@ -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) diff --git a/tests/test_encode.py b/tests/test_encode.py index 54c2c74..9cf6fc5 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -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(): @@ -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():