diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1e0008..2821e15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,6 @@ jobs: - name: Verify the demo runs run: npm run demo + + - name: Verify the package contents + run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index c6c3196..b00605b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ coverage/ *.tsbuildinfo +*.tgz *.sqlite *.sqlite-journal diff --git a/README.md b/README.md index c69aca2..5fe1f06 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Engineer MCP +[![CI](https://github.com/DanielCuevas1208/engineer-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/DanielCuevas1208/engineer-mcp/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/) [![Node](https://img.shields.io/badge/Node-%3E%3D22.13-brightgreen.svg)](package.json) Engineer MCP is a Model Context Protocol server for mechanical-engineering calculations. -It gives coding agents verified answers for beams, bolts, shafts, bearings, stress, sections, and units. +It gives coding agents verified answers for beams, bolts, shafts, bearings, stress, fatigue, sections, and units. Every result shows the formula, the method, and the source. ## What it provides @@ -21,6 +22,7 @@ The release covers these domains: - Shaft torsion and first critical speed. - Bearing rating life to ISO 281. - von Mises equivalent stress. +- Fatigue analysis for cyclic loads. - Cross-section properties. - Dimension-safe unit conversion. - Material property lookup. @@ -50,6 +52,7 @@ Warnings surface when a method uses an approximation. | `shaft_analysis` | Torsion stress, twist, and critical speed. | | `bearing_life` | ISO 281 rating life in revolutions and hours. | | `von_mises` | Equivalent stress and yield safety factor. | +| `fatigue_analysis` | Endurance limit, fatigue safety factor, and estimated life. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | @@ -124,6 +127,22 @@ References: - Mechanics of Materials (Euler-Bernoulli beam theory) ``` +A call to `fatigue_analysis` for a machined S355 part with a 220 MPa amplitude and 60 MPa mean stress: + +```text +Corrected endurance limit 214 MPa +Fatigue safety factor 0.8692 +Equivalent fully reversed amplitude 250.7 MPa +Yield safety factor 1.268 +Estimated life to failure 220564 cycles + +Warning: The fatigue safety factor is below 1. Fatigue failure is predicted. +Method: Stress-life fatigue analysis +Formula: Se = ka kb kc (0.5 Sut), 1/n = Sa/Se + Sm/Sut +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) +``` + A call to `unit_convert` with a torque-to-energy request fails safely: ```text @@ -146,9 +165,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. -- 73 tests across 9 files. +- 100 tests across 10 files. - All tests pass on Node 22 and Node 24. -- The CI workflow runs typecheck, tests, build, and demo. +- The CI workflow runs typecheck, tests, build, demo, and a package check. Run `npm test` to reproduce the results. @@ -159,6 +178,7 @@ Run `npm test` to reproduce the results. - The bolt tables cover coarse metric threads from M5 to M36. - The bearing factors are typical values for deep-groove ball bearings. - The critical speed is a first-mode approximation. +- The fatigue model uses typical Marin factors. Confirm critical designs with test data. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -168,10 +188,18 @@ Check the cited sources for exact values. The server grows in independent releases. Each release stays useful on its own. -- Add fatigue analysis for cyclic loads. -- Add press-fit and spring calculators. -- Add more unit categories. +### Complete + +- Fatigue analysis for cyclic loads. + The `fatigue_analysis` tool estimates the endurance limit and the safety factor. + +### Remaining + +- Add press-fit and interference-fit calculators. +- Add a spring calculator for helical compression springs. +- Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. +- Add a catalog of ISO and DIN standard sections. See [docs/integration.md](docs/integration.md) for the EngineerKit plan. diff --git a/docs/integration.md b/docs/integration.md index 82950af..066a01b 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -38,7 +38,12 @@ This server follows semantic versioning. Breaking a tool signature or the result Engineer MCP grows in independent releases. Each release stays useful on its own. -- Add fatigue analysis for cyclic loads. +### Complete + +- Fatigue analysis for cyclic loads. The `fatigue_analysis` tool estimates the endurance limit and the fatigue safety factor. + +### Remaining + - Add press-fit and interference-fit calculators. - Add a spring calculator for helical compression springs. - Add more unit categories, including viscosity and thermal conductivity. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 389c5a9..7506ae1 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -112,6 +112,40 @@ Inputs: - Cartesian mode uses `sigmaX`, `sigmaY`, `sigmaZ`, `tauXY`, `tauXZ`, `tauYZ`. - `yieldStrength`: enables the safety factor. +## fatigue_analysis + +Compute the fatigue safety factor, corrected endurance limit, and estimated life of a cyclic stress. + +The tool follows the stress-life method of Shigley. It applies the Marin factors to estimate the endurance limit. It uses a mean stress failure line for the safety factor. + +Inputs: + +- `ultimateStrength`: ultimate tensile strength Sut in pascals. +- `yieldStrength`: tensile yield strength in pascals. Enables the yield check and the Soderberg criterion. +- `stressAmplitude`: stress amplitude of the cycle in pascals. +- `meanStress`: mean stress of the cycle in pascals. The default is zero for a fully reversed cycle. +- `enduranceLimit`: corrected endurance limit Se in pascals. When omitted, the tool estimates it from the Marin factors. +- `surfaceFinish`: `ground`, `machined`, `hot_rolled`, or `as_forged`. Sets the surface factor ka. The default is `machined`. +- `loading`: `bending`, `axial`, or `torsion`. Sets the load factor kc. The default is `bending`. +- `sizeFactor`: size factor kb. The default is 1. +- `criterion`: `goodman`, `soderberg`, or `gerber`. Sets the mean stress failure line. The default is `goodman`. +- `targetSafetyFactor`: design safety factor. When set, the tool returns the allowable stress amplitude. + +Example: + +```json +{ + "ultimateStrength": 490000000, + "yieldStrength": 355000000, + "stressAmplitude": 220000000, + "meanStress": 60000000, + "surfaceFinish": "machined", + "loading": "bending" +} +``` + +The tool reports a warning when the peak stress exceeds yield strength. It reports a warning when the fatigue safety factor falls below 1. + ## unit_convert Convert a value between two units. diff --git a/examples/demo.ts b/examples/demo.ts index 6a5507c..0f798b2 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -73,6 +73,7 @@ type ToolHandlers = { shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; }; @@ -86,6 +87,7 @@ const tools: NamedHandler[] = [ ["shaft_analysis", toolHandlers.shaft_analysis], ["bearing_life", toolHandlers.bearing_life], ["von_mises", toolHandlers.von_mises], + ["fatigue_analysis", toolHandlers.fatigue_analysis], ["unit_convert", toolHandlers.unit_convert], ["unit_convert (torque to energy)", toolHandlers.unit_convert], ["material_lookup", toolHandlers.material_lookup], @@ -132,6 +134,15 @@ const inputs: Array> = [ yieldStrength: 355e6, outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, }, + { + ultimateStrength: 490e6, + yieldStrength: 355e6, + stressAmplitude: 220e6, + meanStress: 60e6, + surfaceFinish: "machined", + loading: "bending", + outputUnits: { enduranceLimit: "MPa", equivalentStressAmplitude: "MPa" }, + }, { value: 1000, from: "psi", diff --git a/package-lock.json b/package-lock.json index 5c3628d..6ee1fb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -2585,6 +2585,7 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 197dbf4..49eb29e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,18 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.1.0", + "version": "0.2.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", "author": "EngineerKit", + "repository": { + "type": "git", + "url": "git+https://github.com/DanielCuevas1208/engineer-mcp.git" + }, + "bugs": { + "url": "https://github.com/DanielCuevas1208/engineer-mcp/issues" + }, + "homepage": "https://github.com/DanielCuevas1208/engineer-mcp#readme", "engines": { "node": ">=22.13.0" }, diff --git a/src/engine/fatigue.ts b/src/engine/fatigue.ts new file mode 100644 index 0000000..17c9f37 --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,261 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type SurfaceFinish = "ground" | "machined" | "hot_rolled" | "as_forged"; +export type LoadingMode = "bending" | "axial" | "torsion"; +export type FatigueCriterion = "goodman" | "soderberg" | "gerber"; + +export type FatigueInput = { + ultimateStrength: number; + yieldStrength?: number; + stressAmplitude?: number; + meanStress?: number; + enduranceLimit?: number; + surfaceFinish?: SurfaceFinish; + loading?: LoadingMode; + sizeFactor?: number; + criterion?: FatigueCriterion; + targetSafetyFactor?: number; +}; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Stress-life fatigue analysis", + formula: + "Se = ka kb kc (0.5 Sut). Goodman: 1/n = Sa/Se + Sm/Sut. Soderberg: 1/n = Sa/Se + Sm/Sy. Gerber: 1/n = Sa/Se + (Sm/Sut)^2. Life: N = (Sar/a)^(1/b), a = (0.9 Sut)^2/Se", + notes: + "The endurance limit uses the Marin factors for surface finish, size, and loading on the rotating-beam endurance limit 0.5 x Sut, capped at 700 MPa for steel. The life estimate fits a power-law S-N curve through 0.9 x Sut at 10^3 cycles and the endurance limit at 10^6 cycles. Confirm critical designs with test data.", + referenceIds: ["shigley-2015"], +}; + +const SURFACE_FINISH_FACTORS: Record = { + ground: { a: 1.58, b: -0.085 }, + machined: { a: 4.51, b: -0.265 }, + hot_rolled: { a: 57.7, b: -0.718 }, + as_forged: { a: 272, b: -0.995 }, +}; + +const LOADING_FACTORS: Record = { + bending: 1, + axial: 0.85, + torsion: 0.59, +}; + +const ROTATING_BEAM_CAP_PA = 700e6; +const S_N_FRACTION_F = 0.9; + +export function surfaceFinishFactor(finish: SurfaceFinish, sutPa: number): number { + const sutMpa = sutPa / 1e6; + const { a, b } = SURFACE_FINISH_FACTORS[finish]; + return a * sutMpa ** b; +} + +export function estimateEnduranceLimit(input: { + ultimateStrength: number; + surfaceFinish?: SurfaceFinish; + loading?: LoadingMode; + sizeFactor?: number; +}): { enduranceLimit: number; baseEnduranceLimit: number; ka: number; kb: number; kc: number } { + const baseEnduranceLimit = Math.min(0.5 * input.ultimateStrength, ROTATING_BEAM_CAP_PA); + const ka = surfaceFinishFactor(input.surfaceFinish ?? "machined", input.ultimateStrength); + const kb = input.sizeFactor ?? 1; + const kc = LOADING_FACTORS[input.loading ?? "bending"]; + return { + enduranceLimit: ka * kb * kc * baseEnduranceLimit, + baseEnduranceLimit, + ka, + kb, + kc, + }; +} + +export function fatigueSafetyFactor( + amplitude: number, + mean: number, + enduranceLimit: number, + criterion: FatigueCriterion, + ultimateStrength: number, + yieldStrength?: number, +): number { + switch (criterion) { + case "soderberg": + if (!yieldStrength) { + throw new Error("The Soderberg criterion requires yieldStrength."); + } + return 1 / (amplitude / enduranceLimit + mean / yieldStrength); + case "gerber": + return 1 / (amplitude / enduranceLimit + (mean / ultimateStrength) ** 2); + case "goodman": + default: + return 1 / (amplitude / enduranceLimit + mean / ultimateStrength); + } +} + +export function equivalentStressAmplitude(amplitude: number, mean: number, ultimateStrength: number): number { + return amplitude / (1 - mean / ultimateStrength); +} + +export function estimateFatigueLife(equivalentAmplitude: number, enduranceLimit: number, ultimateStrength: number): number { + const intercept = (S_N_FRACTION_F * ultimateStrength) ** 2 / enduranceLimit; + const exponent = -(1 / 3) * Math.log10((S_N_FRACTION_F * ultimateStrength) / enduranceLimit); + return (equivalentAmplitude / intercept) ** (1 / exponent); +} + +export function analyzeFatigue(input: FatigueInput): Computation { + if (!(input.ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + + const amplitude = input.stressAmplitude ?? 0; + let mean = input.meanStress ?? 0; + const warnings: string[] = []; + + if (amplitude <= 0 && mean <= 0) { + throw new Error("Provide a positive stressAmplitude or meanStress."); + } + if (mean < 0) { + warnings.push("Compressive mean stress is treated as zero. This follows the conservative Shigley convention."); + mean = 0; + } + + const criterion = input.criterion ?? "goodman"; + if (criterion === "soderberg" && !input.yieldStrength) { + throw new Error("The Soderberg criterion requires yieldStrength."); + } + + const estimation = + input.enduranceLimit !== undefined + ? undefined + : estimateEnduranceLimit({ + ultimateStrength: input.ultimateStrength, + surfaceFinish: input.surfaceFinish, + loading: input.loading, + sizeFactor: input.sizeFactor, + }); + const enduranceLimit = input.enduranceLimit ?? estimation?.enduranceLimit ?? 0; + + const safetyFactor = fatigueSafetyFactor( + amplitude, + mean, + enduranceLimit, + criterion, + input.ultimateStrength, + input.yieldStrength, + ); + + const peakStress = amplitude + mean; + if (input.yieldStrength && peakStress > input.yieldStrength) { + warnings.push("The peak stress exceeds the yield strength. Yielding occurs before fatigue failure."); + } + if (safetyFactor < 1) { + warnings.push("The fatigue safety factor is below 1. Fatigue failure is predicted."); + } + + const quantities: Quantity[] = [ + { + key: "enduranceLimit", + label: "Corrected endurance limit", + value: enduranceLimit, + unit: "Pa", + description: + input.enduranceLimit !== undefined + ? "The corrected endurance limit supplied to the analysis." + : "Corrected endurance limit from the Marin surface, size, and loading factors.", + }, + { + key: "fatigueSafetyFactor", + label: "Fatigue safety factor", + value: safetyFactor, + unit: "", + description: `Safety factor on the ${criterion} mean stress failure line.`, + }, + ]; + + const equivalent = equivalentStressAmplitude(amplitude, mean, input.ultimateStrength); + if (mean > 0) { + quantities.push({ + key: "equivalentStressAmplitude", + label: "Equivalent fully reversed amplitude", + value: equivalent, + unit: "Pa", + description: "Fully reversed stress amplitude with the same fatigue damage as the real cycle.", + }); + } + + if (input.yieldStrength) { + quantities.push({ + key: "yieldSafetyFactor", + label: "Yield safety factor", + value: input.yieldStrength / peakStress, + unit: "", + description: "Yield strength divided by the peak stress of the cycle.", + }); + } + + const intercept = (S_N_FRACTION_F * input.ultimateStrength) ** 2 / enduranceLimit; + if (equivalent > enduranceLimit) { + if (intercept > enduranceLimit) { + const life = estimateFatigueLife(equivalent, enduranceLimit, input.ultimateStrength); + quantities.push({ + key: "estimatedLifeCycles", + label: "Estimated life to failure", + value: life, + unit: "cycles", + description: "Predicted number of cycles to failure from the fitted S-N curve.", + }); + if (life < 1e3) { + warnings.push("The predicted life is below 10^3 cycles. This lies outside the fitted S-N range."); + } else { + warnings.push("The stress exceeds the endurance limit. The life estimate uses a fitted S-N curve; confirm with test data."); + } + } else { + warnings.push("The endurance limit exceeds the fitted S-N intercept. The life cannot be estimated from this model."); + } + } + + if (input.targetSafetyFactor !== undefined) { + const target = input.targetSafetyFactor; + let allowable: number; + if (criterion === "soderberg") { + allowable = enduranceLimit * (1 / target - mean / (input.yieldStrength as number)); + } else if (criterion === "gerber") { + allowable = enduranceLimit * (1 / target - (mean / input.ultimateStrength) ** 2); + } else { + allowable = enduranceLimit * (1 / target - mean / input.ultimateStrength); + } + quantities.push({ + key: "allowableAmplitude", + label: "Allowable stress amplitude", + value: allowable, + unit: "Pa", + description: `Maximum stress amplitude for a design safety factor of ${target} on the ${criterion} line.`, + }); + if (allowable < 0) { + warnings.push("The mean stress alone exceeds the design limit. No positive amplitude meets the target safety factor."); + } + } + + return { + method: FATIGUE_METHOD, + inputs: { + ultimateStrength: input.ultimateStrength, + yieldStrength: input.yieldStrength, + stressAmplitude: amplitude, + meanStress: mean, + criterion, + peakStress, + ...(estimation + ? { + surfaceFinish: input.surfaceFinish ?? "machined", + loading: input.loading ?? "bending", + baseEnduranceLimit: estimation.baseEnduranceLimit, + surfaceFactor: estimation.ka, + sizeFactor: estimation.kb, + loadingFactor: estimation.kc, + } + : { enduranceLimit: input.enduranceLimit }), + }, + quantities, + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index acf9449..cd54f10 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -4,3 +4,16 @@ export { analyzeBolt, BOLT_METHOD, tensileStressArea, type BoltGradeData, type B export { computeSection, SECTION_METHOD, type SectionDef, type SectionProperties } from "./sections.js"; export { analyzeShaft, SHAFT_METHOD, type ShaftInput } from "./shaft.js"; export { vonMises, VON_MISES_METHOD, type StressInput, type StressMode } from "./stress.js"; +export { + analyzeFatigue, + equivalentStressAmplitude, + estimateEnduranceLimit, + estimateFatigueLife, + fatigueSafetyFactor, + surfaceFinishFactor, + FATIGUE_METHOD, + type FatigueCriterion, + type FatigueInput, + type LoadingMode, + type SurfaceFinish, +} from "./fatigue.js"; diff --git a/src/handlers.ts b/src/handlers.ts index fc0df7a..2422c7f 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -3,10 +3,14 @@ import { analyzeBeam, analyzeBearing, analyzeBolt, + analyzeFatigue, analyzeShaft, computeSection, vonMises, + type FatigueCriterion, + type LoadingMode, type SectionDef, + type SurfaceFinish, } from "./engine/index.js"; import type { Computation, MethodRecord, Quantity, ReferenceRecord, ToolFailure, ToolResponse, ToolResult } from "./types.js"; import type { UnitOutcome } from "./units/index.js"; @@ -353,6 +357,28 @@ function stressHandler(ctx: AppContext): Handler { }; } +function fatigueHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzeFatigue({ + ultimateStrength: input.ultimateStrength as number, + yieldStrength: input.yieldStrength as number | undefined, + stressAmplitude: input.stressAmplitude as number | undefined, + meanStress: input.meanStress as number | undefined, + enduranceLimit: input.enduranceLimit as number | undefined, + surfaceFinish: input.surfaceFinish as SurfaceFinish | undefined, + loading: input.loading as LoadingMode | undefined, + sizeFactor: input.sizeFactor as number | undefined, + criterion: input.criterion as FatigueCriterion | undefined, + targetSafetyFactor: input.targetSafetyFactor as number | 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 unitConvertHandler(ctx: AppContext): Handler { return (input) => { const value = input.value as number; @@ -423,6 +449,7 @@ export function createHandlers(ctx: AppContext): Record { shaft_analysis: shaftHandler(ctx), bearing_life: bearingHandler(ctx), von_mises: stressHandler(ctx), + fatigue_analysis: fatigueHandler(ctx), unit_convert: unitConvertHandler(ctx), material_lookup: materialHandler(ctx), }; diff --git a/src/schemas.ts b/src/schemas.ts index 1f0dc5e..2ce5b1b 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -107,6 +107,44 @@ export const unitConvertSchema = z.object({ to: z.string().describe("Target unit symbol. Examples: psi, m, N, degF."), }); +export const fatigueSchema = z.object({ + ultimateStrength: z.number().positive().describe("Ultimate tensile strength Sut in pascals."), + yieldStrength: z + .number() + .positive() + .optional() + .describe("Tensile yield strength in pascals. Enables the yield check and the Soderberg criterion."), + stressAmplitude: z + .number() + .min(0) + .describe("Stress amplitude of the cycle in pascals. Zero means a purely static mean stress."), + meanStress: z + .number() + .optional() + .describe("Mean stress of the cycle in pascals. Defaults to zero for a fully reversed cycle."), + enduranceLimit: z + .number() + .positive() + .optional() + .describe("Corrected endurance limit Se in pascals. When omitted, the tool estimates it with the Marin factors."), + surfaceFinish: z + .enum(["ground", "machined", "hot_rolled", "as_forged"]) + .optional() + .describe("Surface finish. Sets the surface factor ka. Defaults to machined."), + loading: z.enum(["bending", "axial", "torsion"]).optional().describe("Loading mode. Sets the load factor kc. Defaults to bending."), + sizeFactor: z.number().positive().optional().describe("Size factor kb. Defaults to 1."), + criterion: z + .enum(["goodman", "soderberg", "gerber"]) + .optional() + .describe("Mean stress failure criterion. Defaults to goodman."), + targetSafetyFactor: z + .number() + .positive() + .optional() + .describe("Design safety factor. When set, the tool returns the allowable stress amplitude."), + outputUnits, +}); + export const materialSchema = z.object({ query: z.string().min(1).describe("Material name or category to search. Matches are case-insensitive."), limit: z.number().int().min(1).max(50).optional().describe("Maximum number of rows to return. Defaults to 10."), @@ -120,3 +158,4 @@ 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; diff --git a/src/server.ts b/src/server.ts index 2acf06e..9f0e54e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { beamSchema, bearingSchema, boltSchema, + fatigueSchema, materialSchema, sectionPropsSchema, shaftSchema, @@ -46,6 +47,11 @@ const TOOL_SCHEMAS: Record = { description: "von Mises equivalent stress, maximum shear stress, and yield safety factor for a stress state.", schema: stressSchema, }, + fatigue_analysis: { + description: + "Corrected endurance limit, fatigue safety factor, and estimated life for a cyclic stress. Uses the Marin factors and the Goodman, Soderberg, or Gerber mean stress criterion.", + schema: fatigueSchema, + }, unit_convert: { description: "Convert a value between compatible units. Rejects mismatched dimensions and quantity categories.", schema: unitConvertSchema, diff --git a/src/version.ts b/src/version.ts index 9995d49..9e86f0c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.1.0"; +export const VERSION = "0.2.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..b4a685d --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFatigue, + equivalentStressAmplitude, + estimateEnduranceLimit, + estimateFatigueLife, + fatigueSafetyFactor, + surfaceFinishFactor, +} from "../src/engine/fatigue.js"; + +describe("fatigue endurance limit", () => { + it("estimates the machined bending endurance limit for steel", () => { + const result = estimateEnduranceLimit({ ultimateStrength: 490e6 }); + expect(result.baseEnduranceLimit).toBeCloseTo(245e6, 6); + expect(result.ka).toBeCloseTo(4.51 * 490 ** -0.265, 6); + expect(result.kc).toBe(1); + expect(result.enduranceLimit).toBeCloseTo(result.ka * result.kc * 245e6, 3); + }); + + it("caps the rotating-beam endurance limit at 700 MPa", () => { + const result = estimateEnduranceLimit({ ultimateStrength: 2000e6, surfaceFinish: "ground" }); + expect(result.baseEnduranceLimit).toBe(700e6); + }); + + it("applies the axial and torsion load factors", () => { + expect(estimateEnduranceLimit({ ultimateStrength: 490e6, loading: "axial" }).kc).toBeCloseTo(0.85, 9); + expect(estimateEnduranceLimit({ ultimateStrength: 490e6, loading: "torsion" }).kc).toBeCloseTo(0.59, 9); + }); + + it("applies the size factor", () => { + const result = estimateEnduranceLimit({ ultimateStrength: 490e6, sizeFactor: 0.8 }); + expect(result.enduranceLimit).toBeCloseTo(0.8 * estimateEnduranceLimit({ ultimateStrength: 490e6 }).enduranceLimit, 6); + }); + + it("computes the surface finish factor from the Shigley table", () => { + expect(surfaceFinishFactor("ground", 490e6)).toBeCloseTo(1.58 * 490 ** -0.085, 6); + expect(surfaceFinishFactor("hot_rolled", 490e6)).toBeCloseTo(57.7 * 490 ** -0.718, 6); + }); +}); + +describe("fatigue safety factor", () => { + it("gives Se/Sa for a fully reversed cycle", () => { + expect(fatigueSafetyFactor(100e6, 0, 200e6, "goodman", 400e6)).toBeCloseTo(2, 9); + }); + + it("applies the Modified Goodman mean stress correction", () => { + const n = fatigueSafetyFactor(100e6, 100e6, 200e6, "goodman", 400e6); + expect(n).toBeCloseTo(1 / (100e6 / 200e6 + 100e6 / 400e6), 9); + expect(n).toBeCloseTo(4 / 3, 9); + }); + + it("applies the Soderberg criterion against yield strength", () => { + const n = fatigueSafetyFactor(100e6, 100e6, 200e6, "soderberg", 400e6, 250e6); + expect(n).toBeCloseTo(1 / (0.5 + 0.4), 9); + }); + + it("requires yield strength for the Soderberg criterion", () => { + expect(() => fatigueSafetyFactor(100e6, 100e6, 200e6, "soderberg", 400e6)).toThrow(); + }); + + it("applies the Gerber criterion", () => { + const n = fatigueSafetyFactor(100e6, 100e6, 200e6, "gerber", 400e6); + expect(n).toBeCloseTo(1 / (0.5 + 0.25 ** 2), 9); + }); +}); + +describe("fatigue life", () => { + it("converts a fluctuating cycle to a fully reversed amplitude", () => { + expect(equivalentStressAmplitude(100e6, 100e6, 400e6)).toBeCloseTo(100e6 / (1 - 0.25), 9); + }); + + it("estimates a finite life above the endurance limit", () => { + const life = estimateFatigueLife(240e6, 214.1e6, 490e6); + expect(life).toBeGreaterThan(0); + expect(life).toBeLessThan(1e6); + }); + + it("predicts failure inside the fitted S-N range", () => { + const life = estimateFatigueLife(260e6, 214.1e6, 490e6); + expect(life).toBeGreaterThan(1e3); + expect(life).toBeLessThan(1e6); + }); +}); + +describe("analyzeFatigue", () => { + it("computes a fully reversed safety factor", () => { + const result = analyzeFatigue({ ultimateStrength: 400e6, stressAmplitude: 100e6, enduranceLimit: 200e6 }); + const safety = result.quantities.find((q) => q.key === "fatigueSafetyFactor"); + expect(safety?.value).toBeCloseTo(2, 9); + expect(result.quantities.find((q) => q.key === "estimatedLifeCycles")).toBeUndefined(); + }); + + it("estimates the endurance limit from the Marin factors", () => { + const result = analyzeFatigue({ + ultimateStrength: 490e6, + stressAmplitude: 120e6, + meanStress: 40e6, + surfaceFinish: "machined", + loading: "bending", + }); + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(endurance?.value).toBeGreaterThan(0); + expect(result.inputs.surfaceFactor).toBeCloseTo(4.51 * 490 ** -0.265, 6); + }); + + it("uses a supplied endurance limit without estimation", () => { + const result = analyzeFatigue({ ultimateStrength: 400e6, stressAmplitude: 100e6, enduranceLimit: 250e6 }); + expect(result.quantities.find((q) => q.key === "enduranceLimit")?.value).toBe(250e6); + expect(result.inputs).not.toHaveProperty("surfaceFactor"); + }); + + it("warns when yielding occurs before fatigue failure", () => { + const result = analyzeFatigue({ + ultimateStrength: 400e6, + yieldStrength: 180e6, + stressAmplitude: 120e6, + meanStress: 80e6, + enduranceLimit: 200e6, + }); + expect(result.warnings.some((w) => w.includes("yield strength"))).toBe(true); + }); + + it("warns when fatigue failure is predicted", () => { + const result = analyzeFatigue({ ultimateStrength: 400e6, stressAmplitude: 300e6, enduranceLimit: 200e6 }); + expect(result.warnings.some((w) => w.includes("below 1"))).toBe(true); + }); + + it("treats a compressive mean stress as zero with a warning", () => { + const result = analyzeFatigue({ ultimateStrength: 400e6, stressAmplitude: 100e6, meanStress: -50e6, enduranceLimit: 200e6 }); + expect(result.inputs.meanStress).toBe(0); + expect(result.warnings.some((w) => w.includes("Compressive"))).toBe(true); + expect(result.quantities.find((q) => q.key === "fatigueSafetyFactor")?.value).toBeCloseTo(2, 9); + }); + + it("estimates life in the finite-life region", () => { + const result = analyzeFatigue({ + ultimateStrength: 490e6, + stressAmplitude: 240e6, + surfaceFinish: "machined", + }); + const life = result.quantities.find((q) => q.key === "estimatedLifeCycles"); + expect(life).toBeDefined(); + expect(life?.value).toBeGreaterThan(0); + expect(result.warnings.some((w) => w.includes("exceeds the endurance limit"))).toBe(true); + }); + + it("reports an allowable amplitude for a target safety factor", () => { + const result = analyzeFatigue({ + ultimateStrength: 400e6, + stressAmplitude: 60e6, + enduranceLimit: 200e6, + targetSafetyFactor: 1.5, + }); + const allowable = result.quantities.find((q) => q.key === "allowableAmplitude"); + expect(allowable?.value).toBeCloseTo(200e6 / 1.5, 6); + }); + + it("rejects a missing stress input", () => { + expect(() => analyzeFatigue({ ultimateStrength: 400e6 })).toThrow(); + }); + + it("rejects a non-positive ultimate strength", () => { + expect(() => analyzeFatigue({ ultimateStrength: 0, stressAmplitude: 100e6, enduranceLimit: 200e6 })).toThrow(); + }); + + it("carries the Shigley provenance", () => { + const result = analyzeFatigue({ ultimateStrength: 400e6, stressAmplitude: 100e6, enduranceLimit: 200e6 }); + expect(result.referenceIds).toContain("shigley-2015"); + expect(result.method.id).toBe("fatigue-analysis"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index d52dce5..9799060 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -12,6 +12,7 @@ type Handlers = { shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; }; @@ -30,7 +31,7 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all eight tools", () => { + it("registers all nine tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", @@ -39,6 +40,7 @@ describe("tool registry", () => { "shaft_analysis", "bearing_life", "von_mises", + "fatigue_analysis", "unit_convert", "material_lookup", ].sort(), @@ -216,6 +218,54 @@ describe("von_mises tool", () => { }); }); +describe("fatigue_analysis tool", () => { + it("computes the endurance limit and safety factor with converted units", () => { + setup(); + const response = handlers.fatigue_analysis({ + ultimateStrength: 490e6, + yieldStrength: 355e6, + stressAmplitude: 120e6, + meanStress: 40e6, + surfaceFinish: "machined", + loading: "bending", + outputUnits: { enduranceLimit: "MPa", equivalentStressAmplitude: "MPa" }, + }); + const result = expectOk(response); + + expect(result.tool).toBe("fatigue_analysis"); + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.references.length).toBeGreaterThan(0); + expect(result.references[0]).toHaveProperty("title"); + + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(endurance?.unit).toBe("MPa"); + expect(endurance?.value).toBeGreaterThan(0); + expect(result.quantities.find((q) => q.key === "fatigueSafetyFactor")?.value).toBeGreaterThan(0); + }); + + it("uses a supplied endurance limit directly", () => { + setup(); + const response = handlers.fatigue_analysis({ + ultimateStrength: 400e6, + stressAmplitude: 100e6, + enduranceLimit: 200e6, + outputUnits: { enduranceLimit: "MPa" }, + }); + const result = expectOk(response); + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(endurance?.value).toBeCloseTo(200, 9); + }); + + it("rejects missing stress inputs", () => { + setup(); + const response = handlers.fatigue_analysis({ ultimateStrength: 400e6 }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("stressAmplitude"); + } + }); +}); + describe("unit_convert tool", () => { it("converts and reports the factor", () => { setup();