diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821e15..de8c8b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,14 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: verify: diff --git a/README.md b/README.md index e94b177..ba765cd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![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, springs, shafts, bearings, stress, sections, and units. +It gives coding agents verified answers for beams, bolts, springs, shafts, bearings, fatigue, stress, sections, and units. Every result shows the formula, the method, and the source. ## What it provides @@ -23,6 +23,7 @@ The release covers these domains: - Shaft torsion and first critical speed. - Bearing rating life to ISO 281. - von Mises equivalent stress. +- Fatigue life and safety factor for cyclic stress. - Cross-section properties. - Dimension-safe unit conversion. - Material property lookup. @@ -38,7 +39,7 @@ The unit layer knows the dimension of every unit. It rejects a conversion between incompatible quantities. For example, it rejects a torque-to-energy conversion. -Safety factors appear only when you provide a yield strength. +Yield-based safety factors appear only when you provide a yield strength. The tool never hides an assumption. Warnings surface when a method uses an approximation. @@ -53,6 +54,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` | Fatigue safety factor, endurance limit, and predicted life for cyclic loads. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | @@ -154,6 +156,24 @@ Error: Category mismatch: N·m is torque, J is energy. Use a unit of the same quantity. ``` +A call to `fatigue_analysis` for an S355 steel part with 180 MPa alternating stress and 40 MPa mean stress: + +```text +Base endurance limit 245 MPa +Endurance correction factor 0.765 +Corrected endurance limit 187.4 MPa +Fatigue strength at 10^3 cycles 441 MPa +Equivalent fully reversed amplitude 196 MPa +Predicted fatigue life 696874 cycles +Fatigue safety factor 0.9597 + +Method: Fatigue analysis for fluctuating stress +Formula: Se' = 0.5 Sut, Se = ka kb kc kd ke kf Se', Goodman: n = 1/(sa/Se + sm/Sut) +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) + - Machinery's Handbook (Thirty-first edition) +``` + ## Development | Command | Purpose | @@ -169,7 +189,7 @@ 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. +- 130 tests across 11 files. - All tests pass on Node 22 and Node 24. - The CI workflow runs typecheck, tests, build, demo, and a package check. @@ -183,7 +203,10 @@ Run `npm test` to reproduce the results. - The bearing factors are typical values for deep-groove ball bearings. - 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. + Use `fatigue_analysis` for cyclic loads. +- The fatigue analysis uses the stress-life method. + It assumes a fully reversed load corrected for the mean stress. + It estimates the endurance limit for steel unless you supply a tested value. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -195,12 +218,19 @@ Each release stays useful on its own. ### Complete +- Beam bending stress, deflection, and safety factor. +- Cross-section properties for five common shapes. +- ISO 898 bolt tensile design. - Helical compression spring design. - The `spring_design` tool reports the spring rate, the shear stress, and the safety factor. +- Shaft torsion and first lateral critical speed. +- ISO 281 bearing rating life. +- von Mises equivalent stress. +- Fatigue analysis for cyclic loads. + The `fatigue_analysis` tool reports the corrected endurance limit, the safety factor by four criteria, and the predicted life. +- Dimension-safe unit conversion and material lookup. ### Remaining -- Add fatigue analysis for cyclic loads. - Add press-fit and interference-fit calculators. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. diff --git a/data/references.json b/data/references.json index b85dfc0..7828e40 100644 --- a/data/references.json +++ b/data/references.json @@ -3,7 +3,7 @@ "title": "Shigley's Mechanical Engineering Design", "source": "McGraw-Hill Education", "edition": "Tenth edition, 2015", - "section": "Chapters 3, 5, 8, 11, and 12", + "section": "Chapters 3, 5, 6, 8, 11, and 12", "note": "Standard reference for strength, fatigue, fasteners, and bearings." }, "roark-2011": { diff --git a/docs/integration.md b/docs/integration.md index 042b7d4..f86bbdd 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -40,11 +40,18 @@ Engineer MCP grows in independent releases. Each release stays useful on its own ### Complete +- Beam bending stress, deflection, and safety factor. The `beam_bending` tool covers point and uniform loads on two supports. +- Cross-section properties. The `section_properties` tool covers five common shapes. +- ISO 898 bolt tensile design. The `bolt_strength` tool reports stress area, preload, and capacity. - Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. +- Shaft torsion and critical speed. The `shaft_analysis` tool covers solid and hollow shafts. +- ISO 281 bearing rating life. The `bearing_life` tool reports life in revolutions and hours. +- von Mises equivalent stress. The `von_mises` tool covers principal and cartesian stress states. +- Fatigue analysis for cyclic loads. The `fatigue_analysis` tool reports the corrected endurance limit, the safety factor by four criteria, and the predicted life. +- Dimension-safe unit conversion and material lookup. ### Remaining -- Add fatigue analysis for cyclic loads. - Add press-fit and interference-fit calculators. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport in addition to stdio. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d38b8c9..1852128 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -146,6 +146,27 @@ 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 predicted life of a fluctuating stress state. + +The tool defaults to the modified Goodman criterion. It also supports Gerber, Soderberg, and ASME-elliptic criteria. + +The endurance limit defaults to 0.5 x ultimate strength for steel. Supply a tested value when you have one. + +Inputs: + +- `material`: material name from the database. Provides the ultimate and yield strength. +- `ultimateStrength`: ultimate tensile strength in pascals. Required when no material is set. +- `yieldStrength`: tensile yield strength in pascals. Required for Soderberg and ASME-elliptic. +- `enduranceLimit`: tested endurance limit in pascals. Defaults to the steel estimate. +- `stressAmplitude`: alternating stress in pascals. +- `meanStress`: mean stress in pascals. The default is zero. +- `surfaceFactor`, `sizeFactor`, `loadFactor`, `temperatureFactor`, `reliabilityFactor`, `miscellaneousFactor`: Marin correction factors. Each defaults to 1. +- `criterion`: `goodman`, `gerber`, `soderberg`, or `asme_elliptic`. The default is `goodman`. + +The tool warns when it estimates the endurance limit. It warns when the predicted life falls below 10^3 cycles. + ## unit_convert Convert a value between two units. diff --git a/examples/demo.ts b/examples/demo.ts index 2013998..4f650cb 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -74,6 +74,7 @@ type ToolHandlers = { shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; }; @@ -88,6 +89,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], @@ -145,6 +147,15 @@ const inputs: Array> = [ yieldStrength: 355e6, outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, }, + { + material: "Structural steel S355", + stressAmplitude: 180e6, + meanStress: 40e6, + surfaceFactor: 0.85, + sizeFactor: 0.9, + criterion: "goodman", + outputUnits: { correctedEnduranceLimit: "MPa", equivalentAmplitude: "MPa" }, + }, { value: 1000, from: "psi", diff --git a/package-lock.json b/package-lock.json index 5c98405..c52b331 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", @@ -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 49eb29e..2a8612f 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", diff --git a/src/engine/fatigue.ts b/src/engine/fatigue.ts new file mode 100644 index 0000000..6f539b4 --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,270 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type FatigueCriterion = "goodman" | "gerber" | "soderberg" | "asme_elliptic"; + +export type FatigueInput = { + ultimateStrength: number; + yieldStrength?: number; + enduranceLimit?: number; + stressAmplitude: number; + meanStress?: number; + surfaceFactor?: number; + sizeFactor?: number; + loadFactor?: number; + temperatureFactor?: number; + reliabilityFactor?: number; + miscellaneousFactor?: number; + criterion?: FatigueCriterion; +}; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Fatigue analysis for fluctuating stress", + formula: + "Se' = 0.5 Sut, Se = ka kb kc kd ke kf Se', Goodman: n = 1/(sa/Se + sm/Sut), S-N: Sf = a N^b with a = (0.9 Sut)^2/Se and b = -log10(0.9 Sut/Se)/3", + notes: + "The base endurance limit defaults to 0.5 x ultimate strength for steel. The corrected endurance limit applies the Marin factors. The safety factor follows the chosen mean-stress criterion. Finite life uses the S-N curve for the equivalent fully reversed amplitude.", + referenceIds: ["shigley-2015", "machinery-handbook"], +}; + +const LOW_CYCLE_LIMIT = 1e3; +const INFINITE_LIFE_LIMIT = 1e6; + +const CRITERION_LABEL: Record = { + goodman: "modified Goodman", + gerber: "Gerber", + soderberg: "Soderberg", + asme_elliptic: "ASME-elliptic", +}; + +export function enduranceLimitEstimate(ultimateStrength: number): number { + return Math.min(0.5 * ultimateStrength, 700e6); +} + +export function fatigueSafetyFactor( + criterion: FatigueCriterion, + stressAmplitude: number, + meanStress: number, + enduranceLimit: number, + ultimateStrength: number, + yieldStrength?: number, +): number { + switch (criterion) { + case "goodman": + return 1 / (stressAmplitude / enduranceLimit + meanStress / ultimateStrength); + case "gerber": { + const coefficient = (meanStress / ultimateStrength) ** 2; + if (coefficient === 0) { + return enduranceLimit / stressAmplitude; + } + const linear = stressAmplitude / enduranceLimit; + return (-linear + Math.sqrt(linear ** 2 + 4 * coefficient)) / (2 * coefficient); + } + case "soderberg": + if (!yieldStrength) { + throw new Error("The soderberg criterion requires yieldStrength."); + } + return 1 / (stressAmplitude / enduranceLimit + meanStress / yieldStrength); + case "asme_elliptic": + if (!yieldStrength) { + throw new Error("The asme_elliptic criterion requires yieldStrength."); + } + return 1 / Math.sqrt((stressAmplitude / enduranceLimit) ** 2 + (meanStress / yieldStrength) ** 2); + } +} + +export function equivalentAmplitude( + criterion: FatigueCriterion, + stressAmplitude: number, + meanStress: number, + ultimateStrength: number, + yieldStrength?: number, +): number { + switch (criterion) { + case "goodman": + return stressAmplitude / (1 - meanStress / ultimateStrength); + case "gerber": + return stressAmplitude / (1 - (meanStress / ultimateStrength) ** 2); + case "soderberg": + if (!yieldStrength) { + throw new Error("The soderberg criterion requires yieldStrength."); + } + return stressAmplitude / (1 - meanStress / yieldStrength); + case "asme_elliptic": + if (!yieldStrength) { + throw new Error("The asme_elliptic criterion requires yieldStrength."); + } + return stressAmplitude / Math.sqrt(1 - (meanStress / yieldStrength) ** 2); + } +} + +function snLife(stressAmplitude: number, enduranceLimit: number, ultimateStrength: number): number { + const strengthCoefficient = (0.9 * ultimateStrength) ** 2 / enduranceLimit; + const exponent = -Math.log10((0.9 * ultimateStrength) / enduranceLimit) / 3; + return (stressAmplitude / strengthCoefficient) ** (1 / exponent); +} + +export function finiteLifeCycles(stressAmplitude: number, enduranceLimit: number, ultimateStrength: number): number { + if (stressAmplitude <= enduranceLimit) { + return INFINITE_LIFE_LIMIT; + } + const life = snLife(stressAmplitude, enduranceLimit, ultimateStrength); + return Math.min(Math.max(life, LOW_CYCLE_LIMIT), INFINITE_LIFE_LIMIT); +} + +export function analyzeFatigue(input: FatigueInput): Computation { + if (!(input.ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + if (!(input.stressAmplitude >= 0)) { + throw new Error("stressAmplitude must be zero or positive."); + } + const meanStress = input.meanStress ?? 0; + if (input.stressAmplitude === 0 && !(meanStress > 0)) { + throw new Error("Provide a non-zero stressAmplitude or a positive meanStress."); + } + + const criterion = input.criterion ?? "goodman"; + if (criterion === "soderberg" || criterion === "asme_elliptic") { + if (!input.yieldStrength) { + throw new Error(`The ${CRITERION_LABEL[criterion]} criterion requires yieldStrength.`); + } + if (meanStress >= input.yieldStrength) { + throw new Error(`meanStress must stay below the yield strength for the ${CRITERION_LABEL[criterion]} criterion.`); + } + } else if (meanStress >= input.ultimateStrength) { + throw new Error("meanStress must stay below the ultimate strength for the fatigue criteria."); + } + + const enduranceLimit = input.enduranceLimit ?? enduranceLimitEstimate(input.ultimateStrength); + if (!(enduranceLimit > 0)) { + throw new Error("enduranceLimit must be positive."); + } + if (enduranceLimit >= 0.9 * input.ultimateStrength) { + throw new Error("enduranceLimit must stay below 0.9 times the ultimate strength."); + } + + const factors = { + surfaceFactor: input.surfaceFactor ?? 1, + sizeFactor: input.sizeFactor ?? 1, + loadFactor: input.loadFactor ?? 1, + temperatureFactor: input.temperatureFactor ?? 1, + reliabilityFactor: input.reliabilityFactor ?? 1, + miscellaneousFactor: input.miscellaneousFactor ?? 1, + }; + for (const [name, value] of Object.entries(factors)) { + if (!(value > 0)) { + throw new Error(`${name} must be positive.`); + } + } + const correctionFactor = Object.values(factors).reduce((product, value) => product * value, 1); + const correctedEnduranceLimit = enduranceLimit * correctionFactor; + + const safetyFactor = fatigueSafetyFactor( + criterion, + input.stressAmplitude, + meanStress, + correctedEnduranceLimit, + input.ultimateStrength, + input.yieldStrength, + ); + + const equivalent = equivalentAmplitude( + criterion, + input.stressAmplitude, + meanStress, + input.ultimateStrength, + input.yieldStrength, + ); + const rawLife = + equivalent <= correctedEnduranceLimit + ? INFINITE_LIFE_LIMIT + : snLife(equivalent, correctedEnduranceLimit, input.ultimateStrength); + const life = Math.min(Math.max(rawLife, LOW_CYCLE_LIMIT), INFINITE_LIFE_LIMIT); + + const warnings: string[] = []; + if (input.enduranceLimit === undefined) { + warnings.push( + "The endurance limit is estimated as 0.5 x ultimate strength for steel. Use a tested value for the final design.", + ); + } + if (rawLife < LOW_CYCLE_LIMIT) { + warnings.push("The predicted life falls below 10^3 cycles. Check static yield before relying on the S-N estimate."); + } + + const quantities: Quantity[] = [ + { + key: "baseEnduranceLimit", + label: "Base endurance limit", + value: enduranceLimit, + unit: "Pa", + description: + input.enduranceLimit === undefined + ? "Estimated as 0.5 x ultimate strength. The steel rule applies before any correction factors." + : "Endurance limit supplied for the material condition.", + }, + { + key: "enduranceCorrectionFactor", + label: "Endurance correction factor", + value: correctionFactor, + unit: "", + description: "Product of the surface, size, load, temperature, reliability, and miscellaneous factors.", + }, + { + key: "correctedEnduranceLimit", + label: "Corrected endurance limit", + value: correctedEnduranceLimit, + unit: "Pa", + description: "Base endurance limit multiplied by the correction factor.", + }, + { + key: "fatigueStrengthAt1000Cycles", + label: "Fatigue strength at 10^3 cycles", + value: 0.9 * input.ultimateStrength, + unit: "Pa", + description: "S-N curve strength at the low-cycle knee, estimated as 0.9 x ultimate strength.", + }, + { + key: "equivalentAmplitude", + label: "Equivalent fully reversed amplitude", + value: equivalent, + unit: "Pa", + description: "Alternating stress converted by the chosen mean-stress criterion for the S-N life estimate.", + }, + { + key: "fatigueLifeCycles", + label: "Predicted fatigue life", + value: life, + unit: "cycles", + description: + equivalent <= correctedEnduranceLimit + ? "The stress amplitude is at or below the corrected endurance limit. The design has infinite life beyond 10^6 cycles." + : "Predicted cycles to failure from the S-N curve for the equivalent fully reversed amplitude.", + }, + ]; + + return { + method: FATIGUE_METHOD, + inputs: { + ultimateStrength: input.ultimateStrength, + yieldStrength: input.yieldStrength, + enduranceLimit: input.enduranceLimit, + stressAmplitude: input.stressAmplitude, + meanStress, + criterion, + ...factors, + correctedEnduranceLimit, + equivalentAmplitude: equivalent, + }, + quantities, + safetyFactor: { + key: "fatigueSafetyFactor", + label: "Fatigue safety factor", + value: safetyFactor, + unit: "", + description: `Safety factor from the ${CRITERION_LABEL[criterion]} mean-stress criterion.`, + }, + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 92492d7..34e8489 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,16 @@ 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, + enduranceLimitEstimate, + equivalentAmplitude, + fatigueSafetyFactor, + FATIGUE_METHOD, + finiteLifeCycles, + type FatigueCriterion, + type FatigueInput, +} from "./fatigue.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..44069c4 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -3,10 +3,12 @@ import { analyzeBeam, analyzeBearing, analyzeBolt, + analyzeFatigue, analyzeShaft, analyzeSpring, computeSection, vonMises, + type FatigueCriterion, type SectionDef, type SpringEndType, } from "./engine/index.js"; @@ -106,6 +108,7 @@ type MaterialValues = { shearModulusPa?: number; densityKgM3?: number; yieldStrengthPa?: number; + ultimateStrengthPa?: number; }; function materialValues(ctx: AppContext, name: string): MaterialValues | undefined { @@ -118,6 +121,7 @@ function materialValues(ctx: AppContext, name: string): MaterialValues | undefin shearModulusPa: material.shearModulusGPa ? material.shearModulusGPa * 1e9 : undefined, densityKgM3: material.densityKgM3, yieldStrengthPa: material.yieldStrengthMPa ? material.yieldStrengthMPa * 1e6 : undefined, + ultimateStrengthPa: material.ultimateStrengthMPa ? material.ultimateStrengthMPa * 1e6 : undefined, }; } @@ -375,6 +379,42 @@ function stressHandler(ctx: AppContext): Handler { }; } +function fatigueHandler(ctx: AppContext): Handler { + return (input) => { + const materialName = input.material as string | undefined; + const values = materialName ? materialValues(ctx, materialName) : undefined; + if (materialName && !values) { + return failure("fatigue_analysis", `Unknown material: ${materialName}`, input); + } + + const ultimateStrength = (input.ultimateStrength as number | undefined) ?? values?.ultimateStrengthPa; + if (!ultimateStrength) { + return failure("fatigue_analysis", "Provide ultimateStrength or a known material.", input); + } + const yieldStrength = (input.yieldStrength as number | undefined) ?? values?.yieldStrengthPa; + + try { + const computation = analyzeFatigue({ + ultimateStrength, + yieldStrength, + enduranceLimit: input.enduranceLimit as number | undefined, + stressAmplitude: input.stressAmplitude as number, + meanStress: input.meanStress as number | undefined, + surfaceFactor: input.surfaceFactor as number | undefined, + sizeFactor: input.sizeFactor as number | undefined, + loadFactor: input.loadFactor as number | undefined, + temperatureFactor: input.temperatureFactor as number | undefined, + reliabilityFactor: input.reliabilityFactor as number | undefined, + miscellaneousFactor: input.miscellaneousFactor as number | undefined, + criterion: input.criterion as FatigueCriterion | 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; @@ -446,6 +486,7 @@ export function createHandlers(ctx: AppContext): Record { spring_design: springHandler(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 7204c18..9be91ee 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -120,6 +120,53 @@ export const stressSchema = z.object({ yieldStrength: z.number().positive().optional().describe("Tensile yield strength in pascals. Enables the safety factor."), }); +export const fatigueSchema = z.object({ + material: z + .string() + .optional() + .describe("Material name from the database. Provides the ultimate and yield strength."), + ultimateStrength: z + .number() + .positive() + .optional() + .describe("Ultimate tensile strength in pascals. Required when no material is set."), + yieldStrength: z + .number() + .positive() + .optional() + .describe("Tensile yield strength in pascals. Required for the soderberg and asme_elliptic criteria."), + enduranceLimit: z + .number() + .positive() + .optional() + .describe("Tested endurance limit in pascals. Defaults to 0.5 x ultimate strength for steel."), + stressAmplitude: z.number().nonnegative().describe("Alternating stress amplitude in pascals."), + meanStress: z.number().optional().describe("Mean stress in pascals. Defaults to zero."), + surfaceFactor: z.number().positive().optional().describe("Surface condition factor. Polished surfaces take 1. Defaults to 1."), + sizeFactor: z.number().positive().optional().describe("Size factor for parts larger than the test specimen. Defaults to 1."), + loadFactor: z.number().positive().optional().describe("Load factor. Bending takes 1, axial about 0.7, torsion about 0.577. Defaults to 1."), + temperatureFactor: z + .number() + .positive() + .optional() + .describe("Temperature factor. Stays at 1 near room temperature. Defaults to 1."), + reliabilityFactor: z + .number() + .positive() + .optional() + .describe("Reliability factor. About 0.9 for 90% reliability. Defaults to 1."), + miscellaneousFactor: z + .number() + .positive() + .optional() + .describe("Miscellaneous effects factor for stress concentration or surface treatments. Defaults to 1."), + criterion: z + .enum(["goodman", "gerber", "soderberg", "asme_elliptic"]) + .optional() + .describe("Mean-stress correction criterion. Defaults to goodman."), + outputUnits, +}); + export const unitConvertSchema = z.object({ value: z.number().describe("Numeric value to convert."), from: z.string().describe("Source unit symbol. Examples: MPa, mm, lbf, degC."), @@ -138,5 +185,6 @@ export type SpringInput = z.infer; export type BearingInput = z.infer; export type SectionPropsInput = z.infer; export type StressInput = z.infer; +export type FatigueInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2029a75..8ae2371 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { beamSchema, bearingSchema, boltSchema, + fatigueSchema, materialSchema, sectionPropsSchema, shaftSchema, @@ -52,6 +53,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: + "Fatigue safety factor, corrected endurance limit, and predicted life for a fluctuating stress state. Supports Goodman, Gerber, Soderberg, and ASME-elliptic criteria.", + 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 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..429a192 --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFatigue, + enduranceLimitEstimate, + equivalentAmplitude, + fatigueSafetyFactor, + finiteLifeCycles, +} from "../src/engine/fatigue.js"; + +const SUT = 600e6; +const SY = 450e6; +const SE = 300e6; + +describe("endurance limit estimate", () => { + it("uses half the ultimate strength below the steel cap", () => { + expect(enduranceLimitEstimate(SUT)).toBe(SE); + }); + + it("caps the estimate at 700 MPa for high-strength steel", () => { + expect(enduranceLimitEstimate(2000e6)).toBe(700e6); + }); +}); + +describe("fatigue safety factors", () => { + const sa = 100e6; + const sm = 100e6; + + it("computes the modified Goodman factor", () => { + expect(fatigueSafetyFactor("goodman", sa, sm, SE, SUT)).toBeCloseTo(2.0, 6); + }); + + it("computes the Gerber factor from the quadratic", () => { + const factor = fatigueSafetyFactor("gerber", sa, sm, SE, SUT); + expect(factor).toBeCloseTo(2.4853, 3); + expect(factor).toBeGreaterThan(fatigueSafetyFactor("goodman", sa, sm, SE, SUT)); + }); + + it("computes the Soderberg factor", () => { + expect(fatigueSafetyFactor("soderberg", sa, sm, SE, SUT, SY)).toBeCloseTo(1.8, 6); + }); + + it("computes the ASME-elliptic factor", () => { + expect(fatigueSafetyFactor("asme_elliptic", sa, sm, SE, SUT, SY)).toBeCloseTo(2.4962, 3); + }); + + it("reduces to the endurance ratio for a fully reversed load", () => { + expect(fatigueSafetyFactor("goodman", 150e6, 0, SE, SUT)).toBeCloseTo(2.0, 6); + expect(fatigueSafetyFactor("gerber", 150e6, 0, SE, SUT)).toBeCloseTo(2.0, 6); + }); + + it("requires a yield strength for the yield-based criteria", () => { + expect(() => fatigueSafetyFactor("soderberg", sa, sm, SE, SUT)).toThrow("yieldStrength"); + expect(() => fatigueSafetyFactor("asme_elliptic", sa, sm, SE, SUT)).toThrow("yieldStrength"); + }); +}); + +describe("equivalent fully reversed amplitude", () => { + it("amplifies the alternating stress for Goodman", () => { + expect(equivalentAmplitude("goodman", 100e6, 100e6, SUT)).toBeCloseTo(120e6, 6); + }); + + it("amplifies the alternating stress for Gerber", () => { + expect(equivalentAmplitude("gerber", 100e6, 100e6, SUT)).toBeCloseTo(100e6 / (1 - (100e6 / SUT) ** 2), 6); + }); + + it("uses the yield strength for Soderberg", () => { + expect(equivalentAmplitude("soderberg", 100e6, 100e6, SUT, SY)).toBeCloseTo(100e6 / (1 - 100e6 / SY), 6); + }); +}); + +describe("finite life from the S-N curve", () => { + it("returns the endurance-limit knee for an amplitude below the limit", () => { + expect(finiteLifeCycles(200e6, SE, SUT)).toBe(1e6); + }); + + it("predicts a finite life above the endurance limit", () => { + const a = (0.9 * SUT) ** 2 / SE; + const b = -Math.log10((0.9 * SUT) / SE) / 3; + const expected = (400e6 / a) ** (1 / b); + const life = finiteLifeCycles(400e6, SE, SUT); + expect(life).toBeLessThan(1e6); + expect(life).toBeGreaterThan(1e3); + expect(life).toBeCloseTo(expected, 0); + }); + + it("clamps to the low-cycle limit", () => { + expect(finiteLifeCycles(580e6, SE, SUT)).toBe(1e3); + }); +}); + +describe("analyzeFatigue", () => { + it("computes an infinite-life design with a Goodman factor", () => { + const result = analyzeFatigue({ + ultimateStrength: SUT, + stressAmplitude: 200e6, + meanStress: 50e6, + }); + + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.referenceIds).toContain("shigley-2015"); + expect(result.quantities.find((q) => q.key === "baseEnduranceLimit")?.value).toBe(SE); + expect(result.quantities.find((q) => q.key === "correctedEnduranceLimit")?.value).toBe(SE); + expect(result.quantities.find((q) => q.key === "fatigueLifeCycles")?.value).toBe(1e6); + expect(result.safetyFactor?.value).toBeCloseTo(1.3333, 3); + expect(result.safetyFactor?.key).toBe("fatigueSafetyFactor"); + expect(result.warnings.some((w) => w.includes("estimated"))).toBe(true); + }); + + it("applies the Marin correction factors", () => { + const result = analyzeFatigue({ + ultimateStrength: SUT, + stressAmplitude: 100e6, + surfaceFactor: 0.9, + sizeFactor: 0.85, + temperatureFactor: 0.9, + }); + const corrected = SE * 0.9 * 0.85 * 0.9; + expect(result.quantities.find((q) => q.key === "correctedEnduranceLimit")?.value).toBeCloseTo(corrected, 2); + expect(result.safetyFactor?.value).toBeCloseTo(corrected / 100e6, 6); + }); + + it("honours a supplied endurance limit without the estimate warning", () => { + const result = analyzeFatigue({ + ultimateStrength: SUT, + enduranceLimit: 260e6, + stressAmplitude: 130e6, + }); + expect(result.quantities.find((q) => q.key === "baseEnduranceLimit")?.value).toBe(260e6); + expect(result.warnings.some((w) => w.includes("estimated"))).toBe(false); + }); + + it("reports a finite life and its warning", () => { + const result = analyzeFatigue({ + ultimateStrength: SUT, + stressAmplitude: 550e6, + }); + const life = result.quantities.find((q) => q.key === "fatigueLifeCycles"); + expect(life?.value).toBe(1e3); + expect(result.safetyFactor?.value).toBeCloseTo(300e6 / 550e6, 6); + expect(result.warnings.some((w) => w.includes("10^3"))).toBe(true); + }); + + it("defaults the criterion to modified Goodman", () => { + const result = analyzeFatigue({ + ultimateStrength: SUT, + stressAmplitude: 100e6, + meanStress: 100e6, + }); + expect(result.inputs.criterion).toBe("goodman"); + expect(result.safetyFactor?.description).toContain("modified Goodman"); + }); + + it("supports the Soderberg criterion with a yield strength", () => { + const result = analyzeFatigue({ + ultimateStrength: SUT, + yieldStrength: SY, + stressAmplitude: 100e6, + meanStress: 100e6, + criterion: "soderberg", + }); + expect(result.safetyFactor?.value).toBeCloseTo(1.8, 6); + expect(result.safetyFactor?.description).toContain("Soderberg"); + }); +}); + +describe("analyzeFatigue validation", () => { + it("rejects a non-positive ultimate strength", () => { + expect(() => analyzeFatigue({ ultimateStrength: 0, stressAmplitude: 100e6 })).toThrow("ultimateStrength"); + }); + + it("rejects a negative stress amplitude", () => { + expect(() => analyzeFatigue({ ultimateStrength: SUT, stressAmplitude: -1 })).toThrow("stressAmplitude"); + }); + + it("rejects a zero stress amplitude with no mean stress", () => { + expect(() => analyzeFatigue({ ultimateStrength: SUT, stressAmplitude: 0 })).toThrow("stressAmplitude"); + }); + + it("accepts a mean-stress-only state", () => { + const result = analyzeFatigue({ ultimateStrength: SUT, stressAmplitude: 0, meanStress: 300e6 }); + expect(result.safetyFactor?.value).toBeCloseTo(2.0, 6); + }); + + it("rejects an endurance limit at or above 0.9 times ultimate strength", () => { + expect(() => + analyzeFatigue({ ultimateStrength: SUT, enduranceLimit: 540e6, stressAmplitude: 100e6 }), + ).toThrow("0.9 times the ultimate strength"); + }); + + it("rejects a non-positive correction factor", () => { + expect(() => + analyzeFatigue({ ultimateStrength: SUT, stressAmplitude: 100e6, surfaceFactor: 0 }), + ).toThrow("surfaceFactor"); + }); + + it("rejects a mean stress at or above the ultimate strength", () => { + expect(() => + analyzeFatigue({ ultimateStrength: SUT, stressAmplitude: 100e6, meanStress: SUT }), + ).toThrow("ultimate strength"); + }); + + it("rejects the Soderberg criterion without a yield strength", () => { + expect(() => + analyzeFatigue({ + ultimateStrength: SUT, + stressAmplitude: 100e6, + meanStress: 100e6, + criterion: "soderberg", + }), + ).toThrow("yieldStrength"); + }); + + it("rejects the ASME-elliptic criterion with a mean stress at yield", () => { + expect(() => + analyzeFatigue({ + ultimateStrength: SUT, + yieldStrength: SY, + stressAmplitude: 100e6, + meanStress: SY, + criterion: "asme_elliptic", + }), + ).toThrow("yield strength"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index dd63bf0..b26cf8c 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -13,6 +13,7 @@ type Handlers = { shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; }; @@ -31,7 +32,7 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all nine tools", () => { + it("registers all ten tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", @@ -41,6 +42,7 @@ describe("tool registry", () => { "shaft_analysis", "bearing_life", "von_mises", + "fatigue_analysis", "unit_convert", "material_lookup", ].sort(), @@ -284,6 +286,41 @@ describe("von_mises tool", () => { }); }); +describe("fatigue_analysis tool", () => { + it("computes a fatigue factor from a material", () => { + setup(); + const response = handlers.fatigue_analysis({ + material: "Alloy steel 42CrMo4", + stressAmplitude: 200e6, + meanStress: 100e6, + outputUnits: { correctedEnduranceLimit: "MPa" }, + }); + const result = expectOk(response); + const corrected = result.quantities.find((q) => q.key === "correctedEnduranceLimit"); + expect(corrected?.unit).toBe("MPa"); + expect(result.safetyFactor?.value).toBeGreaterThan(0); + expect(result.references.length).toBeGreaterThan(0); + }); + + it("rejects an unknown material", () => { + setup(); + const response = handlers.fatigue_analysis({ + material: "Unobtainium", + stressAmplitude: 200e6, + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("Unknown material"); + } + }); + + it("requires the ultimate strength when no material is set", () => { + setup(); + const response = handlers.fatigue_analysis({ stressAmplitude: 200e6 }); + expect(response.ok).toBe(false); + }); +}); + describe("unit_convert tool", () => { it("converts and reports the factor", () => { setup();