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 3e945f7..c25250f 100644 --- a/q.ts +++ b/q.ts @@ -1,7 +1,7 @@ import { Quantity } from "./quantity.ts"; import { QuantityError } from "./error.ts"; -export function Q(strings: string | ReadonlyArray, ...keys: unknown[]): Quantity { +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..fb51d6a 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,8 @@ 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"); /** * Quantity - a value with dimensions (units) @@ -66,30 +66,29 @@ export class Quantity { /** * 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 + * the base units. (e.g. 0C = 273.15K). This is ONLY used within `get()` 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. */ 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. + * Units to use instead of the base units, when displaying this value. */ - protected _unitHintSet: ParsedUnit[] | undefined; + protected 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[]; } = {}, ) { 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); @@ -115,13 +116,15 @@ export class Quantity { // 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. + // We need to track the offset for the get() method to be able to do conversions properly. this._offsetUsed = 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 +241,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,12 +269,28 @@ 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."); - } + 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; + } + + /** + * Get the details of this quantity, using the original unit representation if possible. + * + * ```ts + * new Quantity(10, {units: "N m"}).get() // { magnitude: 10, units: "N⋅m" } + * new Quantity(10, {units: "ft"}).get() // { magnitude: 10, units: "ft" } + * ``` + */ + public get(): SerializedQuantity { + const unitsForResult: readonly ParsedUnit[] = this._unitOutput ?? this.pickUnitsFromList(baseSIUnits); + const converter = new Quantity(1, { units: unitsForResult }); + 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. @@ -259,9 +298,10 @@ export class Quantity { } const result: SerializedQuantity = { magnitude: (this._magnitude - (converter._offsetUsed ?? 0)) / converter._magnitude, - units: typeof units === "string" ? units : toUnitString(units), + units: toUnitString(unitsForResult), }; if (this.significantFigures) { + // TODO: remove this result.significantFigures = this.significantFigures; } if (this.plusMinus) { @@ -271,42 +311,82 @@ export class Quantity { } /** - * Get the details of this quantity, using the original unit representation if possible. + * Get the most compact SI representation for this quantity. * * ```ts - * new Quantity(10, {units: "N m"}).get() // { magnitude: 10, units: "N⋅m" } - * new Quantity(10, {units: "ft"}).get() // { magnitude: 10, units: "ft" } + * new Quantity(10, {units: "N m"}).getSI() // { magnitude: 10, units: "J" } + * new Quantity(10, {units: "ft"}).getSI() // { magnitude: 3.048, units: "m" } * ``` */ - public get(): SerializedQuantity { - if (this._unitHintSet) { - const unitsToUse = this.pickUnitsFromList(this._unitHintSet); - return this.getWithUnits(unitsToUse); - } - // Fall back to SI units if we can't use the units in the "hint set". - return this.getSI(); + public getSI(): SerializedQuantity { + return this.toSI().get(); } /** - * Get the most compact SI representation for this quantity. + * Ensure that this Quantity is using SI units, with the most compact + * representation possible. * * ```ts - * new Quantity(10, {units: "N m"}).getSI() // { magnitude: 10, units: "J" } - * new Quantity(10, {units: "ft"}).getSI() // { magnitude: 3.048, units: "m" } + * 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 getSI(): SerializedQuantity { - const unitList = this.pickUnitsFromList(baseSIUnits); - return this.getWithUnits(unitList); + 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[] = []; @@ -316,9 +396,9 @@ export class Quantity { let bestIdx = -1; let bestInv = 0; let bestRemainder = remainder; - for (let unitIdx = 0; unitIdx < unitList.length; unitIdx++) { + for (let unitIdx = 0; unitIdx < unitArray.length; unitIdx++) { const unitDimensions = unitArray[unitIdx]; - if (unitDimensions.isDimensionless) continue; // Dimensionless units get handled later... + if (unitDimensions.isDimensionless) continue; // skip dimensionless units for (let isInv = 1; isInv >= -1; isInv -= 2) { const newRemainder = remainder.multiply(isInv === 1 ? unitDimensions.invert() : unitDimensions); // If this unit reduces the dimensionality more than the best candidate unit yet found, @@ -335,10 +415,9 @@ export class Quantity { } } // 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 no more progress is being made then we won't be able to find a compatible unit set from this list. if (bestRemainder.dimensionality >= remainder.dimensionality) { - throw new QuantityError(`Cannot represent this quantity with the supplied units`); + 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 @@ -353,57 +432,22 @@ export class Quantity { 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 +473,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 +515,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/benchmark.bench.ts b/tests/benchmark.bench.ts index 92fc0cf..49b361e 100644 --- a/tests/benchmark.bench.ts +++ b/tests/benchmark.bench.ts @@ -13,13 +13,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.`); } }); 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/quantity.test.ts b/tests/quantity.test.ts index 0a7f7d8..4e6be78 100644 --- a/tests/quantity.test.ts +++ b/tests/quantity.test.ts @@ -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/units.ts b/units.ts index 54a9174..f4e4df5 100644 --- a/units.ts +++ b/units.ts @@ -420,6 +420,14 @@ export const builtInUnits = Object.freeze( } 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) {