diff --git a/README.md b/README.md index 055ed39..2af83d4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,40 @@ Based on [PQM](https://github.com/GhostWrench/pqm), with extensive changes. MIT licensed. -Zero dependencies. +## Features + +- Zero dependencies. +- Only 4.5 KiB minified and gzipped. +- Basic math operations: multiply, add, subtract, etc. +- Supports tolerance values like "2±0.2 cm", and carries them through mathematical operations. +- "Remembers" the units you input and uses them by default for output. +- Metric prefixes for all SI units (e.g. km, MHz, μN) +- Binary prefixes for all information units (e.g. kib, kiB, MiB) +- Custom dimensions ("2 foo" times "6 bar" = "12 foo⋅bar") can be defined on the fly +- Temperature units: K (Kelvins), degC (Celcius measurement), deltaC (Celcius difference), degF (Fahrenheit measurement) +- Supports "%" (percent) as a unit (50% of 50% is 25%, not "0.25 % %"; 50% of 400g is 200g, not "20000 g %") +- Faster than any comparable libraries for its feature set (you can run [the benchmark](./tests/benchmark.bench.ts) + yourself with `deno bench`): + - Quantity conversions: + - 1.1x faster than `PQM` + - 1.6x faster than `mathjs` + - 2.1x faster than `unitmath` + - 3.0x faster than `js-quantities` + - Custom dimensions + - 1.2x faster than `mathjs` + - 1.9x faster than `unitmath` + - `PQM` and `js-quantities` don't support custom dimensions + +## Missing Features + +- Some mathematical operations (e.g. division, sqrt) are not implemented yet because I didn't need them yet - feel free + to add them. +- Some units are not supported because I didn't need them yet - feel free to add them (e.g. radiation, luminosity, tsp, + oz). +- Array/vector operations (do math with many similar unit values efficiently) are not supported. +- Handling of "significant figures" is only partially implemented and needs improvement. +- This library generally tries _not_ to support units that can be considered deprecated (like "bar", "dram", "furlong", + "league", "poise", etc.) or that are ambiguous (like "ton", "gallon", etc.). ## Installation @@ -30,20 +63,24 @@ Zero dependencies. Importing: ```ts -import { Quantity } from "@bradenmacdonald/quantity-math-js"; +import { Q, Quantity } from "@bradenmacdonald/quantity-math-js"; ``` Constructing a quantity value: ```ts new Quantity(10, { units: "cm" }); +// or +Q`10 cm`; +// or +Q("10 cm"); ``` Adding two quantities: ```ts -const x = new Quantity(5, { units: "m" }); -const y = new Quantity(20, { units: "cm" }); +const x = Q`5 m`; +const y = Q`20 cm`; const z = x.add(y); z.toString(); // "5.2 m" ``` @@ -51,8 +88,8 @@ z.toString(); // "5.2 m" Multiplying two quantities: ```ts -const x = new Quantity(5, { units: "kg" }); -const y = new Quantity(2, { units: "m" }); +const x = Q`5 kg`; +const y = Q`2 m`; const z = x.multiply(y); z.toString(); // "10 kg⋅m" ``` @@ -64,37 +101,19 @@ const x = new Quantity(5, { units: "lb" }); x.get(); // { magnitude: 5, units: "lb" } ``` -Serialize to simple object, using specified units: +Convert a quantity to the specified units: ```ts -const x = new Quantity(10, { units: "cm" }); -x.getWithUnits("in"); // { magnitude: 3.9370078740157486, units: "in" } +const x = Q`10 cm`; +x.convert("in").get(); // { magnitude: 3.9370078740157486, units: "in" } +x.convert("mm").toString(); // "100 mm" ``` Simplify units: ```ts const x = new Quantity(5, { units: "kg^2⋅m^2⋅s^-4⋅A^-2" }); -x.getSI(); // { magnitude: 5, units: "kg/F" } -``` - -## Syntactic Sugar - -If you prefer, there is a much more compact way to initialize `Quantity` instances: using the `Q` template helper. This -is slightly less efficient, but far more readable and convenient in many cases. - -```ts -import { Q } from "@bradenmacdonald/quantity-math-js"; - -const force = Q`34.2 kg m/s^2`; // Shorter version of: new Quantity(34.2, {units: "kg m/s^2"}) -force.getSI(); // { magnitude: 34.2, units: "N" } -force.multiply(Q`2 s^2`).toString(); // "68.4 kg⋅m" -``` - -You can also call it as a function, which acts like "parse quantity string": - -```ts -const force = Q("34.2 kg m/s^2"); // new Quantity(34.2, {units: "kg m/s^2"}) +x.toSI().toString(); // "5 kg/F" ``` ## Error/uncertainty/tolerance @@ -124,19 +143,6 @@ fb.toString(); // "20 _foo⋅_bar" fb.multiply(f).toString(); // "200 _foo^2⋅_bar" ``` -## Development Roadmap / TODOs - -- Finish implementing "significant digits" -- Implement more mathematical operations like division and exponentiation. -- Add support for angular units, including converting radians to degrees and treating "angle" as a dimension, to avoid - ambiguities with units like "rpm" and "Hz". -- Consider adding support for additional units (radiation, angles, more non-SI units). - -## Non-features / Non-goals - -This library generally tries _not_ to support units that can be considered deprecated (like "bar", "dram", "furlong", -"league", "poise", etc.) or that are ambiguous (like "ton", "gallon", etc.). - ## Running tests To run the tests, code formatter, linter, etc. you need to use [Deno](https://deno.com/). The commands are standard: diff --git a/deno.jsonc b/deno.jsonc index de64f11..d6a5865 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -5,14 +5,16 @@ "version": "1.0.1", "fmt": { "indentWidth": 4, - "lineWidth": 120 + "lineWidth": 120, + "exclude": ["tests/mod.min.js"] }, "lint": { "rules": { // Unfortunately we need "slow types" in units.ts for now. // https://github.com/jsr-io/jsr/issues/155 "exclude": ["no-slow-types"] - } + }, + "exclude": ["tests/mod.min.js"] }, "imports": { "@std/assert": "jsr:@std/assert@^0.218", diff --git a/dimensions.ts b/dimensions.ts index 443d425..ff3d293 100644 --- a/dimensions.ts +++ b/dimensions.ts @@ -2,12 +2,14 @@ import { QuantityError } from "./error.ts"; /** * How many basic dimensions there are - * (mass, length, time, temp, current, substance, luminosity, information) + * (mass, length, time, temp, current, substance, information, reserved) * * As opposed to custom dimensions, like "flurbs per bloop" which has two * custom dimensions (flurbs and bloops). */ -const numBasicDimensions = 9; +const numBasicDimensions = 8; + +const emptyArray = Object.freeze([]); // TODO: add an angle dimension, like Boost and Mathematica do. @@ -30,26 +32,17 @@ export class Dimensions { temperature: number, current: number, substance: number, - luminosity: number, information: number, - angle: number, + reserved: number, // for luminosity or angle or ? /** * Track custom dimensions. * * For special units like "passengers per hour per direction", "passengers" is a custom dimension, as is "direction" */ - custom1?: number, - custom2?: number, - custom3?: number, - custom4?: number, - ], - public readonly customDimensionNames: [ - /** e.g. "fish", "passengers", "$USD", or whatever other custom unit dimension this is */ - custom1?: string, - custom2?: string, - custom3?: string, - custom4?: string, - ] = [], + ...customDimensions: number[], + ] & { length: 8 | 10 | 9 | 11 | 12 }, + /** names of the custom dimensions, e.g. "fish", "passengers", "$USD", if relevant */ + public readonly customDimensionNames: readonly string[] = emptyArray, ) { if (dimensions.length < numBasicDimensions) { throw new QuantityError("not enough dimensions specified for Quantity."); @@ -65,7 +58,7 @@ export class Dimensions { if (numCustomDimensions) { // Make sure customDimensionNames is sorted in alphabetical order, for consistency. // This also validated that there are no duplicate custom dimensions (["floop", "floop"]) - const isSorted = customDimensionNames.every((v, i, a) => (i === 0 || v! > a[i - 1]!)); + const isSorted = customDimensionNames.every((v, i, a) => (i === 0 || v > a[i - 1])); if (!isSorted) { throw new QuantityError("customDimensionNames is not sorted into the correct alphabetical order."); } @@ -74,8 +67,7 @@ export class Dimensions { /** Is this dimensionless? (all dimensions are zero) */ public get isDimensionless(): boolean { - if (this.#cachedDimensionality !== undefined) return this.#cachedDimensionality === 0; - return this.dimensions.every((d) => d === 0); + return this.dimensionality === 0; } /** Private cache of the dimensionality, as an optimization */ @@ -84,21 +76,15 @@ export class Dimensions { /** Get the dimensionality of this - the sum of the absolute values of all dimensions */ public get dimensionality(): number { if (this.#cachedDimensionality === undefined) { - this.#cachedDimensionality = this.dimensions.reduce((sum, d) => sum + Math.abs(d ?? 0), 0); + this.#cachedDimensionality = this.dimensions.reduce((sum, d) => sum + Math.abs(d), 0); } return this.#cachedDimensionality; } private static combineCustomDimensionNames(x: Dimensions, y: Dimensions) { - const customDimensionNames = [...x.customDimensionNames]; - for (const custDimName of y.customDimensionNames) { - if (!customDimensionNames.includes(custDimName)) { - customDimensionNames.push(custDimName); - } - } + const set = new Set([...x.customDimensionNames, ...y.customDimensionNames]); // Custom dimension names must always be sorted. - customDimensionNames.sort(); - return customDimensionNames; + return Array.from(set).sort(); } /** @@ -133,9 +119,8 @@ export class Dimensions { public multiply(y: Dimensions): Dimensions { if (this.customDimensionNames.length === 0 && y.customDimensionNames.length === 0) { // Normal case - no custom dimensions: - const newDimArray = this.dimensions.map((d, i) => d! + y.dimensions[i]!); - // deno-lint-ignore no-explicit-any - return new Dimensions(newDimArray as any, []); + const newDimArray = this.dimensions.map((d, i) => d + y.dimensions[i]) as typeof this.dimensions; + return new Dimensions(newDimArray); } else { // We have to handle custom dimensions in one or both Dimensions objects. // They may have different custom dimensions or may be the same. @@ -147,41 +132,51 @@ export class Dimensions { const newDimArray = new Array(numBasicDimensions + customDimensionNames.length); // Multiply the basic dimensions: for (let i = 0; i < numBasicDimensions; i++) { - newDimArray[i] = this.dimensions[i]! + y.dimensions[i]!; + newDimArray[i] = this.dimensions[i] + y.dimensions[i]; } // Multiply the custom dimensions: for (let i = 0; i < customDimensionNames.length; i++) { let dimValue = 0; const custDimName = customDimensionNames[i]; const thisIdx = this.customDimensionNames.indexOf(custDimName); - if (thisIdx !== -1) dimValue += this.dimensions[numBasicDimensions + thisIdx]!; + if (thisIdx !== -1) dimValue += this.dimensions[numBasicDimensions + thisIdx]; const yIdx = y.customDimensionNames.indexOf(custDimName); - if (yIdx !== -1) dimValue += y.dimensions[numBasicDimensions + yIdx]!; + if (yIdx !== -1) dimValue += y.dimensions[numBasicDimensions + yIdx]; newDimArray[numBasicDimensions + i] = dimValue; } - // deno-lint-ignore no-explicit-any - return new Dimensions(newDimArray as any, customDimensionNames as any); + return new Dimensions(newDimArray as typeof this.dimensions, customDimensionNames); + } + } + + /** Multiply by the inverse of the given dimensions */ + public divide(y: Dimensions): Dimensions { + if (this.customDimensionNames.length === 0 && y.customDimensionNames.length === 0) { + // Optimized case if we don't have to deal with custom dimensions + // This direct "division" via subtraction is faster than multiplying by the inverse + const newDimArray = this.dimensions.map((d, i) => d - y.dimensions[i]) as typeof this.dimensions; + return new Dimensions(newDimArray); + } else { + return this.multiply(y.invert()); } } /** Invert these dimensions, returning a new inverted Dimensions instance */ public invert(): Dimensions { - const newDimArray = this.dimensions.map((d) => d! * -1); - // deno-lint-ignore no-explicit-any - return new Dimensions(newDimArray as any, this.customDimensionNames); + const newDimArray = this.dimensions.map((d) => d * -1) as typeof this.dimensions; + return new Dimensions(newDimArray, this.customDimensionNames); } /** Raise these dimensions to a power */ public pow(n: number): Dimensions { - if (!Number.isInteger(n)) { - throw new QuantityError(`Dimensions.pow(n): n must be an integer`); - } - if (n === 0) { + if (n === 1) { + return this; + } else if (n === 0) { return Dimensionless; + } else if (!Number.isInteger(n)) { + throw new QuantityError(`Dimensions.pow(n): n must be an integer`); } - const newDimArray = this.dimensions.map((d) => d! * n); - // deno-lint-ignore no-explicit-any - return new Dimensions(newDimArray as any, this.customDimensionNames); + const newDimArray = this.dimensions.map((d) => d * n) as typeof this.dimensions; + return new Dimensions(newDimArray, this.customDimensionNames); } /** Use a nice string when logging this with Deno's console.log() etc. */ @@ -223,4 +218,4 @@ export class Dimensions { * * Likewise an SI expression like "1 μm/m" is dimensionless after simplification. */ -export const Dimensionless: Dimensions = new Dimensions([0, 0, 0, 0, 0, 0, 0, 0, 0]); +export const Dimensionless: Dimensions = new Dimensions([0, 0, 0, 0, 0, 0, 0, 0]); diff --git a/error.ts b/error.ts index fe6a5e9..127dc3a 100644 --- a/error.ts +++ b/error.ts @@ -1,2 +1,13 @@ /** Base class for all errors thrown by quantity-math-js */ export class QuantityError extends Error {} + +/** + * The requested conversion is not possible/valid. + * + * e.g. converting meters to seconds. + */ +export class InvalidConversionError extends QuantityError { + constructor() { + super("Cannot convert units that aren't compatible."); + } +} diff --git a/mod.ts b/mod.ts index ded3488..93ff05f 100644 --- a/mod.ts +++ b/mod.ts @@ -7,5 +7,5 @@ export { Quantity, type SerializedQuantity } from "./quantity.ts"; export { Q } from "./q.ts"; export { builtInUnits, type ParsedUnit, parseUnits, type Unit } from "./units.ts"; -export { QuantityError } from "./error.ts"; +export { InvalidConversionError, QuantityError } from "./error.ts"; export { Dimensionless, Dimensions } from "./dimensions.ts"; diff --git a/q.ts b/q.ts index 2d82889..2795f9a 100644 --- a/q.ts +++ b/q.ts @@ -1,6 +1,10 @@ -import { Quantity, QuantityError } from "@bradenmacdonald/quantity-math-js"; +import { Quantity } from "./quantity.ts"; +import { QuantityError } from "./error.ts"; -export function Q(strings: string | ReadonlyArray, ...keys: unknown[]): Quantity { +/** + * Construct a `Quantity` instance from a string. + */ +export function Q(strings: string | readonly string[], ...keys: unknown[]): Quantity { let fullString: string; if (typeof strings == "string") { fullString = strings; diff --git a/quantity.ts b/quantity.ts index 9a7356e..52f989a 100644 --- a/quantity.ts +++ b/quantity.ts @@ -1,6 +1,6 @@ import { Dimensionless, Dimensions } from "./dimensions.ts"; -import { QuantityError } from "./error.ts"; -import { baseSIUnits, getUnitData, ParsedUnit, parseUnits, prefixes, toUnitString } from "./units.ts"; +import { InvalidConversionError, QuantityError } from "./error.ts"; +import { baseSIUnits, getUnitData, ParsedUnit, parseUnits, PreferredUnit, prefixes, toUnitString } from "./units.ts"; /** * Simple data structure that holds all the key data of a Quantity instance. @@ -14,8 +14,10 @@ export interface SerializedQuantity { units: string; } -// Private constructor parameter to pass '_unitHintSet' values. -const setUnitHintSet = Symbol("unitHintSet"); +/** Private constructor parameter to pass '_unitOutput' values. */ +const setUnitOutput = Symbol("setUnitOutput"); +/** Private constructor parameter to skip applying an offset to units like degF that are offset from the base unit */ +const applyOffset = Symbol("applyOffset"); /** * Quantity - a value with dimensions (units) @@ -65,31 +67,28 @@ export class Quantity { protected _plusMinus: number | undefined; /** - * For a few units like "degC", "degF", and "gauge Pascals", we need to keep track of their offset from - * the base units. (e.g. 0C = 273.15K). This is ONLY used within getWithUnits() and this field does not - * need to be preserved when cloning a Quantity or doing math with Quantities, because the offset is - * already applied within the constructor, which converts everything to non-offset base units. + * Units to use instead of the base units, when displaying this value. */ - protected readonly _offsetUsed: number | undefined; - /** - * Units that were used when constructing or deriving this Quantity (if known), to use by default when serializing it. - * The 'power' values of this are always ignored and may be omitted - only the prefixes and units are used. - */ - protected _unitHintSet: ParsedUnit[] | undefined; + public readonly unitOutput: readonly ParsedUnit[] | undefined; constructor( protected _magnitude: number, options: { dimensions?: Dimensions; - units?: string | ParsedUnit[]; + units?: string | readonly ParsedUnit[]; /** * If set, only this many of the decimal digits of the magnitude are significant. */ significantFigures?: number; /** Allowed uncertainty/error/tolerance in this measurement. Must be using the same units as the magnitude. */ plusMinus?: number; - /** Internal use only - set the _unitHintSet on this newly constructed Quantity */ - [setUnitHintSet]?: ParsedUnit[]; + /** Internal use only - set the _unitOutput on this newly constructed Quantity */ + [setUnitOutput]?: readonly ParsedUnit[]; + /** + * Internal use only - override how we handle offset units like "degC" or "degF" + * where 0 in the offset unit doesn't equal 0 in the base unit. + */ + [applyOffset]?: (offset: number) => void; } = {}, ) { this.significantFigures = options.significantFigures; @@ -98,8 +97,10 @@ export class Quantity { if (options.dimensions) { throw new QuantityError(`You can specify units or dimensions, but not both.`); } - const units: ParsedUnit[] = typeof options.units === "string" ? parseUnits(options.units) : options.units; - this._unitHintSet = units; + const units: readonly ParsedUnit[] = typeof options.units === "string" + ? parseUnits(options.units) + : options.units; + this.unitOutput = units; this._dimensions = Dimensionless; for (const u of units) { const unitData = getUnitData(u.unit); @@ -108,20 +109,27 @@ export class Quantity { unitQuantity._pow(u.power); this._multiply(unitQuantity); if (unitData.offset) { + // For a few units like "degC", "degF", and "gauge Pascals", we need to apply an offset from + // the base units. (e.g. 0C = 273.15K). This only happens here during the constructor, where + // we convert everything to non-offset base units. (A related conversion also happens in + // .get() ). if (units.length !== 1) { throw new QuantityError( `It is not permitted to use compound units that include the offset unit "${u}". Try using K, deltaC, or Pa instead.`, ); // e.g. "50 °C per kilometer" doesn't make any sense, but "50 ΔC per kilometer" could make sense. } - this._magnitude += unitData.offset; - // We need to track the offset for the getWithUnits() method to be able to do conversions properly. - this._offsetUsed = unitData.offset; + // Normally we just add the offset to the magnitude of this unit, but the .get() method + // need different functionality so can override that when necessary. + const doOffset = options[applyOffset] ?? ((offset: number) => this._magnitude += offset); + doOffset(unitData.offset); } } } else if (options.dimensions) { this._dimensions = options.dimensions; - this._unitHintSet = options[setUnitHintSet]; + this.unitOutput = options[setUnitOutput]; + // Normalize the _unitOutput value to never be an empty array: + if (this.unitOutput?.length === 0) this.unitOutput = undefined; } else { this._dimensions = Dimensionless; } @@ -238,6 +246,26 @@ export class Quantity { } return r; } + /** + * Convert this Quantity to a different (compatible) unit. + * + * Example: convert 10kg to pounds (approx 22 lb) + * ```ts + * new Quantity(10, {units: "kg"}).convert("lb") // Quantity(22.046..., { units: "lb" }) + * ``` + */ + public convert(units: string | ParsedUnit[]): Quantity { + const unitsNormalized: ParsedUnit[] = typeof units == "string" ? (units ? parseUnits(units) : []) : units; + // First do some validation: + let dimensions = Dimensionless; + for (const u of unitsNormalized) { + dimensions = dimensions.multiply(getUnitData(u.unit).d.pow(u.power)); + } + if (!this._dimensions.equalTo(dimensions)) { + throw new InvalidConversionError(); + } + return this._clone({ newUnitOutput: unitsNormalized }); + } /** * Get the value of this (as a SerializedQuantity) using the specified units. @@ -246,27 +274,13 @@ export class Quantity { * ```ts * new Quantity(10, {units: "kg"}).getWithUnits("lb") // { magnitude: 22.046..., units: "lb" } * ``` + * + * @deprecated Use `.convert(units).get()` instead */ public getWithUnits(units: string | ParsedUnit[]): SerializedQuantity { - const converter = new Quantity(1, { units }); - if (!converter.sameDimensionsAs(this)) { - throw new QuantityError("Cannot convert units that aren't compatible."); - } - if (converter._offsetUsed) { - // For units of C/F temperature or "gauge Pascals" that have an offset, undo that offset - // so that the converter represents the unit quantity. - converter._magnitude -= converter._offsetUsed; - } - const result: SerializedQuantity = { - magnitude: (this._magnitude - (converter._offsetUsed ?? 0)) / converter._magnitude, - units: typeof units === "string" ? units : toUnitString(units), - }; - if (this.significantFigures) { - result.significantFigures = this.significantFigures; - } - if (this.plusMinus) { - result.plusMinus = this.plusMinus / converter.magnitude; - } + const result = this.convert(units).get(); + // getWithUnits() always returned the unit string as passed in, un-normalized: + result.units = typeof units === "string" ? units : toUnitString(units); return result; } @@ -279,12 +293,25 @@ export class Quantity { * ``` */ public get(): SerializedQuantity { - if (this._unitHintSet) { - const unitsToUse = this.pickUnitsFromList(this._unitHintSet); - return this.getWithUnits(unitsToUse); + const unitsForResult: readonly ParsedUnit[] = this.unitOutput ?? this.pickUnitsFromList(baseSIUnits); + let magnitudeUnscaled = this._magnitude; + const converter = new Quantity(1, { + units: unitsForResult, + [applyOffset]: (offset) => magnitudeUnscaled -= offset, + }); + + const result: SerializedQuantity = { + magnitude: magnitudeUnscaled / converter._magnitude, + units: toUnitString(unitsForResult), + }; + if (this.significantFigures) { + // TODO: remove this + result.significantFigures = this.significantFigures; } - // Fall back to SI units if we can't use the units in the "hint set". - return this.getSI(); + if (this.plusMinus) { + result.plusMinus = this.plusMinus / converter.magnitude; + } + return result; } /** @@ -296,17 +323,74 @@ export class Quantity { * ``` */ public getSI(): SerializedQuantity { - const unitList = this.pickUnitsFromList(baseSIUnits); - return this.getWithUnits(unitList); + return this.toSI().get(); + } + + /** + * Ensure that this Quantity is using SI units, with the most compact + * representation possible. + * + * ```ts + * new Quantity(10, {units: "ft"}).toSI().toString() // "3.048 m" + * new Quantity(10, {units: "N m"}).toString() // "10 N⋅m" + * new Quantity(10, {units: "N m"}).toSI().toString() // "10 J" + * ``` + */ + public toSI(): Quantity { + if (this.unitOutput) { + return this._clone({ newUnitOutput: undefined }); + } + return this; } /** * Internal method: given a list of possible units, pick the most compact subset * that can be used to represent this quantity. */ - protected pickUnitsFromList(unitList: ReadonlyArray>): ParsedUnit[] { + protected pickUnitsFromList(unitList: readonly PreferredUnit[]): ParsedUnit[] { // Convert unitList to a dimension Array const unitArray: Dimensions[] = unitList.map((u) => getUnitData(u.unit).d); + // Loop through each dimension and create a list of unit list indexes that + // are the best match for the dimension + const { useUnits, useUnitsPower } = this.pickUnitsFromListIterativeReduction(unitArray); + + // Special case to handle dimensionless units like "%" that we may actually want to use: + if (unitList.length === 1 && useUnits.length === 0) { + // We want "50 % ⋅ 50 %" to give "25 %" + // But we want "50 % ⋅ 400 g" to give "200 g" (not "20,000 g⋅%"!) + for (let unitIdx = 0; unitIdx < unitList.length; unitIdx++) { + if (unitArray[unitIdx].isDimensionless) { + useUnits.push(unitIdx); + useUnitsPower.push(1); + break; // Only include up to one dimensionless unit like "%" + } + } + } + + // At this point the units to be used are in useUnits + return useUnits.map((i, pi) => ({ + unit: unitList[i].unit, + prefix: unitList[i].prefix, + power: useUnitsPower[pi], + })); + } + + /** + * Internal method: given a list of possible units, pick the most compact subset + * that can be used to represent this quantity. + * + * This algorithm doesn't always succeed (e.g. it can't pick "C/s" from [C, s] to + * represent A - amperes), but it will work if given a good basis set (e.g. the + * SI base units), and it does produce an optimal result in most cases. + * + * For challenging cases like picking Coulombs per second to represent 1 Ampere, + * from a list of units that has [Coulombs, seconds] only, it's necessary to + * use a different algorithm, like expressing the problem as a set of linear + * equations and using Gauss–Jordan elimination to solve for the coefficients. + */ + protected pickUnitsFromListIterativeReduction( + unitArray: Dimensions[], + ): { useUnits: number[]; useUnitsPower: number[] } { // Loop through each dimension and create a list of unit list indexes that // are the best match for the dimension const useUnits: number[] = []; @@ -314,31 +398,35 @@ export class Quantity { let remainder = this._dimensions; while (remainder.dimensionality > 0) { let bestIdx = -1; - let bestInv = 0; + let bestInv = false; let bestRemainder = remainder; - for (let unitIdx = 0; unitIdx < unitList.length; unitIdx++) { + unitsLoop: + for (let unitIdx = 0; unitIdx < unitArray.length; unitIdx++) { const unitDimensions = unitArray[unitIdx]; - if (unitDimensions.isDimensionless) continue; // Dimensionless units get handled later... - for (let isInv = 1; isInv >= -1; isInv -= 2) { - const newRemainder = remainder.multiply(isInv === 1 ? unitDimensions.invert() : unitDimensions); + let isInv = false; + do { + const newRemainder = isInv ? remainder.multiply(unitDimensions) : remainder.divide(unitDimensions); // If this unit reduces the dimensionality more than the best candidate unit yet found, // or reduces the dimensionality by the same amount but is in the numerator rather than denominator: if ( (newRemainder.dimensionality < bestRemainder.dimensionality) || - (newRemainder.dimensionality === bestRemainder.dimensionality && isInv === 1 && bestInv === -1) + (newRemainder.dimensionality === bestRemainder.dimensionality && !isInv && bestInv) ) { bestIdx = unitIdx; bestInv = isInv; bestRemainder = newRemainder; - break; // Tiny optimization: if this unit is better than bestRemainder, we don't need to check its inverse + // If we've matched all the dimensions, there's no need to check more units. + if (newRemainder.isDimensionless && !isInv) break unitsLoop; + // Otherwise, if this unit is better than bestRemainder, we don't need to check its inverse + break; } - } + isInv = !isInv; + } while (isInv); } // Check to make sure that progress is being made towards remainder = 0 - // if no more progress is being made then the provided units don't span - // this unit, throw an error. - if (bestRemainder.dimensionality >= remainder.dimensionality) { - throw new QuantityError(`Cannot represent this quantity with the supplied units`); + // If no more progress is being made then we won't be able to find a compatible unit set from this list. + if (bestIdx === -1) { + throw new InvalidConversionError(); } // Check if the new best unit already in the set of numerator or // denominator units. If it is, increase the power of that unit, if it @@ -346,64 +434,29 @@ export class Quantity { const existingIdx = useUnits.indexOf(bestIdx); if (existingIdx == -1) { useUnits.push(bestIdx); - useUnitsPower.push(bestInv); + useUnitsPower.push(bestInv ? -1 : 1); } else { - useUnitsPower[existingIdx] += bestInv; + useUnitsPower[existingIdx] += bestInv ? -1 : 1; } remainder = bestRemainder; } - // Special case to handle dimensionless units like "%" that we may actually want to use: - if (unitList.length === 1 && useUnits.length === 0) { - // We want "50 % ⋅ 50 %" to give "25 %" - // But we want "50 % ⋅ 400 g" to give "200 g" (not "20,000 g⋅%"!) - for (let unitIdx = 0; unitIdx < unitList.length; unitIdx++) { - if (unitArray[unitIdx].isDimensionless) { - useUnits.push(unitIdx); - useUnitsPower.push(1); - break; // Only include up to one dimensionless unit like "%" - } - } - } - - // At this point the units to be used are in useUnits - return useUnits.map((i, pi) => ({ - unit: unitList[i].unit, - prefix: unitList[i].prefix, - power: useUnitsPower[pi], - })); + return { useUnits, useUnitsPower }; } /** * Clone this Quantity. This is an internal method, because as far as the public API allows, * Quantity objects are immutable, so there is no need to use this API publicly. */ - protected _clone(): Quantity { + protected _clone(options: { newUnitOutput?: readonly ParsedUnit[] | undefined } = {}): Quantity { return new Quantity(this._magnitude, { dimensions: this._dimensions, plusMinus: this._plusMinus, significantFigures: this.significantFigures, - [setUnitHintSet]: this._unitHintSet, + [setUnitOutput]: "newUnitOutput" in options ? options.newUnitOutput : this.unitOutput, }); } - /** - * Internal helper method: when doing a mathematical operation involving two Quantities, use this to combine their - * "unit hints" so that the resulting Quantity object will "remember" the preferred unit for the result. - */ - protected static combineUnitHints( - h1: ParsedUnit[] | undefined, - h2: ParsedUnit[] | undefined, - ): ParsedUnit[] | undefined { - const unitHintSet: ParsedUnit[] = []; - for (const u of (h1 ?? []).concat(h2 ?? [])) { - if (!unitHintSet.find((eu) => eu.unit === u.unit)) { - unitHintSet.push(u); - } - } - return unitHintSet.length ? unitHintSet : undefined; - } - /** Add this to another Quantity, returning the result as a new Quantity object */ public add(y: Quantity): Quantity { if (!this._dimensions.equalTo(y._dimensions)) { @@ -429,8 +482,8 @@ export class Quantity { dimensions: this._dimensions, plusMinus, significantFigures, - // Preserve the "unit hints" so that the new Quantity will remember what units were used: - [setUnitHintSet]: Quantity.combineUnitHints(this._unitHintSet, y._unitHintSet), + // Preserve the output units, so that the new Quantity will remember what units were requested: + [setUnitOutput]: this.unitOutput, }); } @@ -471,13 +524,43 @@ export class Quantity { // Multiply the magnitude: this._magnitude *= y._magnitude; - // Add in the additional unit hints, if applicable: - this._unitHintSet = Quantity.combineUnitHints(this._unitHintSet, y._unitHintSet); + + // This internal version of _multiply() doesn't change _unitOutput, but the + // public version will adjust it when needed. } /** Multiply this Quantity by another Quantity and return the new result */ public multiply(y: Quantity): Quantity { - const result = this._clone(); + // Figure out what preferred unit should be used for the new Quantity, if relevant: + let newUnitOutput: readonly ParsedUnit[] | undefined = undefined; + if (this.unitOutput && y.unitOutput) { + const xUnits = this.unitOutput.map((u) => ({ ...u, ...getUnitData(u.unit) })); + const yUnits = y.unitOutput.map((u) => ({ ...u, ...getUnitData(u.unit) })); + if (xUnits.length === 1 && xUnits[0].d.isDimensionless) { + newUnitOutput = y.unitOutput; + } else if (yUnits.length === 1 && yUnits[0].d.isDimensionless) { + newUnitOutput = this.unitOutput; + } else { + // modify xUnits by combining yUnits into it + for (const u of yUnits) { + const xEntry = xUnits.find((x) => x.d.equalTo(u.d)); + if (xEntry !== undefined) { + xEntry.power += u.power; + } else { + xUnits.push(u); + } + } + newUnitOutput = xUnits.filter((u) => u.power !== 0).map((x) => ({ + unit: x.unit, + power: x.power, + prefix: x.prefix, + })); + } + } else { + newUnitOutput = this.unitOutput ?? y.unitOutput; + } + // Do the actual multiplication of the magnitude and dimensions: + const result = this._clone({ newUnitOutput }); result._multiply(y); return result; } diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..08f2bca --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +mod.min.js diff --git a/tests/advanced-custom.test.ts b/tests/advanced-custom.test.ts index 254a762..089c2c5 100644 --- a/tests/advanced-custom.test.ts +++ b/tests/advanced-custom.test.ts @@ -26,7 +26,7 @@ Deno.test("Units with 'custom' dimensions", async (t) => { const q = new Quantity(3400, { units: "pphpd" }); assertEquals( q.dimensions, - new Dimensions([0, 0, -1, 0, 0, 0, 0, 0, 0, -1, 1], [ + new Dimensions([0, 0, -1, 0, 0, 0, 0, 0, -1, 1], [ "dir", "pax", ]), @@ -67,7 +67,7 @@ Deno.test("Units with 'custom' dimensions", async (t) => { magnitude: 3_400, units: "pphpd⋅day", }); - assertEquals(product.getWithUnits(`_pax/_dir`), { + assertEquals(product.convert(`_pax/_dir`).get(), { magnitude: 3_400 * 24, units: "_pax/_dir", }); diff --git a/tests/benchmark.bench.ts b/tests/benchmark.bench.ts index dc0b123..4b76c5a 100644 --- a/tests/benchmark.bench.ts +++ b/tests/benchmark.bench.ts @@ -2,6 +2,8 @@ import { Q as ourQ } from "../mod.ts"; import JSQ_Qty from "npm:js-quantities@1.8.0"; import PQM from "npm:pqm@1.0.0"; +import * as mathjs from "npm:mathjs@12.4.1"; +import unitmath from "npm:unitmath@1.1.0"; Deno.bench("Quantity conversions - quantity-math-js", { group: "conversion", baseline: true }, () => { const Q = ourQ; @@ -12,13 +14,13 @@ Deno.bench("Quantity conversions - quantity-math-js", { group: "conversion", bas const e = d.multiply(Q`0.1 s^-1`); // 1 kg * m / s^2 (= 1 N) const f = e.add(Q`5.5 N`); const g = f.multiply(Q`10`).add(Q`5 N`).add(Q`-20 N`).multiply(Q`2`); - const h = g.getSI(); - if (`${h.magnitude} ${h.units}` !== "100 N") throw new Error(`Got ${h.toString()} unexpectedly.`); + const h = g.toSI(); + if (h.toString() !== "100 N") throw new Error(`Got ${h.toString()} unexpectedly.`); // And some crazy conversion: const orig = Q`500 uF`; - const converted = orig.getWithUnits("h⋅s^3⋅A^2/lb⋅m⋅ft"); - if (`${converted.magnitude} ${converted.units}` !== "1.920207699666667e-8 h⋅s^3⋅A^2/lb⋅m⋅ft") { + const converted = orig.convert("h⋅s^3⋅A^2/lb⋅m⋅ft"); + if (converted.toString() !== "1.920207699666667e-8 h⋅s^3⋅A^2/lb⋅m⋅ft") { throw new Error(`Got ${converted.toString()} unexpectedly.`); } }); @@ -66,3 +68,112 @@ Deno.bench("Quantity conversions - PQM", { group: "conversion" }, () => { throw new Error(`Got ${converted.toString()} unexpectedly.`); } }); + +Deno.bench("Quantity conversions - mathjs", { group: "conversion" }, () => { + const Q = mathjs.unit; + const a = Q(400, `g`); + const b = Q("0.5"); // Can't find a way to do "50 %" in mathjs ? + const c = a.multiply(b); + const d = c.multiply(Q(50, `m/s`)); // 10,000 g * m / s (= 10 kg * m / s) + const e = d.multiply(Q(0.1, `s^-1`)); // 1 kg * m / s^2 (= 1 N) + const f = mathjs.add(e, Q(5.5, `N`)); + const g = mathjs.add(mathjs.add(f.multiply(Q("10")), Q(5, `N`)), Q(-20, `N`)).multiply(Q("2")); + const h = g.toSI(); + // This library won't simplify to Newtons, since it's technically a derived unit. + if (h.toString() !== "100 (kg m) / s^2") throw new Error(`Got ${h.toString()} unexpectedly.`); + + // And some crazy conversion: + const orig = Q(500, `uF`); + const converted = orig.to("hr s^3 A^2/(lbm m ft)"); + if (converted.toString() !== "1.9202076996666667e-8 (hr s^3 A^2) / (lbm m ft)") { + throw new Error(`Got ${converted.toString()} unexpectedly.`); + } +}); + +Deno.bench("Quantity conversions - unitmath", { group: "conversion" }, () => { + // deno-lint-ignore no-explicit-any + const Q = unitmath as any as typeof unitmath.default; // Not sure why types are wrong... + const a = Q("400 g"); + const b = Q("0.5"); // Can't find a way to do "50 %" in unitmath ? + const c = a.mul(b); + c.toString(); + const d = c.mul(Q(`50 m/s`)); // 10,000 g * m / s (= 10 kg * m / s) + const e = d.mul(Q(`0.1 s^-1`)); // 1 kg * m / s^2 (= 1 N) + const f = e.add(Q(`5.5 N`)); + const g = f.mul(Q(`10`)).add(Q(`5 N`)).add(Q(`-20 N`)).mul(Q(`2`)); + const h = g.simplify(); + if (h.toString() !== "100 N") throw new Error(`Got ${h.toString()} unexpectedly.`); + + // And some crazy conversion: + const orig = Q(`500 uF`); + const converted = orig.to("h*s^3*A^2/lb*m*ft"); + if (converted.toString() !== "1.92020769966667e-8 h s^3 A^2 / lb m ft") { + throw new Error(`Got ${converted.toString()} unexpectedly.`); + } +}); + +Deno.bench("Custom units - quantity-math-js", { group: "custom", baseline: true }, () => { + const Q = ourQ; + const a = Q`400 _fleeb`; + const b = Q`50 %`; + const c = a.multiply(b); // 200 _fleeb + const d = c.multiply(Q`50 _bil/_boop`); // 10,000 _fleeb _bil / _boop + const e = d.multiply(Q`0.1 s^-1`); // 1,000 _fleeb _bil / _boop s + const f = e.add(Q`500 _fleeb _bil / _boop s`); // 1,500 _fleeb _bil / _boop s + const g = f.multiply(Q`10`).add(Q`5 _fleeb _bil / _boop s`).add(Q`-20 _fleeb _bil / _boop s`).multiply(Q`2`); + if (g.toString() !== "29970 _fleeb⋅_bil/_boop⋅s") throw new Error(`Got ${g.toString()} unexpectedly.`); + + const h = g.multiply(Q`0.1 _schleem`).sub(Q`997 _fleeb _schleem _bil / _boop s`).multiply(Q`1 _boop / _bil`); + if (h.toString() !== "2000 _fleeb⋅_schleem/s") throw new Error(`Got ${h.toString()} unexpectedly.`); +}); + +mathjs.createUnit("fleeb"); +mathjs.createUnit("bil"); +mathjs.createUnit("boop"); +mathjs.createUnit("schleem"); + +Deno.bench("Custom units - math-js", { group: "custom" }, () => { + const Q = mathjs.unit; + const a = Q(`400 fleeb`); + const b = Q("0.5"); // Can't find a way to do "50 %" in mathjs ? + const c = a.multiply(b); // 200 fleeb + const d = c.multiply(Q(`50 bil/boop`)); // 10,000 fleeb bil / boop + const e = d.multiply(Q(`0.1 s^-1`)); // 1,000 fleeb bil / boop s + const f = mathjs.add(e, Q(`500 fleeb bil / (boop s)`)); // 1,500 fleeb bil / boop s + const g = mathjs.add(mathjs.add(f.multiply(Q(`10`)), Q(`5 fleeb bil / (boop s)`)), Q(`-20 fleeb bil / (boop s)`)) + .multiply(Q(`2`)); + if (g.toString() !== "29970 (fleeb bil) / (boop s)") throw new Error(`Got ${g.toString()} unexpectedly.`); + + const h = mathjs.subtract(g.multiply(Q(`0.1 schleem`)), Q(`997 fleeb schleem bil / (boop s)`)).multiply( + Q(`1 boop / bil`), + ); + if (h.toString() !== "2000 (fleeb schleem) / s") throw new Error(`Got ${h.toString()} unexpectedly.`); +}); + +Deno.bench("Custom units - unitmath", { group: "custom" }, (t) => { + // deno-lint-ignore no-explicit-any + const um = unitmath as any as typeof unitmath.default; // Not sure why types are wrong... + const Q = um.config({ + definitions: { + units: { + fleeb: { quantity: "FLEEB", value: 1 }, + bil: { quantity: "BIL", value: 1 }, + boop: { quantity: "BOOP", value: 1 }, + schleem: { quantity: "SCHLEEM", value: 1 }, + }, + }, + }); + t.start(); // Don't count the setup above in the benchmark. + const a = Q("400 fleeb"); + const b = Q("0.5"); // Can't find a way to do "50 %" in mathjs ? + const c = a.mul(b); // 200 fleeb + const d = c.mul(Q("50 bil/boop")); // 10,000 fleeb bil / boop + const e = d.mul(Q("0.1 s^-1")); // 1,000 fleeb bil / boop s + const f = e.add(Q("500 fleeb bil / boop s")); // 1,500 fleeb bil / boop s + const g = f.mul(Q("10")).add(Q("5 fleeb bil / boop s")).add(Q("-20 fleeb bil / boop s")).mul(Q("2")); + if (g.toString() !== "29970 fleeb bil / boop s") throw new Error(`Got ${g.toString()} unexpectedly.`); + + const h = g.mul(Q("0.1 schleem")).sub(Q("997 fleeb schleem bil / boop s")).mul(Q("1 boop / bil")); + if (h.toString() !== "2000 fleeb schleem / s") throw new Error(`Got ${h.toString()} unexpectedly.`); + t.end(); +}); diff --git a/tests/bundle-size.ts b/tests/bundle-size.ts new file mode 100755 index 0000000..f022206 --- /dev/null +++ b/tests/bundle-size.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env deno run --allow-read --allow-env --allow-net --allow-write --allow-run +/** + * This tool allows a quick way to measure the size of the minified, .gzipped bundle + * of quantity-math-js. Just run it (it's marked as executable). + */ +import * as esbuild from "https://deno.land/x/esbuild@v0.20.1/mod.js"; + +const testsDir = import.meta.dirname + "/"; +const outFile = `${testsDir}/mod.min.js`; + +await esbuild.build({ + entryPoints: ["mod.ts"], + bundle: true, + minify: true, + target: "es2020", + outfile: outFile, + format: "esm", +}); +console.log("Created mod.min.js"); +console.log("Validating it..."); +const { Q } = await import(outFile); +if (Q`10 m`.add(Q(`15 cm`)).toString() !== "10.15 m") { + throw new Error("Minified version doesn't work."); +} +const minifiedSource = await Deno.readFile(outFile); +const minifiedSizeQ = Q(`${minifiedSource.byteLength} B`); +console.log(`Size: ${minifiedSizeQ.convert("KiB").toString()}`); + +// GZip +const instream = ReadableStream.from([minifiedSource]).pipeThrough(new CompressionStream("gzip")); +let gzippedSize = 0; +for await (const chunk of instream) { + gzippedSize += chunk.byteLength; +} +const gzippedSizeQ = Q(`${gzippedSize} B`); +console.log(`Size: ${gzippedSizeQ.convert("KiB").toString()}`); diff --git a/tests/conversions.test.ts b/tests/conversions.test.ts index 88794b5..73fbd0b 100644 --- a/tests/conversions.test.ts +++ b/tests/conversions.test.ts @@ -1,5 +1,5 @@ import { assertEquals, AssertionError, assertThrows } from "@std/assert"; -import { Quantity, QuantityError, SerializedQuantity } from "../mod.ts"; +import { InvalidConversionError, Quantity, SerializedQuantity } from "../mod.ts"; /** * Ensure that the actual number is very close to the expected numeric value. @@ -38,15 +38,23 @@ Deno.test("Quantity conversions", async (t) => { orig: number, options: ConstructorParameters[1] & { units: string }, outUnits: string, - expected: Omit, + expected: Omit & { units?: string }, ) => { await t.step(`${orig} ${options.units} is ${expected.magnitude} ${outUnits}`, () => { - const q1 = new Quantity(orig, options); - const result = q1.getWithUnits(outUnits); + const q = new Quantity(orig, options); + const resultQuantity = q.convert(outUnits); + const result = resultQuantity.get(); // Compare the magnitude (value) of the result, ignoring minor floating point rounding differences: assertAlmostEquals(result.magnitude, expected.magnitude); // Compare result and expected, but ignoring the magnitude: assertEquals(result, { units: outUnits, ...expected, magnitude: result.magnitude }); + + // Test backwards compatibility with older .getWithUnits() API: + const oldResult = q.getWithUnits(outUnits); + assertAlmostEquals(oldResult.magnitude, expected.magnitude); + // The old API returned the units with custom spacing, left unchanged. + // Whereas the new API's result standarizes the format of 'units' + assertEquals(oldResult, { ...expected, units: outUnits, magnitude: result.magnitude }); }); }; @@ -62,12 +70,12 @@ Deno.test("Quantity conversions", async (t) => { await check(100, { units: "km/h" }, "mi/h", { magnitude: 62.137119224 }); // Mass: await check(500, { units: "g" }, "kg", { magnitude: 0.5 }); - await check(500, { units: "g" }, "s^2 N / m", { magnitude: 0.5 }); // 500 g = 0.5 kg = 0.5 (kg m / s^2) * s^2 / m + await check(500, { units: "g" }, "s^2 N / m", { magnitude: 0.5, units: "s^2⋅N/m" }); // 500 g = 0.5 kg = 0.5 (kg m / s^2) * s^2 / m await check(10, { units: "s^2 N / m" }, "g", { magnitude: 10_000 }); // Mass can be expressed in Newton-hours^2 per foot. // This is obviously crazy but stress tests the conversion code effectively. - await check(500, { units: "g" }, "N h^2 / ft", { magnitude: 1.175925925925926e-8 }); - await check(15, { units: "N h^2 / ft" }, "g", { magnitude: 637795275590.5511 }); + await check(500, { units: "g" }, "N⋅h^2/ft", { magnitude: 1.175925925925926e-8 }); + await check(15, { units: "N⋅h^2/ft" }, "g", { magnitude: 637795275590.5511 }); // Time: await check(500, { units: "ms" }, "s", { magnitude: 0.5 }); await check(120, { units: "s" }, "min", { magnitude: 2 }); @@ -143,32 +151,49 @@ Deno.test("Quantity conversions", async (t) => { await check(1, { units: "C" }, "A⋅s", { magnitude: 1 }); await check(1, { units: "mAh" }, "mA⋅h", { magnitude: 1 }); // amp hour await check(1, { units: "Ah" }, "C", { magnitude: 3600 }); - await check(1, { units: "V" }, "kg⋅m^2 / A⋅s^3", { magnitude: 1 }); - await check(1, { units: "ohm" }, "kg⋅m^2 / A^2⋅s^3", { magnitude: 1 }); - await check(1, { units: "F" }, "s^4⋅A^2 / kg^1⋅m^2", { magnitude: 1 }); - await check(1, { units: "H" }, "kg⋅m^2 / s^2⋅A^2", { magnitude: 1 }); + await check(1, { units: "V" }, "kg⋅m^2/A⋅s^3", { magnitude: 1 }); + await check(1, { units: "ohm" }, "kg⋅m^2/A^2⋅s^3", { magnitude: 1 }); + await check(1, { units: "F" }, "s^4⋅A^2 / kg^1⋅m^2", { magnitude: 1, units: "s^4⋅A^2/kg⋅m^2" }); + await check(1, { units: "H" }, "kg⋅m^2/s^2⋅A^2", { magnitude: 1 }); await check(1, { units: "S" }, "ohm^-1", { magnitude: 1 }); - await check(1, { units: "Wb" }, "kg⋅m^2 / s^2⋅A^1", { magnitude: 1 }); - await check(1, { units: "T" }, "Wb / m^2", { magnitude: 1 }); + await check(1, { units: "Wb" }, "kg⋅m^2/s^2⋅A", { magnitude: 1 }); + await check(1, { units: "T" }, "Wb / m^2", { magnitude: 1, units: "Wb/m^2" }); // output units have different spacing // Misc - await check(1, { units: "M" }, "mol / L", { magnitude: 1 }); // molar concentration + await check(1, { units: "M" }, "mol / L", { magnitude: 1, units: "mol/L" }); // molar concentration await check(1, { units: "Hz" }, "s^-1", { magnitude: 1 }); // Hertz await t.step("invalid conversions", () => { - assertThrows( - () => { - new Quantity(3, { units: "kg" }).getWithUnits("m"); - }, - QuantityError, - "Cannot convert units that aren't compatible.", - ); - assertThrows( - () => { - new Quantity(1, { units: "day" }).getWithUnits("kg"); - }, - QuantityError, - "Cannot convert units that aren't compatible.", - ); + assertThrows(() => { + new Quantity(3, { units: "kg" }).convert("m"); + }, InvalidConversionError); + assertThrows(() => { + new Quantity(3, { units: "kg" }).getWithUnits("m"); + }, InvalidConversionError); + assertThrows(() => { + new Quantity(1, { units: "day" }).convert("kg"); + }, InvalidConversionError); + assertThrows(() => { + new Quantity(1, { units: "day" }).getWithUnits("kg"); + }, InvalidConversionError); + assertThrows(() => { + new Quantity(1, { units: "A" }).convert("s/C"); + }, InvalidConversionError); + assertThrows(() => { + new Quantity(1, { units: "A" }).convert("C s"); + }, InvalidConversionError); + }); + + await t.step(".getWithUnits() backwards compatibility", () => { + const requestedUnitStr = "s^4⋅A^2 / kg^1⋅m^2"; + assertEquals(new Quantity(1, { units: "F" }).getWithUnits(requestedUnitStr), { + magnitude: 1, + units: requestedUnitStr, + }); + // Compare to the new API result: + assertEquals(new Quantity(1, { units: "F" }).convert(requestedUnitStr).get(), { + magnitude: 1, + units: "s^4⋅A^2/kg⋅m^2", // no spaces, no ^1 power specified + }); }); }); diff --git a/tests/dimensions.test.ts b/tests/dimensions.test.ts index 3ad810f..e4a5cf2 100644 --- a/tests/dimensions.test.ts +++ b/tests/dimensions.test.ts @@ -1,7 +1,7 @@ import { assertEquals, assertThrows } from "@std/assert"; import { Dimensions, QuantityError } from "../mod.ts"; -const baseDimensions = [0, 0, 0, 0, 0, 0, 0, 0, 0] as const; +const baseDimensions = [0, 0, 0, 0, 0, 0, 0, 0] as const; Deno.test(`Dimensions constructor`, async (t) => { await t.step( @@ -19,14 +19,14 @@ Deno.test(`Dimensions constructor`, async (t) => { assertThrows( () => { // @ts-expect-error: array has too few entries - new Dimensions([1, 2, 3, 4, 5, 6, 7, 8]); + new Dimensions([1, 2, 3, 4, 5, 6, 7]); }, QuantityError, "not enough dimensions specified for Quantity.", ); // This one throws no error: - new Dimensions([1, 2, 3, 4, 5, 6, 7, 8, 9]); + new Dimensions([1, 2, 3, 4, 5, 6, 7, 8]); }, ); @@ -127,18 +127,18 @@ Deno.test(`Multiplying custom dimensions`, async (t) => { Deno.test(`toString`, async (t) => { await t.step(`dimensionless`, () => { - assertEquals(new Dimensions([...baseDimensions]).toString(), "[0,0,0,0,0,0,0,0,0]"); + assertEquals(new Dimensions([...baseDimensions]).toString(), "[0,0,0,0,0,0,0,0]"); }); - await t.step(`[0,2,4,6,8,0,0,0,-3]`, () => { - assertEquals(new Dimensions([0, 2, 4, 6, 8, 0, 0, 0, -3]).toString(), "[0,2,4,6,8,0,0,0,-3]"); + await t.step(`[0,2,4,6,8,0,0,-3]`, () => { + assertEquals(new Dimensions([0, 2, 4, 6, 8, 0, 0, -3]).toString(), "[0,2,4,6,8,0,0,-3]"); }); - await t.step(`[0,2,4,6,8,0,0,0,-3,7] with 7 in a custom "foo" dimension`, () => { - assertEquals(new Dimensions([0, 2, 4, 6, 8, 0, 0, 0, -3, 7], ["foo"]).toString(), "[0,2,4,6,8,0,0,0,-3,foo:7]"); + await t.step(`[0,2,4,6,8,0,0,-3,7] with 7 in a custom "foo" dimension`, () => { + assertEquals(new Dimensions([0, 2, 4, 6, 8, 0, 0, -3, 7], ["foo"]).toString(), "[0,2,4,6,8,0,0,-3,foo:7]"); }); await t.step(`different custom dimensions (4)`, () => { assertEquals( new Dimensions([...baseDimensions, 1, 2, 0, -3], ["abc", "bar", "foo", "zzzzzzz"]).toString(), - "[0,0,0,0,0,0,0,0,0,abc:1,bar:2,foo:0,zzzzzzz:-3]", + "[0,0,0,0,0,0,0,0,abc:1,bar:2,foo:0,zzzzzzz:-3]", ); }); }); diff --git a/tests/quantity.test.ts b/tests/quantity.test.ts index 0a7f7d8..fb6833d 100644 --- a/tests/quantity.test.ts +++ b/tests/quantity.test.ts @@ -1,13 +1,13 @@ import { assert, assertEquals, assertFalse, assertNotEquals, assertThrows } from "@std/assert"; import { Dimensions, Quantity, QuantityError } from "../mod.ts"; -const ONE_MASS_DIMENSION = new Dimensions([1, 0, 0, 0, 0, 0, 0, 0, 0]); -const ONE_LENGTH_DIMENSION = new Dimensions([0, 1, 0, 0, 0, 0, 0, 0, 0]); -const ONE_TEMP_DIMENSION = new Dimensions([0, 0, 0, 1, 0, 0, 0, 0, 0]); -const TWO_LENGTH_DIMENSIONS = new Dimensions([0, 2, 0, 0, 0, 0, 0, 0, 0]); -const THREE_LENGTH_DIMENSIONS = new Dimensions([0, 3, 0, 0, 0, 0, 0, 0, 0]); +const ONE_MASS_DIMENSION = new Dimensions([1, 0, 0, 0, 0, 0, 0, 0]); +const ONE_LENGTH_DIMENSION = new Dimensions([0, 1, 0, 0, 0, 0, 0, 0]); +const ONE_TEMP_DIMENSION = new Dimensions([0, 0, 0, 1, 0, 0, 0, 0]); +const TWO_LENGTH_DIMENSIONS = new Dimensions([0, 2, 0, 0, 0, 0, 0, 0]); +const THREE_LENGTH_DIMENSIONS = new Dimensions([0, 3, 0, 0, 0, 0, 0, 0]); /** Force is mass*length/time^2 */ -const FORCE_DIMENSIONS = new Dimensions([1, 1, -2, 0, 0, 0, 0, 0, 0]); +const FORCE_DIMENSIONS = new Dimensions([1, 1, -2, 0, 0, 0, 0, 0]); Deno.test("Quantity instance equality", async (t) => { /** @@ -53,31 +53,31 @@ Deno.test("Quantity instance equality", async (t) => { await check( "Different dimensions, same magnitude", // This one will equal itself: - () => new Quantity(15, { dimensions: new Dimensions([1, 0, 0, 0, 0, 0, 0, 0, 0]) }), + () => new Quantity(15, { dimensions: new Dimensions([1, 0, 0, 0, 0, 0, 0, 0]) }), // But the one above won't equal this one, with different dimensions: - () => new Quantity(15, { dimensions: new Dimensions([0, 1, 0, 0, 0, 0, 0, 0, 0]) }), + () => new Quantity(15, { dimensions: new Dimensions([0, 1, 0, 0, 0, 0, 0, 0]) }), ); await check( "Different dimensions, same magnitude (2)", - () => new Quantity(0, { dimensions: new Dimensions([1, 0, 0, 0, 0, 0, 0, 0, 0]) }), - () => new Quantity(0, { dimensions: new Dimensions([0, 0, 0, 0, 0, 0, 0, 0, 0]) }), + () => new Quantity(0, { dimensions: new Dimensions([1, 0, 0, 0, 0, 0, 0, 0]) }), + () => new Quantity(0, { dimensions: new Dimensions([0, 0, 0, 0, 0, 0, 0, 0]) }), ); await check( "Different dimensions, same magnitude (3)", - () => new Quantity(-10, { dimensions: new Dimensions([1, 0, 0, 1, 2, 0, 0, 0, 0]) }), - () => new Quantity(-10, { dimensions: new Dimensions([1, 0, 0, 1, 1, 0, 0, 0, 0]) }), + () => new Quantity(-10, { dimensions: new Dimensions([1, 0, 0, 1, 2, 0, 0, 0]) }), + () => new Quantity(-10, { dimensions: new Dimensions([1, 0, 0, 1, 1, 0, 0, 0]) }), ); await check( "Different custom dimensions, same magnitude and regular dimensions", () => new Quantity(-10, { // deno-fmt-ignore - dimensions: new Dimensions([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1], ["a", "b", "c", "d"]), + dimensions: new Dimensions([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1], ["a", "b", "c", "d"]), }), () => new Quantity(-10, { dimensions: new Dimensions( - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ["a", "b", "c", "d"], ), }), @@ -87,14 +87,14 @@ Deno.test("Quantity instance equality", async (t) => { () => new Quantity(-10, { dimensions: new Dimensions( - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2], ["a", "b", "c", "d"], ), }), () => new Quantity(-10, { dimensions: new Dimensions( - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2], ["a", "b", "c", "elf"], ), }), @@ -261,6 +261,7 @@ Deno.test("Multiplying quantities", async (t) => { const z = x.multiply(y); assertEquals(z.magnitude, 15); assertEquals(z.dimensions, TWO_LENGTH_DIMENSIONS); // m² + assertEquals(z.toString(), "15 m^2"); }); await t.step(`(50 %) * (50 %)`, () => { const x = new Quantity(50, { units: "%" }); @@ -274,6 +275,12 @@ Deno.test("Multiplying quantities", async (t) => { const z = x.multiply(y); assertEquals(z.toString(), "200 g"); }); + await t.step(`(400 g) * (50 %)`, () => { + const x = new Quantity(400, { units: "g" }); + const y = new Quantity(50, { units: "%" }); + const z = x.multiply(y); + assertEquals(z.toString(), "200 g"); + }); await t.step(`(500 g) * (2 m/s^2)`, () => { const x = new Quantity(500, { units: "g" }); const y = new Quantity(2, { units: "m/s^2" }); diff --git a/tests/units.test.ts b/tests/units.test.ts index b9bb1de..a3de522 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -1,5 +1,6 @@ import { assertEquals, assertThrows } from "@std/assert"; -import { ParsedUnit, parseUnits, QuantityError } from "../mod.ts"; +import { builtInUnits, ParsedUnit, parseUnits, QuantityError } from "../mod.ts"; +import { prefixes, Unit } from "../units.ts"; Deno.test(`parseUnits()`, async (t) => { const pairs: [input: string, output: ParsedUnit[]][] = [ @@ -84,3 +85,38 @@ Deno.test(`parseUnits() - invalid Strings`, async (t) => { }); } }); + +Deno.test("check for ambiguous units", async (t) => { + // Make sure we don't have any unit where some prefix + the unit abbreviation equals the abbreviation of another unit. + // e.g. "m" (milli) + "in" (inches) = "min" = milli-inches or minutes? + // (That's why we don't allow prefixes on non-SI units like inches.) + const regularPrefixes = Object.keys(prefixes).filter((prefix) => + !prefix.endsWith("i") + ) as (keyof typeof prefixes)[]; + const binaryPrefixes = Object.keys(prefixes).filter((prefix) => prefix.endsWith("i")) as (keyof typeof prefixes)[]; + + for (const [unitAbbrev, unitData] of Object.entries(builtInUnits as Record)) { + if (unitData.prefixable) { + await t.step(`${unitAbbrev} with regular SI prefixes`, () => { + // Test all the non-binary prefixes: + for (const prefix of regularPrefixes) { + assertEquals( + parseUnits(`${prefix}${unitAbbrev}`), + [{ prefix, unit: unitAbbrev, power: 1 }], + ); + } + }); + } + if (unitData.binaryPrefixable) { + await t.step(`${unitAbbrev} with binary SI prefixes`, () => { + // Test all the binary prefixes: + for (const prefix of binaryPrefixes) { + assertEquals( + parseUnits(`${prefix}${unitAbbrev}`), + [{ prefix, unit: unitAbbrev, power: 1 }], + ); + } + }); + } + } +}); diff --git a/units.ts b/units.ts index 54a9174..6a3c53c 100644 --- a/units.ts +++ b/units.ts @@ -59,17 +59,17 @@ export interface Unit { readonly binaryPrefixable?: true; } -const MASS_DIMENSION: Dimensions = new Dimensions([1, 0, 0, 0, 0, 0, 0, 0, 0]); -const DIST_DIMENSION: Dimensions = new Dimensions([0, 1, 0, 0, 0, 0, 0, 0, 0]); -const TIME_DIMENSION: Dimensions = new Dimensions([0, 0, 1, 0, 0, 0, 0, 0, 0]); -const TEMP_DIMENSION: Dimensions = new Dimensions([0, 0, 0, 1, 0, 0, 0, 0, 0]); - -const NRGY_DIMENSIONS: Dimensions = new Dimensions([1, 2, -2, 0, 0, 0, 0, 0, 0]); -const POWR_DIMENSIONS: Dimensions = new Dimensions([1, 2, -3, 0, 0, 0, 0, 0, 0]); -const VOLM_DIMENSIONS: Dimensions = new Dimensions([0, 3, 0, 0, 0, 0, 0, 0, 0]); -const AREA_DIMENSIONS: Dimensions = new Dimensions([0, 2, 0, 0, 0, 0, 0, 0, 0]); -const INFO_DIMENSIONS: Dimensions = new Dimensions([0, 0, 0, 0, 0, 0, 0, 1, 0]); -const PRSR_DIMENSIONS: Dimensions = new Dimensions([1, -1, -2, 0, 0, 0, 0, 0, 0]); +const MASS_DIMENSION: Dimensions = new Dimensions([1, 0, 0, 0, 0, 0, 0, 0]); +const DIST_DIMENSION: Dimensions = new Dimensions([0, 1, 0, 0, 0, 0, 0, 0]); +const TIME_DIMENSION: Dimensions = new Dimensions([0, 0, 1, 0, 0, 0, 0, 0]); +const TEMP_DIMENSION: Dimensions = new Dimensions([0, 0, 0, 1, 0, 0, 0, 0]); + +const NRGY_DIMENSIONS: Dimensions = new Dimensions([1, 2, -2, 0, 0, 0, 0, 0]); +const POWR_DIMENSIONS: Dimensions = new Dimensions([1, 2, -3, 0, 0, 0, 0, 0]); +const VOLM_DIMENSIONS: Dimensions = new Dimensions([0, 3, 0, 0, 0, 0, 0, 0]); +const AREA_DIMENSIONS: Dimensions = new Dimensions([0, 2, 0, 0, 0, 0, 0, 0]); +const INFO_DIMENSIONS: Dimensions = new Dimensions([0, 0, 0, 0, 0, 0, 0, 1]); +const PRSR_DIMENSIONS: Dimensions = new Dimensions([1, -1, -2, 0, 0, 0, 0, 0]); /** * List of all the units built in to quantity-math-js. @@ -202,7 +202,7 @@ export const builtInUnits = Object.freeze( // "fps": { s: 3.048e-1, d: new Dimensions([0, 1, -1, 0, 0, 0, 0, 0]) }, // "knot": { s: 5.14444444444444e-1, d: new Dimensions([0, 1, -1, 0, 0, 0, 0, 0]) }, // "admkn": { s: 5.14773333333333e-1, d: new Dimensions([0, 1, -1, 0, 0, 0, 0, 0]) }, - "c": { s: 2.99792458e+8, d: new Dimensions([0, 1, -1, 0, 0, 0, 0, 0, 0]) }, + "c": { s: 2.99792458e+8, d: new Dimensions([0, 1, -1, 0, 0, 0, 0, 0]) }, // "grav": { s: 9.80665e+0, d: new Dimensions([0, 1, -2, 0, 0, 0, 0, 0]) }, // "galileo": { s: 1e-2, d: new Dimensions([0, 1, -2, 0, 0, 0, 0, 0]) }, /** Pascal: SI standard unit for pressure defined as 1 N/m^2 */ @@ -230,7 +230,7 @@ export const builtInUnits = Object.freeze( // Force /** Newtons */ - "N": { s: 1e+0, d: new Dimensions([1, 1, -2, 0, 0, 0, 0, 0, 0]), prefixable: true }, + "N": { s: 1e+0, d: new Dimensions([1, 1, -2, 0, 0, 0, 0, 0]), prefixable: true }, // "dyn": { s: 1e-5, d: new Dimensions([1, 1, -2, 0, 0, 0, 0, 0]) }, /** Gram Force: the amount of force exerted by standard gravity on a 1 gram mass */ // "gf": { s: 9.80665e-3, d: new Dimensions([1, 1, -2, 0, 0, 0, 0, 0]) }, @@ -340,37 +340,37 @@ export const builtInUnits = Object.freeze( // Electromagnetism /** Ampere: SI standard unit for electric current, equal to 1 C/s */ - "A": { s: 1e+0, d: new Dimensions([0, 0, 0, 0, 1, 0, 0, 0, 0]), prefixable: true }, + "A": { s: 1e+0, d: new Dimensions([0, 0, 0, 0, 1, 0, 0, 0]), prefixable: true }, /** Coulomb: SI standard unit for electric charge */ - "C": { s: 1e+0, d: new Dimensions([0, 0, 1, 0, 1, 0, 0, 0, 0]), prefixable: true }, + "C": { s: 1e+0, d: new Dimensions([0, 0, 1, 0, 1, 0, 0, 0]), prefixable: true }, /** Amp Hour: Charge collected from 1 Amp over 1 hour */ - "Ah": { s: 3.6e+3, d: new Dimensions([0, 0, 1, 0, 1, 0, 0, 0, 0]), prefixable: true }, + "Ah": { s: 3.6e+3, d: new Dimensions([0, 0, 1, 0, 1, 0, 0, 0]), prefixable: true }, // "e": { s: 1.602176634e-19, d: new Dimensions([0, 0, 1, 0, 1, 0, 0, 0]) }, /** Volt */ - "V": { s: 1, d: new Dimensions([1, 2, -3, 0, -1, 0, 0, 0, 0]), prefixable: true }, + "V": { s: 1, d: new Dimensions([1, 2, -3, 0, -1, 0, 0, 0]), prefixable: true }, /** Ohm/Ω: Derived SI unit for electrical resistance */ - "ohm": { s: 1, d: new Dimensions([1, 2, -3, 0, -2, 0, 0, 0, 0]) }, + "ohm": { s: 1, d: new Dimensions([1, 2, -3, 0, -2, 0, 0, 0]) }, /** Farad: Derived SI unit of electrical capacitance */ - "F": { s: 1e+0, d: new Dimensions([-1, -2, 4, 0, 2, 0, 0, 0, 0]), prefixable: true }, + "F": { s: 1e+0, d: new Dimensions([-1, -2, 4, 0, 2, 0, 0, 0]), prefixable: true }, /** Henry: Derived SI unit for inductance */ - "H": { s: 1e+0, d: new Dimensions([1, 2, -2, 0, -2, 0, 0, 0, 0]), prefixable: true }, + "H": { s: 1e+0, d: new Dimensions([1, 2, -2, 0, -2, 0, 0, 0]), prefixable: true }, /** Siemens: Derived SI unit for electrical conductance, equal to 1 / ohm */ - "S": { s: 1e+0, d: new Dimensions([-1, -2, 3, 0, 2, 0, 0, 0, 0]), prefixable: true }, + "S": { s: 1e+0, d: new Dimensions([-1, -2, 3, 0, 2, 0, 0, 0]), prefixable: true }, // "mho": { s: 1e+0, d: new Dimensions([-1, -2, 3, 0, 2, 0, 0, 0, 0]) }, /** Weber: SI unit for magnetic flux defined as 1 kg m^2 / (s^2 A) */ - "Wb": { s: 1e+0, d: new Dimensions([1, 2, -2, 0, -1, 0, 0, 0, 0]), prefixable: true }, + "Wb": { s: 1e+0, d: new Dimensions([1, 2, -2, 0, -1, 0, 0, 0]), prefixable: true }, // "Mx": { s: 1e-8, d: new Dimensions([1, 2, -2, 0, -1, 0, 0, 0, 0]) }, /** Tesla: SI unit for magnetic flux density defined as 1 Wb / m^2 */ - "T": { s: 1e+0, d: new Dimensions([1, 0, -2, 0, -1, 0, 0, 0, 0]), prefixable: true }, + "T": { s: 1e+0, d: new Dimensions([1, 0, -2, 0, -1, 0, 0, 0]), prefixable: true }, // "Gs": { s: 1e-4, d: new Dimensions([1, 0, -2, 0, -1, 0, 0, 0, 0]) }, // "gs": { s: 1e-4, d: new Dimensions([1, 0, -2, 0, -1, 0, 0, 0, 0]) }, // "Fr": { s: 3.3356409519815207e-10, d: new Dimensions([0, 0, 1, 0, 1, 0, 0, 0, 0]) }, // "Gi": { s: 7.957747e-1, d: new Dimensions([0, 0, 0, 0, 1, 0, 0, 0, 0]) }, // "Oe": { s: 7.957747154594767e+1, d: new Dimensions([0, -1, 0, 0, 1, 0, 0, 0, 0]) }, /** Mole: SI standard unit for an amount of substance, defined as exactly 6.02214076e23 elementary entities (usually molecules) */ - "mol": { s: 1e+0, d: new Dimensions([0, 0, 0, 0, 0, 1, 0, 0, 0]) }, + "mol": { s: 1e+0, d: new Dimensions([0, 0, 0, 0, 0, 1, 0, 0]) }, /** Molar Concentration: Amount of substance per Liter of solution */ - "M": { s: 1e+3, d: new Dimensions([0, -3, 0, 0, 0, 1, 0, 0, 0]) }, + "M": { s: 1e+3, d: new Dimensions([0, -3, 0, 0, 0, 1, 0, 0]) }, // "kat": { s: 1e+0, d: new Dimensions([0, 0, -1, 0, 0, 1, 0, 0]) }, // "U": { s: 1.6666666666666667e-8, d: new Dimensions([0, 0, -1, 0, 0, 1, 0, 0]) }, /** Candela */ @@ -401,7 +401,7 @@ export const builtInUnits = Object.freeze( /** If the non-SI unit rpm is considered a unit of frequency, then 1 rpm = 1/60 Hz (Wikipedia) */ // "rpm": { s: 1/60, d: new Dimensions([0, 0, -1, 0, 0, 0, 0, 0]) }, /** Hertz: Frequency defined as 1 (cycle or rotation) / sec */ - "Hz": { s: 1, d: new Dimensions([0, 0, -1, 0, 0, 0, 0, 0, 0]), prefixable: true }, + "Hz": { s: 1, d: new Dimensions([0, 0, -1, 0, 0, 0, 0, 0]), prefixable: true }, // "Bq": { s: 1e+0, d: new Dimensions([0, 0, -1, 0, 0, 0, 0, 0]) }, // "Gy": { s: 1e+0, d: new Dimensions([0, 2, -2, 0, 0, 0, 0, 0]) }, // "Sv": { s: 1e+0, d: new Dimensions([0, 2, -2, 0, 0, 0, 0, 0]) }, @@ -415,11 +415,19 @@ export const builtInUnits = Object.freeze( /** pphpd: "passengers per hour per direction" (_pax/h⋅_dir) */ pphpd: { s: 1 / 3600, - d: new Dimensions([0, 0, -1, 0, 0, 0, 0, 0, 0, -1, 1], ["dir", "pax"]), + d: new Dimensions([0, 0, -1, 0, 0, 0, 0, 0, -1, 1], ["dir", "pax"]), }, } as const satisfies Record, ); +/** A unit that the user wants to use. */ +export interface PreferredUnit { + /** The SI prefix of this unit, if any. e.g. the "k" (kilo) in "km" (kilometers) */ + prefix?: Prefix; + /** The unit, e.g. "m" for meters or "_pax" for a custom "passengers" unit */ + unit: string; +} + /** The result of parsing a unit like "km^2" into its parts (prefix, unit, and power) */ export interface ParsedUnit { /** The SI prefix of this unit, if any. e.g. the "k" (kilo) in "km" (kilometers) */ @@ -431,7 +439,7 @@ export interface ParsedUnit { } /** The base SI units. */ -export const baseSIUnits: ReadonlyArray> = Object.freeze([ +export const baseSIUnits: readonly PreferredUnit[] = Object.freeze([ // Base units: { unit: "g", prefix: "k" }, { unit: "m" }, @@ -542,7 +550,7 @@ export function parseUnits( /** * Convert a parsed unit array, e.g. from parseUnits(), back to a string like "kg⋅m/s^2" */ -export function toUnitString(units: ParsedUnit[]): string { +export function toUnitString(units: readonly ParsedUnit[]): string { const numerator: string[] = []; const denominator: string[] = []; for (const u of units) { @@ -564,13 +572,12 @@ export function toUnitString(units: ParsedUnit[]): string { } export function getUnitData(unit: string, additionalUnits?: Readonly>): Unit { - const units: Record = additionalUnits ? { ...builtInUnits, ...additionalUnits } : builtInUnits; - if (unit in units) { - return units[unit]; - } else if (unit.startsWith("_")) { + const found: Unit | undefined = builtInUnits[unit as keyof typeof builtInUnits] ?? additionalUnits?.[unit]; + if (found !== undefined) return found; + if (unit.startsWith("_")) { // This is our shorthand notation for the base unit in a custom dimension. // e.g. "_pax" is a custom unit with dimensionality of 1 in the "pax" dimension. - return { s: 1, d: new Dimensions([0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [unit.substring(1)]) }; + return { s: 1, d: new Dimensions([0, 0, 0, 0, 0, 0, 0, 0, 1], [unit.substring(1)]) }; } throw new QuantityError(`Unknown/unsupported unit "${unit}"`); }