diff --git a/packages/app/cypress/component/gpu-metrics-axis.cy.tsx b/packages/app/cypress/component/gpu-metrics-axis.cy.tsx
new file mode 100644
index 000000000..2d7443411
--- /dev/null
+++ b/packages/app/cypress/component/gpu-metrics-axis.cy.tsx
@@ -0,0 +1,117 @@
+import GpuMetricsChart from '@/components/gpu-power/GpuPowerChart';
+import type { GpuMetricRow } from '@/components/gpu-power/types';
+import { HW_REGISTRY } from '@semianalysisai/inferencex-constants';
+import { useState } from 'react';
+
+const svg = () => cy.get('[data-testid="gpu-metrics-chart-svg"]');
+
+function samples(clock: (sample: number) => number): GpuMetricRow[] {
+ return Array.from({ length: 8 }, (_chip, index) =>
+ Array.from({ length: 21 }, (_, sample) => ({
+ timestamp: new Date(Date.UTC(2026, 8, 20) + sample * 30_000).toISOString(),
+ index,
+ power: 300 + sample * 30,
+ memClock: clock(sample),
+ })),
+ ).flat();
+}
+
+function mountClock(data: GpuMetricRow[]) {
+ cy.mount(
+ ,
+ );
+}
+
+function assertReadableAxis(value: number) {
+ svg().should(($svg) => {
+ const labels = $svg.find('.y-axis .tick text').toArray();
+ expect(labels.length, 'multiple useful ticks').to.be.greaterThan(1);
+ const values = labels.map((label) => Number(label.textContent?.replaceAll(',', '')));
+ expect(values.every(Number.isFinite), 'numeric ticks').to.eq(true);
+ expect(new Set(values).size, 'distinct ticks').to.eq(values.length);
+ expect(Math.min(...values)).to.be.at.most(value);
+ expect(Math.max(...values)).to.be.greaterThan(value);
+ const title = $svg.find('.y-axis-label')[0]!.getBoundingClientRect();
+ for (const label of labels) {
+ expect(label.getBoundingClientRect().left, 'tick clears axis title').to.be.greaterThan(
+ title.right,
+ );
+ }
+ });
+ svg().find('path.line-path').should('have.length', 8);
+}
+
+describe('PowerX telemetry axis', () => {
+ for (const width of [1280, 375]) {
+ it(`keeps constant memory clocks readable at ${width}px`, () => {
+ cy.viewport(width, 800);
+ mountClock(samples(() => 390));
+ assertReadableAxis(390);
+ });
+ }
+
+ it('does not magnify floating-point noise after rolling averages', () => {
+ mountClock(samples((sample) => 390 - (sample % 2) * Number.EPSILON * 256));
+ assertReadableAxis(390);
+ });
+
+ it('gives all-zero telemetry a nonzero, nonnegative range', () => {
+ mountClock(samples(() => 0));
+ assertReadableAxis(0);
+ svg().find('.y-axis .tick text').first().should('have.text', '0.0');
+ });
+
+ it('preserves varying power measurements and the hardware TDP reference', () => {
+ cy.mount(
+ 390)}
+ visibleGpus={new Set([0, 1, 2, 3, 4, 5, 6, 7])}
+ metricKey="power"
+ artifactName="gpu_metrics_b200"
+ display={{ mode: 'points', windowS: 300, series: 'chips' }}
+ />,
+ );
+ svg().find('.tdp-line text').should('contain.text', `${HW_REGISTRY.b200.tdp}W`);
+ svg().should(($svg) => {
+ const values = $svg
+ .find('.y-axis .tick text')
+ .toArray()
+ .map((label) => Number(label.textContent?.replaceAll(',', '')));
+ expect(Math.min(...values)).to.be.at.most(300);
+ expect(Math.max(...values)).to.be.at.least(HW_REGISTRY.b200.tdp);
+ });
+ });
+
+ it('removes the power reference when switching to memory clock and restores it once', () => {
+ function SwitchMetric() {
+ const [metric, setMetric] = useState<'power' | 'memClock'>('power');
+ return (
+ <>
+
+ 3996)}
+ visibleGpus={new Set([0, 1, 2, 3, 4, 5, 6, 7])}
+ metricKey={metric}
+ artifactName="gpu_metrics_b200"
+ display={{ mode: 'rolling', windowS: 300, series: 'chips' }}
+ />
+ >
+ );
+ }
+ cy.mount();
+ svg().find('.tdp-line').should('have.length', 1);
+ cy.contains('button', 'Switch metric').click();
+ svg().find('.tdp-line').should('not.exist');
+ assertReadableAxis(3996);
+ cy.contains('button', 'Switch metric').click();
+ svg().find('.tdp-line').should('have.length', 1);
+ });
+});
diff --git a/packages/app/src/components/gpu-power/GpuPowerChart.tsx b/packages/app/src/components/gpu-power/GpuPowerChart.tsx
index a1209ec78..ab5c6c9dc 100644
--- a/packages/app/src/components/gpu-power/GpuPowerChart.tsx
+++ b/packages/app/src/components/gpu-power/GpuPowerChart.tsx
@@ -386,8 +386,14 @@ const GpuMetricsChart = React.memo(
if (allPoints.length === 0) return [0, 100] as [number, number];
const ext = d3.extent(allPoints, (d) => d.value) as [number, number];
const range = ext[1] - ext[0];
- const yMin = Math.max(0, ext[0] - range * 0.05);
- let yMax = ext[1] + range * 0.05;
+ // Constant telemetry and floating-point noise still need a readable axis.
+ const padding = Math.max(
+ range * 0.05,
+ Math.max(Math.abs(ext[0]), Math.abs(ext[1])) * 0.01,
+ 1,
+ );
+ const yMin = Math.max(0, ext[0] - padding);
+ let yMax = ext[1] + padding;
if (tdpInfo && tdpInfo.tdp > yMax) yMax = tdpInfo.tdp * 1.05;
return [yMin, yMax] as [number, number];
}, [allPoints, tdpInfo]);
@@ -419,32 +425,31 @@ const GpuMetricsChart = React.memo(
{
type: 'custom',
key: 'tdp-line',
- render: tdpInfo
- ? (group, ctx) => {
- const yScale = ctx.yScale as d3.ScaleLinear;
- const tdpY = yScale(tdpInfo.tdp);
- group.selectAll('.tdp-line').remove();
- const tdpGroup = group.append('g').attr('class', 'tdp-line');
- tdpGroup
- .append('line')
- .attr('x1', 0)
- .attr('x2', ctx.width)
- .attr('y1', tdpY)
- .attr('y2', tdpY)
- .attr('stroke', '#ef4444')
- .attr('stroke-width', 1.5)
- .attr('stroke-dasharray', '6,4');
- tdpGroup
- .append('text')
- .attr('x', ctx.width - 4)
- .attr('y', tdpY - 6)
- .attr('text-anchor', 'end')
- .attr('fill', '#ef4444')
- .attr('font-size', '11px')
- .attr('font-weight', '600')
- .text(`${tdpInfo.sku} TDP: ${tdpInfo.tdp}W`);
- }
- : null,
+ render: (group, ctx) => {
+ group.selectAll('.tdp-line').remove();
+ if (!tdpInfo) return;
+ const yScale = ctx.yScale as d3.ScaleLinear;
+ const tdpY = yScale(tdpInfo.tdp);
+ const tdpGroup = group.append('g').attr('class', 'tdp-line');
+ tdpGroup
+ .append('line')
+ .attr('x1', 0)
+ .attr('x2', ctx.width)
+ .attr('y1', tdpY)
+ .attr('y2', tdpY)
+ .attr('stroke', '#ef4444')
+ .attr('stroke-width', 1.5)
+ .attr('stroke-dasharray', '6,4');
+ tdpGroup
+ .append('text')
+ .attr('x', ctx.width - 4)
+ .attr('y', tdpY - 6)
+ .attr('text-anchor', 'end')
+ .attr('fill', '#ef4444')
+ .attr('font-size', '11px')
+ .attr('font-weight', '600')
+ .text(`${tdpInfo.sku} TDP: ${tdpInfo.tdp}W`);
+ },
},
// GPU lines
{