From b60eb2adfd0ec7ac013d6c18210ac327fba39b96 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:36:56 -0700 Subject: [PATCH] feat: extend engineer mcp --- .github/workflows/ci.yml | 3 + .gitignore | 1 + README.md | 43 ++++++- docs/integration.md | 7 +- docs/mcp-tools.md | 34 ++++++ examples/demo.ts | 13 ++ package-lock.json | 4 +- package.json | 10 +- src/engine/index.ts | 9 ++ src/engine/spring.ts | 205 +++++++++++++++++++++++++++++++ src/handlers.ts | 23 ++++ src/schemas.ts | 20 +++ src/server.ts | 6 + src/units/dimensions.ts | 1 + src/units/registry.ts | 6 + src/version.ts | 2 +- tests/spring.test.ts | 257 +++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 70 ++++++++++- tests/units.test.ts | 28 +++++ 19 files changed, 731 insertions(+), 11 deletions(-) create mode 100644 src/engine/spring.ts create mode 100644 tests/spring.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1e0008..2821e15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,6 @@ jobs: - name: Verify the demo runs run: npm run demo + + - name: Verify the package contents + run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index c6c3196..b00605b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ coverage/ *.tsbuildinfo +*.tgz *.sqlite *.sqlite-journal diff --git a/README.md b/README.md index c69aca2..e94b177 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Engineer MCP +[![CI](https://github.com/DanielCuevas1208/engineer-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/DanielCuevas1208/engineer-mcp/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/) [![Node](https://img.shields.io/badge/Node-%3E%3D22.13-brightgreen.svg)](package.json) Engineer MCP is a Model Context Protocol server for mechanical-engineering calculations. -It gives coding agents verified answers for beams, bolts, shafts, bearings, stress, sections, and units. +It gives coding agents verified answers for beams, bolts, springs, shafts, bearings, stress, sections, and units. Every result shows the formula, the method, and the source. ## What it provides @@ -18,6 +19,7 @@ The release covers these domains: - Beam bending stress and deflection. - Bolt tensile design to ISO 898. +- Helical compression spring design. - Shaft torsion and first critical speed. - Bearing rating life to ISO 281. - von Mises equivalent stress. @@ -47,6 +49,7 @@ Warnings surface when a method uses an approximation. | `beam_bending` | Bending stress, deflection, and safety factor. | | `section_properties` | Area, inertia, and section modulus of a shape. | | `bolt_strength` | Stress area, preload, and capacity of a bolt. | +| `spring_design` | Spring rate, shear stress, and safety factor of a compression spring. | | `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. | @@ -124,6 +127,26 @@ References: - Mechanics of Materials (Euler-Bernoulli beam theory) ``` +A call to `spring_design` for a steel spring under 2 kN with squared and ground ends: + +```text +Spring index 5 +Wahl factor 1.31 +Total coils 6 +Solid height 48 mm +Spring rate 158.6 N/mm +Deflection at load 12.61 mm +Working length 77.39 mm +Maximum shear stress 521.4 MPa +Spring safety factor 1.342 + +Method: Helical compression spring design +Formula: C = D/d, K_w = (4C-1)/(4C-4) + 0.615/C, tau = K_w 8FD/(pi d^3), k = G d^4/(8 D^3 Na), delta = F/k, Ls = d Nt +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) + - Machinery's Handbook (Thirty-first edition) +``` + A call to `unit_convert` with a torque-to-energy request fails safely: ```text @@ -146,9 +169,9 @@ Use a unit of the same quantity. The test suite is deterministic and offline. It covers the engines, the unit layer, the database, and the tools. -- 73 tests across 9 files. +- 98 tests across 10 files. - All tests pass on Node 22 and Node 24. -- The CI workflow runs typecheck, tests, build, and demo. +- The CI workflow runs typecheck, tests, build, demo, and a package check. Run `npm test` to reproduce the results. @@ -159,6 +182,8 @@ Run `npm test` to reproduce the results. - The bolt tables cover coarse metric threads from M5 to M36. - The bearing factors are typical values for deep-groove ball bearings. - The critical speed is a first-mode approximation. +- The spring design covers static round-wire springs only. + It does not estimate fatigue life for cyclic loads. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -168,10 +193,18 @@ Check the cited sources for exact values. The server grows in independent releases. Each release stays useful on its own. +### Complete + +- Helical compression spring design. + The `spring_design` tool reports the spring rate, the shear stress, and the safety factor. + +### Remaining + - Add fatigue analysis for cyclic loads. -- Add press-fit and spring calculators. -- Add more unit categories. +- Add press-fit and interference-fit calculators. +- Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. +- Add a catalog of ISO and DIN standard sections. See [docs/integration.md](docs/integration.md) for the EngineerKit plan. diff --git a/docs/integration.md b/docs/integration.md index 82950af..042b7d4 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -38,9 +38,14 @@ This server follows semantic versioning. Breaking a tool signature or the result Engineer MCP grows in independent releases. Each release stays useful on its own. +### Complete + +- Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. + +### Remaining + - Add fatigue analysis for cyclic loads. - Add press-fit and interference-fit calculators. -- Add a spring calculator for helical compression springs. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport 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 389c5a9..d38b8c9 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -76,6 +76,40 @@ Inputs: - `preloadFraction`: preload as a fraction of proof load. The default is `0.75`. - `pitchMm`: thread pitch. The default comes from the database. +## spring_design + +Compute the geometry, spring rate, shear stress, and safety factor of a helical compression spring. + +The tool uses the Wahl factor for the shear stress. It follows Shigley for the coil counts and the solid height. + +Inputs: + +- `wireDiameter`: wire diameter in metres. +- `meanDiameter`: mean coil diameter in metres. +- `activeCoils`: number of active coils. +- `endType`: `plain`, `plain_ground`, `squared`, or `squared_ground`. The default is `squared_ground`. +- `freeLength`: free length in metres. +- `load`: applied axial load in newtons. Zero checks the geometry only. +- `shearModulus`: shear modulus in pascals. +- `shearYieldStrength`: torsional yield strength in pascals. Enables the safety factor. + +Example: + +```json +{ + "wireDiameter": 0.008, + "meanDiameter": 0.04, + "activeCoils": 4, + "endType": "squared_ground", + "freeLength": 0.09, + "load": 2000, + "shearModulus": 79300000000, + "shearYieldStrength": 700000000 +} +``` + +The tool warns on a spring index below 4 or above 12. It warns when the free-length ratio risks buckling. It warns when the load compresses the spring to solid height. + ## shaft_analysis Compute the torsion stress, angle of twist, and first lateral critical speed of a shaft. diff --git a/examples/demo.ts b/examples/demo.ts index 6a5507c..2013998 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -70,6 +70,7 @@ type ToolHandlers = { beam_bending: Handler; section_properties: Handler; bolt_strength: Handler; + spring_design: Handler; shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; @@ -83,6 +84,7 @@ const tools: NamedHandler[] = [ ["beam_bending", toolHandlers.beam_bending], ["section_properties", toolHandlers.section_properties], ["bolt_strength", toolHandlers.bolt_strength], + ["spring_design", toolHandlers.spring_design], ["shaft_analysis", toolHandlers.shaft_analysis], ["bearing_life", toolHandlers.bearing_life], ["von_mises", toolHandlers.von_mises], @@ -110,6 +112,17 @@ const inputs: Array> = [ axialLoad: 30000, outputUnits: { recommendedPreload: "kN" }, }, + { + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + endType: "squared_ground", + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + shearYieldStrength: 700e6, + outputUnits: { maxShearStress: "MPa", springRate: "N/mm", deflection: "mm", solidHeight: "mm", workingLength: "mm" }, + }, { outerDiameter: 0.05, length: 1.2, diff --git a/package-lock.json b/package-lock.json index 5c3628d..5c98405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 197dbf4..49eb29e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,18 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.1.0", + "version": "0.2.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", "author": "EngineerKit", + "repository": { + "type": "git", + "url": "git+https://github.com/DanielCuevas1208/engineer-mcp.git" + }, + "bugs": { + "url": "https://github.com/DanielCuevas1208/engineer-mcp/issues" + }, + "homepage": "https://github.com/DanielCuevas1208/engineer-mcp#readme", "engines": { "node": ">=22.13.0" }, diff --git a/src/engine/index.ts b/src/engine/index.ts index acf9449..92492d7 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -3,4 +3,13 @@ export { analyzeBearing, BEARING_METHOD, equivalentLoad, type BearingInput, type export { analyzeBolt, BOLT_METHOD, tensileStressArea, type BoltGradeData, type BoltInput } from "./bolt.js"; export { computeSection, SECTION_METHOD, type SectionDef, type SectionProperties } from "./sections.js"; export { analyzeShaft, SHAFT_METHOD, type ShaftInput } from "./shaft.js"; +export { + analyzeSpring, + solidHeight, + SPRING_METHOD, + totalCoils, + wahlFactor, + type SpringEndType, + type SpringInput, +} from "./spring.js"; export { vonMises, VON_MISES_METHOD, type StressInput, type StressMode } from "./stress.js"; diff --git a/src/engine/spring.ts b/src/engine/spring.ts new file mode 100644 index 0000000..0c40d93 --- /dev/null +++ b/src/engine/spring.ts @@ -0,0 +1,205 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type SpringEndType = "plain" | "plain_ground" | "squared" | "squared_ground"; + +export type SpringInput = { + wireDiameter: number; + meanDiameter: number; + activeCoils: number; + endType?: SpringEndType; + freeLength: number; + load: number; + shearModulus: number; + shearYieldStrength?: number; +}; + +export const SPRING_METHOD: MethodRecord = { + id: "spring-design", + name: "Helical compression spring design", + formula: + "C = D/d, K_w = (4C-1)/(4C-4) + 0.615/C, tau = K_w 8FD/(pi d^3), k = G d^4/(8 D^3 Na), delta = F/k, Ls = d Nt", + notes: + "Round-wire closed-form geometry. The Wahl factor corrects the shear stress for curvature and direct shear. The total coil count and the solid height follow the end condition. Check fatigue life separately for cyclic loads.", + referenceIds: ["shigley-2015", "machinery-handbook"], +}; + +const END_TYPE_COILS: Record = { + plain: 0, + plain_ground: 1, + squared: 2, + squared_ground: 2, +}; + +const SOLID_HEIGHT_EXTRA_COIL: Record = { + plain: true, + plain_ground: false, + squared: true, + squared_ground: false, +}; + +const LOW_INDEX_WARNING = 4; +const HIGH_INDEX_WARNING = 12; +const UNGUIDED_BUCKLING_RATIO = 2.63; +const GUIDED_BUCKLING_RATIO = 5.4; + +export function wahlFactor(springIndex: number): number { + return (4 * springIndex - 1) / (4 * springIndex - 4) + 0.615 / springIndex; +} + +export function totalCoils(activeCoils: number, endType: SpringEndType): number { + return activeCoils + END_TYPE_COILS[endType]; +} + +export function solidHeight(wireDiameter: number, coilCount: number, endType: SpringEndType): number { + const extra = SOLID_HEIGHT_EXTRA_COIL[endType] ? 1 : 0; + return wireDiameter * (coilCount + extra); +} + +export function analyzeSpring(input: SpringInput): Computation { + if (!(input.wireDiameter > 0)) { + throw new Error("wireDiameter must be positive."); + } + if (!(input.meanDiameter > 0)) { + throw new Error("meanDiameter must be positive."); + } + if (input.meanDiameter <= input.wireDiameter) { + throw new Error("meanDiameter must exceed wireDiameter."); + } + if (!(input.activeCoils > 0)) { + throw new Error("activeCoils must be positive."); + } + if (!(input.shearModulus > 0)) { + throw new Error("shearModulus must be positive."); + } + if (!(input.freeLength > 0)) { + throw new Error("freeLength must be positive."); + } + if (!(input.load >= 0)) { + throw new Error("load must be zero or positive."); + } + + const endType = input.endType ?? "squared_ground"; + const springIndex = input.meanDiameter / input.wireDiameter; + const kFactor = wahlFactor(springIndex); + const coilCount = totalCoils(input.activeCoils, endType); + const solidLen = solidHeight(input.wireDiameter, coilCount, endType); + + if (input.freeLength <= solidLen) { + throw new Error("freeLength must exceed the solid height."); + } + + const springRate = (input.shearModulus * input.wireDiameter ** 4) / (8 * input.meanDiameter ** 3 * input.activeCoils); + const deflection = input.load / springRate; + const workingLength = input.freeLength - deflection; + const shearStress = (kFactor * 8 * input.load * input.meanDiameter) / (Math.PI * input.wireDiameter ** 3); + + const warnings: string[] = []; + if (springIndex < LOW_INDEX_WARNING) { + warnings.push("The spring index is below 4. Small indices raise manufacturing difficulty."); + } else if (springIndex > HIGH_INDEX_WARNING) { + warnings.push("The spring index exceeds 12. Large indices make the spring prone to buckling."); + } + + const slenderness = input.freeLength / input.meanDiameter; + if (slenderness > GUIDED_BUCKLING_RATIO) { + warnings.push("The free-length to mean-diameter ratio exceeds 5.4. The spring may buckle even when guided."); + } else if (slenderness > UNGUIDED_BUCKLING_RATIO) { + warnings.push( + "The free-length to mean-diameter ratio exceeds 2.63. An unguided spring may buckle. Guide the spring or shorten it.", + ); + } + + if (input.load > 0 && workingLength <= solidLen) { + warnings.push("The applied load compresses the spring to solid height. Reduce the load or increase the free length."); + } + + const quantities: Quantity[] = [ + { + key: "springIndex", + label: "Spring index", + value: springIndex, + unit: "", + description: "Ratio of the mean coil diameter to the wire diameter.", + }, + { + key: "wahlFactor", + label: "Wahl factor", + value: kFactor, + unit: "", + description: "Shear stress correction for curvature and direct shear.", + }, + { + key: "totalCoils", + label: "Total coils", + value: coilCount, + unit: "", + description: "Active coils plus the inactive end coils.", + }, + { + key: "solidHeight", + label: "Solid height", + value: solidLen, + unit: "m", + description: "Spring length when every coil touches.", + }, + { + key: "springRate", + label: "Spring rate", + value: springRate, + unit: "N/m", + description: "Load required for one metre of deflection.", + }, + { + key: "deflection", + label: "Deflection at load", + value: deflection, + unit: "m", + description: "Compression from the free length under the applied load.", + }, + { + key: "workingLength", + label: "Working length", + value: workingLength, + unit: "m", + description: "Spring length under the applied load.", + }, + { + key: "maxShearStress", + label: "Maximum shear stress", + value: shearStress, + unit: "Pa", + description: "Wahl-corrected shear stress at the inner coil surface.", + }, + ]; + + let safetyFactor: Quantity | undefined; + if (input.shearYieldStrength && shearStress > 0) { + safetyFactor = { + key: "springSafetyFactor", + label: "Spring safety factor", + value: input.shearYieldStrength / shearStress, + unit: "", + description: "Torsional yield strength divided by the maximum shear stress.", + }; + } + + return { + method: SPRING_METHOD, + inputs: { + wireDiameter: input.wireDiameter, + meanDiameter: input.meanDiameter, + activeCoils: input.activeCoils, + endType, + freeLength: input.freeLength, + load: input.load, + shearModulus: input.shearModulus, + shearYieldStrength: input.shearYieldStrength, + springIndex, + slenderness, + }, + quantities, + safetyFactor, + referenceIds: SPRING_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/handlers.ts b/src/handlers.ts index fc0df7a..c646e73 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -4,9 +4,11 @@ import { analyzeBearing, analyzeBolt, analyzeShaft, + analyzeSpring, computeSection, vonMises, type SectionDef, + type SpringEndType, } from "./engine/index.js"; import type { Computation, MethodRecord, Quantity, ReferenceRecord, ToolFailure, ToolResponse, ToolResult } from "./types.js"; import type { UnitOutcome } from "./units/index.js"; @@ -291,6 +293,26 @@ function shaftHandler(ctx: AppContext): Handler { }; } +function springHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzeSpring({ + wireDiameter: input.wireDiameter as number, + meanDiameter: input.meanDiameter as number, + activeCoils: input.activeCoils as number, + endType: input.endType as SpringEndType | undefined, + freeLength: input.freeLength as number, + load: input.load as number, + shearModulus: input.shearModulus as number, + shearYieldStrength: input.shearYieldStrength as number | undefined, + }); + return buildResult(ctx, "spring_design", computation, input.outputUnits as Record | undefined); + } catch (error) { + return failure("spring_design", error instanceof Error ? error.message : String(error), input); + } + }; +} + function bearingHandler(ctx: AppContext): Handler { return (input) => { try { @@ -421,6 +443,7 @@ export function createHandlers(ctx: AppContext): Record { section_properties: sectionPropsHandler(ctx), bolt_strength: boltHandler(ctx), shaft_analysis: shaftHandler(ctx), + spring_design: springHandler(ctx), bearing_life: bearingHandler(ctx), von_mises: stressHandler(ctx), unit_convert: unitConvertHandler(ctx), diff --git a/src/schemas.ts b/src/schemas.ts index 1f0dc5e..7204c18 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -76,6 +76,25 @@ export const shaftSchema = z.object({ outputUnits, }); +export const springSchema = z.object({ + wireDiameter: z.number().positive().describe("Wire diameter d in metres."), + meanDiameter: z.number().positive().describe("Mean coil diameter D in metres."), + activeCoils: z.number().positive().describe("Number of active coils Na."), + endType: z + .enum(["plain", "plain_ground", "squared", "squared_ground"]) + .optional() + .describe("End condition. Sets the total coil count and solid height. Defaults to squared_ground."), + freeLength: z.number().positive().describe("Free length L0 in metres."), + load: z.number().min(0).describe("Applied axial load F in newtons. Zero checks the geometry only."), + shearModulus: z.number().positive().describe("Shear modulus G in pascals."), + shearYieldStrength: z + .number() + .positive() + .optional() + .describe("Torsional yield strength Sys in pascals. Enables the safety factor."), + outputUnits, +}); + export const bearingSchema = z.object({ bearingType: z.enum(["ball", "roller"]).describe("Bearing type. Ball uses a life exponent of 3, roller uses 10/3."), dynamicLoadRating: z.number().positive().describe("Basic dynamic load rating C in newtons."), @@ -115,6 +134,7 @@ export const materialSchema = z.object({ export type BeamInput = z.infer; export type BoltInput = z.infer; export type ShaftInput = z.infer; +export type SpringInput = z.infer; export type BearingInput = z.infer; export type SectionPropsInput = z.infer; export type StressInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2acf06e..2029a75 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import { materialSchema, sectionPropsSchema, shaftSchema, + springSchema, stressSchema, unitConvertSchema, } from "./schemas.js"; @@ -34,6 +35,11 @@ const TOOL_SCHEMAS: Record = { description: "Tensile stress area, proof strength, recommended preload, and safety factor for metric bolts.", schema: boltSchema, }, + spring_design: { + description: + "Helical compression spring geometry, spring rate, Wahl-corrected shear stress, and safety factor for round wire.", + schema: springSchema, + }, shaft_analysis: { description: "Torsion stress, angle of twist, and first lateral critical speed for solid or hollow shafts.", schema: shaftSchema, diff --git a/src/units/dimensions.ts b/src/units/dimensions.ts index 32e78d1..67dc684 100644 --- a/src/units/dimensions.ts +++ b/src/units/dimensions.ts @@ -17,6 +17,7 @@ export const DIM_ACCELERATION: Dimension = [1, 0, -2, 0, 0]; export const DIM_AREA: Dimension = [2, 0, 0, 0, 0]; 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 function dimensionsEqual(a: Dimension, b: Dimension): boolean { diff --git a/src/units/registry.ts b/src/units/registry.ts index bb2a59f..64eeff9 100644 --- a/src/units/registry.ts +++ b/src/units/registry.ts @@ -10,6 +10,7 @@ import { DIM_MASS, DIM_POWER, DIM_PRESSURE, + DIM_STIFFNESS, DIM_TEMPERATURE, DIM_TIME, DIM_TORQUE, @@ -131,6 +132,11 @@ export const UNITS: UnitDef[] = [ unit({ canonical: "g/cm3", name: "gram per cubic centimetre", category: "density", dim: DIM_DENSITY, factor: 1e3 }), unit({ canonical: "lb/ft3", name: "pound per cubic foot", category: "density", dim: DIM_DENSITY, factor: 16.01846337 }), + unit({ canonical: "N/m", name: "newton per metre", category: "stiffness", dim: DIM_STIFFNESS, factor: 1 }), + unit({ canonical: "N/mm", name: "newton per millimetre", category: "stiffness", dim: DIM_STIFFNESS, factor: 1e3 }), + unit({ canonical: "kN/m", name: "kilonewton per metre", category: "stiffness", dim: DIM_STIFFNESS, factor: 1e3 }), + unit({ canonical: "lbf/in", name: "pound-force per inch", category: "stiffness", dim: DIM_STIFFNESS, factor: 175.126835 }), + 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 }), diff --git a/src/version.ts b/src/version.ts index 9995d49..9e86f0c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/spring.test.ts b/tests/spring.test.ts new file mode 100644 index 0000000..4b56fc1 --- /dev/null +++ b/tests/spring.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSpring, + solidHeight, + totalCoils, + wahlFactor, + type SpringEndType, +} from "../src/engine/spring.js"; + +describe("spring geometry helpers", () => { + it("computes the Wahl factor for a spring index of 5", () => { + expect(wahlFactor(5)).toBeCloseTo(19 / 16 + 0.615 / 5, 9); + expect(wahlFactor(5)).toBeCloseTo(1.3105, 9); + }); + + it("computes the Wahl factor for a spring index of 10", () => { + expect(wahlFactor(10)).toBeCloseTo(39 / 36 + 0.615 / 10, 9); + }); + + it("counts total coils from the end condition", () => { + expect(totalCoils(10, "plain")).toBe(10); + expect(totalCoils(10, "plain_ground")).toBe(11); + expect(totalCoils(10, "squared")).toBe(12); + expect(totalCoils(10, "squared_ground")).toBe(12); + }); + + it("computes the solid height from the end condition", () => { + expect(solidHeight(0.002, totalCoils(10, "plain"), "plain")).toBeCloseTo(0.022, 9); + expect(solidHeight(0.002, totalCoils(10, "plain_ground"), "plain_ground")).toBeCloseTo(0.022, 9); + expect(solidHeight(0.002, totalCoils(10, "squared"), "squared")).toBeCloseTo(0.026, 9); + expect(solidHeight(0.002, totalCoils(10, "squared_ground"), "squared_ground")).toBeCloseTo(0.024, 9); + }); +}); + +describe("analyzeSpring", () => { + it("computes a clean squared-and-ground spring design", () => { + const result = analyzeSpring({ + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + endType: "squared_ground", + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + shearYieldStrength: 700e6, + }); + + expect(result.quantities.find((q) => q.key === "springIndex")?.value).toBeCloseTo(5, 9); + expect(result.quantities.find((q) => q.key === "wahlFactor")?.value).toBeCloseTo(1.3105, 6); + expect(result.quantities.find((q) => q.key === "totalCoils")?.value).toBeCloseTo(6, 9); + expect(result.quantities.find((q) => q.key === "solidHeight")?.value).toBeCloseTo(0.048, 9); + expect(result.quantities.find((q) => q.key === "springRate")?.value).toBeCloseTo(158600, 2); + expect(result.quantities.find((q) => q.key === "deflection")?.value).toBeCloseTo(2000 / 158600, 6); + expect(result.quantities.find((q) => q.key === "workingLength")?.value).toBeCloseTo(0.09 - 2000 / 158600, 6); + + const expectedStress = (1.3105 * 8 * 2000 * 0.04) / (Math.PI * 0.008 ** 3); + expect(result.quantities.find((q) => q.key === "maxShearStress")?.value).toBeCloseTo(expectedStress, 6); + + const safety = result.safetyFactor; + expect(safety?.key).toBe("springSafetyFactor"); + expect(safety?.value).toBeCloseTo(700e6 / expectedStress, 6); + expect(result.warnings.length).toBe(0); + }); + + it("defaults to squared and ground ends", () => { + const result = analyzeSpring({ + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + }); + expect(result.inputs.endType).toBe("squared_ground"); + expect(result.quantities.find((q) => q.key === "totalCoils")?.value).toBeCloseTo(6, 9); + }); + + it("skips the safety factor without a shear yield strength", () => { + const result = analyzeSpring({ + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + }); + expect(result.safetyFactor).toBeUndefined(); + }); + + it("handles a zero load as a geometry check", () => { + const result = analyzeSpring({ + wireDiameter: 0.004, + meanDiameter: 0.032, + activeCoils: 8, + freeLength: 0.16, + load: 0, + shearModulus: 79.3e9, + }); + expect(result.quantities.find((q) => q.key === "deflection")?.value).toBe(0); + expect(result.quantities.find((q) => q.key === "maxShearStress")?.value).toBe(0); + expect(result.safetyFactor).toBeUndefined(); + }); +}); + +describe("analyzeSpring validation", () => { + it("rejects a mean diameter that does not exceed the wire diameter", () => { + expect(() => + analyzeSpring({ + wireDiameter: 0.01, + meanDiameter: 0.01, + activeCoils: 5, + freeLength: 0.1, + load: 100, + shearModulus: 79.3e9, + }), + ).toThrow("meanDiameter must exceed wireDiameter"); + }); + + it("rejects a free length that does not exceed the solid height", () => { + expect(() => + analyzeSpring({ + wireDiameter: 0.002, + meanDiameter: 0.016, + activeCoils: 4, + freeLength: 0.012, + load: 100, + shearModulus: 79.3e9, + }), + ).toThrow("freeLength must exceed the solid height"); + }); + + it("rejects a negative load", () => { + expect(() => + analyzeSpring({ + wireDiameter: 0.004, + meanDiameter: 0.032, + activeCoils: 8, + freeLength: 0.16, + load: -10, + shearModulus: 79.3e9, + }), + ).toThrow("load must be zero or positive"); + }); + + it("rejects a non-positive active coil count", () => { + expect(() => + analyzeSpring({ + wireDiameter: 0.004, + meanDiameter: 0.032, + activeCoils: 0, + freeLength: 0.16, + load: 100, + shearModulus: 79.3e9, + }), + ).toThrow("activeCoils must be positive"); + }); +}); + +describe("analyzeSpring warnings", () => { + it("warns when the spring index is below 4", () => { + const result = analyzeSpring({ + wireDiameter: 0.01, + meanDiameter: 0.02, + activeCoils: 8, + freeLength: 0.12, + load: 500, + shearModulus: 79.3e9, + }); + expect(result.warnings.some((w) => w.includes("below 4"))).toBe(true); + }); + + it("warns when the spring index exceeds 12", () => { + const result = analyzeSpring({ + wireDiameter: 0.002, + meanDiameter: 0.05, + activeCoils: 8, + freeLength: 0.06, + load: 500, + shearModulus: 79.3e9, + }); + expect(result.warnings.some((w) => w.includes("exceeds 12"))).toBe(true); + }); + + it("warns about buckling risk for an unguided spring", () => { + const result = analyzeSpring({ + wireDiameter: 0.004, + meanDiameter: 0.032, + activeCoils: 8, + freeLength: 0.1, + load: 500, + shearModulus: 79.3e9, + }); + expect(result.inputs.slenderness).toBeCloseTo(3.125, 6); + expect(result.warnings.some((w) => w.includes("2.63"))).toBe(true); + }); + + it("warns about buckling risk even for a guided spring", () => { + const result = analyzeSpring({ + wireDiameter: 0.004, + meanDiameter: 0.032, + activeCoils: 8, + freeLength: 0.2, + load: 500, + shearModulus: 79.3e9, + }); + expect(result.warnings.some((w) => w.includes("5.4"))).toBe(true); + }); + + it("warns when the load compresses the spring to solid height", () => { + const result = analyzeSpring({ + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + freeLength: 0.09, + load: 8000, + shearModulus: 79.3e9, + }); + expect(result.warnings.some((w) => w.includes("solid height"))).toBe(true); + }); + + it("carries the Shigley provenance", () => { + const result = analyzeSpring({ + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + }); + expect(result.referenceIds).toContain("shigley-2015"); + expect(result.referenceIds).toContain("machinery-handbook"); + expect(result.method.id).toBe("spring-design"); + }); +}); + +describe("end condition variants", () => { + const endTypes: SpringEndType[] = ["plain", "plain_ground", "squared", "squared_ground"]; + const wireDiameter = 0.004; + const meanDiameter = 0.032; + + it("accepts every end type", () => { + for (const endType of endTypes) { + const result = analyzeSpring({ + wireDiameter, + meanDiameter, + activeCoils: 6, + endType, + freeLength: 0.1, + load: 300, + shearModulus: 79.3e9, + }); + expect(result.quantities.find((q) => q.key === "totalCoils")?.value).toBe( + totalCoils(6, endType), + ); + } + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index d52dce5..dd63bf0 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -9,6 +9,7 @@ type Handlers = { beam_bending: Handler; section_properties: Handler; bolt_strength: Handler; + spring_design: Handler; shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; @@ -30,12 +31,13 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all eight tools", () => { + it("registers all nine tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", "section_properties", "bolt_strength", + "spring_design", "shaft_analysis", "bearing_life", "von_mises", @@ -163,6 +165,72 @@ describe("shaft_analysis tool", () => { }); }); +describe("spring_design tool", () => { + it("computes a spring and converts its units", () => { + setup(); + const response = handlers.spring_design({ + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + endType: "squared_ground", + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + shearYieldStrength: 700e6, + outputUnits: { maxShearStress: "MPa", springRate: "N/mm", deflection: "mm", solidHeight: "mm", workingLength: "mm" }, + }); + const result = expectOk(response); + + expect(result.tool).toBe("spring_design"); + expect(result.method.id).toBe("spring-design"); + expect(result.references.length).toBeGreaterThan(0); + expect(result.references[0]).toHaveProperty("title"); + + const stress = result.quantities.find((q) => q.key === "maxShearStress"); + expect(stress?.unit).toBe("MPa"); + expect(stress?.value).toBeCloseTo(521.4, 0); + const rate = result.quantities.find((q) => q.key === "springRate"); + expect(rate?.unit).toBe("N/mm"); + expect(rate?.value).toBeCloseTo(158.6, 0); + const deflection = result.quantities.find((q) => q.key === "deflection"); + expect(deflection?.unit).toBe("mm"); + expect(deflection?.value).toBeCloseTo(12.6, 0); + expect(result.safetyFactor).toBeDefined(); + expect(result.warnings.length).toBe(0); + }); + + it("checks the geometry with a zero load", () => { + setup(); + const response = handlers.spring_design({ + wireDiameter: 0.004, + meanDiameter: 0.032, + activeCoils: 8, + freeLength: 0.16, + load: 0, + shearModulus: 79.3e9, + }); + const result = expectOk(response); + expect(result.quantities.find((q) => q.key === "springRate")?.value).toBeGreaterThan(0); + expect(result.safetyFactor).toBeUndefined(); + }); + + it("rejects a mean diameter that does not exceed the wire diameter", () => { + setup(); + const response = handlers.spring_design({ + wireDiameter: 0.01, + meanDiameter: 0.01, + activeCoils: 5, + freeLength: 0.1, + load: 100, + shearModulus: 79.3e9, + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("meanDiameter"); + } + }); +}); + describe("bearing_life tool", () => { it("computes life with a life margin", () => { setup(); diff --git a/tests/units.test.ts b/tests/units.test.ts index 16bd57b..8839b35 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -107,3 +107,31 @@ describe("unit conversion", () => { } }); }); + +describe("stiffness units", () => { + it("converts newtons per millimetre to newtons per metre", () => { + const outcome = convertUnit(1, "N/mm", "N/m"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1000, 9); + expect(outcome.category).toBe("stiffness"); + expect(outcome.siSymbol).toBe("N/m"); + } + }); + + it("converts newtons per metre to pound-force per inch", () => { + const outcome = convertUnit(1, "N/m", "lbf/in"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1 / 175.126835, 6); + } + }); + + it("rejects a stiffness-to-pressure conversion", () => { + const outcome = convertUnit(10, "N/m", "Pa"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +});