DEV Community

HVAC Fitter
HVAC Fitter

Posted on

16x25x1 Isn't 16 by 25: Parsing and Validating HVAC Air Filter Sizes in Python

Measure a common furnace filter with a tape and you'll get about 15.5 by 24.5 inches, three-quarters of an inch thick. Nobody sells that size. The label on the frame says 16x25x1.

That little gap between the label and the tape is a nice, small data-modeling problem. If you build anything that matches parts (a store search, an inventory sheet, a home-maintenance app), you'll hit it. Here's a tiny Python model of the rules.

Written by the HVAC Fitter team (hvacfitter.com). General information, not a replacement for your system's manual.

The rules, in plain English

These come from our guide on how to size an air filter:

  1. The size is printed on the frame of the old filter, like 16x25x1: length, width and depth, in inches.
  2. The order of the first two numbers doesn't matter. A 16x25 and a 25x16 are the same filter.
  3. The label is the nominal size, rounded up. A 16x25x1 really measures about 15.5 x 24.5 x 0.75 in, so it can slide into the slot.
  4. No label? Measure it and round each number up to the next whole inch (or the next half inch for odd sizes).
  5. Depth must match the slot. Filters come 1, 2, 4 and 5 in deep. Never force a thicker one in, and don't use a thin one in a deep slot, because air goes around it.

Rules 2, 3 and 5 are exactly the kind of thing a naive string match gets wrong.

Parsing a size string

People type sizes every way: 16x25x1, 25 X 16 x 1, 16*25*1. Normalize them into one canonical form, with the two face numbers sorted so order stops mattering.

import math
import re
from dataclasses import dataclass

VALID_DEPTHS = {1, 2, 4, 5}  # inches; the common furnace filter depths
SIZE_RE = re.compile(
    r"^\s*(\d+(?:\.\d+)?)\s*[x×X*]\s*(\d+(?:\.\d+)?)\s*[x×X*]\s*(\d+(?:\.\d+)?)\s*$"
)


@dataclass(frozen=True)
class FilterSize:
    a: float      # the smaller face dimension
    b: float      # the larger face dimension
    depth: float

    def __str__(self):
        f = lambda n: f"{n:g}"
        return f"{f(self.a)}x{f(self.b)}x{f(self.depth)}"


def parse_size(text: str) -> FilterSize:
    """Parse '16x25x1', '25 X 16 x 1' or '16*25*1' into one canonical size."""
    m = SIZE_RE.match(text)
    if not m:
        raise ValueError(f"not a filter size: {text!r}")
    w, l, d = (float(g) for g in m.groups())
    a, b = sorted((w, l))  # 16x25 and 25x16 are the same filter
    return FilterSize(a, b, d)
Enter fullscreen mode Exit fullscreen mode

Sorting the face dimensions is the whole trick for rule 2. Now parse_size("25 X 16 x 1") and parse_size("16x25x1") are equal, because frozen dataclasses compare by value.

From a tape measure to a label size

Rule 3 and 4 in code: round each measured number up.


def nominal_from_measured(w: float, l: float, d: float) -> FilterSize:
    """Measured frame (e.g. 15.5 x 24.5 x 0.75) -> the size printed on the label."""
    up = lambda n: float(math.ceil(n - 1e-9))
    return FilterSize(*sorted((up(w), up(l))), up(d))
Enter fullscreen mode Exit fullscreen mode

The - 1e-9 keeps a measured 16.0 from turning into 17 when float math leaves it at 16.0000000001. This version only rounds to whole inches. If you need the "next half inch for odd sizes" case, round to math.ceil(n * 2) / 2 and check the result against a list of sizes you actually stock.

Checking a swap

Most real bugs show up when someone swaps one filter for another. Compare the face sizes and the depth separately, so the error message says what is wrong.


def check_swap(old: str, new: str) -> list[str]:
    """Return problems with replacing filter `old` by filter `new` (empty = fine)."""
    o, n = parse_size(old), parse_size(new)
    problems = []
    if (o.a, o.b) != (n.a, n.b):
        problems.append(f"face size differs: {o.a:g}x{o.b:g} vs {n.a:g}x{n.b:g}")
    if o.depth != n.depth:
        why = ("too thick, it won't fit the slot" if n.depth > o.depth
               else "too thin, air will go around it")
        problems.append(f"depth {n.depth:g} in vs slot {o.depth:g} in: {why}")
    if n.depth not in VALID_DEPTHS:
        problems.append(f"unusual depth {n.depth:g} in; check the slot")
    return problems


if __name__ == "__main__":
    assert str(parse_size("25 X 16 x 1")) == "16x25x1"
    assert str(nominal_from_measured(15.5, 24.5, 0.75)) == "16x25x1"
    assert check_swap("16x25x1", "25x16x1") == []
    print(check_swap("16x25x4", "16x25x1"))
    print(check_swap("16x25x1", "16x20x1"))
Enter fullscreen mode Exit fullscreen mode

Running it:

['depth 1 in vs slot 4 in: too thin, air will go around it']
['face size differs: 16x25 vs 16x20']
Enter fullscreen mode Exit fullscreen mode

The first case is an easy mistake: the slot takes a 4-inch filter, someone grabs a 1-inch one because the face size matches. The face check passes, the depth check catches it.

Why not just store the string?

Because three different strings mean the same filter, and one string (16x25x1) means a frame that measures something else. If your search index stores the raw label text, "25x16x1" returns nothing, and a customer with a tape measure never finds a match. Store the canonical form, and search on it.

The same idea works for part numbers. A 4-inch filter marked CF100A1009 has a set of replacements, which is a lookup table problem, not a parsing problem. See the CF100A1009 replacement page for one example.

A few edge cases worth a test

  • 16 x 25 x 1" with an inch mark: strip " and in before matching.
  • Metric labels: some filters print both. Keep the inch size as the key.
  • MERV rating: it isn't part of the size. Many homes use MERV 8 to 11, and a higher MERV can slow airflow on some systems, so keep it as a separate field.

That's the whole model: one regex, one sort, one round-up and two comparisons. If you want to see it used on real stock, our air filter listings are filtered by the numbers printed on the old frame.

Turn the system off before you swap a filter, and point the airflow arrow toward the furnace or air handler.

Top comments (0)