Skip to content

Commit a407a2f

Browse files
committed
Bump python requirement to 3.9 and remove 3.8 tests and github workflow
Add support for MIL-1750A float parsing Add test cases pulled from MIL-1750A standard document
1 parent 3ed5c05 commit a407a2f

File tree

6 files changed

+136
-35
lines changed

6 files changed

+136
-35
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ jobs:
44
python-version-matrix:
55
strategy:
66
matrix:
7-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
7+
python-version: ["3.9", "3.10", "3.11", "3.12"]
88
uses: ./.github/workflows/test-python-version.yml
99
with:
1010
python-version: ${{ matrix.python-version }}

docker-compose.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,6 @@ services:
99
build:
1010
target: style
1111

12-
3.8-tests:
13-
image: space-packets-3.8-test:latest
14-
build:
15-
target: test
16-
args:
17-
- BASE_IMAGE_PYTHON_VERSION=3.8
18-
1912
3.9-tests:
2013
image: space-packets-3.9-test:latest
2114
build:

docs/source/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Release notes for the `space_packet_parser` library
1616
``f"{int.from_bytes(data, byteorder='big'):0{len(data)*8}b}"``
1717
- Fix EnumeratedParameterType to handle duplicate labels
1818
- Add error reporting for unsupported and invalid parameter types
19+
- Add support for MIL-1750A floats (32-bit only)
1920

2021
### v4.2.0 (released)
2122
- Parse short and long descriptions of parameters

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ keywords = [
3535
]
3636

3737
[tool.poetry.dependencies]
38-
python = ">=3.8"
38+
python = ">=3.9"
3939
lxml = ">=4.8.0"
4040

4141
[tool.poetry.group.dev.dependencies]
@@ -50,6 +50,7 @@ myst-parser = "*"
5050
sphinx-autoapi = "*"
5151
sphinx-rtd-theme = "*"
5252
coverage = "*"
53+
numpy = "*"
5354

5455
[tool.poetry.group.examples]
5556
optional = true

space_packet_parser/xtcedef.py

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Module for parsing XTCE xml files to specify packet format"""
22
# Standard
3-
from abc import ABCMeta
3+
from abc import ABCMeta, abstractmethod
44
from collections import namedtuple
55
from dataclasses import dataclass, field
66
import inspect
@@ -111,6 +111,7 @@ class MatchCriteria(AttrComparable, metaclass=ABCMeta):
111111
}
112112

113113
@classmethod
114+
@abstractmethod
114115
def from_match_criteria_xml_element(cls, element: ElementTree.Element, ns: dict):
115116
"""Abstract classmethod to create a match criteria object from an XML element.
116117
@@ -127,6 +128,7 @@ def from_match_criteria_xml_element(cls, element: ElementTree.Element, ns: dict)
127128
"""
128129
raise NotImplementedError()
129130

131+
@abstractmethod
130132
def evaluate(self, parsed_data: dict, current_parsed_value: Optional[Union[int, float]] = None) -> bool:
131133
"""Evaluate match criteria down to a boolean.
132134
@@ -609,6 +611,7 @@ class Calibrator(AttrComparable, metaclass=ABCMeta):
609611
"""Abstract base class for XTCE calibrators"""
610612

611613
@classmethod
614+
@abstractmethod
612615
def from_calibrator_xml_element(cls, element: ElementTree.Element, ns: dict) -> 'Calibrator':
613616
"""Abstract classmethod to create a default_calibrator object from an XML element.
614617
@@ -625,6 +628,7 @@ def from_calibrator_xml_element(cls, element: ElementTree.Element, ns: dict) ->
625628
"""
626629
return NotImplemented
627630

631+
@abstractmethod
628632
def calibrate(self, uncalibrated_value: Union[int, float]) -> Union[int, float]:
629633
"""Takes an integer-encoded or float-encoded value and returns a calibrated version.
630634
@@ -1035,6 +1039,7 @@ class DataEncoding(AttrComparable, metaclass=ABCMeta):
10351039
"""Abstract base class for XTCE data encodings"""
10361040

10371041
@classmethod
1042+
@abstractmethod
10381043
def from_data_encoding_xml_element(cls, element: ElementTree.Element, ns: dict) -> 'DataEncoding':
10391044
"""Abstract classmethod to create a data encoding object from an XML element.
10401045
@@ -1456,6 +1461,7 @@ def __init__(self, size_in_bits: int,
14561461
def _calculate_size(self, packet: Packet) -> int:
14571462
return self.size_in_bits
14581463

1464+
@abstractmethod
14591465
def _get_raw_value(self, packet: Packet) -> Union[int, float]:
14601466
"""Read the raw value from the packet data
14611467
@@ -1472,6 +1478,16 @@ def _get_raw_value(self, packet: Packet) -> Union[int, float]:
14721478
"""
14731479
raise NotImplementedError()
14741480

1481+
@staticmethod
1482+
def twos_complement(val: int, bit_width: int) -> int:
1483+
"""Take the twos complement of val
1484+
1485+
Used when parsing ints and some floats
1486+
"""
1487+
if (val & (1 << (bit_width - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255
1488+
return val - (1 << bit_width) # compute negative value
1489+
return val
1490+
14751491
def parse_value(self,
14761492
packet: Packet,
14771493
**kwargs) -> Tuple[Union[int, float], Union[int, float]]:
@@ -1522,10 +1538,8 @@ def _get_raw_value(self, packet: Packet) -> int:
15221538
)
15231539
if self.encoding == 'unsigned':
15241540
return val
1525-
# It is a signed integer and we need to take into account the first bit
1526-
if (val & (1 << (self.size_in_bits - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255
1527-
return val - (1 << self.size_in_bits) # compute negative value
1528-
return val # return positive value as is
1541+
# It is a signed integer, and we need to take into account the first bit
1542+
return self.twos_complement(val, self.size_in_bits)
15291543

15301544
@classmethod
15311545
def from_data_encoding_xml_element(cls, element: ElementTree.Element, ns: dict) -> 'IntegerDataEncoding':
@@ -1561,8 +1575,6 @@ def __init__(self, size_in_bits: int, encoding: str = 'IEEE-754',
15611575
context_calibrators: Optional[List[ContextCalibrator]] = None):
15621576
"""Constructor
15631577
1564-
# TODO: Implement MIL-1650A encoding option
1565-
15661578
Parameters
15671579
----------
15681580
size_in_bits : int
@@ -1581,33 +1593,69 @@ def __init__(self, size_in_bits: int, encoding: str = 'IEEE-754',
15811593
if encoding not in self._supported_encodings:
15821594
raise ValueError(f"Invalid encoding type {encoding} for float data. "
15831595
f"Must be one of {self._supported_encodings}.")
1584-
if encoding == 'MIL-1750A':
1585-
raise NotImplementedError("MIL-1750A encoded floats are not supported by this library yet.")
1596+
if encoding == 'MIL-1750A' and size_in_bits != 32:
1597+
raise ValueError("MIL-1750A encoded floats must be 32 bits, per the MIL-1750A spec. See "
1598+
"https://www.xgc-tek.com/manuals/mil-std-1750a/c191.html#AEN324")
15861599
if encoding == 'IEEE-754' and size_in_bits not in (16, 32, 64):
15871600
raise ValueError(f"Invalid size_in_bits value for IEEE-754 FloatDataEncoding, {size_in_bits}. "
15881601
"Must be 16, 32, or 64.")
15891602
super().__init__(size_in_bits, encoding=encoding, byte_order=byte_order,
15901603
default_calibrator=default_calibrator, context_calibrators=context_calibrators)
15911604

1592-
if self.byte_order == "leastSignificantByteFirst":
1593-
self._struct_format = "<"
1605+
if self.encoding == "MIL-1750A":
1606+
def _mil_parse_func(mil_bytes: bytes):
1607+
"""Parsing function for MIL-1750A floats"""
1608+
# MIL 1750A floats are always 32 bit
1609+
# See: https://www.xgc-tek.com/manuals/mil-std-1750a/c191.html#AEN324
1610+
#
1611+
# MSB LSB MSB LSB
1612+
# ------------------------------------------------------------------
1613+
# | S| Mantissa | Exponent |
1614+
# ------------------------------------------------------------------
1615+
# 0 1 23 24 31
1616+
bytes_as_int = int.from_bytes(mil_bytes, byteorder='big')
1617+
exponent = bytes_as_int & 0xFF # last 8 bits
1618+
mantissa = (bytes_as_int >> 8) & 0xFFFFFFFF # bits 0 through 23 (24 bits)
1619+
# We include the sign bit with the mantissa because we can just take the twos complement
1620+
# of it directly and use it in the final calculation for the value
1621+
1622+
# Both mantissa and exponent are stored as twos complement with no bias
1623+
exponent = self.twos_complement(exponent, 8)
1624+
mantissa = self.twos_complement(mantissa, 24)
1625+
1626+
# Calculate float value using native Python floats, which are more precise
1627+
return mantissa * (2.0 ** (exponent - (24 - 1)))
1628+
1629+
# Set up the parsing function just once, so we can use it repeatedly with _get_raw_value
1630+
self.parse_func = _mil_parse_func
15941631
else:
1595-
# Big-endian is the default
1596-
self._struct_format = ">"
1632+
if self.byte_order == "leastSignificantByteFirst":
1633+
self._struct_format = "<"
1634+
else:
1635+
# Big-endian is the default
1636+
self._struct_format = ">"
1637+
1638+
if self.size_in_bits == 16:
1639+
self._struct_format += "e"
1640+
elif self.size_in_bits == 32:
1641+
self._struct_format += "f"
1642+
elif self.size_in_bits == 64:
1643+
self._struct_format += "d"
1644+
1645+
def ieee_parse_func(data: bytes):
1646+
"""Parsing function for IEEE floats"""
1647+
# The packet data we got back is always extracted in big-endian order
1648+
# but the struct format code contains the endianness of the float data
1649+
return struct.unpack(self._struct_format, data)[0]
15971650

1598-
if self.size_in_bits == 16:
1599-
self._struct_format += "e"
1600-
elif self.size_in_bits == 32:
1601-
self._struct_format += "f"
1602-
elif self.size_in_bits == 64:
1603-
self._struct_format += "d"
1651+
# Set up the parsing function just once, so we can use it repeatedly with _get_raw_value
1652+
self.parse_func: callable = ieee_parse_func
16041653

16051654
def _get_raw_value(self, packet):
16061655
"""Read the data in as bytes and return a float representation."""
16071656
data = packet.read_as_bytes(self.size_in_bits)
1608-
# The packet data we got back is always extracted in big-endian order
1609-
# but the struct format code contains the endianness of the float data
1610-
return struct.unpack(self._struct_format, data)[0]
1657+
# The parsing function is fully set during initialization to save time during parsing
1658+
return self.parse_func(data)
16111659

16121660
@classmethod
16131661
def from_data_encoding_xml_element(cls, element: ElementTree.Element, ns: dict) -> 'FloatDataEncoding':

tests/unit/test_xtcedef.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Installed
55
import pytest
66
import lxml.etree as ElementTree
7+
import numpy as np
78
# Local
89
from space_packet_parser import xtcedef, parser
910

@@ -1506,7 +1507,7 @@ def test_float_parameter_type(xml_string: str, expectation):
15061507
# Test big endian 64-bit float
15071508
(xtcedef.FloatParameterType('TEST_FLOAT', xtcedef.FloatDataEncoding(64)),
15081509
xtcedef.Packet(b'\x3F\xF9\xE3\x77\x9B\x97\xF4\xA8'), # 64-bit IEEE 754 value of Phi
1509-
1.61803),
1510+
1.6180339),
15101511
# Test float parameter type encoded as big endian 16-bit integer with contextual polynomial calibrator
15111512
(xtcedef.FloatParameterType(
15121513
'TEST_FLOAT',
@@ -1523,16 +1524,73 @@ def test_float_parameter_type(xml_string: str, expectation):
15231524
xtcedef.Packet(0b1111111111010110.to_bytes(length=2, byteorder='big'),
15241525
parsed_data={'PKT_APID': parser.ParsedDataItem('PKT_APID', 1101)}),
15251526
-82.600000),
1527+
# Test MIL 1750A encoded floats.
1528+
# Test values taken from: https://www.xgc-tek.com/manuals/mil-std-1750a/c191.html#AEN324
1529+
(xtcedef.FloatParameterType(
1530+
'MIL_1750A_FLOAT',
1531+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1532+
xtcedef.Packet(b'\x7f\xff\xff\x7f'),
1533+
0.9999998 * (2 ** 127)),
1534+
(xtcedef.FloatParameterType(
1535+
'MIL_1750A_FLOAT',
1536+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1537+
xtcedef.Packet(b'\x40\x00\x00\x7f'),
1538+
0.5 * (2 ** 127)),
1539+
(xtcedef.FloatParameterType(
1540+
'MIL_1750A_FLOAT',
1541+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1542+
xtcedef.Packet(b'\x50\x00\x00\x04'),
1543+
0.625 * (2 ** 4)),
1544+
(xtcedef.FloatParameterType(
1545+
'MIL_1750A_FLOAT',
1546+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1547+
xtcedef.Packet(b'\x40\x00\x00\x01'),
1548+
0.5 * (2 ** 1)),
1549+
(xtcedef.FloatParameterType(
1550+
'MIL_1750A_FLOAT',
1551+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1552+
xtcedef.Packet(b'\x40\x00\x00\x00'),
1553+
0.5 * (2 ** 0)),
1554+
(xtcedef.FloatParameterType(
1555+
'MIL_1750A_FLOAT',
1556+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1557+
xtcedef.Packet(b'\x40\x00\x00\xff'),
1558+
0.5 * (2 ** -1)),
1559+
(xtcedef.FloatParameterType(
1560+
'MIL_1750A_FLOAT',
1561+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1562+
xtcedef.Packet(b'\x40\x00\x00\x80'),
1563+
0.5 * (2 ** -128)),
1564+
(xtcedef.FloatParameterType(
1565+
'MIL_1750A_FLOAT',
1566+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1567+
xtcedef.Packet(b'\x00\x00\x00\x00'),
1568+
0.0 * (2 ** 0)),
1569+
(xtcedef.FloatParameterType(
1570+
'MIL_1750A_FLOAT',
1571+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1572+
xtcedef.Packet(b'\x80\x00\x00\x00'),
1573+
-1.0 * (2 ** 0)),
1574+
(xtcedef.FloatParameterType(
1575+
'MIL_1750A_FLOAT',
1576+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1577+
xtcedef.Packet(b'\xBF\xFF\xFF\x80'),
1578+
-0.5000001 * (2 ** -128)),
1579+
(xtcedef.FloatParameterType(
1580+
'MIL_1750A_FLOAT',
1581+
xtcedef.FloatDataEncoding(32, encoding="MIL-1750A")),
1582+
xtcedef.Packet(b'\x9F\xFF\xFF\x04'),
1583+
-0.7500001 * (2 ** 4)),
15261584
]
15271585
)
15281586
def test_float_parameter_parsing(parameter_type, packet, expected):
15291587
"""Test parsing float parameters"""
15301588
raw, derived = parameter_type.parse_value(packet)
1589+
# NOTE: These results are compared with a relative tolerance due to the imprecise storage of floats
15311590
if derived:
1532-
# NOTE: These results are rounded due to the imprecise storage of floats
1533-
assert round(derived, 5) == expected
1591+
assert np.isclose(derived, expected, rtol=0.000001)
15341592
else:
1535-
assert round(raw, 5) == expected
1593+
assert np.isclose(raw, expected, rtol=0.000001)
15361594

15371595

15381596
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)