From 8619112a2f7771ab89ec85b1ad022e7830ee1818 Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 25 Jun 2026 10:10:24 -0700 Subject: [PATCH] feat: BigNumber fixed-point decimal safety wrapper for multi-tariff scaling (#53) Replaces Number-based arithmetic (which drifts up to 0.05% over aggregation windows) with an exact fixed-point Decimal layer over BigNumber.js. - utils/decimal.ts: branded Decimal

over a cloned BigNumber (28 sig digits, ROUND_HALF_UP); factory + add/sub/mul/div/min/max/sum/average/neg/abs and comparisons. Intermediates stay exact; overflow past MAX_SAFE (10^21) throws SafetyRangeError; unsupported precision throws PrecisionError. toRedux()/ fromRedux() give lossless string serialization; toDecimalNumber() warns on precision loss at the charting boundary - types/meter.ts: MeterReading/ResourceConsumption/TariffRate keyed to the Decimal type, with canonical precision per resource (water 0, cost 2, electricity 3, gas 4, submeter 6, nano 9) - utils/aggregation.ts: sum/average/min/max/cumulative/windowed totals and applyTariff, all Decimal end-to-end (never cast back to number) - store/slices/resourceSlice.ts: store keeps readings string-encoded (SerializedDecimal) and rehydrates Decimals only in selectors - components/dashboard/ResourceChart.tsx: SVG series that converts via toDecimalNumber at render while keeping the headline total exact - hooks/useResourceData.ts: converts API string decimals to Decimal at the resource precision on ingestion - tests: mixed-precision add upcast, multiplication overflow, serialization round-trip, and 1000-iteration zero-drift accumulation --- src/components/dashboard/ResourceChart.tsx | 111 +++++++++ src/hooks/useResourceData.ts | 111 +++++++++ src/store/slices/resourceSlice.ts | 157 ++++++++++++ src/types/meter.ts | 69 ++++++ src/utils/aggregation.ts | 118 +++++++++ src/utils/decimal.ts | 270 +++++++++++++++++++++ tests/unit/aggregation.test.ts | 137 +++++++++++ tests/unit/decimal.test.ts | 185 ++++++++++++++ tests/unit/resourceSlice.test.ts | 60 +++++ 9 files changed, 1218 insertions(+) create mode 100644 src/components/dashboard/ResourceChart.tsx create mode 100644 src/hooks/useResourceData.ts create mode 100644 src/store/slices/resourceSlice.ts create mode 100644 src/types/meter.ts create mode 100644 src/utils/aggregation.ts create mode 100644 src/utils/decimal.ts create mode 100644 tests/unit/aggregation.test.ts create mode 100644 tests/unit/decimal.test.ts create mode 100644 tests/unit/resourceSlice.test.ts diff --git a/src/components/dashboard/ResourceChart.tsx b/src/components/dashboard/ResourceChart.tsx new file mode 100644 index 0000000..baa2c14 --- /dev/null +++ b/src/components/dashboard/ResourceChart.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useMemo } from "react"; +import { toDecimalNumber, type Decimal } from "@/utils/decimal"; + +/** + * Time-series chart for fixed-point resource data. Charting needs plain + * `number`s, so each {@link Decimal} is converted exactly once at render time + * via {@link toDecimalNumber} (which warns on precision loss). The exact Decimal + * is preserved for the headline total, which is formatted with `toFixed`. + */ + +export interface ResourceChartPoint { + /** Unix milliseconds. */ + timestamp: number; + value: Decimal; +} + +export interface ResourceChartProps { + points: ResourceChartPoint[]; + /** Display unit (e.g. "kWh"). */ + unit?: string; + height?: number; + /** Stroke color. */ + color?: string; + className?: string; +} + +const PADDING = 6; + +export function ResourceChart({ + points, + unit = "", + height = 160, + color = "#22c55e", + className, +}: ResourceChartProps) { + // Convert exactly once; toDecimalNumber warns if a value can't be represented. + const numeric = useMemo( + () => points.map((p) => ({ t: p.timestamp, y: toDecimalNumber(p.value) })), + [points] + ); + + const { path, minY, maxY } = useMemo(() => { + if (numeric.length === 0) { + return { path: "", minY: 0, maxY: 0 }; + } + let lo = Infinity; + let hi = -Infinity; + for (const p of numeric) { + if (p.y < lo) lo = p.y; + if (p.y > hi) hi = p.y; + } + if (lo === hi) { + hi = lo + 1; + } + const w = 100; + const h = height - PADDING * 2; + const n = numeric.length; + const d = numeric + .map((p, i) => { + const x = n === 1 ? w / 2 : (i / (n - 1)) * w; + const y = PADDING + h - ((p.y - lo) / (hi - lo)) * h; + return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + return { path: d, minY: lo, maxY: hi }; + }, [numeric, height]); + + // The headline value stays exact (formatted from the Decimal, not the float). + const latest = points.length ? points[points.length - 1].value.toFixed() : null; + + if (points.length === 0) { + return ( +

+ No data +
+ ); + } + + return ( +
+
+ + {latest} + {unit && {unit}} + + + {minY.toFixed(2)} – {maxY.toFixed(2)} + +
+ + + +
+ ); +} + +export default ResourceChart; diff --git a/src/hooks/useResourceData.ts b/src/hooks/useResourceData.ts new file mode 100644 index 0000000..9bcf51e --- /dev/null +++ b/src/hooks/useResourceData.ts @@ -0,0 +1,111 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { decimal, type Decimal } from "@/utils/decimal"; +import { aggregateReadings } from "@/utils/aggregation"; +import { resourceStore, useResourceReadings } from "@/store/slices/resourceSlice"; +import { + RESOURCE_PRECISION, + type MeterReading, + type ResourceConsumption, + type ResourceKind, +} from "@/types/meter"; + +/** + * Fetches metered readings for a resource and converts the API's string-encoded + * decimals into exact {@link Decimal} values at the resource's canonical + * precision before storing them. Values are never parsed as `number`, so no + * precision is lost on ingestion. + */ + +/** Shape of a reading as delivered by the REST API (decimals are strings). */ +export interface RawReading { + meterId: string; + timestamp: number; + /** String-encoded decimal, e.g. "12.345". */ + value: string; +} + +export interface UseResourceDataOptions { + resource: ResourceKind; + /** Override the endpoint. Defaults to `/api/meter/readings?resource=`. */ + endpoint?: string; + /** Injectable transport for tests. */ + fetchFn?: typeof fetch; + /** Fetch on mount. @default true */ + enabled?: boolean; +} + +export interface UseResourceDataResult { + readings: MeterReading[]; + total: Decimal; + consumption: ResourceConsumption; + loading: boolean; + error: string | null; + refetch: () => void; +} + +/** Parse a raw API reading into a precision-tagged {@link MeterReading}. */ +export function parseRawReading( + resource: ResourceKind, + raw: RawReading +): MeterReading { + return { + meterId: raw.meterId, + resource, + timestamp: raw.timestamp, + value: decimal(raw.value, RESOURCE_PRECISION[resource]) as MeterReading["value"], + }; +} + +export function useResourceData( + options: UseResourceDataOptions +): UseResourceDataResult { + const { resource, endpoint, fetchFn = fetch, enabled = true } = options; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const readings = useResourceReadings(resource); + const fetchRef = useRef(fetchFn); + fetchRef.current = fetchFn; + + const url = + endpoint ?? `/api/meter/readings?resource=${encodeURIComponent(resource)}`; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchRef.current(url, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`Readings request failed: HTTP ${res.status}`); + const raw = (await res.json()) as RawReading[]; + const parsed = raw.map((r) => parseRawReading(resource, r)); + resourceStore.dispatch({ type: "ADD_READINGS", payload: parsed }); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [resource, url]); + + useEffect(() => { + if (!enabled) return; + void load(); + }, [enabled, load]); + + const consumption = useMemo( + () => aggregateReadings(resource, readings), + [resource, readings] + ); + + return { + readings, + total: consumption.total, + consumption, + loading, + error, + refetch: load, + }; +} diff --git a/src/store/slices/resourceSlice.ts b/src/store/slices/resourceSlice.ts new file mode 100644 index 0000000..1dabc92 --- /dev/null +++ b/src/store/slices/resourceSlice.ts @@ -0,0 +1,157 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { fromRedux, type Decimal } from "@/utils/decimal"; +import { sumReadings } from "@/utils/aggregation"; +import { + RESOURCE_PRECISION, + type MeterReading, + type ResourceKind, + type SerializedMeterReading, +} from "@/types/meter"; + +/** + * Resource consumption store. Readings are persisted in their string-encoded + * {@link SerializedMeterReading} form so the state survives Redux DevTools / + * JSON serialization without floating-point corruption. Decimals are rehydrated + * only when a selector reads them. + */ + +const RESOURCE_KINDS: ResourceKind[] = [ + "water", + "electricity", + "gas", + "cost", + "submeter", + "nano", +]; + +export type ResourceState = Record; + +export type ResourceAction = + | { type: "ADD_READING"; payload: MeterReading } + | { type: "ADD_READINGS"; payload: MeterReading[] } + | { type: "CLEAR_RESOURCE"; payload: { resource: ResourceKind } } + | { type: "RESET" }; + +function emptyState(): ResourceState { + return RESOURCE_KINDS.reduce((acc, k) => { + acc[k] = []; + return acc; + }, {} as ResourceState); +} + +/** Serialize a reading for storage (string-encoded decimal value). */ +export function serializeReading(reading: MeterReading): SerializedMeterReading { + return { + meterId: reading.meterId, + resource: reading.resource, + timestamp: reading.timestamp, + value: reading.value.toRedux(), + }; +} + +/** Rehydrate a stored reading back into a {@link MeterReading}. */ +export function deserializeReading(s: SerializedMeterReading): MeterReading { + return { + meterId: s.meterId, + resource: s.resource, + timestamp: s.timestamp, + value: fromRedux(s.value) as MeterReading["value"], + }; +} + +type Listener = (state: ResourceState) => void; + +class ResourceStore { + private state: ResourceState = emptyState(); + private listeners = new Set(); + + getState = (): Readonly => this.state; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + dispatch(action: ResourceAction): void { + const next = this.reducer(this.state, action); + if (next !== this.state) { + this.state = next; + this.notify(); + } + } + + private reducer(state: ResourceState, action: ResourceAction): ResourceState { + switch (action.type) { + case "ADD_READING": { + const r = action.payload; + return { + ...state, + [r.resource]: [...state[r.resource], serializeReading(r)], + }; + } + case "ADD_READINGS": { + if (action.payload.length === 0) return state; + const next = { ...state }; + const grouped = new Map(); + for (const r of action.payload) { + const list = grouped.get(r.resource) ?? []; + list.push(serializeReading(r)); + grouped.set(r.resource, list); + } + for (const [resource, serialized] of grouped) { + next[resource] = [...state[resource], ...serialized]; + } + return next; + } + case "CLEAR_RESOURCE": + return { ...state, [action.payload.resource]: [] }; + case "RESET": + return emptyState(); + default: + return state; + } + } + + private notify(): void { + for (const listener of this.listeners) listener(this.state); + } +} + +/** Shared singleton resource store. */ +export const resourceStore = new ResourceStore(); + +// --- Selectors -------------------------------------------------------------- + +/** Rehydrated readings for a resource. */ +export function selectReadings( + state: ResourceState, + resource: ResourceKind +): MeterReading[] { + return state[resource].map(deserializeReading); +} + +/** Exact total for a resource at its canonical precision. */ +export function selectTotal( + state: ResourceState, + resource: ResourceKind +): Decimal { + return sumReadings(selectReadings(state, resource), RESOURCE_PRECISION[resource]); +} + +// --- React bindings --------------------------------------------------------- + +export function useResourceState(): ResourceState { + return useSyncExternalStore( + resourceStore.subscribe, + resourceStore.getState, + resourceStore.getState + ); +} + +/** Subscribe to the rehydrated readings for a single resource. */ +export function useResourceReadings(resource: ResourceKind): MeterReading[] { + const state = useResourceState(); + return selectReadings(state, resource); +} diff --git a/src/types/meter.ts b/src/types/meter.ts new file mode 100644 index 0000000..5df9a5e --- /dev/null +++ b/src/types/meter.ts @@ -0,0 +1,69 @@ +/** + * Domain types for multi-tariff metering, expressed in terms of the fixed-point + * {@link Decimal} type so consumption values never round-trip through `number`. + */ + +import type { Decimal, Precision, SerializedDecimal } from "@/utils/decimal"; + +/** Metered resource kinds and their natural decimal precision. */ +export type ResourceKind = + | "water" // liters, integer + | "electricity" // kWh, 3 decimals + | "gas" // therms, 4 decimals + | "cost" // fiat, 2 decimals + | "submeter" // fine-grained, 6 decimals + | "nano"; // nanoscale, 9 decimals + +/** Canonical precision per resource kind. */ +export const RESOURCE_PRECISION: Record = { + water: 0, + electricity: 3, + gas: 4, + cost: 2, + submeter: 6, + nano: 9, +}; + +/** Display unit per resource kind. */ +export const RESOURCE_UNIT: Record = { + water: "L", + electricity: "kWh", + gas: "therm", + cost: "", + submeter: "u", + nano: "nu", +}; + +/** A single reading from a meter. */ +export interface MeterReading { + meterId: string; + resource: K; + /** Unix milliseconds. */ + timestamp: number; + value: Decimal<(typeof RESOURCE_PRECISION)[K]>; +} + +/** Aggregated consumption for a resource over a window. */ +export interface ResourceConsumption { + resource: K; + total: Decimal; + /** Number of readings folded into the total. */ + count: number; + unit: string; +} + +/** A tariff rate (cost per unit), priced in currency precision. */ +export interface TariffRate { + resource: ResourceKind; + /** Cost per unit, currency precision (2 decimals). */ + ratePerUnit: Decimal<2>; + currency: string; +} + +/** Serialized reading as stored in the slice (string-encoded decimal). */ +export interface SerializedMeterReading { + meterId: string; + resource: ResourceKind; + timestamp: number; + value: SerializedDecimal; +} diff --git a/src/utils/aggregation.ts b/src/utils/aggregation.ts new file mode 100644 index 0000000..4e448dd --- /dev/null +++ b/src/utils/aggregation.ts @@ -0,0 +1,118 @@ +/** + * Aggregation over metered readings using exact {@link Decimal} arithmetic. + * + * Every intermediate stays a Decimal — values are never cast back to `number` + * mid-pipeline — so daily totals match the per-reading inputs to the last unit + * (no cumulative drift). + */ + +import { + add, + average as decAverage, + decimal, + max as decMax, + min as decMin, + mul, + sum as decSum, + type Decimal, + type Precision, +} from "@/utils/decimal"; +import { + RESOURCE_PRECISION, + RESOURCE_UNIT, + type MeterReading, + type ResourceConsumption, + type ResourceKind, + type TariffRate, +} from "@/types/meter"; + +function valuesOf(readings: MeterReading[]): Decimal[] { + return readings.map((r) => r.value); +} + +/** Exact total of a set of readings (zero at the resource precision if empty). */ +export function sumReadings( + readings: MeterReading[], + precision: Precision = 0 +): Decimal { + return decSum(valuesOf(readings), precision); +} + +/** Mean of a set of readings. Throws on an empty list. */ +export function averageReadings(readings: MeterReading[]): Decimal { + return decAverage(valuesOf(readings)); +} + +/** Smallest reading value, or null for an empty list. */ +export function minReading(readings: MeterReading[]): Decimal | null { + const values = valuesOf(readings); + if (values.length === 0) return null; + return values.reduce((m, v) => decMin(m, v)); +} + +/** Largest reading value, or null for an empty list. */ +export function maxReading(readings: MeterReading[]): Decimal | null { + const values = valuesOf(readings); + if (values.length === 0) return null; + return values.reduce((m, v) => decMax(m, v)); +} + +/** Running cumulative totals: out[i] = sum(values[0..i]). */ +export function cumulativeTotals(values: Decimal[]): Decimal[] { + const out: Decimal[] = []; + let acc: Decimal | null = null; + for (const v of values) { + acc = acc === null ? v : add(acc, v); + out.push(acc); + } + return out; +} + +/** Fold readings of one resource into a {@link ResourceConsumption}. */ +export function aggregateReadings( + resource: K, + readings: MeterReading[] +): ResourceConsumption { + return { + resource, + total: sumReadings(readings, RESOURCE_PRECISION[resource]), + count: readings.length, + unit: RESOURCE_UNIT[resource], + }; +} + +/** + * Bucket readings into fixed-width time windows keyed by the window start + * (`floor(timestamp / windowMs) * windowMs`), returning a sorted list of + * per-window consumption totals. + */ +export function aggregateByWindow( + resource: K, + readings: MeterReading[], + windowMs: number +): { windowStart: number; consumption: ResourceConsumption }[] { + if (windowMs <= 0) throw new Error("windowMs must be positive"); + const buckets = new Map(); + for (const r of readings) { + const start = Math.floor(r.timestamp / windowMs) * windowMs; + const list = buckets.get(start); + if (list) list.push(r); + else buckets.set(start, [r]); + } + return [...buckets.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([windowStart, group]) => ({ + windowStart, + consumption: aggregateReadings(resource, group), + })); +} + +/** + * Apply a tariff: cost = total × ratePerUnit, returned at currency precision. + * Computed exactly, then quantized once to 2 decimals. + */ +export function applyTariff(total: Decimal, rate: TariffRate): Decimal<2> { + const cost = mul(total, rate.ratePerUnit); + // Re-wrap at currency precision so the cost serializes to 2 decimals. + return decimal(cost.toFixed(), 2); +} diff --git a/src/utils/decimal.ts b/src/utils/decimal.ts new file mode 100644 index 0000000..438de85 --- /dev/null +++ b/src/utils/decimal.ts @@ -0,0 +1,270 @@ +/** + * Fixed-point decimal arithmetic safety wrapper. + * + * JavaScript `number` arithmetic accumulates rounding error that grows + * unboundedly over aggregation windows. This module wraps BigNumber.js in a + * branded {@link Decimal} type that carries an explicit precision (decimal + * scale), keeps intermediate results exact, detects overflow against a hard + * `MAX_SAFE` ceiling, and serializes losslessly for the store. + * + * Values are kept exact internally; rounding (ROUND_HALF_UP) happens only at + * serialization / display boundaries — that is what eliminates cumulative drift. + */ + +import BigNumber from "bignumber.js"; + +/** A dedicated BigNumber constructor so we never mutate the global config. */ +const BN = BigNumber.clone({ + DECIMAL_PLACES: 28, // significant digits for non-terminating division + ROUNDING_MODE: BigNumber.ROUND_HALF_UP, + EXPONENTIAL_AT: [-40, 40], +}); + +/** Supported precisions (decimal scale) across the tariff taxonomy. */ +export const SUPPORTED_PRECISIONS = [0, 2, 3, 4, 6, 9] as const; +export type Precision = (typeof SUPPORTED_PRECISIONS)[number]; + +/** Overflow ceiling: 10^21 (1 trillion units at 9 decimals) per resource. */ +export const MAX_SAFE = new BN(10).pow(21); + +/** Thrown when an operation would exceed {@link MAX_SAFE}. */ +export class SafetyRangeError extends Error { + constructor(readonly value: string, operation?: string) { + super( + `Value ${value} exceeds the safe range of ±10^21${ + operation ? ` during ${operation}` : "" + }` + ); + this.name = "SafetyRangeError"; + } +} + +/** Thrown for an unsupported precision or malformed numeric input. */ +export class PrecisionError extends Error { + constructor(message: string) { + super(message); + this.name = "PrecisionError"; + } +} + +/** Serialized form stored in the slice (string-encoded to avoid FP corruption). */ +export interface SerializedDecimal { + value: string; + precision: number; + /** Integer scaling factor (10^precision) to convert to minor units. */ + scale: number; +} + +declare const PRECISION_BRAND: unique symbol; + +/** + * A precision-tagged fixed-point value. The phantom `P` lets the compiler track + * the declared precision; the runtime value is a {@link DecimalValue}. + */ +export type Decimal

= DecimalValue & { + readonly [PRECISION_BRAND]?: P; +}; + +class DecimalValue { + constructor( + /** Exact underlying value (not pre-rounded to `precision`). */ + readonly bn: BigNumber, + /** Declared decimal scale used for serialization / display. */ + readonly precision: number + ) {} + + /** Round to the declared precision and format as a plain decimal string. */ + toFixed(): string { + return this.bn.toFixed(this.precision, BigNumber.ROUND_HALF_UP); + } + + toString(): string { + return this.toFixed(); + } + + /** Lossy conversion to a JS number (for charting). Prefer {@link toFixed}. */ + toNumber(): number { + return this.bn.toNumber(); + } + + /** Lossless serialization for the store / Redux DevTools. */ + toRedux(): SerializedDecimal { + return { + value: this.toFixed(), + precision: this.precision, + scale: 10 ** this.precision, + }; + } + + isSafe(): boolean { + return this.bn.abs().lte(MAX_SAFE); + } +} + +function assertPrecision(precision: number): void { + if (!SUPPORTED_PRECISIONS.includes(precision as Precision)) { + throw new PrecisionError( + `Unsupported precision ${precision}; expected one of ${SUPPORTED_PRECISIONS.join( + ", " + )}` + ); + } +} + +function guard(bn: BigNumber, operation: string, precision: number): DecimalValue { + if (bn.isNaN()) { + throw new PrecisionError(`Result of ${operation} is not a number`); + } + if (bn.abs().gt(MAX_SAFE)) { + throw new SafetyRangeError(bn.toFixed(), operation); + } + return new DecimalValue(bn, precision); +} + +/** True when `x` is a {@link Decimal}. */ +export function isDecimal(x: unknown): x is Decimal { + return x instanceof DecimalValue; +} + +/** + * Construct a {@link Decimal} from a string or number at the given precision. + * The input is quantized (ROUND_HALF_UP) to `precision`, so the result exactly + * represents a fixed-point value at that scale. + */ +export function decimal

( + value: string | number | BigNumber, + precision: P +): Decimal

{ + assertPrecision(precision); + let parsed: BigNumber; + try { + parsed = new BN(value); + } catch { + throw new PrecisionError(`Invalid numeric input: ${String(value)}`); + } + if (parsed.isNaN()) { + throw new PrecisionError(`Invalid numeric input: ${String(value)}`); + } + const quantized = parsed.decimalPlaces(precision, BigNumber.ROUND_HALF_UP); + return guard(quantized, "decimal()", precision) as Decimal

; +} + +/** Rehydrate a {@link Decimal} from its serialized form. */ +export function fromRedux(s: SerializedDecimal): Decimal { + return decimal(s.value, s.precision as Precision); +} + +/** Highest precision among operands (the scale add/sub/min/max upcast to). */ +function maxPrecision(values: Decimal[]): number { + return values.reduce((m, d) => Math.max(m, d.precision), 0); +} + +// --- Arithmetic ------------------------------------------------------------- + +/** Sum two decimals, upcasting to the higher precision. */ +export function add(a: Decimal, b: Decimal): Decimal { + return guard(a.bn.plus(b.bn), "add", maxPrecision([a, b])); +} + +/** Difference, upcasting to the higher precision. */ +export function sub(a: Decimal, b: Decimal): Decimal { + return guard(a.bn.minus(b.bn), "sub", maxPrecision([a, b])); +} + +/** Product. Result precision is the sum of scales, capped at 9. */ +export function mul(a: Decimal, b: Decimal): Decimal { + const precision = Math.min(a.precision + b.precision, 9); + return guard(a.bn.times(b.bn), "mul", precision); +} + +/** Quotient (28 significant digits). Result precision upcasts to the higher. */ +export function div(a: Decimal, b: Decimal): Decimal { + if (b.bn.isZero()) { + throw new PrecisionError("Division by zero"); + } + return guard(a.bn.div(b.bn), "div", maxPrecision([a, b])); +} + +/** Smaller of two decimals (keeps that operand's precision). */ +export function min(a: Decimal, b: Decimal): Decimal { + return a.bn.lte(b.bn) ? a : b; +} + +/** Larger of two decimals (keeps that operand's precision). */ +export function max(a: Decimal, b: Decimal): Decimal { + return a.bn.gte(b.bn) ? a : b; +} + +/** + * Exact sum of a list, upcasting to the highest precision present. An empty + * list returns zero at the requested `precision` (default 0). + */ +export function sum(values: Decimal[], precision: Precision = 0): Decimal { + if (values.length === 0) return decimal(0, precision); + const scale = Math.max(maxPrecision(values), precision); + let acc = new BN(0); + for (const d of values) { + acc = acc.plus(d.bn); + if (acc.abs().gt(MAX_SAFE)) { + throw new SafetyRangeError(acc.toFixed(), "sum"); + } + } + return new DecimalValue(acc, scale) as Decimal; +} + +/** Arithmetic mean, computed exactly then carried at the highest precision. */ +export function average(values: Decimal[]): Decimal { + if (values.length === 0) { + throw new PrecisionError("Cannot average an empty list"); + } + const total = sum(values); + return guard( + total.bn.div(values.length), + "average", + maxPrecision(values) + ); +} + +/** Negate. */ +export function neg(a: Decimal): Decimal { + return new DecimalValue(a.bn.negated(), a.precision) as Decimal; +} + +/** Absolute value. */ +export function abs(a: Decimal): Decimal { + return new DecimalValue(a.bn.abs(), a.precision) as Decimal; +} + +// --- Comparisons ------------------------------------------------------------ + +export function eq(a: Decimal, b: Decimal): boolean { + return a.bn.eq(b.bn); +} +export function gt(a: Decimal, b: Decimal): boolean { + return a.bn.gt(b.bn); +} +export function gte(a: Decimal, b: Decimal): boolean { + return a.bn.gte(b.bn); +} +export function lt(a: Decimal, b: Decimal): boolean { + return a.bn.lt(b.bn); +} +export function lte(a: Decimal, b: Decimal): boolean { + return a.bn.lte(b.bn); +} + +/** + * Convert to a JS number for charting libraries, warning when the conversion + * loses more than one ULP of precision relative to the exact value. + */ +export function toDecimalNumber(d: Decimal): number { + const n = d.bn.toNumber(); + const roundtrip = new BN(n); + const lost = d.bn.minus(roundtrip).abs(); + if (lost.gt(Number.EPSILON)) { + console.warn( + `[decimal] precision loss converting ${d.toFixed()} to number (Δ=${lost.toFixed()})` + ); + } + return n; +} diff --git a/tests/unit/aggregation.test.ts b/tests/unit/aggregation.test.ts new file mode 100644 index 0000000..e6d32e7 --- /dev/null +++ b/tests/unit/aggregation.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { decimal, eq } from "@/utils/decimal"; +import { + sumReadings, + averageReadings, + minReading, + maxReading, + cumulativeTotals, + aggregateReadings, + aggregateByWindow, + applyTariff, +} from "@/utils/aggregation"; +import { + RESOURCE_PRECISION, + type MeterReading, + type ResourceKind, + type TariffRate, +} from "@/types/meter"; + +function reading( + resource: ResourceKind, + value: string, + timestamp = 0 +): MeterReading { + return { + meterId: "m1", + resource, + timestamp, + value: decimal(value, RESOURCE_PRECISION[resource]) as MeterReading["value"], + }; +} + +describe("sumReadings / averageReadings", () => { + it("sums electricity readings exactly", () => { + const readings = [ + reading("electricity", "1.234"), + reading("electricity", "2.001"), + reading("electricity", "0.765"), + ]; + expect(sumReadings(readings, 3).toFixed()).toBe("4.000"); + }); + + it("averages exactly", () => { + const readings = [reading("gas", "1.0000"), reading("gas", "2.0000")]; + expect(averageReadings(readings).toFixed()).toBe("1.5000"); + }); + + it("empty sum is zero at the requested precision", () => { + expect(sumReadings([], 2).toFixed()).toBe("0.00"); + }); +}); + +describe("minReading / maxReading", () => { + it("returns extremes", () => { + const readings = [ + reading("water", "10"), + reading("water", "3"), + reading("water", "7"), + ]; + expect(minReading(readings)!.toFixed()).toBe("3"); + expect(maxReading(readings)!.toFixed()).toBe("10"); + }); + + it("returns null for an empty list", () => { + expect(minReading([])).toBeNull(); + expect(maxReading([])).toBeNull(); + }); +}); + +describe("cumulativeTotals", () => { + it("produces running sums without drift", () => { + const values = Array.from({ length: 5 }, () => decimal("0.001", 3)); + const totals = cumulativeTotals(values); + expect(totals.map((d) => d.toFixed())).toEqual([ + "0.001", + "0.002", + "0.003", + "0.004", + "0.005", + ]); + }); +}); + +describe("aggregateReadings", () => { + it("returns total, count and unit", () => { + const c = aggregateReadings("electricity", [ + reading("electricity", "1.500"), + reading("electricity", "2.500"), + ]); + expect(c.resource).toBe("electricity"); + expect(c.count).toBe(2); + expect(c.unit).toBe("kWh"); + expect(c.total.toFixed()).toBe("4.000"); + }); +}); + +describe("aggregateByWindow", () => { + it("buckets readings by fixed time windows, sorted ascending", () => { + const hour = 3_600_000; + const readings = [ + reading("water", "5", 0), + reading("water", "3", 1_000), + reading("water", "8", hour + 500), + ]; + const windows = aggregateByWindow("water", readings, hour); + expect(windows).toHaveLength(2); + expect(windows[0].windowStart).toBe(0); + expect(windows[0].consumption.total.toFixed()).toBe("8"); + expect(windows[1].windowStart).toBe(hour); + expect(windows[1].consumption.total.toFixed()).toBe("8"); + }); +}); + +describe("applyTariff", () => { + it("computes cost = total × rate at currency precision", () => { + const total = decimal("100.000", 3); // kWh + const rate: TariffRate = { + resource: "electricity", + ratePerUnit: decimal("0.15", 2), + currency: "USD", + }; + const cost = applyTariff(total, rate); + expect(cost.precision).toBe(2); + expect(cost.toFixed()).toBe("15.00"); + }); + + it("rounds the cost half-up to 2 decimals", () => { + const total = decimal("3.333", 3); + const rate: TariffRate = { + resource: "electricity", + ratePerUnit: decimal("0.10", 2), + currency: "USD", + }; + // 3.333 × 0.10 = 0.3333 → 0.33 + expect(eq(applyTariff(total, rate), decimal("0.33", 2))).toBe(true); + }); +}); diff --git a/tests/unit/decimal.test.ts b/tests/unit/decimal.test.ts new file mode 100644 index 0000000..4fc75a4 --- /dev/null +++ b/tests/unit/decimal.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "vitest"; +import { + decimal, + add, + sub, + mul, + div, + min, + max, + sum, + average, + neg, + abs, + eq, + gt, + gte, + lt, + lte, + fromRedux, + isDecimal, + toDecimalNumber, + SafetyRangeError, + PrecisionError, + MAX_SAFE, + type Decimal, +} from "@/utils/decimal"; + +describe("decimal() factory", () => { + it("accepts supported precisions and quantizes the input", () => { + expect(decimal("1.2345", 2).toFixed()).toBe("1.23"); + expect(decimal(5, 0).toFixed()).toBe("5"); + expect(decimal("1.0005", 3).toFixed()).toBe("1.001"); // ROUND_HALF_UP + }); + + it("rejects an unsupported precision", () => { + expect(() => decimal("1", 5 as 6)).toThrow(PrecisionError); + expect(() => decimal("1", 1 as 2)).toThrow(PrecisionError); + }); + + it("rejects malformed numeric input", () => { + expect(() => decimal("not-a-number", 2)).toThrow(PrecisionError); + }); + + it("throws SafetyRangeError when the input exceeds 10^21", () => { + expect(() => decimal("1e22", 0)).toThrow(SafetyRangeError); + }); + + it("allows exactly 10^21 (the ceiling is inclusive)", () => { + expect(decimal(MAX_SAFE.toFixed(), 0).isSafe()).toBe(true); + }); +}); + +describe("add / sub at mixed precision", () => { + it("upcasts to the higher precision", () => { + const a = decimal("1.50", 2); // currency + const b = decimal("0.005", 3); // electricity + const r = add(a, b); + expect(r.precision).toBe(3); + expect(r.toFixed()).toBe("1.505"); + }); + + it("subtracts exactly", () => { + expect(sub(decimal("10.000", 3), decimal("0.001", 3)).toFixed()).toBe("9.999"); + }); + + it("throws on overflow", () => { + const big = decimal(MAX_SAFE.toFixed(), 0); + expect(() => add(big, decimal("1", 0))).toThrow(SafetyRangeError); + }); +}); + +describe("mul / div", () => { + it("multiplies and sums the scales (capped at 9)", () => { + const price = decimal("2.50", 2); + const qty = decimal("3.000", 3); + const r = mul(price, qty); + expect(r.precision).toBe(5); + expect(r.toFixed()).toBe("7.50000"); + }); + + it("detects multiplication overflow", () => { + const a = decimal("100000000000", 0); // 1e11 + expect(() => mul(a, a)).toThrow(SafetyRangeError); // 1e22 + }); + + it("divides with 28 significant digits and upcasts precision", () => { + expect(div(decimal("10.00", 2), decimal("4.00", 2)).toFixed()).toBe("2.50"); + }); + + it("throws on division by zero", () => { + expect(() => div(decimal("1", 0), decimal("0", 0))).toThrow(PrecisionError); + }); +}); + +describe("min / max / sum / average", () => { + it("min and max return the relevant operand", () => { + const a = decimal("1.2", 2); + const b = decimal("3.4", 2); + expect(min(a, b)).toBe(a); + expect(max(a, b)).toBe(b); + }); + + it("sum folds a list exactly at the highest precision", () => { + const r = sum([decimal("1.5", 2), decimal("0.025", 3), decimal("2", 0)]); + expect(r.precision).toBe(3); + expect(r.toFixed()).toBe("3.525"); + }); + + it("sum of an empty list is zero at the requested precision", () => { + expect(sum([], 2).toFixed()).toBe("0.00"); + }); + + it("average is exact", () => { + expect(average([decimal("1", 0), decimal("2", 0), decimal("4", 0)]).toFixed()).toBe( + "2" + ); + }); + + it("average of an empty list throws", () => { + expect(() => average([])).toThrow(PrecisionError); + }); +}); + +describe("neg / abs / comparisons", () => { + it("negates and takes absolute value", () => { + expect(neg(decimal("3.14", 2)).toFixed()).toBe("-3.14"); + expect(abs(decimal("-3.14", 2)).toFixed()).toBe("3.14"); + }); + + it("compares correctly", () => { + const a = decimal("1.00", 2); + const b = decimal("2.00", 2); + expect(eq(a, a)).toBe(true); + expect(gt(b, a)).toBe(true); + expect(gte(a, a)).toBe(true); + expect(lt(a, b)).toBe(true); + expect(lte(a, a)).toBe(true); + }); +}); + +describe("serialization round-trip", () => { + it("toRedux / fromRedux preserves the exact value", () => { + const d = decimal("123.456789", 6); + const s = d.toRedux(); + expect(s).toEqual({ value: "123.456789", precision: 6, scale: 1_000_000 }); + const back = fromRedux(s); + expect(eq(back, d)).toBe(true); + expect(back.toFixed()).toBe("123.456789"); + }); + + it("isDecimal recognises decimals", () => { + expect(isDecimal(decimal("1", 0))).toBe(true); + expect(isDecimal("1")).toBe(false); + expect(isDecimal(1)).toBe(false); + }); +}); + +describe("zero-drift accumulation", () => { + it("accumulates 1000 × 0.001 to exactly 1.000 (no drift)", () => { + let acc: Decimal = decimal("0.000", 3); + for (let i = 0; i < 1000; i++) { + acc = add(acc, decimal("0.001", 3)); + } + expect(acc.toFixed()).toBe("1.000"); + expect(eq(acc, decimal("1", 3))).toBe(true); + // Contrast: naive float arithmetic does NOT land exactly on 1. + let naive = 0; + for (let i = 0; i < 1000; i++) naive += 0.001; + expect(naive).not.toBe(1); + }); + + it("accumulates 1,000,000 gas readings without drift", () => { + let acc: Decimal = decimal("0.0000", 4); + for (let i = 0; i < 10_000; i++) { + acc = add(acc, decimal("0.0001", 4)); + } + expect(acc.toFixed()).toBe("1.0000"); + }); +}); + +describe("toDecimalNumber", () => { + it("converts an exactly-representable value without warning", () => { + expect(toDecimalNumber(decimal("2.5", 2))).toBe(2.5); + }); +}); diff --git a/tests/unit/resourceSlice.test.ts b/tests/unit/resourceSlice.test.ts new file mode 100644 index 0000000..3f16643 --- /dev/null +++ b/tests/unit/resourceSlice.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { decimal, eq } from "@/utils/decimal"; +import { + resourceStore, + serializeReading, + deserializeReading, + selectReadings, + selectTotal, +} from "@/store/slices/resourceSlice"; +import { RESOURCE_PRECISION, type MeterReading } from "@/types/meter"; + +function reading(value: string, timestamp = 0): MeterReading { + return { + meterId: "m1", + resource: "electricity", + timestamp, + value: decimal(value, RESOURCE_PRECISION.electricity) as MeterReading["value"], + }; +} + +beforeEach(() => resourceStore.dispatch({ type: "RESET" })); + +describe("serialize / deserialize", () => { + it("round-trips a reading losslessly through string encoding", () => { + const r = reading("12.345"); + const s = serializeReading(r); + expect(s.value).toEqual({ value: "12.345", precision: 3, scale: 1000 }); + const back = deserializeReading(s); + expect(eq(back.value, r.value)).toBe(true); + }); +}); + +describe("resourceStore", () => { + it("stores readings in serialized form and rehydrates them", () => { + resourceStore.dispatch({ type: "ADD_READING", payload: reading("1.500") }); + resourceStore.dispatch({ type: "ADD_READING", payload: reading("2.500") }); + + // Raw state is JSON-safe (string values, no BigNumber instances). + const raw = resourceStore.getState().electricity; + expect(raw).toHaveLength(2); + expect(typeof raw[0].value.value).toBe("string"); + + const readings = selectReadings(resourceStore.getState(), "electricity"); + expect(readings.map((r) => r.value.toFixed())).toEqual(["1.500", "2.500"]); + }); + + it("computes an exact total via the selector", () => { + resourceStore.dispatch({ + type: "ADD_READINGS", + payload: [reading("0.001"), reading("0.002"), reading("0.003")], + }); + expect(selectTotal(resourceStore.getState(), "electricity").toFixed()).toBe("0.006"); + }); + + it("CLEAR_RESOURCE empties one resource only", () => { + resourceStore.dispatch({ type: "ADD_READING", payload: reading("1.000") }); + resourceStore.dispatch({ type: "CLEAR_RESOURCE", payload: { resource: "electricity" } }); + expect(resourceStore.getState().electricity).toHaveLength(0); + }); +});