diff --git a/engineering/mech/standards/bearings.py b/engineering/mech/standards/bearings.py new file mode 100644 index 0000000..b0031fe --- /dev/null +++ b/engineering/mech/standards/bearings.py @@ -0,0 +1,136 @@ +"""ISO 15 deep groove ball bearings, plus the ISO 8826 representation rules. + +SOURCE +------ +* ``DEEP_GROOVE`` - ISO 15:2017 Table 4: boundary dimensions ``d`` (bore), + ``D`` (outside diameter) and ``B`` (width) for the radial bearings of + dimension series 10 (600x), 02 (620x) and 03 (630x). +* ``SOURCE_ISO_8826_1`` - ISO 8826-1:1989, general simplified representation: + the bearing is drawn as its envelope outline with an upright cross inside + that does not touch the outline. +* ``SOURCE_ISO_8826_2`` - ISO 8826-2:1994, particular simplified + representation: the envelope outline plus a symbol for the rolling element. + +COVERAGE +-------- +600x, 620x and 630x, bore numbers 00..10 (d = 10..50 mm) - 33 rows. Bore +numbers 00/01/02/03 are 10/12/15/17 mm by the ISO 15 rule; 04 and up are +``5 x bore number``. Deliberately **not** transcribed, and refused by name: +bore numbers above 10, the wide series 622x/623x, the light series 618x/619x +and 16xx, the inch series, and every suffixed variant (``6205-2RS``, +``6205-Z``) - a suffix changes width and sealing, not a number this module may +guess. + +The chamfer dimensions ``r_s`` are not transcribed either: neither simplified +representation needs them, so no row carries a value nobody checked. + +CROSS_FRACTION and BALL_SYMBOL_FRACTION are **declared drawing conventions**, +not transcribed dimensions. ISO 8826-1 fixes the *form* of the cross and +requires only that it not touch the outline; ISO 15 carries no ball diameter +at all, so the element symbol is sized as a fraction of the ring gap rather +than invented as a real ball. +""" + +from __future__ import annotations + +from . import Coverage, register + +SOURCE_ISO_15 = ( + "ISO 15:2017 Table 4 - rolling bearings, radial bearings, boundary dimensions " + "(dimension series 10, 02, 03)" +) +SOURCE_ISO_8826_1 = ( + "ISO 8826-1:1989 - technical drawings, rolling bearings, general simplified representation" +) +SOURCE_ISO_8826_2 = ( + "ISO 8826-2:1994 - technical drawings, rolling bearings, particular simplified representation" +) + +#: Fraction of the half-width / half-gap used by the ISO 8826-1 upright cross. +#: A declared convention (see the module docstring), never a table value. +CROSS_FRACTION = 0.6 + +#: Fraction of the half-gap used by the ISO 8826-2 rolling-element symbol. +#: A declared convention, never a ball diameter. +BALL_SYMBOL_FRACTION = 0.6 + +BEARING_SERIES = ("600x", "620x", "630x") +_SERIES_PREFIX = {"60": "600x", "62": "620x", "63": "630x"} + +#: ISO 15:2017 Table 4 - d, D, B in millimetres. +DEEP_GROOVE: dict[str, dict[str, float]] = { + # dimension series 10 (600x) + "6000": {"d": 10.0, "D": 26.0, "B": 8.0}, + "6001": {"d": 12.0, "D": 28.0, "B": 8.0}, + "6002": {"d": 15.0, "D": 32.0, "B": 9.0}, + "6003": {"d": 17.0, "D": 35.0, "B": 10.0}, + "6004": {"d": 20.0, "D": 42.0, "B": 12.0}, + "6005": {"d": 25.0, "D": 47.0, "B": 12.0}, + "6006": {"d": 30.0, "D": 55.0, "B": 13.0}, + "6007": {"d": 35.0, "D": 62.0, "B": 14.0}, + "6008": {"d": 40.0, "D": 68.0, "B": 15.0}, + "6009": {"d": 45.0, "D": 75.0, "B": 16.0}, + "6010": {"d": 50.0, "D": 80.0, "B": 16.0}, + # dimension series 02 (620x) + "6200": {"d": 10.0, "D": 30.0, "B": 9.0}, + "6201": {"d": 12.0, "D": 32.0, "B": 10.0}, + "6202": {"d": 15.0, "D": 35.0, "B": 11.0}, + "6203": {"d": 17.0, "D": 40.0, "B": 12.0}, + "6204": {"d": 20.0, "D": 47.0, "B": 14.0}, + "6205": {"d": 25.0, "D": 52.0, "B": 15.0}, + "6206": {"d": 30.0, "D": 62.0, "B": 16.0}, + "6207": {"d": 35.0, "D": 72.0, "B": 17.0}, + "6208": {"d": 40.0, "D": 80.0, "B": 18.0}, + "6209": {"d": 45.0, "D": 85.0, "B": 19.0}, + "6210": {"d": 50.0, "D": 90.0, "B": 20.0}, + # dimension series 03 (630x) + "6300": {"d": 10.0, "D": 35.0, "B": 11.0}, + "6301": {"d": 12.0, "D": 37.0, "B": 12.0}, + "6302": {"d": 15.0, "D": 42.0, "B": 13.0}, + "6303": {"d": 17.0, "D": 47.0, "B": 14.0}, + "6304": {"d": 20.0, "D": 52.0, "B": 15.0}, + "6305": {"d": 25.0, "D": 62.0, "B": 17.0}, + "6306": {"d": 30.0, "D": 72.0, "B": 19.0}, + "6307": {"d": 35.0, "D": 80.0, "B": 21.0}, + "6308": {"d": 40.0, "D": 90.0, "B": 23.0}, + "6309": {"d": 45.0, "D": 100.0, "B": 25.0}, + "6310": {"d": 50.0, "D": 110.0, "B": 27.0}, +} + + +def bearing(designation) -> dict: + """Boundary dimensions of one deep groove ball bearing. + + Returns ``{"designation", "series", "source", "d", "D", "B"}``. A + designation outside the transcribed table is refused by name; nothing is + interpolated between series or bore numbers. + """ + key = " ".join(str(designation).split()).upper() + if key not in DEEP_GROOVE: + raise ValueError( + "ISO 15 deep groove ball bearings are transcribed here for the 600x, 620x " + f"and 630x series, bore numbers 00-10 (d = 10-50 mm); {key!r} is outside " + f"that table and is never interpolated ({SOURCE_ISO_15})." + ) + return { + "designation": key, + "series": _SERIES_PREFIX[key[:2]], + "source": SOURCE_ISO_15, + **DEEP_GROOVE[key], + } + + +def list_bearings(series: str | None = None) -> tuple[dict, ...]: + """Every transcribed bearing, optionally restricted to one series.""" + rows = tuple(bearing(name) for name in DEEP_GROOVE) + if series is None: + return rows + name = " ".join(str(series).split()).lower() + if name not in BEARING_SERIES: + raise ValueError( + f"list_bearings: series must be one of {', '.join(BEARING_SERIES)}, got {series!r}." + ) + return tuple(row for row in rows if row["series"] == name) + + +register("ISO 15", DEEP_GROOVE, Coverage(10.0, 50.0), SOURCE_ISO_15) diff --git a/engineering/mech/standards/fasteners.py b/engineering/mech/standards/fasteners.py new file mode 100644 index 0000000..27f1451 --- /dev/null +++ b/engineering/mech/standards/fasteners.py @@ -0,0 +1,269 @@ +"""ISO metric fastener tables - transcribed from the standards, never derived. + +SOURCE +------ +* ``HEX_HEAD`` ISO 4014:2011 Table 1 (hexagon head bolts, product grades A + and B) and ISO 4017:2014 Table 1 (hexagon head screws, fully threaded). The + two standards carry the same width across flats ``s`` and head height ``k`` + for every size shipped here, so one table serves both and the standard is a + property of the *part*, not a second copy of the table. +* ``HEX_NUT`` ISO 4032:2012 Table 1 (hexagon regular nuts, style 1): + width across flats ``s``, nut height ``m`` (max). +* ``WASHER`` ISO 7089:2000 Table 1 (plain washers, normal series, product + grade A): inside diameter ``d1``, outside diameter ``d2``, thickness ``h``. +* ``SOCKET_HEAD`` ISO 4762:2004 Table 1 (hexagon socket head cap screws): head + diameter ``dk`` (max), head height ``k`` (max), hexagon socket width across + flats ``s``, thread length ``b``. +* ``COARSE_PITCH`` ISO 261:1998, coarse pitch series. + +COVERAGE (deliberately narrow - the table rule of the mech spec, section 5) +--------------------------------------------------------------------------- +Only ISO 261 **first-choice** sizes are shipped: + +* ISO 4014 / 4017 / 4032 / 7089 : M5 M6 M8 M10 M12 M16 M20 M24 M30 M36 +* ISO 4762 : M3 M4 M5 M6 M8 M10 M12 M16 M20 M24 + +Second-choice sizes (M14, M18, M22, M27, M33) and everything beyond those +ranges are **not** shipped and are refused by name. A narrow table is merely +narrow; a wrong across-flats ships a wrong workshop drawing. + +Two columns are deliberately absent: + +* across-corners ``e`` - :func:`across_corners` returns the *nominal* + hexagon's ``2s/sqrt(3)`` instead. The standards' own ``e`` is a minimum + computed from ``s`` min and is product-grade dependent; a drawing of the + nominal part wants the nominal hexagon, so no ``e`` column is transcribed. +* the per-size nominal length ranges of ISO 4014 Table 1 - ``l`` is validated + as a finite positive number and the useful thread length comes from the + standard's own ``b`` rule in :func:`thread_length`. +""" + +from __future__ import annotations + +import math + +from . import Coverage, register + +SOURCE_ISO_4014 = "ISO 4014:2011 Table 1 - hexagon head bolts, product grades A and B" +SOURCE_ISO_4017 = "ISO 4017:2014 Table 1 - hexagon head screws, fully threaded" +SOURCE_ISO_4032 = "ISO 4032:2012 Table 1 - hexagon regular nuts, style 1" +SOURCE_ISO_7089 = "ISO 7089:2000 Table 1 - plain washers, normal series, product grade A" +SOURCE_ISO_4762 = "ISO 4762:2004 Table 1 - hexagon socket head cap screws" +SOURCE_ISO_261 = "ISO 261:1998 - ISO general purpose metric screw threads, coarse pitch series" + +#: ISO 261:1998 coarse pitch, one entry per size any table below carries. +COARSE_PITCH: dict[str, float] = { + "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, +} + +#: ISO 4014:2011 / ISO 4017:2014 Table 1 - s = across flats, k = head height. +HEX_HEAD: dict[str, dict[str, float]] = { + "M5": {"s": 8.0, "k": 3.5}, + "M6": {"s": 10.0, "k": 4.0}, + "M8": {"s": 13.0, "k": 5.3}, + "M10": {"s": 16.0, "k": 6.4}, + "M12": {"s": 18.0, "k": 7.5}, + "M16": {"s": 24.0, "k": 10.0}, + "M20": {"s": 30.0, "k": 12.5}, + "M24": {"s": 36.0, "k": 15.0}, + "M30": {"s": 46.0, "k": 18.7}, + "M36": {"s": 55.0, "k": 22.5}, +} + +#: ISO 4032:2012 Table 1 - s = across flats, m = nut height (max). +HEX_NUT: dict[str, dict[str, float]] = { + "M5": {"s": 8.0, "m": 4.7}, + "M6": {"s": 10.0, "m": 5.2}, + "M8": {"s": 13.0, "m": 6.8}, + "M10": {"s": 16.0, "m": 8.4}, + "M12": {"s": 18.0, "m": 10.8}, + "M16": {"s": 24.0, "m": 14.8}, + "M20": {"s": 30.0, "m": 18.0}, + "M24": {"s": 36.0, "m": 21.5}, + "M30": {"s": 46.0, "m": 25.6}, + "M36": {"s": 55.0, "m": 31.0}, +} + +#: ISO 7089:2000 Table 1 - d1 = bore, d2 = outside diameter, h = thickness. +WASHER: dict[str, dict[str, float]] = { + "M5": {"d1": 5.3, "d2": 10.0, "h": 1.0}, + "M6": {"d1": 6.4, "d2": 12.0, "h": 1.6}, + "M8": {"d1": 8.4, "d2": 16.0, "h": 1.6}, + "M10": {"d1": 10.5, "d2": 20.0, "h": 2.0}, + "M12": {"d1": 13.0, "d2": 24.0, "h": 2.5}, + "M16": {"d1": 17.0, "d2": 30.0, "h": 3.0}, + "M20": {"d1": 21.0, "d2": 37.0, "h": 3.0}, + "M24": {"d1": 25.0, "d2": 44.0, "h": 4.0}, + "M30": {"d1": 31.0, "d2": 56.0, "h": 4.0}, + "M36": {"d1": 37.0, "d2": 66.0, "h": 5.0}, +} + +#: ISO 4762:2004 Table 1 - dk = head diameter (max), k = head height (max), +#: s = hexagon socket across flats, b = thread length. +SOCKET_HEAD: dict[str, dict[str, float]] = { + "M3": {"dk": 5.5, "k": 3.0, "s": 2.5, "b": 18.0}, + "M4": {"dk": 7.0, "k": 4.0, "s": 3.0, "b": 20.0}, + "M5": {"dk": 8.5, "k": 5.0, "s": 4.0, "b": 22.0}, + "M6": {"dk": 10.0, "k": 6.0, "s": 5.0, "b": 24.0}, + "M8": {"dk": 13.0, "k": 8.0, "s": 6.0, "b": 28.0}, + "M10": {"dk": 16.0, "k": 10.0, "s": 8.0, "b": 32.0}, + "M12": {"dk": 18.0, "k": 12.0, "s": 10.0, "b": 36.0}, + "M16": {"dk": 24.0, "k": 16.0, "s": 14.0, "b": 44.0}, + "M20": {"dk": 30.0, "k": 20.0, "s": 17.0, "b": 52.0}, + "M24": {"dk": 36.0, "k": 24.0, "s": 19.0, "b": 60.0}, +} + +_TABLES: dict[str, tuple[dict[str, dict[str, float]], str]] = { + "ISO 4014": (HEX_HEAD, SOURCE_ISO_4014), + "ISO 4017": (HEX_HEAD, SOURCE_ISO_4017), + "ISO 4032": (HEX_NUT, SOURCE_ISO_4032), + "ISO 7089": (WASHER, SOURCE_ISO_7089), + "ISO 4762": (SOCKET_HEAD, SOURCE_ISO_4762), +} + +THREAD_LENGTH_STANDARDS = ("ISO 4014", "ISO 4017", "ISO 4762") + + +def _key(size) -> str: + key = str(size).strip().upper() + if not key: + raise ValueError("fastener size must be a non-empty string such as 'M12'") + return key + + +def _row(table: dict, standard: str, source: str, key: str) -> dict: + if key not in table: + raise ValueError( + f"{standard} is transcribed here for {', '.join(table)} only; {key!r} is " + f"outside that table and is never interpolated ({source})." + ) + return dict(table[key]) + + +def nominal_diameter(size) -> float: + """Nominal thread diameter in mm, e.g. ``'M12' -> 12.0``.""" + key = _key(size) + if key not in COARSE_PITCH: + raise ValueError( + f"{SOURCE_ISO_261} is transcribed here for {', '.join(COARSE_PITCH)}; " + f"{key!r} is outside it." + ) + return float(key[1:]) + + +def thread_pitch(size) -> float: + """ISO 261 coarse pitch in mm.""" + key = _key(size) + if key not in COARSE_PITCH: + raise ValueError( + f"{SOURCE_ISO_261} is transcribed here for {', '.join(COARSE_PITCH)}; " + f"{key!r} is outside it." + ) + return COARSE_PITCH[key] + + +def hex_head(size) -> dict: + """ISO 4014 / ISO 4017 head row plus the nominal diameter and pitch.""" + key = _key(size) + row = _row(HEX_HEAD, "ISO 4014 / ISO 4017", SOURCE_ISO_4014, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def hex_nut(size) -> dict: + """ISO 4032 nut row plus the nominal diameter and pitch.""" + key = _key(size) + row = _row(HEX_NUT, "ISO 4032", SOURCE_ISO_4032, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def washer(size) -> dict: + """ISO 7089 washer row plus the mating thread's nominal diameter and pitch.""" + key = _key(size) + row = _row(WASHER, "ISO 7089", SOURCE_ISO_7089, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def socket_head(size) -> dict: + """ISO 4762 socket head cap screw row plus the nominal diameter and pitch.""" + key = _key(size) + row = _row(SOCKET_HEAD, "ISO 4762", SOURCE_ISO_4762, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def thread_length(size, length: float, standard: str = "ISO 4014") -> float: + """Useful thread length ``b`` for a nominal length ``l``, per the part standard. + + ISO 4014 fixes ``b`` by rule rather than by table: ``2d + 6`` for + ``l <= 125``, ``2d + 12`` for ``125 < l <= 200`` and ``2d + 25`` for + ``l > 200``. ISO 4017 screws are threaded over their whole length. ISO 4762 + tabulates ``b`` per size. In every case a screw shorter than its own ``b`` + is threaded to the head, so the result is capped at ``l``. + """ + key = _key(size) + name = " ".join(str(standard).split()) + if name not in THREAD_LENGTH_STANDARDS: + raise ValueError( + f"thread_length: standard must be one of {', '.join(THREAD_LENGTH_STANDARDS)}, " + f"got {standard!r}." + ) + if isinstance(length, bool) or not isinstance(length, (int, float)): + raise ValueError(f"thread_length: length must be a number, got {length!r}") + l_mm = float(length) + if not math.isfinite(l_mm) or l_mm <= 0.0: + raise ValueError(f"thread_length: length must be finite and > 0, got {length!r}") + if name == "ISO 4017": + hex_head(key) + return l_mm + if name == "ISO 4762": + return min(socket_head(key)["b"], l_mm) + diameter = nominal_diameter(key) + hex_head(key) # ISO 4014 coverage: refuse a size the head table does not carry + if l_mm <= 125.0: + b = 2.0 * diameter + 6.0 + elif l_mm <= 200.0: + b = 2.0 * diameter + 12.0 + else: + b = 2.0 * diameter + 25.0 + return min(b, l_mm) + + +def across_corners(s: float) -> float: + """Across-corners width of the *nominal* hexagon: ``2s/sqrt(3)``. + + Derived geometry, not a transcribed column - see the module docstring. + """ + if isinstance(s, bool) or not isinstance(s, (int, float)): + raise ValueError(f"across_corners: s must be a number, got {s!r}") + width = float(s) + if not math.isfinite(width) or width <= 0.0: + raise ValueError(f"across_corners: s must be finite and > 0, got {s!r}") + return 2.0 * width / math.sqrt(3.0) + + +def sizes(standard: str) -> tuple[str, ...]: + """The transcribed sizes of one fastener standard, in table order.""" + name = " ".join(str(standard).split()).upper() + if name not in _TABLES: + raise ValueError( + f"sizes: {standard!r} is not a fastener standard in this module; " + f"have {', '.join(_TABLES)}." + ) + return tuple(_TABLES[name][0]) + + +register("ISO 4014", HEX_HEAD, Coverage(5.0, 36.0), SOURCE_ISO_4014) +register("ISO 4017", HEX_HEAD, Coverage(5.0, 36.0), SOURCE_ISO_4017) +register("ISO 4032", HEX_NUT, Coverage(5.0, 36.0), SOURCE_ISO_4032) +register("ISO 7089", WASHER, Coverage(5.0, 36.0), SOURCE_ISO_7089) +register("ISO 4762", SOCKET_HEAD, Coverage(3.0, 24.0), SOURCE_ISO_4762) diff --git a/tests/test_mech_bearings.py b/tests/test_mech_bearings.py new file mode 100644 index 0000000..7c6c595 --- /dev/null +++ b/tests/test_mech_bearings.py @@ -0,0 +1,111 @@ +"""ISO 15 boundary dimensions and the ISO 8826 representation conventions.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.standards import standards +from engineering.mech.standards.bearings import ( + BALL_SYMBOL_FRACTION, + BEARING_SERIES, + CROSS_FRACTION, + DEEP_GROOVE, + SOURCE_ISO_15, + SOURCE_ISO_8826_1, + SOURCE_ISO_8826_2, + bearing, + list_bearings, +) + + +@pytest.mark.parametrize( + ("designation", "bore", "outer", "width"), + [ + ("6000", 10.0, 26.0, 8.0), + ("6004", 20.0, 42.0, 12.0), + ("6006", 30.0, 55.0, 13.0), + ("6010", 50.0, 80.0, 16.0), + ("6200", 10.0, 30.0, 9.0), + ("6203", 17.0, 40.0, 12.0), + ("6205", 25.0, 52.0, 15.0), + ("6208", 40.0, 80.0, 18.0), + ("6210", 50.0, 90.0, 20.0), + ("6300", 10.0, 35.0, 11.0), + ("6305", 25.0, 62.0, 17.0), + ("6309", 45.0, 100.0, 25.0), + ("6310", 50.0, 110.0, 27.0), + ], +) +def test_named_rows_match_iso15(designation, bore, outer, width): + row = bearing(designation) + assert (row["d"], row["D"], row["B"]) == (bore, outer, width) + assert row["designation"] == designation + assert row["source"] == SOURCE_ISO_15 + + +def test_bore_number_rule_holds_across_every_series(): + """ISO 15: bore 00/01/02/03 = 10/12/15/17 mm; 04 and up = 5 x bore number.""" + for series in ("60", "62", "63"): + assert bearing(f"{series}00")["d"] == 10.0 + assert bearing(f"{series}01")["d"] == 12.0 + assert bearing(f"{series}02")["d"] == 15.0 + assert bearing(f"{series}03")["d"] == 17.0 + for n in range(4, 11): + assert bearing(f"{series}{n:02d}")["d"] == 5.0 * n + + +def test_outside_diameter_and_width_grow_with_the_series(): + for n in range(0, 11): + light = bearing(f"60{n:02d}") + medium = bearing(f"62{n:02d}") + heavy = bearing(f"63{n:02d}") + assert light["d"] == medium["d"] == heavy["d"] + assert light["D"] < medium["D"] < heavy["D"] + assert light["B"] < medium["B"] < heavy["B"] + + +def test_coverage_refusals_name_the_table(): + with pytest.raises(ValueError, match="ISO 15"): + bearing("6011") + with pytest.raises(ValueError, match="ISO 15"): + bearing("6211") + with pytest.raises(ValueError, match="ISO 15"): + bearing("6311") + with pytest.raises(ValueError, match="ISO 15"): + bearing("61805") # the 618x light series is not transcribed + with pytest.raises(ValueError, match="ISO 15"): + bearing("6205-2RS") # a seal suffix is a variant this table does not carry + + +def test_representation_fractions_are_declared_conventions_not_table_values(): + assert 0.0 < CROSS_FRACTION < 1.0 + assert 0.0 < BALL_SYMBOL_FRACTION < 1.0 + assert "ISO 8826-1" in SOURCE_ISO_8826_1 + assert "ISO 8826-2" in SOURCE_ISO_8826_2 + assert "ISO 15:2017" in SOURCE_ISO_15 + + +def test_the_element_symbol_always_fits_inside_the_ring_rectangle(): + """The declared fractions must never draw a symbol outside the envelope.""" + for name in DEEP_GROOVE: + row = bearing(name) + half_width = row["B"] / 2.0 + half_gap = (row["D"] - row["d"]) / 4.0 + assert BALL_SYMBOL_FRACTION * half_gap < half_width + assert CROSS_FRACTION * half_width < half_width + assert CROSS_FRACTION * half_gap < half_gap + + +def test_list_bearings_filters_by_series(): + assert len(DEEP_GROOVE) == 33 + assert len(list_bearings()) == 33 + assert BEARING_SERIES == ("600x", "620x", "630x") + assert {row["designation"] for row in list_bearings("620x")} == { + f"62{n:02d}" for n in range(11) + } + with pytest.raises(ValueError, match="620x"): + list_bearings("622x") + + +def test_the_table_registers_itself(): + assert "ISO 15" in standards() diff --git a/tests/test_mech_fasteners.py b/tests/test_mech_fasteners.py new file mode 100644 index 0000000..c67ce8a --- /dev/null +++ b/tests/test_mech_fasteners.py @@ -0,0 +1,189 @@ +"""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. 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_NUT, + SOCKET_HEAD, + SOURCE_ISO_4014, + across_corners, + hex_head, + hex_nut, + nominal_diameter, + sizes, + socket_head, + thread_length, + thread_pitch, + washer, +) + + +def test_iso4014_named_rows_match_the_standard(): + """ISO 4014:2011 Table 1 - width across flats s and head height k.""" + assert (hex_head("M6")["s"], hex_head("M6")["k"]) == (10.0, 4.0) + assert (hex_head("M8")["s"], hex_head("M8")["k"]) == (13.0, 5.3) + assert (hex_head("M10")["s"], hex_head("M10")["k"]) == (16.0, 6.4) + assert (hex_head("M12")["s"], hex_head("M12")["k"]) == (18.0, 7.5) + assert (hex_head("M16")["s"], hex_head("M16")["k"]) == (24.0, 10.0) + assert (hex_head("M20")["s"], hex_head("M20")["k"]) == (30.0, 12.5) + assert (hex_head("M24")["s"], hex_head("M24")["k"]) == (36.0, 15.0) + assert (hex_head("M30")["s"], hex_head("M30")["k"]) == (46.0, 18.7) + assert (hex_head("M36")["s"], hex_head("M36")["k"]) == (55.0, 22.5) + + +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") + + +def test_iso4032_named_rows_match_the_standard(): + """ISO 4032:2012 Table 1 - nut height m (max).""" + assert hex_nut("M6")["m"] == 5.2 + assert hex_nut("M8")["m"] == 6.8 + assert hex_nut("M10")["m"] == 8.4 + assert hex_nut("M12")["m"] == 10.8 + assert hex_nut("M16")["m"] == 14.8 + assert hex_nut("M20")["m"] == 18.0 + assert hex_nut("M24")["m"] == 21.5 + assert hex_nut("M30")["m"] == 25.6 + assert hex_nut("M36")["m"] == 31.0 + for size in HEX_NUT: + assert hex_nut(size)["s"] == hex_head(size)["s"] + + +def test_iso7089_named_rows_match_the_standard(): + """ISO 7089:2000 Table 1 - d1, d2, h (nominal), normal series, grade A.""" + assert (washer("M6")["d1"], washer("M6")["d2"], washer("M6")["h"]) == (6.4, 12.0, 1.6) + assert (washer("M8")["d1"], washer("M8")["d2"], washer("M8")["h"]) == (8.4, 16.0, 1.6) + assert (washer("M10")["d1"], washer("M10")["d2"], washer("M10")["h"]) == (10.5, 20.0, 2.0) + assert (washer("M12")["d1"], washer("M12")["d2"], washer("M12")["h"]) == (13.0, 24.0, 2.5) + assert (washer("M16")["d1"], washer("M16")["d2"], washer("M16")["h"]) == (17.0, 30.0, 3.0) + assert (washer("M20")["d1"], washer("M20")["d2"], washer("M20")["h"]) == (21.0, 37.0, 3.0) + assert (washer("M24")["d1"], washer("M24")["d2"], washer("M24")["h"]) == (25.0, 44.0, 4.0) + assert (washer("M30")["d1"], washer("M30")["d2"], washer("M30")["h"]) == (31.0, 56.0, 4.0) + assert (washer("M36")["d1"], washer("M36")["d2"], washer("M36")["h"]) == (37.0, 66.0, 5.0) + + +def test_iso4762_named_rows_match_the_standard(): + """ISO 4762:2004 Table 1 - head dk (max), socket s, thread length b.""" + assert ( + socket_head("M3")["dk"], + socket_head("M3")["s"], + socket_head("M3")["b"], + ) == (5.5, 2.5, 18.0) + assert ( + socket_head("M5")["dk"], + socket_head("M5")["s"], + socket_head("M5")["b"], + ) == (8.5, 4.0, 22.0) + assert ( + socket_head("M8")["dk"], + socket_head("M8")["s"], + socket_head("M8")["b"], + ) == (13.0, 6.0, 28.0) + assert ( + socket_head("M12")["dk"], + socket_head("M12")["s"], + socket_head("M12")["b"], + ) == (18.0, 10.0, 36.0) + assert ( + socket_head("M20")["dk"], + socket_head("M20")["s"], + socket_head("M20")["b"], + ) == (30.0, 17.0, 52.0) + assert ( + socket_head("M24")["dk"], + socket_head("M24")["s"], + socket_head("M24")["b"], + ) == (36.0, 19.0, 60.0) + # 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")