UNPKG

@atomic-ehr/ucum

Version:

TypeScript implementation of UCUM (Unified Code for Units of Measure)

230 lines (229 loc) 10.6 kB
import { toCanonicalForm } from './canonical-form.js'; import { Dimension } from './dimension.js'; import { getSpecialFunction } from './special-functions.js'; // Error types for better error handling export class ConversionError extends Error { constructor(message) { super(message); this.name = 'ConversionError'; } } export class IncompatibleDimensionsError extends ConversionError { constructor(fromUnit, toUnit, fromDim, toDim) { const fromDimStr = Dimension.toString(fromDim); const toDimStr = Dimension.toString(toDim); super(`Cannot convert from ${fromUnit} to ${toUnit}: incompatible dimensions (${fromDimStr} vs ${toDimStr})`); this.name = 'IncompatibleDimensionsError'; } } // Helper functions for special unit detection function isSpecialUnit(canonical) { return canonical.specialFunction !== undefined; } function hasSpecialUnits(from, to) { return isSpecialUnit(from) || isSpecialUnit(to); } // Get the base special unit's magnitude (without any prefix) function getBaseSpecialUnitMagnitude(specialFunctionName) { // Base special units and their magnitudes in canonical form // These are the magnitudes that appear in the canonical form for the base unit (no prefix) const baseSpecialMagnitudes = { 'Cel': 1, // Celsius: canonical magnitude = 1 'degF': 0.5555555555555556, // Fahrenheit: 5/9 ≈ 0.5556 'degRe': 1.25, // Réaumur: 5/4 = 1.25 'pH': 1e-9, // pH: mol/L reference, canonical magnitude = 1e-9 'ln': 1, // Natural log (Neper) 'lg': 1, // Common log (Bel) 'lgTimes2': 1, // 2*log10 (B[SPL], B[V], etc.) - varies by reference unit 'ld': 1, // Binary log (bit) 'hpX': 1, // Homeopathic decimal 'hpC': 1, // Homeopathic centesimal 'hpM': 1, // Homeopathic millesimal 'hpQ': 1, // Homeopathic quintamillesimal 'tanTimes100': 1, // Prism diopter '100tan': 0.017453292519943295, // Percent slope: deg canonical magnitude 'sqrt': 1 // Square root }; return baseSpecialMagnitudes[specialFunctionName] || 1; } // Calculate scale factor for special units that can have prefixes function getScaleFactor(canonical) { if (!canonical.specialFunction) return 1; // Only certain special units can have prefixes (isMetric="yes" in UCUM) const metricSpecialUnits = ['Cel', 'ln', 'lg', 'lgTimes2', 'ld']; if (!metricSpecialUnits.includes(canonical.specialFunction.name)) { return 1; // Non-metric special units can't have prefixes } // For metric special units, we need to be careful about detecting prefixes // The challenge is that some special units like B[W] have non-1 magnitudes // due to their reference units, not due to prefixes // For temperature (Cel), the base magnitude is always 1 if (canonical.specialFunction.name === 'Cel' && Math.abs(canonical.magnitude - 1) > 1e-10) { return canonical.magnitude; // This is a prefix like mCel } // For logarithmic units (lg, ln, ld), only B, Np, and bit_s have magnitude 1 as base // B[W], B[SPL], etc. have different magnitudes due to their reference units if (canonical.specialFunction.name === 'lg') { // Only plain B has magnitude 1, prefixed versions like dB have 0.1 if (canonical.magnitude === 1) return 1; // Plain B if (Math.abs(canonical.magnitude - 0.1) < 1e-10) return 0.1; // dB if (Math.abs(canonical.magnitude - 0.01) < 1e-10) return 0.01; // cB // B[W], B[SPL] etc. have other magnitudes - don't treat as prefixed return 1; } if (canonical.specialFunction.name === 'ln') { // Neper and its prefixed versions if (canonical.magnitude === 1) return 1; // Plain Np if (Math.abs(canonical.magnitude - 0.001) < 1e-10) return 0.001; // mNp return 1; } if (canonical.specialFunction.name === 'ld') { // bit_s and its prefixed versions if (canonical.magnitude === 1) return 1; // Plain bit_s return 1; } // For lgTimes2 (B[SPL], B[V], etc.), the magnitude varies with reference unit // So we can't use this approach - just return 1 for now return 1; } // Convert between units with special functions function convertWithSpecialFunctions(value, fromCanonical, toCanonical) { let result = value; // Extract scale factors for prefixed special units const fromScale = getScaleFactor(fromCanonical); const toScale = getScaleFactor(toCanonical); // Step 1: Convert FROM special unit to proper unit if (fromCanonical.specialFunction) { const fn = getSpecialFunction(fromCanonical.specialFunction.name); if (!fn) { throw new ConversionError(`Unknown special function: ${fromCanonical.specialFunction.name}`); } // Check output domain for the special unit value if (fn.outputDomain && !fn.outputDomain(result)) { throw new ConversionError(`Value ${result} is outside the valid domain for ${fn.name}`); } // Apply inverse function with scale: m = f_s⁻¹(α × r_s) × u // where r_s is our input value and α is fromScale result = fn.inverse(fromScale * result); // Check that the result is valid for the proper unit if (fn.inputDomain && !fn.inputDomain(result)) { throw new ConversionError(`Conversion result ${result} is outside the valid domain for the proper unit`); } } // Step 2: Linear conversion between proper units if (!fromCanonical.specialFunction && !toCanonical.specialFunction) { // Regular linear conversion result = result * (fromCanonical.magnitude / toCanonical.magnitude); } else if (fromCanonical.specialFunction && !toCanonical.specialFunction) { // Converting from special unit to regular unit // The result from the inverse function is in the reference unit // We need to convert from reference unit to target unit // But not for temperature functions where the magnitude is part of the definition // Also skip if this is a prefixed special unit (scale factor != 1) if (!fromCanonical.specialFunction.name.includes('deg') && fromCanonical.specialFunction.name !== 'Cel' && fromCanonical.specialFunction.name !== 'degRe' && fromScale === 1) { result = result * (fromCanonical.magnitude / toCanonical.magnitude); } } else if (!fromCanonical.specialFunction && toCanonical.specialFunction) { // Converting from regular unit to special unit // We need to convert to the reference unit first // But only if the dimensions match (not for temperature conversions) if (fromCanonical.magnitude !== toCanonical.magnitude) { // Don't apply magnitude conversion for temperature-like conversions // where the special function handles everything const fromDim = JSON.stringify(fromCanonical.dimension); const toDim = JSON.stringify(toCanonical.dimension); if (fromDim === toDim && !toCanonical.specialFunction.name.includes('deg') && toCanonical.specialFunction.name !== 'Cel') { result = result * (fromCanonical.magnitude / toCanonical.magnitude); } } } // If both are special units, no linear conversion is needed // Step 3: Convert TO special unit from proper unit if (toCanonical.specialFunction) { const fn = getSpecialFunction(toCanonical.specialFunction.name); if (!fn) { throw new ConversionError(`Unknown special function: ${toCanonical.specialFunction.name}`); } // Check if the value is in the valid input domain for the forward function if (fn.inputDomain && !fn.inputDomain(result)) { throw new ConversionError(`Value ${result} is outside the valid domain for ${fn.name}`); } // Apply forward function with scale: r_s = f_s(m/u) / α // where result is m/u and we divide by α (toScale) to get r_s result = fn.forward(result) / toScale; // Check output domain if (fn.outputDomain && !fn.outputDomain(result)) { throw new ConversionError(`Result ${result} is outside the valid output range for ${fn.name}`); } } return result; } // Main conversion function export function convert(value, fromUnit, toUnit) { // Parse units to canonical form let fromCanonical; let toCanonical; try { fromCanonical = toCanonicalForm(fromUnit); } catch (error) { throw new ConversionError(`Invalid source unit: ${fromUnit}`); } try { toCanonical = toCanonicalForm(toUnit); } catch (error) { throw new ConversionError(`Invalid target unit: ${toUnit}`); } // Check commensurability (same dimensions) if (!Dimension.equals(fromCanonical.dimension, toCanonical.dimension)) { throw new IncompatibleDimensionsError(fromUnit, toUnit, fromCanonical.dimension, toCanonical.dimension); } // Handle special function conversion if (hasSpecialUnits(fromCanonical, toCanonical)) { return convertWithSpecialFunctions(value, fromCanonical, toCanonical); } // Linear conversion: multiply by ratio of magnitudes const factor = fromCanonical.magnitude / toCanonical.magnitude; return value * factor; } // Convenience functions export function isConvertible(fromUnit, toUnit) { try { const fromCanonical = toCanonicalForm(fromUnit); const toCanonical = toCanonicalForm(toUnit); // Check dimensions match if (!Dimension.equals(fromCanonical.dimension, toCanonical.dimension)) { return false; } // Special units are now convertible! return true; } catch { return false; } } export function getConversionFactor(fromUnit, toUnit) { const fromCanonical = toCanonicalForm(fromUnit); const toCanonical = toCanonicalForm(toUnit); if (!Dimension.equals(fromCanonical.dimension, toCanonical.dimension)) { throw new IncompatibleDimensionsError(fromUnit, toUnit, fromCanonical.dimension, toCanonical.dimension); } if (hasSpecialUnits(fromCanonical, toCanonical)) { throw new ConversionError(`Cannot get linear conversion factor for special units`); } return fromCanonical.magnitude / toCanonical.magnitude; }