@@ -97,7 +103,7 @@ function PinCard({
Pin {index + 1}
- {current.toFixed(1)} / {PIN_OCP_A} A
+ {current.toFixed(1)} / {PIN_RATED_A} A
diff --git a/frontend/src/hooks/useWireviewImbalance.test.ts b/frontend/src/hooks/useWireviewImbalance.test.ts
new file mode 100644
index 0000000..9b4164b
--- /dev/null
+++ b/frontend/src/hooks/useWireviewImbalance.test.ts
@@ -0,0 +1,129 @@
+import { useState } from "react";
+import { renderHook, act } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { useWireviewImbalance } from "./useWireviewImbalance.js";
+import type { WireViewSample } from "../types.js";
+
+/** Build a minimal sample with the given per-pin currents (A). */
+function sample(pinCurrents: number[]): WireViewSample {
+ return {
+ timestamp: Date.now(),
+ power_total_w: 0,
+ current_total_a: pinCurrents.reduce((a, b) => a + b, 0),
+ pin_imbalance_a: 0,
+ voltage_avg_v: 12,
+ pins: pinCurrents.map((a) => ({
+ voltage_v: 12,
+ current_a: a,
+ power_w: 12 * a,
+ })),
+ temp_in_c: null,
+ temp_out_c: null,
+ temp_ext1_c: null,
+ temp_ext2_c: null,
+ fan_duty_pct: 0,
+ fault_status: 0,
+ fault_log: 0,
+ psu_capability_w: 600,
+ };
+}
+
+const BALANCED = [4.5, 4.6, 4.4, 4.8, 5.0, 4.2]; // spread 0.8 A
+const IMBALANCED = [4.5, 4.6, 4.4, 4.8, 5.6, 3.5]; // spread 2.1 A
+
+function useTestHook() {
+ const [s, setS] = useState(null);
+ const alert = useWireviewImbalance(s);
+ return { alert, setSample: setS };
+}
+
+/**
+ * Feed `times` fresh samples (new object identity, like the websocket).
+ * Each sample must be a separate state update — the hook counts per sample,
+ * so the loop is intentional (not batchable).
+ */
+function feed(
+ setSample: (s: WireViewSample) => void,
+ currents: number[],
+ times: number,
+) {
+ for (let i = 0; i < times; i++) {
+ act(() => setSample(sample(currents)));
+ }
+}
+
+describe("useWireviewImbalance", () => {
+ it("returns null for a null sample", () => {
+ const { result } = renderHook(() => useTestHook());
+ expect(result.current.alert).toBeNull();
+ });
+
+ it("stays hidden while pins are balanced", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, BALANCED, 10);
+ expect(result.current.alert).toBeNull();
+ });
+
+ it("shows only after 7 consecutive imbalanced samples", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, IMBALANCED, 6);
+ expect(result.current.alert).toBeNull();
+ feed(result.current.setSample, IMBALANCED, 1);
+ expect(result.current.alert).not.toBeNull();
+ });
+
+ it("reports the correct pins and spread", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, IMBALANCED, 7);
+ const alert = result.current.alert!;
+ expect(alert.diff).toBeCloseTo(2.1, 5);
+ expect(alert.hiPin).toBe(5); // 5.6 A
+ expect(alert.loPin).toBe(6); // 3.5 A
+ expect(alert.maxI).toBeCloseTo(5.6, 5);
+ expect(alert.minI).toBeCloseTo(3.5, 5);
+ });
+
+ it("a single balanced sample in the streak resets the counter", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, IMBALANCED, 6);
+ feed(result.current.setSample, BALANCED, 1);
+ feed(result.current.setSample, IMBALANCED, 6);
+ expect(result.current.alert).toBeNull();
+ feed(result.current.setSample, IMBALANCED, 1);
+ expect(result.current.alert).not.toBeNull();
+ });
+
+ it("keeps showing for 5 samples after the imbalance clears, then hides", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, IMBALANCED, 7);
+ expect(result.current.alert).not.toBeNull();
+ feed(result.current.setSample, BALANCED, 4);
+ expect(result.current.alert).not.toBeNull(); // grace period
+ feed(result.current.setSample, BALANCED, 1);
+ expect(result.current.alert).toBeNull();
+ });
+
+ it("stays shown (with updated values) while the imbalance persists", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, IMBALANCED, 7);
+ feed(result.current.setSample, [4.5, 4.6, 4.4, 4.8, 6.1, 3.2], 1);
+ const alert = result.current.alert!;
+ expect(alert.diff).toBeCloseTo(2.9, 5);
+ expect(alert.hiPin).toBe(5);
+ expect(alert.loPin).toBe(6);
+ });
+
+ it("clears immediately when the sample disappears (device unplugged)", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, IMBALANCED, 7);
+ expect(result.current.alert).not.toBeNull();
+ act(() => result.current.setSample(null));
+ expect(result.current.alert).toBeNull();
+ });
+
+ it("ignores samples with fewer than 2 pins", () => {
+ const { result } = renderHook(() => useTestHook());
+ feed(result.current.setSample, [10], 5);
+ expect(result.current.alert).toBeNull();
+ });
+});
diff --git a/frontend/src/hooks/useWireviewImbalance.ts b/frontend/src/hooks/useWireviewImbalance.ts
new file mode 100644
index 0000000..097ca70
--- /dev/null
+++ b/frontend/src/hooks/useWireviewImbalance.ts
@@ -0,0 +1,78 @@
+import { useEffect, useRef, useState } from "react";
+import type { WireViewSample } from "../types.js";
+
+// Current difference between any two pins that indicates a bad contact.
+const PIN_IMBALANCE_A = 2;
+// Show only after the imbalance holds for this many consecutive samples (~1 s each).
+// AI workloads produce frequent short load ramps that skew one pin transiently,
+// so the threshold is deliberately high: a bad contact stays imbalanced, a ramp does not.
+const SHOW_AFTER = 7;
+// Keep showing this many samples after the imbalance clears (grace period).
+const HIDE_AFTER = 5;
+
+export interface ImbalanceAlert {
+ diff: number;
+ maxI: number;
+ minI: number;
+ /** 1-based pin index carrying the most current */
+ hiPin: number;
+ /** 1-based pin index carrying the least current */
+ loPin: number;
+}
+
+/**
+ * Debounced pin-current imbalance detection.
+ *
+ * A single sample can transiently skew (load change, measurement glitch), so
+ * the alert only appears after the imbalance persists for a few samples, and
+ * stays visible briefly after it clears instead of flickering.
+ */
+export function useWireviewImbalance(
+ sample: WireViewSample | null,
+): ImbalanceAlert | null {
+ const [alert, setAlert] = useState(null);
+ const alertRef = useRef(null);
+ const streak = useRef(0);
+ const grace = useRef(0);
+
+ useEffect(() => {
+ function update(next: ImbalanceAlert | null) {
+ alertRef.current = next;
+ setAlert(next);
+ }
+
+ if (!sample || sample.pins.length < 2) {
+ streak.current = 0;
+ grace.current = 0;
+ if (alertRef.current) update(null);
+ return;
+ }
+
+ const currents = sample.pins.map((p) => p.current_a);
+ const maxI = Math.max(...currents);
+ const minI = Math.min(...currents);
+ const diff = maxI - minI;
+
+ if (diff > PIN_IMBALANCE_A) {
+ streak.current += 1;
+ grace.current = 0;
+ if (streak.current >= SHOW_AFTER) {
+ update({
+ diff,
+ maxI,
+ minI,
+ hiPin: currents.indexOf(maxI) + 1,
+ loPin: currents.indexOf(minI) + 1,
+ });
+ }
+ } else {
+ streak.current = 0;
+ if (alertRef.current) {
+ grace.current += 1;
+ if (grace.current >= HIDE_AFTER) update(null);
+ }
+ }
+ }, [sample]);
+
+ return alert;
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 40dc8df..3499a01 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -178,6 +178,8 @@ export interface WireViewSample {
timestamp: number;
power_total_w: number;
current_total_a: number;
+ /** Spread (A) between the highest- and lowest-loaded pin */
+ pin_imbalance_a: number;
voltage_avg_v: number;
pins: WireViewPin[];
// Temperatures are null when the source has no channel for them
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..6aa22e8
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "happy-dom",
+ include: ["src/**/*.test.ts"],
+ },
+});
diff --git a/nvcurve/wireview.py b/nvcurve/wireview.py
index 2be0c22..0c25b8e 100644
--- a/nvcurve/wireview.py
+++ b/nvcurve/wireview.py
@@ -152,10 +152,16 @@ def parse_sensor_struct(buf: bytes) -> dict:
power_total += voltage_v * current_a
current_total += current_a
+ # Spread between the highest- and lowest-loaded pin; a growing spread
+ # indicates a bad pin contact (see the frontend imbalance warning).
+ pin_currents = [p["current_a"] for p in pins]
+ pin_imbalance = round(max(pin_currents) - min(pin_currents), 3)
+
return {
"timestamp": time.time(),
"power_total_w": round(power_total, 3),
"current_total_a": round(current_total, 3),
+ "pin_imbalance_a": pin_imbalance,
"voltage_avg_v": (
round(power_total / current_total, 3) if current_total > 0 else 0.0
),
@@ -608,11 +614,14 @@ class WireViewHwmonDevice:
for v, i in zip(pin_voltage, pin_current, strict=True)
)
current_total = sum(pin_current)
+ pin_currents = [round(i, 3) for i in pin_current]
+ pin_imbalance = round(max(pin_currents) - min(pin_currents), 3)
return {
"timestamp": time.time(),
"power_total_w": round(power_total, 3),
"current_total_a": round(current_total, 3),
+ "pin_imbalance_a": pin_imbalance,
"voltage_avg_v": (
round(power_total / current_total, 3)
if current_total > 0
diff --git a/tests/test_wireview.py b/tests/test_wireview.py
index 2f2a8fb..28b2591 100644
--- a/tests/test_wireview.py
+++ b/tests/test_wireview.py
@@ -89,6 +89,18 @@ def test_parse_sensor_struct():
"total power",
abs(s["power_total_w"] - round(6 * 11.904 * 6.221, 3)) < 1e-9,
)
+ check(
+ "pin imbalance (uniform)",
+ s["pin_imbalance_a"] == 0.0,
+ )
+ # Imbalance is the spread between the highest- and lowest-loaded pin.
+ uneven = wv.parse_sensor_struct(
+ make_frame(pins=((11904, 6221, 74055),) * 5 + ((11900, 1100, 13090),))
+ )
+ check(
+ "pin imbalance (uneven)",
+ abs(uneven["pin_imbalance_a"] - 5.121) < 1e-9,
+ )
check(
"avg voltage",
abs(s["voltage_avg_v"] - 11.904) < 1e-3,
@@ -296,6 +308,7 @@ def test_hwmon_device():
"hwmon total current",
abs(s["current_total_a"] - round(6 * 6.221, 3)) < 1e-9,
)
+ check("hwmon pin imbalance (uniform)", s["pin_imbalance_a"] == 0.0)
dev.close()
check("hwmon closed", not dev.connected)