Skip to content

Commit

Permalink
feat: new convert() API, Quantity remembers preferred output unit exa…
Browse files Browse the repository at this point in the history
…ctly
  • Loading branch information
bradenmacdonald committed Mar 19, 2024
1 parent fd7f113 commit 6a36409
Show file tree
Hide file tree
Showing 8 changed files with 246 additions and 121 deletions.
11 changes: 11 additions & 0 deletions error.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
}
2 changes: 1 addition & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 1 addition & 1 deletion q.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Quantity } from "./quantity.ts";
import { QuantityError } from "./error.ts";

export function Q(strings: string | ReadonlyArray<string>, ...keys: unknown[]): Quantity {
export function Q(strings: string | readonly string[], ...keys: unknown[]): Quantity {
let fullString: string;
if (typeof strings == "string") {
fullString = strings;
Expand Down
244 changes: 159 additions & 85 deletions quantity.ts

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions tests/benchmark.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
}
});
Expand Down
81 changes: 53 additions & 28 deletions tests/conversions.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -38,15 +38,23 @@ Deno.test("Quantity conversions", async (t) => {
orig: number,
options: ConstructorParameters<typeof Quantity>[1] & { units: string },
outUnits: string,
expected: Omit<SerializedQuantity, "units">,
expected: Omit<SerializedQuantity, "units"> & { 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 });
});
};

Expand All @@ -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" }, "Nh^2/ft", { magnitude: 1.175925925925926e-8 });
await check(15, { units: "Nh^2/ft" }, "g", { magnitude: 637795275590.5511 });
// Time:
await check(500, { units: "ms" }, "s", { magnitude: 0.5 });
await check(120, { units: "s" }, "min", { magnitude: 2 });
Expand Down Expand Up @@ -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
});
});
});

Expand Down
7 changes: 7 additions & 0 deletions tests/quantity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "%" });
Expand All @@ -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" });
Expand Down
12 changes: 10 additions & 2 deletions units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,14 @@ export const builtInUnits = Object.freeze(
} as const satisfies Record<string, Unit>,
);

/** 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) */
Expand All @@ -431,7 +439,7 @@ export interface ParsedUnit {
}

/** The base SI units. */
export const baseSIUnits: ReadonlyArray<Omit<ParsedUnit, "power">> = Object.freeze([
export const baseSIUnits: readonly PreferredUnit[] = Object.freeze([
// Base units:
{ unit: "g", prefix: "k" },
{ unit: "m" },
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 6a36409

Please sign in to comment.