diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821e15..3a4920a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,11 @@ jobs: - name: Build run: npm run build + - name: Smoke test the built CLI + run: | + node dist/index.js --version + node dist/index.js --list + - name: Verify the demo runs run: npm run demo diff --git a/README.md b/README.md index e94b177..fc34ada 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ [![CI](https://github.com/DanielCuevas1208/engineer-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/DanielCuevas1208/engineer-mcp/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue.svg)](https://www.typescriptlang.org/) [![Node](https://img.shields.io/badge/Node-%3E%3D22.13-brightgreen.svg)](package.json) +[![Version](https://img.shields.io/badge/version-0.3.0-blue.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 +24,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 +55,7 @@ Warnings surface when a method uses an approximation. | `shaft_analysis` | Torsion stress, twist, and critical speed. | | `bearing_life` | ISO 281 rating life in revolutions and hours. | | `von_mises` | Equivalent stress and yield safety factor. | +| `fatigue_analysis` | Endurance limit, fatigue criteria, and governing safety factor. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | @@ -154,6 +157,28 @@ Error: Category mismatch: N·m is torque, J is energy. Use a unit of the same quantity. ``` +A call to `fatigue_analysis` for a machined 20 mm S355 rod under alternating bending: + +```text +Unmodified endurance limit 245 MPa +Modified endurance limit 156.8 MPa +Equivalent alternating stress 80 MPa +Equivalent mean stress 120 MPa +Soderberg safety factor 1.179 +Modified Goodman safety factor 1.324 +Gerber safety factor 1.643 +ASME-elliptic safety factor 1.634 +Yield safety factor 1.775 +Governing fatigue safety factor 1.179 + +Method: Stress-life fatigue analysis +Formula: Se' = 0.5 Sut, Se = ka kb kc kd ke kf Se', ... +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) +``` + +The fatigue report lists every Marin factor. It shows all four fatigue criteria plus the yield check. The governing factor is the minimum of the five. + ## Development | Command | Purpose | @@ -169,9 +194,9 @@ Use a unit of the same quantity. The test suite is deterministic and offline. It covers the engines, the unit layer, the database, and the tools. -- 98 tests across 10 files. +- 134 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. +- The CI workflow runs typecheck, tests, build, a CLI smoke test, the demo, and a package check. Run `npm test` to reproduce the results. @@ -184,6 +209,9 @@ Run `npm test` to reproduce the results. - The critical speed is a first-mode approximation. - The spring design covers static round-wire springs only. It does not estimate fatigue life for cyclic loads. +- The fatigue analysis is a stress-life estimate for steel. + It does not model notch sensitivity, mean-stress scatter, or crack growth. + Verify the result with testing for a production part. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -197,12 +225,16 @@ 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 applies the Marin endurance-limit modifiers. + It reports the Soderberg, modified Goodman, Gerber, ASME-elliptic, and yield safety factors. +- Dynamic viscosity and thermal conductivity unit categories. + The `unit_convert` tool converts these quantities like every other registered category. ### Remaining -- Add fatigue analysis for cyclic loads. - Add press-fit and interference-fit calculators. -- Add more unit categories, including viscosity and thermal conductivity. +- Add more unit categories, including kinematic viscosity and electric current. - Add HTTP transport. - Add a catalog of ISO and DIN standard sections. 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..c3878e6 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -41,12 +41,13 @@ 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 Marin endurance-limit modifiers and reports the Soderberg, Goodman, Gerber, ASME-elliptic, and yield safety factors. +- Dynamic viscosity and thermal conductivity unit categories. The `unit_convert` tool converts these quantities like every other registered category. ### Remaining -- Add fatigue analysis for cyclic loads. - Add press-fit and interference-fit calculators. -- Add more unit categories, including viscosity and thermal conductivity. +- Add more unit categories, including kinematic viscosity and electric current. - Add HTTP transport in addition to stdio. - Add a catalog of ISO and DIN standard sections. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d38b8c9..892506c 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 fatigue safety factors for a cyclic stress state. + +The tool applies the Marin endurance-limit modifiers. It reports four fatigue criteria and the static yield check. + +Inputs: + +- `alternatingStress`: stress amplitude in pascals. For torsion, the shear amplitude. +- `meanStress`: mean stress in pascals. Use zero for fully reversed loading. +- `loading`: `bending`, `axial`, or `torsion`. +- `material`: material name from the database. Provides the ultimate and yield strength. +- `ultimateStrength` and `yieldStrength`: use these when you have no material. +- `surfaceCondition`: `ground`, `machined`, `cold_drawn`, `hot_rolled`, or `as_forged`. The default is `machined`. +- `diameterMm`: section diameter in millimetres. Sets the size factor. +- `reliabilityPct`: one of `50`, `90`, `95`, `99`, `99.9`, `99.99`, `99.999`, `99.9999`. +- `temperatureC`: operating temperature in degrees Celsius. +- `surfaceFactor`, `sizeFactor`, `loadFactor`, `temperatureFactor`, `reliabilityFactor`, `miscellaneousFactor`: explicit Marin factor overrides. + +Example: + +```json +{ + "material": "Structural steel S355", + "alternatingStress": 80000000, + "meanStress": 120000000, + "loading": "bending", + "diameterMm": 20, + "reliabilityPct": 99 +} +``` + +The tool reports the Soderberg, modified Goodman, Gerber, and ASME-elliptic safety factors. It also reports the yield safety factor. The governing factor is the minimum of all five. Torsion uses the von Mises equivalent stresses. A compressive mean stress is treated as zero, which is the conservative choice. + ## unit_convert Convert a value between two units. @@ -158,6 +192,8 @@ Inputs: The converter rejects mismatched dimensions and mismatched quantity categories. For example, it rejects a torque-to-energy conversion. +The registry covers length, mass, time, angle, temperature, force, pressure, torque, energy, power, velocity, acceleration, area, volume, density, stiffness, frequency, dynamic viscosity, and thermal conductivity. + ## material_lookup Look up mechanical properties of common engineering materials. diff --git a/examples/demo.ts b/examples/demo.ts index 2013998..e5d6ec1 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -76,6 +76,7 @@ type ToolHandlers = { von_mises: Handler; unit_convert: Handler; material_lookup: Handler; + fatigue_analysis: Handler; }; const toolHandlers = handlers as ToolHandlers; @@ -91,6 +92,7 @@ const tools: NamedHandler[] = [ ["unit_convert", toolHandlers.unit_convert], ["unit_convert (torque to energy)", toolHandlers.unit_convert], ["material_lookup", toolHandlers.material_lookup], + ["fatigue_analysis", toolHandlers.fatigue_analysis], ]; const inputs: Array> = [ @@ -158,6 +160,16 @@ const inputs: Array> = [ { query: "steel", }, + { + material: "Structural steel S355", + alternatingStress: 80e6, + meanStress: 120e6, + loading: "bending", + surfaceCondition: "machined", + diameterMm: 20, + reliabilityPct: 99, + outputUnits: { enduranceLimit: "MPa", unmodifiedEnduranceLimit: "MPa", equivalentAlternatingStress: "MPa", equivalentMeanStress: "MPa" }, + }, ]; console.log("Engineer MCP - demo run"); 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..615023a --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,441 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type FatigueLoading = "bending" | "axial" | "torsion"; + +export type FatigueSurface = "ground" | "machined" | "cold_drawn" | "hot_rolled" | "as_forged"; + +export type FatigueInput = { + alternatingStress: number; + meanStress: number; + loading: FatigueLoading; + ultimateStrength: number; + yieldStrength: number; + surfaceCondition?: FatigueSurface; + diameterMm?: number; + reliabilityPct?: number; + temperatureC?: number; + surfaceFactor?: number; + sizeFactor?: number; + loadFactor?: number; + temperatureFactor?: number; + reliabilityFactor?: number; + miscellaneousFactor?: number; +}; + +export type FatigueCriterion = "soderberg" | "goodman" | "gerber" | "asmeElliptic"; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Stress-life fatigue analysis", + formula: + "Se' = 0.5 Sut, Se = ka kb kc kd ke kf Se', 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: + "The unmodified endurance limit assumes steel with a tensile strength up to 1400 MPa. The Marin factors cover surface, size, load, temperature, reliability, and miscellaneous effects. Torsion uses the von Mises equivalent stresses. The tool reports the governing criterion. Verify the result with testing for a production part.", + referenceIds: ["shigley-2015"], +}; + +const SURFACE_SPECS: Record = { + ground: { label: "Ground finish", a: 1.58, b: -0.085 }, + machined: { label: "Machined or cold-drawn finish", a: 4.51, b: -0.265 }, + cold_drawn: { label: "Cold-drawn finish", a: 4.51, b: -0.265 }, + hot_rolled: { label: "Hot-rolled finish", a: 57.7, b: -0.718 }, + as_forged: { label: "As-forged finish", a: 272, b: -0.995 }, +}; + +const TEMPERATURE_FACTORS: ReadonlyArray = [ + [20, 1.0], + [50, 1.01], + [100, 1.02], + [150, 1.025], + [200, 1.02], + [250, 1.0], + [300, 0.975], + [350, 0.943], + [400, 0.9], + [450, 0.843], + [500, 0.768], + [550, 0.672], + [600, 0.549], +]; + +const RELIABILITY_FACTORS: Record = { + 50: 1.0, + 90: 0.897, + 95: 0.868, + 99: 0.814, + 99.9: 0.753, + 99.99: 0.702, + 99.999: 0.659, + 99.9999: 0.62, +}; + +export function surfaceFactor(condition: FatigueSurface, ultimateStrengthPa: number): number { + const spec = SURFACE_SPECS[condition]; + const sutMpa = ultimateStrengthPa / 1e6; + return spec.a * sutMpa ** spec.b; +} + +export function sizeFactor( + loading: FatigueLoading, + diameterMm?: number, +): { value: number; note?: string } { + if (loading === "axial") { + return { + value: 1, + note: "The size factor is 1 for axial loading because there is no stress gradient.", + }; + } + if (diameterMm === undefined) { + return { + value: 1, + note: "No diameter was provided. The size factor is 1, which assumes a small section. Provide diameterMm for a larger section.", + }; + } + if (diameterMm <= 7.62) { + return { value: 1 }; + } + if (diameterMm > 250) { + return { + value: 0.75, + note: "The diameter exceeds 250 mm. The size factor is capped at 0.75.", + }; + } + return { value: 1.24 * diameterMm ** -0.107 }; +} + +export function loadFactor(loading: FatigueLoading): number { + return loading === "axial" ? 0.85 : 1; +} + +export function temperatureFactor( + temperatureC: number, +): { value: number; note?: string } { + if (temperatureC <= 20) { + return { value: 1, note: "The temperature is at or below 20 C. The factor is 1." }; + } + if (temperatureC >= 600) { + return { + value: 0.549, + note: "The temperature is at or above 600 C. The factor is clamped to the table limit.", + }; + } + for (let i = 0; i < TEMPERATURE_FACTORS.length - 1; i += 1) { + const current = TEMPERATURE_FACTORS[i]; + const next = TEMPERATURE_FACTORS[i + 1]; + if (current && next && temperatureC <= next[0]) { + const ratio = (temperatureC - current[0]) / (next[0] - current[0]); + return { value: current[1] + ratio * (next[1] - current[1]) }; + } + } + return { value: 0.549 }; +} + +export function reliabilityFactor(percent: number): number | undefined { + return RELIABILITY_FACTORS[percent]; +} + +export function unmodifiedEnduranceLimit( + ultimateStrengthPa: number, +): { value: number; capped: boolean } { + const half = 0.5 * ultimateStrengthPa; + if (half > 700e6) { + return { value: 700e6, capped: true }; + } + return { value: half, capped: false }; +} + +export function fatigueSafetyFactor( + criterion: FatigueCriterion, + sigmaA: number, + sigmaM: number, + enduranceLimit: number, + ultimateStrength: number, + yieldStrength: number, +): number { + switch (criterion) { + case "soderberg": + return 1 / (sigmaA / enduranceLimit + sigmaM / yieldStrength); + case "goodman": + return 1 / (sigmaA / enduranceLimit + sigmaM / ultimateStrength); + case "gerber": { + const x = sigmaA / enduranceLimit; + const y = sigmaM / ultimateStrength; + if (y === 0) { + return 1 / x; + } + return (Math.sqrt(x ** 2 + 4 * y ** 2) - x) / (2 * y ** 2); + } + case "asmeElliptic": + return 1 / Math.sqrt((sigmaA / enduranceLimit) ** 2 + (sigmaM / yieldStrength) ** 2); + } +} + +export function analyzeFatigue(input: FatigueInput): Computation { + if (!(input.alternatingStress > 0)) { + throw new Error("alternatingStress must be positive."); + } + if (!(input.ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + if (!(input.yieldStrength > 0)) { + throw new Error("yieldStrength must be positive."); + } + + const warnings: string[] = []; + const loading = input.loading; + const surfaceCondition = input.surfaceCondition ?? "machined"; + + const base = unmodifiedEnduranceLimit(input.ultimateStrength); + if (base.capped) { + warnings.push( + "The ultimate strength exceeds 1400 MPa. The unmodified endurance limit is capped at 700 MPa.", + ); + } + + const ka = input.surfaceFactor ?? surfaceFactor(surfaceCondition, input.ultimateStrength); + + const size = input.sizeFactor !== undefined ? { value: input.sizeFactor } : sizeFactor(loading, input.diameterMm); + const kc = input.loadFactor ?? loadFactor(loading); + + const temperature = + input.temperatureFactor !== undefined + ? { value: input.temperatureFactor } + : input.temperatureC === undefined + ? { value: 1 } + : temperatureFactor(input.temperatureC); + + let reliability: { value: number }; + if (input.reliabilityFactor !== undefined) { + reliability = { value: input.reliabilityFactor }; + } else if (input.reliabilityPct === undefined) { + reliability = { value: 1 }; + } else { + const factor = reliabilityFactor(input.reliabilityPct); + if (factor === undefined) { + throw new Error( + `Unsupported reliability percentage: ${input.reliabilityPct}. Use 50, 90, 95, 99, 99.9, 99.99, 99.999, or 99.9999.`, + ); + } + reliability = { value: factor }; + } + + const kf = input.miscellaneousFactor ?? 1; + + if (size.note) { + warnings.push(size.note); + } + if (temperature.note) { + warnings.push(temperature.note); + } + if (loading === "axial") { + warnings.push( + "Axial loading uses a load factor of 0.85. Without a stress gradient, axial loading lowers the endurance limit.", + ); + } + if (loading === "torsion") { + warnings.push( + "Torsion uses the von Mises equivalent stresses. The load factor is 1 because the transformation includes the shear effect.", + ); + } + + const enduranceLimit = ka * size.value * kc * temperature.value * reliability.value * kf * base.value; + + const shearFactor = loading === "torsion" ? Math.sqrt(3) : 1; + const actualMean = shearFactor * input.meanStress; + const equivalentMean = shearFactor * Math.max(input.meanStress, 0); + const equivalentAlternating = shearFactor * input.alternatingStress; + + if (input.meanStress < 0) { + warnings.push( + "The mean stress is compressive. The fatigue criteria use a mean of zero, which is the conservative choice.", + ); + } + + const soderberg = fatigueSafetyFactor( + "soderberg", + equivalentAlternating, + equivalentMean, + enduranceLimit, + input.ultimateStrength, + input.yieldStrength, + ); + const goodman = fatigueSafetyFactor( + "goodman", + equivalentAlternating, + equivalentMean, + enduranceLimit, + input.ultimateStrength, + input.yieldStrength, + ); + const gerber = fatigueSafetyFactor( + "gerber", + equivalentAlternating, + equivalentMean, + enduranceLimit, + input.ultimateStrength, + input.yieldStrength, + ); + const asmeElliptic = fatigueSafetyFactor( + "asmeElliptic", + equivalentAlternating, + equivalentMean, + enduranceLimit, + input.ultimateStrength, + input.yieldStrength, + ); + + const maxCycleStress = equivalentAlternating + actualMean; + const yieldFactor = maxCycleStress > 0 ? input.yieldStrength / maxCycleStress : Number.POSITIVE_INFINITY; + + const governing = Math.min(soderberg, goodman, gerber, asmeElliptic, yieldFactor); + + const quantities: Quantity[] = [ + { + key: "unmodifiedEnduranceLimit", + label: "Unmodified endurance limit", + value: base.value, + unit: "Pa", + description: "Fully reversed endurance limit of a polished rotating-beam specimen, before the Marin factors.", + }, + { + key: "surfaceFactor", + label: "Surface factor", + value: ka, + unit: "", + description: `Surface finish factor k_a for a ${SURFACE_SPECS[surfaceCondition].label.toLowerCase()}.`, + }, + { + key: "sizeFactor", + label: "Size factor", + value: size.value, + unit: "", + description: "Size factor k_b for the section geometry.", + }, + { + key: "loadFactor", + label: "Load factor", + value: kc, + unit: "", + description: "Load factor k_c for the loading type.", + }, + { + key: "temperatureFactor", + label: "Temperature factor", + value: temperature.value, + unit: "", + description: "Temperature factor k_d for the operating temperature.", + }, + { + key: "reliabilityFactor", + label: "Reliability factor", + value: reliability.value, + unit: "", + description: "Reliability factor k_e for the chosen survival probability.", + }, + { + key: "miscellaneousFactor", + label: "Miscellaneous factor", + value: kf, + unit: "", + description: "Miscellaneous factor k_f for stress concentrations and other effects.", + }, + { + key: "enduranceLimit", + label: "Modified endurance limit", + value: enduranceLimit, + unit: "Pa", + description: "Endurance limit of the part after all Marin factors.", + }, + { + key: "equivalentAlternatingStress", + label: "Equivalent alternating stress", + value: equivalentAlternating, + unit: "Pa", + description: loading === "torsion" + ? "von Mises equivalent of the shear stress amplitude." + : "Alternating stress amplitude for the fatigue cycle.", + }, + { + key: "equivalentMeanStress", + label: "Equivalent mean stress", + value: equivalentMean, + unit: "Pa", + description: loading === "torsion" + ? "von Mises equivalent of the shear mean stress." + : "Mean stress for the fatigue cycle.", + }, + { + key: "soderbergSafetyFactor", + label: "Soderberg safety factor", + value: soderberg, + unit: "", + description: "Fatigue safety factor using the Soderberg line.", + }, + { + key: "goodmanSafetyFactor", + label: "Modified Goodman safety factor", + value: goodman, + unit: "", + description: "Fatigue safety factor using the modified Goodman line.", + }, + { + key: "gerberSafetyFactor", + label: "Gerber safety factor", + value: gerber, + unit: "", + description: "Fatigue safety factor using the Gerber parabola.", + }, + { + key: "asmeEllipticSafetyFactor", + label: "ASME-elliptic safety factor", + value: asmeElliptic, + unit: "", + description: "Fatigue safety factor using the ASME-elliptic line.", + }, + ]; + + if (Number.isFinite(yieldFactor)) { + quantities.push({ + key: "yieldSafetyFactor", + label: "Yield safety factor", + value: yieldFactor, + unit: "", + description: "Yield strength divided by the maximum stress of the cycle.", + }); + } else { + warnings.push("The maximum stress of the cycle is compressive. The static yield check is skipped."); + } + + return { + method: FATIGUE_METHOD, + inputs: { + alternatingStress: input.alternatingStress, + meanStress: input.meanStress, + loading, + ultimateStrength: input.ultimateStrength, + yieldStrength: input.yieldStrength, + surfaceCondition, + surfaceFactor: ka, + sizeFactor: size.value, + loadFactor: kc, + temperatureFactor: temperature.value, + reliabilityFactor: reliability.value, + miscellaneousFactor: kf, + diameterMm: input.diameterMm, + reliabilityPct: input.reliabilityPct, + temperatureC: input.temperatureC, + enduranceLimit, + governingCriterion: "minimum of all criteria", + }, + quantities, + safetyFactor: { + key: "fatigueSafetyFactor", + label: "Governing fatigue safety factor", + value: governing, + unit: "", + description: "Minimum of the Soderberg, Goodman, Gerber, ASME-elliptic, and yield factors.", + }, + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 92492d7..1809444 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,3 +1,18 @@ +export { + analyzeFatigue, + FATIGUE_METHOD, + fatigueSafetyFactor, + loadFactor, + reliabilityFactor, + sizeFactor, + surfaceFactor, + temperatureFactor, + unmodifiedEnduranceLimit, + type FatigueCriterion, + type FatigueInput, + type FatigueLoading, + type FatigueSurface, +} from "./fatigue.js"; 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"; diff --git a/src/handlers.ts b/src/handlers.ts index c646e73..805a66f 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -3,10 +3,13 @@ import { analyzeBeam, analyzeBearing, analyzeBolt, + analyzeFatigue, analyzeShaft, analyzeSpring, computeSection, vonMises, + type FatigueLoading, + type FatigueSurface, type SectionDef, type SpringEndType, } from "./engine/index.js"; @@ -106,6 +109,7 @@ type MaterialValues = { shearModulusPa?: number; densityKgM3?: number; yieldStrengthPa?: number; + ultimateStrengthPa: number; }; function materialValues(ctx: AppContext, name: string): MaterialValues | undefined { @@ -118,6 +122,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, }; } @@ -437,6 +442,52 @@ function materialHandler(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; + if (!yieldStrength) { + return failure( + "fatigue_analysis", + "Provide yieldStrength. The selected material has no yield value in the database.", + input, + ); + } + + try { + const computation = analyzeFatigue({ + alternatingStress: input.alternatingStress as number, + meanStress: input.meanStress as number, + loading: input.loading as FatigueLoading, + ultimateStrength, + yieldStrength, + surfaceCondition: input.surfaceCondition as FatigueSurface | undefined, + diameterMm: input.diameterMm as number | undefined, + reliabilityPct: input.reliabilityPct as number | undefined, + temperatureC: input.temperatureC 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, + }); + 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); + } + }; +} + export function createHandlers(ctx: AppContext): Record { return { beam_bending: beamHandler(ctx), @@ -448,5 +499,6 @@ export function createHandlers(ctx: AppContext): Record { von_mises: stressHandler(ctx), unit_convert: unitConvertHandler(ctx), material_lookup: materialHandler(ctx), + fatigue_analysis: fatigueHandler(ctx), }; } diff --git a/src/schemas.ts b/src/schemas.ts index 7204c18..e700ff7 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -131,6 +131,40 @@ export const materialSchema = z.object({ limit: z.number().int().min(1).max(50).optional().describe("Maximum number of rows to return. Defaults to 10."), }); +export const fatigueSchema = z.object({ + alternatingStress: z.number().positive().describe( + "Alternating stress amplitude in pascals. For torsion loading, this is the shear stress amplitude.", + ), + meanStress: z.number().describe( + "Mean stress in pascals. Use zero for fully reversed loading. For torsion loading, this is the shear mean stress.", + ), + loading: z.enum(["bending", "axial", "torsion"]).describe("Loading type. Sets the load factor and the stress transformation."), + 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 material is not set."), + yieldStrength: z.number().positive().optional().describe("Tensile yield strength in pascals. Required for the Soderberg, ASME-elliptic, and yield checks."), + surfaceCondition: z + .enum(["ground", "machined", "cold_drawn", "hot_rolled", "as_forged"]) + .optional() + .describe("Surface finish of the part. Sets the surface factor. Defaults to machined."), + diameterMm: z.number().positive().optional().describe("Section diameter in millimetres. Sets the size factor for bending and torsion."), + reliabilityPct: z + .union([z.literal(50), z.literal(90), z.literal(95), z.literal(99), z.literal(99.9), z.literal(99.99), z.literal(99.999), z.literal(99.9999)]) + .optional() + .describe("Desired reliability as a percentage. Sets the reliability factor. Defaults to 50."), + temperatureC: z.number().optional().describe("Operating temperature in degrees Celsius. Sets the temperature factor."), + surfaceFactor: z.number().positive().optional().describe("Override the surface factor k_a."), + sizeFactor: z.number().positive().optional().describe("Override the size factor k_b."), + loadFactor: z.number().positive().optional().describe("Override the load factor k_c."), + temperatureFactor: z.number().positive().optional().describe("Override the temperature factor k_d."), + reliabilityFactor: z.number().positive().optional().describe("Override the reliability factor k_e."), + miscellaneousFactor: z + .number() + .positive() + .optional() + .describe("Miscellaneous factor k_f for stress concentrations and other effects. Defaults to 1."), + outputUnits, +}); + export type BeamInput = z.infer; export type BoltInput = z.infer; export type ShaftInput = z.infer; @@ -140,3 +174,4 @@ export type SectionPropsInput = z.infer; export type StressInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; +export type FatigueInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2029a75..82600f0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { beamSchema, bearingSchema, boltSchema, + fatigueSchema, materialSchema, sectionPropsSchema, shaftSchema, @@ -60,6 +61,11 @@ const TOOL_SCHEMAS: Record = { description: "Look up mechanical properties for common engineering materials from the curated database.", schema: materialSchema, }, + fatigue_analysis: { + description: + "Fatigue safety factors for a cyclic stress state. Uses the Soderberg, modified Goodman, Gerber, and ASME-elliptic criteria with Marin endurance-limit modifiers.", + schema: fatigueSchema, + }, }; function asStructuredContent(value: ToolResult): Record { diff --git a/src/units/dimensions.ts b/src/units/dimensions.ts index 67dc684..19f01bc 100644 --- a/src/units/dimensions.ts +++ b/src/units/dimensions.ts @@ -19,6 +19,8 @@ export const DIM_VOLUME: Dimension = [3, 0, 0, 0, 0]; export const DIM_DENSITY: Dimension = [-3, 1, 0, 0, 0]; export const DIM_STIFFNESS: Dimension = [0, 1, -2, 0, 0]; export const DIM_FREQUENCY: Dimension = [0, 0, -1, 0, 0]; +export const DIM_DYNAMIC_VISCOSITY: Dimension = [-1, 1, -1, 0, 0]; +export const DIM_THERMAL_CONDUCTIVITY: Dimension = [1, 1, -3, -1, 0]; export function dimensionsEqual(a: Dimension, b: Dimension): boolean { for (let i = 0; i < a.length; i += 1) { diff --git a/src/units/registry.ts b/src/units/registry.ts index 64eeff9..b7294f8 100644 --- a/src/units/registry.ts +++ b/src/units/registry.ts @@ -3,6 +3,7 @@ import { DIM_ANGLE, DIM_AREA, DIM_DENSITY, + DIM_DYNAMIC_VISCOSITY, DIM_ENERGY, DIM_FORCE, DIM_FREQUENCY, @@ -12,6 +13,7 @@ import { DIM_PRESSURE, DIM_STIFFNESS, DIM_TEMPERATURE, + DIM_THERMAL_CONDUCTIVITY, DIM_TIME, DIM_TORQUE, DIM_VELOCITY, @@ -140,6 +142,17 @@ export const UNITS: UnitDef[] = [ unit({ canonical: "Hz", name: "hertz", category: "frequency", dim: DIM_FREQUENCY, factor: 1 }), unit({ canonical: "rpm", name: "revolution per minute", category: "frequency", dim: DIM_FREQUENCY, factor: 1 / 60 }), unit({ canonical: "kHz", name: "kilohertz", category: "frequency", dim: DIM_FREQUENCY, factor: 1e3 }), + + unit({ canonical: "Pa·s", aliases: ["Pa.s", "Pas"], name: "pascal second", category: "dynamic_viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1 }), + unit({ canonical: "mPa·s", aliases: ["mPa.s", "mPas"], name: "millipascal second", category: "dynamic_viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1e-3 }), + unit({ canonical: "P", name: "poise", category: "dynamic_viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 0.1 }), + unit({ canonical: "cP", name: "centipoise", category: "dynamic_viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1e-3 }), + unit({ canonical: "lbf·s/in2", aliases: ["lbf.s/in2"], name: "pound-force second per square inch", category: "dynamic_viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 6894.757293168 }), + + unit({ canonical: "W/(m·K)", aliases: ["W/(mK)", "W/mK", "W/m.K"], name: "watt per metre kelvin", category: "thermal_conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1 }), + unit({ canonical: "W/(m·°C)", aliases: ["W/(mC)", "W/mC", "W/m.C"], name: "watt per metre degree Celsius", category: "thermal_conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1 }), + unit({ canonical: "BTU/(ft·h·°F)", aliases: ["BTU/(ft.h.F)", "BTU/(fthF)"], name: "BTU per foot hour degree Fahrenheit", category: "thermal_conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1.730735 }), + unit({ canonical: "kcal/(m·h·°C)", aliases: ["kcal/(m.h.C)", "kcal/(mhC)"], name: "kilocalorie per metre hour degree Celsius", category: "thermal_conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1.163 }), ]; export function normalizeSymbol(symbol: string): string { diff --git a/src/version.ts b/src/version.ts index 9e86f0c..03714b1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/fatigue.test.ts b/tests/fatigue.test.ts new file mode 100644 index 0000000..b2a79a6 --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFatigue, + fatigueSafetyFactor, + loadFactor, + reliabilityFactor, + sizeFactor, + surfaceFactor, + temperatureFactor, + unmodifiedEnduranceLimit, +} from "../src/engine/fatigue.js"; + +const SUT = 490e6; +const SY = 355e6; +const SE_UNMODIFIED = 0.5 * SUT; +const KA_MACHINED = 4.51 * 490 ** -0.265; +const SE_BENDING = KA_MACHINED * SE_UNMODIFIED; + +describe("fatigue helper factors", () => { + it("computes the machined surface factor", () => { + expect(surfaceFactor("machined", SUT)).toBeCloseTo(KA_MACHINED, 9); + expect(surfaceFactor("machined", SUT)).toBeCloseTo(0.8733, 3); + }); + + it("computes every surface factor", () => { + expect(surfaceFactor("ground", SUT)).toBeCloseTo(1.58 * 490 ** -0.085, 9); + expect(surfaceFactor("cold_drawn", SUT)).toBeCloseTo(KA_MACHINED, 9); + expect(surfaceFactor("hot_rolled", SUT)).toBeCloseTo(57.7 * 490 ** -0.718, 9); + expect(surfaceFactor("as_forged", SUT)).toBeCloseTo(272 * 490 ** -0.995, 9); + }); + + it("returns a size factor of 1 for axial loading", () => { + const result = sizeFactor("axial"); + expect(result.value).toBe(1); + expect(result.note).toBeDefined(); + }); + + it("returns a size factor of 1 for a small section", () => { + expect(sizeFactor("bending", 6).value).toBe(1); + expect(sizeFactor("bending").value).toBe(1); + }); + + it("computes the size factor for a 20 mm section", () => { + expect(sizeFactor("bending", 20).value).toBeCloseTo(1.24 * 20 ** -0.107, 6); + expect(sizeFactor("bending", 20).value).toBeCloseTo(0.8999, 3); + }); + + it("caps the size factor for a very large section", () => { + const result = sizeFactor("bending", 500); + expect(result.value).toBe(0.75); + expect(result.note).toBeDefined(); + }); + + it("sets the load factor by loading type", () => { + expect(loadFactor("bending")).toBe(1); + expect(loadFactor("torsion")).toBe(1); + expect(loadFactor("axial")).toBe(0.85); + }); + + it("interpolates the temperature factor", () => { + expect(temperatureFactor(20).value).toBe(1); + expect(temperatureFactor(100).value).toBe(1.02); + expect(temperatureFactor(150).value).toBe(1.025); + expect(temperatureFactor(175).value).toBeCloseTo(1.0225, 9); + expect(temperatureFactor(200).value).toBe(1.02); + expect(temperatureFactor(600).value).toBe(0.549); + }); + + it("clamps the temperature factor outside the table", () => { + expect(temperatureFactor(10).value).toBe(1); + expect(temperatureFactor(650).value).toBe(0.549); + expect(temperatureFactor(650).note).toBeDefined(); + }); + + it("maps reliability percentages to factors", () => { + expect(reliabilityFactor(50)).toBe(1); + expect(reliabilityFactor(90)).toBe(0.897); + expect(reliabilityFactor(95)).toBe(0.868); + expect(reliabilityFactor(99)).toBe(0.814); + expect(reliabilityFactor(99.9)).toBe(0.753); + expect(reliabilityFactor(99.9999)).toBe(0.62); + expect(reliabilityFactor(85)).toBeUndefined(); + }); + + it("computes the unmodified endurance limit", () => { + expect(unmodifiedEnduranceLimit(SUT).value).toBeCloseTo(245e6, 6); + expect(unmodifiedEnduranceLimit(SUT).capped).toBe(false); + expect(unmodifiedEnduranceLimit(1500e6).value).toBe(700e6); + expect(unmodifiedEnduranceLimit(1500e6).capped).toBe(true); + }); +}); + +describe("fatigueSafetyFactor", () => { + it("matches hand-computed factors for a mean stress case", () => { + const sigmaA = 80e6; + const sigmaM = 120e6; + expect(fatigueSafetyFactor("soderberg", sigmaA, sigmaM, SE_BENDING, SUT, SY)).toBeCloseTo(1.40465, 3); + expect(fatigueSafetyFactor("goodman", sigmaA, sigmaM, SE_BENDING, SUT, SY)).toBeCloseTo(1.61605, 3); + expect(fatigueSafetyFactor("gerber", sigmaA, sigmaM, SE_BENDING, SUT, SY)).toBeCloseTo(2.02003, 3); + expect(fatigueSafetyFactor("asmeElliptic", sigmaA, sigmaM, SE_BENDING, SUT, SY)).toBeCloseTo(1.98397, 3); + }); + + it("uses the endurance ratio when the mean is zero", () => { + const n = fatigueSafetyFactor("gerber", 100e6, 0, SE_BENDING, SUT, SY); + expect(n).toBeCloseTo(SE_BENDING / 100e6, 9); + }); +}); + +describe("analyzeFatigue", () => { + it("computes a fully reversed bending case", () => { + const result = analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + }); + + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.quantities.find((q) => q.key === "surfaceFactor")?.value).toBeCloseTo(KA_MACHINED, 9); + expect(result.quantities.find((q) => q.key === "enduranceLimit")?.value).toBeCloseTo(SE_BENDING, 6); + expect(result.quantities.find((q) => q.key === "soderbergSafetyFactor")?.value).toBeCloseTo(SE_BENDING / 100e6, 6); + expect(result.quantities.find((q) => q.key === "gerberSafetyFactor")?.value).toBeCloseTo(SE_BENDING / 100e6, 6); + expect(result.quantities.find((q) => q.key === "yieldSafetyFactor")?.value).toBeCloseTo(3.55, 6); + expect(result.safetyFactor?.value).toBeCloseTo(SE_BENDING / 100e6, 6); + expect(result.referenceIds).toContain("shigley-2015"); + }); + + it("reports the governing criterion for a mean stress case", () => { + const result = analyzeFatigue({ + alternatingStress: 80e6, + meanStress: 120e6, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + }); + expect(result.safetyFactor?.value).toBeCloseTo(1.40465, 3); + expect(result.quantities.find((q) => q.key === "soderbergSafetyFactor")?.value).toBeCloseTo(1.40465, 3); + expect(result.quantities.find((q) => q.key === "goodmanSafetyFactor")?.value).toBeCloseTo(1.61605, 3); + }); + + it("applies the von Mises transformation for torsion", () => { + const result = analyzeFatigue({ + alternatingStress: 60e6, + meanStress: 40e6, + loading: "torsion", + ultimateStrength: SUT, + yieldStrength: SY, + }); + const alternating = result.quantities.find((q) => q.key === "equivalentAlternatingStress"); + expect(alternating?.value).toBeCloseTo(Math.sqrt(3) * 60e6, 6); + const mean = result.quantities.find((q) => q.key === "equivalentMeanStress"); + expect(mean?.value).toBeCloseTo(Math.sqrt(3) * 40e6, 6); + expect(result.warnings.some((w) => w.includes("von Mises"))).toBe(true); + }); + + it("applies the size and reliability factors", () => { + const result = analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + diameterMm: 20, + reliabilityPct: 99, + }); + const size = result.quantities.find((q) => q.key === "sizeFactor"); + expect(size?.value).toBeCloseTo(1.24 * 20 ** -0.107, 6); + const reliability = result.quantities.find((q) => q.key === "reliabilityFactor"); + expect(reliability?.value).toBe(0.814); + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(endurance?.value).toBeCloseTo(KA_MACHINED * (1.24 * 20 ** -0.107) * 0.814 * SE_UNMODIFIED, 6); + }); + + it("honours explicit factor overrides", () => { + const result = analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + surfaceFactor: 0.9, + sizeFactor: 0.9, + loadFactor: 0.8, + reliabilityFactor: 0.9, + miscellaneousFactor: 0.85, + temperatureFactor: 0.95, + }); + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + const expected = 0.9 * 0.9 * 0.8 * 0.95 * 0.9 * 0.85 * SE_UNMODIFIED; + expect(endurance?.value).toBeCloseTo(expected, 6); + }); + + it("warns and caps the endurance limit for a very strong steel", () => { + const result = analyzeFatigue({ + alternatingStress: 200e6, + meanStress: 0, + loading: "bending", + ultimateStrength: 1500e6, + yieldStrength: 1200e6, + }); + expect(result.quantities.find((q) => q.key === "unmodifiedEnduranceLimit")?.value).toBe(700e6); + expect(result.warnings.some((w) => w.includes("1400 MPa"))).toBe(true); + }); + + it("treats a compressive mean stress conservatively", () => { + const result = analyzeFatigue({ + alternatingStress: 100e6, + meanStress: -20e6, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + }); + expect(result.warnings.some((w) => w.includes("compressive"))).toBe(true); + expect(result.quantities.find((q) => q.key === "soderbergSafetyFactor")?.value).toBeCloseTo(SE_BENDING / 100e6, 6); + expect(result.quantities.find((q) => q.key === "yieldSafetyFactor")?.value).toBeCloseTo(355e6 / 80e6, 6); + }); + + it("skips the yield check for a fully compressive cycle", () => { + const result = analyzeFatigue({ + alternatingStress: 100e6, + meanStress: -200e6, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + }); + expect(result.quantities.find((q) => q.key === "yieldSafetyFactor")).toBeUndefined(); + expect(result.warnings.some((w) => w.includes("static yield check is skipped"))).toBe(true); + expect(result.safetyFactor?.value).toBeCloseTo(SE_BENDING / 100e6, 6); + }); + + it("carries a warning when no diameter is given", () => { + const result = analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + }); + expect(result.warnings.some((w) => w.includes("No diameter"))).toBe(true); + }); +}); + +describe("analyzeFatigue validation", () => { + it("rejects a non-positive alternating stress", () => { + expect(() => + analyzeFatigue({ + alternatingStress: 0, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + }), + ).toThrow("alternatingStress"); + }); + + it("rejects a non-positive ultimate strength", () => { + expect(() => + analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: 0, + yieldStrength: SY, + }), + ).toThrow("ultimateStrength"); + }); + + it("rejects a non-positive yield strength", () => { + expect(() => + analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: 0, + }), + ).toThrow("yieldStrength"); + }); + + it("rejects an unsupported reliability percentage", () => { + expect(() => + analyzeFatigue({ + alternatingStress: 100e6, + meanStress: 0, + loading: "bending", + ultimateStrength: SUT, + yieldStrength: SY, + reliabilityPct: 85, + }), + ).toThrow("Unsupported reliability"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index dd63bf0..773d3da 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -15,6 +15,7 @@ type Handlers = { von_mises: Handler; unit_convert: Handler; material_lookup: Handler; + fatigue_analysis: Handler; }; let ctx: AppContext; @@ -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", @@ -43,6 +44,7 @@ describe("tool registry", () => { "von_mises", "unit_convert", "material_lookup", + "fatigue_analysis", ].sort(), ); }); @@ -318,3 +320,50 @@ describe("material_lookup tool", () => { expect(response.ok).toBe(false); }); }); + +describe("fatigue_analysis tool", () => { + it("computes fatigue factors from a material and converts the endurance unit", () => { + setup(); + const response = handlers.fatigue_analysis({ + material: "Structural steel S355", + alternatingStress: 80e6, + meanStress: 120e6, + loading: "bending", + diameterMm: 20, + reliabilityPct: 99, + outputUnits: { enduranceLimit: "MPa", equivalentAlternatingStress: "MPa" }, + }); + const result = expectOk(response); + expect(result.tool).toBe("fatigue_analysis"); + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.references.length).toBeGreaterThan(0); + const endurance = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(endurance?.unit).toBe("MPa"); + expect(endurance?.value).toBeGreaterThan(0); + expect(result.safetyFactor).toBeDefined(); + }); + + it("reports an unknown material", () => { + setup(); + const response = handlers.fatigue_analysis({ + material: "Unobtainium", + alternatingStress: 80e6, + meanStress: 0, + loading: "bending", + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("Unknown material"); + } + }); + + it("rejects missing strength data", () => { + setup(); + const response = handlers.fatigue_analysis({ + alternatingStress: 80e6, + meanStress: 0, + loading: "bending", + }); + expect(response.ok).toBe(false); + }); +}); diff --git a/tests/units.test.ts b/tests/units.test.ts index 8839b35..07ba00c 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -135,3 +135,66 @@ describe("stiffness units", () => { } }); }); + +describe("dynamic viscosity units", () => { + it("converts pascal seconds to centipoise", () => { + const outcome = convertUnit(1, "Pa·s", "cP"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1000, 9); + expect(outcome.category).toBe("dynamic_viscosity"); + expect(outcome.siSymbol).toBe("Pa·s"); + } + }); + + it("converts poise to pascal seconds", () => { + const outcome = convertUnit(1, "P", "Pa·s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(0.1, 9); + } + }); + + it("converts millipascal seconds to centipoise", () => { + const outcome = convertUnit(1, "mPa·s", "cP"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1, 9); + } + }); + + it("rejects a viscosity-to-pressure conversion", () => { + const outcome = convertUnit(10, "Pa·s", "Pa"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +}); + +describe("thermal conductivity units", () => { + it("converts BTU per foot hour degree Fahrenheit", () => { + const outcome = convertUnit(1, "BTU/(ft·h·°F)", "W/(m·K)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1.730735, 6); + expect(outcome.category).toBe("thermal_conductivity"); + } + }); + + it("converts kilocalorie per metre hour degree Celsius", () => { + const outcome = convertUnit(1, "kcal/(m·h·°C)", "W/(m·K)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1.163, 6); + } + }); + + it("finds the SI symbol for thermal conductivity", () => { + const outcome = convertUnit(1, "W/(m·°C)", "BTU/(ft·h·°F)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.siSymbol).toBe("W/(m·K)"); + } + }); +});