diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821e15..f410a8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,5 +39,8 @@ jobs: - name: Verify the demo runs run: npm run demo + - name: Verify the CLI lists tools + run: node dist/index.js --list + - name: Verify the package contents run: npm pack --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37b808b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +This project follows semantic versioning. Adding a tool or a unit is a minor release. Breaking a tool signature is a major release. + +## 0.3.0 - 2026-08-03 + +Added the fatigue analysis release. + +- Added the `fatigue_analysis` tool. +- The tool reports infinite-life safety factors on four criteria. +- The criteria are modified Goodman, Soderberg, Gerber, and ASME-elliptic. +- The tool estimates the endurance limit for steel when you do not provide one. +- The tool reports warnings for estimated values and yield check failures. +- Added the `@engineerkit/engineer-mcp/engine` exports for the fatigue engine. +- The Shigley reference now covers the fatigue chapter. + +## 0.2.0 - 2026-08-03 + +Added the helical spring release. + +- Added the `spring_design` tool. +- The tool reports the spring rate, the shear stress, and the safety factor. +- The tool supports four end conditions and warns on buckling and solid height. +- Added stiffness units to the unit layer. + +## 0.1.0 - 2026-08-03 + +Initial release. + +- Added the MCP server over standard input and output. +- Added tools for beams, bolts, shafts, bearings, stress, sections, and materials. +- Added the dimension-safe unit layer. +- Added SQLite storage and provenance for every result. diff --git a/README.md b/README.md index e94b177..bb6a4df 100644 --- a/README.md +++ b/README.md @@ -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 safety factors for cyclic loads. - Cross-section properties. - Dimension-safe unit conversion. - Material property lookup. @@ -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` | Infinite-life fatigue factors on four criteria. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | @@ -147,6 +149,27 @@ References: - Machinery's Handbook (Thirty-first edition) ``` +A call to `fatigue_analysis` for an S355 shaft under a 150 MPa mean stress and a 100 MPa amplitude: + +```text +Mean stress 150 MPa +Stress amplitude 100 MPa +Stress ratio R 0.2 +Endurance limit 245 MPa +Modified Goodman safety factor 1.4 +Soderberg safety factor 1.204 +Gerber safety factor 1.748 +ASME-elliptic safety factor 1.702 +Yield safety factor 1.42 + +Method: Infinite-life fatigue analysis +Formula: R = (sm - sa)/(sm + sa); Goodman: 1/n = sa/Se + sm/Sut; Soderberg: 1/n = sa/Se + sm/Sy +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) +``` + +The endurance limit is an estimate unless you provide it. The tool warns when it estimates a value. + A call to `unit_convert` with a torque-to-energy request fails safely: ```text @@ -169,7 +192,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. +- 126 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 +206,9 @@ 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 criteria assume a constant-amplitude cycle and an infinite life. + The endurance limit is an estimate for steel when you do not provide one. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -197,10 +222,11 @@ 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. +- Fatigue analysis for cyclic loads. + The `fatigue_analysis` tool reports infinite-life safety factors on four criteria. ### 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. @@ -218,3 +244,5 @@ Read the boundary rules in [docs/integration.md](docs/integration.md). ## License MIT. See [LICENSE](LICENSE). + +See [CHANGELOG.md](CHANGELOG.md) for the release history. 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..ed4b398 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -41,10 +41,10 @@ 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. +- Fatigue analysis for cyclic loads. The `fatigue_analysis` tool reports infinite-life safety factors on four criteria. ### 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..c54c0d8 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -146,6 +146,35 @@ Inputs: - Cartesian mode uses `sigmaX`, `sigmaY`, `sigmaZ`, `tauXY`, `tauXZ`, `tauYZ`. - `yieldStrength`: enables the safety factor. +## fatigue_analysis + +Compute the infinite-life fatigue safety factors of a constant-amplitude stress cycle. + +The tool evaluates four failure criteria. It reports one safety factor for each criterion. They are the modified Goodman, Soderberg, Gerber, and ASME-elliptic lines. + +Inputs: + +- `meanStress`: mean stress of the cycle in pascals. +- `amplitudeStress`: alternating stress amplitude in pascals. +- `material`: material name from the database. It provides the ultimate and yield strengths. +- `ultimateStrength`: tensile ultimate strength in pascals. Required when no material is set. +- `yieldStrength`: tensile yield strength in pascals. It enables the Soderberg, ASME-elliptic, and yield factors. +- `enduranceLimit`: fully reversed endurance limit in pascals. The default is an estimate for steel. +- `criterion`: primary criterion for the safety factor. The options are `goodman`, `soderberg`, `gerber`, and `asme_elliptic`. The default is `goodman`. + +Example: + +```json +{ + "meanStress": 150000000, + "amplitudeStress": 100000000, + "material": "Structural steel S355", + "enduranceLimit": 245000000 +} +``` + +The tool warns when the endurance limit is estimated. It warns when the amplitude exceeds the endurance limit. It warns when the cycle reaches yield at its peak or trough. + ## unit_convert Convert a value between two units. diff --git a/examples/demo.ts b/examples/demo.ts index 2013998..454da44 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,12 @@ const inputs: Array> = [ yieldStrength: 355e6, outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, }, + { + meanStress: 150e6, + amplitudeStress: 100e6, + material: "Structural steel S355", + outputUnits: { meanStress: "MPa", amplitudeStress: "MPa", minStress: "MPa", maxStress: "MPa", enduranceLimit: "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..1c53ef3 --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,233 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type FatigueCriterion = "goodman" | "soderberg" | "gerber" | "asme_elliptic"; + +export type FatigueInput = { + meanStress: number; + amplitudeStress: number; + ultimateStrength: number; + yieldStrength?: number; + enduranceLimit?: number; + criterion?: FatigueCriterion; +}; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Infinite-life fatigue analysis", + formula: + "R = (sm - sa)/(sm + sa); Goodman: 1/n = sa/Se + sm/Sut; Soderberg: 1/n = sa/Se + sm/Sy; Gerber: n(sa/Se) + n^2(sm/Sut)^2 = 1; ASME-elliptic: n^2((sa/Se)^2 + (sm/Sy)^2) = 1", + notes: + "The criteria assume a constant-amplitude cycle and an infinite life. The endurance limit Se is the fully reversed bending limit. For steel it is near half the ultimate strength below 1400 MPa. A compressive mean stress makes the Goodman criterion conservative. Check the cycle peak against yield for local yielding.", + referenceIds: ["shigley-2015"], +}; + +const ENDURANCE_CAP = 700e6; + +export function estimateEnduranceLimit(ultimateStrength: number): number { + if (!(ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + return Math.min(0.5 * ultimateStrength, ENDURANCE_CAP); +} + +function requirePositive(value: number, name: string): void { + if (!(value > 0)) { + throw new Error(`${name} must be positive.`); + } +} + +function goodmanFactor(amplitudeStress: number, meanStress: number, enduranceLimit: number, ultimateStrength: number): number { + const denominator = amplitudeStress / enduranceLimit + meanStress / ultimateStrength; + return denominator > 0 ? 1 / denominator : Number.POSITIVE_INFINITY; +} + +function soderbergFactor(amplitudeStress: number, meanStress: number, enduranceLimit: number, yieldStrength: number): number { + const denominator = amplitudeStress / enduranceLimit + meanStress / yieldStrength; + return denominator > 0 ? 1 / denominator : Number.POSITIVE_INFINITY; +} + +function gerberFactor(amplitudeStress: number, meanStress: number, enduranceLimit: number, ultimateStrength: number): number { + if (meanStress === 0) { + return enduranceLimit / amplitudeStress; + } + const a = meanStress / ultimateStrength; + const b = amplitudeStress / enduranceLimit; + const discriminant = b * b + 4 * a * a; + return (Math.sqrt(discriminant) - b) / (2 * a * a); +} + +function asmeFactor(amplitudeStress: number, meanStress: number, enduranceLimit: number, yieldStrength: number): number { + const sum = (amplitudeStress / enduranceLimit) ** 2 + (meanStress / yieldStrength) ** 2; + return sum > 0 ? 1 / Math.sqrt(sum) : Number.POSITIVE_INFINITY; +} + +const CRITERION_LABELS: Record = { + goodman: "Modified Goodman", + soderberg: "Soderberg", + gerber: "Gerber", + asme_elliptic: "ASME-elliptic", +}; + +export function analyzeFatigue(input: FatigueInput): Computation { + requirePositive(input.amplitudeStress, "amplitudeStress"); + requirePositive(input.ultimateStrength, "ultimateStrength"); + if (input.enduranceLimit !== undefined) { + requirePositive(input.enduranceLimit, "enduranceLimit"); + } + if (input.yieldStrength !== undefined) { + requirePositive(input.yieldStrength, "yieldStrength"); + } + + const criterion = input.criterion ?? "goodman"; + if ((criterion === "soderberg" || criterion === "asme_elliptic") && input.yieldStrength === undefined) { + throw new Error(`${criterion} requires yieldStrength.`); + } + + const { meanStress, amplitudeStress, ultimateStrength } = input; + const yieldStrength = input.yieldStrength; + const enduranceLimit = input.enduranceLimit ?? estimateEnduranceLimit(ultimateStrength); + + const minStress = meanStress - amplitudeStress; + const maxStress = meanStress + amplitudeStress; + const stressRatio = maxStress !== 0 ? minStress / maxStress : Number.POSITIVE_INFINITY; + + const warnings: string[] = []; + if (input.enduranceLimit === undefined) { + warnings.push("The endurance limit is estimated as half the ultimate strength. Use test data for exact values."); + } + if (amplitudeStress > enduranceLimit) { + warnings.push("The amplitude exceeds the endurance limit. Infinite life is unlikely at this amplitude."); + } + if (meanStress < 0) { + warnings.push("The mean stress is compressive. The criteria are conservative for compressive mean stress."); + } + if (yieldStrength !== undefined && maxStress > yieldStrength) { + warnings.push("The cycle peak exceeds the yield strength. Local yielding may occur at the peak of the cycle."); + } + if (yieldStrength !== undefined && minStress < -yieldStrength) { + warnings.push("The cycle trough exceeds the yield strength in compression. Local yielding may occur at the trough of the cycle."); + } + + const goodman = goodmanFactor(amplitudeStress, meanStress, enduranceLimit, ultimateStrength); + const gerber = gerberFactor(amplitudeStress, meanStress, enduranceLimit, ultimateStrength); + const soderberg = + yieldStrength !== undefined ? soderbergFactor(amplitudeStress, meanStress, enduranceLimit, yieldStrength) : undefined; + const asme = yieldStrength !== undefined ? asmeFactor(amplitudeStress, meanStress, enduranceLimit, yieldStrength) : undefined; + const yieldFactor = yieldStrength !== undefined && maxStress > 0 ? yieldStrength / maxStress : undefined; + + const quantities: Quantity[] = [ + { + key: "meanStress", + label: "Mean stress", + value: meanStress, + unit: "Pa", + description: "Average of the maximum and minimum cycle stress.", + }, + { + key: "amplitudeStress", + label: "Stress amplitude", + value: amplitudeStress, + unit: "Pa", + description: "Half of the stress range of the cycle.", + }, + { + key: "minStress", + label: "Minimum cycle stress", + value: minStress, + unit: "Pa", + description: "Trough of the stress cycle.", + }, + { + key: "maxStress", + label: "Maximum cycle stress", + value: maxStress, + unit: "Pa", + description: "Peak of the stress cycle.", + }, + { + key: "stressRatio", + label: "Stress ratio R", + value: stressRatio, + unit: "", + description: "Ratio of the minimum to the maximum cycle stress.", + }, + { + key: "enduranceLimit", + label: "Endurance limit", + value: enduranceLimit, + unit: "Pa", + description: "Fully reversed fatigue limit used by the criteria.", + }, + { + key: "goodmanSafetyFactor", + label: "Modified Goodman safety factor", + value: goodman, + unit: "", + description: "Fatigue safety factor on the modified Goodman line.", + }, + { + key: "gerberSafetyFactor", + label: "Gerber safety factor", + value: gerber, + unit: "", + description: "Fatigue safety factor on the Gerber parabola.", + }, + ]; + + if (soderberg !== undefined) { + quantities.push({ + key: "soderbergSafetyFactor", + label: "Soderberg safety factor", + value: soderberg, + unit: "", + description: "Conservative fatigue safety factor on the Soderberg line.", + }); + } + if (asme !== undefined) { + quantities.push({ + key: "asmeEllipticSafetyFactor", + label: "ASME-elliptic safety factor", + value: asme, + unit: "", + description: "Fatigue safety factor on the ASME-elliptic curve.", + }); + } + if (yieldFactor !== undefined) { + quantities.push({ + key: "yieldSafetyFactor", + label: "Yield safety factor", + value: yieldFactor, + unit: "", + description: "Yield strength divided by the maximum cycle stress.", + }); + } + + const safetyFactorByCriterion: Record = { + goodman, + soderberg: soderberg ?? goodman, + gerber, + asme_elliptic: asme ?? gerber, + }; + + return { + method: FATIGUE_METHOD, + inputs: { + meanStress, + amplitudeStress, + ultimateStrength, + yieldStrength, + enduranceLimit: input.enduranceLimit, + criterion, + }, + quantities, + safetyFactor: { + key: "fatigueSafetyFactor", + label: `${CRITERION_LABELS[criterion]} fatigue safety factor`, + value: safetyFactorByCriterion[criterion], + unit: "", + description: `Fatigue safety factor for infinite life on the ${CRITERION_LABELS[criterion]} criterion.`, + }, + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 92492d7..5736c7e 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,13 @@ 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, + estimateEnduranceLimit, + FATIGUE_METHOD, + 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..44be5a1 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 * 1e6, }; } @@ -375,6 +379,36 @@ 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({ + meanStress: input.meanStress as number, + amplitudeStress: input.amplitudeStress as number, + ultimateStrength, + yieldStrength, + enduranceLimit: input.enduranceLimit 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 +480,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..39763d3 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -126,6 +126,20 @@ export const unitConvertSchema = z.object({ to: z.string().describe("Target unit symbol. Examples: psi, m, N, degF."), }); +export const fatigueSchema = z.object({ + meanStress: z.number().describe("Mean stress of the cycle in pascals. May be negative for compression."), + amplitudeStress: z.number().positive().describe("Alternating stress amplitude of the cycle in pascals."), + material: z.string().optional().describe("Material name from the database. Provides the ultimate and yield strengths."), + ultimateStrength: z.number().positive().optional().describe("Tensile ultimate strength in pascals. Required when no material is set."), + yieldStrength: z.number().positive().optional().describe("Tensile yield strength in pascals. Enables the Soderberg, ASME-elliptic, and yield factors."), + enduranceLimit: z.number().positive().optional().describe("Fully reversed endurance limit in pascals. Defaults to an estimate for steel."), + criterion: z + .enum(["goodman", "soderberg", "gerber", "asme_elliptic"]) + .optional() + .describe("Primary criterion for the safety factor. Defaults to goodman."), + 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."), @@ -139,4 +153,5 @@ export type BearingInput = z.infer; export type SectionPropsInput = z.infer; export type StressInput = z.infer; export type UnitConvertInput = z.infer; +export type FatigueInput = z.infer; export type MaterialInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2029a75..d093319 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: + "Infinite-life fatigue safety factors for a constant-amplitude stress cycle. Uses the modified Goodman, Soderberg, Gerber, 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..58fd514 --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFatigue, + estimateEnduranceLimit, + type FatigueCriterion, +} from "../src/engine/fatigue.js"; + +const SE = 245e6; +const SUT = 490e6; +const SY = 355e6; + +function baseInput() { + return { + meanStress: 150e6, + amplitudeStress: 100e6, + ultimateStrength: SUT, + yieldStrength: SY, + enduranceLimit: SE, + }; +} + +describe("estimateEnduranceLimit", () => { + it("estimates half the ultimate strength for steel", () => { + expect(estimateEnduranceLimit(490e6)).toBeCloseTo(245e6, 6); + }); + + it("caps the estimate at 700 MPa", () => { + expect(estimateEnduranceLimit(2000e6)).toBe(700e6); + }); + + it("rejects a non-positive ultimate strength", () => { + expect(() => estimateEnduranceLimit(0)).toThrow("ultimateStrength"); + }); +}); + +describe("analyzeFatigue factors", () => { + it("computes the modified Goodman factor", () => { + const result = analyzeFatigue(baseInput()); + const goodman = result.quantities.find((q) => q.key === "goodmanSafetyFactor"); + expect(goodman?.value).toBeCloseTo(1 / (100e6 / SE + 150e6 / SUT), 9); + expect(goodman?.value).toBeCloseTo(1.4, 6); + }); + + it("computes the Soderberg factor from yield strength", () => { + const result = analyzeFatigue(baseInput()); + const soderberg = result.quantities.find((q) => q.key === "soderbergSafetyFactor"); + expect(soderberg?.value).toBeCloseTo(1 / (100e6 / SE + 150e6 / SY), 9); + }); + + it("computes the Gerber factor", () => { + const result = analyzeFatigue(baseInput()); + const gerber = result.quantities.find((q) => q.key === "gerberSafetyFactor"); + const a = 150e6 / SUT; + const b = 100e6 / SE; + const expected = (Math.sqrt(b * b + 4 * a * a) - b) / (2 * a * a); + expect(gerber?.value).toBeCloseTo(expected, 9); + expect(gerber?.value).toBeGreaterThan(1.7); + }); + + it("computes the ASME-elliptic factor", () => { + const result = analyzeFatigue(baseInput()); + const asme = result.quantities.find((q) => q.key === "asmeEllipticSafetyFactor"); + const inner = (100e6 / SE) ** 2 + (150e6 / SY) ** 2; + expect(asme?.value).toBeCloseTo(1 / Math.sqrt(inner), 9); + }); + + it("computes the yield safety factor from the cycle peak", () => { + const result = analyzeFatigue(baseInput()); + const yieldFactor = result.quantities.find((q) => q.key === "yieldSafetyFactor"); + expect(yieldFactor?.value).toBeCloseTo(SY / 250e6, 9); + }); + + it("reports the stress ratio and cycle extremes", () => { + const result = analyzeFatigue(baseInput()); + expect(result.quantities.find((q) => q.key === "stressRatio")?.value).toBeCloseTo(0.2, 9); + expect(result.quantities.find((q) => q.key === "minStress")?.value).toBe(50e6); + expect(result.quantities.find((q) => q.key === "maxStress")?.value).toBe(250e6); + }); + + it("sets the default safety factor to the Goodman criterion", () => { + const result = analyzeFatigue(baseInput()); + expect(result.safetyFactor?.key).toBe("fatigueSafetyFactor"); + expect(result.safetyFactor?.value).toBeCloseTo(1.4, 6); + expect(result.safetyFactor?.label).toContain("Goodman"); + }); + + it("selects the requested criterion for the safety factor", () => { + const result = analyzeFatigue({ ...baseInput(), criterion: "soderberg" }); + expect(result.safetyFactor?.label).toContain("Soderberg"); + const soderberg = result.quantities.find((q) => q.key === "soderbergSafetyFactor"); + expect(result.safetyFactor?.value).toBe(soderberg?.value); + }); +}); + +describe("analyzeFatigue endurance limit", () => { + it("uses an explicit endurance limit", () => { + const result = analyzeFatigue(baseInput()); + expect(result.quantities.find((q) => q.key === "enduranceLimit")?.value).toBe(SE); + expect(result.inputs.enduranceLimit).toBe(SE); + }); + + it("estimates the endurance limit and warns when it is omitted", () => { + const result = analyzeFatigue({ + meanStress: 150e6, + amplitudeStress: 100e6, + ultimateStrength: SUT, + yieldStrength: SY, + }); + expect(result.quantities.find((q) => q.key === "enduranceLimit")?.value).toBeCloseTo(245e6, 6); + expect(result.warnings.some((w) => w.includes("estimated"))).toBe(true); + }); + + it("warns when the amplitude exceeds the endurance limit", () => { + const result = analyzeFatigue({ + meanStress: 0, + amplitudeStress: 300e6, + ultimateStrength: SUT, + yieldStrength: SY, + enduranceLimit: SE, + }); + expect(result.warnings.some((w) => w.includes("exceeds the endurance limit"))).toBe(true); + }); +}); + +describe("analyzeFatigue warnings", () => { + it("warns on a compressive mean stress", () => { + const result = analyzeFatigue({ + meanStress: -50e6, + amplitudeStress: 100e6, + ultimateStrength: SUT, + yieldStrength: SY, + enduranceLimit: SE, + }); + expect(result.warnings.some((w) => w.includes("compressive"))).toBe(true); + }); + + it("warns when the cycle peak exceeds yield", () => { + const result = analyzeFatigue({ + meanStress: 300e6, + amplitudeStress: 200e6, + ultimateStrength: SUT, + yieldStrength: SY, + enduranceLimit: SE, + }); + expect(result.warnings.some((w) => w.includes("peak"))).toBe(true); + }); +}); + +describe("analyzeFatigue validation", () => { + it("rejects a non-positive amplitude", () => { + expect(() => analyzeFatigue({ ...baseInput(), amplitudeStress: 0 })).toThrow("amplitudeStress"); + }); + + it("rejects a non-positive ultimate strength", () => { + expect(() => analyzeFatigue({ ...baseInput(), ultimateStrength: 0 })).toThrow("ultimateStrength"); + }); + + it("rejects a non-positive endurance limit", () => { + expect(() => analyzeFatigue({ ...baseInput(), enduranceLimit: -1 })).toThrow("enduranceLimit"); + }); + + it("requires yield strength for the Soderberg criterion", () => { + const { yieldStrength: _ignored, ...withoutYield } = baseInput(); + expect(() => analyzeFatigue({ ...withoutYield, criterion: "soderberg" })).toThrow("yieldStrength"); + }); + + it("requires yield strength for the ASME-elliptic criterion", () => { + const { yieldStrength: _ignored, ...withoutYield } = baseInput(); + expect(() => analyzeFatigue({ ...withoutYield, criterion: "asme_elliptic" })).toThrow("yieldStrength"); + }); + + it("works without yield strength for Goodman and Gerber", () => { + const { yieldStrength: _ignored, ...withoutYield } = baseInput(); + const goodman = analyzeFatigue({ ...withoutYield }); + expect(goodman.safetyFactor?.value).toBeCloseTo(1.4, 6); + expect(goodman.quantities.some((q) => q.key === "soderbergSafetyFactor")).toBe(false); + const gerber = analyzeFatigue({ ...withoutYield, criterion: "gerber" }); + expect(gerber.safetyFactor?.key).toBe("fatigueSafetyFactor"); + }); +}); + +describe("analyzeFatigue provenance", () => { + it("carries the Shigley reference", () => { + const result = analyzeFatigue(baseInput()); + expect(result.referenceIds).toContain("shigley-2015"); + expect(result.method.id).toBe("fatigue-analysis"); + }); + + it("accepts every criterion enum", () => { + const criteria: FatigueCriterion[] = ["goodman", "soderberg", "gerber", "asme_elliptic"]; + for (const criterion of criteria) { + const result = analyzeFatigue({ ...baseInput(), criterion }); + expect(result.safetyFactor?.value).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index dd63bf0..daacd84 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,69 @@ describe("von_mises tool", () => { }); }); +describe("fatigue_analysis tool", () => { + it("computes fatigue factors from a material", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 150e6, + amplitudeStress: 100e6, + material: "Structural steel S355", + outputUnits: { meanStress: "MPa", amplitudeStress: "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 mean = result.quantities.find((q) => q.key === "meanStress"); + expect(mean?.unit).toBe("MPa"); + expect(mean?.value).toBe(150); + const goodman = result.quantities.find((q) => q.key === "goodmanSafetyFactor"); + expect(goodman?.value).toBeCloseTo(1.4, 6); + expect(result.safetyFactor?.label).toContain("Goodman"); + }); + + it("honors an explicit criterion and unit overrides", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 150e6, + amplitudeStress: 100e6, + ultimateStrength: 490e6, + yieldStrength: 355e6, + enduranceLimit: 245e6, + criterion: "gerber", + outputUnits: { maxStress: "MPa" }, + }); + const result = expectOk(response); + expect(result.safetyFactor?.label).toContain("Gerber"); + expect(result.quantities.find((q) => q.key === "maxStress")?.unit).toBe("MPa"); + }); + + it("reports an unknown material", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 150e6, + amplitudeStress: 100e6, + material: "Unobtainium", + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("Unknown material"); + } + }); + + it("fails without an ultimate strength", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 150e6, + amplitudeStress: 100e6, + }); + expect(response.ok).toBe(false); + }); +}); + describe("unit_convert tool", () => { it("converts and reports the factor", () => { setup();