diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821e15..e8aef60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: - name: Build run: npm run build + - name: Verify the CLI contract + run: | + node dist/index.js --version | grep -q "^engineer-mcp" + node dist/index.js --list | grep -q "beam_bending" + node dist/index.js --list | grep -q "press_fit" + - name: Verify the demo runs run: npm run demo diff --git a/README.md b/README.md index e94b177..c554dc8 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ The release covers these domains: - Helical compression spring design. - Shaft torsion and first critical speed. - Bearing rating life to ISO 281. +- Fatigue life to the Soderberg, Goodman, Gerber, and ASME-elliptic criteria. +- Press-fit and interference-fit joint design by Lamé theory. - von Mises equivalent stress. - Cross-section properties. -- Dimension-safe unit conversion. +- Dimension-safe unit conversion, including viscosity and thermal conductivity. - Material property lookup. ## How results stay trustworthy @@ -52,6 +54,8 @@ Warnings surface when a method uses an approximation. | `spring_design` | Spring rate, shear stress, and safety factor of a compression spring. | | `shaft_analysis` | Torsion stress, twist, and critical speed. | | `bearing_life` | ISO 281 rating life in revolutions and hours. | +| `fatigue_analysis` | Fatigue safety factors for a constant-amplitude stress cycle. | +| `press_fit` | Contact pressure, stresses, and capacity of an interference fit. | | `von_mises` | Equivalent stress and yield safety factor. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | @@ -147,6 +151,43 @@ References: - Machinery's Handbook (Thirty-first edition) ``` +A call to `fatigue_analysis` for a steel part under a fluctuating load: + +```text +Endurance limit 310 MPa +Mean stress 100 MPa +Alternating stress 100 MPa +Soderberg fatigue factor 1.622 +Modified Goodman fatigue factor 2.067 +Gerber fatigue factor 2.568 +ASME-elliptic fatigue factor 2.291 +First-cycle yield factor 1.7 +Governing fatigue factor 1.622 + +Method: Constant-amplitude fatigue criteria +Formula: Soderberg: Sa/Se + Sm/Sy = 1/n. Goodman: Sa/Se + Sm/Sut = 1/n +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) +``` + +A call to `press_fit` for a steel hub on a steel shaft with 20 µm interference: + +```text +Interface contact pressure 52.5 MPa +Hub tangential stress 87.5 MPa +Shaft tangential stress -52.5 MPa +Hub von Mises stress 122.5 MPa +Press-in force 22.27 kN +Torque capacity 334 N·m +Hub safety factor 2.898 + +Method: Interference fit by Lamé thick-cylinder theory +Formula: p = u / (r ((1/Eh)((ro^2+r^2)/(ro^2-r^2)+nh) + (1/Es)((r^2+ri^2)/(r^2-ri^2)-ns))) +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) + - Machinery's Handbook (Thirty-first edition) +``` + A call to `unit_convert` with a torque-to-energy request fails safely: ```text @@ -169,9 +210,9 @@ Use a unit of the same quantity. The test suite is deterministic and offline. It covers the engines, the unit layer, the database, and the tools. -- 98 tests across 10 files. +- 135 tests across 12 files. - All tests pass on Node 22 and Node 24. -- The CI workflow runs typecheck, tests, build, demo, and a package check. +- The CI workflow runs typecheck, tests, build, the CLI contract, the demo, and a package check. Run `npm test` to reproduce the results. @@ -184,6 +225,10 @@ Run `npm test` to reproduce the results. - The critical speed is a first-mode approximation. - The spring design covers static round-wire springs only. It does not estimate fatigue life for cyclic loads. +- The fatigue criteria assume a polished test specimen. + Apply surface, size, and loading factors to a real part. +- The press-fit theory assumes uniform contact along the hub. + It ignores stress concentrations at the hub edges. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -197,12 +242,18 @@ Each release stays useful on its own. - Helical compression spring design. The `spring_design` tool reports the spring rate, the shear stress, and the safety factor. +- Constant-amplitude fatigue analysis. + The `fatigue_analysis` tool reports factors for four fatigue criteria. +- Press-fit and interference-fit design. + The `press_fit` tool reports pressure, stresses, and joint capacity. +- Viscosity and thermal-conductivity units. + The `unit_convert` tool converts dynamic viscosity, kinematic viscosity, and thermal conductivity. ### Remaining -- Add fatigue analysis for cyclic loads. -- Add press-fit and interference-fit calculators. -- Add more unit categories, including viscosity and thermal conductivity. +- Add fatigue analysis for welded or notched joints. +- Add a shrink-fit temperature for assembly. +- Add more unit categories, including specific heat capacity. - Add HTTP transport. - Add a catalog of ISO and DIN standard sections. diff --git a/docs/integration.md b/docs/integration.md index 042b7d4..7e662e9 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -41,12 +41,15 @@ Engineer MCP grows in independent releases. Each release stays useful on its own ### Complete - Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. +- Constant-amplitude fatigue analysis. The `fatigue_analysis` tool reports factors for the Soderberg, Goodman, Gerber, and ASME-elliptic criteria. +- Press-fit and interference-fit design. The `press_fit` tool reports pressure, stresses, and joint capacity from Lamé theory. +- Viscosity and thermal-conductivity units. The `unit_convert` tool covers dynamic viscosity, kinematic viscosity, and thermal conductivity. ### Remaining -- Add fatigue analysis for cyclic loads. -- Add press-fit and interference-fit calculators. -- Add more unit categories, including viscosity and thermal conductivity. +- Add fatigue analysis for welded or notched joints. +- Add a shrink-fit temperature for assembly. +- Add more unit categories, including specific heat capacity. - Add HTTP transport in addition to stdio. - Add a catalog of ISO and DIN standard sections. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d38b8c9..5a5134a 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -135,6 +135,59 @@ Inputs: - `speedRpm`: enables life in hours. - `requiredLifeHours`: enables the life margin. +## fatigue_analysis + +Compute fatigue safety factors for a constant-amplitude stress cycle. + +The tool evaluates the Soderberg, modified Goodman, Gerber, and ASME-elliptic criteria. It reports the minimum factor as the governing factor. + +Inputs: + +- `meanStress`: mean stress in pascals. Negative values are compressive. +- `amplitudeStress`: alternating stress in pascals. Zero means a steady load. +- `ultimateStrength`: ultimate tensile strength in pascals. +- `yieldStrength`: yield strength in pascals. Enables the Soderberg and ASME-elliptic criteria. +- `enduranceLimit`: endurance limit in pascals. The tool estimates it when missing. +- `criterion`: one of `soderberg`, `goodman`, `gerber`, `asme_elliptic`, or `all`. The default is `all`. + +The tool warns when the mean stress is compressive. It warns when the endurance limit is an estimate. It warns when the governing factor is below one. + +## press_fit + +Compute the contact pressure, stresses, and capacity of an interference fit. + +The tool uses the Lamé solution for two cylinders in interference. The hub carries tensile hoop stress. The shaft carries compressive hoop stress. + +Inputs: + +- `hubOuterDiameter`: hub outer diameter in metres. +- `interfaceDiameter`: interface diameter in metres. +- `shaftInnerDiameter`: shaft inner diameter in metres. The default is a solid shaft. +- `hubLength`: hub engagement length in metres. +- `diametralInterference`: diametral interference in metres. +- `hubElasticModulus` and `shaftElasticModulus`: Young's moduli in pascals. +- `hubPoissonRatio` and `shaftPoissonRatio`: Poisson's ratios. The default is `0.3`. +- `frictionCoefficient`: interface friction coefficient. The default is `0.15` for dry steel. +- `hubYieldStrength`: enables the hub safety factor. +- `shaftYieldStrength`: enables the shaft safety factor. +- `appliedAxialForce`: enables the axial joint safety factor. + +Example: + +```json +{ + "hubOuterDiameter": 0.06, + "interfaceDiameter": 0.03, + "hubLength": 0.03, + "diametralInterference": 0.00002, + "hubElasticModulus": 210000000000, + "shaftElasticModulus": 210000000000, + "hubYieldStrength": 355000000 +} +``` + +The tool warns on a thin hub. It warns when the applied axial force exceeds the press-in force. + ## von_mises Compute the von Mises equivalent stress and yield safety factor of a stress state. @@ -156,6 +209,14 @@ Inputs: - `from`: source unit symbol. - `to`: target unit symbol. +The converter covers these quantities: + +- length, mass, time, and angle. +- temperature, force, pressure, and torque. +- energy, power, velocity, and acceleration. +- area, volume, density, and stiffness. +- frequency, dynamic viscosity, kinematic viscosity, and thermal conductivity. + The converter rejects mismatched dimensions and mismatched quantity categories. For example, it rejects a torque-to-energy conversion. ## material_lookup diff --git a/examples/demo.ts b/examples/demo.ts index 2013998..3d7b5a2 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -76,6 +76,8 @@ type ToolHandlers = { von_mises: Handler; unit_convert: Handler; material_lookup: Handler; + fatigue_analysis: Handler; + press_fit: Handler; }; const toolHandlers = handlers as ToolHandlers; @@ -90,6 +92,9 @@ const tools: NamedHandler[] = [ ["von_mises", toolHandlers.von_mises], ["unit_convert", toolHandlers.unit_convert], ["unit_convert (torque to energy)", toolHandlers.unit_convert], + ["unit_convert (thermal conductivity)", toolHandlers.unit_convert], + ["fatigue_analysis", toolHandlers.fatigue_analysis], + ["press_fit", toolHandlers.press_fit], ["material_lookup", toolHandlers.material_lookup], ]; @@ -155,6 +160,39 @@ const inputs: Array> = [ from: "N·m", to: "J", }, + { + value: 205, + from: "W/(m·K)", + to: "BTU/(ft·h·degF)", + }, + { + meanStress: 100e6, + amplitudeStress: 100e6, + ultimateStrength: 620e6, + yieldStrength: 340e6, + outputUnits: { enduranceLimit: "MPa", meanStress: "MPa", amplitudeStress: "MPa" }, + }, + { + hubOuterDiameter: 0.06, + interfaceDiameter: 0.03, + hubLength: 0.03, + diametralInterference: 20e-6, + hubElasticModulus: 210e9, + shaftElasticModulus: 210e9, + hubYieldStrength: 355e6, + shaftYieldStrength: 355e6, + appliedAxialForce: 10000, + outputUnits: { + contactPressure: "MPa", + hubTangentialStress: "MPa", + shaftTangentialStress: "MPa", + radialStress: "MPa", + hubVonMisesStress: "MPa", + shaftVonMisesStress: "MPa", + pressForce: "kN", + torqueCapacity: "N·m", + }, + }, { query: "steel", }, diff --git a/package-lock.json b/package-lock.json index 5c98405..1e30317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -1608,7 +1608,7 @@ } }, "node_modules/forwarded": { - "version": "0.2.0", + "version": "0.3.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", diff --git a/package.json b/package.json index 49eb29e..349e363 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.2.0", + "version": "0.3.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", @@ -48,6 +48,8 @@ "engineering", "calculations", "unit-conversion", + "fatigue", + "press-fit", "sqlite", "provenance", "engineerkit" diff --git a/src/engine/fatigue.ts b/src/engine/fatigue.ts new file mode 100644 index 0000000..c0546ef --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,218 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type FatigueCriterion = "soderberg" | "goodman" | "gerber" | "asme_elliptic"; + +export type FatigueInput = { + meanStress: number; + amplitudeStress: number; + ultimateStrength: number; + yieldStrength?: number; + enduranceLimit?: number; + criterion?: FatigueCriterion | "all"; +}; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Constant-amplitude fatigue criteria", + formula: + "Soderberg: Sa/Se + Sm/Sy = 1/n. Goodman: Sa/Se + Sm/Sut = 1/n. Gerber: n Sa/Se + (n Sm/Sut)^2 = 1. ASME-elliptic: (n Sa/Se)^2 + (n Sm/Sy)^2 = 1", + notes: + "Criteria for fluctuating axial or bending loads. The endurance limit Se describes a polished test specimen. Apply surface, size, and loading factors for real parts. A negative mean stress is compressive and may make the criteria optimistic.", + referenceIds: ["shigley-2015"], +}; + +const CRITERION_NAMES: Record = { + soderberg: "Soderberg", + goodman: "Modified Goodman", + gerber: "Gerber", + asme_elliptic: "ASME-elliptic", +}; + +const CRITERION_ORDER: FatigueCriterion[] = ["soderberg", "goodman", "gerber", "asme_elliptic"]; + +const ENDURANCE_LIMIT_CAP_PA = 700e6; + +function needsYieldStrength(criterion: FatigueCriterion): boolean { + return criterion === "soderberg" || criterion === "asme_elliptic"; +} + +export function estimateEnduranceLimit(ultimateStrength: number): number { + return Math.min(0.5 * ultimateStrength, ENDURANCE_LIMIT_CAP_PA); +} + +export function criterionSafetyFactor( + criterion: FatigueCriterion, + amplitudeStress: number, + meanStress: number, + enduranceLimit: number, + ultimateStrength: number, + yieldStrength: number, +): number | undefined { + const amplitudeRatio = amplitudeStress / enduranceLimit; + const meanUltimateRatio = meanStress / ultimateStrength; + switch (criterion) { + case "soderberg": + return 1 / (amplitudeRatio + meanStress / yieldStrength); + case "goodman": + return 1 / (amplitudeRatio + meanUltimateRatio); + case "gerber": { + if (meanUltimateRatio === 0) { + return amplitudeRatio === 0 ? undefined : 1 / amplitudeRatio; + } + return (-amplitudeRatio + Math.sqrt(amplitudeRatio ** 2 + 4 * meanUltimateRatio ** 2)) / (2 * meanUltimateRatio ** 2); + } + case "asme_elliptic": + return 1 / Math.sqrt(amplitudeRatio ** 2 + (meanStress / yieldStrength) ** 2); + } +} + +export function analyzeFatigue(input: FatigueInput): Computation { + if (!(input.ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + if (!(input.amplitudeStress >= 0)) { + throw new Error("amplitudeStress must be zero or positive."); + } + if (input.meanStress === 0 && input.amplitudeStress === 0) { + throw new Error("Provide a nonzero meanStress or amplitudeStress."); + } + if (input.enduranceLimit !== undefined && !(input.enduranceLimit > 0)) { + throw new Error("enduranceLimit must be positive."); + } + if (input.yieldStrength !== undefined && !(input.yieldStrength > 0)) { + throw new Error("yieldStrength must be positive."); + } + + const enduranceLimit = input.enduranceLimit ?? estimateEnduranceLimit(input.ultimateStrength); + const criteria: FatigueCriterion[] = + input.criterion === undefined || input.criterion === "all" ? [...CRITERION_ORDER] : [input.criterion]; + + if (input.criterion !== undefined && input.criterion !== "all" && needsYieldStrength(input.criterion) && input.yieldStrength === undefined) { + throw new Error(`The ${CRITERION_NAMES[input.criterion]} criterion requires yieldStrength.`); + } + + const warnings: string[] = []; + if (input.enduranceLimit === undefined) { + warnings.push("Endurance limit estimated as half the ultimate strength, capped at 700 MPa."); + } + if (input.meanStress < 0) { + warnings.push("Mean stress is compressive. The criteria may overestimate the fatigue life. Use a zero mean stress for a conservative estimate."); + } + if (input.amplitudeStress === 0) { + warnings.push("The alternating stress is zero. Treat the load as static; fatigue does not govern."); + } + + const factors = new Map(); + for (const criterion of criteria) { + if (needsYieldStrength(criterion) && input.yieldStrength === undefined) { + warnings.push(`The ${CRITERION_NAMES[criterion]} criterion is skipped. Provide yieldStrength to enable it.`); + continue; + } + const factor = criterionSafetyFactor( + criterion, + input.amplitudeStress, + input.meanStress, + enduranceLimit, + input.ultimateStrength, + input.yieldStrength ?? 0, + ); + if (factor === undefined || factor <= 0) { + warnings.push(`The ${CRITERION_NAMES[criterion]} criterion gives no positive factor for this load and is skipped.`); + continue; + } + factors.set(criterion, factor); + } + + if (factors.size === 0) { + throw new Error("No fatigue criterion could be evaluated. Choose a criterion that matches the available strengths."); + } + + let governing: FatigueCriterion = "soderberg"; + let governingFactor = Number.POSITIVE_INFINITY; + for (const [criterion, factor] of factors) { + if (factor < governingFactor) { + governingFactor = factor; + governing = criterion; + } + } + + const quantities: Quantity[] = [ + { + key: "enduranceLimit", + label: "Endurance limit", + value: enduranceLimit, + unit: "Pa", + description: "Fully reversed stress at which the material survives indefinitely. Estimated when not supplied.", + }, + { + key: "meanStress", + label: "Mean stress", + value: input.meanStress, + unit: "Pa", + description: "Steady component of the stress cycle. Positive values are tensile.", + }, + { + key: "amplitudeStress", + label: "Alternating stress", + value: input.amplitudeStress, + unit: "Pa", + description: "Reversing component of the stress cycle. Zero means a steady load.", + }, + ]; + + for (const criterion of CRITERION_ORDER) { + const factor = factors.get(criterion); + if (factor === undefined) { + continue; + } + quantities.push({ + key: `${criterion}Factor`, + label: `${CRITERION_NAMES[criterion]} fatigue factor`, + value: factor, + unit: "", + description: `Fatigue safety factor from the ${CRITERION_NAMES[criterion]} criterion.`, + }); + } + + let firstCycleYieldFactor: number | undefined; + if (input.yieldStrength !== undefined && input.meanStress + input.amplitudeStress > 0) { + firstCycleYieldFactor = input.yieldStrength / (input.meanStress + input.amplitudeStress); + quantities.push({ + key: "firstCycleYieldFactor", + label: "First-cycle yield factor", + value: firstCycleYieldFactor, + unit: "", + description: "Yield strength divided by the sum of mean and alternating stress.", + }); + } + + if (firstCycleYieldFactor !== undefined && firstCycleYieldFactor < governingFactor) { + warnings.push("First-cycle yielding governs over fatigue. Check the static yield against the peak stress."); + } + if (governingFactor < 1) { + warnings.push("The governing fatigue factor is below 1. The part may fail under the stated cycle."); + } + + return { + method: FATIGUE_METHOD, + inputs: { + meanStress: input.meanStress, + amplitudeStress: input.amplitudeStress, + ultimateStrength: input.ultimateStrength, + yieldStrength: input.yieldStrength, + enduranceLimit: input.enduranceLimit, + criterion: input.criterion ?? "all", + enduranceLimitUsed: enduranceLimit, + }, + quantities, + safetyFactor: { + key: "governingFactor", + label: "Governing fatigue factor", + value: governingFactor, + unit: "", + description: `Minimum fatigue safety factor across the evaluated criteria. Governed by ${CRITERION_NAMES[governing]}.`, + }, + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/fit.ts b/src/engine/fit.ts new file mode 100644 index 0000000..13a4996 --- /dev/null +++ b/src/engine/fit.ts @@ -0,0 +1,218 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type PressFitInput = { + hubOuterDiameter: number; + interfaceDiameter: number; + shaftInnerDiameter?: number; + hubLength: number; + diametralInterference: number; + hubElasticModulus: number; + shaftElasticModulus: number; + hubPoissonRatio?: number; + shaftPoissonRatio?: number; + frictionCoefficient?: number; + hubYieldStrength?: number; + shaftYieldStrength?: number; + appliedAxialForce?: number; +}; + +export const PRESS_FIT_METHOD: MethodRecord = { + id: "press-fit", + name: "Interference fit by Lamé thick-cylinder theory", + formula: + "p = u / (r ((1/Eh)((ro^2+r^2)/(ro^2-r^2)+nh) + (1/Es)((r^2+ri^2)/(r^2-ri^2)-ns))), u = I/2, sigma_t,h = p (ro^2+r^2)/(ro^2-r^2), F = mu p pi d L, T = F r", + notes: + "Plane-stress Lamé solution for two cylinders in interference. The hub carries tensile hoop stress, the shaft carries compressive hoop stress. A solid shaft sees biaxial compression equal to the contact pressure. The press-in force assumes a uniform friction coefficient over the full engagement length. The default coefficient of 0.15 suits dry steel-on-steel.", + referenceIds: ["shigley-2015", "machinery-handbook", "roark-2011"], +}; + +const DEFAULT_POISSON_RATIO = 0.3; +const DEFAULT_FRICTION = 0.15; +const THIN_HUB_RATIO = 1.5; + +export function planeStressVonMises(sigmaTangential: number, sigmaRadial: number): number { + const [a, b, c] = [sigmaTangential, sigmaRadial, 0]; + return Math.sqrt(0.5 * ((a - b) ** 2 + (b - c) ** 2 + (c - a) ** 2)); +} + +export function analyzePressFit(input: PressFitInput): Computation { + if (!(input.hubOuterDiameter > 0)) { + throw new Error("hubOuterDiameter must be positive."); + } + if (!(input.interfaceDiameter > 0)) { + throw new Error("interfaceDiameter must be positive."); + } + if (input.interfaceDiameter >= input.hubOuterDiameter) { + throw new Error("hubOuterDiameter must exceed interfaceDiameter."); + } + const shaftInnerDiameter = input.shaftInnerDiameter ?? 0; + if (shaftInnerDiameter < 0 || shaftInnerDiameter >= input.interfaceDiameter) { + throw new Error("shaftInnerDiameter must be smaller than interfaceDiameter."); + } + if (!(input.hubLength > 0)) { + throw new Error("hubLength must be positive."); + } + if (!(input.diametralInterference > 0)) { + throw new Error("diametralInterference must be positive."); + } + if (!(input.hubElasticModulus > 0)) { + throw new Error("hubElasticModulus must be positive."); + } + if (!(input.shaftElasticModulus > 0)) { + throw new Error("shaftElasticModulus must be positive."); + } + + const hubPoissonRatio = input.hubPoissonRatio ?? DEFAULT_POISSON_RATIO; + const shaftPoissonRatio = input.shaftPoissonRatio ?? DEFAULT_POISSON_RATIO; + if (!(hubPoissonRatio >= 0 && hubPoissonRatio < 0.5)) { + throw new Error("hubPoissonRatio must be below 0.5."); + } + if (!(shaftPoissonRatio >= 0 && shaftPoissonRatio < 0.5)) { + throw new Error("shaftPoissonRatio must be below 0.5."); + } + const frictionCoefficient = input.frictionCoefficient ?? DEFAULT_FRICTION; + if (!(frictionCoefficient >= 0)) { + throw new Error("frictionCoefficient must be zero or positive."); + } + + const innerRadius = shaftInnerDiameter / 2; + const interfaceRadius = input.interfaceDiameter / 2; + const outerRadius = input.hubOuterDiameter / 2; + const radialInterference = input.diametralInterference / 2; + + const hubTerm = (outerRadius ** 2 + interfaceRadius ** 2) / (outerRadius ** 2 - interfaceRadius ** 2) + hubPoissonRatio; + const shaftTerm = (interfaceRadius ** 2 + innerRadius ** 2) / (interfaceRadius ** 2 - innerRadius ** 2) - shaftPoissonRatio; + const contactPressure = + radialInterference / (interfaceRadius * (hubTerm / input.hubElasticModulus + shaftTerm / input.shaftElasticModulus)); + + const hubTangentialStress = (contactPressure * (outerRadius ** 2 + interfaceRadius ** 2)) / (outerRadius ** 2 - interfaceRadius ** 2); + const shaftTangentialStress = (-contactPressure * (interfaceRadius ** 2 + innerRadius ** 2)) / (interfaceRadius ** 2 - innerRadius ** 2); + const radialStress = -contactPressure; + + const hubVonMisesStress = planeStressVonMises(hubTangentialStress, radialStress); + const shaftVonMisesStress = planeStressVonMises(shaftTangentialStress, radialStress); + + const contactArea = Math.PI * input.interfaceDiameter * input.hubLength; + const pressForce = frictionCoefficient * contactPressure * contactArea; + const torqueCapacity = pressForce * interfaceRadius; + + const quantities: Quantity[] = [ + { + key: "contactPressure", + label: "Interface contact pressure", + value: contactPressure, + unit: "Pa", + description: "Uniform radial pressure at the interface caused by the interference.", + }, + { + key: "hubTangentialStress", + label: "Hub tangential stress", + value: hubTangentialStress, + unit: "Pa", + description: "Tensile hoop stress at the hub bore.", + }, + { + key: "shaftTangentialStress", + label: "Shaft tangential stress", + value: shaftTangentialStress, + unit: "Pa", + description: "Compressive hoop stress at the shaft surface.", + }, + { + key: "radialStress", + label: "Radial stress at interface", + value: radialStress, + unit: "Pa", + description: "Compressive radial stress equal to the negative of the contact pressure.", + }, + { + key: "hubVonMisesStress", + label: "Hub von Mises stress", + value: hubVonMisesStress, + unit: "Pa", + description: "Equivalent stress at the hub bore under plane stress.", + }, + { + key: "shaftVonMisesStress", + label: "Shaft von Mises stress", + value: shaftVonMisesStress, + unit: "Pa", + description: "Equivalent stress in the shaft under plane stress.", + }, + { + key: "pressForce", + label: "Press-in force", + value: pressForce, + unit: "N", + description: "Axial force needed to assemble the joint at the given friction coefficient.", + }, + { + key: "torqueCapacity", + label: "Torque capacity", + value: torqueCapacity, + unit: "N·m", + description: "Torque the joint can transmit before slipping at the interface.", + }, + ]; + + const warnings: string[] = []; + if (outerRadius / interfaceRadius < THIN_HUB_RATIO) { + warnings.push("The hub is thin. The solution stays valid but check local yielding near the bore."); + } + + let safetyFactor: Quantity | undefined; + if (input.hubYieldStrength) { + safetyFactor = { + key: "hubSafetyFactor", + label: "Hub safety factor", + value: input.hubYieldStrength / hubVonMisesStress, + unit: "", + description: "Hub yield strength divided by the hub von Mises stress.", + }; + } + if (input.shaftYieldStrength && shaftVonMisesStress > 0) { + quantities.push({ + key: "shaftSafetyFactor", + label: "Shaft safety factor", + value: input.shaftYieldStrength / shaftVonMisesStress, + unit: "", + description: "Shaft yield strength divided by the shaft von Mises stress.", + }); + } + if (input.appliedAxialForce !== undefined) { + quantities.push({ + key: "axialJointSafetyFactor", + label: "Axial joint safety factor", + value: pressForce / input.appliedAxialForce, + unit: "", + description: "Press-in force divided by the applied axial force.", + }); + if (input.appliedAxialForce > pressForce) { + warnings.push("The applied axial force exceeds the press-in force. The joint may separate or slip."); + } + } + + return { + method: PRESS_FIT_METHOD, + inputs: { + hubOuterDiameter: input.hubOuterDiameter, + interfaceDiameter: input.interfaceDiameter, + shaftInnerDiameter, + hubLength: input.hubLength, + diametralInterference: input.diametralInterference, + radialInterference, + hubPoissonRatio, + shaftPoissonRatio, + frictionCoefficient, + hubElasticModulus: input.hubElasticModulus, + hubYieldStrength: input.hubYieldStrength, + shaftElasticModulus: input.shaftElasticModulus, + shaftYieldStrength: input.shaftYieldStrength, + appliedAxialForce: input.appliedAxialForce, + }, + quantities, + safetyFactor, + referenceIds: PRESS_FIT_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 92492d7..7d72ebd 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,15 @@ export { analyzeBeam, BEAM_METHOD, type BeamInput, type BeamLoad, type BeamSupport } from "./beam.js"; export { analyzeBearing, BEARING_METHOD, equivalentLoad, type BearingInput, type BearingType } from "./bearing.js"; export { analyzeBolt, BOLT_METHOD, tensileStressArea, type BoltGradeData, type BoltInput } from "./bolt.js"; +export { + analyzeFatigue, + criterionSafetyFactor, + estimateEnduranceLimit, + FATIGUE_METHOD, + type FatigueCriterion, + type FatigueInput, +} from "./fatigue.js"; +export { analyzePressFit, planeStressVonMises, PRESS_FIT_METHOD, type PressFitInput } from "./fit.js"; export { computeSection, SECTION_METHOD, type SectionDef, type SectionProperties } from "./sections.js"; export { analyzeShaft, SHAFT_METHOD, type ShaftInput } from "./shaft.js"; export { diff --git a/src/handlers.ts b/src/handlers.ts index c646e73..eb6fa6a 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -3,6 +3,8 @@ import { analyzeBeam, analyzeBearing, analyzeBolt, + analyzeFatigue, + analyzePressFit, analyzeShaft, analyzeSpring, computeSection, @@ -405,6 +407,49 @@ function unitConvertHandler(ctx: AppContext): Handler { }; } +function fatigueHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzeFatigue({ + meanStress: input.meanStress as number, + amplitudeStress: input.amplitudeStress as number, + ultimateStrength: input.ultimateStrength as number, + yieldStrength: input.yieldStrength as number | undefined, + enduranceLimit: input.enduranceLimit as number | undefined, + criterion: input.criterion as "soderberg" | "goodman" | "gerber" | "asme_elliptic" | "all" | undefined, + }); + return buildResult(ctx, "fatigue_analysis", computation, input.outputUnits as Record | undefined); + } catch (error) { + return failure("fatigue_analysis", error instanceof Error ? error.message : String(error), input); + } + }; +} + +function pressFitHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzePressFit({ + hubOuterDiameter: input.hubOuterDiameter as number, + interfaceDiameter: input.interfaceDiameter as number, + shaftInnerDiameter: input.shaftInnerDiameter as number | undefined, + hubLength: input.hubLength as number, + diametralInterference: input.diametralInterference as number, + hubElasticModulus: input.hubElasticModulus as number, + shaftElasticModulus: input.shaftElasticModulus as number, + hubPoissonRatio: input.hubPoissonRatio as number | undefined, + shaftPoissonRatio: input.shaftPoissonRatio as number | undefined, + frictionCoefficient: input.frictionCoefficient as number | undefined, + hubYieldStrength: input.hubYieldStrength as number | undefined, + shaftYieldStrength: input.shaftYieldStrength as number | undefined, + appliedAxialForce: input.appliedAxialForce as number | undefined, + }); + return buildResult(ctx, "press_fit", computation, input.outputUnits as Record | undefined); + } catch (error) { + return failure("press_fit", error instanceof Error ? error.message : String(error), input); + } + }; +} + function materialHandler(ctx: AppContext): Handler { return (input) => { const query = input.query as string; @@ -448,5 +493,7 @@ export function createHandlers(ctx: AppContext): Record { von_mises: stressHandler(ctx), unit_convert: unitConvertHandler(ctx), material_lookup: materialHandler(ctx), + fatigue_analysis: fatigueHandler(ctx), + press_fit: pressFitHandler(ctx), }; } diff --git a/src/index.ts b/src/index.ts index 8e439d3..d70ebad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,11 @@ Options: -h, --help Show this help and exit. `; -function info(message: string): void { +function writeOut(message: string): void { + process.stdout.write(`${message}\n`); +} + +function writeErr(message: string): void { process.stderr.write(`${message}\n`); } @@ -35,17 +39,17 @@ function main(): void { }); if (values.help) { - info(HELP); + writeOut(HELP); return; } if (values.version) { - info(`${SERVER_NAME} v${VERSION}`); + writeOut(`${SERVER_NAME} v${VERSION}`); return; } if (values.list) { - info("Available tools:"); + writeOut("Available tools:"); for (const tool of listTools()) { - info(` - ${tool}`); + writeOut(` - ${tool}`); } return; } @@ -56,7 +60,7 @@ function main(): void { const transport = new StdioServerTransport(); server.connect(transport).catch((error: unknown) => { - info(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`); + writeErr(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); } diff --git a/src/schemas.ts b/src/schemas.ts index 7204c18..8afa246 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -131,6 +131,36 @@ export const materialSchema = z.object({ limit: z.number().int().min(1).max(50).optional().describe("Maximum number of rows to return. Defaults to 10."), }); +export const fatigueSchema = z.object({ + meanStress: z.number().describe("Mean stress in pascals. Negative values are compressive."), + amplitudeStress: z.number().min(0).describe("Alternating stress in pascals. Zero means a steady load."), + ultimateStrength: z.number().positive().describe("Ultimate tensile strength in pascals."), + yieldStrength: z.number().positive().optional().describe("Yield strength in pascals. Enables the Soderberg and ASME-elliptic criteria."), + enduranceLimit: z.number().positive().optional().describe("Endurance limit in pascals. Estimated when not supplied."), + criterion: z + .enum(["soderberg", "goodman", "gerber", "asme_elliptic", "all"]) + .optional() + .describe("Fatigue criterion to evaluate. Defaults to all criteria."), + outputUnits, +}); + +export const pressFitSchema = z.object({ + hubOuterDiameter: z.number().positive().describe("Hub outer diameter in metres."), + interfaceDiameter: z.number().positive().describe("Interface diameter in metres."), + shaftInnerDiameter: z.number().min(0).optional().describe("Shaft inner diameter in metres for a hollow shaft. Defaults to a solid shaft."), + hubLength: z.number().positive().describe("Hub engagement length in metres."), + diametralInterference: z.number().positive().describe("Diametral interference in metres."), + hubElasticModulus: z.number().positive().describe("Hub Young's modulus in pascals."), + shaftElasticModulus: z.number().positive().describe("Shaft Young's modulus in pascals."), + hubPoissonRatio: z.number().min(0).max(0.5).optional().describe("Hub Poisson's ratio. Defaults to 0.3."), + shaftPoissonRatio: z.number().min(0).max(0.5).optional().describe("Shaft Poisson's ratio. Defaults to 0.3."), + frictionCoefficient: z.number().min(0).optional().describe("Interface friction coefficient. Defaults to 0.15 for dry steel."), + hubYieldStrength: z.number().positive().optional().describe("Hub yield strength in pascals. Enables the hub safety factor."), + shaftYieldStrength: z.number().positive().optional().describe("Shaft yield strength in pascals. Enables the shaft safety factor."), + appliedAxialForce: z.number().min(0).optional().describe("Applied axial force in newtons. Enables the axial joint safety factor."), + outputUnits, +}); + export type BeamInput = z.infer; export type BoltInput = z.infer; export type ShaftInput = z.infer; @@ -140,3 +170,5 @@ export type SectionPropsInput = z.infer; export type StressInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; +export type FatigueInput = z.infer; +export type PressFitInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2029a75..fd6bd84 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,9 @@ import { beamSchema, bearingSchema, boltSchema, + fatigueSchema, materialSchema, + pressFitSchema, sectionPropsSchema, shaftSchema, springSchema, @@ -60,6 +62,16 @@ const TOOL_SCHEMAS: Record = { description: "Look up mechanical properties for common engineering materials from the curated database.", schema: materialSchema, }, + fatigue_analysis: { + description: + "Fatigue safety factors for a constant-amplitude stress cycle using the Soderberg, Goodman, Gerber, and ASME-elliptic criteria.", + schema: fatigueSchema, + }, + press_fit: { + description: + "Contact pressure, stresses, press-in force, and torque capacity of an interference fit by Lamé thick-cylinder theory.", + schema: pressFitSchema, + }, }; function asStructuredContent(value: ToolResult): Record { diff --git a/src/units/dimensions.ts b/src/units/dimensions.ts index 67dc684..81e7b71 100644 --- a/src/units/dimensions.ts +++ b/src/units/dimensions.ts @@ -19,6 +19,9 @@ export const DIM_VOLUME: Dimension = [3, 0, 0, 0, 0]; export const DIM_DENSITY: Dimension = [-3, 1, 0, 0, 0]; export const DIM_STIFFNESS: Dimension = [0, 1, -2, 0, 0]; export const DIM_FREQUENCY: Dimension = [0, 0, -1, 0, 0]; +export const DIM_DYNAMIC_VISCOSITY: Dimension = [-1, 1, -1, 0, 0]; +export const DIM_KINEMATIC_VISCOSITY: Dimension = [2, 0, -1, 0, 0]; +export const DIM_THERMAL_CONDUCTIVITY: Dimension = [1, 1, -3, -1, 0]; export function dimensionsEqual(a: Dimension, b: Dimension): boolean { for (let i = 0; i < a.length; i += 1) { diff --git a/src/units/index.ts b/src/units/index.ts index 8280319..cb688bf 100644 --- a/src/units/index.ts +++ b/src/units/index.ts @@ -1,3 +1,28 @@ export { convertUnit, type UnitOutcome } from "./convert.js"; -export { dimensionLabel, dimensionsEqual, DIMENSION_AXES, type Dimension } from "./dimensions.js"; +export { + dimensionLabel, + dimensionsEqual, + DIMENSION_AXES, + DIM_ACCELERATION, + DIM_ANGLE, + DIM_AREA, + DIM_DENSITY, + DIM_DYNAMIC_VISCOSITY, + DIM_ENERGY, + DIM_FORCE, + DIM_FREQUENCY, + DIM_KINEMATIC_VISCOSITY, + DIM_LENGTH, + DIM_MASS, + DIM_POWER, + DIM_PRESSURE, + DIM_STIFFNESS, + DIM_TEMPERATURE, + DIM_THERMAL_CONDUCTIVITY, + DIM_TIME, + DIM_TORQUE, + DIM_VELOCITY, + DIM_VOLUME, + type Dimension, +} from "./dimensions.js"; export { findSiUnit, findUnit, listUnits, UNITS, type UnitDef } from "./registry.js"; diff --git a/src/units/registry.ts b/src/units/registry.ts index 64eeff9..3cdb2f7 100644 --- a/src/units/registry.ts +++ b/src/units/registry.ts @@ -3,15 +3,18 @@ import { DIM_ANGLE, DIM_AREA, DIM_DENSITY, + DIM_DYNAMIC_VISCOSITY, DIM_ENERGY, DIM_FORCE, DIM_FREQUENCY, + DIM_KINEMATIC_VISCOSITY, DIM_LENGTH, DIM_MASS, DIM_POWER, DIM_PRESSURE, DIM_STIFFNESS, DIM_TEMPERATURE, + DIM_THERMAL_CONDUCTIVITY, DIM_TIME, DIM_TORQUE, DIM_VELOCITY, @@ -140,6 +143,21 @@ export const UNITS: UnitDef[] = [ unit({ canonical: "Hz", name: "hertz", category: "frequency", dim: DIM_FREQUENCY, factor: 1 }), unit({ canonical: "rpm", name: "revolution per minute", category: "frequency", dim: DIM_FREQUENCY, factor: 1 / 60 }), unit({ canonical: "kHz", name: "kilohertz", category: "frequency", dim: DIM_FREQUENCY, factor: 1e3 }), + + unit({ canonical: "Pa·s", name: "pascal second", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1 }), + unit({ canonical: "mPa·s", name: "millipascal second", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1e-3 }), + unit({ canonical: "P", name: "poise", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 0.1 }), + unit({ canonical: "cP", name: "centipoise", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1e-3 }), + unit({ canonical: "lbf·s/ft2", aliases: ["reyn"], name: "pound-force second per square foot", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 47.88025898 }), + + unit({ canonical: "m2/s", name: "square metre per second", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1 }), + unit({ canonical: "mm2/s", name: "square millimetre per second", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1e-6 }), + unit({ canonical: "St", name: "stokes", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1e-4 }), + unit({ canonical: "cSt", name: "centistokes", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1e-6 }), + unit({ canonical: "ft2/s", name: "square foot per second", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 0.09290304 }), + + unit({ canonical: "W/(m·K)", aliases: ["W/mK", "W/(m·degC)"], name: "watt per metre kelvin", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1 }), + unit({ canonical: "BTU/(ft·h·degF)", aliases: ["BTU/(ft.h.degF)"], name: "BTU per foot hour degree Fahrenheit", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1.730734666 }), ]; export function normalizeSymbol(symbol: string): string { diff --git a/src/version.ts b/src/version.ts index 9e86f0c..03714b1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/fatigue.test.ts b/tests/fatigue.test.ts new file mode 100644 index 0000000..fe74a43 --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { analyzeFatigue, criterionSafetyFactor, estimateEnduranceLimit } from "../src/engine/fatigue.js"; + +const BASE = { + meanStress: 100e6, + amplitudeStress: 100e6, + ultimateStrength: 620e6, + yieldStrength: 340e6, +}; + +describe("fatigue endurance limit", () => { + it("estimates half the ultimate strength", () => { + expect(estimateEnduranceLimit(620e6)).toBeCloseTo(310e6, 6); + }); + + it("caps the estimate at 700 MPa", () => { + expect(estimateEnduranceLimit(2000e6)).toBeCloseTo(700e6, 6); + }); +}); + +describe("fatigue criteria", () => { + it("computes the Soderberg factor", () => { + const factor = criterionSafetyFactor("soderberg", 100e6, 100e6, 310e6, 620e6, 340e6); + expect(factor).toBeCloseTo(1.6215, 3); + }); + + it("computes the Goodman factor", () => { + const factor = criterionSafetyFactor("goodman", 100e6, 100e6, 310e6, 620e6, 340e6); + expect(factor).toBeCloseTo(2.0667, 3); + }); + + it("computes the Gerber factor", () => { + const factor = criterionSafetyFactor("gerber", 100e6, 100e6, 310e6, 620e6, 340e6); + expect(factor).toBeCloseTo(2.5682, 3); + }); + + it("computes the ASME-elliptic factor", () => { + const factor = criterionSafetyFactor("asme_elliptic", 100e6, 100e6, 310e6, 620e6, 340e6); + expect(factor).toBeCloseTo(2.2908, 3); + }); + + it("handles a fully reversed load with zero mean stress", () => { + const factor = criterionSafetyFactor("goodman", 100e6, 0, 310e6, 620e6, 340e6); + expect(factor).toBeCloseTo(3.1, 3); + }); +}); + +describe("fatigue analysis", () => { + it("reports the governing factor across all criteria", () => { + const result = analyzeFatigue(BASE); + expect(result.safetyFactor?.key).toBe("governingFactor"); + expect(result.safetyFactor?.value).toBeCloseTo(1.6215, 3); + expect(result.quantities.find((q) => q.key === "soderbergFactor")?.value).toBeCloseTo(1.6215, 3); + expect(result.quantities.find((q) => q.key === "goodmanFactor")?.value).toBeCloseTo(2.0667, 3); + expect(result.quantities.find((q) => q.key === "gerberFactor")?.value).toBeCloseTo(2.5682, 3); + expect(result.quantities.find((q) => q.key === "asme_ellipticFactor")?.value).toBeCloseTo(2.2908, 3); + }); + + it("reports the first-cycle yield factor", () => { + const result = analyzeFatigue(BASE); + const factor = result.quantities.find((q) => q.key === "firstCycleYieldFactor"); + expect(factor?.value).toBeCloseTo(1.7, 6); + }); + + it("skips yield-based criteria when yield strength is missing", () => { + const result = analyzeFatigue({ meanStress: 100e6, amplitudeStress: 100e6, ultimateStrength: 620e6 }); + expect(result.quantities.find((q) => q.key === "soderbergFactor")).toBeUndefined(); + expect(result.quantities.find((q) => q.key === "asmeEllipticFactor")).toBeUndefined(); + expect(result.safetyFactor?.value).toBeCloseTo(2.0667, 3); + expect(result.warnings.some((w) => w.includes("estimated"))).toBe(true); + }); + + it("evaluates a single requested criterion", () => { + const result = analyzeFatigue({ ...BASE, criterion: "gerber" }); + expect(result.quantities.find((q) => q.key === "goodmanFactor")).toBeUndefined(); + expect(result.quantities.find((q) => q.key === "gerberFactor")).toBeDefined(); + expect(result.safetyFactor?.value).toBeCloseTo(2.5682, 3); + }); + + it("warns on a zero alternating stress", () => { + const result = analyzeFatigue({ ...BASE, amplitudeStress: 0 }); + expect(result.warnings.some((w) => w.includes("static"))).toBe(true); + }); + + it("warns on a compressive mean stress", () => { + const result = analyzeFatigue({ ...BASE, meanStress: -50e6 }); + expect(result.warnings.some((w) => w.includes("compressive"))).toBe(true); + }); + + it("warns when the governing factor is below one", () => { + const result = analyzeFatigue({ ...BASE, meanStress: 500e6, amplitudeStress: 200e6 }); + expect(result.safetyFactor?.value).toBeLessThan(1); + expect(result.warnings.some((w) => w.includes("below 1"))).toBe(true); + }); + + it("throws when a yield-based criterion lacks yield strength", () => { + expect(() => + analyzeFatigue({ meanStress: 100e6, amplitudeStress: 100e6, ultimateStrength: 620e6, criterion: "soderberg" }), + ).toThrow(/yieldStrength/); + }); + + it("throws on invalid input", () => { + expect(() => analyzeFatigue({ ...BASE, meanStress: 0, amplitudeStress: 0 })).toThrow(/nonzero/); + expect(() => analyzeFatigue({ ...BASE, ultimateStrength: 0 })).toThrow(/positive/); + }); +}); diff --git a/tests/pressfit.test.ts b/tests/pressfit.test.ts new file mode 100644 index 0000000..6a3895c --- /dev/null +++ b/tests/pressfit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { analyzePressFit, planeStressVonMises } from "../src/engine/fit.js"; + +const BASE = { + hubOuterDiameter: 0.06, + interfaceDiameter: 0.03, + hubLength: 0.03, + diametralInterference: 20e-6, + hubElasticModulus: 210e9, + shaftElasticModulus: 210e9, + hubYieldStrength: 355e6, +}; + +describe("press fit", () => { + it("computes the contact pressure for a steel hub on a solid shaft", () => { + const result = analyzePressFit(BASE); + const pressure = result.quantities.find((q) => q.key === "contactPressure"); + expect(pressure?.value).toBeCloseTo(52.5e6, 3); + }); + + it("computes the hoop and radial stresses", () => { + const result = analyzePressFit(BASE); + expect(result.quantities.find((q) => q.key === "hubTangentialStress")?.value).toBeCloseTo(87.5e6, 3); + expect(result.quantities.find((q) => q.key === "shaftTangentialStress")?.value).toBeCloseTo(-52.5e6, 3); + expect(result.quantities.find((q) => q.key === "radialStress")?.value).toBeCloseTo(-52.5e6, 3); + }); + + it("computes the von Mises stresses", () => { + const result = analyzePressFit(BASE); + expect(result.quantities.find((q) => q.key === "hubVonMisesStress")?.value).toBeCloseTo(122.5e6, 3); + expect(result.quantities.find((q) => q.key === "shaftVonMisesStress")?.value).toBeCloseTo(52.5e6, 3); + }); + + it("computes the press-in force and torque capacity", () => { + const result = analyzePressFit(BASE); + const pressForce = result.quantities.find((q) => q.key === "pressForce"); + expect(pressForce?.value).toBeCloseTo(22266.038, 2); + expect(result.quantities.find((q) => q.key === "torqueCapacity")?.value).toBeCloseTo(333.99, 2); + }); + + it("reports the hub safety factor", () => { + const result = analyzePressFit(BASE); + expect(result.safetyFactor?.key).toBe("hubSafetyFactor"); + expect(result.safetyFactor?.value).toBeCloseTo(355e6 / 122.5e6, 3); + }); + + it("reports the shaft and axial joint safety factors when provided", () => { + const result = analyzePressFit({ ...BASE, shaftYieldStrength: 355e6, appliedAxialForce: 10000 }); + expect(result.quantities.find((q) => q.key === "shaftSafetyFactor")?.value).toBeCloseTo(355e6 / 52.5e6, 3); + const joint = result.quantities.find((q) => q.key === "axialJointSafetyFactor"); + expect(joint?.value).toBeCloseTo(22266.038 / 10000, 2); + }); + + it("warns when the applied axial force exceeds the press-in force", () => { + const result = analyzePressFit({ ...BASE, appliedAxialForce: 100000 }); + expect(result.warnings.some((w) => w.includes("exceeds"))).toBe(true); + }); + + it("handles a hollow shaft", () => { + const result = analyzePressFit({ ...BASE, shaftInnerDiameter: 0.012 }); + expect(result.quantities.find((q) => q.key === "contactPressure")?.value).toBeGreaterThan(0); + }); + + it("rejects invalid geometry", () => { + expect(() => analyzePressFit({ ...BASE, interfaceDiameter: 0.06 })).toThrow(/exceed/); + expect(() => analyzePressFit({ ...BASE, shaftInnerDiameter: 0.03 })).toThrow(/smaller/); + expect(() => analyzePressFit({ ...BASE, diametralInterference: 0 })).toThrow(/positive/); + expect(() => analyzePressFit({ ...BASE, hubPoissonRatio: 0.6 })).toThrow(/below 0.5/); + }); + + it("computes the plane-stress von Mises stress", () => { + expect(planeStressVonMises(87.5e6, -52.5e6)).toBeCloseTo(122.5e6, 3); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index dd63bf0..393cc19 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -15,6 +15,8 @@ type Handlers = { von_mises: Handler; unit_convert: Handler; material_lookup: Handler; + fatigue_analysis: Handler; + press_fit: Handler; }; let ctx: AppContext; @@ -31,7 +33,7 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all nine tools", () => { + it("registers all eleven tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", @@ -43,6 +45,8 @@ describe("tool registry", () => { "von_mises", "unit_convert", "material_lookup", + "fatigue_analysis", + "press_fit", ].sort(), ); }); @@ -318,3 +322,68 @@ describe("material_lookup tool", () => { expect(response.ok).toBe(false); }); }); + +describe("fatigue_analysis tool", () => { + it("returns the governing fatigue factor with provenance", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 100e6, + amplitudeStress: 100e6, + ultimateStrength: 620e6, + yieldStrength: 340e6, + outputUnits: { enduranceLimit: "MPa" }, + }); + const result = expectOk(response); + expect(result.tool).toBe("fatigue_analysis"); + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.references.length).toBeGreaterThan(0); + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(endurance?.unit).toBe("MPa"); + expect(result.safetyFactor?.value).toBeCloseTo(1.6215, 3); + }); + + it("rejects a zero stress cycle", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 0, + amplitudeStress: 0, + ultimateStrength: 620e6, + }); + expect(response.ok).toBe(false); + }); +}); + +describe("press_fit tool", () => { + it("returns the contact pressure and converted units", () => { + setup(); + const response = handlers.press_fit({ + hubOuterDiameter: 0.06, + interfaceDiameter: 0.03, + hubLength: 0.03, + diametralInterference: 20e-6, + hubElasticModulus: 210e9, + shaftElasticModulus: 210e9, + hubYieldStrength: 355e6, + outputUnits: { contactPressure: "MPa", hubVonMisesStress: "MPa" }, + }); + const result = expectOk(response); + expect(result.tool).toBe("press_fit"); + expect(result.method.id).toBe("press-fit"); + expect(result.quantities.find((q) => q.key === "contactPressure")?.value).toBeCloseTo(52.5, 3); + expect(result.quantities.find((q) => q.key === "contactPressure")?.unit).toBe("MPa"); + expect(result.safetyFactor).toBeDefined(); + }); + + it("rejects a zero interference", () => { + setup(); + const response = handlers.press_fit({ + hubOuterDiameter: 0.06, + interfaceDiameter: 0.03, + hubLength: 0.03, + diametralInterference: 0, + hubElasticModulus: 210e9, + shaftElasticModulus: 210e9, + }); + expect(response.ok).toBe(false); + }); +}); diff --git a/tests/units.test.ts b/tests/units.test.ts index 8839b35..56731fb 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -135,3 +135,59 @@ describe("stiffness units", () => { } }); }); + +describe("viscosity units", () => { + it("converts pascal seconds to centipoise", () => { + const outcome = convertUnit(1, "Pa·s", "cP"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1000, 9); + expect(outcome.category).toBe("dynamic viscosity"); + } + }); + + it("converts poise to centipoise", () => { + const outcome = convertUnit(1, "P", "cP"); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(100, 9); + } + }); + + it("converts kinematic viscosity to centistokes", () => { + const outcome = convertUnit(1, "m2/s", "cSt"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1e6, 3); + expect(outcome.category).toBe("kinematic viscosity"); + } + }); + + it("rejects a dynamic-to-kinematic viscosity conversion", () => { + const outcome = convertUnit(1, "Pa·s", "cSt"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +}); + +describe("thermal conductivity units", () => { + it("converts watts per metre kelvin to BTU units", () => { + const outcome = convertUnit(205, "W/(m·K)", "BTU/(ft·h·degF)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(118.45, 1); + expect(outcome.category).toBe("thermal conductivity"); + expect(outcome.siSymbol).toBe("W/(m·K)"); + } + }); + + it("finds the degC alias for the kelvin delta", () => { + expect(findUnit("W/(m·degC)")?.canonical).toBe("W/(m·K)"); + }); + + it("rejects a thermal-conductivity to velocity conversion", () => { + const outcome = convertUnit(1, "W/(m·K)", "m/s"); + expect(outcome.ok).toBe(false); + }); +});