The module docstring claimed "every value asserted here is a named row of a named table", but the converse did not hold: 27 of the 83 shipped rows had a dimension no test compared against a literal. ISO 4014/4017 M5 (s,k), ISO 4032 M5 (m), ISO 7089 M5 (d1,d2,h), ISO 4762 M4/M6/M10/M16 (dk,s,b) and D/B for 20 of the 33 ISO 15 bearings were constrained only by inequality tests (series growth) and by the bore-number rule - never by the transcribed number. For a task whose worst failure is "a standards table row that is not in the standard", those rows were exactly where such a row would hide. Measured before the fix with a pytest plugin that rewrote five rows in memory (M5 hex bolt s=k=99, M5 nut, M5 washer, M16 socket screw dk=s=b=99, 6307 as 78x20 instead of 80x21): 32 passed in 0.07s. All five now fail. Each of the five tables is pinned by an ISO*_ROWS dict literal carrying every dimension of every shipped size, parametrized one case per size/designation, plus a completeness test asserting the table's key set equals the literal's - so a row added later without a pin is red too (verified: a fabricated M14 head and 6011 bearing fail the completeness gate). 32 -> 93 tests, 0.06 -> 0.13 s. No production code changed; no ActiveX member is involved, so no live probe was required for this finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
224 lines
7.4 KiB
Python
224 lines
7.4 KiB
Python
"""Provenance and coverage of the ISO fastener tables (the table rule, spec 5).
|
|
|
|
Every value asserted here is a named row of a named table, and the converse holds
|
|
too: the ``ISO*_ROWS`` literals below carry *every* dimension of *every* shipped
|
|
size, and a completeness test asserts each table's key set equals its literal's.
|
|
A row nobody pinned is exactly where a wrong across-flats would hide. A row that
|
|
could not be verified against the standard is not in the module at all, so the
|
|
coverage refusals below are as load-bearing as the values.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from engineering.mech.standards import standards
|
|
from engineering.mech.standards.fasteners import (
|
|
COARSE_PITCH,
|
|
HEX_HEAD,
|
|
HEX_NUT,
|
|
SOCKET_HEAD,
|
|
SOURCE_ISO_4014,
|
|
WASHER,
|
|
across_corners,
|
|
hex_head,
|
|
hex_nut,
|
|
nominal_diameter,
|
|
sizes,
|
|
socket_head,
|
|
thread_length,
|
|
thread_pitch,
|
|
washer,
|
|
)
|
|
|
|
#: ISO 4014:2011 / ISO 4017:2014 Table 1 - every transcribed size, (s, k).
|
|
ISO4014_ROWS: dict[str, tuple[float, float]] = {
|
|
"M5": (8.0, 3.5),
|
|
"M6": (10.0, 4.0),
|
|
"M8": (13.0, 5.3),
|
|
"M10": (16.0, 6.4),
|
|
"M12": (18.0, 7.5),
|
|
"M16": (24.0, 10.0),
|
|
"M20": (30.0, 12.5),
|
|
"M24": (36.0, 15.0),
|
|
"M30": (46.0, 18.7),
|
|
"M36": (55.0, 22.5),
|
|
}
|
|
|
|
#: ISO 4032:2012 Table 1 - every transcribed size, (s, m).
|
|
ISO4032_ROWS: dict[str, tuple[float, float]] = {
|
|
"M5": (8.0, 4.7),
|
|
"M6": (10.0, 5.2),
|
|
"M8": (13.0, 6.8),
|
|
"M10": (16.0, 8.4),
|
|
"M12": (18.0, 10.8),
|
|
"M16": (24.0, 14.8),
|
|
"M20": (30.0, 18.0),
|
|
"M24": (36.0, 21.5),
|
|
"M30": (46.0, 25.6),
|
|
"M36": (55.0, 31.0),
|
|
}
|
|
|
|
#: ISO 7089:2000 Table 1 - every transcribed size, (d1, d2, h).
|
|
ISO7089_ROWS: dict[str, tuple[float, float, float]] = {
|
|
"M5": (5.3, 10.0, 1.0),
|
|
"M6": (6.4, 12.0, 1.6),
|
|
"M8": (8.4, 16.0, 1.6),
|
|
"M10": (10.5, 20.0, 2.0),
|
|
"M12": (13.0, 24.0, 2.5),
|
|
"M16": (17.0, 30.0, 3.0),
|
|
"M20": (21.0, 37.0, 3.0),
|
|
"M24": (25.0, 44.0, 4.0),
|
|
"M30": (31.0, 56.0, 4.0),
|
|
"M36": (37.0, 66.0, 5.0),
|
|
}
|
|
|
|
#: ISO 4762:2004 Table 1 - every transcribed size, (dk, k, s, b).
|
|
ISO4762_ROWS: dict[str, tuple[float, float, float, float]] = {
|
|
"M3": (5.5, 3.0, 2.5, 18.0),
|
|
"M4": (7.0, 4.0, 3.0, 20.0),
|
|
"M5": (8.5, 5.0, 4.0, 22.0),
|
|
"M6": (10.0, 6.0, 5.0, 24.0),
|
|
"M8": (13.0, 8.0, 6.0, 28.0),
|
|
"M10": (16.0, 10.0, 8.0, 32.0),
|
|
"M12": (18.0, 12.0, 10.0, 36.0),
|
|
"M16": (24.0, 16.0, 14.0, 44.0),
|
|
"M20": (30.0, 20.0, 17.0, 52.0),
|
|
"M24": (36.0, 24.0, 19.0, 60.0),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(("size", "expected"), sorted(ISO4014_ROWS.items()))
|
|
def test_iso4014_named_rows_match_the_standard(size, expected):
|
|
"""ISO 4014:2011 Table 1 - width across flats s and head height k."""
|
|
row = hex_head(size)
|
|
assert (row["s"], row["k"]) == expected
|
|
|
|
|
|
def test_every_iso4014_row_is_pinned_by_a_literal():
|
|
"""No shipped size may escape the table above - that is where a wrong row hides."""
|
|
assert set(HEX_HEAD) == set(ISO4014_ROWS)
|
|
assert sizes("ISO 4014") == tuple(HEX_HEAD)
|
|
|
|
|
|
def test_iso4017_shares_the_iso4014_head():
|
|
"""ISO 4017 screws and ISO 4014 bolts have the same head at every size here."""
|
|
assert sizes("ISO 4017") == sizes("ISO 4014")
|
|
|
|
|
|
@pytest.mark.parametrize(("size", "expected"), sorted(ISO4032_ROWS.items()))
|
|
def test_iso4032_named_rows_match_the_standard(size, expected):
|
|
"""ISO 4032:2012 Table 1 - width across flats s and nut height m (max)."""
|
|
row = hex_nut(size)
|
|
assert (row["s"], row["m"]) == expected
|
|
|
|
|
|
def test_every_iso4032_row_is_pinned_and_shares_the_bolt_across_flats():
|
|
assert set(HEX_NUT) == set(ISO4032_ROWS)
|
|
for size in HEX_NUT:
|
|
assert hex_nut(size)["s"] == hex_head(size)["s"]
|
|
|
|
|
|
@pytest.mark.parametrize(("size", "expected"), sorted(ISO7089_ROWS.items()))
|
|
def test_iso7089_named_rows_match_the_standard(size, expected):
|
|
"""ISO 7089:2000 Table 1 - d1, d2, h (nominal), normal series, grade A."""
|
|
row = washer(size)
|
|
assert (row["d1"], row["d2"], row["h"]) == expected
|
|
|
|
|
|
def test_every_iso7089_row_is_pinned_by_a_literal():
|
|
assert set(WASHER) == set(ISO7089_ROWS)
|
|
|
|
|
|
@pytest.mark.parametrize(("size", "expected"), sorted(ISO4762_ROWS.items()))
|
|
def test_iso4762_named_rows_match_the_standard(size, expected):
|
|
"""ISO 4762:2004 Table 1 - head dk (max), head height k (max), socket s, thread length b."""
|
|
row = socket_head(size)
|
|
assert (row["dk"], row["k"], row["s"], row["b"]) == expected
|
|
|
|
|
|
def test_every_iso4762_row_is_pinned_and_its_head_height_equals_the_thread_diameter():
|
|
assert set(SOCKET_HEAD) == set(ISO4762_ROWS)
|
|
# ISO 4762 head height equals the nominal thread diameter at every size here.
|
|
for size in SOCKET_HEAD:
|
|
assert socket_head(size)["k"] == nominal_diameter(size)
|
|
|
|
|
|
def test_iso261_coarse_pitch_column():
|
|
assert COARSE_PITCH == {
|
|
"M3": 0.5,
|
|
"M4": 0.7,
|
|
"M5": 0.8,
|
|
"M6": 1.0,
|
|
"M8": 1.25,
|
|
"M10": 1.5,
|
|
"M12": 1.75,
|
|
"M16": 2.0,
|
|
"M20": 2.5,
|
|
"M24": 3.0,
|
|
"M30": 3.5,
|
|
"M36": 4.0,
|
|
}
|
|
assert thread_pitch("M12") == 1.75
|
|
assert nominal_diameter("M12") == 12.0
|
|
|
|
|
|
def test_coverage_is_refused_at_both_ends_by_name():
|
|
with pytest.raises(ValueError, match="ISO 4014"):
|
|
hex_head("M3")
|
|
with pytest.raises(ValueError, match="ISO 4014"):
|
|
hex_head("M42")
|
|
with pytest.raises(ValueError, match="ISO 4032"):
|
|
hex_nut("M42")
|
|
with pytest.raises(ValueError, match="ISO 7089"):
|
|
washer("M3")
|
|
with pytest.raises(ValueError, match="ISO 4762"):
|
|
socket_head("M2")
|
|
with pytest.raises(ValueError, match="ISO 4762"):
|
|
socket_head("M30")
|
|
|
|
|
|
def test_second_choice_sizes_are_absent_not_interpolated():
|
|
for absent in ("M14", "M18", "M22", "M27", "M33"):
|
|
with pytest.raises(ValueError):
|
|
hex_head(absent)
|
|
with pytest.raises(ValueError):
|
|
hex_nut(absent)
|
|
|
|
|
|
def test_thread_length_follows_the_standards_own_b_rule():
|
|
assert thread_length("M12", 60.0) == pytest.approx(30.0) # 2d + 6, l <= 125
|
|
assert thread_length("M12", 150.0) == pytest.approx(36.0) # 2d + 12, 125 < l <= 200
|
|
assert thread_length("M12", 260.0) == pytest.approx(49.0) # 2d + 25, l > 200
|
|
assert thread_length("M12", 25.0) == pytest.approx(25.0) # never longer than the bolt
|
|
assert thread_length("M12", 60.0, "ISO 4017") == pytest.approx(60.0)
|
|
assert thread_length("M8", 30.0, "ISO 4762") == pytest.approx(28.0)
|
|
assert thread_length("M8", 20.0, "ISO 4762") == pytest.approx(20.0)
|
|
with pytest.raises(ValueError):
|
|
thread_length("M12", 0.0)
|
|
with pytest.raises(ValueError):
|
|
thread_length("M12", float("inf"))
|
|
with pytest.raises(ValueError, match="ISO 4014"):
|
|
thread_length("M12", 60.0, "DIN 931")
|
|
|
|
|
|
def test_across_corners_is_derived_geometry_not_a_transcribed_column():
|
|
assert across_corners(18.0) == pytest.approx(2.0 * 18.0 / math.sqrt(3.0))
|
|
assert across_corners(18.0) == pytest.approx(20.7846, abs=1e-4)
|
|
with pytest.raises(ValueError):
|
|
across_corners(0.0)
|
|
|
|
|
|
def test_every_table_registers_itself_with_the_shared_registry():
|
|
for name in ("ISO 4014", "ISO 4017", "ISO 4032", "ISO 7089", "ISO 4762"):
|
|
assert name in standards()
|
|
assert "ISO 4014:2011" in SOURCE_ISO_4014
|
|
|
|
|
|
def test_sizes_refuses_a_standard_this_module_does_not_carry():
|
|
assert sizes("ISO 4014")[0] == "M5"
|
|
with pytest.raises(ValueError, match="ISO 4014"):
|
|
sizes("ISO 15")
|