I build Modbus TCP FieldKit, an iPhone and iPad app for Modbus TCP. You won't need it here: the scripts below run on a laptop with Python and pymodbus.

Picture a meter manual with the line Voltage L1: 40021, FLOAT32 and no mention of word order. You drop the leading 4, pass 21 as the address, decode with your library's default and print:

volts: 230.92 hertz: 49.75 

The meter's display reads 230.4 V and 49.98 Hz. Half a volt looks like calibration drift, so you move on to the next register.

That output carries two bugs. You would catch either one alone in seconds, because it gives you a number like -3.2e+26. Together they give you a reading close enough to pass a glance at the display.

Reproducing it

I wrote a small simulated meter with a layout you will meet on real devices: low word first. Install pymodbus and run this in one terminal:

# meter.py import asyncio, struct from pymodbus.server import ModbusTcpServer from pymodbus.simulator import DataType, SimData, SimDevice def cdab(value): high, low = struct.unpack(">HH", struct.pack(">f", value)) return [low, high] registers = [0] * 20 + cdab(230.4) + cdab(49.98) + [0] * 10 device = SimDevice(1, simdata=[SimData(0, values=registers, datatype=DataType.REGISTERS)]) async def main(): server = ModbusTcpServer(context=[device], address=("127.0.0.1", 15020)) await server.serve_forever() asyncio.run(main()) 

Then read it the way the manual suggests:

# read_meter.py from pymodbus.client import ModbusTcpClient client = ModbusTcpClient("127.0.0.1", port=15020) client.connect() def read_float(address): regs = client.read_holding_registers(address, count=2, device_id=1).registers return client.convert_from_registers(regs, client.DATATYPE.FLOAT32) print("volts:", round(read_float(21), 2)) print("hertz:", round(read_float(23), 2)) 

I ran both on 30 October 2026 with pymodbus 3.16.1 and Python 3.15 on macOS.

The two bugs

Off by one. 40021 names the 21st holding register, and the frame carries it as address 20. Section 4.4 of the Modbus specification numbers data model elements from 1 and PDU addresses from 0. I wrote up the prefixes and 6-digit references in this guide to register addressing.

Word order. The specification defines 16-bit registers and leaves multi-register values to the vendor. My meter stores the low word first, and convert_from_registers in pymodbus defaults to word_order="big", high word first. The four word orders go by the names ABCD, CDAB, BADC and DCBA.

Fixing both gives the right value:

# fixed.py from pymodbus.client import ModbusTcpClient client = ModbusTcpClient("127.0.0.1", port=15020) client.connect() regs = client.read_holding_registers(20, count=2, device_id=1).registers volts = client.convert_from_registers(regs, client.DATATYPE.FLOAT32, word_order="little") print("volts:", round(volts, 2)) 
volts: 230.4 

The arithmetic behind the cancellation

address 20 21 22 23 register 6666 4366 EB85 4247 | volts | | Hz | 

IEEE 754 encodes 230.4 as 0x43666666. The meter writes the low word 6666 first, then the high word 4366. A read from 21 grabs 4366 EB85. Decoding high word first puts the voltage's real high word in the right slot and fills the low half from the frequency.

The high word of a float32 holds the sign, the exponent and the top seven mantissa bits. At this magnitude 0x4366 pins the value between 230.0 and 231.0, and the low word picks a spot inside that volt. 0xEB85 lands at .92.

You can see how loud each bug is alone by decoding each window under all four orders:

# grid.py import struct from pymodbus.client import ModbusTcpClient ORDERS = {"ABCD": (0, 1, 2, 3), "CDAB": (2, 3, 0, 1), "BADC": (1, 0, 3, 2), "DCBA": (3, 2, 1, 0)} def decode(pair, order): raw = struct.pack(">HH", *pair) return struct.unpack(">f", bytes(raw[i] for i in ORDERS[order]))[0] client = ModbusTcpClient("127.0.0.1", port=15020) client.connect() start = 19 regs = client.read_holding_registers(start, count=6, device_id=1).registers print("addr " + "".join(f"{o:>12}" for o in ORDERS)) for i in range(len(regs) - 1): row = [f"{decode(regs[i:i + 2], o):12.5g}" for o in ORDERS] print(f"{start + i:<6}" + "".join(row)) 
addr ABCD CDAB BADC DCBA 19 3.6734e-41 2.7154e+23 3.6734e-41 2.7154e+23 20 2.7185e+23 230.4 2.7201e+23 2.3069e+23 21 230.92 -3.2221e+26 2.3083e+23 -2.2137e-35 22 -3.222e+26 49.98 -2.2125e-35 49798 23 49.75 2.3776e-41 49664 2.5562e-41 

Bug one alone sits at row 21 under CDAB: -3.2221e+26. Bug two alone sits at row 20 under ABCD: 2.7185e+23. Both together give 230.92 at row 21 under ABCD.

Rows 22 and 23 repeat the pattern for frequency with 49.98 and 49.75. You get two diagonals of sane-looking readings, so a range check can't choose between them.

The tell: decimals that follow the wrong quantity

I rewrote the simulated readings from a second client and read them back with the buggy code:

 true V true Hz read V read Hz 230.40 49.98 230.92 49.75 231.70 49.98 231.92 49.75 229.10 49.98 229.92 49.75 230.40 50.01 230.04 50.00 230.40 49.90 230.60 49.75 

The integer part of the voltage tracks the real voltage. The fraction stays at .92 while the voltage moves, then jumps to .04 and .60 after I change the frequency. The frequency reading snaps to steps of 0.25, because a zero register follows it in my map. On a real meter, the contents of the next register set that fraction.

Catching it on a device

  1. Read a window around the documented address and print the grid. Expect two plausible diagonals.
  2. Choose between them with a value whose decimals you can check: a display with one decimal place, or a setpoint you control. 230.92 and 230.4 differ in the first decimal.
  3. Change the quantity and read again. The fraction has to move with it.
  4. Store the address and word order next to the device model, and add a test with an exact expected value.

For a one-off check, paste two registers into this float converter, which decodes all four orders in the browser. The address converter does the 40021 arithmetic.

FieldKit stores format, word order, scale and unit with each register in a saved device profile, so the next meter of that model starts from the right settings. If you do this next to a panel with a phone, the client page shows what it reads and writes.

Have you hit a plausible but wrong reading on real hardware? Tell me which device in the comments.