Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""ISO 286 fit-table lookups pinned against published table values."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from fastmcp.exceptions import ToolError
|
||
|
||
from engineering.fits import FitDeviation, fit_lookup, parse_fit_code
|
||
from engineering.tolerances import build_dim_override
|
||
from server import _fit_to_tolerances
|
||
|
||
|
||
def _um(value_mm: float) -> float:
|
||
return round(value_mm * 1000.0, 3)
|
||
|
||
|
||
# (code, nominal, upper µm, lower µm) — published ISO 286-2 values.
|
||
CANONICAL_FITS = [
|
||
("H7", 20.0, 21, 0),
|
||
("H7", 40.0, 25, 0),
|
||
("H8", 30.0, 33, 0),
|
||
("H9", 6.0, 30, 0),
|
||
("H7", 200.0, 46, 0),
|
||
("g6", 20.0, -7, -20),
|
||
("g6", 6.0, -4, -12),
|
||
("f7", 40.0, -25, -50),
|
||
("e8", 25.0, -40, -73),
|
||
("d9", 100.0, -120, -207),
|
||
("h6", 30.0, 0, -13), # 30 mm is inside the >18-30 step (inclusive upper bound)
|
||
("h7", 50.0, 0, -25),
|
||
("k6", 20.0, 15, 2),
|
||
("m6", 25.0, 21, 8),
|
||
("n6", 20.0, 28, 15),
|
||
("p6", 20.0, 35, 22),
|
||
("js9", 25.0, 26, -26),
|
||
("G7", 6.0, 16, 4),
|
||
("F8", 40.0, 64, 25),
|
||
("D10", 30.0, 149, 65),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("code,nominal,upper_um,lower_um", CANONICAL_FITS)
|
||
def test_canonical_fit_values(code, nominal, upper_um, lower_um):
|
||
deviation = fit_lookup(code, nominal)
|
||
assert isinstance(deviation, FitDeviation)
|
||
assert _um(deviation.upper_mm) == pytest.approx(upper_um), f"{code}@{nominal} upper"
|
||
assert _um(deviation.lower_mm) == pytest.approx(lower_um), f"{code}@{nominal} lower"
|
||
|
||
|
||
def test_k_outside_it4_to_7_has_zero_lower_deviation():
|
||
deviation = fit_lookup("k9", 20.0)
|
||
assert deviation.lower_mm == 0.0
|
||
assert _um(deviation.upper_mm) == 52 # IT9 @ 18-30
|
||
|
||
|
||
def test_parse_fit_code():
|
||
assert parse_fit_code("H7") == ("H", 7)
|
||
assert parse_fit_code("js10") == ("js", 10)
|
||
with pytest.raises(ValueError):
|
||
parse_fit_code("77")
|
||
with pytest.raises(ValueError):
|
||
parse_fit_code("")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"code,nominal",
|
||
[
|
||
# K7/P7 used to be here: the delta rule derives them from tables already
|
||
# in the module, so they are authored as of 1.5.0 and pinned against
|
||
# published values above.
|
||
("t6", 20.0), # ISO 286 does not define shaft t below 24 mm
|
||
("x6", 20.0), # letter outside the authored subset (regex rejects)
|
||
("H3", 20.0), # grade below authored range
|
||
("H12", 20.0), # grade above authored range
|
||
("H7", 0.5), # below 1 mm
|
||
("H7", 600.0), # above 500 mm
|
||
],
|
||
)
|
||
def test_out_of_scope_lookups_raise(code, nominal):
|
||
with pytest.raises(ValueError):
|
||
fit_lookup(code, nominal)
|
||
|
||
|
||
def test_double_positive_fit_renders_correct_dimvars():
|
||
"""p6-style fits (+upper/+lower) must produce a negative DIMTM so AutoCAD
|
||
displays the plus lower deviation."""
|
||
deviation = fit_lookup("p6", 20.0)
|
||
override, _ = build_dim_override(
|
||
tol_upper=deviation.upper_mm,
|
||
tol_lower=-deviation.lower_mm, # server convention: positive = minus
|
||
tol_mode="deviation",
|
||
)
|
||
assert override["dimtp"] == pytest.approx(0.035)
|
||
assert override["dimtm"] == pytest.approx(-0.022) # displayed as +0.022
|
||
|
||
|
||
def test_fit_to_tolerances_contract():
|
||
tol_upper, tol_lower, tol_mode, text = _fit_to_tolerances("H7", 20.0, None, None, "none", None)
|
||
assert tol_upper == pytest.approx(0.021)
|
||
assert tol_lower == pytest.approx(0.0)
|
||
assert tol_mode == "deviation"
|
||
assert text == "<> H7"
|
||
|
||
|
||
def test_fit_to_tolerances_rejects_mixed_usage():
|
||
with pytest.raises(ToolError):
|
||
_fit_to_tolerances("H7", 20.0, 0.1, None, "none", None)
|
||
with pytest.raises(ToolError):
|
||
_fit_to_tolerances("H7", 20.0, None, None, "symmetric", None)
|
||
with pytest.raises(ToolError):
|
||
_fit_to_tolerances("Q9", 20.0, None, None, "none", None)
|
||
|
||
|
||
def test_fit_passthrough_without_code():
|
||
assert _fit_to_tolerances(None, 20.0, 0.1, 0.05, "deviation", "x") == (
|
||
0.1,
|
||
0.05,
|
||
"deviation",
|
||
"x",
|
||
)
|
||
|
||
|
||
# ── ISO 286 transition / interference HOLES (K, M, N, P) ────────────────────
|
||
#
|
||
# These were refused by name until 1.5.0. They are not new table data: ISO 286
|
||
# derives an upper-letter hole from the same-letter shaft by the delta rule,
|
||
#
|
||
# ES = -ei + Δ, Δ = IT(n) − IT(n−1)
|
||
#
|
||
# so every value below comes from tables already authored in this module. The
|
||
# expected numbers are the published ISO 286 ones, cross-checked by hand — the
|
||
# point of pinning them is that the *rule* is right, not that the arithmetic runs.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("code", "nominal", "upper_um", "lower_um"),
|
||
[
|
||
("K7", 20.0, 6, -15),
|
||
("M7", 20.0, 0, -21),
|
||
("N7", 20.0, -7, -28),
|
||
("P7", 20.0, -14, -35),
|
||
("K7", 50.0, 7, -18),
|
||
("N7", 50.0, -8, -33),
|
||
("P7", 50.0, -17, -42),
|
||
("K6", 20.0, 2, -11),
|
||
("N6", 20.0, -11, -24),
|
||
("P6", 20.0, -18, -31),
|
||
],
|
||
)
|
||
def test_transition_hole_letters_match_published_iso286(code, nominal, upper_um, lower_um):
|
||
fit = fit_lookup(code, nominal)
|
||
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
|
||
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
|
||
|
||
|
||
def test_h7_over_p6_is_a_genuine_interference_fit():
|
||
"""The pairing check the tables exist for: the shaft must be bigger."""
|
||
hole = fit_lookup("H7", 20.0)
|
||
shaft = fit_lookup("p6", 20.0)
|
||
assert shaft.lower_mm > hole.upper_mm - hole.it_value_mm, "min shaft > min hole"
|
||
assert shaft.lower_mm > 0 and hole.lower_mm == 0
|
||
|
||
|
||
def test_the_delta_collapses_above_the_grade_the_rule_covers():
|
||
"""ISO 286 applies Δ only to IT<=8 for K/M/N and IT<=7 for P; then ES = -ei."""
|
||
from engineering.fits import _SHAFT_EI, _size_index
|
||
|
||
index = _size_index(20.0)
|
||
n9 = fit_lookup("N9", 20.0)
|
||
assert n9.upper_mm == pytest.approx(-_SHAFT_EI["n"][index] / 1000.0, abs=1e-9)
|
||
|
||
p8 = fit_lookup("P8", 20.0)
|
||
assert p8.upper_mm == pytest.approx(-_SHAFT_EI["p"][index] / 1000.0, abs=1e-9)
|
||
|
||
|
||
def test_grade_4_is_refused_rather_than_guessed():
|
||
"""Δ needs IT3, which is outside the authored IT4-IT11 range."""
|
||
with pytest.raises(ValueError, match="IT5"):
|
||
fit_lookup("K4", 20.0)
|
||
|
||
|
||
# ── ISO 286 interference SHAFTS (r, s, t, u) ────────────────────────────────
|
||
#
|
||
# These need their own fundamental deviations, sub-stepped more finely than the
|
||
# main size steps above 50 mm, so unlike K/M/N/P they are table data rather than
|
||
# a rule. Authored table data is only as good as its provenance, so every value
|
||
# is cross-checked below against ISO 286-1's own derivation formulae — an
|
||
# independent source that agrees or does not. What survives is stated; what
|
||
# diverges is listed by name, so a reader with the standard checks five numbers
|
||
# instead of ninety-five.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("code", "nominal", "upper_um", "lower_um"),
|
||
[
|
||
# ei + IT above the nominal — these are all interference shafts.
|
||
("r6", 20.0, 41, 28),
|
||
("s6", 20.0, 48, 35),
|
||
("u6", 20.0, 54, 41),
|
||
("s7", 50.0, 68, 43),
|
||
("u6", 30.0, 61, 48), # 24-30 sub-step: u jumps 41 -> 48 inside 18-30
|
||
("s6", 60.0, 72, 53), # 50-65 sub-step, IT6 from the main 50-80 step
|
||
("t6", 60.0, 85, 66),
|
||
],
|
||
)
|
||
def test_interference_shafts_match_published_iso286(code, nominal, upper_um, lower_um):
|
||
fit = fit_lookup(code, nominal)
|
||
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
|
||
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
|
||
|
||
|
||
def test_the_sub_steps_are_real_and_not_flattened():
|
||
"""Above 50 mm these letters split the main steps; collapsing them is a
|
||
silent error of tens of microns."""
|
||
assert fit_lookup("u6", 55.0).lower_mm != fit_lookup("u6", 70.0).lower_mm
|
||
assert fit_lookup("u6", 20.0).lower_mm != fit_lookup("u6", 28.0).lower_mm
|
||
|
||
|
||
def test_t_is_refused_below_24_mm_because_iso_does_not_define_it():
|
||
with pytest.raises(ValueError, match="24"):
|
||
fit_lookup("t6", 20.0)
|
||
assert fit_lookup("t6", 30.0).lower_mm > 0
|
||
|
||
|
||
# -- provenance: the table against ISO 286-1's own formulae ------------------
|
||
|
||
|
||
def test_r_is_the_geometric_mean_of_p_and_s():
|
||
"""ISO 286 *defines* r that way, and p is already pinned in this module.
|
||
|
||
24/24 sub-steps agree within 1 um, which validates r and s together against
|
||
data that was trusted before either was authored.
|
||
"""
|
||
import math
|
||
|
||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI, _SHAFT_EI_FINE, _size_index
|
||
|
||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||
mid = (step[0] + step[1]) / 2.0
|
||
p = _SHAFT_EI["p"][_size_index(mid)]
|
||
s = _SHAFT_EI_FINE["s"][index]
|
||
r = _SHAFT_EI_FINE["r"][index]
|
||
assert abs(round(math.sqrt(p * s)) - r) <= 1, f"r at {step}: table {r}, sqrt(p*s) {p}/{s}"
|
||
|
||
|
||
#: Steps where ISO 286's rounding rules put the published value more than 1 um
|
||
#: from the formula. Listed rather than hidden: these are the only values a
|
||
#: reader with the standard needs to check by hand.
|
||
KNOWN_FORMULA_DIVERGENCES = {
|
||
("u", (250, 280)),
|
||
("u", (400, 450)),
|
||
("u", (450, 500)),
|
||
("t", (24, 30)),
|
||
("t", (450, 500)),
|
||
}
|
||
|
||
|
||
#: Below this the formulae do not describe the published tables at all — ISO
|
||
#: tabulates the small sizes directly. Measured for u: the formula is 6 um low at
|
||
#: 1-3 mm and only converges from 18 mm up (-6, -7, -5, -2, +1). Scoping the
|
||
#: check here is a statement about where the cross-check has authority, not a
|
||
#: threshold moved until the test passed.
|
||
FORMULA_VALID_FROM_MM = 18
|
||
|
||
|
||
@pytest.mark.parametrize(("letter", "coefficient"), [("t", 0.63), ("u", 1.0)])
|
||
def test_t_and_u_track_their_iso_formula(letter, coefficient):
|
||
"""ISO 286-1: ei = IT7 + k*D, D the geometric mean of the step bounds.
|
||
|
||
The standard rounds its published tables, so exact equality is not the
|
||
claim — agreement within 1 um everywhere except a listed handful is.
|
||
"""
|
||
import math
|
||
|
||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
|
||
|
||
checked = 0
|
||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||
ei = _SHAFT_EI_FINE[letter][index]
|
||
if ei is None or step[0] < FORMULA_VALID_FROM_MM:
|
||
continue
|
||
checked += 1
|
||
mid = (step[0] + step[1]) / 2.0
|
||
it7 = _it_um(7, _size_index(mid))
|
||
formula = it7 + coefficient * math.sqrt(step[0] * step[1])
|
||
drift = abs(round(formula) - ei)
|
||
if (letter, step) in KNOWN_FORMULA_DIVERGENCES:
|
||
assert 1 < drift <= 3, f"{letter} at {step} drifted {drift} um — re-check the table"
|
||
else:
|
||
assert drift <= 1, f"{letter} at {step}: table {ei}, formula {formula:.1f}"
|
||
assert checked >= 18, f"only {checked} steps cross-checked for {letter}"
|
||
|
||
|
||
def test_s_above_50_tracks_its_iso_formula():
|
||
import math
|
||
|
||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
|
||
|
||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||
if step[0] < 50:
|
||
continue
|
||
mid = (step[0] + step[1]) / 2.0
|
||
formula = _it_um(7, _size_index(mid)) + 0.4 * math.sqrt(step[0] * step[1])
|
||
assert abs(round(formula) - _SHAFT_EI_FINE["s"][index]) <= 1, f"s at {step}"
|
||
|
||
|
||
def test_s_below_50_sits_in_the_iso_band_above_it8():
|
||
"""ISO 286: for D <= 50, s is IT8 plus a small step. Monotone, 0..4 um."""
|
||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
|
||
|
||
deltas = []
|
||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||
if step[0] >= 50:
|
||
break
|
||
mid = (step[0] + step[1]) / 2.0
|
||
deltas.append(_SHAFT_EI_FINE["s"][index] - _it_um(8, _size_index(mid)))
|
||
assert all(0 <= d <= 4 for d in deltas), deltas
|
||
assert deltas == sorted(deltas), f"the step above IT8 must not decrease: {deltas}"
|
||
|
||
|
||
def test_h7_over_s6_is_tighter_than_h7_over_p6():
|
||
"""The reason these letters exist: progressively heavier interference."""
|
||
p6 = fit_lookup("p6", 20.0).lower_mm
|
||
r6 = fit_lookup("r6", 20.0).lower_mm
|
||
s6 = fit_lookup("s6", 20.0).lower_mm
|
||
u6 = fit_lookup("u6", 20.0).lower_mm
|
||
assert p6 < r6 < s6 < u6
|