Source code for gearbox.profile

"""Transverse tooth form of an external cylindrical gear generated by a rack (hob).

The half tooth is drawn in a frame with the gear axis at the origin and the tooth axis along +y,
on the side of negative x. It runs from the middle of the tooth gap on the root circle along

* the root circle,
* the root fillet, the trochoid-like curve that the rounded tip of the rack leaves,
* the involute flank,
* the tip circle,

to the tip on the tooth axis. The exporters and the finite-element meshes build on this form.
Lengths are in millimetres.
"""

from __future__ import annotations

import warnings
from math import atan, cos, degrees, pi, radians, sin, tan
from typing import TYPE_CHECKING, Any

import numpy as np
from scipy.optimize import brentq

from gearbox.mathutils import involute, rotate

if TYPE_CHECKING:
    from gearbox.models import Gear

__all__ = ["ToothProfile"]

# points per segment of the half tooth
_ROOT_POINTS = 5
_FILLET_POINTS = 50
_FLANK_POINTS = 100
_TIP_POINTS = 20
# samples of the rack rounding used to locate the fillet before it is resampled
_FILLET_SAMPLES = 2001


[docs] class ToothProfile: """Tooth form and main dimensions of an external gear cut by a rack. Attributes: data: Dimensions by their usual symbol, the names used by the exporters, the meshes and the export templates: ``z``, ``m_n``, ``m_t``, ``b``, ``beta``, ``alpha_n``, ``alpha_t`` (degrees), ``x``, ``d``, ``d_b``, ``d_a``, ``d_f``, ``d_Ff`` (root form diameter, where the involute starts), ``d_s`` (bore), ``rho_fP`` (tip radius of the rack), and ``s_p`` (tooth thickness on the reference circle, normal section). formcoords: Points ``[x, y]`` of the half tooth, from the middle of the gap to the tip. shaftcoords: Two points on the bore: on the tooth axis and in the middle of the gap. rotate_ang: Rotation [deg] that puts the tooth axis on the axis of symmetry of a gap, used by the export templates. """
[docs] def __init__(self, gear: Gear) -> None: """Generate the tooth form of ``gear``. Raises: ValueError: For an internal gear, a tool tip radius too large for the gap, or a bore that does not fit inside the root circle. """ if gear.internal: raise ValueError("the tooth form is generated for external gears only") module = gear.module alpha_n = radians(gear.pressure_angle) beta = radians(gear.helix_angle) alpha_t = atan(tan(alpha_n) / cos(beta)) z = gear.number_of_teeth shift = gear.profile_shift_coefficient reference = module * z / cos(beta) self.data: dict[str, Any] = { "z": z, "m_n": module, "m_t": module / cos(beta), "b": gear.face_width, "beta": gear.helix_angle, "alpha_n": gear.pressure_angle, "alpha_t": degrees(alpha_t), "x": shift, "d": reference, "d_b": reference * cos(alpha_t), "d_a": gear.tip_diameter, "d_f": gear.root_diameter, "d_s": gear.shaft_diameter, "rho_fP": gear.root_fillet_radius, "s_p": pi * module / 2 + 2 * module * shift * tan(alpha_n), } fillet = self._rack_fillet() # sets d_Ff, and raises d_f if the roundings overlap if not 0 < self.data["d_s"] < self.data["d_f"]: raise ValueError("shaft diameter greater than root circle diameter") self.formcoords = self._half_tooth(fillet) bore = self.data["d_s"] / 2 self.shaftcoords = [[0.0, bore], rotate([[0.0, bore]], 180.0 / z)[0]] self.rotate_ang = -degrees(self.data["s_p"] / reference)
[docs] def half_angle(self, radius: float | np.ndarray) -> float | np.ndarray: """Half the angular tooth thickness of the involute at ``radius`` [rad]. ``(pi/2 + 2 x tan(alpha_n)) / z + inv(alpha_t) - inv(alpha_y)``, with ``cos(alpha_y) = r_b / r``; radii below the base circle are taken on it. """ data = self.data base = data["d_b"] / 2 pressure = np.arccos(np.minimum(1.0, base / np.asarray(radius))) return ( (pi / 2 + 2 * data["x"] * tan(radians(data["alpha_n"]))) / data["z"] + involute(data["alpha_t"]) - (np.tan(pressure) - pressure) )
def _rack_fillet(self) -> np.ndarray: """Root fillet left by the rounded tip of the rack, from the root to the involute. In the normal section the rack tooth has straight flanks at ``alpha_n`` and a tip rounded with ``rho_fP``; in the transverse section the rounding becomes an ellipse with semi-axes ``rho_fP / cos(beta)`` along the rack and ``rho_fP`` across it. The rack rolls without slip on the reference circle, its datum line ``x m_n`` outside it and its tip line on the root circle. A point of the rack cuts the gear when the normal to the rack there passes through the pitch point. The fillet meets the involute tangentially where the straight flank of the rack starts. When that point lies beyond the interference point the involute is undercut: the fillet ends where it cuts the involute. When the roundings of neighbouring rack teeth overlap, the fillet starts on the line through the middle of the gap and the root diameter is raised to its lowest point. ``d_Ff`` (and ``d_f`` in that case) are set. Raises: ValueError: If the rack tip is so wide that no root is left. """ data = self.data module = data["m_n"] alpha_n = radians(data["alpha_n"]) alpha_t = radians(data["alpha_t"]) cos_beta = cos(radians(data["beta"])) rho = data["rho_fP"] pitch_radius = data["d"] / 2 datum = data["x"] * module # datum line of the rack above the reference circle depth = pitch_radius + datum - data["d_f"] / 2 # datum line to the tip line of the rack half_gap = pi / data["z"] # centre of the tip rounding in rack coordinates: u along the rack from the middle of a # rack tooth, v away from the gear; transverse lengths along the rack are 1/cos(beta) times # the normal ones centre_u = ( pi * module / 4 - depth * tan(alpha_n) - rho * (1 - sin(alpha_n)) / cos(alpha_n) ) / cos_beta centre_v = rho - depth along = rho / cos_beta def cut_point(t: np.ndarray) -> np.ndarray: """Gear point cut by the rounding at angle ``t`` (0 at the tip line), shape (n, 2).""" u = centre_u + along * np.sin(t) v = centre_v - rho * np.cos(t) normal_u, normal_v = rho * np.sin(t), -along * np.cos(t) # roll angle that brings the rack normal through the pitch point roll = (u - (v + datum) * normal_u / normal_v) / pitch_radius x, y = u - pitch_radius * roll, pitch_radius + datum + v turn = half_gap - roll # into the gear frame, tooth axis on +y return np.column_stack( [x * np.cos(turn) - y * np.sin(turn), x * np.sin(turn) + y * np.cos(turn)] ) def angle_from_axis(points: np.ndarray) -> np.ndarray: return np.arctan2(-points[:, 0], points[:, 1]) end = pi / 2 - alpha_n # the rounding meets the straight flank of the rack lead = depth - rho * (1 - sin(alpha_n)) - datum # that point below the rolling line if lead > pitch_radius * sin(alpha_t) ** 2: # beyond the interference point: undercut def past_involute(t: float) -> float: point = cut_point(np.array([t])) return float(angle_from_axis(point)[0] - self.half_angle(np.hypot(*point[0]))) t = np.linspace(0.0, end, _FILLET_SAMPLES) gap = np.array([past_involute(value) for value in t]) crossings = np.nonzero((gap[:-1] < 0) & (gap[1:] >= 0))[0] if not len(crossings): raise ValueError("undercut profile cannot be determined") first = crossings[0] end = brentq(past_involute, t[first], t[first + 1]) warnings.warn("undercutting occurs", stacklevel=3) t = np.linspace(0.0, end, _FILLET_SAMPLES) points = cut_point(t) beyond = angle_from_axis(points) > half_gap if beyond.any(): if beyond[-1]: raise ValueError("tool tip radius too large: root shape cannot be determined") first = int(np.argmax(~beyond)) def past_gap_line(value: float) -> float: return float(angle_from_axis(cut_point(np.array([value])))[0] - half_gap) start = brentq(past_gap_line, t[first - 1], t[first]) points = cut_point(np.linspace(start, end, _FILLET_SAMPLES)) data["d_f"] = 2 * float(np.hypot(*points[0])) data["d_Ff"] = 2 * float(np.hypot(*points[-1])) return points def _half_tooth(self, fillet: np.ndarray) -> list[list[float]]: """Join root circle, fillet, involute and tip circle into one polyline.""" data = self.data root_radius, tip_radius = data["d_f"] / 2, data["d_a"] / 2 parts = [] gap_angle = pi / data["z"] fillet_angle = float(np.arctan2(-fillet[0, 0], fillet[0, 1])) if gap_angle - fillet_angle > 1e-9: angles = np.linspace(gap_angle, fillet_angle, _ROOT_POINTS) parts.append(root_radius * np.column_stack([-np.sin(angles), np.cos(angles)])) parts.append(_resample(fillet, _FILLET_POINTS)) radii = np.linspace(data["d_Ff"] / 2, tip_radius, _FLANK_POINTS) flank_angles = self.half_angle(radii) flank = np.column_stack([-np.sin(flank_angles), np.cos(flank_angles)]) parts.append(radii[:, None] * flank) tip_angles = np.linspace(float(flank_angles[-1]), 0.0, _TIP_POINTS) parts.append(tip_radius * np.column_stack([-np.sin(tip_angles), np.cos(tip_angles)])) points = [parts[0][0]] for part in parts: for point in part: if np.hypot(*(point - points[-1])) > 1e-9 * data["m_n"]: points.append(point) points[-1] = np.array([0.0, tip_radius]) return [[float(x), float(y)] for x, y in points]
def _resample(points: np.ndarray, count: int) -> np.ndarray: """``count`` points spaced evenly by arc length along the polyline ``points``.""" length = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(points, axis=0).T))]) target = np.linspace(0.0, length[-1], count) return np.column_stack([np.interp(target, length, points[:, i]) for i in range(2)])