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
@@ -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