diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js index 84c769c1..933f2e3e 100644 --- a/app/src/components/Calculators.js +++ b/app/src/components/Calculators.js @@ -8,6 +8,7 @@ import {DipoleAntennaCalculator} from "./calculators/HamCalculators"; import {Link} from "react-router"; import {RatioCalculators} from "./calculators/RatioCalculator"; import {QRCodeCalculator} from "./calculators/QRCodeCalculator"; +import {DriveCalculator} from "./calculators/DriveCalculator"; export const useCalculators = () => { const [calc, setCalc] = useCalcQuery(); @@ -15,6 +16,7 @@ export const useCalculators = () => { const calculators = [ {key: 'ratio', icon: 'th large', button: 'Ratio', contents: }, {key: 'electrical', icon: 'lightning', button: 'Electrical', contents: }, + {key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: }, {key: 'antenna', icon: 'signal', button: 'Antenna', contents: }, {key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: }, {key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: }, diff --git a/app/src/components/Theme.tsx b/app/src/components/Theme.tsx index 68e0915f..176143e4 100644 --- a/app/src/components/Theme.tsx +++ b/app/src/components/Theme.tsx @@ -285,7 +285,9 @@ export function Divider(props: DividerProps) { export function Header(props: HeaderProps) { const {t} = useContext(ThemeContext); - return + // Merge the caller's style on top of the theme's text style; a bare `{...props}` + // would let an incoming `style` prop replace (and drop) the theme's text color. + return } export function Icon(props: IconProps) { diff --git a/app/src/components/calculators/DriveCalculator.js b/app/src/components/calculators/DriveCalculator.js new file mode 100644 index 00000000..95bbf603 --- /dev/null +++ b/app/src/components/calculators/DriveCalculator.js @@ -0,0 +1,660 @@ +import React from "react"; +import {Form, Header, Segment, Table} from "../Theme"; +import {Button, GridColumn, GridRow, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-react"; +import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; +import {ColoredInput} from "../Apps"; +import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common"; +import {Media} from "../../contexts/contexts"; + +// Pure calculation functions for the Drive Ratio calculator (pulleys, gears, sprockets). +// +// All three drive types obey one relation between a driver and a driven element: +// +// s1 * n1 = s2 * n2 +// +// where `s` is the element "size" (pitch diameter for pulleys, tooth count for gears +// and sprockets) and `n` is the rotational speed (RPM). The driven element spins +// slower when it is larger, and its torque increases by the same ratio. +// +// These functions are intentionally unit-agnostic: callers must pass consistent units +// (e.g. all lengths in inches, or all in millimeters) and the result is returned in +// those same units. The React component is responsible for unit selection/display. + +// Divide `a` by `b`, returning null when the result would not be a finite number. +// This keeps empty/zero inputs from producing NaN or Infinity in the UI. +const safeDiv = (a, b) => { + const result = a / b; + return Number.isFinite(result) ? result : null; +}; + +// Solve for the one drive value the user has not recently edited, using s1*n1 = s2*n2. +// `target` is the field to compute ('s1' | 's2' | 'n1' | 'n2'); the remaining three are +// read from `values`. Returns the computed value, or null when it cannot be determined. +export function computeDriveValue(target, {s1, s2, n1, n2}) { + switch (target) { + case 's1': + return safeDiv(s2 * n2, n1); + case 's2': + return safeDiv(s1 * n1, n2); + case 'n1': + return safeDiv(s2 * n2, s1); + case 'n2': + return safeDiv(s1 * n1, s2); + default: + return null; + } +} + +// Speed/torque ratio of the drive: driven size over driver size (s2 / s1). +// Equals driverRPM / drivenRPM, and equals the factor by which torque is multiplied. +export function driveRatio(s1, s2) { + return safeDiv(s2, s1); +} + +// Output torque from an input torque, given the driver and driven sizes. +// Torque is multiplied by the drive ratio (s2 / s1). +export function torqueOut(torqueIn, s1, s2) { + const ratio = driveRatio(s1, s2); + return ratio === null ? null : torqueIn * ratio; +} + +// Surface (pitch-line) velocity of a rotating element: pi * diameter * rpm. +// For a belt drive this is the belt speed; for gears/sprockets the pitch-line velocity. +// Returns length-units per minute (matching the units of `diameter`). +export function surfaceSpeed(diameter, rpm) { + return Math.PI * diameter * rpm; +} + +// Surface speed in a human-friendly unit: ft/min (imperial) or m/s (metric). +// `diameter` is in inches when metric is false, or millimeters when metric is true. +// Returns {value, unit}, or null when it cannot be computed. +export function surfaceSpeedDisplay(diameter, rpm, metric) { + if (!(diameter > 0) || !(rpm > 0)) { + return null; + } + const perMinute = surfaceSpeed(diameter, rpm); // in/min or mm/min + return metric + ? {value: perMinute / 60000, unit: 'm/s'} // mm/min -> m/s + : {value: perMinute / 12, unit: 'ft/min'}; // in/min -> ft/min +} + +// Length of an open belt around two pulleys of pitch diameters d1 and d2 whose centers +// are `centerDistance` apart. Returns the belt length in the same units as the inputs, +// or null when the geometry is impossible (centers closer than the pulleys touching). +// +// This is the standard shop/textbook approximation: +// L = 2C + (pi/2)(d1 + d2) + (d2 - d1)^2 / (4C) +// It is accurate when C is comfortably larger than (d1 + d2)/2 and degrades as the +// centers tighten toward that limit, where the pulleys would touch. +export function beltLength(d1, d2, centerDistance) { + // Pulleys overlap when the centers are within the sum of their radii — no valid belt. + if (!(d1 > 0) || !(d2 > 0) || !Number.isFinite(centerDistance) || centerDistance <= (d1 + d2) / 2) { + return null; + } + return ( + 2 * centerDistance + + (Math.PI / 2) * (d1 + d2) + + Math.pow(d2 - d1, 2) / (4 * centerDistance) + ); +} + +// Pitch diameter of a chain sprocket with `teeth` teeth and a given chain `pitch`. +// PD = pitch / sin(pi / teeth) +export function chainPitchDiameter(teeth, pitch) { + if (!Number.isFinite(teeth) || teeth < 1) { + return null; + } + return safeDiv(pitch, Math.sin(Math.PI / teeth)); +} + +// Exact chain length, expressed in pitches (links), for two sprockets t1/t2 teeth whose +// centers are `centerDistance` apart, using a chain of the given `pitch`. Returns null +// when the geometry is impossible (centers closer than the sprockets touching). +// L = (t1 + t2)/2 + 2*(C/p) + ((t2 - t1)/(2*pi))^2 / (C/p) +export function chainLengthPitches(t1, t2, centerDistance, pitch) { + const pd1 = chainPitchDiameter(t1, pitch); + const pd2 = chainPitchDiameter(t2, pitch); + // Sprockets overlap when the centers are within the sum of their pitch radii. + if (pd1 === null || pd2 === null || !Number.isFinite(centerDistance) + || centerDistance <= (pd1 + pd2) / 2) { + return null; + } + const centerPitches = safeDiv(centerDistance, pitch); + if (centerPitches === null || centerPitches <= 0) { + return null; + } + return ( + (t1 + t2) / 2 + + 2 * centerPitches + + Math.pow((t2 - t1) / (2 * Math.PI), 2) / centerPitches + ); +} + +// Chain length rounded up to the nearest even number of pitches (chains join in pairs). +export function chainLengthPitchesEven(t1, t2, centerDistance, pitch) { + const exact = chainLengthPitches(t1, t2, centerDistance, pitch); + return exact === null ? null : 2 * Math.ceil(exact / 2); +} + +// Pitch diameter of a metric gear: teeth * module (module in mm). +export function gearPitchDiameterMetric(teeth, module) { + return teeth * module; +} + +// Pitch diameter of an imperial gear: teeth / diametralPitch (teeth per inch). +export function gearPitchDiameterImperial(teeth, diametralPitch) { + return safeDiv(teeth, diametralPitch); +} + +// Center distance between two meshing gears (or any two tangent pitch circles): +// half the sum of their pitch diameters. +export function centerDistanceFromPitchDiameters(d1, d2) { + return (d1 + d2) / 2; +} + +// --------------------------------------------------------------------------- +// Diagram geometry — pure helpers used to draw the schematic SVG. No new +// dependencies: the component renders plain inline SVG from these numbers. +// --------------------------------------------------------------------------- + +// The two external ("open", non-crossing) tangent lines of two circles. +// Returns two segments, each {a: [x,y], b: [x,y]}, touching circle 1 then circle 2. +// Used to draw a belt (pulleys) or chain (sprockets) wrapping both wheels. +export function externalBeltTangents(x1, y1, r1, x2, y2, r2) { + const d = Math.hypot(x2 - x1, y2 - y1); + if (d === 0) { + return null; + } + const gamma = Math.atan2(y2 - y1, x2 - x1); + // (r1 - r2) / d can drift slightly outside [-1, 1] from rounding; clamp it. + const ratio = Math.max(-1, Math.min(1, (r1 - r2) / d)); + const beta = Math.asin(ratio); + const point = (cx, cy, r, angle) => [cx + r * Math.cos(angle), cy + r * Math.sin(angle)]; + const a1 = gamma + (Math.PI / 2 - beta); + const a2 = gamma - (Math.PI / 2 - beta); + return [ + {a: point(x1, y1, r1, a1), b: point(x2, y2, r2, a1)}, + {a: point(x1, y1, r1, a2), b: point(x2, y2, r2, a2)}, + ]; +} + +// SVG polygon points for a schematic toothed wheel (gear or sprocket). +// Not an involute — `teeth` trapezoidal teeth alternating between rOuter (tip) +// and rInner (root). `tipFrac` is the fraction of each tooth period spent at the tip. +// Returns a string suitable for an SVG . +export function gearPoints(cx, cy, rOuter, rInner, teeth, tipFrac = 0.5) { + const count = Math.max(3, Math.round(teeth)); + const step = (2 * Math.PI) / count; + const gap = (step * (1 - tipFrac)) / 2; + const points = []; + const push = (r, angle) => + points.push(`${(cx + r * Math.cos(angle)).toFixed(2)},${(cy + r * Math.sin(angle)).toFixed(2)}`); + for (let i = 0; i < count; i++) { + const a0 = i * step; + push(rInner, a0); // root before the tooth + push(rOuter, a0 + gap); // rising flank + push(rOuter, a0 + gap + step * tipFrac);// tooth tip + push(rInner, a0 + step); // falling flank into next root + } + return points.join(' '); +} + +// --------------------------------------------------------------------------- +// React component +// --------------------------------------------------------------------------- + +// Per-mode wording. All three drive types share the same math; only the size +// label (and the diagram) differ. +const MODES = { + pulley: {button: 'Pulley', icon: 'circle outline', sizeLabel: 'Diameter', toothed: false}, + gear: {button: 'Gear', icon: 'cogs', sizeLabel: 'Teeth', toothed: true}, + sprocket: {button: 'Sprocket', icon: 'cog', sizeLabel: 'Teeth', toothed: true}, +}; + +// Distinct, colorblind-friendly wheel colors (Paul Tol's bright palette): +// blue, red, green, yellow, cyan, purple. Grey is reserved as a neutral. +const ELEMENT_COLORS = ['#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377']; + +const MIN_ELEMENTS = 2; +const MAX_ELEMENTS = ELEMENT_COLORS.length; // 6 — one per palette color + +// Placeholder sizes so the diagram is visible before the user types anything. +const DEFAULT_DIAGRAM_SIZES = { + pulley: [4, 8, 6, 10, 5, 7], + toothed: [12, 24, 18, 30, 15, 21], +}; + +// Explanation shown in the Center Distance info popup (pulley and sprocket modes). +const CENTER_DISTANCE_INFO = 'The straight-line distance between the centers of the two shafts — ' + + 'the driver and driven axles. It sets how far apart the wheels are mounted, which ' + + 'determines the belt or chain length.'; + +// Disclaimer shown in the Output Torque info popup. +const TORQUE_INFO = 'Ideal output torque, assuming no losses. Real belt, chain, and gear drives ' + + 'lose a few percent to friction and slip, so actual output torque is somewhat lower.'; + +// Pick black or white label text for legibility on a given background color. +function textColorFor(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6 ? '#000000' : '#ffffff'; +} + +const emptyElement = () => ({size: '', rpm: ''}); +const driveInitialState = {elements: [emptyElement(), emptyElement()], lastUpdated: []}; + +// Solve a single shared belt/chain train: every wheel rides one belt, so size*rpm is the +// same constant K for all of them. The most recently edited wheel that has both a size +// and an RPM anchors K; every other wheel's RPM follows from its size (or its size from +// its RPM). Intermediate wheels set their own speed but do not change the overall +// first-to-last ratio (they are idlers). +// +// Re-anchoring is intentional and supported: if a user later provides both size+ rpm on +// a different wheel, that wheel becomes the new anchor and all other wheels (including +// ones the user had previously entered values for) are re-solved from the new K. +function solveTrain(elements, lastUpdated) { + const num = (v) => (v === '' ? null : Number(v)); + const anchor = lastUpdated.find((i) => + elements[i] && num(elements[i].size) > 0 && num(elements[i].rpm) > 0); + console.debug('[DriveCalc] solveTrain', { + lastUpdated: [...lastUpdated], + anchor, + k: anchor !== undefined ? num(elements[anchor].size) * num(elements[anchor].rpm) : null + }); + if (anchor === undefined) { + return elements; + } + const k = num(elements[anchor].size) * num(elements[anchor].rpm); + return elements.map((el, i) => { + if (i === anchor) { + return el; + } + const size = num(el.size), rpm = num(el.rpm); + if (size > 0) { + return {...el, rpm: String(roundDigits(k / size, 4))}; + } + if (rpm > 0) { + return {...el, size: String(roundDigits(k / rpm, 4))}; + } + return el; + }); +} + +function driveReducer(state, action) { + if (action.type === 'add') { + if (state.elements.length >= MAX_ELEMENTS) { + return state; + } + return {...state, elements: [...state.elements, emptyElement()]}; + } + if (action.type === 'remove') { + if (state.elements.length <= MIN_ELEMENTS) { + return state; + } + const elements = state.elements.filter((_, i) => i !== action.index); + // Drop the removed index from the edit history and shift higher indices down. + const lastUpdated = state.lastUpdated + .filter((i) => i !== action.index) + .map((i) => (i > action.index ? i - 1 : i)); + return {...state, elements: solveTrain(elements, lastUpdated), lastUpdated}; + } + if (action.type === 'field') { + const {index, field, value} = action; + // Negative sizes/speeds are meaningless here; clamp to a positive magnitude. + const clamped = value === '' ? '' : String(Math.abs(Number(value))); + const elements = state.elements.map((el, i) => + i === index ? {...el, [field]: clamped} : el); + const lastUpdated = [index, ...state.lastUpdated.filter((i) => i !== index)]; + console.debug('[DriveCalc] field', {index, field, value: clamped, lastUpdated: [...lastUpdated]}); + // Only update the field the user is actively typing. Solving is debounced in the component. + return {...state, elements, lastUpdated}; + } + if (action.type === 'solve') { + return {...state, elements: solveTrain(state.elements, state.lastUpdated)}; + } + return state; +} + +// Schematic, dependency-free SVG of the drive train. Draws one colored wheel per +// element, connected by a single belt/chain (or meshing teeth for gears). Sizes fall +// back to placeholders so a diagram is always visible, even before the user types. +function DriveDiagram({mode, sizes}) { + const toothed = MODES[mode].toothed; + const defaults = toothed ? DEFAULT_DIAGRAM_SIZES.toothed : DEFAULT_DIAGRAM_SIZES.pulley; + const nums = sizes.map((s, i) => (Number(s) > 0 ? Number(s) : defaults[i % defaults.length])); + const maxSize = Math.max(...nums); + + // Map each size onto a drawn pitch radius, keeping small wheels visible. + const MAX_R = 60, MIN_R = 22, TOOTH = 6, PAD = 16; + const radii = nums.map((s) => MIN_R + (MAX_R - MIN_R) * (s / maxSize)); + const extent = (r) => (toothed ? r + TOOTH : r); + const maxExtent = Math.max(...radii.map(extent)); + const cy = PAD + maxExtent; + + // Lay wheels left to right. Gears mesh (pitch circles touch); belts/chains leave a gap. + const gap = mode === 'gear' ? 0 : 54; + const centers = [PAD + extent(radii[0])]; + for (let i = 1; i < radii.length; i++) { + centers.push(centers[i - 1] + radii[i - 1] + radii[i] + gap); + } + const width = centers[centers.length - 1] + extent(radii[radii.length - 1]) + PAD; + const height = cy + maxExtent + PAD; + + const wheel = (cx, r, size, color) => { + if (toothed) { + const teeth = Math.max(6, Math.min(40, Math.round(size))); + return + + + ; + } + return + + + ; + }; + + return + {mode !== 'gear' && centers.slice(1).map((_, idx) => { + // Belt/chain between each adjacent pair of wheels, drawn behind the wheels. + const segs = externalBeltTangents(centers[idx], cy, radii[idx], centers[idx + 1], cy, radii[idx + 1]); + return segs && segs.map((seg, j) => + ); + })} + {centers.map((cx, i) => + {wheel(cx, radii[i], nums[i], ELEMENT_COLORS[i % ELEMENT_COLORS.length])})} + ; +} + +// Format the drive ratio as "N : 1" (reduction) or "1 : N" (overdrive). +function formatRatio(ratio) { + if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) { + return '—'; + } + return ratio >= 1 + ? `${roundDigits(ratio, 3)} : 1` + : `1 : ${roundDigits(1 / ratio, 3)}`; +} + +// Format a positive length/number with a unit, or an em-dash when unavailable. +function fmt(value, unit) { + if (!Number.isFinite(value) || value <= 0) { + return '—'; + } + return unit ? `${roundDigits(value, 3)} ${unit}` : `${roundDigits(value, 3)}`; +} + +// Format a {value, unit} surface-speed result, or an em-dash when unavailable. +function fmtSpeed(speed) { + return speed ? `${roundDigits(speed.value, 3)} ${speed.unit}` : '—'; +} + +export function DriveCalculator() { + const [mode, setMode] = React.useState('pulley'); + const [metric, setMetric] = useLocalStorage('drive_calculator_metric', false); + const [state, dispatch] = React.useReducer(driveReducer, driveInitialState); + const [torqueIn, setTorqueIn] = React.useState(''); + // Mode-specific auxiliary inputs (do not participate in the size*rpm solver). + const [centerDistance, setCenterDistance] = React.useState(''); + const [gearTooth, setGearTooth] = React.useState(''); // module (metric) or diametral pitch (imperial) + const [chainPitch, setChainPitch] = React.useState(''); + + // Debounce solving so that typing a multi-digit number doesn't cause constant + // re-solving + overwriting of other wheels with partial K values. + const solveTimeoutRef = React.useRef(null); + const scheduleSolve = React.useCallback(() => { + if (solveTimeoutRef.current) clearTimeout(solveTimeoutRef.current); + solveTimeoutRef.current = setTimeout(() => { + dispatch({ type: 'solve' }); + }, 280); + }, [dispatch]); + + React.useEffect(() => { + return () => { + if (solveTimeoutRef.current) clearTimeout(solveTimeoutRef.current); + }; + }, []); + + const {sizeLabel, button, toothed} = MODES[mode]; + const {elements} = state; + const twoElements = elements.length === 2; + console.debug('[DriveCalc] render', elements.map((e, i) => ({i, size: e.size, rpm: e.rpm})), 'lastUpdated:', state.lastUpdated); + const lengthUnit = metric ? 'mm' : 'in'; + const torqueUnit = metric ? 'N·m' : 'lb·ft'; + // Pulley diameters carry a length unit; gear/sprocket teeth are unitless. + const sizeUnit = mode === 'pulley' ? ` (${lengthUnit})` : ''; + + // Overall ratio is the first wheel -> the last wheel (intermediate wheels are idlers). + const firstSize = Number(elements[0].size); + const lastSize = Number(elements[elements.length - 1].size); + const ratio = driveRatio(firstSize, lastSize); + const outTorque = torqueIn !== '' ? torqueOut(Number(torqueIn), firstSize, lastSize) : null; + + const inputProps = { + fluid: true, + type: 'number', + onSelect: e => e.target.select(), + autoComplete: 'off', + }; + // Each wheel's inputs use its diagram color so the form and the picture line up. + const coloredField = (hex, labelText, value, onChange, extraProps = {}, name) => + + {labelText}}/>; + // Auxiliary (non-solver) numeric input. + const auxInput = (label, value, setter, name) => + setter(e.target.value)}/>; + + // Pitch diameter of a wheel given the mode's extra parameter. Pulley size IS the + // pitch diameter; gears need a module/diametral-pitch, sprockets a chain pitch. + const pitchDiameter = (size) => { + if (mode === 'gear') { + return metric ? gearPitchDiameterMetric(size, Number(gearTooth)) + : gearPitchDiameterImperial(size, Number(gearTooth)); + } + if (mode === 'sprocket') { + return chainPitchDiameter(size, Number(chainPitch)); + } + return size; + }; + + // Belt/chain speed is constant across the train; compute it from any complete wheel. + const speedEl = elements.find(el => Number(el.size) > 0 && Number(el.rpm) > 0); + const trainSpeed = speedEl + ? surfaceSpeedDisplay(pitchDiameter(Number(speedEl.size)), Number(speedEl.rpm), metric) + : null; + + const modeButtons = + {Object.entries(MODES).map(([key, m]) => + setMode(key)}/>)} + ; + + // One row of inputs per wheel: size, RPM, and a remove button (above the minimum). + const elementRows = elements.map((el, i) => { + const hex = ELEMENT_COLORS[i % ELEMENT_COLORS.length]; + const onField = (field) => (e) => { + dispatch({type: 'field', index: i, field, value: e.target.value}); + scheduleSolve(); + }; + // Teeth are whole numbers; pulley diameters stay continuous. + const onSize = toothed + ? (e) => { + dispatch({type: 'field', index: i, field: 'size', + value: e.target.value === '' ? '' : String(Math.round(Math.abs(Number(e.target.value))))}); + scheduleSolve(); + } + : onField('size'); + return { + hex, + sizeField: coloredField(hex, `#${i + 1} ${sizeLabel}${sizeUnit}`, el.size, onSize, + toothed ? {step: 1, min: 1} : {}, `size-${i}`), + rpmField: coloredField(hex, `#${i + 1} RPM`, el.rpm, onField('rpm'), {}, `rpm-${i}`), + removeBtn: elements.length > MIN_ELEMENTS + ? dispatch({type: 'remove', index: i})}/> + : null, + }; + }); + + const addButton = = MAX_ELEMENTS} + onClick={() => dispatch({type: 'add'})} + style={{marginTop: '0.5em'}}/>; + + // Small colored swatch matching a wheel, used to label its detail rows. + const swatch = (hex) => ; + + // Per-wheel pitch-diameter rows (gear/sprocket only). + const pitchDiameterRows = toothed ? elements.map((el, i) => + + {swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter + {fmt(pitchDiameter(Number(el.size)), lengthUnit)} + ) : null; + + // Pairwise belt/chain length and center distance only make sense for a two-wheel + // drive; with 3+ wheels they depend on the physical layout, so they are omitted. + let detailRows; + if (mode === 'pulley') { + const belt = twoElements ? beltLength(firstSize, lastSize, Number(centerDistance)) : null; + detailRows = <> + {twoElements && <> + + Center Distance + {auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")} + + + Belt Length + {fmt(belt, lengthUnit)} + + >} + + Belt Speed + {fmtSpeed(trainSpeed)} + + >; + } else if (mode === 'gear') { + const paramLabel = metric ? 'Module (mm)' : 'Diametral Pitch (1/in)'; + const pd1 = pitchDiameter(firstSize), pd2 = pitchDiameter(lastSize); + const cd = twoElements && pd1 > 0 && pd2 > 0 ? centerDistanceFromPitchDiameters(pd1, pd2) : null; + detailRows = <> + + {paramLabel} + {auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")} + + {pitchDiameterRows} + {twoElements && + Center Distance + {fmt(cd, lengthUnit)} + } + + Pitch-Line Velocity + {fmtSpeed(trainSpeed)} + + >; + } else { // sprocket + const pitch = Number(chainPitch); + const links = twoElements && pitch > 0 + ? chainLengthPitchesEven(firstSize, lastSize, Number(centerDistance), pitch) + : null; + detailRows = <> + + Chain Pitch + {auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")} + + {twoElements && + Center Distance + {auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")} + } + {pitchDiameterRows} + {twoElements && + Chain Length + {links === null ? '—' : `${links} pitches`} + } + + Chain Speed + {fmtSpeed(trainSpeed)} + + >; + } + + return + Drive Ratio + + {modeButtons} + + setMetric(!metric)}/> + + + + {elementRows.map(({sizeField, rpmField, removeBtn}, i) => + + {sizeField} + {rpmField} + {removeBtn} + {i < elementRows.length - 1 && } + )} + + + + + {elementRows.map(({sizeField, rpmField, removeBtn}, i) => + + {sizeField} + {rpmField} + {removeBtn} + )} + + + + {addButton} + + + el.size)}/> + + + + + + Drive Ratio{' '} + (#1 → #{elements.length}) + + {formatRatio(ratio)} + + + Torque Multiplier + {ratio === null ? '—' : `${roundDigits(ratio, 3)}×`} + + + Output Torque + + setTorqueIn(e.target.value)}/> + {outTorque !== null && + = {roundDigits(outTorque, 3)} {torqueUnit} + } + + + + + + {button} Details + + {detailRows} + + ; +} + +// Exported for testing +export {driveReducer}; diff --git a/app/src/components/calculators/DriveCalculator.test.js b/app/src/components/calculators/DriveCalculator.test.js new file mode 100644 index 00000000..f9ca02bb --- /dev/null +++ b/app/src/components/calculators/DriveCalculator.test.js @@ -0,0 +1,319 @@ +import { + beltLength, + centerDistanceFromPitchDiameters, + chainLengthPitches, + chainLengthPitchesEven, + chainPitchDiameter, + computeDriveValue, + driveRatio, + driveReducer, + externalBeltTangents, + gearPitchDiameterImperial, + gearPitchDiameterMetric, + gearPoints, + surfaceSpeed, + surfaceSpeedDisplay, + torqueOut, +} from "./DriveCalculator"; + +describe('computeDriveValue (s1*n1 = s2*n2)', () => { + // A 2:1 reduction: a 10-tooth driver at 100 RPM driving a 20-tooth gear. + const known = {s1: 10, s2: 20, n1: 100, n2: 50}; + + test('solves driven RPM', () => { + expect(computeDriveValue('n2', {s1: 10, s2: 20, n1: 100})).toBeCloseTo(50, 6); + }); + + test('solves driver RPM', () => { + expect(computeDriveValue('n1', {s1: 10, s2: 20, n2: 50})).toBeCloseTo(100, 6); + }); + + test('solves driven size', () => { + expect(computeDriveValue('s2', {s1: 10, n1: 100, n2: 50})).toBeCloseTo(20, 6); + }); + + test('solves driver size', () => { + expect(computeDriveValue('s1', {s2: 20, n1: 100, n2: 50})).toBeCloseTo(10, 6); + }); + + test('the solved value is consistent with the others', () => { + expect(known.s1 * known.n1).toBeCloseTo(known.s2 * known.n2, 6); + }); + + test('returns null on divide-by-zero', () => { + expect(computeDriveValue('n2', {s1: 10, s2: 0, n1: 100})).toBeNull(); + }); + + test('returns null for an unknown target', () => { + expect(computeDriveValue('nope', {s1: 10, s2: 20, n1: 100})).toBeNull(); + }); +}); + +describe('driveRatio and torque', () => { + test('2:1 reduction has a ratio of 2', () => { + expect(driveRatio(10, 20)).toBeCloseTo(2, 6); + }); + + test('overdrive has a ratio below 1', () => { + expect(driveRatio(20, 10)).toBeCloseTo(0.5, 6); + }); + + test('output torque is multiplied by the ratio', () => { + // 5 units in, 2:1 reduction -> 10 units out. + expect(torqueOut(5, 10, 20)).toBeCloseTo(10, 6); + }); + + test('ratio is null when the driver size is zero', () => { + expect(driveRatio(0, 20)).toBeNull(); + expect(torqueOut(5, 0, 20)).toBeNull(); + }); +}); + +describe('surfaceSpeed', () => { + test('belt speed of a 10" pulley at 100 RPM', () => { + // pi * 10 * 100 + expect(surfaceSpeed(10, 100)).toBeCloseTo(3141.59, 2); + }); +}); + +describe('surfaceSpeedDisplay', () => { + test('imperial: 10" pulley at 100 RPM -> ft/min', () => { + // pi * 10 * 100 / 12 + const result = surfaceSpeedDisplay(10, 100, false); + expect(result.unit).toBe('ft/min'); + expect(result.value).toBeCloseTo(261.8, 1); + }); + + test('metric: 100 mm pulley at 100 RPM -> m/s', () => { + // pi * 100 * 100 / 60000 + const result = surfaceSpeedDisplay(100, 100, true); + expect(result.unit).toBe('m/s'); + expect(result.value).toBeCloseTo(0.524, 3); + }); + + test('returns null when the diameter or RPM is missing', () => { + expect(surfaceSpeedDisplay(0, 100, false)).toBeNull(); + expect(surfaceSpeedDisplay(10, 0, false)).toBeNull(); + }); +}); + +describe('beltLength (open belt)', () => { + test('classic two-pulley belt length', () => { + // d1=10, d2=20, C=30 -> 60 + pi*15 + 100/120 + expect(beltLength(10, 20, 30)).toBeCloseTo(107.96, 2); + }); + + test('equal pulleys: belt is 2C + pi*d', () => { + // d1=d2=10, C=20 -> 40 + pi*10, with no diameter-difference term + expect(beltLength(10, 10, 20)).toBeCloseTo(40 + Math.PI * 10, 6); + }); + + test('returns null for a non-positive center distance', () => { + expect(beltLength(10, 20, 0)).toBeNull(); + }); + + test('returns null when the geometry is impossible (pulleys would overlap)', () => { + // Minimum center distance is (d1 + d2)/2 = 15; anything at or below overlaps. + expect(beltLength(10, 20, 15)).toBeNull(); + expect(beltLength(10, 20, 14)).toBeNull(); + }); + + test('returns null when a diameter is missing', () => { + expect(beltLength(0, 20, 30)).toBeNull(); + }); +}); + +describe('chainPitchDiameter', () => { + test('17-tooth sprocket with 1/2" chain pitch (#40)', () => { + // 0.5 / sin(pi/17) ~= 2.721" + expect(chainPitchDiameter(17, 0.5)).toBeCloseTo(2.721, 3); + }); + + test('returns null for fewer than one tooth', () => { + expect(chainPitchDiameter(0, 0.5)).toBeNull(); + }); +}); + +describe('chainLengthPitches', () => { + test('exact length in pitches', () => { + // t1=17, t2=34, C=20", p=0.5" -> ~105.68 pitches + expect(chainLengthPitches(17, 34, 20, 0.5)).toBeCloseTo(105.68, 2); + }); + + test('rounded up to the next even number of pitches', () => { + expect(chainLengthPitchesEven(17, 34, 20, 0.5)).toBe(106); + }); + + test('returns null for a non-positive center distance', () => { + expect(chainLengthPitches(17, 34, 0, 0.5)).toBeNull(); + expect(chainLengthPitchesEven(17, 34, 0, 0.5)).toBeNull(); + }); + + test('returns null when the geometry is impossible (sprockets would overlap)', () => { + // 17T and 34T at 0.5" pitch have pitch diameters ~2.72" and ~5.42", + // so the centers must exceed ~4.07"; 2" is impossible. + expect(chainLengthPitches(17, 34, 2, 0.5)).toBeNull(); + expect(chainLengthPitchesEven(17, 34, 2, 0.5)).toBeNull(); + }); +}); + +describe('gear pitch diameter and center distance', () => { + test('metric: 20 teeth, module 2 mm -> 40 mm', () => { + expect(gearPitchDiameterMetric(20, 2)).toBeCloseTo(40, 6); + }); + + test('imperial: 20 teeth, 10 diametral pitch -> 2"', () => { + expect(gearPitchDiameterImperial(20, 10)).toBeCloseTo(2, 6); + }); + + test('center distance is half the sum of pitch diameters', () => { + expect(centerDistanceFromPitchDiameters(40, 60)).toBeCloseTo(50, 6); + }); +}); + +describe('externalBeltTangents (diagram geometry)', () => { + test('returns two tangent segments', () => { + const segs = externalBeltTangents(0, 0, 10, 100, 0, 20); + expect(segs).toHaveLength(2); + }); + + test('tangent length matches sqrt(d^2 - (r1-r2)^2)', () => { + // Horizontal centers 100 apart, radii 10 and 20. + const [seg] = externalBeltTangents(0, 0, 10, 100, 0, 20); + const length = Math.hypot(seg.b[0] - seg.a[0], seg.b[1] - seg.a[1]); + const expected = Math.sqrt(100 * 100 - (10 - 20) ** 2); + expect(length).toBeCloseTo(expected, 4); + }); + + test('equal radii give two parallel tangents of length d', () => { + const segs = externalBeltTangents(0, 0, 15, 80, 0, 15); + for (const seg of segs) { + const length = Math.hypot(seg.b[0] - seg.a[0], seg.b[1] - seg.a[1]); + expect(length).toBeCloseTo(80, 4); + } + }); + + test('returns null for coincident centers', () => { + expect(externalBeltTangents(0, 0, 10, 0, 0, 20)).toBeNull(); + }); +}); + +describe('gearPoints (diagram geometry)', () => { + test('produces four points per tooth', () => { + const points = gearPoints(0, 0, 20, 12, 10).split(' '); + expect(points).toHaveLength(40); + }); + + test('clamps to a minimum of three teeth', () => { + const points = gearPoints(0, 0, 20, 12, 1).split(' '); + expect(points).toHaveLength(12); + }); +}); + +describe('driveReducer (single-belt train)', () => { + const fresh = (count = 2) => ({ + elements: Array.from({length: count}, () => ({size: '', rpm: ''})), + lastUpdated: [], + }); + const field = (state, index, f, value) => { + let s = driveReducer(state, {type: 'field', index, field: f, value}); + return driveReducer(s, {type: 'solve'}); + }; + + test('solves a driven wheel RPM from the driver (2:1)', () => { + let state = fresh(); + state = field(state, 0, 'size', '4'); + state = field(state, 1, 'size', '8'); + state = field(state, 0, 'rpm', '100'); + // Belt speed K = 4*100 = 400; wheel 2 (8") spins at 400/8 = 50. + expect(Number(state.elements[1].rpm)).toBeCloseTo(50, 4); + }); + + test('propagates one input speed across a 3-wheel train', () => { + let state = driveReducer(fresh(), {type: 'add'}); // 3 wheels + state = field(state, 0, 'size', '2'); + state = field(state, 1, 'size', '6'); + state = field(state, 2, 'size', '3'); + state = field(state, 0, 'rpm', '1000'); + // K = 2000; wheel 2 = 2000/6, wheel 3 = 2000/3. + expect(Number(state.elements[1].rpm)).toBeCloseTo(333.333, 2); + expect(Number(state.elements[2].rpm)).toBeCloseTo(666.667, 2); + }); + + test('editing a wheel RPM re-anchors the belt speed', () => { + let state = fresh(); + state = field(state, 0, 'size', '4'); + state = field(state, 1, 'size', '8'); + state = field(state, 0, 'rpm', '100'); // wheel 2 -> 50 + state = field(state, 1, 'rpm', '25'); // re-anchor: K = 8*25 = 200 + expect(Number(state.elements[0].rpm)).toBeCloseTo(50, 4); // 200/4 + }); + + test('add appends wheels up to the maximum of 6', () => { + let state = fresh(); + for (let i = 0; i < 10; i++) { + state = driveReducer(state, {type: 'add'}); + } + expect(state.elements).toHaveLength(6); + }); + + test('remove deletes a wheel but never below the minimum of 2', () => { + let state = driveReducer(fresh(), {type: 'add'}); // 3 + state = driveReducer(state, {type: 'remove', index: 1}); + expect(state.elements).toHaveLength(2); + state = driveReducer(state, {type: 'remove', index: 0}); + expect(state.elements).toHaveLength(2); + }); + + test('clamps negative input to a positive magnitude', () => { + const state = field(fresh(), 0, 'size', '-7'); + expect(state.elements[0].size).toBe('7'); + }); + + test('ignores unknown actions', () => { + const initial = fresh(); + expect(driveReducer(initial, {type: 'bogus'})).toBe(initial); + }); + + // --- Edge cases --- + test('clearing the anchor falls back to the next most recent complete wheel', () => { + let state = fresh(3); + state = field(state, 0, 'size', '10'); + state = field(state, 0, 'rpm', '800'); // K=8000 + state = field(state, 2, 'size', '20'); // wheel 3 gets 400 + // Now clear the original anchor's rpm + state = field(state, 0, 'rpm', ''); + // Should still have a complete wheel (index 2), but no anchor that has both → no solving happens for others + expect(state.elements[2].rpm).toBe('400'); // previously solved value stays + }); + + test('completing a middle wheel as first anchor works', () => { + let state = fresh(3); + state = field(state, 1, 'size', '5'); + state = field(state, 1, 'rpm', '400'); // K=2000 + state = field(state, 0, 'size', '4'); + state = field(state, 2, 'size', '10'); + // Both outer wheels should now be solved from the middle anchor + expect(Number(state.elements[0].rpm)).toBeCloseTo(500, 4); + expect(Number(state.elements[2].rpm)).toBeCloseTo(200, 4); + }); + + test('solving only happens into the missing side (re-anchoring still works)', () => { + let state = fresh(); + state = field(state, 0, 'size', '5'); + state = field(state, 0, 'rpm', '200'); // K=1000 + state = field(state, 1, 'size', '20'); + expect(Number(state.elements[1].rpm)).toBeCloseTo(50, 4); + // Re-anchor by giving wheel 1 both values + state = field(state, 1, 'rpm', '30'); // new K=600 + expect(Number(state.elements[0].rpm)).toBeCloseTo(120, 4); + }); + + test('very small and fractional values are handled without NaN', () => { + let state = fresh(); + state = field(state, 0, 'size', '0.5'); + state = field(state, 0, 'rpm', '10000'); + state = field(state, 1, 'size', '0.25'); + expect(Number(state.elements[1].rpm)).toBeCloseTo(20000, 4); + }); +});