Repository: NCSU-High-Powered-Rocketry-Club/AirbrakesV2
Issue category: Performance Issue

Issue:
Refactor logging to be more clear and performant
After the changes in #146 in the logger to get #147 resolved, our logging process has slightly slowed down due to use of `msgpec.structs.asdict()` instead of `msgspec.to_builtins()`. We can still keep up with the IMU process, but the margin has reduced. 

I propose the following optimizations:

1. Make `LoggerDataPacket` a msgspec Struct again. Cost of initializing a new Struct is much cheaper than converting existing ones to a dict anyway.
2. The main process shouldn't be making the LoggerDataPacket into a dict, we can send it to the Logger process as a struct, and take advantage of the de/encoder now.

Rejected idea:
1. We technically don't need to attach `Context` and `Servo` packets to every LoggerDataPacket, as that just increases the amount of bytes we need to transfer to the other process. But separating that from `LoggerDataPacket` is a bit too unclean, confusing, and increases development time.


Hints:

Patch:
diff --git a/airbrakes/airbrakes.py b/airbrakes/airbrakes.py
index a4519053..f52d7200 100644
--- a/airbrakes/airbrakes.py
+++ b/airbrakes/airbrakes.py
@@ -1,7 +1,6 @@
 """Module which provides a high level interface to the air brakes system on the rocket."""
 
 import time
-from collections import deque
 from typing import TYPE_CHECKING
 
 from airbrakes.hardware.base_imu import BaseIMU
@@ -84,7 +83,7 @@ def __init__(
         self.state: State = StandbyState(self)
 
         self.shutdown_requested = False
-        self.imu_data_packets: deque[IMUDataPacket] = deque()
+        self.imu_data_packets: list[IMUDataPacket] = []
         self.processor_data_packets: list[ProcessorDataPacket] = []
         self.apogee_predictor_data_packets: list[ApogeePredictorDataPacket] = []
         self.est_data_packets: list[EstimatedDataPacket] = []
@@ -137,7 +136,7 @@ def update(self) -> None:
         self.est_data_packets = [
             data_packet
             for data_packet in self.imu_data_packets
-            if isinstance(data_packet, EstimatedDataPacket)
+            if type(data_packet) is EstimatedDataPacket  # type() is ~55% faster than isinstance()
         ]
 
         # Update the processed data with the new data packets. We only care about EstDataPackets
diff --git a/airbrakes/hardware/base_imu.py b/airbrakes/hardware/base_imu.py
index 47a7d57a..4ac7777e 100644
--- a/airbrakes/hardware/base_imu.py
+++ b/airbrakes/hardware/base_imu.py
@@ -1,7 +1,6 @@
 """Module defining the base class (BaseIMU) for interacting with
 the IMU (Inertial measurement unit) on the rocket."""
 
-import collections
 import contextlib
 import sys
 
@@ -93,19 +92,18 @@ def get_imu_data_packet(self) -> IMUDataPacket | None:
         """
         return self._data_queue.get(timeout=IMU_TIMEOUT_SECONDS)
 
-    def get_imu_data_packets(self, block: bool = True) -> collections.deque[IMUDataPacket]:
+    def get_imu_data_packets(self, block: bool = True) -> list[IMUDataPacket]:
         """
         Returns all available data packets from the IMU.
-        :return: A deque containing the latest data packets from the IMU.
+        :return: A list containing the latest data packets from the IMU.
         """
-        # We use a deque because it's faster than a list for popping from the left
         try:
             packets = self._data_queue.get_many(
                 block=block, max_messages_to_get=MAX_FETCHED_PACKETS, timeout=IMU_TIMEOUT_SECONDS
             )
         except Empty:  # If the queue is empty (i.e. timeout hit), don't bother waiting.
-            return collections.deque()
+            return []
         else:
             if STOP_SIGNAL in packets:  # only used by the MockIMU
-                return collections.deque()
-            return collections.deque(packets)
+                return []
+            return packets
diff --git a/airbrakes/telemetry/logger.py b/airbrakes/telemetry/logger.py
index efad5bce..3d72d41b 100644
--- a/airbrakes/telemetry/logger.py
+++ b/airbrakes/telemetry/logger.py
@@ -18,7 +18,6 @@
     from queue import Empty
 
 import msgspec
-from msgspec.structs import asdict
 
 from airbrakes.constants import (
     BUFFER_SIZE_IN_BYTES,
@@ -32,6 +31,10 @@
 from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
 from airbrakes.telemetry.packets.processor_data_packet import ProcessorDataPacket
 
+DecodedLoggerDataPacket = list[int | float | str]
+"""The type of LoggerDataPacket after an instance of it was decoded from the queue. It is
+the same type as msgspec.to_builtins(LoggerDataPacket)."""
+
 
 class Logger:
     """
@@ -79,14 +82,17 @@ def __init__(self, log_dir: Path) -> None:
         # Create a new log file with the next number in sequence
         self.log_path = log_dir / f"log_{max_suffix + 1}.csv"
         with self.log_path.open(mode="w", newline="") as file_writer:
-            writer = csv.DictWriter(file_writer, fieldnames=list(LoggerDataPacket.__annotations__))
-            writer.writeheader()
+            headers = list(LoggerDataPacket.__struct_fields__)
+            writer = csv.writer(file_writer)
+            writer.writerow(headers)
 
         # Makes a queue to store log messages, basically it's a process-safe list that you add to
         # the back and pop from front, meaning that things will be logged in the order they were
         # added.
         # Signals (like stop) are sent as strings, but data is sent as dictionaries
         msgpack_encoder = msgspec.msgpack.Encoder(enc_hook=Logger._convert_unknown_type_to_str)
+        # No need to specify the type to decode to, since we want to log it immediately, so a list
+        # is wanted (and faster!):
         msgpack_decoder = msgspec.msgpack.Decoder()
 
         if sys.platform == "win32":
@@ -133,19 +139,20 @@ def _convert_unknown_type_to_str(obj_type: Any) -> str:
         return f"{obj_type:.8f}"
 
     @staticmethod
-    def _prepare_log_dict(
+    def _prepare_logger_packets(
         context_data_packet: ContextDataPacket,
         servo_data_packet: ServoDataPacket,
-        imu_data_packets: deque[IMUDataPacket],
+        imu_data_packets: list[IMUDataPacket],
         processor_data_packets: list[ProcessorDataPacket],
         apogee_predictor_data_packets: list[ApogeePredictorDataPacket],
     ) -> list[LoggerDataPacket]:
         """
-        Creates a data packet dictionary representing a row of data to be logged.
+        Creates a data packet representing a row of data to be logged.
         :param context_data_packet: The context data packet to log.
         :param servo_data_packet: The servo data packet to log.
         :param imu_data_packets: The IMU data packets to log.
-        :param processor_data_packets: The processor data packets to log.
+        :param processor_data_packets: The processor data packets to log. This is always the same
+        length as the number of EstimatedDataPackets present in the `imu_data_packets`.
         :param apogee_predictor_data_packets: The apogee predictor data packets to log.
         :return: A deque of LoggerDataPacket objects.
         """
@@ -153,46 +160,98 @@ def _prepare_log_dict(
 
         index = 0  # Index to loop over processor data packets:
 
-        # Convert the imu data packets to a dictionary:
+        # Convert the imu data packets to a LoggerDataPacket:
         for imu_data_packet in imu_data_packets:
-            # Let's first add the state, extension field:
-            logger_fields: LoggerDataPacket = {}
-            # Convert the packets to a dictionary
-            context_data_packet_dict: dict[str, int | str] = asdict(context_data_packet)
-            logger_fields.update(context_data_packet_dict)
-            servo_data_packet_dict: dict[str, int | float] = asdict(servo_data_packet)
-            logger_fields.update(servo_data_packet_dict)
-            # Using to_builtins() is much faster than asdict() for some reason, but we can't use
-            # that since that's going to convert it to a list because of array_like=True
-            imu_data_packet_dict: dict[str, int | float | list[str]] = asdict(imu_data_packet)
-            logger_fields.update(imu_data_packet_dict)
-
-            # Get the processor data packet fields:
-            if isinstance(imu_data_packet, EstimatedDataPacket):
-                processor_data_packet_dict: dict[str, float] = asdict(
-                    processor_data_packets[index],
-                )
-                # Let's drop the "time_since_last_data_packet" field:
-                processor_data_packet_dict.pop("time_since_last_data_packet", None)
-                logger_fields.update(processor_data_packet_dict)
+            logger_packet = LoggerDataPacket(
+                state_letter=context_data_packet.state_letter,
+                set_extension=servo_data_packet.set_extension,
+                encoder_position=servo_data_packet.encoder_position,
+                timestamp=imu_data_packet.timestamp,
+                invalid_fields=imu_data_packet.invalid_fields,
+                fetched_packets_in_main=context_data_packet.fetched_packets_in_main,
+                imu_queue_size=context_data_packet.imu_queue_size,
+                apogee_predictor_queue_size=context_data_packet.apogee_predictor_queue_size,
+                fetched_imu_packets=context_data_packet.fetched_imu_packets,
+                update_timestamp_ns=context_data_packet.update_timestamp_ns,
+            )
 
+            # Get the IMU data packet fields:
+            # Performance comparison (python 3.13.1 on x86_64 linux):
+            # - isinstance is 45.2% faster than match statement
+            # - hasattr is 20.57% faster than isinstance
+            # - type() is 34.85% faster than hasattr
+            if type(imu_data_packet) is EstimatedDataPacket:
+                # Extract all the fields from the EstimatedDataPacket
+                logger_packet.estOrientQuaternionW = imu_data_packet.estOrientQuaternionW
+                logger_packet.estOrientQuaternionX = imu_data_packet.estOrientQuaternionX
+                logger_packet.estOrientQuaternionY = imu_data_packet.estOrientQuaternionY
+                logger_packet.estOrientQuaternionZ = imu_data_packet.estOrientQuaternionZ
+                logger_packet.estPressureAlt = imu_data_packet.estPressureAlt
+                logger_packet.estAttitudeUncertQuaternionW = (
+                    imu_data_packet.estAttitudeUncertQuaternionW
+                )
+                logger_packet.estAttitudeUncertQuaternionX = (
+                    imu_data_packet.estAttitudeUncertQuaternionX
+                )
+                logger_packet.estAttitudeUncertQuaternionY = (
+                    imu_data_packet.estAttitudeUncertQuaternionY
+                )
+                logger_packet.estAttitudeUncertQuaternionZ = (
+                    imu_data_packet.estAttitudeUncertQuaternionZ
+                )
+                logger_packet.estAngularRateX = imu_data_packet.estAngularRateX
+                logger_packet.estAngularRateY = imu_data_packet.estAngularRateY
+                logger_packet.estAngularRateZ = imu_data_packet.estAngularRateZ
+                logger_packet.estCompensatedAccelX = imu_data_packet.estCompensatedAccelX
+                logger_packet.estCompensatedAccelY = imu_data_packet.estCompensatedAccelY
+                logger_packet.estCompensatedAccelZ = imu_data_packet.estCompensatedAccelZ
+                logger_packet.estLinearAccelX = imu_data_packet.estLinearAccelX
+                logger_packet.estLinearAccelY = imu_data_packet.estLinearAccelY
+                logger_packet.estLinearAccelZ = imu_data_packet.estLinearAccelZ
+                logger_packet.estGravityVectorX = imu_data_packet.estGravityVectorX
+                logger_packet.estGravityVectorY = imu_data_packet.estGravityVectorY
+                logger_packet.estGravityVectorZ = imu_data_packet.estGravityVectorZ
+
+                # Now also extract the fields from the ProcessorDataPacket
+                logger_packet.current_altitude = processor_data_packets[index].current_altitude
+                logger_packet.vertical_velocity = processor_data_packets[index].vertical_velocity
+                logger_packet.vertical_acceleration = processor_data_packets[
+                    index
+                ].vertical_acceleration
+
+                # Add index:
                 index += 1
-
-            # Apogee Prediction happens asynchronously, so we need to check if we have a packet
-            # This means that rather than belonging to a specific IMU packet, it belongs to the
-            # context packet.
+            else:  # It is a raw packet:
+                # Extract all the fields from the RawDataPacket
+                logger_packet.scaledAccelX = imu_data_packet.scaledAccelX
+                logger_packet.scaledAccelY = imu_data_packet.scaledAccelY
+                logger_packet.scaledAccelZ = imu_data_packet.scaledAccelZ
+                logger_packet.scaledGyroX = imu_data_packet.scaledGyroX
+                logger_packet.scaledGyroY = imu_data_packet.scaledGyroY
+                logger_packet.scaledGyroZ = imu_data_packet.scaledGyroZ
+                logger_packet.deltaVelX = imu_data_packet.deltaVelX
+                logger_packet.deltaVelY = imu_data_packet.deltaVelY
+                logger_packet.deltaVelZ = imu_data_packet.deltaVelZ
+                logger_packet.deltaThetaX = imu_data_packet.deltaThetaX
+                logger_packet.deltaThetaY = imu_data_packet.deltaThetaY
+                logger_packet.deltaThetaZ = imu_data_packet.deltaThetaZ
+                logger_packet.scaledAmbientPressure = imu_data_packet.scaledAmbientPressure
+
+            # Apogee Prediction happens asynchronously, so we need to check if we have a packet:
 
             # There is a small possibility that we won't log all the apogee predictor data packets
             # if the length of the IMU data packets is less than the length of the apogee predictor
             # data packets. However, this is unlikely to happen in practice. This particular case
             # is NOT covered by tests.
             if apogee_predictor_data_packets:
-                apogee_predictor_data_packet_dict: dict[str, float] = asdict(
-                    apogee_predictor_data_packets.pop(0),
-                )
-                logger_fields.update(apogee_predictor_data_packet_dict)
+                apogee_packet = apogee_predictor_data_packets.pop(0)
+                logger_packet.predicted_apogee = apogee_packet.predicted_apogee
+                logger_packet.a_coefficient = apogee_packet.a_coefficient
+                logger_packet.b_coefficient = apogee_packet.b_coefficient
+                logger_packet.uncertainty_threshold_1 = apogee_packet.uncertainty_threshold_1
+                logger_packet.uncertainty_threshold_2 = apogee_packet.uncertainty_threshold_2
 
-            logger_data_packets.append(logger_fields)
+            logger_data_packets.append(logger_packet)
 
         return logger_data_packets
 
@@ -216,7 +275,7 @@ def log(
         self,
         context_data_packet: ContextDataPacket,
         servo_data_packet: ServoDataPacket,
-        imu_data_packets: deque[IMUDataPacket],
+        imu_data_packets: list[IMUDataPacket],
         processor_data_packets: list[ProcessorDataPacket],
         apogee_predictor_data_packets: list[ApogeePredictorDataPacket],
     ) -> None:
@@ -228,8 +287,8 @@ def log(
         :param processor_data_packets: The processor data packets to log.
         :param apogee_predictor_data_packets: The apogee predictor data packets to log.
         """
-        # We are populating a dictionary with the fields of the logger data packet
-        logger_data_packets: list[LoggerDataPacket] = Logger._prepare_log_dict(
+        # We are populating a list with the fields of the logger data packet
+        logger_data_packets: list[LoggerDataPacket] = Logger._prepare_logger_packets(
             context_data_packet,
             servo_data_packet,
             imu_data_packets,
@@ -268,16 +327,13 @@ def _log_the_buffer(self):
 
     # ------------------------ ALL METHODS BELOW RUN IN A SEPARATE PROCESS -------------------------
     @staticmethod
-    def _truncate_floats(data: LoggerDataPacket) -> dict[str, str | object]:
+    def _truncate_floats(data: DecodedLoggerDataPacket) -> list[str | int]:
         """
-        Truncates the decimal place of the floats in the dictionary to 8 decimal places.
-        :param data: The dictionary to truncate.
-        :return: The truncated dictionary.
+        Truncates the decimal place of the floats in the list to 8 decimal places.
+        :param data: The list of values whose floats we should truncate.
+        :return: The truncated list.
         """
-        return {
-            key: f"{value:.8f}" if isinstance(value, float) else value
-            for key, value in data.items()
-        }
+        return [f"{value:.8f}" if isinstance(value, float) else value for value in data]
 
     def _logging_loop(self) -> None:  # pragma: no cover
         """
@@ -292,12 +348,12 @@ def _logging_loop(self) -> None:  # pragma: no cover
 
         # Set up the csv logging in the new process
         with self.log_path.open(mode="a", newline="") as file_writer:
-            writer = csv.DictWriter(file_writer, fieldnames=list(LoggerDataPacket.__annotations__))
+            writer = csv.writer(file_writer)
             while True:
                 # Get a message from the queue (this will block until a message is available)
                 # Because there's no timeout, it will wait indefinitely until it gets a message.
                 try:
-                    message_fields: list[LoggerDataPacket | Literal["STOP"]] = (
+                    message_fields: list[DecodedLoggerDataPacket | Literal["STOP"]] = (
                         self._log_queue.get_many(timeout=MAX_GET_TIMEOUT_SECONDS)
                     )
                 except Empty:
diff --git a/airbrakes/telemetry/packets/logger_data_packet.py b/airbrakes/telemetry/packets/logger_data_packet.py
index fbcbd405..c4578241 100644
--- a/airbrakes/telemetry/packets/logger_data_packet.py
+++ b/airbrakes/telemetry/packets/logger_data_packet.py
@@ -1,9 +1,9 @@
 """Module for describing the data packet for the logger to log"""
 
-from typing import TypedDict
+import msgspec
 
 
-class LoggerDataPacket(TypedDict, total=False):  # total=False means all fields are NotRequired
+class LoggerDataPacket(msgspec.Struct, array_like=True, kw_only=True):
     """
     Represents a collection of all data that the logger can log in a line. Not every field will be
     filled in every packet. The order in which the fields are defined determines the order in which
@@ -22,54 +22,56 @@ class LoggerDataPacket(TypedDict, total=False):  # total=False means all fields
     invalid_fields: list[str] | None
 
     # Raw Data Packet Fields
-    scaledAccelX: float | None
-    scaledAccelY: float | None
-    scaledAccelZ: float | None
-    scaledGyroX: float | None
-    scaledGyroY: float | None
-    scaledGyroZ: float | None
-    deltaVelX: float | None
-    deltaVelY: float | None
-    deltaVelZ: float | None
-    deltaThetaX: float | None
-    deltaThetaY: float | None
-    deltaThetaZ: float | None
-    scaledAmbientPressure: float | None
+    scaledAccelX: float | None = None
+    scaledAccelY: float | None = None
+    scaledAccelZ: float | None = None
+    scaledGyroX: float | None = None
+    scaledGyroY: float | None = None
+    scaledGyroZ: float | None = None
+    deltaVelX: float | None = None
+    deltaVelY: float | None = None
+    deltaVelZ: float | None = None
+    deltaThetaX: float | None = None
+    deltaThetaY: float | None = None
+    deltaThetaZ: float | None = None
+    scaledAmbientPressure: float | None = None
 
     # Estimated Data Packet Fields
-    estOrientQuaternionW: float | None
-    estOrientQuaternionX: float | None
-    estOrientQuaternionY: float | None
-    estOrientQuaternionZ: float | None
-    estPressureAlt: float | None
-    estAttitudeUncertQuaternionW: float | None
-    estAttitudeUncertQuaternionX: float | None
-    estAttitudeUncertQuaternionY: float | None
-    estAttitudeUncertQuaternionZ: float | None
-    estAngularRateX: float | None
-    estAngularRateY: float | None
-    estAngularRateZ: float | None
-    estCompensatedAccelX: float | None
-    estCompensatedAccelY: float | None
-    estCompensatedAccelZ: float | None
-    estLinearAccelX: float | None
-    estLinearAccelY: float | None
-    estLinearAccelZ: float | None
-    estGravityVectorX: float | None
-    estGravityVectorY: float | None
-    estGravityVectorZ: float | None
+    estOrientQuaternionW: float | None = None
+    estOrientQuaternionX: float | None = None
+    estOrientQuaternionY: float | None = None
+    estOrientQuaternionZ: float | None = None
+    estPressureAlt: float | None = None
+    estAttitudeUncertQuaternionW: float | None = None
+    estAttitudeUncertQuaternionX: float | None = None
+    estAttitudeUncertQuaternionY: float | None = None
+    estAttitudeUncertQuaternionZ: float | None = None
+    estAngularRateX: float | None = None
+    estAngularRateY: float | None = None
+    estAngularRateZ: float | None = None
+    estCompensatedAccelX: float | None = None
+    estCompensatedAccelY: float | None = None
+    estCompensatedAccelZ: float | None = None
+    estLinearAccelX: float | None = None
+    estLinearAccelY: float | None = None
+    estLinearAccelZ: float | None = None
+    estGravityVectorX: float | None = None
+    estGravityVectorY: float | None = None
+    estGravityVectorZ: float | None = None
 
     # Processor Data Packet Fields
-    current_altitude: float | None
-    vertical_velocity: float | None
-    vertical_acceleration: float | None
+    current_altitude: float | None = None
+    vertical_velocity: float | None = None
+    vertical_acceleration: float | None = None
 
     # Apogee Predictor Data Packet Fields
-    predicted_apogee: float | None
-    a_coefficient: float | None
-    b_coefficient: float | None
-    uncertainty_threshold_1: float | None
-    uncertainty_threshold_2: float | None
+    # These fields are "str" because they were numpy.float64, which is converted to a string
+    # when encoded in the logger. Encoding directly to string with 8 decimal places truncation.
+    predicted_apogee: str | None = None
+    a_coefficient: str | None = None
+    b_coefficient: str | None = None
+    uncertainty_threshold_1: str | None = None
+    uncertainty_threshold_2: str | None = None
 
     # Other fields in ContextDataPacket
     fetched_packets_in_main: int | None
diff --git a/airbrakes/utils.py b/airbrakes/utils.py
index bedee488..706c86f1 100644
--- a/airbrakes/utils.py
+++ b/airbrakes/utils.py
@@ -7,8 +7,11 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
+import msgspec
 import psutil
 
+from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
+
 if TYPE_CHECKING:
     import multiprocessing
 
@@ -25,7 +28,14 @@ def get_always_list(self, *args, **kwargs) -> list:
     as the multiprocessing.Queue doesn't have a `get_many` method"""
     fetched = self.get(*args, **kwargs)
     if isinstance(fetched, list):
+        if isinstance(fetched[0], LoggerDataPacket):
+            # Return the encoded packet as a list:
+            return [
+                msgspec.to_builtins(packet, enc_hook=_convert_unknown_type_to_float)
+                for packet in fetched
+            ]
         return fetched
+
     return [fetched]
 
 


Test patch:
diff --git a/tests/auxil/utils.py b/tests/auxil/utils.py
index 3db14c63..24b1d76f 100644
--- a/tests/auxil/utils.py
+++ b/tests/auxil/utils.py
@@ -3,6 +3,7 @@
 from airbrakes.telemetry.packets.apogee_predictor_data_packet import ApogeePredictorDataPacket
 from airbrakes.telemetry.packets.context_data_packet import ContextDataPacket
 from airbrakes.telemetry.packets.imu_data_packet import EstimatedDataPacket, RawDataPacket
+from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
 from airbrakes.telemetry.packets.processor_data_packet import ProcessorDataPacket
 from airbrakes.telemetry.packets.servo_data_packet import ServoDataPacket
 
@@ -57,3 +58,11 @@ def make_apogee_predictor_data_packet(**kwargs) -> ApogeePredictorDataPacket:
 
     dummy_values = {k: 0.123456789 for k in ApogeePredictorDataPacket.__struct_fields__}
     return ApogeePredictorDataPacket(**{**dummy_values, **kwargs})
+
+
+def make_logger_data_packet(**kwargs) -> LoggerDataPacket:
+    """Creates a LoggerDataPacket with the specified keyword arguments. Provides dummy values for
+    arguments not specified."""
+
+    dummy_values = {k: "test" for k in LoggerDataPacket.__struct_fields__}
+    return LoggerDataPacket(**{**dummy_values, **kwargs})
diff --git a/tests/test_components/test_logger.py b/tests/test_components/test_logger.py
index 0927b21c..3957a2be 100644
--- a/tests/test_components/test_logger.py
+++ b/tests/test_components/test_logger.py
@@ -73,12 +73,18 @@ def convert_dict_vals_to_str(d: dict[str, float], truncation: bool = True) -> di
 class TestLogger:
     """Tests the Logger() class in logger.py"""
 
-    sample_data = {
-        "state_letter": "S",
-        "set_extension": "0.0",
-        "timestamp": "4",
-        "invalid_fields": "[]",
-    }
+    sample_ldp = LoggerDataPacket(
+        state_letter="S",
+        set_extension="0.0",
+        timestamp=4,
+        invalid_fields=[],
+        encoder_position=None,
+        fetched_imu_packets=None,
+        fetched_packets_in_main=None,
+        imu_queue_size=None,
+        apogee_predictor_queue_size=None,
+        update_timestamp_ns=None,
+    )
 
     @pytest.fixture(autouse=True)  # autouse=True means run this function before/after every test
     def _clear_directory(self):
@@ -125,7 +131,7 @@ def test_init_log_file_has_correct_headers(self, logger):
         with logger.log_path.open() as f:
             reader = csv.DictReader(f)
             keys = reader.fieldnames
-            assert list(keys) == list(LoggerDataPacket.__annotations__)
+            assert list(keys) == list(LoggerDataPacket.__struct_fields__)
 
     def test_log_buffer_is_full_property(self, logger):
         """Tests whether the property is_log_buffer_full works correctly."""
@@ -156,7 +162,7 @@ def test_logging_loop_start_stop(self, logger):
 
     def test_logger_stop_logs_the_buffer(self, logger):
         logger.start()
-        logger._log_buffer.appendleft(self.sample_data)
+        logger._log_buffer.appendleft(self.sample_ldp)
         logger.stop()
         with logger.log_path.open() as f:
             reader = csv.DictReader(f)
@@ -166,7 +172,7 @@ def test_logger_stop_logs_the_buffer(self, logger):
                 assert len(row) > 5  # Should have other fields with empty values
                 # Only fetch non-empty values:
                 row_dict = {k: v for k, v in row.items() if v}
-                assert row_dict == self.sample_data
+                assert row_dict == convert_dict_vals_to_str(asdict(self.sample_ldp), False)
             assert count == 1
         assert len(logger._log_buffer) == 0
 
@@ -196,14 +202,8 @@ def _logging_loop_patched(self):
         assert values.qsize() == 1
         assert values.get() == "clean exit"
 
-    def test_logger_loop_exception_raised(self, logger):
-        """Tests whether the Logger loop properly propogates unknown exceptions."""
-        logger._log_queue.put({"wrong_filed": "wrong_value"})
-        with pytest.raises(ValueError, match="dict contains fields not in fieldnames"):
-            logger._logging_loop()
-
     def test_logging_loop_add_to_queue(self, logger):
-        logger._log_queue.put(self.sample_data)
+        logger._log_queue.put(self.sample_ldp)
         assert logger._log_queue.qsize() == 1
         logger.start()
         time.sleep(0.01)  # Give the process time to log to file
@@ -216,14 +216,14 @@ def test_logging_loop_add_to_queue(self, logger):
             # Only fetch non-empty values:
             row_dict = {k: v for k, v in row.items() if v}
 
-            assert row_dict == self.sample_data
+            assert row_dict == convert_dict_vals_to_str(asdict(self.sample_ldp), truncation=False)
 
     def test_queue_hits_timeout_and_continues(self, logger, monkeypatch):
         """Tests whether the logger continues to log after a timeout."""
         monkeypatch.setattr("airbrakes.telemetry.logger.MAX_GET_TIMEOUT_SECONDS", 0.01)
         logger.start()
         time.sleep(0.05)
-        logger._log_queue.put(self.sample_data)
+        logger._log_queue.put(self.sample_ldp)
         logger.stop()
         # Let's check the contents of the file:
         with logger.log_path.open() as f:
@@ -234,7 +234,7 @@ def test_queue_hits_timeout_and_continues(self, logger, monkeypatch):
             # Only fetch non-empty values:
             row_dict = {k: v for k, v in row.items() if v}
 
-            assert row_dict == self.sample_data
+            assert row_dict == convert_dict_vals_to_str(asdict(self.sample_ldp), truncation=False)
 
     # This decorator is used to run the same test with different data
     # read more about it here: https://docs.pytest.org/en/stable/parametrize.html
@@ -427,11 +427,11 @@ def test_log_capacity_exceeded_standby(
         processor_data_packets: list[ProcessorDataPacket],
         apogee_predictor_data_packets: list[ApogeePredictorDataPacket],
         monkeypatch,
+        logger,
     ):
         """Tests whether the log buffer works correctly for the Standby and Landed state."""
 
-        monkeypatch.setattr(Logger, "stop", patched_stop)
-        logger = Logger(LOG_PATH)
+        monkeypatch.setattr(logger.__class__, "stop", patched_stop)
         # Test if the buffer works correctly
         logger.start()
         # Log more than IDLE_LOG_CAPACITY packets to test if it stops logging after LOG_BUFFER_SIZE.
@@ -710,12 +710,11 @@ def test_log_buffer_reset_after_landed(
                 [],
                 [],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="S")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_raw_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
-                    }
+                    )
                 ],
             ),
             (
@@ -725,13 +724,12 @@ def test_log_buffer_reset_after_landed(
                 [make_processor_data_packet()],
                 [],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="S")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_est_data_packet()),
-                        **asdict(make_processor_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
-                    }
+                        **only_logged_pdp_fields(asdict(make_processor_data_packet())),
+                    ),
                 ],
             ),
             (
@@ -741,18 +739,17 @@ def test_log_buffer_reset_after_landed(
                 [make_processor_data_packet()],
                 [],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="M")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_raw_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
-                    },
-                    {
+                    ),
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="M")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_est_data_packet()),
-                        **asdict(make_processor_data_packet()),
-                    },
+                        **only_logged_pdp_fields(asdict(make_processor_data_packet())),
+                    ),
                 ],
             ),
             (
@@ -762,19 +759,18 @@ def test_log_buffer_reset_after_landed(
                 [make_processor_data_packet()],
                 [make_apogee_predictor_data_packet()],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="C")),
                         **asdict(make_servo_data_packet(set_extension="0.5")),
                         **asdict(make_raw_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
                         **asdict(make_apogee_predictor_data_packet()),
-                    },
-                    {
+                    ),
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="C")),
                         **asdict(make_servo_data_packet(set_extension="0.5")),
                         **asdict(make_est_data_packet()),
-                        **asdict(make_processor_data_packet()),
-                    },
+                        **only_logged_pdp_fields(asdict(make_processor_data_packet())),
+                    ),
                 ],
             ),
         ],
@@ -785,7 +781,7 @@ def test_log_buffer_reset_after_landed(
             "both in coast",
         ],
     )
-    def test_prepare_log_dict(
+    def test_prepare_logger_packets(
         self,
         logger,
         context_packet,
@@ -795,18 +791,18 @@ def test_prepare_log_dict(
         apogee_predictor_data_packet,
         expected_outputs,
     ):
-        """Tests whether the _prepare_log_dict method creates the correct LoggerDataPackets."""
+        """Tests whether the _prepare_logger_packets method creates correct LoggerDataPackets."""
 
         # set some invalid fields to test if they stay as a list
-        invalid_field = ["something"]
+        invalid_field = ["something", "hey"]
         for imu_packet in imu_data_packets:
             imu_packet.invalid_fields = invalid_field
         # we need to change the output also
         for expected in expected_outputs:
-            expected["invalid_fields"] = invalid_field
+            expected.invalid_fields = invalid_field
 
         # Now let's test the method
-        logger_data_packets = logger._prepare_log_dict(
+        logger_data_packets = logger._prepare_logger_packets(
             context_packet,
             servo_packet,
             imu_data_packets,
@@ -817,20 +813,11 @@ def test_prepare_log_dict(
         assert len(logger_data_packets) == len(expected_outputs)
 
         for logger_data_packet, expected in zip(logger_data_packets, expected_outputs, strict=True):
-            assert isinstance(logger_data_packet, dict)
-            converted = logger_data_packet
-
-            # certain fields are not converted to strings (intentionally. See logger.py)
-            assert isinstance(converted["invalid_fields"], list)
-            assert isinstance(converted["timestamp"], int)
-
-            # Remove "time_since_last_data_packet" from the expected output, since we don't log that
-            expected.pop("time_since_last_data_packet", None)
-
-            assert converted == expected
+            assert isinstance(logger_data_packet, LoggerDataPacket)
+            assert logger_data_packet == expected
 
     def test_benchmark_log_method(self, benchmark, logger):
-        """Tests the performance of the _prepare_log_dict method."""
+        """Tests the performance of the _prepare_logger_packets method."""
         context_packet = make_context_data_packet(state_letter="S")
         servo_packet = make_servo_data_packet(set_extension="0.1")
         imu_data_packets = deque([make_raw_data_packet()])
@@ -846,8 +833,8 @@ def test_benchmark_log_method(self, benchmark, logger):
             apogee_predictor_data_packet,
         )
 
-    def test_benchmark_prepare_log_dict(self, benchmark, logger):
-        """Tests the performance of the _prepare_log_dict method."""
+    def test_benchmark_prepare_logger_packets(self, benchmark, logger):
+        """Tests the performance of the _prepare_logger_packets method."""
         context_packet = make_context_data_packet(state_letter="S")
         servo_packet = make_servo_data_packet(set_extension="0.1")
         imu_data_packets = deque([make_raw_data_packet()])
@@ -855,7 +842,7 @@ def test_benchmark_prepare_log_dict(self, benchmark, logger):
         apogee_predictor_data_packet = [make_apogee_predictor_data_packet()]
 
         benchmark(
-            logger._prepare_log_dict,
+            logger._prepare_logger_packets,
             context_packet,
             servo_packet,
             imu_data_packets,
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 1d5f77ba..1b1190a6 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -250,7 +250,7 @@ def test_update(
             reader = csv.DictReader(f)
             # Check if all headers were logged:
             headers = reader.fieldnames
-            assert list(headers) == list(LoggerDataPacket.__annotations__)
+            assert list(headers) == list(LoggerDataPacket.__struct_fields__)
 
             # Let's just test the first line (excluding the headers) for a few things:
             line = next(reader)
diff --git a/tests/test_packets/test_logged_data_packet.py b/tests/test_packets/test_logged_data_packet.py
index 10fb647b..d71887e5 100644
--- a/tests/test_packets/test_logged_data_packet.py
+++ b/tests/test_packets/test_logged_data_packet.py
@@ -6,35 +6,25 @@
 from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
 from airbrakes.telemetry.packets.processor_data_packet import ProcessorDataPacket
 from airbrakes.telemetry.packets.servo_data_packet import ServoDataPacket
+from tests.auxil.utils import make_logger_data_packet
 
 
 @pytest.fixture
 def logger_data_packet():
-    return LoggerDataPacket(
-        state_letter=TestLoggerDataPacket.state_letter,
-        set_extension=TestLoggerDataPacket.set_extension,
-        timestamp=TestLoggerDataPacket.timestamp,
-    )
+    return make_logger_data_packet()
 
 
 class TestLoggerDataPacket:
     """Tests for the LoggerDataPacket class."""
 
-    state_letter = "test"
-    set_extension = "0.0"
-    timestamp = 10
-
     def test_init(self, logger_data_packet):
         packet = logger_data_packet
-        assert isinstance(packet, dict)
-        assert packet["state_letter"] == self.state_letter
-        assert packet["set_extension"] == self.set_extension
-        assert packet["timestamp"] == self.timestamp
+        assert isinstance(packet, LoggerDataPacket)
 
     def test_logger_data_packet_has_all_fields_of_data_packets(self):
         """Tests whether the LoggerDataPacket class has all the fields of the data packet
         classes."""
-        log_dp_fields = set(LoggerDataPacket.__annotations__)
+        log_dp_fields = set(LoggerDataPacket.__struct_fields__)
 
         context_dp_fields = set(ContextDataPacket.__struct_fields__)
         servo_dp_fields = set(ServoDataPacket.__struct_fields__)
@@ -82,7 +72,7 @@ def test_logger_data_packet_field_order(self):
         # Only context data packet has a different order - one field - "state_letter" is in the
         # beginning, the rest at the end.
 
-        log_dp_fields = list(LoggerDataPacket.__annotations__)
+        log_dp_fields = list(LoggerDataPacket.__struct_fields__)
         context_dp_fields = list(ContextDataPacket.__struct_fields__)
         servo_dp_fields = list(ServoDataPacket.__struct_fields__)
         raw_dp_fields = list(RawDataPacket.__struct_fields__)