Skip to content
76 changes: 64 additions & 12 deletions README.md

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions src/complex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
complexFromPolar,
complexLiteralFromPolar,
complexMagnitude,
complexPhase,
} from "./complex";
import { evaluateValue } from "./evaluator";
import type { Resolvers } from "./resolvers";
import { ExpressionNodeSchema } from "./tree";

/** No helper below reaches a resolver at all; these exist only to satisfy `evaluateValue`'s signature for the one node-building check. */
const resolvers: Resolvers = {
resolveValue: async () => Promise.resolve({ found: false }),
resolveLookup: async () => Promise.resolve({ found: false }),
resolveCollection: async () => Promise.resolve([]),
};

/** The 3-4-5 right triangle, used so the magnitude below is exact rather than a floating-point approximation. */
const pythagoreanMagnitude = 5;
const quarterTurn = Math.PI / 2;
const arbitraryPhase = 0.7;

describe("complexFromPolar", () => {
it("places a zero-phase value entirely on the real axis", () => {
expect(complexFromPolar(2, 0)).toEqual({
kind: "complex",
re: 2,
im: 0,
});
});

it("carries the unit through unchanged", () => {
expect(complexFromPolar(2, 0, { V: 1, A: -1 })).toEqual({
kind: "complex",
re: 2,
im: 0,
unit: { V: 1, A: -1 },
});
});
});

describe("complexMagnitude, complexPhase", () => {
it("reads the magnitude back as a real number carrying the value's own unit", () => {
expect(
complexMagnitude({ kind: "complex", re: 3, im: -4, unit: { V: 1 } }),
).toEqual({ kind: "number", value: pythagoreanMagnitude, unit: { V: 1 } });
});

it("reads the phase back as a dimensionless angle in radians, whatever unit the value carries", () => {
expect(
complexPhase({ kind: "complex", re: 0, im: 2, unit: { V: 1 } }),
).toEqual({ kind: "number", value: quarterTurn });
});

it("round-trips a magnitude and phase through the rectangular form", () => {
const roundTripped = complexFromPolar(pythagoreanMagnitude, arbitraryPhase);
expect(complexMagnitude(roundTripped).value).toBeCloseTo(
pythagoreanMagnitude,
);
expect(complexPhase(roundTripped).value).toBeCloseTo(arbitraryPhase);
});
});

describe("complexLiteralFromPolar", () => {
it("builds a complexLiteral the expression tree accepts, evaluating to the value complexFromPolar would have produced directly", async () => {
const node = complexLiteralFromPolar(2, 0, { V: 1 });
expect(ExpressionNodeSchema.parse(node)).toEqual(node);

const result = await evaluateValue(node, undefined, resolvers);
expect(result).toEqual({
status: "definite",
value: complexFromPolar(2, 0, { V: 1 }),
});
});
});
45 changes: 45 additions & 0 deletions src/complex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ComputedValue, Unit } from "./computed-value";
import type { ComplexLiteralNode } from "./tree";

/**
* The `complex` computed-value kind stores one canonical representation, rectangular (`re` + `im`i), with no `form` discriminant offering a polar alternative alongside it -- see the "Complex values" section of README.md for the reasoning. These four helpers are what keep the magnitude-and-phase view reachable for the domains that reason that way, as conversions at the edges rather than a second encoding of the same value that every operator would then have to branch on.
*
* Phase is in radians throughout, measured from the positive real axis, matching `Math.atan2`'s own range of (-pi, pi].
*/

type ComplexValue = Extract<ComputedValue, { kind: "complex" }>;
type NumberValue = Extract<ComputedValue, { kind: "number" }>;

export const complexFromPolar = (
magnitude: number,
phase: number,
unit?: Unit,
): ComplexValue => ({
kind: "complex",
re: magnitude * Math.cos(phase),
im: magnitude * Math.sin(phase),
unit,
});

/** The `complexLiteral` node counterpart of `complexFromPolar`, for authoring a tree in polar terms; the conversion itself is done once, there, rather than repeated here. */
export const complexLiteralFromPolar = (
magnitude: number,
phase: number,
unit?: Unit,
): ComplexLiteralNode => {
const { re, im } = complexFromPolar(magnitude, phase, unit);
return { kind: "complexLiteral", re, im, unit };
};

/** |z|, as a real number in the same unit the complex value itself carries -- the magnitude of an impedance in ohms is a real quantity in ohms. */
export const complexMagnitude = (value: ComplexValue): NumberValue => ({
kind: "number",
value: Math.hypot(value.re, value.im),
unit: value.unit,
});

/** arg(z), as a dimensionless real number of radians: an angle is a ratio of two lengths, so it carries no unit of its own regardless of what the value it was read from carried. */
export const complexPhase = (value: ComplexValue): NumberValue => ({
kind: "number",
value: Math.atan2(value.im, value.re),
});
20 changes: 20 additions & 0 deletions src/computed-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { ComputedValueSchema } from "./computed-value";

describe("the complex computed-value kind", () => {
it("parses with and without an optional unit", () => {
const withoutUnit = { kind: "complex", re: 3, im: 4 };
const withUnit = { kind: "complex", re: 3, im: 4, unit: { V: 1, A: -1 } };
expect(ComputedValueSchema.parse(withoutUnit)).toEqual(withoutUnit);
expect(ComputedValueSchema.parse(withUnit)).toEqual(withUnit);
});

it("rejects a value carrying only one of the two components", () => {
expect(
ComputedValueSchema.safeParse({ kind: "complex", re: 3 }).success,
).toBe(false);
expect(
ComputedValueSchema.safeParse({ kind: "complex", im: 4 }).success,
).toBe(false);
});
});
7 changes: 7 additions & 0 deletions src/computed-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export const ComputedValueSchema = z.discriminatedUnion("kind", [
value: z.number(),
unit: DurationUnitSchema,
}),
/** Stored canonically in rectangular form -- `re` + `im`i -- rather than as a magnitude and phase, and with no `form` discriminant offering both: see the "Complex values" section of README.md for why one canonical form is the whole point, and `complex.ts` for the polar builder/accessor helpers that make the other form reachable without a second encoding of the same value. */
z.object({
kind: z.literal("complex"),
re: z.number(),
im: z.number(),
unit: UnitSchema.optional(),
}),
]);
export type ComputedValue = z.infer<typeof ComputedValueSchema>;

Expand Down
5 changes: 5 additions & 0 deletions src/evaluator.indeterminacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,11 @@ const fixtures: readonly Fixture[] = [
{ kind: "durationLiteral", value: 1, unit: "min" },
isDefinite,
),
expr(
"complexLiteral: never indeterminate",
{ kind: "complexLiteral", re: 1, im: -1 },
isDefinite,
),

// reference
expr(
Expand Down
Loading
Loading