Decoding IEEE 754 Floats from Instrument Binary Blocks

Raw measurement blocks from oscilloscopes, DAQ cards, and Modbus gateways arrive as packed IEEE 754 bytes that decode into physically impossible values the instant byte order, width, or word ordering is assumed wrong. This guide, part of Binary & ASCII Format Parsing inside the Data Capture, Validation & Metadata Sync pipeline, pins down correct single- and double-precision float extraction with struct and numpy — endianness, 32- versus 64-bit selection, NaN/Inf/subnormal handling, and the word-swapped floats that PLC gateways emit.

Decode Contract & Byte-Order Scope

This page assumes you already hold a clean payload: a contiguous run of bytes that is only floats, with the framing, delimiters, and length prefix already stripped. Extracting that clean run from a #42048-style preamble is the job of the neighbouring Parsing IEEE 488.2 Definite-Length Block Data guide; integrity of the bytes themselves is proven earlier through Checksum & CRC Validation. What remains here is the numeric decode: turning N bytes into N/4 or N/8 finite measurements, or explicitly rejecting the ones that are not.

Four parameters fully determine the decode, and every one of them is a device property that must be declared, never guessed: width (32-bit f or 64-bit d), byte order (< little-endian or > big-endian in struct terms), word ordering (natural, or the swapped 16-bit register pairs common to Modbus/PLC bridges), and a non-finite policy (what to do with NaN, ±Inf, subnormals, and reserved sentinel fills). SCPI instruments expose the first two directly: FORM:DATA REAL,32 selects single precision and FORM:BORD NORM / FORM:BORD SWAP selects big- or little-endian transmission on the same device. Query these before every acquisition run rather than caching them, because a firmware update or a front-panel change silently flips BORD and turns every subsequent sample into large-magnitude garbage.

Reconstructing the Value: Sign, Exponent, Mantissa

A single-precision float packs one sign bit s, an 8-bit biased exponent e, and a 23-bit trailing mantissa m into 32 bits. For a normal number (0 < e < 255) the value is reconstructed as:

The bias of 127 (1023 for double precision, with an 11-bit exponent and 52-bit mantissa) lets the same field encode magnitudes from roughly 1.2 \times 10^{-38} to 3.4 \times 10^{38}. Two exponent extremes are reserved and decode specially: e = 0 gives subnormals (v = (-1)^s\,(m/2^{23})\,2^{-126}, no implicit leading 1) that gracefully underflow toward zero, and e = 255 gives ±Inf when m = 0 and NaN when m \neq 0. Working the canonical pattern 0x40490FDB by hand fixes the arithmetic: s = 0, e = 0x80 = 128, m = 0x490FDB = 4{,}788{,}187, so v = (1 + 4788187/8388608)\cdot 2^{1} = 3.1415927. That is why a round-trip against 0x40490FDB → 3.14159 and 0x7FC00000 → NaN is the fastest way to prove a decoder’s byte order and width are correct before it ever touches live hardware.

You never implement this bit arithmetic by hand in production — struct.unpack and numpy dtypes do it in C — but the decomposition is what makes the failure modes legible. A one-byte offset shifts the exponent field into the mantissa, which is exactly why a shifted buffer surfaces as 3.4e38 rather than a small wrong number: the corrupted exponent dominates.

Byte Order, Width, and the Modbus Word Swap

Byte order alone is not the whole story. A 32-bit float crossing a Modbus or PLC gateway spans two 16-bit holding registers, and gateways disagree on whether the high-order register ships first. The result is four wire layouts of the same value, and picking the wrong one yields a structurally valid float of plausible-but-wrong magnitude — the most dangerous class of error, because it never raises an exception.

One float, four wire byte orderings The IEEE 754 single-precision value 3.14159 has canonical big-endian bytes 40 49 0F DB, labelled A, B, C, D from most to least significant. A source node on the left holds this value and fans out to four rows on the right. Each row shows the four bytes in a different physical order and names the ordering: ABCD is big-endian, decoded with the struct prefix greater-than f, used by most USB-TMC scopes and network-order LXI. DCBA is little-endian, prefix less-than f, used by x86 DAQ cards. BADC is byte-swapped big-endian seen on some legacy GPIB controllers. CDAB is the word-swapped mid-little-endian order emitted by many Modbus and PLC gateways. Bytes keep a consistent colour across rows so the permutation is traceable: A teal, B purple, C green, D amber. Only a decoder configured for the matching ordering recovers 3.14159; the other three yield plausible but wrong magnitudes. ONE VALUE 3.14159 • FOUR WIRE LAYOUTS OF THE SAME FOUR BYTES 3.14159 0x40490FDB MSByte A → LSByte D 40 49 0F DB ABCD · big-endian >f USB-TMC scopes, network-order LXI DB 0F 49 40 DCBA · little-endian <f x86 DAQ cards, native firmware 49 40 DB 0F BADC · byte-swapped some legacy GPIB controllers 0F DB 40 49 CDAB · word-swapped Modbus / PLC gateways (“mid-little”) Same colours track bytes A/B/C/D across rows — only the matching decoder recovers 3.14159.

The identical four bytes of 3.14159 shipped in four orderings; a decoder configured for the wrong permutation produces a valid float of the wrong magnitude, never an exception.

The word swap is not a byte swap. Little-endian (DCBA) reverses all four bytes; the Modbus mid-little order (CDAB) keeps each 16-bit register’s internal byte order but transmits the low register first. That distinction is why treating a word-swapped stream as plain little-endian still gives wrong numbers — you have to swap 16-bit halves, not individual bytes.

Production Float Block Decoder

The decoder below takes the four device parameters explicitly, validates length before touching the bytes, applies the optional word swap, vectorizes the decode through a numpy dtype that carries endianness, and inspects the raw bit patterns for reserved sentinels before float coercion. It is standard-library plus numpy, with no per-sample Python loop.

from __future__ import annotations

import numpy as np
from dataclasses import dataclass
from enum import Enum
from typing import Literal

ByteOrder = Literal["big", "little"]


class NonFinitePolicy(Enum):
    """What to do when a decoded sample is NaN, +/-Inf, or a sentinel fill."""
    RAISE = "raise"      # abort the whole block on the first offender
    FLAG = "flag"        # keep the value, return a boolean rejection mask
    NULLIFY = "nullify"  # overwrite with NaN, preserve array shape/length


class FloatDecodeError(ValueError):
    """Raised on length, alignment, width, or policy violations."""


@dataclass(frozen=True)
class DecodedBlock:
    values: np.ndarray          # float32 or float64 in host byte order
    nonfinite_mask: np.ndarray  # bool, True where NaN / +/-Inf / sentinel
    count: int


class FloatBlockDecoder:
    """Decode a clean IEEE 754 payload with an explicit byte order, width,
    word-swap flag, and non-finite policy. One decoder per instrument."""

    def __init__(
        self,
        *,
        width: Literal[32, 64] = 32,
        byte_order: ByteOrder = "big",
        word_swapped: bool = False,
        policy: NonFinitePolicy = NonFinitePolicy.FLAG,
        sentinels: tuple[int, ...] = (0x7FFFFFFF, 0xFFFFFFFF),
    ) -> None:
        if width not in (32, 64):
            raise FloatDecodeError(f"width must be 32 or 64, got {width}")
        if word_swapped and width != 32:
            raise FloatDecodeError("word_swapped applies only to 32-bit floats")
        self.item_bytes = width // 8
        self.width = width
        self.word_swapped = word_swapped
        self.policy = policy
        self._sentinels = np.array(sentinels, dtype=np.uint64)
        endian = ">" if byte_order == "big" else "<"
        self._float_dtype = np.dtype(f"{endian}f{self.item_bytes}")
        self._uint_dtype = np.dtype(f"{endian}u{self.item_bytes}")

    def decode_block(self, raw: bytes | bytearray | memoryview) -> DecodedBlock:
        """Validate, optionally word-swap, and vectorize-decode a payload."""
        n = len(raw)
        if n == 0:
            raise FloatDecodeError("empty payload")
        if n % self.item_bytes != 0:
            raise FloatDecodeError(
                f"payload of {n} B is not a multiple of {self.item_bytes} B "
                "-- header offset not stripped or count/length mismatch"
            )

        buf = bytes(raw)
        if self.word_swapped:
            buf = self._swap_words(buf)

        # Read raw bit patterns first: 0x7FFFFFFF re-reads as NaN once coerced
        # to float, so sentinels must be caught at the integer level.
        as_uint = np.frombuffer(buf, dtype=self._uint_dtype)
        sentinel_mask = np.isin(as_uint.astype(np.uint64), self._sentinels)

        target = np.float32 if self.width == 32 else np.float64
        values = np.frombuffer(buf, dtype=self._float_dtype).astype(target)
        nonfinite = ~np.isfinite(values) | sentinel_mask
        return self._apply_policy(values, nonfinite)

    @staticmethod
    def _swap_words(buf: bytes) -> bytes:
        """Swap the two 16-bit words of each 32-bit float (CDAB <-> ABCD),
        preserving intra-word byte order -- a byte swap would corrupt it."""
        b = np.frombuffer(buf, dtype=np.uint8).reshape(-1, 4)
        return b[:, [2, 3, 0, 1]].tobytes()

    def _apply_policy(
        self, values: np.ndarray, nonfinite: np.ndarray
    ) -> DecodedBlock:
        count = int(values.size)
        if nonfinite.any():
            if self.policy is NonFinitePolicy.RAISE:
                idx = int(np.argmax(nonfinite))
                raise FloatDecodeError(
                    f"non-finite sample at index {idx}; "
                    f"{int(nonfinite.sum())} of {count} rejected"
                )
            if self.policy is NonFinitePolicy.NULLIFY:
                values = values.copy()
                values[nonfinite] = np.nan
        return DecodedBlock(values=values, nonfinite_mask=nonfinite, count=count)

Subnormals are intentionally not rejected by np.isfinite — they are finite, valid, and represent genuine near-zero readings, so they pass through unflagged. If a channel’s physics forbids denormals (a pressure sensor that never legitimately reads below 1e-30), add a magnitude bound to the policy layer rather than conflating it with the NaN/Inf gate. Attach the decoded array’s provenance — the resolved FORM:BORD state, width, and word-swap flag — through Metadata Injection Workflows so a later reviewer can reproduce the exact decode that produced a stored measurement.

Validation Against Known Bit Patterns

Never validate a float decoder against live instrument data first — you cannot tell a decode bug from a hardware fault that way. Round-trip fixed bit patterns whose values you know exactly, then vary one decode parameter at a time to prove each guard fires:

def test_known_bit_patterns() -> None:
    be = FloatBlockDecoder(width=32, byte_order="big")

    # Canonical single-precision pi round-trips exactly.
    pi = be.decode_block(bytes.fromhex("40490FDB"))
    assert abs(float(pi.values[0]) - 3.14159265) < 1e-6
    assert not pi.nonfinite_mask[0]

    # 0x7FC00000 is a quiet NaN and must flag, not pass through as data.
    nan_block = be.decode_block(bytes.fromhex("7FC00000"))
    assert np.isnan(nan_block.values[0]) and nan_block.nonfinite_mask[0]

    # 0x7FFFFFFF is a common fill sentinel; it re-reads as NaN but is caught
    # at the integer level, so the mask is set even under FLAG policy.
    fill = be.decode_block(bytes.fromhex("7FFFFFFF"))
    assert fill.nonfinite_mask[0]

    # Same wire bytes, wrong endianness -> a valid but wildly wrong magnitude.
    le = FloatBlockDecoder(width=32, byte_order="little")
    wrong = float(le.decode_block(bytes.fromhex("40490FDB")).values[0])
    assert abs(wrong - 3.14159265) > 1.0

    # Modbus word-swapped pi (registers CDAB) recovers only with the flag set.
    ms = FloatBlockDecoder(width=32, byte_order="big", word_swapped=True)
    assert abs(float(ms.decode_block(bytes.fromhex("0FDB4049")).values[0])
               - 3.14159265) < 1e-6

    # Odd length is a hard reject, never a truncated silent decode.
    try:
        be.decode_block(b"\x40\x49\x0f")
        assert False, "3-byte payload must raise"
    except FloatDecodeError:
        pass

On a live rig, the equivalent smoke test is to command the instrument to output one known DC level or a single calibrated reference sample, decode it, and confirm the value lands within tolerance. A whole block reading 3.4e38, alternating sign, or clustering around zero when you expect volts is the unmistakable signature of a parameter mismatch, not a sensor fault.

Failure Modes & Diagnosis

Endianness or word-swap mismatch — plausible but wrong magnitude. The decode raises nothing; every sample is a finite float, just the wrong one. A FORM:BORD SWAP scope read as big-endian, or a Modbus float read as plain little-endian, typically yields values off by many orders of magnitude with a scrambled sign pattern. Diagnose by decoding one known reference sample under all four orderings from the diagram above and picking the one that matches; then pin the byte order per device via a Protocol Abstraction Layer rather than a global default.

Block offset by unstripped header bytes. If the definite-length preamble or a leading status byte is left on the payload, every float is shifted by one to four bytes. A one- or three-byte shift usually trips the n % item_bytes guard and raises cleanly; a two-byte shift on 32-bit data can stay length-aligned and decode into 3.4e38-scale nonsense because the exponent field now sits where the mantissa was. Confirm the byte count against 4 × sample_count and route block extraction through Parsing IEEE 488.2 Definite-Length Block Data.

Sentinel 0x7FFFFFFF mistaken for a real reading. Many instruments write a reserved fill for over-range, no-data, or masked channels. That pattern coerces to NaN once read as a float, so a decoder that checks only np.isfinite cannot distinguish an instrument-signalled gap from a genuine computed NaN. The decoder here catches it at the integer level before coercion — keep the sentinel list synced to the vendor manual, and treat a rising sentinel rate as a channel-health signal for Threshold Tuning & Alerting.

Count/length mismatch from a 32-versus-64-bit assumption. Reading 64-bit doubles as 32-bit floats (or the reverse) doubles or halves the sample count and misaligns every value after the first. The length guard catches a genuinely odd byte count, but an even mismatch decodes silently into twice as many wrong numbers. Cross-check the returned count against the instrument’s reported sample count, and verify the transport delivered whole frames through PySerial Configuration & Tuning so a short read never masquerades as a shorter block.

← Back to Binary & ASCII Format Parsing

References