Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework: new convert() API, higher performance, remember preferred unit exactly #3

Merged
merged 16 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 48 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -30,29 +63,33 @@ 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"
```

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"
```
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 42 additions & 47 deletions dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.");
Expand All @@ -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.");
}
Expand All @@ -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 */
Expand All @@ -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<number>((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();
}

/**
Expand Down Expand Up @@ -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.
Expand All @@ -147,41 +132,51 @@ export class Dimensions {
const newDimArray = new Array<number>(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. */
Expand Down Expand Up @@ -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]);
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";
8 changes: 6 additions & 2 deletions q.ts
Original file line number Diff line number Diff line change
@@ -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<string>, ...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;
Expand Down
Loading