export const TAU = Math.PI * 2; export const DEFAULT_KINEMATIC_SAMPLE_COUNT = 145; const DEFAULT_ABSOLUTE_TOLERANCE = 1e-6; const MAX_ISSUES_PER_CONSTRAINT = 12; export type ValidationSeverity = 'error' | 'warning'; export interface NumericTolerance { absolute?: number; relative?: number; } export interface ScalarTrack { id: string; label?: string; units?: string; sample: (timeSeconds: number) => number; } export interface KinematicValidationIssue { severity: ValidationSeverity; code: string; message: string; suiteId?: string; machineId?: string; constraintId?: string; sampleIndex?: number; timeSeconds?: number; actual?: number; expected?: number; tolerance?: number; details?: Record; } export interface ValidationRunOptions { sampleCount?: number; includeEndpoint?: boolean; treatWarningsAsErrors?: boolean; } export interface ConstraintEvaluationContext { suite: KinematicValidationSuite; sampleTimes: number[]; options: ValidationRunOptions; } export interface KinematicConstraint { id: string; description: string; evaluate: (context: ConstraintEvaluationContext) => KinematicValidationIssue[]; } export interface KinematicValidationSuite { id: string; machineId: string; title: string; durationSeconds: number; sampleCount?: number; metadata?: Record; constraints: KinematicConstraint[]; } export interface KinematicConstraintReport { constraintId: string; description: string; ok: boolean; issues: KinematicValidationIssue[]; } export interface KinematicSuiteValidationReport { suiteId: string; machineId: string; title: string; ok: boolean; durationSeconds: number; sampleCount: number; constraintReports: KinematicConstraintReport[]; issues: KinematicValidationIssue[]; } export interface KinematicValidationReport { ok: boolean; suiteCount: number; constraintCount: number; sampleCount: number; errorCount: number; warningCount: number; elapsedMilliseconds: number; suiteReports: KinematicSuiteValidationReport[]; } export interface ScalarRange { min?: number; max?: number; } export interface ScalarPointExpectation { atSeconds?: number; atFraction?: number; expected: number | ((timeSeconds: number, durationSeconds: number) => number); tolerance?: NumericTolerance; label?: string; } export interface PhaseRatioConstraintOptions { id?: string; description?: string; driver: ScalarTrack; driven: ScalarTrack; ratio: number; offsetRadians?: number; tolerance?: NumericTolerance; } export interface AngularTravelConstraintOptions { id?: string; description?: string; track: ScalarTrack; expectedDeltaRadians: number; tolerance?: NumericTolerance; } export interface ConstantAngularVelocityConstraintOptions { id?: string; description?: string; track: ScalarTrack; radiansPerSecond: number; tolerance?: NumericTolerance; } export interface WillisPlanetaryConstraintOptions { id?: string; description?: string; sunAngle: ScalarTrack; ringAngle: ScalarTrack; carrierAngle: ScalarTrack; sunTeeth: number; ringTeeth: number; tolerance?: NumericTolerance; } export interface DifferentialAverageConstraintOptions { id?: string; description?: string; leftOutputAngle: ScalarTrack; rightOutputAngle: ScalarTrack; carrierAngle: ScalarTrack; tolerance?: NumericTolerance; } export interface CrankSliderConstraintOptions { id?: string; description?: string; crankAngle: ScalarTrack; pistonPosition: ScalarTrack; crankRadiusMeters: number; connectingRodLengthMeters: number; pistonOffsetMeters?: number; tolerance?: NumericTolerance; } export interface MonotonicScalarConstraintOptions { id?: string; description?: string; track: ScalarTrack; direction?: 'increasing' | 'decreasing'; tolerance?: NumericTolerance; } export interface SymmetryConstraintOptions { id?: string; description?: string; a: ScalarTrack; b: ScalarTrack; expectedSum?: number; tolerance?: NumericTolerance; } export interface CenterOrbitConstraintOptions { id?: string; description?: string; centerX: ScalarTrack; centerY: ScalarTrack; orbitAngle: ScalarTrack; radius: number; radiusTolerance?: NumericTolerance; angleToleranceRadians?: number; angleOffsetRadians?: number; } export interface RadialDistanceConstraintOptions { id?: string; description?: string; x: ScalarTrack; y: ScalarTrack; centerX?: ScalarTrack; centerY?: ScalarTrack; radius: number; tolerance?: NumericTolerance; } export interface GenevaOutputOptions { slots?: number; driveStartFraction?: number; driveWindowFraction?: number; } export interface GenevaIndexConstraintOptions extends GenevaOutputOptions { id?: string; description?: string; inputAngle: ScalarTrack; outputAngle: ScalarTrack; tolerance?: NumericTolerance; monotonicToleranceRadians?: number; } export interface BearingKinematicParameters { ballDiameterMeters: number; pitchDiameterMeters: number; contactAngleRadians?: number; } export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } export function positiveModulo(value: number, divisor: number): number { return ((value % divisor) + divisor) % divisor; } export function smoothstep01(value: number): number { const t = clamp(value, 0, 1); return t * t * (3 - 2 * t); } export function smootherstep01(value: number): number { const t = clamp(value, 0, 1); return t * t * t * (t * (t * 6 - 15) + 10); } export function angularDifferenceRadians(actual: number, expected: number): number { const difference = positiveModulo(actual - expected + Math.PI, TAU) - Math.PI; return difference === -Math.PI ? Math.PI : difference; } export function createCycleSamples( durationSeconds: number, sampleCount = DEFAULT_KINEMATIC_SAMPLE_COUNT, includeEndpoint = true ): number[] { if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { throw new Error(`durationSeconds must be a positive finite number; received ${durationSeconds}`); } const count = Math.max(2, Math.floor(sampleCount)); if (includeEndpoint) { return Array.from({ length: count }, (_unused, index) => (durationSeconds * index) / (count - 1)); } return Array.from({ length: count }, (_unused, index) => (durationSeconds * index) / count); } export function toleranceLimit(actual: number, expected: number, tolerance: NumericTolerance = {}): number { const absolute = tolerance.absolute ?? DEFAULT_ABSOLUTE_TOLERANCE; const relative = tolerance.relative ?? 0; return absolute + relative * Math.max(Math.abs(actual), Math.abs(expected), 1); } export function withinTolerance(actual: number, expected: number, tolerance: NumericTolerance = {}): boolean { if (!Number.isFinite(actual) || !Number.isFinite(expected)) { return false; } return Math.abs(actual - expected) <= toleranceLimit(actual, expected, tolerance); } export function crankSliderDisplacement( crankAngleRadians: number, crankRadiusMeters: number, connectingRodLengthMeters: number ): number { if (connectingRodLengthMeters <= crankRadiusMeters) { throw new Error( `connectingRodLengthMeters must be greater than crankRadiusMeters; received ${connectingRodLengthMeters} <= ${crankRadiusMeters}` ); } const lateralOffset = crankRadiusMeters * Math.sin(crankAngleRadians); const underRoot = connectingRodLengthMeters * connectingRodLengthMeters - lateralOffset * lateralOffset; return crankRadiusMeters * Math.cos(crankAngleRadians) + Math.sqrt(Math.max(0, underRoot)); } export function bearingCageRatio(parameters: BearingKinematicParameters): number { assertBearingParameters(parameters); const contactAngle = parameters.contactAngleRadians ?? 0; const diameterRatio = parameters.ballDiameterMeters / parameters.pitchDiameterMeters; return 0.5 * (1 - diameterRatio * Math.cos(contactAngle)); } export function bearingBallSpinRatio(parameters: BearingKinematicParameters): number { assertBearingParameters(parameters); const contactAngle = parameters.contactAngleRadians ?? 0; const diameterRatio = parameters.ballDiameterMeters / parameters.pitchDiameterMeters; return ( (parameters.pitchDiameterMeters / (2 * parameters.ballDiameterMeters)) * (1 - Math.pow(diameterRatio * Math.cos(contactAngle), 2)) ); } export function genevaOutputAngle(inputTurns: number, options: GenevaOutputOptions = {}): number { if (!Number.isFinite(inputTurns)) { return Number.NaN; } const slots = options.slots ?? 4; if (!Number.isFinite(slots) || slots < 2) { throw new Error(`Geneva drive slots must be >= 2; received ${slots}`); } const slotCount = Math.floor(slots); const stepRadians = TAU / slotCount; const driveWindowFraction = clamp(options.driveWindowFraction ?? 0.34, 0.02, 0.92); const driveStartFraction = clamp(options.driveStartFraction ?? 0.08, 0, 1 - driveWindowFraction); const driveEndFraction = driveStartFraction + driveWindowFraction; const completedInputTurns = Math.floor(inputTurns); const localTurnPhase = positiveModulo(inputTurns, 1); let indexProgress = 0; if (localTurnPhase <= driveStartFraction) { indexProgress = 0; } else if (localTurnPhase >= driveEndFraction) { indexProgress = 1; } else { indexProgress = smootherstep01((localTurnPhase - driveStartFraction) / driveWindowFraction); } return (completedInputTurns + indexProgress) * stepRadians; } export function finiteScalarTrackConstraint(track: ScalarTrack): KinematicConstraint { return { id: `${track.id}.finite`, description: `${track.label ?? track.id} remains finite for every sampled frame.`, evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { let actual: number; try { actual = track.sample(timeSeconds); } catch (error) { pushLimitedIssue(issues, { severity: 'error', code: 'track.sample-threw', message: `${track.id} threw while sampling at t=${timeSeconds.toFixed(6)}s: ${messageFromUnknown(error)}`, sampleIndex, timeSeconds }); return; } if (!Number.isFinite(actual)) { pushLimitedIssue(issues, { severity: 'error', code: 'track.non-finite', message: `${track.id} produced a non-finite value at t=${timeSeconds.toFixed(6)}s.`, sampleIndex, timeSeconds, actual }); } }); return issues; } }; } export function scalarRangeConstraint( track: ScalarTrack, range: ScalarRange, options: { id?: string; description?: string; tolerance?: NumericTolerance } = {} ): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: DEFAULT_ABSOLUTE_TOLERANCE }; return { id: options.id ?? `${track.id}.range`, description: options.description ?? `${track.label ?? track.id} stays within ${range.min ?? '-∞'}..${range.max ?? '+∞'} ${track.units ?? ''}.`, evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { const actual = track.sample(timeSeconds); if (!Number.isFinite(actual)) { pushLimitedIssue(issues, { severity: 'error', code: 'track.non-finite', message: `${track.id} produced a non-finite value while checking range.`, sampleIndex, timeSeconds, actual }); return; } if (range.min !== undefined && actual < range.min - toleranceLimit(actual, range.min, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'scalar.below-range', message: `${track.id} fell below its minimum bound.`, sampleIndex, timeSeconds, actual, expected: range.min, tolerance: toleranceLimit(actual, range.min, tolerance) }); } if (range.max !== undefined && actual > range.max + toleranceLimit(actual, range.max, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'scalar.above-range', message: `${track.id} exceeded its maximum bound.`, sampleIndex, timeSeconds, actual, expected: range.max, tolerance: toleranceLimit(actual, range.max, tolerance) }); } }); return issues; } }; } export function scalarPointConstraint( track: ScalarTrack, points: ScalarPointExpectation[], options: { id?: string; description?: string; tolerance?: NumericTolerance } = {} ): KinematicConstraint { return { id: options.id ?? `${track.id}.point-expectations`, description: options.description ?? `${track.label ?? track.id} matches expected values at named cycle events.`, evaluate: ({ suite }) => { const issues: KinematicValidationIssue[] = []; points.forEach((point, sampleIndex) => { const timeSeconds = point.atSeconds ?? (point.atFraction ?? 0) * suite.durationSeconds; const expected = typeof point.expected === 'function' ? point.expected(timeSeconds, suite.durationSeconds) : point.expected; const actual = track.sample(timeSeconds); const tolerance = point.tolerance ?? options.tolerance ?? { absolute: DEFAULT_ABSOLUTE_TOLERANCE }; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'scalar.point-mismatch', message: `${track.id} did not match expected value at ${point.label ?? `${timeSeconds.toFixed(6)}s`}.`, sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } }); return issues; } }; } export function phaseRatioConstraint(options: PhaseRatioConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-8 }; return { id: options.id ?? `${options.driven.id}.phase-ratio`, description: options.description ?? `${options.driven.label ?? options.driven.id} follows ${options.ratio}× ${options.driver.label ?? options.driver.id}.`, evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; const startTime = sampleTimes[0] ?? 0; const driverStart = options.driver.sample(startTime); const drivenStart = options.driven.sample(startTime); if (!Number.isFinite(driverStart) || !Number.isFinite(drivenStart)) { return [ { severity: 'error', code: 'phase.non-finite-baseline', message: `Cannot establish phase ratio baseline for ${options.driven.id}.`, timeSeconds: startTime, actual: drivenStart, expected: options.ratio * driverStart } ]; } const offset = options.offsetRadians ?? drivenStart - options.ratio * driverStart; sampleTimes.forEach((timeSeconds, sampleIndex) => { const driver = options.driver.sample(timeSeconds); const actual = options.driven.sample(timeSeconds); const expected = options.ratio * driver + offset; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'phase.ratio', message: `${options.driven.id} is not phase-locked to ${options.driver.id}.`, sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance), details: { ratio: options.ratio } }); } }); return issues; } }; } export function angularTravelConstraint(options: AngularTravelConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-8 }; return { id: options.id ?? `${options.track.id}.angular-travel`, description: options.description ?? `${options.track.label ?? options.track.id} completes ${options.expectedDeltaRadians.toFixed(6)} radians over the suite.`, evaluate: ({ suite }) => { const start = options.track.sample(0); const end = options.track.sample(suite.durationSeconds); const actual = end - start; const expected = options.expectedDeltaRadians; if (withinTolerance(actual, expected, tolerance)) { return []; } return [ { severity: 'error', code: 'angle.travel', message: `${options.track.id} completed the wrong angular travel over ${suite.durationSeconds.toFixed(6)}s.`, timeSeconds: suite.durationSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) } ]; } }; } export const loopClosureConstraint = angularTravelConstraint; export function constantAngularVelocityConstraint(options: ConstantAngularVelocityConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-8 }; return { id: options.id ?? `${options.track.id}.constant-angular-velocity`, description: options.description ?? `${options.track.label ?? options.track.id} keeps ${options.radiansPerSecond.toFixed(6)} rad/s angular velocity.`, evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; for (let index = 1; index < sampleTimes.length; index += 1) { const previousTime = sampleTimes[index - 1]; const currentTime = sampleTimes[index]; const deltaTime = currentTime - previousTime; if (deltaTime <= 0) { continue; } const previous = options.track.sample(previousTime); const current = options.track.sample(currentTime); const actual = (current - previous) / deltaTime; const expected = options.radiansPerSecond; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'angle.velocity', message: `${options.track.id} angular velocity drifted from its configured speed.`, sampleIndex: index, timeSeconds: currentTime, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } } return issues; } }; } export function willisPlanetaryConstraint(options: WillisPlanetaryConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-4, relative: 1e-9 }; return { id: options.id ?? 'planetary.willis-equation', description: options.description ?? 'Planetary gear set satisfies Willis equation: (Ns + Nr)ωc = Nsωs + Nrωr.', evaluate: ({ sampleTimes }) => { if (options.sunTeeth <= 0 || options.ringTeeth <= 0) { return [ { severity: 'error', code: 'planetary.invalid-tooth-count', message: 'Planetary gear tooth counts must be positive.', details: { sunTeeth: options.sunTeeth, ringTeeth: options.ringTeeth } } ]; } const relation = (timeSeconds: number) => (options.sunTeeth + options.ringTeeth) * options.carrierAngle.sample(timeSeconds) - options.sunTeeth * options.sunAngle.sample(timeSeconds) - options.ringTeeth * options.ringAngle.sample(timeSeconds); const baseline = relation(sampleTimes[0] ?? 0); const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { const actual = relation(timeSeconds); const expected = baseline; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'planetary.willis', message: 'Planetary gear angles violate Willis equation.', sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance), details: { sunTeeth: options.sunTeeth, ringTeeth: options.ringTeeth } }); } }); return issues; } }; } export function differentialAverageConstraint(options: DifferentialAverageConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-8 }; return { id: options.id ?? 'differential.average-output', description: options.description ?? 'Open differential carrier angle remains the average of the left and right output angles.', evaluate: ({ sampleTimes }) => { const relation = (timeSeconds: number) => options.leftOutputAngle.sample(timeSeconds) + options.rightOutputAngle.sample(timeSeconds) - 2 * options.carrierAngle.sample(timeSeconds); const baseline = relation(sampleTimes[0] ?? 0); const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { const actual = relation(timeSeconds); const expected = baseline; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'differential.average', message: 'Differential outputs no longer average to the carrier angle.', sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } }); return issues; } }; } export function crankSliderConstraint(options: CrankSliderConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-7 }; return { id: options.id ?? 'crank-slider.geometry', description: options.description ?? 'Piston position follows the exact crank-slider displacement curve for the configured rod ratio.', evaluate: ({ sampleTimes }) => { if (options.connectingRodLengthMeters <= options.crankRadiusMeters) { return [ { severity: 'error', code: 'crank-slider.invalid-geometry', message: 'Connecting rod length must be greater than crank radius.', details: { crankRadiusMeters: options.crankRadiusMeters, connectingRodLengthMeters: options.connectingRodLengthMeters } } ]; } const startTime = sampleTimes[0] ?? 0; const inferredOffset = options.pistonOffsetMeters ?? options.pistonPosition.sample(startTime) - crankSliderDisplacement( options.crankAngle.sample(startTime), options.crankRadiusMeters, options.connectingRodLengthMeters ); const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { const expected = inferredOffset + crankSliderDisplacement( options.crankAngle.sample(timeSeconds), options.crankRadiusMeters, options.connectingRodLengthMeters ); const actual = options.pistonPosition.sample(timeSeconds); if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'crank-slider.displacement', message: 'Piston position does not match crank-slider geometry.', sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } }); return issues; } }; } export function monotonicScalarConstraint(options: MonotonicScalarConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: DEFAULT_ABSOLUTE_TOLERANCE }; const direction = options.direction ?? 'increasing'; return { id: options.id ?? `${options.track.id}.monotonic-${direction}`, description: options.description ?? `${options.track.label ?? options.track.id} remains monotonic ${direction} across the cycle.`, evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; for (let index = 1; index < sampleTimes.length; index += 1) { const previous = options.track.sample(sampleTimes[index - 1]); const current = options.track.sample(sampleTimes[index]); const limit = toleranceLimit(current, previous, tolerance); const violatesIncreasing = direction === 'increasing' && current < previous - limit; const violatesDecreasing = direction === 'decreasing' && current > previous + limit; if (violatesIncreasing || violatesDecreasing) { pushLimitedIssue(issues, { severity: 'error', code: 'scalar.not-monotonic', message: `${options.track.id} reversed while expected to be ${direction}.`, sampleIndex: index, timeSeconds: sampleTimes[index], actual: current, expected: previous, tolerance: limit }); } } return issues; } }; } export function symmetryConstraint(options: SymmetryConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: DEFAULT_ABSOLUTE_TOLERANCE }; const expectedSum = options.expectedSum ?? 0; return { id: options.id ?? `${options.a.id}.${options.b.id}.symmetry`, description: options.description ?? `${options.a.id} and ${options.b.id} remain symmetric.`, evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { const actual = options.a.sample(timeSeconds) + options.b.sample(timeSeconds); const expected = expectedSum; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'scalar.symmetry', message: `${options.a.id} and ${options.b.id} lost symmetric motion.`, sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } }); return issues; } }; } export function centerOrbitConstraint(options: CenterOrbitConstraintOptions): KinematicConstraint { const radiusTolerance = options.radiusTolerance ?? { absolute: 1e-5, relative: 1e-6 }; const angleToleranceRadians = options.angleToleranceRadians ?? 1e-4; return { id: options.id ?? 'center.orbit', description: options.description ?? 'Orbiting center remains on its eccentric radius and follows the configured driver phase.', evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; const startTime = sampleTimes[0] ?? 0; const angleOffset = options.angleOffsetRadians ?? Math.atan2(options.centerY.sample(startTime), options.centerX.sample(startTime)) - options.orbitAngle.sample(startTime); sampleTimes.forEach((timeSeconds, sampleIndex) => { const x = options.centerX.sample(timeSeconds); const y = options.centerY.sample(timeSeconds); const actualRadius = Math.hypot(x, y); const expectedRadius = options.radius; if (!withinTolerance(actualRadius, expectedRadius, radiusTolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'orbit.radius', message: 'Orbit center radius drifted from the configured eccentricity.', sampleIndex, timeSeconds, actual: actualRadius, expected: expectedRadius, tolerance: toleranceLimit(actualRadius, expectedRadius, radiusTolerance) }); } const actualAngle = Math.atan2(y, x); const expectedAngle = options.orbitAngle.sample(timeSeconds) + angleOffset; const angleError = Math.abs(angularDifferenceRadians(actualAngle, expectedAngle)); if (angleError > angleToleranceRadians) { pushLimitedIssue(issues, { severity: 'error', code: 'orbit.phase', message: 'Orbit center phase drifted from the configured driver angle.', sampleIndex, timeSeconds, actual: actualAngle, expected: expectedAngle, tolerance: angleToleranceRadians }); } }); return issues; } }; } export function radialDistanceConstraint(options: RadialDistanceConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-6 }; return { id: options.id ?? `${options.x.id}.${options.y.id}.radial-distance`, description: options.description ?? 'Point remains at the configured radial distance from its center.', evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; sampleTimes.forEach((timeSeconds, sampleIndex) => { const centerX = options.centerX?.sample(timeSeconds) ?? 0; const centerY = options.centerY?.sample(timeSeconds) ?? 0; const x = options.x.sample(timeSeconds); const y = options.y.sample(timeSeconds); const actual = Math.hypot(x - centerX, y - centerY); const expected = options.radius; if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'radial-distance.radius', message: 'Point radial distance drifted from expected radius.', sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } }); return issues; } }; } export function genevaIndexConstraint(options: GenevaIndexConstraintOptions): KinematicConstraint { const tolerance = options.tolerance ?? { absolute: 1e-5, relative: 1e-8 }; const monotonicToleranceRadians = options.monotonicToleranceRadians ?? tolerance.absolute ?? 1e-5; const outputOptions: GenevaOutputOptions = { slots: options.slots, driveStartFraction: options.driveStartFraction, driveWindowFraction: options.driveWindowFraction }; return { id: options.id ?? 'geneva.indexing', description: options.description ?? 'Geneva output dwells while locked and advances exactly one slot during each drive pin engagement.', evaluate: ({ sampleTimes }) => { const issues: KinematicValidationIssue[] = []; const startTime = sampleTimes[0] ?? 0; const inputStartTurns = options.inputAngle.sample(startTime) / TAU; const outputOffset = options.outputAngle.sample(startTime) - genevaOutputAngle(inputStartTurns, outputOptions); let previousOutput = options.outputAngle.sample(startTime); sampleTimes.forEach((timeSeconds, sampleIndex) => { const inputTurns = options.inputAngle.sample(timeSeconds) / TAU; const expected = outputOffset + genevaOutputAngle(inputTurns, outputOptions); const actual = options.outputAngle.sample(timeSeconds); if (!withinTolerance(actual, expected, tolerance)) { pushLimitedIssue(issues, { severity: 'error', code: 'geneva.index-angle', message: 'Geneva output angle did not match the dwell/index profile.', sampleIndex, timeSeconds, actual, expected, tolerance: toleranceLimit(actual, expected, tolerance) }); } if (sampleIndex > 0 && actual < previousOutput - monotonicToleranceRadians) { pushLimitedIssue(issues, { severity: 'error', code: 'geneva.reverse', message: 'Geneva output reversed during an indexing cycle.', sampleIndex, timeSeconds, actual, expected: previousOutput, tolerance: monotonicToleranceRadians }); } previousOutput = actual; }); return issues; } }; } export function runKinematicValidation( suites: readonly KinematicValidationSuite[], options: ValidationRunOptions = {} ): KinematicValidationReport { const startedAt = nowMilliseconds(); const suiteReports = suites.map((suite) => runSuiteValidation(suite, options)); const issues = suiteReports.flatMap((suite) => suite.issues); const errorCount = issues.filter((issue) => issue.severity === 'error').length; const warningCount = issues.filter((issue) => issue.severity === 'warning').length; const constraintCount = suiteReports.reduce((total, suite) => total + suite.constraintReports.length, 0); const sampleCount = suiteReports.reduce((total, suite) => total + suite.sampleCount, 0); return { ok: !issues.some((issue) => isBlockingIssue(issue, options)), suiteCount: suites.length, constraintCount, sampleCount, errorCount, warningCount, elapsedMilliseconds: nowMilliseconds() - startedAt, suiteReports }; } export function formatKinematicValidationReport(report: KinematicValidationReport, maxIssues = 40): string { if (report.ok) { return `Kinematic validation passed: ${report.suiteCount} suites, ${report.constraintCount} constraints, ${report.sampleCount} sampled frames.`; } const lines = [ `Kinematic validation failed: ${report.errorCount} errors, ${report.warningCount} warnings across ${report.suiteCount} suites.` ]; let emittedIssues = 0; for (const suite of report.suiteReports) { if (suite.ok && suite.issues.length === 0) { continue; } lines.push(`- ${suite.title} (${suite.machineId})`); for (const issue of suite.issues) { if (emittedIssues >= maxIssues) { lines.push(` • Additional issues omitted after ${maxIssues} entries.`); return lines.join('\n'); } const at = issue.timeSeconds === undefined ? '' : ` @ ${issue.timeSeconds.toFixed(6)}s`; const actual = issue.actual === undefined ? '' : ` actual=${formatNumber(issue.actual)}`; const expected = issue.expected === undefined ? '' : ` expected=${formatNumber(issue.expected)}`; const tolerance = issue.tolerance === undefined ? '' : ` tol=${formatNumber(issue.tolerance)}`; lines.push(` • [${issue.severity}] ${issue.code}${at}: ${issue.message}${actual}${expected}${tolerance}`); emittedIssues += 1; } } return lines.join('\n'); } function runSuiteValidation( suite: KinematicValidationSuite, options: ValidationRunOptions ): KinematicSuiteValidationReport { let sampleTimes: number[]; try { sampleTimes = createCycleSamples( suite.durationSeconds, options.sampleCount ?? suite.sampleCount ?? DEFAULT_KINEMATIC_SAMPLE_COUNT, options.includeEndpoint ?? true ); } catch (error) { const issue: KinematicValidationIssue = { severity: 'error', code: 'suite.invalid-sample-grid', message: messageFromUnknown(error), suiteId: suite.id, machineId: suite.machineId }; return { suiteId: suite.id, machineId: suite.machineId, title: suite.title, ok: false, durationSeconds: suite.durationSeconds, sampleCount: 0, constraintReports: [ { constraintId: 'suite.sample-grid', description: 'Sample grid can be generated for the validation suite.', ok: false, issues: [issue] } ], issues: [issue] }; } const constraintReports = suite.constraints.map((constraint): KinematicConstraintReport => { let rawIssues: KinematicValidationIssue[]; try { rawIssues = constraint.evaluate({ suite, sampleTimes, options }); } catch (error) { rawIssues = [ { severity: 'error', code: 'constraint.threw', message: `${constraint.id} threw during validation: ${messageFromUnknown(error)}` } ]; } const issues = rawIssues.map((issue) => ({ ...issue, suiteId: issue.suiteId ?? suite.id, machineId: issue.machineId ?? suite.machineId, constraintId: issue.constraintId ?? constraint.id })); return { constraintId: constraint.id, description: constraint.description, ok: !issues.some((issue) => isBlockingIssue(issue, options)), issues }; }); const issues = constraintReports.flatMap((report) => report.issues); return { suiteId: suite.id, machineId: suite.machineId, title: suite.title, ok: !issues.some((issue) => isBlockingIssue(issue, options)), durationSeconds: suite.durationSeconds, sampleCount: sampleTimes.length, constraintReports, issues }; } function assertBearingParameters(parameters: BearingKinematicParameters): void { if (!Number.isFinite(parameters.ballDiameterMeters) || parameters.ballDiameterMeters <= 0) { throw new Error(`ballDiameterMeters must be positive; received ${parameters.ballDiameterMeters}`); } if (!Number.isFinite(parameters.pitchDiameterMeters) || parameters.pitchDiameterMeters <= 0) { throw new Error(`pitchDiameterMeters must be positive; received ${parameters.pitchDiameterMeters}`); } if (parameters.ballDiameterMeters >= parameters.pitchDiameterMeters) { throw new Error( `ballDiameterMeters must be smaller than pitchDiameterMeters; received ${parameters.ballDiameterMeters} >= ${parameters.pitchDiameterMeters}` ); } } function pushLimitedIssue(issues: KinematicValidationIssue[], issue: KinematicValidationIssue): void { if (issues.length < MAX_ISSUES_PER_CONSTRAINT) { issues.push(issue); return; } if (issues.length === MAX_ISSUES_PER_CONSTRAINT) { issues.push({ severity: 'warning', code: 'validation.issue-limit', message: `Additional failures omitted after ${MAX_ISSUES_PER_CONSTRAINT} issues for this constraint.` }); } } function isBlockingIssue(issue: KinematicValidationIssue, options: ValidationRunOptions): boolean { return issue.severity === 'error' || Boolean(options.treatWarningsAsErrors && issue.severity === 'warning'); } function nowMilliseconds(): number { return typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(); } function messageFromUnknown(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } function formatNumber(value: number): string { if (!Number.isFinite(value)) { return String(value); } const magnitude = Math.abs(value); if (magnitude !== 0 && (magnitude < 0.001 || magnitude >= 10000)) { return value.toExponential(4); } return value.toFixed(6).replace(/\.?0+$/, ''); }