Skip to content
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
111 changes: 111 additions & 0 deletions src/components/dashboard/ConsumptionPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import {
aggregateReadings,
perResourceUnits,
PRECISION_THRESHOLD,
type Reading,
} from "@/utils/aggregation/resourceMath";
import { RESOURCE_TYPES, type ResourceType } from "@/utils/aggregation/unitConversion";

/**
* Real-time resource-consumption panel.
*
* Aggregation is exact (BigInt); the only conversion to `Number` happens here,
* at the display tail, formatted with `Intl.NumberFormat`. A subtle badge
* surfaces the precision audit when the legacy `Number` pipeline would have
* drifted beyond the tolerance.
*/

const RESOURCE_LABEL: Record<ResourceType, string> = {
water: "Water",
energy: "Energy",
bandwidth: "Bandwidth",
};

export interface ConsumptionPanelProps {
/** Readings for the current window. */
readings: Reading[];
/** Pull a fresh window on an interval (ms); omit for a static snapshot. */
refreshIntervalMs?: number;
/** Source for live refreshes (defaults to the static `readings` prop). */
readingsSource?: () => Reading[];
className?: string;
}

export function ConsumptionPanel({
readings,
refreshIntervalMs,
readingsSource,
className,
}: ConsumptionPanelProps) {
const [window, setWindow] = useState<Reading[]>(readings);

useEffect(() => {
setWindow(readings);
}, [readings]);

// Dashboard refresh: re-pull the window on the configured interval.
useEffect(() => {
if (!refreshIntervalMs || !readingsSource) return;
const id = setInterval(() => setWindow(readingsSource()), refreshIntervalMs);
return () => clearInterval(id);
}, [refreshIntervalMs, readingsSource]);

const result = useMemo(() => aggregateReadings(window), [window]);
const byResource = useMemo(() => perResourceUnits(result), [result]);

const driftDetected = result.relativeError > PRECISION_THRESHOLD;

return (
<div
className={`rounded-xl border border-border bg-background p-6 space-y-4 ${
className ?? ""
}`}
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">Resource Consumption</h3>
<p className="text-sm text-muted-foreground">
{result.readingCount.toLocaleString()} readings · unified resource
units
</p>
</div>
{driftDetected && (
<span
className="rounded-full bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600"
title={`Legacy Number pipeline drift: ${(result.relativeError * 100).toFixed(4)}% — exact BigInt total shown`}
>
precision-corrected
</span>
)}
</div>

<div className="space-y-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Total
</span>
<p className="text-3xl font-bold tabular-nums" title={result.exactBase.toString()}>
{result.total.toDisplay(4, 2)}
</p>
</div>

<dl className="grid grid-cols-3 gap-3 pt-2">
{RESOURCE_TYPES.map((type) => (
<div key={type} className="rounded-lg border border-border p-3">
<dt className="text-xs text-muted-foreground">{RESOURCE_LABEL[type]}</dt>
<dd
className="mt-1 font-semibold tabular-nums"
title={result.byResource[type].toString()}
>
{byResource[type].toDisplay(4, 2)}
</dd>
</div>
))}
</dl>
</div>
);
}

export default ConsumptionPanel;
100 changes: 100 additions & 0 deletions src/utils/aggregation/ResourceUnits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Fixed-point resource quantity backed by BigInt.
*
* The value is stored as an integer `raw` interpreted as `raw / 10^scale`, so
* additions and integer multiplications are exact regardless of magnitude. The
* only lossy operation is {@link ResourceUnits.toNumber} / {@link toDisplay},
* which is the single, deliberate conversion-to-`Number` at the tail of the
* aggregation pipeline.
*
* ES2017 target → BigInt literals are unavailable; `BigInt()` is used.
*/

const TEN = BigInt(10);

function pow10(n: number): bigint {
let result = BigInt(1);
for (let i = 0; i < n; i++) result *= TEN;
return result;
}

export class ResourceUnits {
/** @param raw integer value scaled by 10^scale. @param scale fractional digits. */
constructor(readonly raw: bigint, readonly scale: number = 18) {
if (scale < 0 || !Number.isInteger(scale)) {
throw new Error(`scale must be a non-negative integer, got ${scale}`);
}
}

static zero(scale = 18): ResourceUnits {
return new ResourceUnits(BigInt(0), scale);
}

/** Wrap an exact integer count of base units at the given scale. */
static fromBaseUnits(units: bigint, scale = 18): ResourceUnits {
return new ResourceUnits(units * pow10(scale), scale);
}

/** Re-express this value at a different scale (down-scaling floors). */
rescale(targetScale: number): ResourceUnits {
if (targetScale === this.scale) return this;
if (targetScale > this.scale) {
return new ResourceUnits(this.raw * pow10(targetScale - this.scale), targetScale);
}
return new ResourceUnits(this.raw / pow10(this.scale - targetScale), targetScale);
}

/** Exact addition; operands are aligned to the higher scale. */
add(other: ResourceUnits): ResourceUnits {
const scale = Math.max(this.scale, other.scale);
const a = this.rescale(scale);
const b = other.rescale(scale);
return new ResourceUnits(a.raw + b.raw, scale);
}

/** Exact multiplication by an integer factor (BigInt or integer Number). */
multiply(factor: bigint | number): ResourceUnits {
let f: bigint;
if (typeof factor === "bigint") {
f = factor;
} else {
if (!Number.isInteger(factor)) {
throw new Error(`multiply expects an integer factor, got ${factor}`);
}
f = BigInt(factor);
}
return new ResourceUnits(this.raw * f, this.scale);
}

/** The exact integer count of base units (fractional part floored). */
toBaseUnits(): bigint {
return this.raw / pow10(this.scale);
}

/**
* Convert to a JS number — the single lossy step. Splits into integer and
* fractional parts so large magnitudes keep their fractional precision.
*/
toNumber(): number {
const divisor = pow10(this.scale);
const whole = this.raw / divisor;
const frac = this.raw - whole * divisor;
return Number(whole) + Number(frac) / Number(divisor);
}

/**
* Formatted string for display via `Intl.NumberFormat`. Defaults match the
* dashboard: 2–4 fraction digits.
*/
toDisplay(maximumFractionDigits = 4, minimumFractionDigits = 2): string {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits,
maximumFractionDigits,
}).format(this.toNumber());
}

equals(other: ResourceUnits): boolean {
const scale = Math.max(this.scale, other.scale);
return this.rescale(scale).raw === other.rescale(scale).raw;
}
}
114 changes: 114 additions & 0 deletions src/utils/aggregation/resourceMath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Exact resource-consumption aggregation.
*
* All conversion and summation happens in BigInt space; the result is an exact
* integer count of base resource units. A precision audit also computes the old
* `Number`-arithmetic total and logs a warning when its relative error exceeds
* {@link PRECISION_THRESHOLD} — observability for the drift this module fixes.
*/

import {
RESOURCE_TYPES,
convertToBase,
convertToBaseApprox,
type ResourceType,
} from "@/utils/aggregation/unitConversion";
import { ResourceUnits } from "@/utils/aggregation/ResourceUnits";

/** Maximum readings folded into a single aggregation window. */
export const MAX_READINGS_PER_WINDOW = 10_000;
/** Relative-error budget: |displayed − exact| / exact must stay below this. */
export const PRECISION_THRESHOLD = 1e-5;

export interface Reading {
resource: ResourceType;
/** Raw integer meter reading (micro-units). */
value: bigint | number | string;
}

export interface AggregationResult {
/** Exact total in base resource units. */
total: ResourceUnits;
/** Exact integer base units. */
exactBase: bigint;
/** Old `Number`-pipeline total, retained for the audit comparison. */
approxBase: number;
/** `|approxBase − exactBase| / exactBase` (0 when exact is 0). */
relativeError: number;
/** Exact per-resource subtotals. */
byResource: Record<ResourceType, bigint>;
readingCount: number;
}

export interface AggregateOptions {
/** Injectable logger (defaults to console) for the precision audit. */
logger?: Pick<Console, "warn">;
}

/** Relative error between a `Number` approximation and a BigInt exact value. */
export function relativeError(approx: number, exact: bigint): number {
if (exact === BigInt(0)) return approx === 0 ? 0 : Infinity;
const exactNum = Number(exact);
return Math.abs(approx - exactNum) / Math.abs(exactNum);
}

function emptyByResource(): Record<ResourceType, bigint> {
return { water: BigInt(0), energy: BigInt(0), bandwidth: BigInt(0) };
}

/**
* Aggregate a window of readings into an exact total. The BigInt pipeline is
* authoritative; the `Number` pipeline runs only to audit precision drift.
*/
export function aggregateReadings(
readings: Reading[],
options: AggregateOptions = {}
): AggregationResult {
const logger = options.logger ?? console;

if (readings.length > MAX_READINGS_PER_WINDOW) {
logger.warn(
`[resourceMath] window has ${readings.length} readings, exceeding the ${MAX_READINGS_PER_WINDOW} cap`
);
}

const byResource = emptyByResource();
let exactBase = BigInt(0);
let approxBase = 0;

for (const reading of readings) {
const base = convertToBase(reading.resource, reading.value);
exactBase += base;
byResource[reading.resource] += base;
approxBase += convertToBaseApprox(reading.resource, reading.value);
}

const relError = relativeError(approxBase, exactBase);
if (relError > PRECISION_THRESHOLD) {
logger.warn(
`[resourceMath] precision audit: Number pipeline drifted ${(relError * 100).toFixed(
4
)}% (approx=${approxBase}, exact=${exactBase.toString()}) — using exact BigInt total`
);
}

return {
total: ResourceUnits.fromBaseUnits(exactBase, 0),
exactBase,
approxBase,
relativeError: relError,
byResource,
readingCount: readings.length,
};
}

/** Exact per-resource totals wrapped as {@link ResourceUnits} (scale 0). */
export function perResourceUnits(
result: AggregationResult
): Record<ResourceType, ResourceUnits> {
const out = {} as Record<ResourceType, ResourceUnits>;
for (const type of RESOURCE_TYPES) {
out[type] = ResourceUnits.fromBaseUnits(result.byResource[type], 0);
}
return out;
}
55 changes: 55 additions & 0 deletions src/utils/aggregation/unitConversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Per-resource conversion factors for the consumption dashboard.
*
* Raw meter readings are integers (micro-units); each resource is converted to
* a common "resource unit" base by an integer factor. Conversion is done in
* BigInt so a factor like 3,600,000 never loses low-order digits the way
* `Number` multiplication does (the 0.01%-per-reading drift that accumulated to
* a 3.5% daily dashboard error).
*
* Note: this project targets ES2017, so BigInt *literals* (`1000n`) are not
* available — the `BigInt()` constructor is used throughout.
*/

export type ResourceType = "water" | "energy" | "bandwidth";

/** Exact integer conversion factors (BigInt). */
export const RESOURCE_FACTORS: Record<ResourceType, bigint> = {
water: BigInt(1000), // 10^3 (megaliters)
energy: BigInt(3_600_000), // 3.6×10^6 (kWh → base)
bandwidth: BigInt(1_000_000_000), // 10^9 (GB)
};

/** The same factors as `number`, used only by the precision-audit comparison. */
export const RESOURCE_FACTORS_NUMBER: Record<ResourceType, number> = {
water: 1e3,
energy: 3.6e6,
bandwidth: 1e9,
};

export const RESOURCE_TYPES: ResourceType[] = ["water", "energy", "bandwidth"];

/** Coerce a reading value to a non-negative BigInt integer. */
export function toBigIntReading(value: bigint | number | string): bigint {
if (typeof value === "bigint") return value;
if (typeof value === "string") return BigInt(value);
if (!Number.isFinite(value)) throw new Error(`invalid reading: ${value}`);
// Meter readings are integers; truncate any incidental fractional part.
return BigInt(Math.trunc(value));
}

/** Convert a raw reading to exact base resource units (BigInt). */
export function convertToBase(
resource: ResourceType,
rawReading: bigint | number | string
): bigint {
return toBigIntReading(rawReading) * RESOURCE_FACTORS[resource];
}

/** Convert a raw reading to base units using lossy `Number` math (audit only). */
export function convertToBaseApprox(
resource: ResourceType,
rawReading: bigint | number | string
): number {
return Number(rawReading) * RESOURCE_FACTORS_NUMBER[resource];
}
Loading
Loading