"""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)])