Skip to content

Commit acf233d

Browse files
authored
Experimental version with bit vector (#200)
* feat: adds BitVector * docs: bit_vector * chore: rename BitVector to bit_vector * chore: renames data to bv (NMEASentence)
1 parent f7c7bcb commit acf233d

File tree

4 files changed

+194
-190
lines changed

4 files changed

+194
-190
lines changed

pyais/bit_vector.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
This bit vector uses pre-computed lookup tables and stores the entire payload as a single
3+
arbitrary-precision int so that every field extraction is a constant-time shift-and-mask operation.
4+
"""
5+
import typing as t
6+
7+
# ASCII ordinal 6-bit AIS payload value
8+
# Valid input range: ordinals 48 ('0') through 119 ('w').
9+
# Everything outside that range maps to 0; upstream validation is expected.
10+
_PAYLOAD_ARMOR: t.Final[tuple[int, ...]] = tuple(
11+
(c - 48 - 8 if c - 48 > 40 else c - 48) if 48 <= c <= 119 else 0 for c in range(256)
12+
)
13+
14+
# 6-bit value decoded AIS character (for text fields)
15+
_SIXBIT_CHAR: t.Final[tuple[str, ...]] = tuple(
16+
chr(v + 64) if v < 32 else chr(v) for v in range(64)
17+
)
18+
19+
# Bitmasks: _MASK[n] = (1 << n) - 1
20+
# 257 entries covers every realistic AIS field width.
21+
_MASK: t.Final[tuple[int, ...]] = tuple((1 << n) - 1 for n in range(257))
22+
23+
24+
class bit_vector:
25+
26+
__slots__ = ("_value", "_length")
27+
28+
def __init__(self, data: bytes, pad: int = 0) -> None:
29+
value = 0
30+
for byte in data:
31+
value = (value << 6) | _PAYLOAD_ARMOR[byte]
32+
33+
length = len(data) * 6
34+
if pad:
35+
value >>= pad
36+
length -= pad
37+
38+
self._value: int = value
39+
self._length: int = length
40+
41+
def get_num(self, start: int, width: int, signed: bool = False) -> int:
42+
"""Return an unsigned integer of *width* bits at bit position *start*.
43+
Takes an additional argument *signed* if the number is signed.
44+
Returns an unsigned integer by default.
45+
"""
46+
if signed:
47+
return self.get_signed(start, width)
48+
return self.get(start, width)
49+
50+
def get(self, start: int, width: int) -> int:
51+
"""Return an unsigned integer of *width* bits at bit position *start*."""
52+
# If the requested range extends beyond the end of the vector,
53+
# only the available bits are returned
54+
if width <= 0 or start >= self._length:
55+
return 0
56+
available = min(width, self._length - start)
57+
shift = self._length - start - available
58+
return (self._value >> shift) & _MASK[available]
59+
60+
def get_signed(self, start: int, width: int) -> int:
61+
"""Return a signed (two's-complement) integer."""
62+
if width <= 0 or start >= self._length:
63+
return 0
64+
available = min(width, self._length - start)
65+
shift = self._length - start - available
66+
val = (self._value >> shift) & _MASK[available]
67+
if val & (1 << (available - 1)):
68+
val -= 1 << available
69+
return val
70+
71+
def get_bool(self, start: int) -> bool:
72+
"""Return a single bit as a boolean."""
73+
if start >= self._length:
74+
return False
75+
shift = self._length - start - 1
76+
return bool((self._value >> shift) & 1)
77+
78+
def get_str(self, start: int, width: int) -> str:
79+
"""Return a 6-bit-encoded AIS text string."""
80+
if width <= 0 or start >= self._length:
81+
return ""
82+
chars = _SIXBIT_CHAR
83+
get = self.get
84+
parts: list[str] = [chars[get(i, 6)] for i in range(start, start + width, 6)]
85+
return "".join(parts).rstrip("@").strip()
86+
87+
def get_bytes(self, start: int, width: int) -> bytes:
88+
"""Return the value of *width* bits at bit position *start* as bytes."""
89+
if width <= 0 or start >= self._length:
90+
return b""
91+
92+
available = min(width, self._length - start)
93+
shift = self._length - start - available
94+
# Not using _MASK because the width might be larger than 257 bits
95+
val = (self._value >> shift) & ((1 << available) - 1)
96+
97+
# Pad to byte boundary on the right
98+
num_bytes = (available + 7) // 8
99+
pad_bits = num_bytes * 8 - available
100+
val <<= pad_bits
101+
102+
return val.to_bytes(num_bytes, "big")
103+
104+
def __len__(self) -> int:
105+
return self._length
106+
107+
def __repr__(self) -> str:
108+
return f"bit_vector(length={self._length})"
109+
110+
def __eq__(self, value: object) -> bool:
111+
try:
112+
if not isinstance(value, bit_vector):
113+
return False
114+
return self._length == value._length and self._value == value._value
115+
except ValueError:
116+
return False

pyais/messages.py

Lines changed: 45 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88

99
import attr
1010

11+
from pyais.bit_vector import bit_vector
1112
from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType, NavAid, StationType, \
1213
TransmitMode, StationIntervals, TurnRate, InlandLoadedType
1314
from pyais.exceptions import InvalidNMEAMessageException, TagBlockNotInitializedException, UnknownMessageException, UnknownPartNoException, \
1415
InvalidDataTypeException, MissingPayloadException
15-
from pyais.util import SIX_BIT_ENCODING, SixBitNibleDecoder, SixBitNibleEncoder, checksum, compute_checksum, decode_bytes_as_ascii6, extract_bits, get_bytes, get_itdma_comm_state, get_num, get_sotdma_comm_state, chk_to_int, coerce_val, b64encode_str, is_auxiliary_craft
16+
from pyais.util import SIX_BIT_ENCODING, SixBitNibleEncoder, checksum, compute_checksum, get_itdma_comm_state, get_sotdma_comm_state, chk_to_int, coerce_val, b64encode_str, is_auxiliary_craft
1617

1718
NMEA_VALUE = typing.Union[str, float, int, bool, bytes]
1819

@@ -535,9 +536,8 @@ class AISSentence(NMEASentence):
535536
'frag_num',
536537
'seq_id',
537538
'payload',
538-
'data',
539+
'bv',
539540
'ais_id',
540-
'total_bits',
541541
'channel',
542542
)
543543

@@ -575,8 +575,8 @@ def __init__(self, raw: bytes) -> None:
575575
raise InvalidNMEAMessageException("Too many fragments")
576576

577577
# Finally decode bytes into bits
578-
self.data, self.total_bits = SixBitNibleDecoder().decode(self.payload, self.fill_bits)
579-
self.ais_id = extract_bits(self.data, 0, 6, self.total_bits)
578+
self.bv = bit_vector(self.payload, self.fill_bits)
579+
self.ais_id = self.bv.get(0, 6)
580580

581581
def asdict(self) -> Dict[str, Any]:
582582
"""
@@ -641,9 +641,7 @@ def assemble_from_iterable(cls, messages: Sequence["AISSentence"]) -> "AISSenten
641641

642642
messages[0].raw = raw
643643
messages[0].payload = payload
644-
data, total_bits = SixBitNibleDecoder().decode(payload, fill_bits=messages[-1].fill_bits)
645-
messages[0].data = data
646-
messages[0].total_bits = total_bits
644+
messages[0].bv = bit_vector(payload, messages[-1].fill_bits)
647645
messages[0].is_valid = is_valid
648646
return messages[0]
649647

@@ -670,7 +668,7 @@ def decode(self) -> "ANY_MESSAGE":
670668
if not self.payload:
671669
raise MissingPayloadException(self.raw.decode())
672670
try:
673-
return MSG_CLASS[self.ais_id].from_bytes(self.data, self.total_bits)
671+
return MSG_CLASS[self.ais_id].from_bytes(self.bv)
674672
except KeyError as e:
675673
raise UnknownMessageException(f"The message {self} is not supported!") from e
676674

@@ -818,16 +816,13 @@ def create(cls, **kwargs: NMEA_VALUE) -> "ANY_MESSAGE":
818816
return cls(**args) # type:ignore
819817

820818
@classmethod
821-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
819+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
822820
cur: int = 0
823821
kwargs: typing.Dict[str, typing.Any] = {}
824822

825-
large_number = int.from_bytes(data, byteorder='big')
826-
large_number = large_number >> (8 - (total_bits % 8)) % 8
827-
828823
# Iterate over fields and data
829824
for field in cls.fields():
830-
if cur >= total_bits:
825+
if cur >= len(bv):
831826
# All fields that did not fit into the bit array are None
832827
kwargs[field.name] = None
833828
continue
@@ -839,17 +834,17 @@ def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
839834
val: typing.Any
840835
# Get the correct data type and decoding function
841836
if d_type == int or d_type == bool or d_type == float:
842-
val = get_num(large_number, cur, width, total_bits, signed=field.metadata['signed'])
837+
val = bv.get_num(cur, width, field.metadata['signed'])
843838

844839
if d_type == float:
845840
val = float(val)
846841
elif d_type == bool:
847842
val = bool(val)
848843

849844
elif d_type == str:
850-
val = decode_bytes_as_ascii6(data, cur, width)
845+
val = bv.get_str(cur, width)
851846
elif d_type == bytes:
852-
val = get_bytes(data, cur, width)
847+
val = bv.get_bytes(cur, width)
853848
else:
854849
raise InvalidDataTypeException(d_type)
855850

@@ -1170,13 +1165,13 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME
11701165
return MessageType8Default.create(**kwargs)
11711166

11721167
@classmethod
1173-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
1174-
dac: int = extract_bits(data, 40, 10)
1175-
fid: int = extract_bits(data, 50, 6)
1168+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
1169+
dac: int = bv.get(40, 10)
1170+
fid: int = bv.get(50, 6)
11761171
if dac == 200 and fid == 10:
1177-
return MessageType8Dac200Fid10.from_bytes(data, total_bits)
1172+
return MessageType8Dac200Fid10.from_bytes(bv)
11781173
else:
1179-
return MessageType8Default.from_bytes(data, total_bits)
1174+
return MessageType8Default.from_bytes(bv)
11801175

11811176

11821177
@attr.s(slots=True)
@@ -1407,10 +1402,10 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME
14071402
return MessageType16DestinationA.create(**kwargs)
14081403

14091404
@classmethod
1410-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
1411-
if total_bits > 96:
1412-
return MessageType16DestinationAB.from_bytes(data, total_bits)
1413-
return MessageType16DestinationA.from_bytes(data, total_bits)
1405+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
1406+
if len(bv) > 96:
1407+
return MessageType16DestinationAB.from_bytes(bv)
1408+
return MessageType16DestinationA.from_bytes(bv)
14141409

14151410

14161411
@attr.s(slots=True)
@@ -1651,11 +1646,11 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME
16511646
return MessageType22Broadcast.create(**kwargs)
16521647

16531648
@classmethod
1654-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
1655-
if extract_bits(data, 139, 1):
1656-
return MessageType22Addressed.from_bytes(data, total_bits)
1649+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
1650+
if bv.get(139, 1):
1651+
return MessageType22Addressed.from_bytes(bv)
16571652
else:
1658-
return MessageType22Broadcast.from_bytes(data, total_bits)
1653+
return MessageType22Broadcast.from_bytes(bv)
16591654

16601655

16611656
@attr.s(slots=True)
@@ -1767,15 +1762,15 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME
17671762
raise UnknownPartNoException(f"Partno {partno} is not allowed!")
17681763

17691764
@classmethod
1770-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
1771-
mmsi: int = extract_bits(data, 8, 30)
1772-
partno: int = extract_bits(data, 38, 2)
1765+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
1766+
mmsi: int = bv.get(8, 30)
1767+
partno: int = bv.get(38, 2)
17731768
if partno == 0:
1774-
return MessageType24PartA.from_bytes(data, total_bits)
1769+
return MessageType24PartA.from_bytes(bv)
17751770
elif partno == 1:
17761771
if is_auxiliary_craft(mmsi):
1777-
return MessageType24PartBAuxiliaryCraft.from_bytes(data, total_bits)
1778-
return MessageType24PartB.from_bytes(data, total_bits)
1772+
return MessageType24PartBAuxiliaryCraft.from_bytes(bv)
1773+
return MessageType24PartB.from_bytes(bv)
17791774
else:
17801775
raise UnknownPartNoException(f"Partno {partno} is not allowed!")
17811776

@@ -1859,20 +1854,20 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME
18591854
return MessageType25BroadcastUnstructured.create(**kwargs)
18601855

18611856
@classmethod
1862-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
1863-
addressed: int = extract_bits(data, 38, 1)
1864-
structured: int = extract_bits(data, 39, 1)
1857+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
1858+
addressed: int = bv.get(38, 1)
1859+
structured: int = bv.get(39, 1)
18651860

18661861
if addressed:
18671862
if structured:
1868-
return MessageType25AddressedStructured.from_bytes(data, total_bits)
1863+
return MessageType25AddressedStructured.from_bytes(bv)
18691864
else:
1870-
return MessageType25AddressedUnstructured.from_bytes(data, total_bits)
1865+
return MessageType25AddressedUnstructured.from_bytes(bv)
18711866
else:
18721867
if structured:
1873-
return MessageType25BroadcastStructured.from_bytes(data, total_bits)
1868+
return MessageType25BroadcastStructured.from_bytes(bv)
18741869
else:
1875-
return MessageType25BroadcastUnstructured.from_bytes(data, total_bits)
1870+
return MessageType25BroadcastUnstructured.from_bytes(bv)
18761871

18771872

18781873
@attr.s(slots=True)
@@ -1959,20 +1954,20 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME
19591954
return MessageType26BroadcastUnstructured.create(**kwargs)
19601955

19611956
@classmethod
1962-
def from_bytes(cls, data: bytes, total_bits: int) -> "ANY_MESSAGE":
1963-
addressed: int = extract_bits(data, 38, 1)
1964-
structured: int = extract_bits(data, 39, 1)
1957+
def from_bytes(cls, bv: bit_vector) -> "ANY_MESSAGE":
1958+
addressed: int = bv.get(38, 1)
1959+
structured: int = bv.get(39, 1)
19651960

19661961
if addressed:
19671962
if structured:
1968-
return MessageType26AddressedStructured.from_bytes(data, total_bits)
1963+
return MessageType26AddressedStructured.from_bytes(bv)
19691964
else:
1970-
return MessageType26AddressedUnstructured.from_bytes(data, total_bits)
1965+
return MessageType26AddressedUnstructured.from_bytes(bv)
19711966
else:
19721967
if structured:
1973-
return MessageType26BroadcastStructured.from_bytes(data, total_bits)
1968+
return MessageType26BroadcastStructured.from_bytes(bv)
19741969
else:
1975-
return MessageType26BroadcastUnstructured.from_bytes(data, total_bits)
1970+
return MessageType26BroadcastUnstructured.from_bytes(bv)
19761971

19771972

19781973
@attr.s(slots=True)

0 commit comments

Comments
 (0)