Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

MicroPython for Microcontrollers  Module 2 – The language on a chip: MicroPython vs CPython

Numbers, bytes and text on small chips

Single-precision floats on the Pico and ESP32, sensor maths in whole numbers, struct and to_bytes for binary frames, and UTF-8 text in MicroPython 1.29.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict float results on single-precision boards and keep sensor maths in whole numbers
  • Encode and decode binary frames with struct, int.to_bytes and int.from_bytes, signed fields included
  • Handle text within MicroPython's UTF-8 and ASCII encodings, and cut it to a byte limit without breaking a character
  • Format whole numbers of hundredths for display, negative values included

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict float results on single-precision boards and keep sensor maths in whole numbers
  • Encode and decode binary frames with struct, int.to_bytes and int.from_bytes, signed fields included
  • Handle text within MicroPython's UTF-8 and ASCII encodings, and cut it to a byte limit without breaking a character
  • Format whole numbers of hundredths for display, negative values included

Before you start

On this page

Put 16777217.0 into a float on a Pico and the board keeps 16777216.0: it stores floats in 32 bits, and that number needs 25 bits of precision. The same code on your laptop, on the unix port or in the browser build keeps it exactly, so sensor maths that passes its tests on a computer can be off on the board by more than the sensor’s own error.

This lesson shows what single-precision floats do to real calculations, how to keep sensor maths in whole numbers, how to pack and unpack binary frames with struct, int.to_bytes() and int.from_bytes(), and how MicroPython handles text as UTF-8 bytes under only three encoding names.

The Pico and the ESP32 keep floats in 32 bits

The rp2 and esp32 ports build MicroPython with single precision: The 32-bit floating-point format of IEEE 754, with a 24-bit significand, about 7 significant decimal digits. The rp2 and esp32 ports store MicroPython floats this way; CPython uses the 64-bit double format. floats, as their configuration files say with MICROPY_FLOAT_IMPL_FLOAT. The unix port’s standard variant and the browser build use double precision, the same 64-bit floats as CPython. A single-precision float keeps a 24-bit significand, about 7 significant decimal digits, and every operation on such a board rounds its result to the nearest value that fits.

The next program is a model of that rounding: f32() rounds a number to the nearest single-precision value with struct, whose f format is IEEE 754’s 32-bit format, and every operation goes through it, so it gives the result a board stores, not an estimate. It prints each result as a double-precision build keeps it, and as a single-precision board stores it:

Four calculations in double and in single precision Python · floats/float_width.py
"""Four calculations as a double-precision and a single-precision build store their results.

The rp2 and esp32 ports build MicroPython with single-precision floats (MICROPY_FLOAT_IMPL_FLOAT
in their mpconfigport.h); the unix port and the browser build use doubles, as CPython does.
f32() rounds a number to the nearest IEEE 754 single-precision value, as every operation on
such a board does, so this model gives the board's stored result exactly.
"""
import struct


def f32(x):
    return struct.unpack("<f", struct.pack("<f", x))[0]


def short(x):
    """The shortest decimal that reads back as the same single-precision value."""
    for digits in range(1, 10):
        text = "%.*g" % (digits, x)
        if f32(float(text)) == x:
            return text
    return repr(x)


def tenth_added(r, times):
    total = r(0.0)
    for _ in range(times):
        total = r(total + r(0.1))
    return total


CASES = [
    ("0.1 + 0.2", lambda r: r(r(0.1) + r(0.2))),
    ("1 / 3", lambda r: r(r(1.0) / r(3.0))),
    ("16777217.0", lambda r: r(16777217.0)),
    ("0.1 added 10000 times", lambda r: tenth_added(r, 10000)),
]

for name, calc in CASES:
    double = calc(float)
    single = calc(f32)
    print(name)
    print("  double: %r" % double)
    print("  single: %s  (stored as %r)" % (short(single), single))


def gap(x):
    """The distance from x to the next larger single-precision value."""
    bits = struct.unpack("<I", struct.pack("<f", x))[0]
    return struct.unpack("<f", struct.pack("<I", bits + 1))[0] - x


for x in (1.0, 1000.0, 1000000.0, 16777216.0):
    print("next single-precision value after %r is %r away" % (x, gap(x)))

Output

0.1 + 0.2
  double: 0.30000000000000004
  single: 0.3  (stored as 0.30000001192092896)
1 / 3
  double: 0.3333333333333333
  single: 0.33333334  (stored as 0.3333333432674408)
16777217.0
  double: 16777217.0
  single: 16777216  (stored as 16777216.0)
0.1 added 10000 times
  double: 1000.0000000001588
  single: 999.9029  (stored as 999.9028930664062)
next single-precision value after 1.0 is 1.1920928955078125e-07 away
next single-precision value after 1000.0 is 6.103515625e-05 away
next single-precision value after 1000000.0 is 0.0625 away
next single-precision value after 16777216.0 is 2.0 away

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 float_width.py

Test a calculation of your own

Press Edit and add a case to CASES, for example ("21.37 * 100", lambda r: r(r(21.37) * r(100.0))), with every operation wrapped in r(). Run it to see what a Pico stores, before any board is plugged in.

Three things stand out. 0.1 + 0.2 lands exactly on the single-precision value nearest to 0.3, so the board’s result prints as 0.3, while CPython’s famous 0.30000000000000004 appears only in double precision. 16777217.0 cannot be stored at all: above 2²⁴ = 16,777,216 the next value is 2.0 away. And adding 0.1 ten thousand times gives 999.9029 instead of 1000, because each of the 10,000 additions is rounded to the nearest single-precision value and the roundings do not cancel out.

The program prints the shortest decimal that reads back as the stored value. Since 1.26, MicroPython’s own float printer is built for that property too; its release notes measure 98.5 % of single-precision values printing in a form that reads back exactly, so a board shows these short forms in most cases.

Comparing a board's floats with values computed on a computer

A test that computes an expected float with CPython and compares it with == against the board’s result fails whenever the two precisions round differently. Compare whole numbers, or compare floats within a tolerance of the board’s precision, such as one part in 10⁶.

IEEE 754 Floating-Point Converter See the 32 bits of any number as a single-precision float, and the value it really stores.

Checkpoint

Check: Single-precision floats on the boards

  1. Question 1 of 2 A Pico counts pulses in a float, count = count + 1.0. What happens once count reaches 16,777,216?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The count stops growing, because 16,777,217 cannot be stored and the sum rounds back to 16,777,216

    The program shows the gap after 16,777,216.0 is 2.0. Count pulses in an int, which has no such limit.

  2. Question 2 of 2 Why does adding 0.1 ten thousand times give 999.9029 on a single-precision board instead of 1000?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each addition rounds its result to the nearest single-precision value, and the small errors add up

    Double precision gives 1000.0000000001588, also not exact, but with 53 bits the error is far smaller.

Keep sensor maths in whole numbers

MicroPython’s speed guide gives the advice for every port: some ports keep floats on the heap and some do their float arithmetic in software, so take readings as integers, keep them as integers, and convert to floats only where the rest of the code needs them. On a single-precision board there is a second reason: integers do not round. A sensor that reports whole hundredths of a degree can stay in hundredths from the bus to the log, which is fixed-point: Keeping a fractional quantity as a whole number of small units, such as a temperature in hundredths of a degree, so that the arithmetic is exact integer arithmetic and the point is placed only when the number is shown. arithmetic.

The next program puts both reasons into numbers. First it converts readings in degrees to hundredths with int(t * 100); then it adds up a week of readings taken once a second, 604,800 of them, three ways:

Floats and whole hundredths, on double and single-precision builds Python · fixed/fixed_point.py
"""Sensor maths in floats and in whole numbers of hundredths, on double and single-precision builds.

f32() rounds to the nearest single-precision value, as each operation on a Pico or an
ESP32 board does (the model of float_width.py). The sensor reports whole hundredths of
a degree, so the integer path never rounds at all.
"""
import struct

F = struct.Struct("<f")


def f32(x):
    return F.unpack(F.pack(x))[0]


# 1. Converting a reading in degrees to hundredths with int(t * 100) truncates.
for t in (19.99, 36.6):
    double = int(t * 100)
    single = int(f32(f32(t) * f32(100.0)))
    print("int(%s * 100): double %d, single %d" % (t, double, single))

# 2. A week of readings, one a second, added up for the mean: 21.00 to 21.74 degrees.
n = 7 * 24 * 3600
degrees = [f32(f32(h) / f32(100.0)) for h in range(2100, 2175)]
exact = 0
double = 0.0
single = 0.0
for i in range(n):
    hundredths = 2100 + (i * 37) % 75
    exact += hundredths
    double += hundredths / 100
    single = f32(single + degrees[hundredths - 2100])
print("%d readings; sum of hundredths %d, %s 2**30" % (n, exact, "above" if exact >= 2**30 else "below"))
mean = exact // n
print("mean from integers:      %d.%02d" % (mean // 100, mean % 100))
print("mean from double floats: %.4f" % (double / n))
print("mean from single floats: %.4f" % f32(single / f32(n)))

Output

int(19.99 * 100): double 1998, single 1999
int(36.6 * 100): double 3660, single 3659
604800 readings; sum of hundredths 1292457600, above 2**30
mean from integers:      21.37
mean from double floats: 21.3700
mean from single floats: 21.3452

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fixed_point.py

int(t * 100) truncates in both precisions, on different values: 19.99 becomes 1998 on a double-precision build, and 36.6 becomes 3659 on a single-precision one. The week’s mean is 21.37 degrees from the integers and from double floats, but 21.3452 from single floats: the running total grew so large that each new reading was rounded on the way in, and the mean lost 0.0248 of a degree. The integer sum is exact by construction, not by luck.

Key idea

Keep readings in whole units of the sensor, such as hundredths of a degree, and convert to float only for display: integers never round, while single-precision floats round every result to about 7 digits.

Integers on MicroPython grow without limit, as in CPython, but they come in two sizes. A small integer: An integer that MicroPython keeps inside the object word itself, with no heap allocation: on most 32-bit ports one from -2**30 to just below 2**30. Larger integers stay exact but are stored on the heap., from −2³⁰ to just below 2³⁰ on most 32-bit ports, lives inside the object word and needs no heap; a larger one is stored on the heap, which costs time and memory. The week’s sum, 1,292,457,600 hundredths, is above 2³⁰ = 1,073,741,824, so on a board it is a heap integer: still exact, but allocated. A day’s sum, a seventh of it, stays small.

Formatting negative hundredths with // and %

"%d.%02d" % (v // 100, v % 100) prints -30 hundredths as -1.70, because Python’s // rounds down and % then returns 70. Format the sign and the size apart: "%s%d.%02d" % ("-" if v < 0 else "", abs(v) // 100, abs(v) % 100) gives -0.30 on both Pythons.

Checkpoint

Check: Sensor maths in whole numbers

  1. Question 1 of 2 By the lesson's program, what does int(36.6 * 100) give on a single-precision board?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 3659 hundredths

    In single precision 36.6 * 100 is stored as 3659.999755859375, and int() cuts the fraction off. A double-precision build gives 3660 for this value, and 1998 for 19.99, where the board gives 1999. Keep the reading in hundredths from the start.

  2. Question 2 of 2 v holds -30 hundredths of a degree. Which expression prints -0.30 on both Pythons with integer arithmetic alone?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "%s%d.%02d" % ("-" if v < 0 else "", abs(v) // 100, abs(v) % 100)

    Python's floor division rounds towards minus infinity, so splitting a negative number with // and % moves it a whole unit down. Handle the sign first.

struct packs numbers into frames of bytes

Sensors, radios and other boards speak in bytes, so numbers have to be packed into frames and unpacked again. MicroPython’s struct module does it the CPython way, with a format string: > for big-endian: The byte order that puts the most significant byte of a number first, as network protocols do; > in a struct format. Little-endian, <, puts the least significant byte first. order, B for an unsigned byte, h for a signed 16-bit number, H for an unsigned one, and so on, in the standard sizes of its documentation. That page lists one difference, no spaces in a format, and the differences pages add that MicroPython does not check how many values you pass. struct.pack_into() writes into a buffer you made once, so a loop that sends a frame a second does not make a new bytes object each time. Step through one six-byte frame:

Interactive · step by step The six bytes of one sensor frame Step through how struct.pack_into fills the frame with sensor 3, -0.30 degrees and 45.5 %, then how a receiver reads it back.
The six bytes of one sensor frame, step 5 of 5. Showing byte 0 (sensor 3: 03), byte 1 (temperature high: ff), byte 2 (temperature low: e2), byte 3 (humidity high: 01), byte 4 (humidity low: c7), byte 5 (CRC-8: 21). In focus: byte 1, byte 2.byte 0sensor 3: 03byte 1temperature high: ffbyte 2temperature low: e2byte 3humidity high: 01byte 4humidity low: c7byte 5CRC-8: 21

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The sensor, format B. The frame is a bytearray of 6 made once, and struct.pack_into writes into it with the format >BhH. B is one unsigned byte: the sensor number 3, written 03.
  2. The temperature, format h. h is a signed 16-bit number, and > puts the high byte first. -0.30 degrees is -30 hundredths, which in two's complement is 65536 - 30 = 65506, or 0xffe2.
  3. The humidity, format H. H is an unsigned 16-bit number. 45.5 % is 455 tenths, 0x01c7, so byte 3 holds 01 and byte 4 holds c7.
  4. The check byte. CRC-8 with the polynomial 0x07 over bytes 0 to 4 gives 0x21, stored in byte 5. A receiver computes it again; a changed bit in the frame changes the CRC.
  5. Reading it back. struct.unpack_from with the same format returns 3, -30 and 455. Read as a plain unsigned number, bytes 1 and 2 are 65506; only the h format, or a sign step of your own, turns them back into -30.

The temperature’s two bytes, ff e2, are -30 in two's complement: The usual way of storing negative whole numbers in a fixed number of bits: in 16 bits, -30 is stored as 65536 - 30 = 65506, so the same bytes read as -30 when signed and 65506 when unsigned.: read as a plain unsigned number they are 65506. The program below packs the same frame, checks it with a CRC: Cyclic redundancy check: a short value computed from a block of bytes by polynomial division, sent along with them, so that a receiver that computes it again can tell when bits were changed on the way., reads it back, and shows both readings of those two bytes:

A six-byte sensor frame with struct and a CRC-8 Python · frame/frame.py
"""A six-byte sensor frame packed and checked with struct, the same way on CPython and MicroPython.

Byte 0 is the sensor, bytes 1-2 the temperature in hundredths of a degree (signed, big-endian),
bytes 3-4 the humidity in tenths of a percent (unsigned) and byte 5 a CRC-8 of the first five
(polynomial 0x07, as SMBus uses). struct needs no spaces in its formats on MicroPython.
"""
import struct

FORMAT = ">BhH"  # 5 bytes: sensor, temperature, humidity


def crc8(data):
    crc = 0
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
    return crc


def sign16(value):
    """Two's complement of a 16-bit unsigned value: what int.from_bytes(..., signed=True) gives in CPython."""
    return value - 0x10000 if value & 0x8000 else value


frame = bytearray(6)  # made once; pack_into fills it without a new allocation
struct.pack_into(FORMAT, frame, 0, 3, -30, 455)  # sensor 3, -0.30 degrees, 45.5 %
frame[5] = crc8(frame[:5])
print("frame:", frame.hex())

sensor, temp, hum = struct.unpack_from(FORMAT, frame, 0)
print("crc ok:", crc8(frame[:5]) == frame[5])
print("sensor %d, temperature %d hundredths, humidity %d tenths" % (sensor, temp, hum))
raw = int.from_bytes(frame[1:3], "big")
print("bytes 1-2 unsigned: %d, sign-extended: %d" % (raw, sign16(raw)))
print("check value of CRC-8 over b'123456789': 0x%02x" % crc8(b"123456789"))
print("(-30).to_bytes(2, 'big', signed=True): %s" % (-30).to_bytes(2, "big", signed=True).hex())

Output

frame: 03ffe201c721
crc ok: True
sensor 3, temperature -30 hundredths, humidity 455 tenths
bytes 1-2 unsigned: 65506, sign-extended: -30
check value of CRC-8 over b'123456789': 0xf4
(-30).to_bytes(2, 'big', signed=True): ffe2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 frame.py

The frame is 03ffe201c721, the same bytes as the figure, and the CRC function gives 0xf4 for the bytes of “123456789”, the standard check value of this CRC, so it can be tested against any other implementation.

For a single number, int.to_bytes() and int.from_bytes() are shorter, with one trap. Since 1.29, to_bytes(length, byteorder, signed=True) works on MicroPython as in CPython, and the program’s last line used it. from_bytes() does not support signed numbers at all: the 1.29.0 source takes its arguments by position only, so signed=True raises TypeError, and a fourth argument is accepted but ignored, so negative values come out as large positive ones. For signed fields, use a struct format such as h, or the sign step of sign16() in the program.

Name the byte order for anything that leaves the board

Use > or < in every format that a frame, a file or another device will read. The @ order also means native sizes and alignment, which differ between a Pico, an ESP32 and the computer that decodes the data.

CRC Calculator (CRC-8/16/32/64) Check the CRC-8 of a frame's first five bytes against the byte it carries.

Checkpoint

Check: Packing frames with struct and to_bytes

  1. Question 1 of 2 A CPython decoder reads a temperature with int.from_bytes(data[1:3], "big", signed=True). What happens on a board running 1.29.0, and what works on both?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: TypeError, because from_bytes takes no keyword arguments; struct.unpack_from(">h", data, 1) works on both

    The 1.29.0 source of int.from_bytes notes that it assumes signed=False. Decode signed fields with a struct format such as h, or sign-extend the unsigned value yourself.

  2. Question 2 of 2 What does MicroPython do with struct.pack("> B h", 3, -30)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It refuses the format, because spaces are not supported in struct formats

    Write formats without spaces, ">BhH", and they mean the same on both Pythons.

Text is UTF-8, under three encoding names

MicroPython stores every string as UTF-8: The encoding that stores each Unicode code point in one to four bytes, ASCII characters in one. MicroPython keeps every str as UTF-8, and accepts only the names utf-8, utf8 and ascii when it converts text., and on all ports of support tiers 1 to 3 its strings are made of Unicode code points: len(), indexing and slicing count code points, not bytes. That is CPython’s model, and the difference shows only at the edges, where text turns into bytes and back:

  • Three encoding names. encode(), decode(), str() and bytes() accept only "utf-8", "utf8" and "ascii", spelled exactly so. Since 1.29 any other name raises LookupError, "latin-1" and even "UTF-8" included, where CPython also accepts other spellings and many more encodings.
  • Errors when decoding. Since 1.29, decode() takes "strict", "ignore" or "replace", given by position: keyword arguments raise TypeError. A strict decode of bad bytes raises UnicodeError, and except UnicodeError also catches CPython’s UnicodeDecodeError, a subclass of it. Encoding ignores the errors argument altogether.
  • No \N{...} escapes. Writing a character by its Unicode name raises NotImplementedError.

The next program measures three words in characters and in bytes, cuts a display line to fit 16 and 20 bytes, and decodes a buffer whose last character was cut in half:

Characters, UTF-8 bytes and cut buffers Python · text/text_bytes.py
"""Text as characters and as UTF-8 bytes, in forms that MicroPython 1.29.0 accepts too.

MicroPython keeps str as UTF-8 and accepts only the encoding names "utf-8", "utf8" and
"ascii", in lower case; bytes.decode() takes its arguments by position.
"""


def fit_utf8(text, max_bytes):
    """The longest start of text whose UTF-8 form fits in max_bytes, never cut inside a character."""
    out = ""
    used = 0
    for ch in text:
        size = len(ch.encode("utf-8"))
        if used + size > max_bytes:
            break
        out += ch
        used += size
    return out


for word in ("hello", "héllo", "नमस्ते"):
    print("%s: %d characters, %d bytes in UTF-8" % (word, len(word), len(word.encode("utf-8"))))

line = "Temp 21.5 °C नमस्ते"
for width in (16, 20):
    part = fit_utf8(line, width)
    print("fits in %d bytes: %r (%d bytes)" % (width, part, len(part.encode("utf-8"))))

broken = "café".encode("utf-8")[:4]  # the last character cut in half
print("cut bytes:", broken)
print("decoded with 'replace':", repr(broken.decode("utf-8", "replace")))
print("decoded with 'ignore':", repr(broken.decode("utf-8", "ignore")))

Output

hello: 5 characters, 5 bytes in UTF-8
héllo: 5 characters, 6 bytes in UTF-8
नमस्ते: 6 characters, 18 bytes in UTF-8
fits in 16 bytes: 'Temp 21.5 °C ' (14 bytes)
fits in 20 bytes: 'Temp 21.5 °C नम' (20 bytes)
cut bytes: b'caf\xc3'
decoded with 'replace': 'caf�'
decoded with 'ignore': 'caf'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 text_bytes.py

“नमस्ते” is 6 code points and 18 bytes, three bytes each, so a 16-byte display line holds Temp 21.5 °C and a space, 14 bytes, and not one Devanagari letter more. The cutting function walks the text one character at a time; step through its loop on a shorter line, with a limit of 8 bytes:

Interactive · line by line Cutting text to fit 8 bytes without splitting a character Watch used grow by each character's size in UTF-8. The loop stops before a character that would not fit.

Program · text/fit_bytes.py

text = "°C नमस्ते"
limit = 8
out = ""
used = 0
for ch in text:
    size = len(ch.encode())
    if used + size > limit:
        break
    out += ch
    used += size
print(out, used)

Variables

After line 11: text is '°C नमस्ते'; limit is 8; out is '°C न'; used is 7; ch is 'म'; size is 3.text°0C1 2न3म4स5्6त7े8limit8out'°C न'used7ch'म'size3
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next

Printed so far

°C न 7

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 30 steps are listed below.

Line run 11
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. text = "°C नमस्ते" sets text to '°C नमस्ते'.
  3. Line 2. limit = 8 sets limit to 8.
  4. Line 3. out = "" sets out to ''.
  5. Line 4. used = 0 sets used to 0.
  6. Line 5. for ch in text: takes the character at position 0 of text: ch is '°'.
  7. Line 6. size = len(ch.encode()) sets size to 2.
  8. Line 7. used + size > limit is false (limit is 8, used is 0 and size is 2), so Python skips the block under if used + size > limit:.
  9. Line 9. out += ch changes out from '' to '°'.
  10. Line 10. used += size changes used from 0 to 2.
  11. Line 5. for ch in text: takes the character at position 1 of text: ch is 'C'.
  12. Line 6. size = len(ch.encode()) sets size to 1 (it was 2).
  13. Line 7. used + size > limit is false (limit is 8, used is 2 and size is 1), so Python skips the block under if used + size > limit:.
  14. Line 9. out += ch changes out from '°' to '°C'.
  15. Line 10. used += size changes used from 2 to 3.
  16. Line 5. for ch in text: takes the character at position 2 of text: ch is ' '.
  17. Line 6. size = len(ch.encode()) size keeps its value, 1.
  18. Line 7. used + size > limit is false (limit is 8, used is 3 and size is 1), so Python skips the block under if used + size > limit:.
  19. Line 9. out += ch changes out from '°C' to '°C '.
  20. Line 10. used += size changes used from 3 to 4.
  21. Line 5. for ch in text: takes the character at position 3 of text: ch is 'न'.
  22. Line 6. size = len(ch.encode()) sets size to 3 (it was 1).
  23. Line 7. used + size > limit is false (limit is 8, used is 4 and size is 3), so Python skips the block under if used + size > limit:.
  24. Line 9. out += ch changes out from '°C ' to '°C न'.
  25. Line 10. used += size changes used from 4 to 7.
  26. Line 5. for ch in text: takes the character at position 4 of text: ch is 'म'.
  27. Line 6. size = len(ch.encode()) size keeps its value, 3.
  28. Line 7. used + size > limit is true (limit is 8, used is 7 and size is 3), so the block under if used + size > limit: runs.
  29. Line 8. break leaves the loop at once, without reading the items it has not reached. Key point म needs 3 bytes and only 1 of the 8 is left, so the loop stops. The cut falls between two characters, never inside one.
  30. Line 11. print(out, used) prints °C न 7. That was the last line, so the program ends. The result is °C न, 7 bytes. Cutting the encoded bytes at byte 8 instead would split म and leave bytes that are not valid UTF-8.

Cut Devanagari at a space

fit_utf8() never splits a code point, but a letter such as स्त is several code points, and a cut between them leaves a half-letter on the screen. For Hindi, Marathi and other Devanagari text, cut at the last space that fits.

Unicode Character Inspector Look up the code points and UTF-8 bytes of any text you send to a board.

Checkpoint

Check: UTF-8 text and its encoding names

  1. Question 1 of 2 What does "café".encode("UTF-8") do on MicroPython 1.29.0?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises LookupError, because only "utf-8", "utf8" and "ascii" are accepted, spelled exactly so

    The 1.29 release notes list the check as a breaking change. Lower-case "utf-8" works on both Pythons.

  2. Question 2 of 2 Which call decodes a buffer whose last character was cut, without an exception, on both Pythons?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: buf.decode("utf-8", "replace")

    The program decoded b'caf\xc3' to 'caf' plus the replacement character, and to 'caf' with "ignore".

Practise: decode a buffer of frames

Write the receiving end of the frame: a function that splits a buffer into six-byte frames, checks each CRC and returns the readings in whole numbers. The sample tests include cold readings, a broken byte and a buffer cut short:

Exercise · Medium · Python

Decode sensor frames with integer maths only

A radio receiver collects the six-byte frames of this lesson into one buffer: byte 0 the sensor, bytes 1-2 the temperature in hundredths of a degree (signed, big-endian), bytes 3-4 the humidity in tenths of a percent (unsigned, big-endian) and byte 5 the CRC-8 of the first five bytes, which crc8() in frames.py computes.

Write decode_frames(buf). It returns a pair: a list of (sensor, temperature, humidity) tuples of whole numbers, one for each frame whose CRC matches, in the order of the buffer, and the number of frames it skipped because their CRC did not match. Bytes at the end that do not make a whole frame are ignored. buf may be bytes or bytearray.

Keep it to integer maths and to calls that MicroPython 1.29.0 has: struct.unpack_from() with a format such as ">BhH" works on both Pythons, while int.from_bytes(..., signed=True) does not work on a board. The sample tests use frames with negative temperatures, a frame with a broken byte, and a buffer cut short.

Starter code · frames.py

import struct


def crc8(data):
    """CRC-8 with the polynomial 0x07 (as SMBus uses), starting from 0."""
    crc = 0
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
    return crc


def decode_frames(buf):
    """Return ([(sensor, temperature, humidity), ...], skipped) for the six-byte frames in buf."""
    return [], 0
The sample tests · test_frames.py
import struct

from frames import crc8, decode_frames


def frame(sensor, temperature, humidity):
    head = struct.pack(">BhH", sensor, temperature, humidity)
    return head + bytes([crc8(head)])


def test_one_frame():
    """decodes the lesson's frame: sensor 3, -0.30 degrees, 45.5 %"""
    assert decode_frames(bytes.fromhex("03ffe201c721")) == ([(3, -30, 455)], 0)


def test_several_and_negative():
    """decodes several frames in order, cold ones included"""
    buf = frame(1, 2137, 400) + frame(2, -1250, 999) + frame(3, -1, 0)
    readings, skipped = decode_frames(bytearray(buf))
    assert readings == [(1, 2137, 400), (2, -1250, 999), (3, -1, 0)]
    assert skipped == 0


def test_bad_crc_skipped():
    """skips a frame whose CRC does not match, and counts it"""
    good = frame(1, 2000, 500)
    bad = bytearray(frame(2, 2100, 510))
    bad[2] ^= 0x10
    readings, skipped = decode_frames(good + bytes(bad) + good)
    assert readings == [(1, 2000, 500), (1, 2000, 500)]
    assert skipped == 1


def test_cut_short():
    """ignores bytes that do not make a whole frame"""
    buf = frame(4, 150, 600) + frame(5, 160, 610)[:4]
    assert decode_frames(buf) == ([(4, 150, 600)], 0)


def test_whole_numbers():
    """returns whole numbers, never floats"""
    readings, _ = decode_frames(frame(6, -32768, 65535))
    assert readings == [(6, -32768, 65535)]
    assert all(type(value) is int for value in readings[0])


def test_empty():
    """gives nothing for an empty buffer"""
    assert decode_frames(b"") == ([], 0)
A hint

Loop over the start of each whole frame, for start in range(0, len(buf) - 5, 6), compare crc8(buf[start:start + 5]) with buf[start + 5], and read the three fields of a good frame with struct.unpack_from(">BhH", buf, start). Count the bad frames as you go.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then take the self-check. It covers single-precision floats, whole-number maths, frames and text, starts with the part you have answered worst so far, and explains each answer you choose:

Practice that adapts to you

Practise this lesson

14 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 Which builds of MicroPython 1.29.0 store floats in single precision? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 14

    Answer:

    • ESP32_GENERIC
    • RPI_PICO and the other rp2 boards

    The differences pages are generated on the unix port, with doubles, so their float rows may not match a board.

  2. Question 2 of 14 On a Pico, what does 0.1 + 0.2 == 0.3 give, and why?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: True, because the single-precision sum lands on the same value as the literal 0.3

    Equality of floats depends on the precision, so the same test can pass on a board and fail on a computer, or the reverse. Compare whole numbers, or within a tolerance.

  3. Question 3 of 14 A test compares a board's computed voltage with a value CPython computed. Which comparison is fair to both precisions?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: abs(board - expected) <= 1e-6 * abs(expected), a relative tolerance near single precision

    Better still, keep the values in whole millivolts and compare integers exactly.

  4. Question 4 of 14 Why does MicroPython's speed guide advise taking ADC readings as integers?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Some ports allocate floats on the heap or compute them in software, so integer maths is faster

    Capture the readings as integers in one quick go, and convert them only where the rest of the code needs floats.

  5. Question 5 of 14 Which of these values is a small integer on most 32-bit ports, needing no heap?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: 184,636,800, a day of readings in hundredths

    Big integers stay exact on MicroPython, but each one is a heap allocation. Keep running totals within the small range where you can.

  6. Question 6 of 14 In the lesson's program, which mean did the single-precision sum of a week of readings give, in degrees?

    Type a number.

    Show the answer: Question 6 of 14

    Answer: 21.3452 degrees (anything from 21.3451 to 21.3453 counts)

    The integers and the doubles both give 21.37. As the single-precision total grew, each new reading was rounded on the way in, and the mean lost 0.0248 of a degree.

  7. Question 7 of 14 In a struct format, what does > mean?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Big-endian order with standard sizes, the most significant byte first

    ! means network order, which is big-endian too. Name the order in every format that leaves the board.

  8. Question 8 of 14 What are the two bytes ff e2 as one unsigned big-endian number?

    Type a number.

    Show the answer: Question 8 of 14

    Answer: 65506 (unsigned value)

    0xff * 256 + 0xe2 = 65506. As a signed 16-bit number they are 65506 - 65536 = -30.

  9. Question 9 of 14 Which of these works the same on MicroPython 1.29.0 and on CPython?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: (-30).to_bytes(2, "big", signed=True)

    to_bytes and from_bytes are not symmetric on MicroPython 1.29.0: packing a signed number works, unpacking one needs struct or a sign step.

  10. Question 10 of 14 A board writes records with struct.pack("@hi", ...) to a file that a computer later decodes. Why is that risky?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: @ uses native sizes and alignment, which can differ between the board and the computer, so the bytes may not line up

    For anything that leaves the board, pick an order and standard sizes explicitly, for example "<hi".

  11. Question 11 of 14 What does len("नमस्ते") give on MicroPython, which counts code points as CPython does?

    Type a number.

    Show the answer: Question 11 of 14

    Answer: 6 code points

    न, म, स, the virama ्, त and the vowel sign े are six code points, although a reader sees fewer letters.

  12. Question 12 of 14 How many bytes does "नमस्ते" take in UTF-8?

    Type a number.

    Show the answer: Question 12 of 14

    Answer: 18 bytes

    Each Devanagari code point lies between U+0800 and U+FFFF, so UTF-8 uses 3 bytes for each of the 6.

  13. Question 13 of 14 Code decodes bytes from a sensor in strict mode. Which exception should it catch to work on both Pythons?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: UnicodeError

    The Unicode support page says strict decoding raises UnicodeError on invalid UTF-8.

  14. Question 14 of 14 A board shows Hindi messages on a display that takes 32 bytes a line. Which way of shortening a message is best?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Cut at the last space that fits, because a code-point cut can split a conjunct such as स्त

    len() and slicing count code points, not what a reader sees as letters. For Devanagari, a space is the safe place to cut.

Interview questions

Warm-up (fresher): Why does 16777217.0 come back as 16777216.0 on a Pico?

The rp2 port builds MicroPython with single-precision floats, the IEEE 754 32-bit format, which keeps a 24-bit significand. Every whole number up to 2 to the 24th power, 16,777,216, fits exactly, but above it the gap between neighbouring values is 2, so 16,777,217 rounds to the nearest value that exists, 16,777,216. The unix port, the browser build and CPython use 64-bit doubles, which hold every whole number up to 2 to the 53rd, so the same line keeps its value there. On a board, keep counts and sensor readings as integers, which have no such limit.

This lesson leads to two longer questions in the module’s bank:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A Pico meters a 20 W load: every 100 ms it adds the energy of that interval, 2 joules, to a float total. After about three weeks the total stops growing, although the load still runs. Explain it with numbers, and redesign the counter.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Design the binary frame that a battery-powered Pico sends to a CPython server: temperature, humidity, battery voltage and a reading counter. Which fields, types and order do you choose, and how do servers that are already deployed keep reading frames once you add a field?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How precise are floats on the rp2 and esp32 ports?

    Single precision, the IEEE 754 32-bit format: a 24-bit significand, about 7 significant digits. The unix standard variant, the browser build and CPython use 64-bit doubles.

  2. Why does 16777217.0 come back as 16777216.0 on a Pico?

    Above 2²⁴ = 16,777,216, neighbouring single-precision values are 2 apart, so 16,777,217 rounds to the nearest value that exists.

  3. Why keep sensor readings as whole numbers, such as hundredths of a degree?

    Integers never round, while single-precision floats round every result, and some ports keep floats on the heap or compute them in software. Convert to float only for display.

  4. Which integers need no heap on a 32-bit port?

    Small integers, from −2³⁰ to just below 2³⁰, which live inside the object word. Larger integers stay exact but are stored on the heap.

  5. Why does "%d.%02d" % (v // 100, v % 100) print -30 hundredths as -1.70?

    // rounds down, to -1, and % then returns 70. Format the sign apart and use abs(v) // 100 and abs(v) % 100, which prints -0.30.

  6. What is the trap of int.from_bytes() on MicroPython 1.29?

    It takes its arguments by position and has no signed mode: signed=True raises TypeError and a fourth argument is ignored, so negative fields come out as large positive numbers. Use a struct format such as h.

  7. Which encoding names does MicroPython accept?

    "utf-8", "utf8" and "ascii", spelled exactly so. Since 1.29 any other name, "UTF-8" and "latin-1" included, raises LookupError.

  8. How does decode() take its error mode on MicroPython 1.29?

    By position: "strict", "ignore" or "replace". A keyword argument raises TypeError, and a strict decode of bad bytes raises UnicodeError.

Key takeaways

  • The rp2 and esp32 ports store floats in 32 bits, about 7 significant digits, while the unix port, the browser build and CPython use doubles, so the same calculation can give different results on a board.
  • Readings kept in whole units, such as hundredths, never round; int(t * 100) truncates in both precisions, and a long single-precision sum loses accuracy as it grows.
  • Integers below 2³⁰ are small and need no heap on 32-bit ports; larger ones stay exact but are allocated on the heap.
  • struct packs and unpacks frames as in CPython, without spaces in formats, and int.from_bytes() ignores signedness on MicroPython, so signed fields use a format such as h or a sign step of your own.
  • Strings are UTF-8 code points; only "utf-8", "utf8" and "ascii" are accepted as encoding names, and decoding takes its error mode by position.

Terms in this lesson

big-endian
The byte order that puts the most significant byte of a number first, as network protocols do; > in a struct format. Little-endian, <, puts the least significant byte first.
CRC
Cyclic redundancy check: a short value computed from a block of bytes by polynomial division, sent along with them, so that a receiver that computes it again can tell when bits were changed on the way.
fixed-point
Keeping a fractional quantity as a whole number of small units, such as a temperature in hundredths of a degree, so that the arithmetic is exact integer arithmetic and the point is placed only when the number is shown.
single precision
The 32-bit floating-point format of IEEE 754, with a 24-bit significand, about 7 significant decimal digits. The rp2 and esp32 ports store MicroPython floats this way; CPython uses the 64-bit double format.
small integer
An integer that MicroPython keeps inside the object word itself, with no heap allocation: on most 32-bit ports one from -2**30 to just below 2**30. Larger integers stay exact but are stored on the heap.
two's complement
The usual way of storing negative whole numbers in a fixed number of bits: in 16 bits, -30 is stored as 65536 - 30 = 65506, so the same bytes read as -30 when signed and 65506 when unsigned.
UTF-8
The encoding that stores each Unicode code point in one to four bytes, ASCII characters in one. MicroPython keeps every str as UTF-8, and accepts only the names utf-8, utf8 and ascii when it converts text.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.