feat(task-3): the feature catalogue and its standards tables
engineering/mech/features.py is the nineteen typed features of spec section 5,
each answering the same four questions - is my placement legal, what do I
contribute to this view, to a cut face, and what dimension do I want - plus the
five structural hooks the view engine needs: removal_box, radial_edit,
corner_edit, outline_edit and omission. Pure arithmetic; a test asserts emitted
primitive tuples, not a screenshot.
Nothing restates a table the repository already owns: Keyway takes its width
and depth from engineering.keyway (DIN 6885-1) and is refused by name outside
that table's bore range; GearTeeth takes its outline from engineering.gear's
involute generator and is refused when its tip diameter disagrees with the
segment it sits on.
standards/threads.py transcribes ISO 261 Table 1 and Table 2 - first-choice
M1.6-M64 plus second-choice M14/M18/M22/M27/M33/M39/M45/M52/M60 - and computes
ISO 724's basic minor and pitch diameters from ISO 68-1's own formula, pinned
against the published values (M20x2.5 -> 17.294 / 18.376). The remaining
second-choice diameters, every third-choice diameter and fine pitches below M8
are not transcribed and are refused by name, never interpolated. The drawing
rules are ISO 6410: minor at 0.8 x major, a thread-length limit line across the
major, a three-quarter circle in the end view, and an internal thread whose
section hatch runs to the major diameter.
standards/{undercuts,rings,centres,orings}.py ship DIN 509, DIN 471/472,
DIN 332-1 and ISO 3601-2 with their SOURCE, their row-key contract and an
**empty** row set. That is deliberate and is the track's table rule taken at its
word: a row that could not be verified against the standard is not shipped,
because a wrong relief depth or groove diameter is a wrong workshop drawing
while a narrow table is merely narrow. Every lookup is refused by name and says
what to transcribe and where; the features accept explicit dimensions so the
geometry is fully exercised, and DIN 332 form R is refused outright rather than
approximated by the straight 60 degree cone of forms A and B. Filling any of
these tables later is a data edit with no code change, and a shape test already
fails any row that lands without a column the drawing code reads.
Three deviations from the plan text, each measured rather than assumed
(the merge agent should keep exactly one copy of the first):
1. standards/__init__.py (Task 1, on main) refused a registration with no rows
("registered no rows"), which would have made the four not-transcribed
tables impossible to register at all - and registering them is what keeps a
later transcription a data edit. register() now refuses a rows argument that
is not a dict and accepts an empty one; two tests in tests/test_mech_standards.py
pin both halves. No existing test asserted the old refusal.
2. ORingGroove's housing kind is carried by `housing`, not `kind`. A dataclass
field named `kind` overwrites the discriminator _Base.to_dict() writes:
measured, Probe(id='og').to_dict() == {'kind': 'radial', 'id': 'og'}, so
feature_from_dict would then refuse its own payload as an unknown feature
kind and no part carrying an O-ring groove could round-trip through XDATA.
A new test pins the round trip.
3. The plan's end-chamfer test asserted after == (75.0, 10.0) - the corner
itself - and before == (72.0, 8.268), a point on neither surface. The real
edit is before (72.0, 10.0) on the cylinder and after (75.0, 8.268) on the
end face, consistent with the 45-degree start chamfer the same file pins.
The test was corrected to the measured geometry; the behaviour is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
04d269318a
commit
ac6c4075ba
@@ -6,6 +6,31 @@ and alphabetical — keep every side's names.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .features import (
|
||||
FEATURE_KINDS,
|
||||
AxialBore,
|
||||
BendLine,
|
||||
CentreHole,
|
||||
Chamfer,
|
||||
CornerChamfer,
|
||||
CornerFillet,
|
||||
CrossHole,
|
||||
Feature,
|
||||
Fillet,
|
||||
GearTeeth,
|
||||
Hole,
|
||||
HoleCircle,
|
||||
HolePattern,
|
||||
Keyway,
|
||||
ORingGroove,
|
||||
Pocket,
|
||||
RetainingGroove,
|
||||
Slot,
|
||||
Thread,
|
||||
Undercut,
|
||||
feature_from_dict,
|
||||
mirror_about_x,
|
||||
)
|
||||
from .part import (
|
||||
PART_KINDS,
|
||||
PrismaticPart,
|
||||
@@ -44,26 +69,49 @@ from .primitives import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FEATURE_KINDS",
|
||||
"PART_KINDS",
|
||||
"ROLES",
|
||||
"ROLE_LAYER",
|
||||
"Arc",
|
||||
"AxialBore",
|
||||
"BendLine",
|
||||
"CentreHole",
|
||||
"Chamfer",
|
||||
"Circle",
|
||||
"CornerChamfer",
|
||||
"CornerFillet",
|
||||
"CrossHole",
|
||||
"DimIntent",
|
||||
"Feature",
|
||||
"Fillet",
|
||||
"GearTeeth",
|
||||
"HatchArea",
|
||||
"Hole",
|
||||
"HoleCircle",
|
||||
"HolePattern",
|
||||
"Keyway",
|
||||
"Line",
|
||||
"ORingGroove",
|
||||
"Pocket",
|
||||
"Poly",
|
||||
"Prim",
|
||||
"PrismaticPart",
|
||||
"Pt",
|
||||
"RetainingGroove",
|
||||
"RevolvedPart",
|
||||
"Role",
|
||||
"Segment",
|
||||
"Slot",
|
||||
"Text",
|
||||
"Thread",
|
||||
"Undercut",
|
||||
"bbox",
|
||||
"build_part",
|
||||
"feature_from_dict",
|
||||
"inner_radius_at",
|
||||
"layer_for",
|
||||
"mirror_about_x",
|
||||
"outer_radius_at",
|
||||
"outline_bbox",
|
||||
"part_length",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,12 @@ wrong row ships a wrong workshop drawing; a narrow table is merely narrow.
|
||||
variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw``
|
||||
selects among those variants and is refused when it selects none or more than
|
||||
one — an ambiguous lookup never quietly picks the first row.
|
||||
|
||||
``rows`` may be **empty**: that is a table whose structure, coverage and SOURCE
|
||||
ship before any row could be verified against the standard (DIN 509, DIN 471,
|
||||
DIN 472, DIN 332-1 and ISO 3601-2 in this build). Registering it keeps the
|
||||
later transcription a data edit, and every lookup against it is refused by
|
||||
name — which is exactly the table rule, not an exception to it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -62,8 +68,11 @@ def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None
|
||||
raise ValueError("standards.register: standard must be a non-empty name")
|
||||
if standard in _REGISTRY:
|
||||
raise ValueError(f"standards.register: {standard!r} is already registered")
|
||||
if not rows:
|
||||
raise ValueError(f"standards.register: {standard!r} registered no rows")
|
||||
if not isinstance(rows, dict):
|
||||
raise ValueError(
|
||||
f"standards.register: {standard!r} must register a rows dict; an empty one is "
|
||||
"the table whose structure ships before its rows are transcribed"
|
||||
)
|
||||
if not isinstance(source, str) or "table" not in source.lower():
|
||||
raise ValueError(
|
||||
f"standards.register: {standard!r} needs a SOURCE naming the standard, its "
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""DIN 332-1 centre holes, forms A and B.
|
||||
|
||||
SOURCE
|
||||
------
|
||||
DIN 332-1:1986-04, *Centre holes 60 degrees - Forms R, A and B*, Table 1.
|
||||
|
||||
COVERAGE
|
||||
--------
|
||||
**No rows are transcribed in this build** (same rule as ``undercuts.py``).
|
||||
Form R is not drawn by this build at all: its profile is an arc form whose
|
||||
radius is not transcribed here, and it is refused outright rather than
|
||||
approximated by the straight 60 degree cone of forms A and B.
|
||||
|
||||
Extending is a data edit: fill ``ROWS_A`` / ``ROWS_B`` with
|
||||
``{pilot_diameter_mm: {"d1": ..., "d2": ..., "d3": ..., "t": ...}}`` (``d3``
|
||||
only for form B's protective 120 degree countersink) and widen the coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.mech.standards import Coverage, register
|
||||
|
||||
SOURCE = "DIN 332-1:1986-04, Table 1 (forms A and B) - NOT TRANSCRIBED in this build"
|
||||
|
||||
#: The keys a DIN 332 row must carry; form A rows may omit "d3".
|
||||
CENTRE_ROW_KEYS: tuple[str, ...] = ("d1", "d2", "d3", "t")
|
||||
|
||||
FORMS: tuple[str, ...] = ("A", "B")
|
||||
|
||||
ROWS_A: dict[float, dict] = {}
|
||||
ROWS_B: dict[float, dict] = {}
|
||||
|
||||
COVERAGE_A = Coverage(0.0, 0.0, "mm")
|
||||
COVERAGE_B = Coverage(0.0, 0.0, "mm")
|
||||
|
||||
register("DIN 332-A", ROWS_A, COVERAGE_A, SOURCE)
|
||||
register("DIN 332-B", ROWS_B, COVERAGE_B, SOURCE)
|
||||
|
||||
_ROWS = {"A": ROWS_A, "B": ROWS_B}
|
||||
|
||||
|
||||
def centre_hole_dims(form: str, size: float) -> dict:
|
||||
"""``{"d1","d2","d3","t"}`` for a pilot diameter, or a named refusal."""
|
||||
key = str(form or "").strip().upper()
|
||||
if key == "R":
|
||||
raise ValueError(
|
||||
"DIN 332 form R: this build draws forms A and B only. Form R's arc profile "
|
||||
"is not transcribed here and is refused rather than approximated by the "
|
||||
"straight 60 degree cone."
|
||||
)
|
||||
if key not in FORMS:
|
||||
raise ValueError(f"DIN 332: expected form A or B, got {form!r}.")
|
||||
rows = _ROWS[key]
|
||||
if not rows:
|
||||
raise ValueError(
|
||||
f"DIN 332 form {key}: no rows are transcribed in this build, so no centre hole "
|
||||
f"can be looked up for d1 = {float(size):g} mm. Pass d1, d2 (and d3 for form B) "
|
||||
f"and t explicitly, or transcribe {SOURCE.split(' - ')[0]} into "
|
||||
"engineering/mech/standards/centres.py. Nothing is interpolated."
|
||||
)
|
||||
pilot = float(size)
|
||||
if pilot in rows:
|
||||
row = rows[pilot]
|
||||
return {name: (float(row[name]) if name in row else None) for name in CENTRE_ROW_KEYS}
|
||||
raise ValueError(
|
||||
f"DIN 332 form {key}: d1 = {pilot:g} mm has no transcribed row (sizes: {sorted(rows)})."
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""ISO 3601-2 O-ring housing (groove) dimensions.
|
||||
|
||||
SOURCE
|
||||
------
|
||||
ISO 3601-2:2016, *Fluid power systems - O-rings - Part 2: Housing dimensions
|
||||
for general applications*, Tables 1 and 2 (radial static housings).
|
||||
|
||||
COVERAGE
|
||||
--------
|
||||
**No rows are transcribed in this build** (same rule as ``undercuts.py``): the
|
||||
groove width and depth per cord diameter decide whether the seal works, and
|
||||
they are not written here from memory. Every lookup is refused by name.
|
||||
|
||||
Extending is a data edit: fill ``ROWS`` with
|
||||
``{cord_diameter_mm: {"b": groove_width_mm, "h": groove_depth_mm}}`` - the two
|
||||
keys group S's ``std_feature_draw`` already reads - and widen ``COVERAGE``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.mech.standards import Coverage, register
|
||||
|
||||
SOURCE = "ISO 3601-2:2016, Tables 1-2 (radial static housings) - NOT TRANSCRIBED in this build"
|
||||
|
||||
#: The keys every ISO 3601-2 row must carry.
|
||||
ORING_ROW_KEYS: tuple[str, ...] = ("b", "h")
|
||||
|
||||
KINDS: tuple[str, ...] = ("radial", "axial")
|
||||
|
||||
ROWS: dict[float, dict] = {}
|
||||
|
||||
COVERAGE = Coverage(0.0, 0.0, "mm")
|
||||
|
||||
register("ISO 3601-2", ROWS, COVERAGE, SOURCE)
|
||||
|
||||
|
||||
def oring_groove_dims(cord: float, kind: str = "radial") -> dict:
|
||||
"""``{"b": width, "h": depth}`` for a cord diameter, or a named refusal."""
|
||||
key = str(kind or "").strip().lower()
|
||||
if key not in KINDS:
|
||||
raise ValueError(f"O-ring housing: expected kind 'radial' or 'axial', got {kind!r}.")
|
||||
if not ROWS:
|
||||
raise ValueError(
|
||||
f"ISO 3601-2: no rows are transcribed in this build, so no {key} housing can be "
|
||||
f"looked up for a {float(cord):g} mm cord. Pass b and h explicitly, or transcribe "
|
||||
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/orings.py. "
|
||||
"Nothing is interpolated."
|
||||
)
|
||||
size = float(cord)
|
||||
if size in ROWS:
|
||||
return {name: float(ROWS[size][name]) for name in ORING_ROW_KEYS}
|
||||
raise ValueError(
|
||||
f"ISO 3601-2: cord {size:g} mm has no transcribed row (sizes: {sorted(ROWS)})."
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""DIN 471 / DIN 472 retaining-ring groove dimensions.
|
||||
|
||||
SOURCE
|
||||
------
|
||||
DIN 471:2011-04, *Retaining rings for shafts*, Table 1 (groove diameter d2 and
|
||||
groove width m) and DIN 472:2011-04, *Retaining rings for bores*, Table 1.
|
||||
|
||||
COVERAGE
|
||||
--------
|
||||
**No rows are transcribed in this build**, for the reason given in
|
||||
``undercuts.py``: the groove diameter is the number a wrong drawing gets wrong,
|
||||
and it is not written here from memory. Every lookup is refused by name.
|
||||
|
||||
Extending is a data edit: fill ``ROWS_471`` / ``ROWS_472`` with
|
||||
``{nominal_shaft_or_bore_mm: {"m": groove_width_mm, "d2": groove_diameter_mm}}``
|
||||
and widen the coverage. Those two keys are the contract group S's
|
||||
``std_feature_draw`` already reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.mech.standards import Coverage, register
|
||||
|
||||
SOURCE = (
|
||||
"DIN 471:2011-04 Table 1 (shafts) and DIN 472:2011-04 Table 1 (bores) "
|
||||
"- NOT TRANSCRIBED in this build"
|
||||
)
|
||||
|
||||
#: The keys every DIN 471/472 row must carry.
|
||||
RING_ROW_KEYS: tuple[str, ...] = ("m", "d2")
|
||||
|
||||
ROWS_471: dict[float, dict] = {}
|
||||
ROWS_472: dict[float, dict] = {}
|
||||
|
||||
COVERAGE_471 = Coverage(0.0, 0.0, "mm")
|
||||
COVERAGE_472 = Coverage(0.0, 0.0, "mm")
|
||||
|
||||
register("DIN 471", ROWS_471, COVERAGE_471, SOURCE)
|
||||
register("DIN 472", ROWS_472, COVERAGE_472, SOURCE)
|
||||
|
||||
_ROWS = {"DIN 471": ROWS_471, "DIN 472": ROWS_472}
|
||||
|
||||
|
||||
def ring_groove_dims(standard: str, d: float) -> dict:
|
||||
"""``{"m": ..., "d2": ...}`` for a nominal size, or a named refusal."""
|
||||
key = " ".join(str(standard or "").split()).upper()
|
||||
if key not in _ROWS:
|
||||
raise ValueError(f"retaining-ring groove: expected DIN 471 or DIN 472, got {standard!r}.")
|
||||
rows = _ROWS[key]
|
||||
if not rows:
|
||||
raise ValueError(
|
||||
f"{key}: no rows are transcribed in this build, so no groove can be looked up "
|
||||
f"for d = {float(d):g} mm. Pass m and d2 explicitly, or transcribe "
|
||||
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/rings.py. "
|
||||
"Nothing is interpolated."
|
||||
)
|
||||
size = float(d)
|
||||
if size in rows:
|
||||
return {name: float(rows[size][name]) for name in RING_ROW_KEYS}
|
||||
raise ValueError(f"{key}: d = {size:g} mm has no transcribed row (sizes: {sorted(rows)}).")
|
||||
@@ -0,0 +1,250 @@
|
||||
"""ISO 261 metric thread pitches and the ISO 6410 drawing representation.
|
||||
|
||||
SOURCE
|
||||
------
|
||||
**Pitches.** ISO 261:2022, *ISO general purpose metric screw threads - General
|
||||
plan*, Table 1 (coarse pitch series) and Table 2 (fine pitch series).
|
||||
|
||||
**Transcribed coverage.** The first-choice diameters M1.6 to M64, plus the
|
||||
second-choice diameters M14, M18, M22, M27, M33, M39, M45, M52 and M60. The
|
||||
small second-choice diameters (M1.8, M2.2, M3.5, M4.5, M5.5, M7, M9, M11),
|
||||
every third-choice diameter, everything above M64 and the fine pitch series
|
||||
below M8 are **not** transcribed and are refused by name rather than
|
||||
interpolated - the table rule of this track. Where a diameter's fine series
|
||||
here is shorter than the standard's, the pitch that is missing is refused by
|
||||
name as well; widening it is a data edit in ``FINE`` with no code change.
|
||||
|
||||
**Basic diameters.** ISO 68-1:1998 fundamental triangle, H = (sqrt(3)/2) * P,
|
||||
with ISO 724's basic minor diameter d1 = d - 1.25*H = d - 1.082532*P and basic
|
||||
pitch diameter d2 = d - 0.75*H = d - 0.649519*P. That is the standard's own
|
||||
formula rather than transcribed table data, which is why it can be pinned
|
||||
against published ISO 724 values instead of trusted: M20x2.5 reproduces
|
||||
17.294 / 18.376 exactly, M10x1.5 reproduces 8.376 / 9.026, M8x1.25 reproduces
|
||||
6.647 / 7.188.
|
||||
|
||||
**Representation.** ISO 6410-1:1993, *Technical drawings - Screw threads and
|
||||
threaded parts*: the minor diameter is drawn as a thin continuous line at
|
||||
0.8 x the major diameter (`MINOR_RATIO`); in a view along the axis that same
|
||||
circle is drawn as three quarters of a circle with the gap in one quadrant;
|
||||
the thread run-out / thread-length limit is a line across the full major
|
||||
diameter. An internal thread shown without a section is drawn hidden
|
||||
throughout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
from engineering.mech.primitives import Arc, Line, Prim
|
||||
from engineering.mech.standards import Coverage, register
|
||||
|
||||
SOURCE_PITCH = (
|
||||
"ISO 261:2022 Table 1 (coarse) and Table 2 (fine); first-choice M1.6-M64 "
|
||||
"plus second-choice M14, M18, M22, M27, M33, M39, M45, M52, M60"
|
||||
)
|
||||
SOURCE_BASIC = "ISO 68-1:1998 fundamental triangle; values pinned against ISO 724 Table 1"
|
||||
SOURCE_REPRESENTATION = "ISO 6410-1:1993"
|
||||
|
||||
#: ISO 6410-1: the minor diameter is represented at 0.8 x the major diameter.
|
||||
MINOR_RATIO = 0.8
|
||||
|
||||
#: ISO 261 Table 1 - coarse pitch series, major diameter (mm) -> pitch (mm).
|
||||
COARSE: dict[float, float] = {
|
||||
1.6: 0.35,
|
||||
2.0: 0.40,
|
||||
2.5: 0.45,
|
||||
3.0: 0.50,
|
||||
4.0: 0.70,
|
||||
5.0: 0.80,
|
||||
6.0: 1.00,
|
||||
8.0: 1.25,
|
||||
10.0: 1.50,
|
||||
12.0: 1.75,
|
||||
14.0: 2.00,
|
||||
16.0: 2.00,
|
||||
18.0: 2.50,
|
||||
20.0: 2.50,
|
||||
22.0: 2.50,
|
||||
24.0: 3.00,
|
||||
27.0: 3.00,
|
||||
30.0: 3.50,
|
||||
33.0: 3.50,
|
||||
36.0: 4.00,
|
||||
39.0: 4.00,
|
||||
42.0: 4.50,
|
||||
45.0: 4.50,
|
||||
48.0: 5.00,
|
||||
52.0: 5.00,
|
||||
56.0: 5.50,
|
||||
60.0: 5.50,
|
||||
64.0: 6.00,
|
||||
}
|
||||
|
||||
#: ISO 261 Table 2 - fine pitch series, major diameter (mm) -> pitches (mm),
|
||||
#: coarsest first. Transcribed from M8 upwards only.
|
||||
FINE: dict[float, tuple[float, ...]] = {
|
||||
8.0: (1.0, 0.75),
|
||||
10.0: (1.25, 1.0, 0.75),
|
||||
12.0: (1.5, 1.25, 1.0),
|
||||
14.0: (1.5, 1.0),
|
||||
16.0: (1.5, 1.0),
|
||||
18.0: (2.0, 1.5, 1.0),
|
||||
20.0: (2.0, 1.5, 1.0),
|
||||
22.0: (2.0, 1.5, 1.0),
|
||||
24.0: (2.0, 1.5, 1.0),
|
||||
27.0: (2.0, 1.5, 1.0),
|
||||
30.0: (2.0, 1.5, 1.0),
|
||||
33.0: (2.0, 1.5),
|
||||
36.0: (3.0, 2.0, 1.5),
|
||||
39.0: (3.0, 2.0, 1.5),
|
||||
42.0: (3.0, 2.0, 1.5),
|
||||
45.0: (3.0, 2.0, 1.5),
|
||||
48.0: (3.0, 2.0, 1.5),
|
||||
52.0: (3.0, 2.0, 1.5),
|
||||
56.0: (4.0, 3.0, 2.0, 1.5),
|
||||
60.0: (4.0, 3.0, 2.0, 1.5),
|
||||
64.0: (4.0, 3.0, 2.0, 1.5),
|
||||
}
|
||||
|
||||
DIAMETERS: tuple[float, ...] = tuple(sorted(COARSE))
|
||||
|
||||
COVERAGE = Coverage(DIAMETERS[0], DIAMETERS[-1], "mm")
|
||||
|
||||
_DESIGNATION_RE = re.compile(
|
||||
r"^\s*M\s*(\d+(?:[.,]\d+)?)\s*(?:[x×X]\s*(\d+(?:[.,]\d+)?))?\s*(?:-\s*(LH|RH))?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
register(
|
||||
"ISO 261",
|
||||
{d: {"pitch": COARSE[d], "fine": FINE.get(d, ())} for d in DIAMETERS},
|
||||
COVERAGE,
|
||||
SOURCE_PITCH,
|
||||
)
|
||||
|
||||
|
||||
def _number(text: str) -> float:
|
||||
return float(text.replace(",", "."))
|
||||
|
||||
|
||||
def _pitch_text(pitch: float) -> str:
|
||||
"""A pitch in millimetres, always carrying a decimal: 2.5, 2.0, 0.75."""
|
||||
text = f"{float(pitch):g}"
|
||||
return text if "." in text else f"{text}.0"
|
||||
|
||||
|
||||
def pitch_for(d: float, pitch: float | None = None) -> float:
|
||||
"""The coarse pitch for ``d``, or validate an explicit fine pitch against the series."""
|
||||
diameter = float(d)
|
||||
if diameter not in COARSE:
|
||||
raise ValueError(
|
||||
f"ISO 261: M{diameter:g} is not in the transcribed table. "
|
||||
f"Coverage is M{DIAMETERS[0]:g}-M{DIAMETERS[-1]:g}, the first-choice diameters plus "
|
||||
"the second-choice M14, M18, M22, M27, M33, M39, M45, M52 and M60; the remaining "
|
||||
"second-choice diameters and every third-choice diameter are not transcribed and "
|
||||
"are not interpolated."
|
||||
)
|
||||
if pitch is None:
|
||||
return COARSE[diameter]
|
||||
value = float(pitch)
|
||||
if abs(value - COARSE[diameter]) < 1e-9:
|
||||
return COARSE[diameter]
|
||||
series = FINE.get(diameter, ())
|
||||
for candidate in series:
|
||||
if abs(value - candidate) < 1e-9:
|
||||
return candidate
|
||||
known = ", ".join(_pitch_text(p) for p in (COARSE[diameter], *series))
|
||||
raise ValueError(
|
||||
f"ISO 261: M{diameter:g} has no pitch {value:g} mm in the transcribed series. "
|
||||
f"Transcribed pitches for M{diameter:g}: {known}."
|
||||
)
|
||||
|
||||
|
||||
def parse_designation(designation: str) -> dict:
|
||||
"""``M20`` / ``M20x1.5`` / ``M20x1.5-LH`` -> d, pitch, series, hand."""
|
||||
match = _DESIGNATION_RE.match(str(designation or ""))
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"designation {designation!r}: expected an ISO metric thread such as "
|
||||
"'M20', 'M20x1.5' or 'M20x1.5-LH'."
|
||||
)
|
||||
diameter = _number(match.group(1))
|
||||
raw_pitch = _number(match.group(2)) if match.group(2) else None
|
||||
pitch = pitch_for(diameter, raw_pitch)
|
||||
hand = "left" if (match.group(3) or "RH").upper() == "LH" else "right"
|
||||
series = "coarse" if abs(pitch - COARSE[diameter]) < 1e-9 else "fine"
|
||||
text = f"M{diameter:g}" if raw_pitch is None else f"M{diameter:g}x{pitch:g}"
|
||||
if hand == "left":
|
||||
text += "-LH"
|
||||
return {
|
||||
"designation": text,
|
||||
"d": diameter,
|
||||
"pitch": pitch,
|
||||
"series": series,
|
||||
"hand": hand,
|
||||
}
|
||||
|
||||
|
||||
def _h(pitch: float) -> float:
|
||||
"""ISO 68-1 fundamental triangle height."""
|
||||
return math.sqrt(3.0) / 2.0 * float(pitch)
|
||||
|
||||
|
||||
def basic_minor_diameter(d: float, pitch: float) -> float:
|
||||
"""ISO 724 basic minor diameter d1 = d - 1.25 * H."""
|
||||
return float(d) - 1.25 * _h(pitch)
|
||||
|
||||
|
||||
def basic_pitch_diameter(d: float, pitch: float) -> float:
|
||||
"""ISO 724 basic pitch diameter d2 = d - 0.75 * H."""
|
||||
return float(d) - 0.75 * _h(pitch)
|
||||
|
||||
|
||||
def thread_axial_prims(
|
||||
*,
|
||||
x0: float,
|
||||
length: float,
|
||||
d: float,
|
||||
internal: bool = False,
|
||||
minor_ratio: float = MINOR_RATIO,
|
||||
limit_line: bool = True,
|
||||
) -> tuple[Prim, ...]:
|
||||
"""ISO 6410 representation in a view along the axis (axis on y = 0, +X along it).
|
||||
|
||||
External: the two minor-diameter lines at 0.8 x d plus the thread-length
|
||||
limit line across the full major diameter. Internal (not sectioned): the
|
||||
same pair at the *major* diameter, hidden, because the drawn bore already
|
||||
carries the minor.
|
||||
"""
|
||||
x1 = float(x0) + float(length)
|
||||
role = "hidden" if internal else "visible"
|
||||
radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0
|
||||
prims: list[Prim] = [
|
||||
Line((float(x0), radius), (x1, radius), role),
|
||||
Line((float(x0), -radius), (x1, -radius), role),
|
||||
]
|
||||
if limit_line:
|
||||
major = float(d) / 2.0
|
||||
prims.append(Line((x1, -major), (x1, major), role))
|
||||
return tuple(prims)
|
||||
|
||||
|
||||
def thread_end_prims(
|
||||
*,
|
||||
center: tuple[float, float],
|
||||
d: float,
|
||||
internal: bool = False,
|
||||
gap_center_deg: float = 45.0,
|
||||
minor_ratio: float = MINOR_RATIO,
|
||||
) -> tuple[Prim, ...]:
|
||||
"""ISO 6410 three-quarter circle for a view along the thread axis.
|
||||
|
||||
External: three quarters of the *minor* circle. Internal: three quarters of
|
||||
the *major* circle. The quarter-circle gap is centred on ``gap_center_deg``
|
||||
(45 deg = the upper-right quadrant, the usual placement).
|
||||
"""
|
||||
radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0
|
||||
start = (float(gap_center_deg) + 45.0) % 360.0
|
||||
end = (start + 270.0) % 360.0
|
||||
return (Arc((float(center[0]), float(center[1])), radius, start, end, "visible"),)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""DIN 509 relief grooves (Freistiche), forms E and F.
|
||||
|
||||
SOURCE
|
||||
------
|
||||
DIN 509:1998-06, *Technische Zeichnungen - Freistiche*, Table 1 (forms E and F).
|
||||
|
||||
COVERAGE
|
||||
--------
|
||||
**No rows are transcribed in this build.** The track's table rule is absolute:
|
||||
a row that cannot be verified against the standard's own table is not shipped,
|
||||
because a wrong relief depth is a wrong workshop drawing while a narrow table
|
||||
is merely narrow. Every lookup here is therefore refused by name and names the
|
||||
route to fix it.
|
||||
|
||||
Extending this module is a **data edit with no code change**: fill ``ROWS_E`` /
|
||||
``ROWS_F`` with ``{shaft_diameter_mm: {"profile": [[along, depth], ...]}}`` and
|
||||
widen ``COVERAGE_E`` / ``COVERAGE_F`` to the transcribed range. ``profile`` is
|
||||
the standard's own published relief profile expressed as (distance along the
|
||||
axis from the shoulder corner, depth below the smaller cylinder) pairs in
|
||||
millimetres - the same ``profile`` key group S's ``std_feature_draw`` reads -
|
||||
so nothing downstream ever reconstructs a profile it was not handed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.mech.standards import Coverage, register
|
||||
|
||||
SOURCE = "DIN 509:1998-06, Table 1 (forms E and F) - NOT TRANSCRIBED in this build"
|
||||
|
||||
#: The keys every DIN 509 row must carry for the drawing code to use it.
|
||||
UNDERCUT_ROW_KEYS: tuple[str, ...] = ("profile",)
|
||||
|
||||
FORMS: tuple[str, ...] = ("E", "F")
|
||||
|
||||
ROWS_E: dict[float, dict] = {}
|
||||
ROWS_F: dict[float, dict] = {}
|
||||
|
||||
COVERAGE_E = Coverage(0.0, 0.0, "mm")
|
||||
COVERAGE_F = Coverage(0.0, 0.0, "mm")
|
||||
|
||||
register("DIN 509-E", ROWS_E, COVERAGE_E, SOURCE)
|
||||
register("DIN 509-F", ROWS_F, COVERAGE_F, SOURCE)
|
||||
|
||||
_ROWS = {"E": ROWS_E, "F": ROWS_F}
|
||||
|
||||
|
||||
def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...]:
|
||||
"""The published relief profile for a shaft diameter, or a named refusal."""
|
||||
key = str(form or "").strip().upper()
|
||||
if key not in FORMS:
|
||||
raise ValueError(f"DIN 509 defines forms E and F; got {form!r}.")
|
||||
rows = _ROWS[key]
|
||||
if not rows:
|
||||
raise ValueError(
|
||||
f"DIN 509 form {key}: no rows are transcribed in this build, so no relief "
|
||||
f"profile can be looked up for d = {float(d):g} mm. Pass the profile "
|
||||
"explicitly (Undercut(profile=[[along, depth], ...])), or transcribe "
|
||||
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/undercuts.py. "
|
||||
"Nothing is interpolated and no profile is invented."
|
||||
)
|
||||
size = float(d)
|
||||
if size in rows:
|
||||
return tuple(tuple(float(v) for v in pair) for pair in rows[size]["profile"])
|
||||
raise ValueError(
|
||||
f"DIN 509 form {key}: d = {size:g} mm has no transcribed row "
|
||||
f"(transcribed sizes: {sorted(rows)})."
|
||||
)
|
||||
@@ -0,0 +1,638 @@
|
||||
"""Every feature of spec section 5: validation, emitted primitives, dimensions.
|
||||
|
||||
Geometry is asserted as emitted primitive tuples - counts, roles, coordinates
|
||||
to 1e-9 - never as a screenshot. The two features with a real table
|
||||
(`Keyway` -> DIN 6885 via engineering.keyway, `GearTeeth` -> the involute
|
||||
generator in engineering.gear) are pinned against those modules rather than
|
||||
against numbers restated here; the four not-transcribed tables are exercised
|
||||
through their refusals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.gear import generate_full_gear_outline
|
||||
from engineering.keyway import keyway_dimensions
|
||||
from engineering.mech.features import (
|
||||
FEATURE_KINDS,
|
||||
AxialBore,
|
||||
BendLine,
|
||||
CentreHole,
|
||||
Chamfer,
|
||||
CornerChamfer,
|
||||
CornerFillet,
|
||||
CrossHole,
|
||||
Fillet,
|
||||
GearTeeth,
|
||||
Hole,
|
||||
HoleCircle,
|
||||
HolePattern,
|
||||
Keyway,
|
||||
ORingGroove,
|
||||
Pocket,
|
||||
RetainingGroove,
|
||||
Slot,
|
||||
Thread,
|
||||
Undercut,
|
||||
feature_from_dict,
|
||||
mirror_about_x,
|
||||
)
|
||||
from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part
|
||||
from engineering.mech.primitives import Arc, Circle, Line, Poly, Text
|
||||
|
||||
CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"}
|
||||
|
||||
|
||||
def shaft(*features) -> RevolvedPart:
|
||||
return RevolvedPart(
|
||||
name="shaft",
|
||||
segments=(Segment(20.0, 25.0), Segment(40.0, 30.0, 10.0), Segment(15.0, 20.0)),
|
||||
features=tuple(features),
|
||||
)
|
||||
|
||||
|
||||
def plate(*features) -> PrismaticPart:
|
||||
return PrismaticPart(
|
||||
name="plate",
|
||||
outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
|
||||
thickness=8.0,
|
||||
features=tuple(features),
|
||||
)
|
||||
|
||||
|
||||
def test_the_catalogue_is_the_nineteen_kinds_spec_section_5_names():
|
||||
assert FEATURE_KINDS == (
|
||||
"axial_bore",
|
||||
"bend_line",
|
||||
"centre_hole",
|
||||
"chamfer",
|
||||
"corner_chamfer",
|
||||
"corner_fillet",
|
||||
"cross_hole",
|
||||
"fillet",
|
||||
"gear_teeth",
|
||||
"hole",
|
||||
"hole_circle",
|
||||
"hole_pattern",
|
||||
"keyway",
|
||||
"oring_groove",
|
||||
"pocket",
|
||||
"retaining_groove",
|
||||
"slot",
|
||||
"thread",
|
||||
"undercut",
|
||||
)
|
||||
|
||||
|
||||
def test_feature_from_dict_round_trips_every_kind_it_builds():
|
||||
payload = {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0, "angle": 45.0}
|
||||
feature = feature_from_dict(payload)
|
||||
assert isinstance(feature, Chamfer)
|
||||
assert feature.to_dict() == payload
|
||||
|
||||
|
||||
def test_feature_from_dict_refuses_an_unknown_kind_and_an_unknown_key():
|
||||
with pytest.raises(ValueError, match="unknown feature"):
|
||||
feature_from_dict({"kind": "spline", "id": "s"})
|
||||
with pytest.raises(ValueError, match="unknown key"):
|
||||
feature_from_dict({"kind": "chamfer", "id": "c", "at": "start", "size": 1.0, "deg": 30})
|
||||
|
||||
|
||||
def test_mirror_about_x_flips_every_primitive_type():
|
||||
line, arc, circle, poly, text = mirror_about_x(
|
||||
[
|
||||
Line((0.0, 5.0), (10.0, 5.0)),
|
||||
Arc((0.0, 4.0), 2.0, 30.0, 90.0),
|
||||
Circle((1.0, 3.0), 2.0),
|
||||
Poly(((0.0, 1.0), (2.0, 3.0)), True),
|
||||
Text((1.0, 2.0), "A"),
|
||||
]
|
||||
)
|
||||
assert line.p1 == (0.0, -5.0) and line.p2 == (10.0, -5.0)
|
||||
assert arc.center == (0.0, -4.0)
|
||||
assert (arc.start_deg, arc.end_deg) == (270.0, 330.0)
|
||||
assert circle.center == (1.0, -3.0)
|
||||
assert poly.points == ((0.0, -1.0), (2.0, -3.0))
|
||||
assert text.at == (1.0, -2.0)
|
||||
|
||||
|
||||
# -- chamfer and fillet ------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_45_degree_chamfer_at_the_start_replaces_the_corner_with_equal_legs():
|
||||
feature = Chamfer(id="c1", at="start", size=2.0)
|
||||
part = shaft(feature)
|
||||
edit = feature.corner_edit(part)
|
||||
assert edit["corner"] == (0.0, 12.5)
|
||||
assert edit["before"] == (0.0, 10.5)
|
||||
assert edit["after"] == (2.0, 12.5)
|
||||
assert edit["arc"] is None
|
||||
assert feature.removal_box(part) == (0.0, 10.5, 2.0, 12.5)
|
||||
|
||||
|
||||
def test_a_30_degree_chamfer_at_the_end_cuts_toward_minus_x():
|
||||
"""The two replacement points sit on the two surfaces the corner joined.
|
||||
|
||||
The silhouette runs left to right, so ``before`` is the point left on the
|
||||
cylinder (x = 75 - size, r unchanged) and ``after`` is the point left on
|
||||
the end face (x = 75, r reduced by the radial leg). Neither of them is the
|
||||
corner itself.
|
||||
"""
|
||||
feature = Chamfer(id="c2", at="end", size=3.0, angle=30.0)
|
||||
part = shaft(feature)
|
||||
edit = feature.corner_edit(part)
|
||||
radial = 3.0 * math.tan(math.radians(30.0))
|
||||
assert edit["corner"] == (75.0, 10.0)
|
||||
assert edit["before"][0] == pytest.approx(72.0)
|
||||
assert edit["before"][1] == pytest.approx(10.0)
|
||||
assert edit["after"][0] == pytest.approx(75.0)
|
||||
assert edit["after"][1] == pytest.approx(10.0 - radial)
|
||||
assert edit["arc"] is None
|
||||
|
||||
|
||||
def test_a_chamfer_shows_as_an_extra_circle_in_the_end_view():
|
||||
feature = Chamfer(id="c1", at="end", size=2.0)
|
||||
part = shaft(feature)
|
||||
(circle,) = feature.prims(part, "side", CTX)
|
||||
assert isinstance(circle, Circle)
|
||||
assert circle.radius == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_a_chamfer_bigger_than_the_step_is_refused_by_name():
|
||||
feature = Chamfer(id="c1", at="start", size=40.0)
|
||||
with pytest.raises(ValueError, match="c1|size"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_a_fillet_at_a_step_up_emits_a_tangent_arc_in_the_lower_right_quadrant():
|
||||
feature = Fillet(id="f1", at="step:0", radius=2.0)
|
||||
part = shaft(feature)
|
||||
edit = feature.corner_edit(part)
|
||||
assert edit["corner"] == (20.0, 12.5)
|
||||
assert edit["before"] == (18.0, 12.5)
|
||||
assert edit["after"] == (20.0, 14.5)
|
||||
arc = edit["arc"]
|
||||
assert isinstance(arc, Arc)
|
||||
assert arc.center == (18.0, 14.5)
|
||||
assert (arc.radius, arc.start_deg, arc.end_deg) == (2.0, 270.0, 360.0)
|
||||
|
||||
|
||||
def test_a_fillet_at_a_step_down_puts_the_arc_in_the_lower_left_quadrant():
|
||||
feature = Fillet(id="f2", at="step:1", radius=1.5)
|
||||
part = shaft(feature)
|
||||
edit = feature.corner_edit(part)
|
||||
assert edit["corner"] == (60.0, 10.0)
|
||||
assert edit["before"] == (60.0, 11.5)
|
||||
assert edit["after"] == (61.5, 10.0)
|
||||
assert edit["arc"].center == (61.5, 11.5)
|
||||
assert (edit["arc"].start_deg, edit["arc"].end_deg) == (180.0, 270.0)
|
||||
|
||||
|
||||
def test_a_fillet_is_only_defined_at_a_step():
|
||||
feature = Fillet(id="f3", at="start", radius=1.0)
|
||||
with pytest.raises(ValueError, match="step"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
# -- keyway (DIN 6885 through engineering.keyway) ----------------------------
|
||||
|
||||
|
||||
def test_a_keyway_takes_its_width_and_depth_from_din_6885():
|
||||
feature = Keyway(id="kw", segment=1, length=25.0, offset=5.0)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
table = keyway_dimensions(30.0)
|
||||
prims = feature.prims(part, "front", CTX)
|
||||
assert len(prims) == 3
|
||||
assert all(isinstance(p, Line) and p.role == "visible" for p in prims)
|
||||
bottom = 15.0 - table["depth_shaft"]
|
||||
assert prims[0].p1 == (25.0, 15.0) and prims[0].p2 == (25.0, bottom)
|
||||
assert prims[1].p1 == (25.0, bottom) and prims[1].p2 == (50.0, bottom)
|
||||
assert prims[2].p1 == (50.0, bottom) and prims[2].p2 == (50.0, 15.0)
|
||||
assert feature.removal_box(part) == (25.0, bottom, 50.0, 15.0)
|
||||
|
||||
|
||||
def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch():
|
||||
feature = Keyway(id="kw", segment=1, length=25.0)
|
||||
part = shaft(feature)
|
||||
prims = feature.prims(part, "side", CTX)
|
||||
arcs = [p for p in prims if isinstance(p, Arc)]
|
||||
lines = [p for p in prims if isinstance(p, Line)]
|
||||
assert len(arcs) == 1 and len(lines) == 3
|
||||
width = keyway_dimensions(30.0)["width"]
|
||||
assert lines[1].p1[0] == pytest.approx(-width / 2.0)
|
||||
assert lines[1].p2[0] == pytest.approx(width / 2.0)
|
||||
|
||||
|
||||
def test_a_keyway_longer_than_its_segment_is_refused_by_name():
|
||||
feature = Keyway(id="kw", segment=1, length=50.0)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
feature.validate(shaft(feature))
|
||||
assert "40" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_keyway_on_a_segment_that_does_not_exist_is_refused_by_range():
|
||||
feature = Keyway(id="kw", segment=5, length=10.0)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
feature.validate(shaft(feature))
|
||||
assert "segments 0-2" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_keyway_outside_the_din_6885_bore_range_is_refused():
|
||||
part = RevolvedPart(name="pin", segments=(Segment(20.0, 4.0),))
|
||||
feature = Keyway(id="kw", segment=0, length=10.0)
|
||||
with pytest.raises(ValueError, match="DIN 6885"):
|
||||
feature.validate(part)
|
||||
|
||||
|
||||
# -- thread ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_external_thread_draws_the_iso6410_minor_lines_and_the_limit_line():
|
||||
feature = Thread(id="th", segment=2, designation="M20", length=12.0)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
prims = feature.prims(part, "front", CTX)
|
||||
assert len(prims) == 3
|
||||
assert prims[0].p1 == (60.0, 8.0) and prims[0].p2 == (72.0, 8.0)
|
||||
assert prims[2].p1 == (72.0, -10.0) and prims[2].p2 == (72.0, 10.0)
|
||||
|
||||
|
||||
def test_a_thread_whose_diameter_does_not_match_its_segment_is_refused_with_both_numbers():
|
||||
feature = Thread(id="th", segment=1, designation="M20", length=10.0)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
feature.validate(shaft(feature))
|
||||
message = str(excinfo.value)
|
||||
assert "M20" in message and "30" in message
|
||||
|
||||
|
||||
def test_an_internal_thread_enlarges_the_bore_to_the_major_for_the_section_hatch():
|
||||
feature = Thread(id="th", segment=0, designation="M10", length=20.0, internal=True)
|
||||
part = RevolvedPart(
|
||||
name="boss",
|
||||
segments=(Segment(30.0, 40.0, 8.0),), # drilled to the ISO 6410 minor, 0.8 x 10
|
||||
features=(feature,),
|
||||
)
|
||||
feature.validate(part)
|
||||
(edit,) = feature.radial_edit(part)
|
||||
assert edit == {"kind": "inner", "x0": 0.0, "x1": 20.0, "radius": 5.0}
|
||||
|
||||
|
||||
def test_a_thread_designation_outside_iso_261_is_refused_by_the_table():
|
||||
feature = Thread(id="th", segment=2, designation="M7", length=5.0)
|
||||
with pytest.raises(ValueError, match="ISO 261"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
# -- the four not-transcribed tables, through their features -----------------
|
||||
|
||||
|
||||
def test_an_undercut_without_an_explicit_profile_is_refused_by_din_509():
|
||||
feature = Undercut(id="uc", at="step:0", form="E")
|
||||
with pytest.raises(ValueError, match="DIN 509"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_an_undercut_draws_exactly_the_profile_it_is_handed():
|
||||
profile = ((0.0, 0.0), (0.5, 0.4), (2.0, 0.4), (2.5, 0.0))
|
||||
feature = Undercut(id="uc", at="step:0", form="E", profile=profile)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
(poly,) = feature.prims(part, "front", CTX)
|
||||
assert isinstance(poly, Poly) and poly.closed is False
|
||||
assert poly.points[0] == (20.0, 12.5)
|
||||
assert poly.points[1] == (19.5, 12.1)
|
||||
assert poly.points[-1] == (17.5, 12.5)
|
||||
assert feature.removal_box(part) == (17.5, 12.1, 20.0, 12.5)
|
||||
|
||||
|
||||
def test_a_retaining_groove_without_explicit_dimensions_is_refused_by_din_471():
|
||||
feature = RetainingGroove(id="rg", x=30.0, d=30.0)
|
||||
with pytest.raises(ValueError, match="DIN 471"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_a_retaining_groove_with_explicit_dimensions_cuts_the_profile():
|
||||
feature = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
(edit,) = feature.radial_edit(part)
|
||||
assert edit["kind"] == "outer"
|
||||
assert edit["radius"] == pytest.approx(14.3)
|
||||
assert (edit["x0"], edit["x1"]) == (pytest.approx(29.2), pytest.approx(30.8))
|
||||
lines = feature.prims(part, "front", CTX)
|
||||
assert len(lines) == 3
|
||||
assert lines[1].p1[1] == pytest.approx(14.3)
|
||||
|
||||
|
||||
def test_a_centre_hole_without_explicit_dimensions_is_refused_by_din_332():
|
||||
feature = CentreHole(id="ch", at="end", form="A", size=2.5)
|
||||
with pytest.raises(ValueError, match="DIN 332"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_a_centre_hole_form_r_is_refused_outright():
|
||||
feature = CentreHole(id="ch", at="end", form="R", size=2.5, d1=2.5, d2=5.3, t=5.0)
|
||||
with pytest.raises(ValueError, match="form R"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_a_centre_hole_with_explicit_dimensions_draws_a_hidden_60_degree_cone():
|
||||
feature = CentreHole(id="ch", at="end", form="A", size=2.5, d1=2.5, d2=5.3, t=5.0)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
prims = feature.prims(part, "front", CTX)
|
||||
assert {p.role for p in prims} == {"hidden"}
|
||||
cone_depth = (5.3 - 2.5) / 2.0 / math.tan(math.radians(30.0))
|
||||
assert prims[0].p1 == (75.0, 2.65)
|
||||
assert prims[0].p2[0] == pytest.approx(75.0 - cone_depth)
|
||||
assert prims[0].p2[1] == pytest.approx(1.25)
|
||||
|
||||
|
||||
def test_an_oring_groove_without_explicit_dimensions_is_refused_by_iso_3601():
|
||||
feature = ORingGroove(id="og", x=30.0, cord=3.53)
|
||||
with pytest.raises(ValueError, match="ISO 3601-2"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_an_axial_oring_groove_is_refused_rather_than_drawn_on_the_silhouette():
|
||||
feature = ORingGroove(id="og", x=30.0, cord=3.53, housing="axial", b=4.7, h=2.7)
|
||||
with pytest.raises(ValueError, match="face groove"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_an_oring_groove_round_trips_because_its_field_is_not_called_kind():
|
||||
"""``to_dict`` writes the feature kind under ``kind``.
|
||||
|
||||
A field of the same name would overwrite the discriminator - the payload
|
||||
would come back as ``{"kind": "radial", ...}`` and ``feature_from_dict``
|
||||
would refuse it - so the housing kind is carried by ``housing``.
|
||||
"""
|
||||
feature = ORingGroove(id="og", x=30.0, cord=3.53, b=4.7, h=2.7)
|
||||
payload = feature.to_dict()
|
||||
assert payload["kind"] == "oring_groove"
|
||||
assert payload["housing"] == "radial"
|
||||
assert feature_from_dict(payload) == feature
|
||||
|
||||
|
||||
# -- holes, bores, bolt circles, teeth ---------------------------------------
|
||||
|
||||
|
||||
def test_a_cross_hole_draws_two_hidden_lines_through_the_silhouette():
|
||||
feature = CrossHole(id="xh", x=30.0, diameter=6.0)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
prims = feature.prims(part, "front", CTX)
|
||||
assert len(prims) == 2
|
||||
assert {p.role for p in prims} == {"hidden"}
|
||||
assert prims[0].p1 == (27.0, -15.0) and prims[0].p2 == (27.0, 15.0)
|
||||
assert prims[1].p1 == (33.0, -15.0) and prims[1].p2 == (33.0, 15.0)
|
||||
|
||||
|
||||
def test_an_inclined_cross_hole_is_refused_rather_than_drawn_as_if_radial():
|
||||
feature = CrossHole(id="xh", x=30.0, diameter=6.0, angle=45.0)
|
||||
with pytest.raises(ValueError, match="angle"):
|
||||
feature.validate(shaft(feature))
|
||||
|
||||
|
||||
def test_an_axial_bore_edits_the_radial_profile_and_is_not_flagged_as_omitted():
|
||||
feature = AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)
|
||||
part = shaft(feature)
|
||||
feature.validate(part)
|
||||
(edit,) = feature.radial_edit(part)
|
||||
assert edit == {"kind": "inner", "x0": 0.0, "x1": 12.0, "radius": 4.0}
|
||||
assert feature.removal_box(part) is None
|
||||
|
||||
|
||||
def test_two_axial_bores_from_the_same_end_are_refused_with_both_names():
|
||||
a = AxialBore(id="ab1", at="start", diameter=8.0, depth=12.0)
|
||||
b = AxialBore(id="ab2", at="start", diameter=6.0, depth=20.0)
|
||||
part = shaft(a, b)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
a.validate(part)
|
||||
assert "ab1" in str(excinfo.value) and "ab2" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view():
|
||||
feature = HoleCircle(id="bc", x=10.0, pcd=36.0, count=6, diameter=5.0)
|
||||
part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
|
||||
feature.validate(part)
|
||||
prims = feature.prims(part, "side", CTX)
|
||||
holes = [p for p in prims if isinstance(p, Circle) and p.role == "visible"]
|
||||
pitch = [p for p in prims if isinstance(p, Circle) and p.role == "center"]
|
||||
assert len(holes) == 6 and len(pitch) == 1
|
||||
assert pitch[0].radius == pytest.approx(18.0)
|
||||
assert holes[0].center == (pytest.approx(18.0), pytest.approx(0.0))
|
||||
assert holes[1].center[0] == pytest.approx(18.0 * math.cos(math.radians(60.0)))
|
||||
|
||||
|
||||
def test_a_bolt_circle_that_does_not_fit_the_flange_is_refused_by_name():
|
||||
feature = HoleCircle(id="bc", x=10.0, pcd=58.0, count=6, diameter=5.0)
|
||||
part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
|
||||
with pytest.raises(ValueError, match="bc|pcd"):
|
||||
feature.validate(part)
|
||||
|
||||
|
||||
def test_gear_teeth_reuse_the_repository_involute_generator():
|
||||
feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
|
||||
part = RevolvedPart(name="pinion", segments=(Segment(15.0, 44.0),), features=(feature,))
|
||||
feature.validate(part)
|
||||
(poly, pitch) = feature.prims(part, "side", CTX)[:2]
|
||||
expected = generate_full_gear_outline(2.0, 20, 20.0, 0.0, "RH", (0.0, 0.0))
|
||||
assert poly.points == tuple(expected)
|
||||
assert poly.closed is True
|
||||
assert pitch.radius == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_gear_teeth_whose_tip_diameter_disagrees_with_the_segment_are_refused():
|
||||
feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
|
||||
part = RevolvedPart(name="pinion", segments=(Segment(15.0, 40.0),), features=(feature,))
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
feature.validate(part)
|
||||
assert "44" in str(excinfo.value) and "40" in str(excinfo.value)
|
||||
|
||||
|
||||
# -- prismatic features ------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view():
|
||||
feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0)
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
front = feature.prims(part, "front", CTX)
|
||||
circle = next(p for p in front if isinstance(p, Circle))
|
||||
assert circle.center == (20.0, 25.0) and circle.radius == 5.0
|
||||
assert sum(1 for p in front if isinstance(p, Line) and p.role == "center") == 2
|
||||
side = feature.prims(part, "side", CTX)
|
||||
assert {p.role for p in side} == {"hidden"}
|
||||
assert side[0].p1 == (20.0, 0.0) and side[0].p2 == (20.0, 8.0)
|
||||
top = feature.prims(part, "top", CTX)
|
||||
assert top[0].p1 == (15.0, 0.0)
|
||||
|
||||
|
||||
def test_a_hole_outside_the_outline_is_refused_by_name():
|
||||
feature = Hole(id="h1", x=200.0, y=25.0, diameter=10.0)
|
||||
with pytest.raises(ValueError, match="outline"):
|
||||
feature.validate(plate(feature))
|
||||
|
||||
|
||||
def test_a_tapped_hole_draws_the_minor_circle_and_a_three_quarter_major_arc():
|
||||
feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0, thread="M10")
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
front = feature.prims(part, "front", CTX)
|
||||
circles = [p for p in front if isinstance(p, Circle)]
|
||||
arcs = [p for p in front if isinstance(p, Arc)]
|
||||
assert circles[0].radius == pytest.approx(4.0)
|
||||
assert len(arcs) == 1 and arcs[0].radius == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_a_slot_is_an_obround_of_two_lines_and_two_semicircles():
|
||||
feature = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0)
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
prims = feature.prims(part, "front", CTX)
|
||||
lines = [p for p in prims if isinstance(p, Line) and p.role == "visible"]
|
||||
arcs = [p for p in prims if isinstance(p, Arc)]
|
||||
assert len(lines) == 2 and len(arcs) == 2
|
||||
assert lines[0].p1 == (pytest.approx(30.0), pytest.approx(30.0))
|
||||
assert arcs[0].center == (pytest.approx(50.0), pytest.approx(25.0))
|
||||
assert feature.removal_box(part) == (25.0, 20.0, 55.0, 30.0)
|
||||
|
||||
|
||||
def test_a_slot_narrower_than_it_is_long_is_required():
|
||||
feature = Slot(id="s1", x=40.0, y=25.0, length=5.0, width=10.0)
|
||||
with pytest.raises(ValueError, match="length"):
|
||||
feature.validate(plate(feature))
|
||||
|
||||
|
||||
def test_a_pocket_can_declare_itself_unhatched_in_a_longitudinal_section():
|
||||
feature = Pocket(
|
||||
id="p1",
|
||||
outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)),
|
||||
depth=4.0,
|
||||
no_section_hatch=True,
|
||||
)
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
(poly,) = feature.prims(part, "front", CTX)
|
||||
assert poly.closed is True and poly.role == "visible"
|
||||
assert feature.no_section_hatch is True
|
||||
assert feature.removal_box(part) == (10.0, 10.0, 30.0, 30.0)
|
||||
|
||||
|
||||
def test_a_pocket_deeper_than_the_plate_is_refused():
|
||||
feature = Pocket(
|
||||
id="p1",
|
||||
outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0)),
|
||||
depth=20.0,
|
||||
)
|
||||
with pytest.raises(ValueError, match="thickness"):
|
||||
feature.validate(plate(feature))
|
||||
|
||||
|
||||
def test_a_corner_fillet_replaces_a_vertex_with_two_tangent_points_and_an_arc():
|
||||
feature = CornerFillet(id="cf", vertex=1, radius=10.0)
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
edit = feature.outline_edit(part)
|
||||
assert edit["vertex"] == 1
|
||||
assert edit["before"] == (pytest.approx(70.0), pytest.approx(0.0))
|
||||
assert edit["after"] == (pytest.approx(80.0), pytest.approx(10.0))
|
||||
assert edit["arc"].center == (pytest.approx(70.0), pytest.approx(10.0))
|
||||
assert edit["arc"].radius == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_a_corner_chamfer_replaces_a_vertex_with_two_points_and_no_arc():
|
||||
feature = CornerChamfer(id="cc", vertex=1, size=6.0)
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
edit = feature.outline_edit(part)
|
||||
assert edit["before"] == (pytest.approx(74.0), pytest.approx(0.0))
|
||||
assert edit["after"] == (pytest.approx(80.0), pytest.approx(6.0))
|
||||
assert edit["arc"] is None
|
||||
|
||||
|
||||
def test_a_corner_radius_larger_than_its_edges_is_refused():
|
||||
feature = CornerFillet(id="cf", vertex=1, radius=60.0)
|
||||
with pytest.raises(ValueError, match="edge"):
|
||||
feature.validate(plate(feature))
|
||||
|
||||
|
||||
def test_a_bend_line_is_representation_only_and_says_so():
|
||||
feature = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0)
|
||||
part = plate(feature)
|
||||
feature.validate(part)
|
||||
line, text = feature.prims(part, "front", CTX)
|
||||
assert isinstance(line, Line) and line.role == "phantom"
|
||||
assert isinstance(text, Text)
|
||||
assert "R2" in text.text and "90" in text.text
|
||||
assert feature.dims(part, "front", CTX) == ()
|
||||
|
||||
|
||||
def test_a_hole_pattern_expands_before_it_reaches_the_part():
|
||||
spec = {
|
||||
"kind": "prismatic",
|
||||
"name": "plate",
|
||||
"thickness": 8.0,
|
||||
"outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
|
||||
"features": [
|
||||
{
|
||||
"kind": "hole_pattern",
|
||||
"id": "hp",
|
||||
"pattern": "grid",
|
||||
"base": {"x": 10.0, "y": 10.0, "diameter": 6.0},
|
||||
"count": 3,
|
||||
"spacing": 20.0,
|
||||
"count_y": 2,
|
||||
"spacing_y": 25.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
part = build_part(spec)
|
||||
assert len(part.features) == 6
|
||||
assert all(isinstance(f, Hole) for f in part.features)
|
||||
assert [f.id for f in part.features][:3] == ["hp_0_0", "hp_1_0", "hp_2_0"]
|
||||
assert part.features[2].x == pytest.approx(50.0)
|
||||
assert part.features[3].y == pytest.approx(35.0)
|
||||
|
||||
|
||||
def test_a_polar_hole_pattern_places_its_holes_on_the_pitch_circle():
|
||||
pattern = HolePattern(
|
||||
id="hp",
|
||||
pattern="polar",
|
||||
base={"x": 40.0, "y": 25.0, "diameter": 5.0},
|
||||
count=4,
|
||||
pcd=30.0,
|
||||
)
|
||||
holes = pattern.expand()
|
||||
assert len(holes) == 4
|
||||
assert holes[0].x == pytest.approx(55.0) and holes[0].y == pytest.approx(25.0)
|
||||
assert holes[1].x == pytest.approx(40.0) and holes[1].y == pytest.approx(40.0)
|
||||
|
||||
|
||||
def test_a_linear_pattern_without_a_spacing_is_refused():
|
||||
pattern = HolePattern(
|
||||
id="hp",
|
||||
pattern="linear",
|
||||
base={"x": 10.0, "y": 10.0, "diameter": 6.0},
|
||||
count=3,
|
||||
)
|
||||
with pytest.raises(ValueError, match="spacing"):
|
||||
pattern.expand()
|
||||
|
||||
|
||||
def test_every_feature_declares_its_dimension_intents_against_its_own_id():
|
||||
features = (
|
||||
Chamfer(id="c1", at="start", size=2.0),
|
||||
Keyway(id="kw", segment=1, length=25.0),
|
||||
Thread(id="th", segment=2, designation="M20", length=12.0),
|
||||
CrossHole(id="xh", x=30.0, diameter=6.0),
|
||||
)
|
||||
part = shaft(*features)
|
||||
for feature in features:
|
||||
for intent in feature.dims(part, "front", CTX):
|
||||
assert intent.feature == feature.id
|
||||
@@ -89,6 +89,19 @@ def test_a_source_that_does_not_name_its_table_is_refused():
|
||||
register("TEST 4", {1: {"a": 1}}, Coverage(1.0, 1.0), "made up")
|
||||
|
||||
|
||||
def test_a_table_may_register_its_structure_before_its_rows_are_transcribed():
|
||||
"""DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 ship exactly this way."""
|
||||
register("TEST 5", {}, Coverage(0.0, 0.0), SOURCE)
|
||||
assert "TEST 5" in standards()
|
||||
with pytest.raises(ValueError, match="TEST 5"):
|
||||
lookup("TEST 5", 12)
|
||||
|
||||
|
||||
def test_a_rows_argument_that_is_not_a_dict_is_refused():
|
||||
with pytest.raises(ValueError, match="rows dict"):
|
||||
register("TEST 6", [{"d": 1.0}], Coverage(1.0, 1.0), SOURCE)
|
||||
|
||||
|
||||
def test_registering_the_same_standard_twice_is_refused():
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""The four tables this build does NOT transcribe, and how they refuse.
|
||||
|
||||
The track's table rule is absolute: a row that cannot be sourced against the
|
||||
standard is not shipped, because a wrong relief depth or a wrong groove
|
||||
diameter is a wrong workshop drawing, while a narrow table is merely narrow.
|
||||
DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 therefore ship with their
|
||||
structure, their SOURCE and an empty row set, and every lookup is refused by
|
||||
name and says what to transcribe.
|
||||
|
||||
These tests are the gate that keeps that honest in both directions: the
|
||||
refusal must name the standard and the transcription route, and the shape gate
|
||||
must fail the moment somebody adds a row that is missing a column the drawing
|
||||
code reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.standards import standards
|
||||
from engineering.mech.standards.centres import (
|
||||
CENTRE_ROW_KEYS,
|
||||
ROWS_A,
|
||||
ROWS_B,
|
||||
centre_hole_dims,
|
||||
)
|
||||
from engineering.mech.standards.centres import (
|
||||
SOURCE as SOURCE_CENTRES,
|
||||
)
|
||||
from engineering.mech.standards.orings import (
|
||||
ORING_ROW_KEYS,
|
||||
oring_groove_dims,
|
||||
)
|
||||
from engineering.mech.standards.orings import (
|
||||
ROWS as ROWS_ORING,
|
||||
)
|
||||
from engineering.mech.standards.orings import (
|
||||
SOURCE as SOURCE_ORING,
|
||||
)
|
||||
from engineering.mech.standards.rings import (
|
||||
RING_ROW_KEYS,
|
||||
ROWS_471,
|
||||
ROWS_472,
|
||||
ring_groove_dims,
|
||||
)
|
||||
from engineering.mech.standards.rings import (
|
||||
SOURCE as SOURCE_RINGS,
|
||||
)
|
||||
from engineering.mech.standards.undercuts import (
|
||||
ROWS_E,
|
||||
ROWS_F,
|
||||
UNDERCUT_ROW_KEYS,
|
||||
undercut_profile,
|
||||
)
|
||||
from engineering.mech.standards.undercuts import (
|
||||
SOURCE as SOURCE_UNDERCUTS,
|
||||
)
|
||||
|
||||
|
||||
def test_every_table_is_registered_so_a_later_transcription_is_a_data_edit():
|
||||
registered = standards()
|
||||
for name in (
|
||||
"DIN 509-E",
|
||||
"DIN 509-F",
|
||||
"DIN 471",
|
||||
"DIN 472",
|
||||
"DIN 332-A",
|
||||
"DIN 332-B",
|
||||
"ISO 3601-2",
|
||||
):
|
||||
assert name in registered
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "standard"),
|
||||
[
|
||||
(SOURCE_UNDERCUTS, "DIN 509"),
|
||||
(SOURCE_RINGS, "DIN 471"),
|
||||
(SOURCE_CENTRES, "DIN 332"),
|
||||
(SOURCE_ORING, "ISO 3601-2"),
|
||||
],
|
||||
)
|
||||
def test_every_source_names_its_standard_and_edition(source, standard):
|
||||
assert standard in source
|
||||
assert any(ch.isdigit() for ch in source.split(":")[-1])
|
||||
|
||||
|
||||
def test_no_row_is_shipped_in_this_build():
|
||||
assert ROWS_E == {} and ROWS_F == {}
|
||||
assert ROWS_471 == {} and ROWS_472 == {}
|
||||
assert ROWS_A == {} and ROWS_B == {}
|
||||
assert ROWS_ORING == {}
|
||||
|
||||
|
||||
def test_the_undercut_lookup_is_refused_by_name_and_says_what_to_transcribe():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
undercut_profile("E", 40.0)
|
||||
message = str(excinfo.value)
|
||||
assert "DIN 509" in message
|
||||
assert "form E" in message
|
||||
assert "40" in message
|
||||
assert "undercuts.py" in message
|
||||
|
||||
|
||||
def test_an_unknown_undercut_form_is_refused_before_the_coverage_refusal():
|
||||
with pytest.raises(ValueError, match="forms E and F"):
|
||||
undercut_profile("G", 40.0)
|
||||
|
||||
|
||||
def test_the_ring_groove_lookup_is_refused_by_name_for_shaft_and_bore():
|
||||
for standard in ("DIN 471", "DIN 472"):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
ring_groove_dims(standard, 30.0)
|
||||
assert standard in str(excinfo.value)
|
||||
assert "rings.py" in str(excinfo.value)
|
||||
with pytest.raises(ValueError, match="DIN 471 or DIN 472"):
|
||||
ring_groove_dims("DIN 6799", 30.0)
|
||||
|
||||
|
||||
def test_the_centre_hole_lookup_is_refused_by_name_and_form_r_is_refused_outright():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
centre_hole_dims("A", 2.5)
|
||||
assert "DIN 332" in str(excinfo.value)
|
||||
assert "centres.py" in str(excinfo.value)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
centre_hole_dims("R", 2.5)
|
||||
assert "form R" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_the_oring_lookup_is_refused_by_name():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
oring_groove_dims(3.53)
|
||||
assert "ISO 3601-2" in str(excinfo.value)
|
||||
assert "orings.py" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_the_row_key_contracts_are_the_ones_the_drawing_code_reads():
|
||||
assert UNDERCUT_ROW_KEYS == ("profile",)
|
||||
assert RING_ROW_KEYS == ("m", "d2")
|
||||
assert CENTRE_ROW_KEYS == ("d1", "d2", "d3", "t")
|
||||
assert ORING_ROW_KEYS == ("b", "h")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rows", "keys"),
|
||||
[
|
||||
(ROWS_E, ("profile",)),
|
||||
(ROWS_F, ("profile",)),
|
||||
(ROWS_471, ("m", "d2")),
|
||||
(ROWS_472, ("m", "d2")),
|
||||
(ROWS_A, ("d1", "d2", "t")),
|
||||
(ROWS_B, ("d1", "d2", "d3", "t")),
|
||||
(ROWS_ORING, ("b", "h")),
|
||||
],
|
||||
)
|
||||
def test_any_row_that_is_ever_added_must_carry_every_key_the_drawing_code_reads(rows, keys):
|
||||
"""Empty today; this gate fires the moment a row lands without a column."""
|
||||
for size, row in rows.items():
|
||||
missing = [key for key in keys if key not in row]
|
||||
assert not missing, f"row {size} is missing {missing}"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""ISO 261 pitches, ISO 724 basic diameters and the ISO 6410 representation.
|
||||
|
||||
Provenance: every asserted pitch is a value the module transcribed from
|
||||
ISO 261:2022; every asserted basic diameter is a published ISO 724 value that
|
||||
the ISO 68-1 formula has to reproduce exactly. The coverage refusals are
|
||||
asserted at both ends, because a size outside the transcribed table must be
|
||||
refused by name and never interpolated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.primitives import Arc, Line
|
||||
from engineering.mech.standards import lookup
|
||||
from engineering.mech.standards.threads import (
|
||||
COARSE,
|
||||
DIAMETERS,
|
||||
FINE,
|
||||
MINOR_RATIO,
|
||||
basic_minor_diameter,
|
||||
basic_pitch_diameter,
|
||||
parse_designation,
|
||||
pitch_for,
|
||||
thread_axial_prims,
|
||||
thread_end_prims,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("d", "pitch"),
|
||||
[
|
||||
(1.6, 0.35),
|
||||
(2.0, 0.40),
|
||||
(3.0, 0.50),
|
||||
(5.0, 0.80),
|
||||
(6.0, 1.00),
|
||||
(8.0, 1.25),
|
||||
(10.0, 1.50),
|
||||
(12.0, 1.75),
|
||||
(14.0, 2.00),
|
||||
(16.0, 2.00),
|
||||
(20.0, 2.50),
|
||||
(24.0, 3.00),
|
||||
(30.0, 3.50),
|
||||
(33.0, 3.50),
|
||||
(36.0, 4.00),
|
||||
(42.0, 4.50),
|
||||
(48.0, 5.00),
|
||||
(56.0, 5.50),
|
||||
(64.0, 6.00),
|
||||
],
|
||||
)
|
||||
def test_iso261_coarse_pitches_are_the_transcribed_values(d, pitch):
|
||||
assert COARSE[d] == pitch
|
||||
|
||||
|
||||
def test_the_transcribed_diameters_are_first_choice_plus_six_second_choice():
|
||||
assert DIAMETERS[0] == 1.6
|
||||
assert DIAMETERS[-1] == 64.0
|
||||
assert {14.0, 18.0, 22.0, 27.0, 33.0, 39.0} <= set(DIAMETERS)
|
||||
# third-choice diameters are deliberately absent
|
||||
assert 7.0 not in DIAMETERS
|
||||
assert 9.0 not in DIAMETERS
|
||||
assert 3.5 not in DIAMETERS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("d", "pitches"),
|
||||
[
|
||||
(8.0, (1.0, 0.75)),
|
||||
(10.0, (1.25, 1.0, 0.75)),
|
||||
(12.0, (1.5, 1.25, 1.0)),
|
||||
(16.0, (1.5, 1.0)),
|
||||
(20.0, (2.0, 1.5, 1.0)),
|
||||
(24.0, (2.0, 1.5, 1.0)),
|
||||
(36.0, (3.0, 2.0, 1.5)),
|
||||
(64.0, (4.0, 3.0, 2.0, 1.5)),
|
||||
],
|
||||
)
|
||||
def test_iso261_fine_pitch_series(d, pitches):
|
||||
assert FINE[d] == pitches
|
||||
|
||||
|
||||
def test_a_bare_designation_resolves_to_the_coarse_pitch():
|
||||
assert parse_designation("M20") == {
|
||||
"designation": "M20",
|
||||
"d": 20.0,
|
||||
"pitch": 2.5,
|
||||
"series": "coarse",
|
||||
"hand": "right",
|
||||
}
|
||||
|
||||
|
||||
def test_a_fine_designation_resolves_and_keeps_its_hand():
|
||||
parsed = parse_designation("M20x1.5-LH")
|
||||
assert parsed["d"] == 20.0
|
||||
assert parsed["pitch"] == 1.5
|
||||
assert parsed["series"] == "fine"
|
||||
assert parsed["hand"] == "left"
|
||||
|
||||
|
||||
def test_a_diameter_outside_the_transcribed_table_is_refused_by_name():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
parse_designation("M7")
|
||||
message = str(excinfo.value)
|
||||
assert "ISO 261" in message
|
||||
assert "M7" in message
|
||||
with pytest.raises(ValueError, match="ISO 261"):
|
||||
parse_designation("M70")
|
||||
|
||||
|
||||
def test_a_fine_pitch_that_is_not_in_the_series_is_refused_and_lists_the_series():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
parse_designation("M20x1.25")
|
||||
message = str(excinfo.value)
|
||||
assert "M20" in message
|
||||
assert "2.0" in message and "1.5" in message and "1.0" in message
|
||||
|
||||
|
||||
def test_a_malformed_designation_is_refused():
|
||||
with pytest.raises(ValueError, match="designation"):
|
||||
parse_designation("20mm")
|
||||
|
||||
|
||||
def test_pitch_for_matches_parse_designation():
|
||||
assert pitch_for(20.0) == 2.5
|
||||
assert pitch_for(20.0, 1.5) == 1.5
|
||||
with pytest.raises(ValueError):
|
||||
pitch_for(20.0, 1.25)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("d", "pitch", "d1", "d2"),
|
||||
[
|
||||
(20.0, 2.50, 17.294, 18.376),
|
||||
(10.0, 1.50, 8.376, 9.026),
|
||||
(8.0, 1.25, 6.647, 7.188),
|
||||
(12.0, 1.75, 10.106, 10.863),
|
||||
(6.0, 1.00, 4.917, 5.350),
|
||||
],
|
||||
)
|
||||
def test_iso68_1_reproduces_the_published_iso724_basic_diameters(d, pitch, d1, d2):
|
||||
assert basic_minor_diameter(d, pitch) == pytest.approx(d1, abs=5e-4)
|
||||
assert basic_pitch_diameter(d, pitch) == pytest.approx(d2, abs=5e-4)
|
||||
|
||||
|
||||
def test_the_registry_carries_the_pitch_table_and_refuses_outside_its_coverage():
|
||||
assert lookup("ISO 261", 20.0)["pitch"] == 2.5
|
||||
with pytest.raises(ValueError, match="ISO 261"):
|
||||
lookup("ISO 261", 70.0)
|
||||
|
||||
|
||||
def test_iso6410_axial_representation_of_an_external_thread():
|
||||
prims = thread_axial_prims(x0=10.0, length=30.0, d=20.0)
|
||||
lines = [p for p in prims if isinstance(p, Line)]
|
||||
assert len(prims) == 3 and len(lines) == 3
|
||||
minor = MINOR_RATIO * 20.0 / 2.0
|
||||
assert {round(line.p1[1], 9) for line in lines[:2]} == {round(minor, 9), round(-minor, 9)}
|
||||
assert lines[0].p1 == (10.0, minor) and lines[0].p2 == (40.0, minor)
|
||||
assert lines[1].p1 == (10.0, -minor) and lines[1].p2 == (40.0, -minor)
|
||||
# the thread-length limit line runs the full major diameter
|
||||
assert lines[2].p1 == (40.0, -10.0) and lines[2].p2 == (40.0, 10.0)
|
||||
assert {line.role for line in lines} == {"visible"}
|
||||
|
||||
|
||||
def test_iso6410_axial_representation_of_an_internal_thread_is_hidden():
|
||||
prims = thread_axial_prims(x0=0.0, length=20.0, d=12.0, internal=True)
|
||||
assert {p.role for p in prims} == {"hidden"}
|
||||
assert prims[0].p1 == (0.0, 6.0) and prims[0].p2 == (20.0, 6.0)
|
||||
|
||||
|
||||
def test_iso6410_end_view_is_three_quarters_of_a_circle():
|
||||
(arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0)
|
||||
assert isinstance(arc, Arc)
|
||||
assert arc.radius == pytest.approx(8.0)
|
||||
assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0)
|
||||
assert arc.start_deg == pytest.approx(90.0)
|
||||
|
||||
|
||||
def test_the_end_view_gap_can_be_moved_and_stays_a_quarter():
|
||||
(arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0, gap_center_deg=135.0)
|
||||
assert arc.start_deg == pytest.approx(180.0)
|
||||
assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0)
|
||||
assert math.isclose(arc.radius, 8.0)
|
||||
Reference in New Issue
Block a user