Source code for exlink.manufacturing

"""Manufacturability: what a shop can actually cut, and what that costs in mass.

A sizing loop that reports ``d = 7.3184 mm`` has answered a question nobody
asked.  Round bar arrives in preferred sizes, gear cutters exist for standard
modules, and a wall thinner than a couple of millimetres cannot be cast or
reliably machined.  This module holds those rules in one place so that the rest
of the package can state a *buildable* design rather than a mathematical one.

Three kinds of rule appear here:

**Preferred sizes.**  :data:`STOCK_DIAMETERS` is the R20 renard series of
metric round bar, :data:`STANDARD_MODULES` the ISO 54 series 1 gear modules.
Both are ordered ascending, and :func:`round_up_to_stock` moves a continuous
requirement up to the next available size.  Rounding *up* is the only safe
direction: it can never turn a safe section unsafe.

**Minimum thicknesses.**  A dimension can be safe against every stress and
still be unmakeable.  :data:`MIN_WALL_THICKNESS` and friends are floors applied
after strength sizing, never before.

**The manufacturing premium.**  Rounding up adds mass that no stress analysis
asked for.  :func:`stock_premium` measures it, because it is the honest answer
to "how much did buildability cost?" and because it is not negligible: on a
member whose strength requirement lands just above a stock size the premium can
exceed 30 %.

Why this is applied *outside* the sizing fixed point
----------------------------------------------------
Rounding is a step function, and a step function inside a fixed-point iteration
turns a smooth contraction into something that can limit-cycle between two
stock sizes forever.  The coupled solve therefore converges on continuous
diameters, and discretisation is applied once at the end -- then the loads are
re-evaluated on the discretised sections to confirm the design is still safe.
That "size continuous, discretise, verify" order is standard practice and it is
what :func:`exlink.coupled.solve_coupled` does when asked for a buildable
result.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import NDArray

FloatArray = NDArray[np.float64]

#: Preferred metric round-bar diameters, R20 renard series [mm].
STOCK_DIAMETERS: FloatArray = np.array(
    [
        3.0,
        3.5,
        4.0,
        4.5,
        5.0,
        5.5,
        6.0,
        7.0,
        8.0,
        9.0,
        10.0,
        11.0,
        12.0,
        14.0,
        16.0,
        18.0,
        20.0,
        22.0,
        25.0,
        28.0,
        30.0,
        32.0,
        35.0,
        40.0,
        45.0,
        50.0,
        56.0,
        63.0,
        70.0,
        80.0,
        90.0,
        100.0,
    ]
)

#: ISO 54 series-1 gear modules [mm].  Cutters exist for these; a module
#: between two of them means a bespoke hob.
STANDARD_MODULES: FloatArray = np.array(
    [0.5, 0.6, 0.8, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0]
)

MIN_WALL_THICKNESS = 2.5
"""Thinnest reliably castable aluminium wall [mm].

Sand and gravity die casting of a small crankcase; a thinner wall risks
misruns and cold shuts regardless of what the stress says.
"""

MIN_MACHINED_THICKNESS = 1.5
"""Thinnest wall to leave on a machined-from-solid part [mm].

Below this the part distorts as the clamping releases and the tolerance is
not holdable.
"""

MIN_CROWN_THICKNESS = 3.0
"""Thinnest piston crown [mm].

Set by thermal, not mechanical, considerations: the crown has to conduct
combustion heat to the rings, and a thin crown runs hot enough to pre-ignite.
"""

MIN_FACE_WIDTH = 4.0
"""Narrowest gear face a small hob will cut cleanly [mm]."""

MIN_TEETH = 17
"""Fewest teeth on a 20 deg involute pinion before undercutting [-].

The standard result: ``z_min = 2 / sin^2(alpha)`` rounded up, which is 17 at a
20 deg pressure angle.  Fewer teeth need profile shift, which this model does
not carry.
"""


[docs] def round_up_to_stock( diameter: FloatArray | float, catalogue: FloatArray = STOCK_DIAMETERS ) -> FloatArray: """Move each diameter up to the next available stock size. Args: diameter: Continuous requirement [mm], any shape. catalogue: Ascending preferred sizes [mm]. Returns: The next catalogue entry at or above each input, elementwise. A requirement above the largest entry is returned unchanged, so that a runaway design is still visible as a runaway rather than being silently clipped to the top of the catalogue. """ d = np.asarray(diameter, dtype=float) index = np.searchsorted(catalogue, d, side="left") clipped = np.minimum(index, catalogue.size - 1) rounded = catalogue[clipped] return np.where(d > catalogue[-1], d, rounded)
[docs] def round_to_module(module: float, catalogue: FloatArray = STANDARD_MODULES) -> float: """Move a gear module to the nearest standard one. Unlike bar stock this rounds to *nearest*, not up: a module is not a safety margin, and the tooth strength is recovered by the face width instead. Args: module: Continuous module [mm]. catalogue: Ascending standard modules [mm]. Returns: The closest catalogue entry. """ return float(catalogue[int(np.argmin(np.abs(catalogue - float(module))))])
[docs] def stock_premium(continuous: FloatArray | float, discrete: FloatArray | float) -> float: """Fractional mass added by rounding sections up to stock. Mass goes as ``d^2`` at fixed length, so the premium on one member is ``(d_stock / d_required)^2 - 1``. Reported over a whole set of members as the ratio of summed areas, which weights each member by how much material it actually carries. Args: continuous: Strength-driven diameters [mm]. discrete: The same members rounded to stock [mm]. Returns: ``mass_discrete / mass_continuous - 1``, a fraction. Zero means every member landed exactly on a stock size. """ required = np.asarray(continuous, dtype=float) supplied = np.asarray(discrete, dtype=float) if required.size == 0: return 0.0 return float(np.sum(supplied**2) / np.sum(required**2) - 1.0)