From 23841d5a7d34bf06cfaefe979da1305772d4f69a Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:01:36 -0700 Subject: [PATCH] feat: extend engineer mcp --- .github/workflows/ci.yml | 3 + README.md | 37 +++++++- data/references.json | 2 +- docs/integration.md | 2 +- docs/mcp-tools.md | 34 +++++++ examples/demo.ts | 10 ++ package-lock.json | 5 +- package.json | 2 +- src/engine/fatigue.ts | 173 ++++++++++++++++++++++++++++++++++ src/engine/index.ts | 1 + src/handlers.ts | 21 +++++ src/schemas.ts | 14 +++ src/server.ts | 6 ++ src/version.ts | 2 +- tests/fatigue.test.ts | 197 +++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 54 ++++++++++- 16 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 src/engine/fatigue.ts create mode 100644 tests/fatigue.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821e15..60d499c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Build run: npm run build + - name: Smoke test the built binary + run: node dist/index.js --list + - name: Verify the demo runs run: npm run demo diff --git a/README.md b/README.md index e94b177..6b67da9 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, stress, fatigue, 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 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` | Fatigue safety factors for cyclic loads. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | @@ -154,6 +156,29 @@ Error: Category mismatch: N·m is torque, J is energy. Use a unit of the same quantity. ``` +A call to `fatigue_analysis` for a steel part under 400 MPa mean stress and 200 MPa alternating stress: + +```text +Mean stress 400 MPa +Alternating stress 200 MPa +Stress ratio 0.3333 +Endurance limit 500 MPa +Soderberg safety factor 1.218 +Goodman safety factor 1.364 +Gerber safety factor 1.699 +ASME-elliptic safety factor 1.722 + +Soderberg safety factor 1.218 + Factor for the Soderberg line from Se to Sy. It is the most conservative criterion. + +Method: Fatigue failure criteria for fluctuating stress +Formula: Soderberg: sa/Se + sm/Sy = 1/n. Goodman: sa/Se + sm/Sut = 1/n. Gerber: n.sa/Se + (n.sm/Sut)^2 = 1. ASME-elliptic: (n.sa/Se)^2 + (n.sm/Sy)^2 = 1 +References: + - Shigley's Mechanical Engineering Design (McGraw-Hill Education, Tenth edition, 2015) +``` + +The tool always returns all four safety factors. It warns when a factor falls below 1. + ## Development | Command | Purpose | @@ -169,7 +194,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. +- 115 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 +208,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 the `fatigue_analysis` tool for cyclic loads. +- The fatigue criteria assume a tensile mean stress. + The endurance limit defaults to `0.5 x Sut`. + Apply modifying factors for surface, size, load, temperature, and reliability. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -197,10 +225,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 returns safety factors for the Soderberg, Goodman, Gerber, and ASME-elliptic 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. 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..312f2b4 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 applies the Soderberg, Goodman, Gerber, and ASME-elliptic 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..da96fb1 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -146,6 +146,40 @@ Inputs: - Cartesian mode uses `sigmaX`, `sigmaY`, `sigmaZ`, `tauXY`, `tauXZ`, `tauYZ`. - `yieldStrength`: enables the safety factor. +## fatigue_analysis + +Compute the fatigue safety factor for a fluctuating stress. + +The tool applies the four standard failure criteria. They are Soderberg, modified Goodman, Gerber, and ASME-elliptic. Each criterion returns a safety factor for the same load cycle. + +Inputs: + +- `meanStress`: steady component of the stress in pascals. +- `alternatingStress`: amplitude of the stress in pascals. +- `ultimateStrength`: ultimate tensile strength Sut in pascals. +- `yieldStrength`: tensile yield strength Sy in pascals. +- `enduranceLimit`: endurance limit Se in pascals. The default is `0.5 x Sut`. +- `criterion`: `soderberg`, `goodman`, `gerber`, `asme`, or `all`. The default is `all`. + +The tool always returns all four safety factors as quantities. The headline safety factor follows the selected criterion. For `all`, the headline factor is Soderberg, because it is the most conservative. + +The tool estimates the endurance limit when you omit it. It warns that modifying factors still apply. It warns when any safety factor drops below 1. + +Example: + +```json +{ + "meanStress": 400000000, + "alternatingStress": 200000000, + "ultimateStrength": 1200000000, + "yieldStrength": 950000000, + "enduranceLimit": 500000000, + "criterion": "goodman" +} +``` + +The criteria compare the alternating stress and the mean stress against the endurance limit and the static strengths. This method is for an infinite-life design check. + ## unit_convert Convert a value between two units. diff --git a/examples/demo.ts b/examples/demo.ts index 2013998..cddaf37 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,14 @@ const inputs: Array> = [ yieldStrength: 355e6, outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, }, + { + meanStress: 400e6, + alternatingStress: 200e6, + ultimateStrength: 1200e6, + yieldStrength: 950e6, + enduranceLimit: 500e6, + outputUnits: { meanStress: "MPa", alternatingStress: "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..f400cc1 --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,173 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type FatigueCriterion = "soderberg" | "goodman" | "gerber" | "asme"; + +export type FatigueInput = { + meanStress: number; + alternatingStress: number; + ultimateStrength: number; + yieldStrength: number; + enduranceLimit?: number; + criterion?: FatigueCriterion | "all"; +}; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Fatigue failure criteria for fluctuating stress", + formula: + "Soderberg: sa/Se + sm/Sy = 1/n. Goodman: sa/Se + sm/Sut = 1/n. Gerber: n.sa/Se + (n.sm/Sut)^2 = 1. ASME-elliptic: (n.sa/Se)^2 + (n.sm/Sy)^2 = 1", + notes: + "Each criterion returns the safety factor for a mean stress and an alternating stress. Soderberg is the most conservative line for ductile materials. Without an endurance limit, the tool estimates it as 0.5 x ultimate strength for steel. Use modifying factors for a precise value.", + referenceIds: ["shigley-2015"], +}; + +export const ESTIMATED_ENDURANCE_FRACTION = 0.5; + +const FACTOR_LABELS: Record = { + soderberg: "Soderberg safety factor", + goodman: "Goodman safety factor", + gerber: "Gerber safety factor", + asme: "ASME-elliptic safety factor", +}; + +const FACTOR_DESCRIPTIONS: Record = { + soderberg: "Factor for the Soderberg line from Se to Sy. It is the most conservative criterion.", + goodman: "Factor for the modified Goodman line from Se to Sut.", + gerber: "Factor for the Gerber parabola through Se and Sut.", + asme: "Factor for the ASME-elliptic curve through Se and Sy.", +}; + +export function fatigueSafetyFactor( + criterion: FatigueCriterion, + alternatingStress: number, + meanStress: number, + enduranceLimit: number, + ultimateStrength: number, + yieldStrength: number, +): number { + switch (criterion) { + case "soderberg": + return 1 / (alternatingStress / enduranceLimit + meanStress / yieldStrength); + case "goodman": + return 1 / (alternatingStress / enduranceLimit + meanStress / ultimateStrength); + case "gerber": { + const a = (meanStress / ultimateStrength) ** 2; + const b = alternatingStress / enduranceLimit; + if (a === 0) { + return 1 / b; + } + return (-b + Math.sqrt(b ** 2 + 4 * a)) / (2 * a); + } + case "asme": + return 1 / Math.sqrt((alternatingStress / enduranceLimit) ** 2 + (meanStress / yieldStrength) ** 2); + } +} + +const CRITERIA: FatigueCriterion[] = ["soderberg", "goodman", "gerber", "asme"]; + +function factorQuantity(criterion: FatigueCriterion, value: number): Quantity { + return { + key: `${criterion}Factor`, + label: FACTOR_LABELS[criterion], + value, + unit: "", + description: FACTOR_DESCRIPTIONS[criterion], + }; +} + +export function analyzeFatigue(input: FatigueInput): Computation { + if (!(input.ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + if (!(input.yieldStrength > 0)) { + throw new Error("yieldStrength must be positive."); + } + if (!(input.meanStress >= 0)) { + throw new Error("meanStress must be zero or positive."); + } + if (!(input.alternatingStress >= 0)) { + throw new Error("alternatingStress must be zero or positive."); + } + if (input.meanStress === 0 && input.alternatingStress === 0) { + throw new Error("Provide a positive meanStress or alternatingStress."); + } + if (input.enduranceLimit !== undefined && !(input.enduranceLimit > 0)) { + throw new Error("enduranceLimit must be positive."); + } + + const { meanStress, alternatingStress, ultimateStrength, yieldStrength } = input; + const warnings: string[] = []; + + const enduranceLimit = input.enduranceLimit ?? ESTIMATED_ENDURANCE_FRACTION * ultimateStrength; + if (input.enduranceLimit === undefined) { + warnings.push( + "The endurance limit is estimated as 0.5 x ultimate strength. Apply surface, size, load, temperature, and reliability factors for a precise value.", + ); + } + + const factors: Record = { + soderberg: fatigueSafetyFactor("soderberg", alternatingStress, meanStress, enduranceLimit, ultimateStrength, yieldStrength), + goodman: fatigueSafetyFactor("goodman", alternatingStress, meanStress, enduranceLimit, ultimateStrength, yieldStrength), + gerber: fatigueSafetyFactor("gerber", alternatingStress, meanStress, enduranceLimit, ultimateStrength, yieldStrength), + asme: fatigueSafetyFactor("asme", alternatingStress, meanStress, enduranceLimit, ultimateStrength, yieldStrength), + }; + + const stressRatio = (meanStress - alternatingStress) / (meanStress + alternatingStress); + + const quantities: Quantity[] = [ + { + key: "meanStress", + label: "Mean stress", + value: meanStress, + unit: "Pa", + description: "Steady component of the fluctuating stress.", + }, + { + key: "alternatingStress", + label: "Alternating stress", + value: alternatingStress, + unit: "Pa", + description: "Amplitude of the fluctuating stress about the mean.", + }, + { + key: "stressRatio", + label: "Stress ratio", + value: stressRatio, + unit: "", + description: "Ratio R of the minimum stress to the maximum stress. A fully reversed load gives -1.", + }, + { + key: "enduranceLimit", + label: "Endurance limit", + value: enduranceLimit, + unit: "Pa", + description: "Completely reversed stress amplitude for indefinite life at the selected material state.", + }, + ...CRITERIA.map((criterion) => factorQuantity(criterion, factors[criterion])), + ]; + + const selected = input.criterion ?? "all"; + const headline: FatigueCriterion = selected === "all" ? "soderberg" : selected; + + const lowest = Math.min(...CRITERIA.map((criterion) => factors[criterion])); + if (lowest < 1) { + warnings.push("The lowest fatigue safety factor is below 1. The design may fail in fatigue."); + } + + return { + method: FATIGUE_METHOD, + inputs: { + meanStress, + alternatingStress, + ultimateStrength, + yieldStrength, + enduranceLimit, + criterion: selected, + stressRatio, + }, + quantities, + safetyFactor: factorQuantity(headline, factors[headline]), + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 92492d7..a26199f 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,7 @@ 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, fatigueSafetyFactor, 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..8f8eab9 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"; @@ -332,6 +334,24 @@ function bearingHandler(ctx: AppContext): Handler { }; } +function fatigueHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzeFatigue({ + meanStress: input.meanStress as number, + alternatingStress: input.alternatingStress as number, + ultimateStrength: input.ultimateStrength as number, + yieldStrength: input.yieldStrength as number, + enduranceLimit: input.enduranceLimit as number | undefined, + criterion: input.criterion as FatigueCriterion | "all" | undefined, + }); + return buildResult(ctx, "fatigue_analysis", computation, input.outputUnits as Record | undefined); + } catch (error) { + return failure("fatigue_analysis", error instanceof Error ? error.message : String(error), input); + } + }; +} + function stressHandler(ctx: AppContext): Handler { return (input) => { const mode = input.mode as "principal" | "cartesian"; @@ -446,6 +466,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..31e0c4c 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -106,6 +106,19 @@ export const bearingSchema = z.object({ outputUnits, }); +export const fatigueSchema = z.object({ + meanStress: z.number().min(0).describe("Steady component of the fluctuating stress in pascals. Use zero for a compressive mean."), + alternatingStress: z.number().min(0).describe("Amplitude of the fluctuating stress in pascals."), + ultimateStrength: z.number().positive().describe("Ultimate tensile strength Sut in pascals."), + yieldStrength: z.number().positive().describe("Tensile yield strength Sy in pascals."), + enduranceLimit: z.number().positive().optional().describe("Endurance limit Se in pascals. Defaults to 0.5 x Sut."), + criterion: z + .enum(["soderberg", "goodman", "gerber", "asme", "all"]) + .optional() + .describe("Failure criterion for the headline safety factor. Defaults to all."), + outputUnits, +}); + export const stressSchema = z.object({ mode: z.enum(["principal", "cartesian"]).describe("Stress input mode. Principal uses sigma1-3. Cartesian uses sigmaX, sigmaY, and shear terms."), sigma1: z.number().optional().describe("First principal stress in pascals. Required in principal mode."), @@ -137,6 +150,7 @@ export type ShaftInput = z.infer; export type SpringInput = z.infer; export type BearingInput = z.infer; export type SectionPropsInput = z.infer; +export type FatigueInput = z.infer; export type StressInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2029a75..5fa4b61 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 factors for a fluctuating stress using the Soderberg, Goodman, 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..f3cf1f6 --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { analyzeFatigue, fatigueSafetyFactor, type FatigueCriterion } from "../src/engine/fatigue.js"; + +const CRITERIA: FatigueCriterion[] = ["soderberg", "goodman", "gerber", "asme"]; + +describe("fatigueSafetyFactor", () => { + it("computes each criterion from the closed-form expressions", () => { + const sa = 200e6; + const sm = 400e6; + const se = 500e6; + const sut = 1200e6; + const sy = 950e6; + + expect(fatigueSafetyFactor("soderberg", sa, sm, se, sut, sy)).toBeCloseTo(1 / (sa / se + sm / sy), 6); + expect(fatigueSafetyFactor("goodman", sa, sm, se, sut, sy)).toBeCloseTo(1 / (sa / se + sm / sut), 6); + const a = (sm / sut) ** 2; + const b = sa / se; + expect(fatigueSafetyFactor("gerber", sa, sm, se, sut, sy)).toBeCloseTo((-b + Math.sqrt(b ** 2 + 4 * a)) / (2 * a), 6); + expect(fatigueSafetyFactor("asme", sa, sm, se, sut, sy)).toBeCloseTo(1 / Math.sqrt((sa / se) ** 2 + (sm / sy) ** 2), 6); + }); + + it("reduces Gerber to the endurance ratio for a zero mean stress", () => { + expect(fatigueSafetyFactor("gerber", 100e6, 0, 300e6, 600e6, 400e6)).toBeCloseTo(3, 6); + }); +}); + +describe("analyzeFatigue", () => { + it("computes all four criteria and reports Soderberg as the headline", () => { + const result = analyzeFatigue({ + meanStress: 400e6, + alternatingStress: 200e6, + ultimateStrength: 1200e6, + yieldStrength: 950e6, + enduranceLimit: 500e6, + }); + + const sa = 200e6; + const sm = 400e6; + const se = 500e6; + const sut = 1200e6; + const sy = 950e6; + + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.referenceIds).toContain("shigley-2015"); + expect(result.safetyFactor?.key).toBe("soderbergFactor"); + expect(result.quantities.find((q) => q.key === "soderbergFactor")?.value).toBeCloseTo(1 / (sa / se + sm / sy), 6); + expect(result.quantities.find((q) => q.key === "goodmanFactor")?.value).toBeCloseTo(1 / (sa / se + sm / sut), 6); + expect(result.quantities.find((q) => q.key === "gerberFactor")?.value).toBeCloseTo( + ((-sa / se + Math.sqrt((sa / se) ** 2 + 4 * (sm / sut) ** 2)) / (2 * (sm / sut) ** 2)), + 6, + ); + expect(result.quantities.find((q) => q.key === "asmeFactor")?.value).toBeCloseTo( + 1 / Math.sqrt((sa / se) ** 2 + (sm / sy) ** 2), + 6, + ); + expect(result.warnings.length).toBe(0); + }); + + it("reports the stress ratio for the load cycle", () => { + const reversed = analyzeFatigue({ + meanStress: 0, + alternatingStress: 100e6, + ultimateStrength: 600e6, + yieldStrength: 400e6, + enduranceLimit: 300e6, + }); + expect(reversed.quantities.find((q) => q.key === "stressRatio")?.value).toBeCloseTo(-1, 9); + + const pulsating = analyzeFatigue({ + meanStress: 100e6, + alternatingStress: 100e6, + ultimateStrength: 600e6, + yieldStrength: 400e6, + enduranceLimit: 300e6, + }); + expect(pulsating.quantities.find((q) => q.key === "stressRatio")?.value).toBeCloseTo(0, 9); + }); + + it("uses the requested criterion for the headline safety factor", () => { + const result = analyzeFatigue({ + meanStress: 400e6, + alternatingStress: 200e6, + ultimateStrength: 1200e6, + yieldStrength: 950e6, + enduranceLimit: 500e6, + criterion: "gerber", + }); + expect(result.safetyFactor?.key).toBe("gerberFactor"); + }); + + it("estimates the endurance limit and warns when it is not supplied", () => { + const result = analyzeFatigue({ + meanStress: 100e6, + alternatingStress: 100e6, + ultimateStrength: 800e6, + yieldStrength: 600e6, + }); + expect(result.quantities.find((q) => q.key === "enduranceLimit")?.value).toBeCloseTo(400e6, 6); + expect(result.warnings.some((w) => w.includes("estimated"))).toBe(true); + }); + + it("treats a static load with the yield line", () => { + const result = analyzeFatigue({ + meanStress: 200e6, + alternatingStress: 0, + ultimateStrength: 600e6, + yieldStrength: 400e6, + enduranceLimit: 300e6, + }); + expect(result.quantities.find((q) => q.key === "soderbergFactor")?.value).toBeCloseTo(2, 6); + expect(result.quantities.find((q) => q.key === "asmeFactor")?.value).toBeCloseTo(2, 6); + }); + + it("warns when a safety factor drops below 1", () => { + const result = analyzeFatigue({ + meanStress: 0, + alternatingStress: 600e6, + ultimateStrength: 800e6, + yieldStrength: 600e6, + enduranceLimit: 400e6, + }); + expect(result.warnings.some((w) => w.includes("below 1"))).toBe(true); + }); + + it("carries every safety factor as a quantity", () => { + const result = analyzeFatigue({ + meanStress: 300e6, + alternatingStress: 150e6, + ultimateStrength: 900e6, + yieldStrength: 700e6, + enduranceLimit: 450e6, + }); + for (const criterion of CRITERIA) { + const quantity = result.quantities.find((q) => q.key === `${criterion}Factor`); + expect(quantity?.value).toBeGreaterThan(0); + expect(quantity?.unit).toBe(""); + } + }); +}); + +describe("analyzeFatigue validation", () => { + it("rejects a non-positive ultimate strength", () => { + expect(() => + analyzeFatigue({ + meanStress: 100e6, + alternatingStress: 50e6, + ultimateStrength: 0, + yieldStrength: 400e6, + }), + ).toThrow("ultimateStrength"); + }); + + it("rejects a non-positive yield strength", () => { + expect(() => + analyzeFatigue({ + meanStress: 100e6, + alternatingStress: 50e6, + ultimateStrength: 800e6, + yieldStrength: -1, + }), + ).toThrow("yieldStrength"); + }); + + it("rejects a negative mean stress", () => { + expect(() => + analyzeFatigue({ + meanStress: -100e6, + alternatingStress: 50e6, + ultimateStrength: 800e6, + yieldStrength: 600e6, + }), + ).toThrow("meanStress"); + }); + + it("rejects a zero load", () => { + expect(() => + analyzeFatigue({ + meanStress: 0, + alternatingStress: 0, + ultimateStrength: 800e6, + yieldStrength: 600e6, + }), + ).toThrow("Provide a positive meanStress or alternatingStress"); + }); + + it("rejects a non-positive endurance limit", () => { + expect(() => + analyzeFatigue({ + meanStress: 100e6, + alternatingStress: 50e6, + ultimateStrength: 800e6, + yieldStrength: 600e6, + enduranceLimit: 0, + }), + ).toThrow("enduranceLimit"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index dd63bf0..fba5aeb 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,56 @@ describe("von_mises tool", () => { }); }); +describe("fatigue_analysis tool", () => { + it("computes all four criteria and converts stresses", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 400e6, + alternatingStress: 200e6, + ultimateStrength: 1200e6, + yieldStrength: 950e6, + enduranceLimit: 500e6, + outputUnits: { meanStress: "MPa", alternatingStress: "MPa", enduranceLimit: "MPa" }, + }); + const result = expectOk(response); + expect(result.tool).toBe("fatigue_analysis"); + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.references[0]?.id).toBe("shigley-2015"); + + const mean = result.quantities.find((q) => q.key === "meanStress"); + expect(mean?.unit).toBe("MPa"); + expect(mean?.value).toBeCloseTo(400, 6); + const soderberg = result.quantities.find((q) => q.key === "soderbergFactor"); + expect(soderberg?.value).toBeCloseTo(1 / (200 / 500 + 400 / 950), 6); + expect(result.safetyFactor?.key).toBe("soderbergFactor"); + }); + + it("fails on a zero load", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 0, + alternatingStress: 0, + ultimateStrength: 1000e6, + yieldStrength: 800e6, + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("positive"); + } + }); + + it("fails on a negative alternating stress", () => { + setup(); + const response = handlers.fatigue_analysis({ + meanStress: 100e6, + alternatingStress: -50e6, + ultimateStrength: 1000e6, + yieldStrength: 800e6, + }); + expect(response.ok).toBe(false); + }); +}); + describe("unit_convert tool", () => { it("converts and reports the factor", () => { setup();