diff --git a/content/_data/constants.yml b/content/_data/constants.yml index 2d4f4d550504..38b8a55ee87c 100644 --- a/content/_data/constants.yml +++ b/content/_data/constants.yml @@ -136,6 +136,20 @@ cookie_consent: - link: "https://qx.vtt.fi/docs/devices/q50.html" teaser: "Read more about VTT Q50 (VTT website)" icon: mdiOpenInNew + + - name: EuroHPC VLQ + desc: |- + EuroHPC VLQ is a superconducting IQM quantum computer with 24 qubits in a star topology. It is operated by IT4I. + image: "/assets/images/vlq-images/vlq-image.jpg" + links: + - link: "/resource-call" + teaser: "Apply for access to EuroHPC VLQ" + - link: "https://docs.csc.fi/computing/quantum-computing/running-quantum-jobs/" + teaser: "How to access EuroHPC VLQ, instructions" + icon: mdiArrowRight + - link: "https://docs.it4i.cz/en/docs/clusters/vlq/introduction" + teaser: "Read more about EuroHPC VLQ (IT4I website)" + icon: mdiOpenInNew - name: VTT Q5 (Helmi) - End of life desc: |- @@ -256,6 +270,9 @@ cookie_consent: Aalto Q20 access is currently being rolled out! See the CSC Quantum Computing docs for more info. link: "https://docs.csc.fi/computing/quantum-computing/overview/" type: info + - text: |- + Access to EuroHPC VLQ is coming soon! + type: info quantum-computers: - name: VTT Q50 qubits: 53 @@ -271,6 +288,13 @@ cookie_consent: pulse: "True" device_id: "Q20" + - name: VLQ + qubits: 24 + basis: "PRX, CZ, MOVE" + topology: "Star" + pulse: "True" + device_id: "VLQ" + "/cookies/": title: Cookie Policy desc: |- diff --git a/content/assets/images/vlq-images/vlq-image.jpg b/content/assets/images/vlq-images/vlq-image.jpg new file mode 100644 index 000000000000..65a78709ead2 Binary files /dev/null and b/content/assets/images/vlq-images/vlq-image.jpg differ diff --git a/src/components/Loading.jsx b/src/components/Loading.jsx new file mode 100644 index 000000000000..3b76b2e2cf3b --- /dev/null +++ b/src/components/Loading.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { CSpinner } from '@cscfi/csc-ui-react' + +/** + * Stand-in for the Online/Offline status pill. Keeps the pill's height so the + * card doesn't jump when the healthcheck resolves. + */ +export const StatusPillLoading = () => ( +
+

Loading...

+
+) + +/** Centered spinner for a panel whose data hasn't arrived yet. */ +export const LoadingBlock = (props) => ( +
+ +

{props.label || 'Loading…'}

+
+) + +/** Shown when a fetch failed, in the same slot the LoadingBlock occupied. */ +export const ErrorBlock = (props) => ( +
+

{props.label || 'Data is currently unavailable.'}

+
+) diff --git a/src/components/QcLayouts/QcLayout.jsx b/src/components/QcLayouts/QcLayout.jsx index 6e211bb2eed3..502f7bc275eb 100644 --- a/src/components/QcLayouts/QcLayout.jsx +++ b/src/components/QcLayouts/QcLayout.jsx @@ -16,10 +16,14 @@ const NODE_UNITS = 90; const MAX_NODE_PX = 48; export function QcLayout({ layout, metrics }) { - const { spacing, nodes, edges } = layout; + const { spacing, nodes, edges, resonator } = layout; + // Star layouts (with a central resonator) render qubits as upright squares; + // lattice layouts render them as 45°-rotated diamonds. + const nodeRotation = resonator ? 0 : 45; const { calibrationData, qubitMetric, couplerMetric, - qubitMetricFormatted, couplerMetricFormatted, thresholdQubit, thresholdCoupler, + qubitMetricFormatted, couplerMetricFormatted, + thresholdQubit, thresholdCoupler, } = metrics; const [hoveredNode, setHoveredNode] = useState(null); @@ -41,16 +45,26 @@ export function QcLayout({ layout, metrics }) { return entry?.unit || ''; }; - // Resolve a coupler key from the two endpoints (try both orderings) + // Resolve a coupler key from the two endpoints (try both orderings). const couplerKey = (a, b) => { if (getMetricValue(couplerMetric, `${a}__${b}`) !== null) return `${a}__${b}`; - return `${b}__${a}`; + if (getMetricValue(couplerMetric, `${b}__${a}`) !== null) return `${b}__${a}`; + // Star MOVE/CZ gates routed through the resonator to a fixed anchor qubit + // are keyed QBx__RES__QBanchor. The edge is (QBx, RES): find the matching + // key so the gate's value renders on that edge (and shows on hover). + if (resonator && calibrationData?.[couplerMetric]) { + const qubit = a === resonator.id ? b : a; + const prefix = `${qubit}__${resonator.id}__`; + const match = Object.keys(calibrationData[couplerMetric]).find(k => k.startsWith(prefix)); + if (match) return match; + } + return `${a}__${b}`; }; - // Color for a metric value. dim: 1 = qubit, 2 = coupler. + // Color for a metric value. dim: 1 = qubit, 2 = coupler, 3 = resonator. const getColor = (metric, id, dim, threshold) => { - // No metric selected: qubits get the brand blue, couplers a light grey - if (!metric || metric === '') return dim === 1 ? DEFAULT_NODE_COLOR : '#aaa'; + // No metric selected: qubits/resonator get the brand blue, couplers a light grey + if (!metric || metric === '') return dim === 2 ? '#aaa' : DEFAULT_NODE_COLOR; if (!calibrationData || !calibrationData[metric]) return GREY; const value = getMetricValue(metric, id); @@ -108,8 +122,6 @@ export function QcLayout({ layout, metrics }) { if (!containerRef.current || !tooltip) return { left: 0, top: 0 }; const containerRect = containerRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; let tooltipWidth = 300; // rough estimate let tooltipHeight = 150; // rough estimate @@ -119,26 +131,30 @@ export function QcLayout({ layout, metrics }) { tooltipHeight = 70; // rough estimate } - // Calculate absolute position on screen - const absoluteX = containerRect.left + mousePos.x; - const absoluteY = containerRect.top + mousePos.y; - let left = mousePos.x + 8; let top = mousePos.y + 8; - // Check right boundary against viewport - if (absoluteX + 8 + tooltipWidth > viewportWidth) { - left = mousePos.x - tooltipWidth - 8; // Position to the left of cursor + // Horizontal: keep the tooltip's right edge within the layout area so it + // never spills past the right edge. If it would, flip it to the left of + // the cursor; never let it run off the left of the viewport. + const maxLeft = containerRect.width - tooltipWidth - 8; // relative to container + if (left > maxLeft) { + left = mousePos.x - tooltipWidth - 8; // flip to the left of the cursor } - - // Check bottom boundary against viewport - if (absoluteY + 8 + tooltipHeight > viewportHeight) { - top = mousePos.y - tooltipHeight - 8; // Position above cursor + const minLeft = 8; // keep the tooltip's left edge inside the layout area + left = Math.min(left, maxLeft); + left = Math.max(left, minLeft); + + // Vertical: keep the tooltip's bottom within the layout area so it never + // spills past the bottom edge. If it would, flip it above the cursor; let + // it extend upward (the modal has room above) but never above the viewport. + const maxTop = containerRect.height - tooltipHeight - 8; // relative to container + if (top > maxTop) { + top = mousePos.y - tooltipHeight - 8; // flip above the cursor } - - // Ensure tooltip stays within container bounds - left = Math.max(8, Math.min(left, containerRect.width - tooltipWidth - 8)); - top = Math.max(8, Math.min(top, containerRect.height - tooltipHeight - 8)); + const minTop = 8; // keep the tooltip's top edge inside the layout area + top = Math.min(top, maxTop); + top = Math.max(top, minTop); return { left, top }; }; @@ -146,27 +162,50 @@ export function QcLayout({ layout, metrics }) { // Build coordinate map directly from nodes const coordMap = Object.fromEntries(nodes.map(n => [n.id, n])); - // Compute dynamic bounds + // Resolve an edge's two endpoints. A star-layout edge connects a qubit to the + // central resonator: that endpoint isn't a node, so it maps to the point on the + // resonator bar directly above/below the qubit (a vertical coupler). + const endpoint = (id, other) => { + if (resonator && id === resonator.id) return { x: coordMap[other].x, y: resonator.y }; + return coordMap[id]; + }; + + // Compute dynamic bounds (include the resonator bar if present) const xs = nodes.map(n => n.x); const ys = nodes.map(n => n.y); - const minX = Math.min(...xs) - spacing; - const maxX = Math.max(...xs) + spacing; - const minY = Math.min(...ys) - spacing; - const maxY = Math.max(...ys) + spacing; + if (resonator) { + xs.push(resonator.x1, resonator.x2); + ys.push(resonator.y); + } + // Margin around the outermost nodes. Star layouts pack tightly (upright + // squares), so they only need a node-sized margin; lattice layouts keep a + // full spacing of breathing room. + const margin = resonator ? NODE_UNITS * 0.75 : spacing; + const minX = Math.min(...xs) - margin; + const maxX = Math.max(...xs) + margin; + const minY = Math.min(...ys) - margin; + const maxY = Math.max(...ys) + margin; const viewBoxWidth = maxX - minX; const viewBoxHeight = maxY - minY; const viewBox = `${minX} ${-maxY} ${viewBoxWidth} ${viewBoxHeight}`; // Cap the rendered size so an SVG unit never maps to more pixels than // MAX_NODE_PX / NODE_UNITS — i.e. nodes/couplers stay the same on-screen size - // across layouts. Use the larger viewBox dimension so neither axis overshoots. - const maxWidth = (MAX_NODE_PX / NODE_UNITS) * Math.max(viewBoxWidth, viewBoxHeight); + // across layouts. The container matches the viewBox aspect ratio so a wide, + // short layout (e.g. the star) isn't padded out to a tall square. + const pxPerUnit = MAX_NODE_PX / NODE_UNITS; + const maxRenderWidth = pxPerUnit * viewBoxWidth; + const maxRenderHeight = pxPerUnit * viewBoxHeight; return (
{ setHoveredNode(null); setHoveredEdge(null); setTooltip(null); }} > @@ -206,11 +245,10 @@ export function QcLayout({ layout, metrics }) { viewBox={viewBox} preserveAspectRatio="xMidYMid meet" className="w-full h-full" - style={{ maxWidth: `${maxWidth}px`, maxHeight: `${maxWidth}px` }} - > + > {edges.map(([a, b]) => { - const A = coordMap[a]; - const B = coordMap[b]; + const A = endpoint(a, b); + const B = endpoint(b, a); const key = `${a}-${b}`; const hover = hoveredEdge === key; const edgeColor = getColor(couplerMetric, couplerKey(a, b), 2, thresholdCoupler); @@ -227,13 +265,53 @@ export function QcLayout({ layout, metrics }) { transition={{ type: 'spring', stiffness: 300, damping: 20 }} onMouseEnter={() => { setHoveredEdge(key); - setTooltip(buildTooltip('edge', couplerMetric, couplerKey(a, b), couplerMetricFormatted, `Coupler: ${a}__${b}`)); + setTooltip(buildTooltip('edge', couplerMetric, couplerKey(a, b), couplerMetricFormatted, `Coupler: ${couplerKey(a, b)}`)); }} onMouseLeave={() => { setHoveredEdge(null); setTooltip(null); }} className={hover ? 'cursor-pointer filter drop-shadow-md' : 'cursor-pointer'} /> ); })} + {resonator && (() => { + const hover = hoveredEdge === resonator.id; + const barHeight = 90; + // The resonator has no dropdown of its own: its T1/T2 share the + // qubit keys, so the bar follows the selected qubit metric. + const barColor = getColor(qubitMetric, resonator.id, 1, thresholdQubit); + return ( + { + setHoveredEdge(resonator.id); + setTooltip(buildTooltip('resonator', qubitMetric, resonator.id, qubitMetricFormatted, `Resonator: ${resonator.id}`)); + }} + onMouseLeave={() => { setHoveredEdge(null); setTooltip(null); }} + className={hover ? 'cursor-pointer filter drop-shadow-lg' : 'cursor-pointer'} + > + + {resonator.id} + + ); + })()} {nodes.map(n => { const hover = hoveredNode === n.id; const nodeColor = getColor(qubitMetric, n.id, 1, thresholdQubit); @@ -250,7 +328,7 @@ export function QcLayout({ layout, metrics }) { onMouseLeave={() => { setHoveredNode(null); setTooltip(null); }} className={hover ? 'cursor-pointer filter drop-shadow-lg' : 'cursor-pointer'} > - + {n.id} diff --git a/src/components/QcLayouts/layouts.js b/src/components/QcLayouts/layouts.js index 37b8aeb25ddf..8cd187b2900f 100644 --- a/src/components/QcLayouts/layouts.js +++ b/src/components/QcLayouts/layouts.js @@ -3,6 +3,14 @@ // To add a new QC: add one entry to QC_LAYOUTS keyed by the lowercased device_id, // with { spacing, nodes, edges }. No new component or call-site change is needed. // +// Two topology shapes are supported: +// 1. Lattice — qubits coupled directly to neighbouring qubits. `edges` is a list +// of [qubitA, qubitB] pairs (see Q20/Q50). +// 2. Star — every qubit couples to a single central resonator providing +// one-to-all connectivity. Add a `resonator` descriptor and make +// each edge a [qubit, resonator.id] pair. The resonator renders as a central +// bar and each edge as a coupler from its qubit to that bar. +// // Nodes are positioned on a diagonal grid. `grid(xOrigin, yOrigin, spacing)` returns // a helper `(id, col, row) => { id, x, y }` so coordinates read as grid offsets instead // of repeated `xOrigin + spacing * N` arithmetic. @@ -88,7 +96,46 @@ const q50Edges = [ ['QB2', 'QB1'], ]; +// --- VLQ (24-qubit IQM star) ---------------------------------------------- +// One central computational resonator with every qubit coupled to it, giving +// one-to-all connectivity. Qubits sit in two rows above and below the resonator: +// +// O O O ... (12 qubits) +// | | | +// =========== <- central resonator +// | | | +// O O O ... (12 qubits) +// +// `RESONATOR_ID` must match the resonator's name in the calibration coupler keys +// (e.g. "QB1__COMPR1") and in any resonator-level metrics. It is also shown as +// the label on the resonator bar. Change it here if the backend uses another name. +const RESONATOR_ID = 'COMPR1'; +const vlqSpacing = 140; +const vlqCols = 12; // 12 qubits per row, 24 total +const vlqDiamond = vlqSpacing * 0.45; // half a node's diagonal, for bar overlap + +// Top row QB1..QB12 (left→right), bottom row QB13..QB24 (left→right). +const vlqNodes = [ + ...Array.from({ length: vlqCols }, (_, col) => ({ + id: `QB${col + 1}`, x: col * vlqSpacing, y: vlqSpacing, + })), + ...Array.from({ length: vlqCols }, (_, col) => ({ + id: `QB${col + 1 + vlqCols}`, x: col * vlqSpacing, y: -vlqSpacing, + })), +]; +// Central resonator bar, spanning slightly past the outermost qubit columns. +const vlqResonator = { + id: RESONATOR_ID, + label: 'Computational Resonator', + y: 0, + x1: -vlqDiamond, + x2: (vlqCols - 1) * vlqSpacing + vlqDiamond, +}; +// Every qubit couples to the resonator. +const vlqEdges = vlqNodes.map(n => [n.id, RESONATOR_ID]); + export const QC_LAYOUTS = { q20: { spacing: q20Spacing, nodes: q20Nodes, edges: q20Edges }, q50: { spacing: q50Spacing, nodes: q50Nodes, edges: q50Edges }, + vlq: { spacing: vlqSpacing, nodes: vlqNodes, edges: vlqEdges, resonator: vlqResonator }, }; diff --git a/src/components/ServiceStatus.jsx b/src/components/ServiceStatus.jsx index d369fd6956d2..0d1e7d596a6b 100644 --- a/src/components/ServiceStatus.jsx +++ b/src/components/ServiceStatus.jsx @@ -2,12 +2,13 @@ import React, { useCallback, useMemo, useState } from 'react' import { useStatus } from '../hooks/useStatus' import { useBookings } from '../hooks/useBookings.jsx'; -import { mdiInformation, mdiClose, mdiAlert } from '@mdi/js'; +import { mdiInformation, mdiClose, mdiAlert, mdiRefresh } from '@mdi/js'; import { CCard, CCardTitle, CCardContent, CIcon, CButton, CSelect } from '@cscfi/csc-ui-react'; import { StatusModal } from './StatusModal/StatusModal'; import { BookingModal } from './bookingCalendar.jsx'; import { API_BASE_URL } from '../config/api.js'; import { AlertBanner } from './AlertBanner.jsx'; +import { StatusPillLoading } from './Loading.jsx'; const StatusCard = (props) => { const isOnline = props.health; @@ -27,7 +28,9 @@ const StatusCard = (props) => {
Service status: - {isOnline ? ( + {props.statusLoading ? ( + + ) : isOnline ? (

Online

@@ -43,7 +46,7 @@ const StatusCard = (props) => { } export const ServiceStatus = (props) => { - const { status: statusList } = useStatus(`${API_BASE_URL}/devices/healthcheck`); + const { status: statusList, loading: statusLoading, refetch: refetchStatus } = useStatus(`${API_BASE_URL}/devices/healthcheck`); const { bookingData: bookingData } = useBookings(`${API_BASE_URL}/bookings`) const qcs = props["quantum-computers"] || []; @@ -137,11 +140,20 @@ export const ServiceStatus = (props) => { onChangeValue={handleSortChange} key={`device-sort`} /> + + Refresh + +
{sortedDevices.map((qc, index) => ( - handleCardClick(qc)} /> + handleCardClick(qc)} /> ))} @@ -153,7 +165,13 @@ export const ServiceStatus = (props) => { )} {modalOpen && ( - + )} diff --git a/src/components/StatusModal/DeviceStatus.jsx b/src/components/StatusModal/DeviceStatus.jsx index d14eefc742fe..c420a2410ed2 100644 --- a/src/components/StatusModal/DeviceStatus.jsx +++ b/src/components/StatusModal/DeviceStatus.jsx @@ -1,8 +1,9 @@ import React from 'react'; +import { StatusPillLoading } from '../Loading'; export const DeviceStatus = (props) => { - const { deviceData, devicesWithStatus } = props; + const { deviceData, devicesWithStatus, statusLoading } = props; return ( <> @@ -10,11 +11,14 @@ export const DeviceStatus = (props) => {

Qubits: {deviceData.qubits}

Basis gates: {deviceData.basis}

Topology: {deviceData.topology}

+

Pulse access: {deviceData.pulse === "True" ? "Yes" : "No"}

Service status: - {devicesWithStatus.find(d => d.device_id === deviceData.device_id)?.health ? ( + {statusLoading ? ( + + ) : devicesWithStatus.find(d => d.device_id === deviceData.device_id)?.health ? (

Online

diff --git a/src/components/StatusModal/MetricSwitcher.jsx b/src/components/StatusModal/MetricSwitcher.jsx index 20a82db77e15..d63946f38215 100644 --- a/src/components/StatusModal/MetricSwitcher.jsx +++ b/src/components/StatusModal/MetricSwitcher.jsx @@ -9,7 +9,8 @@ import {mdiHelpCircleOutline} from "@mdi/js" export const MetricSwitcher = (props) => { const { metricsState, updateMetricsState, calibrationData, - qubitMetricOptions, couplerMetricOptions, qubitInputValue, setQubitInputValue, + qubitMetricOptions, couplerMetricOptions, + qubitInputValue, setQubitInputValue, couplerInputValue, setCouplerInputValue } = props; return ( @@ -185,14 +186,24 @@ export const MetricSwitcher = (props) => { className='py-2' clearable value={metricsState.couplerMetric} - items={couplerMetricOptions} placeholder='Choose metric' onChangeValue={(e) => { updateMetricsState('couplerMetric', e.detail || ''); updateMetricsState('thresholdCoupler', 0); setCouplerInputValue('0'); }} - /> + > + {couplerMetricOptions.map(option => + + {option.name} + + )} +
diff --git a/src/components/StatusModal/RawDataSwitcher.jsx b/src/components/StatusModal/RawDataSwitcher.jsx index 1257008e7cfd..9f0a04380316 100644 --- a/src/components/StatusModal/RawDataSwitcher.jsx +++ b/src/components/StatusModal/RawDataSwitcher.jsx @@ -5,7 +5,8 @@ import { getCurrentRawData, copyToClipboard, downloadRawData } from '../../utils export const RawDataSwitcher = (props) => { const { viewState, updateViewState, metricsState, updateMetricsState, - calibrationDataAll, deviceInfoData, deviceData, copySuccess, setCopySuccess } = props; + calibrationDataAll, deviceInfoData, deviceData, copySuccess, setCopySuccess, + dataLoading } = props; // Copy to clipboard function const handleCopyToClipboard = async () => { @@ -75,6 +76,7 @@ export const RawDataSwitcher = (props) => { {copySuccess ? 'Copied!' : 'Copy'} @@ -82,6 +84,7 @@ export const RawDataSwitcher = (props) => { Download diff --git a/src/components/StatusModal/SideBar.jsx b/src/components/StatusModal/SideBar.jsx index 8daa3a42af31..258e61b0b245 100644 --- a/src/components/StatusModal/SideBar.jsx +++ b/src/components/StatusModal/SideBar.jsx @@ -3,6 +3,7 @@ import { DeviceStatus } from './DeviceStatus'; import { MetricSwitcher } from './MetricSwitcher'; import { RawDataSwitcher } from './RawDataSwitcher'; import { getMetricStatistics } from '../../utils/sidebarUtils'; +import { LoadingBlock, ErrorBlock } from '../Loading'; export const SideBar = (props) => { const { @@ -14,6 +15,10 @@ export const SideBar = (props) => { calibrationDataAll, deviceInfoData, devicesWithStatus, + statusLoading, + calibrationLoading, + calibrationError, + dataLoading, qubitMetricOptions, couplerMetricOptions, deviceData, @@ -69,27 +74,34 @@ export const SideBar = (props) => { return (
- + - {(activeTab === "layout" || activeTab === "graphical") && - - Object.prototype.hasOwnProperty.call(calibrationData ?? {}, opt.value) - )} - couplerMetricOptions={couplerMetricOptions.filter(opt => - Object.prototype.hasOwnProperty.call(calibrationData ?? {}, opt.value) - )} - qubitInputValue={qubitInputValue} - setQubitInputValue={setQubitInputValue} - couplerInputValue={couplerInputValue} - setCouplerInputValue={setCouplerInputValue} - /> - } + {(activeTab === "layout" || activeTab === "graphical") && ( + calibrationLoading ? ( + + ) : calibrationError ? ( + + ) : ( + + Object.prototype.hasOwnProperty.call(calibrationData ?? {}, opt.value) + )} + couplerMetricOptions={couplerMetricOptions.filter(opt => + Object.prototype.hasOwnProperty.call(calibrationData ?? {}, opt.value) + )} + qubitInputValue={qubitInputValue} + setQubitInputValue={setQubitInputValue} + couplerInputValue={couplerInputValue} + setCouplerInputValue={setCouplerInputValue} + /> + ) + )} {activeTab === "raw" && { - const { calibrationData: calibrationDataAll, calibrationError } = useCalibration(`${API_BASE_URL}/device/${props.device_id.toLowerCase()}/calibration`) - const { deviceInfo: deviceInfoData, infoError } = useDeviceInfo(`${API_BASE_URL}/device/${props.device_id.toLowerCase()}`) + const { + calibrationData: calibrationDataAll, + error: calibrationError, + loading: calibrationLoading, + } = useCalibration(`${API_BASE_URL}/device/${props.device_id.toLowerCase()}/calibration`) + const { + deviceInfo: deviceInfoData, + error: infoError, + loading: infoLoading, + } = useDeviceInfo(`${API_BASE_URL}/device/${props.device_id.toLowerCase()}`) + + // The tab bodies mix both sources, so they wait on either fetch. + const dataLoading = calibrationLoading || infoLoading; + const dataError = calibrationError || infoError; const [activeTab, setActiveTab] = useState('overview'); @@ -48,14 +61,28 @@ export const ModalContent = (props) => { }; const calibrationData = calibrationDataAll.metrics - const lastCalibrated = new Date(calibrationDataAll.quality_metric_set_end_timestamp) + const lastCalibratedDate = new Date(calibrationDataAll.quality_metric_set_end_timestamp) + // Guard against "Invalid Date" while the fetch is in flight or when the + // endpoint returns no timestamp. + const lastCalibrated = Number.isNaN(lastCalibratedDate.getTime()) + ? null + : lastCalibratedDate.toLocaleString() - const deviceName = deviceInfoData?.name || ''; + // Prefer the canonical name from the device info endpoint, but fall back to + // the device_id (e.g. "vlq") when it's unavailable — getDeviceMetricsConfig + // matches case-insensitively. + const deviceName = deviceInfoData?.name || props.device_id || ''; const deviceConfig = getDeviceMetricsConfig(deviceName); const qubitMetricOptions = deviceConfig.qubitOptions || getDeviceMetricsConfig('default').qubitOptions; const couplerMetricOptions = deviceConfig.couplerOptions || getDeviceMetricsConfig('default').couplerOptions; + // Star layouts carry a central `resonator`. Its only calibration-tracked + // metrics (T1/T2) share the qubit keys, so they live in the qubit dropdown + // and the resonator bar follows the selected qubit metric — no separate + // resonator dropdown. + const layout = QC_LAYOUTS[props.device_id.toLowerCase()]; + return ( {props.name} @@ -70,6 +97,10 @@ export const ModalContent = (props) => { calibrationDataAll={calibrationDataAll} deviceInfoData={deviceInfoData} devicesWithStatus={props.devicesWithStatus} + statusLoading={props.statusLoading} + calibrationLoading={calibrationLoading} + calibrationError={calibrationError} + dataLoading={dataLoading} qubitMetricOptions={qubitMetricOptions} couplerMetricOptions={couplerMetricOptions} deviceData={{ ...props }} @@ -91,17 +122,29 @@ export const ModalContent = (props) => { - + {dataLoading ? ( + + ) : ( + // Overview reports per-section availability itself, so a + // failure in one fetch doesn't hide what the other returned. + + )}
- {QC_LAYOUTS[props.device_id.toLowerCase()] ? ( + {calibrationLoading ? ( + + ) : calibrationError ? ( + + ) : layout ? ( {
- {viewState.rawDataType.value === 'calibration_data' && viewState.tableView ? ( + {dataLoading ? ( + + ) : dataError ? ( + + ) : viewState.rawDataType.value === 'calibration_data' && viewState.tableView ? ( {
-

Last calibrated: {lastCalibrated.toLocaleString()}

+

+ Last calibrated:{' '} + {calibrationLoading ? 'Loading…' : lastCalibrated || 'Not available'} +

props.setIsModalOpen(false)} text>Close
diff --git a/src/components/StatusModal/StatusOverview.jsx b/src/components/StatusModal/StatusOverview.jsx index e89373cf4aee..e48f94e475f4 100644 --- a/src/components/StatusModal/StatusOverview.jsx +++ b/src/components/StatusModal/StatusOverview.jsx @@ -15,84 +15,133 @@ const parseResultMedian = (data, unit) => { } } +const Section = (props) => ( +
+

{props.title}

+ {props.children} +
+
+) + +const MetricRows = (props) => ( + <> + {props.metrics.map((metric) => ( +
+

+ {metric.title}: + {metric.value} + {metric.unit === 's' ? <> μs : metric.unit ? metric.unit : '%'} + +

+
+ ))} + +) + +const Unavailable = () =>

Not available

+ export const Overview = (props) => { const calibrationData = props.calibrationData; const deviceInfoData = props.deviceInfoData; const limitationsData = deviceInfoData?.job_policy || {}; - const deviceName = deviceInfoData?.name || ''; + const deviceName = deviceInfoData?.name || props.device_id || ''; const { overview } = getDeviceMetricsConfig(deviceName); - const limitations = { - "Max circuits per batch": limitationsData?.max_number_circuits_per_batch || "-", - "Max shots per job": limitationsData?.max_number_shots_per_job || "-", - "Max jobs in queue": limitationsData?.max_queue_length || "-" - } + // Distinguishes "the fetch gave us nothing" from "this device doesn't track + // that metric" — the former gets a message, the latter is simply omitted. + const calibrationUnavailable = !!props.calibrationError + || !calibrationData + || Object.keys(calibrationData).length === 0; + const limitationsUnavailable = !!props.infoError || !deviceInfoData; - const buildMetric = (title, keys) => { + const buildMetric = (key, title, keys) => { const entry = pickMetricData(calibrationData, keys); const unit = getMetricUnit(entry?.data) || '%'; return { + key, title, value: parseResultMedian(entry?.data?.statistics, unit), unit, }; }; - const qualityMetricsSingle = { - "median-prx": buildMetric('Median single-qubit gate fidelity', overview?.single?.singleGateFidelity), - "median-readout-fidelity": buildMetric('Median readout fidelity', overview?.single?.readoutFidelity), - "median-t1": buildMetric('Median T1 time', overview?.single?.t1), - "median-t2": buildMetric('Median T2 time', overview?.single?.t2), - }; + const qubitMetrics = [ + buildMetric('median-prx', 'Median single-qubit gate fidelity', overview?.single?.singleGateFidelity), + buildMetric('median-readout-fidelity', 'Median readout fidelity', overview?.single?.readoutFidelity), + buildMetric('median-t1', 'Median T1 time', overview?.single?.t1), + buildMetric('median-t2', 'Median T2 time', overview?.single?.t2), + ].filter(metric => metric.value !== null); - const qualityMetricsTwo = { - "median-2-qubit": buildMetric('Median 2-qubit gate fidelity', overview?.coupler?.twoQubitFidelity), - "median-clifford": buildMetric('Median Clifford gate fidelity', overview?.coupler?.cliffordFidelity), - }; + const couplerMetrics = [ + buildMetric('median-2-qubit', 'Median 2-qubit gate fidelity', overview?.coupler?.twoQubitFidelity), + buildMetric('median-clifford', 'Median Clifford gate fidelity', overview?.coupler?.cliffordFidelity), + ].filter(metric => metric.value !== null); - return ( -
-
-

Qubit Metrics:

- {Object.entries(qualityMetricsSingle).filter(([key, metric]) => metric.value !== null).map(([key, metric]) => ( -
-

- {metric.title}: - {metric.value} - {metric.unit === 's' ? <> μs : metric.unit ? metric.unit : '%'} - -

-
- ))} -
+ const limitations = [ + { key: 'max-circuits', title: 'Max circuits per batch', value: limitationsData?.max_number_circuits_per_batch }, + { key: 'max-shots', title: 'Max shots per job', value: limitationsData?.max_number_shots_per_job }, + { key: 'max-queue', title: 'Max jobs in queue', value: limitationsData?.max_queue_length }, + ].filter(limitation => limitation.value !== null && limitation.value !== undefined); -
-
-

Coupler Metrics:

- {Object.entries(qualityMetricsTwo).map(([key, metric]) => ( -
-

- {metric.title}: - {metric.value} - {metric.unit === 's' ? <> μs : metric.unit ? metric.unit : '%'} - -

-
- ))} -
+ // Each section is included only when it has something to say: either real + // values, or a note that the data it depends on couldn't be fetched. + const sections = []; -
-
-

Limitations:

- {Object.entries(limitations).map(([key, value]) => ( -
-

{key}:

-

{value}

+ if (calibrationUnavailable) { + sections.push( +
+ +
+ ); + } else { + if (qubitMetrics.length > 0) { + sections.push( +
+ +
+ ); + } + if (couplerMetrics.length > 0) { + sections.push( +
+ +
+ ); + } + } + + if (limitationsUnavailable) { + sections.push( +
+ +
+ ); + } else if (limitations.length > 0) { + sections.push( +
+ {limitations.map(limitation => ( +
+

{limitation.title}:

+

{limitation.value}

))} +
+ ); + } + + if (sections.length === 0) { + return ( +
+

No device data is currently available.

+ ) + } + + return ( +
+ {sections}
) -} \ No newline at end of file +} diff --git a/src/config/deviceMetrics.js b/src/config/deviceMetrics.js index 9a9ca09818b3..a10c9ae033b2 100644 --- a/src/config/deviceMetrics.js +++ b/src/config/deviceMetrics.js @@ -107,6 +107,53 @@ export const DEVICE_METRICS = { ], }, + // IQM 24-qubit star: every qubit couples to one central computational + // resonator (COMPR1). Two-qubit gates and MOVE gates are expressed as + // qubit<->resonator (QBxx__COMPR1) couplers. Resonator-level metrics + // (t1_time / t2_time with a COMPR1 entry) are derived automatically from + // the calibration data via the layout's resonator id — see StatusModalConent. + VLQ: { + overview: { + single: { + singleGateFidelity: ['prx_rb_drag_crf_fidelity', 'prx_rb_fidelity'], + readoutFidelity: ['measure_fidelity_ssro_constant_fidelity', 'measure_ssro_fidelity'], + t1: ['t1_time'], + t2: ['t2_time'], + }, + coupler: { + twoQubitFidelity: ['cz_irb_crf_crf_fidelity', 'cz_irb_fidelity'], + cliffordFidelity: ['clifford_rb_xy_move_cz_fidelity', 'clifford_rb_fidelity'], + }, + }, + qubitOptions: [ + { name: 'T1 Time', value: 't1_time' }, + { name: 'T2 Time', value: 't2_time' }, + { name: 'T2 Echo Time', value: 't2_echo_time' }, + { name: 'PRX Gate Fidelity', value: 'prx_rb_drag_crf_fidelity' }, + { name: 'Clifford Gate Fidelity', value: 'clifford_rb_xy_fidelity' }, + { name: '1->0 Readout Error', value: 'measure_fidelity_ssro_constant_error_1_to_0' }, + { name: '0->1 Readout Error', value: 'measure_fidelity_ssro_constant_error_0_to_1' }, + { name: 'Readout Fidelity', value: 'measure_fidelity_ssro_constant_fidelity' }, + { name: '1->0 MCM Error', value: 'measure_ssro_constant_error_1_to_0', title: 'MCM = Mid Circuit Measurement' }, + { name: '0->1 MCM Error', value: 'measure_ssro_constant_error_0_to_1', title: 'MCM = Mid Circuit Measurement' }, + { name: 'MCM Fidelity', value: 'measure_ssro_constant_fidelity', title: 'MCM = Mid Circuit Measurement' }, + { name: 'QNDness Fidelity', value: 'measure_qndness_constant_fidelity', title: 'QND = Quantum Non-Demolition' }, + { name: 'QNDness 0 State', value: 'measure_qndness_constant_qndness_0', title: 'QND = Quantum Non-Demolition' }, + { name: 'QNDness 1 State', value: 'measure_qndness_constant_qndness_1', title: 'QND = Quantum Non-Demolition' }, + { name: 'QNDness Repeatability', value: 'measure_qndness_constant_repeatability', title: 'QND = Quantum Non-Demolition' }, + ], + // The last three are qubit<->resonator<->qubit gates keyed + // QBx__COMPR1__QBanchor. They are rendered on the QBx__COMPR1 edge (see + // QcLayout's coupler-key resolver), colored by the QBx__COMPR1__QBanchor value. + couplerOptions: [ + { name: 'CZ Gate Fidelity', value: 'cz_irb_crf_crf_fidelity' }, + { name: 'MOVE Gate Fidelity', value: 'move_irb_crf_crf_fidelity' }, + { name: 'Move CZ 0', value: 'move_ncz_irb_fidelity_0' }, + { name: 'Move CZ 1', value: 'move_ncz_irb_fidelity_1' }, + { name: 'Move Clifford', value: 'clifford_rb_xy_move_cz_fidelity' }, + ], + }, + Q5: { overview: { single: { @@ -136,8 +183,15 @@ export const DEVICE_METRICS = { }, }; -export const getDeviceMetricsConfig = (deviceName = '') => - DEVICE_METRICS[deviceName] || DEVICE_METRICS.default; +export const getDeviceMetricsConfig = (deviceName = '') => { + if (DEVICE_METRICS[deviceName]) return DEVICE_METRICS[deviceName]; + // Fall back to a case-insensitive match so a device_id like "vlq" resolves the + // "VLQ" config (the /device info endpoint that supplies the canonical name is + // not always available). + const key = Object.keys(DEVICE_METRICS) + .find(k => k.toLowerCase() === String(deviceName).toLowerCase()); + return (key && DEVICE_METRICS[key]) || DEVICE_METRICS.default; +}; export const pickMetricData = (calibrationData, keys = []) => { for (const key of keys) { diff --git a/src/hooks/useCalibration.jsx b/src/hooks/useCalibration.jsx index 7332daa52498..036484189ad9 100644 --- a/src/hooks/useCalibration.jsx +++ b/src/hooks/useCalibration.jsx @@ -3,22 +3,27 @@ import { useEffect, useState } from 'react' export const useCalibration = (calibrationUrl) => { const [calibrationData, setCalibrationData] = useState([]) const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { const fetchCalibration = async () => { const url = calibrationUrl; + setLoading(true); try { const resp = await fetch (url); const result = await resp.json(); setCalibrationData(result?.data || []); + setError(null); } catch (err) { console.error(err); setError(err); + } finally { + setLoading(false); } } fetchCalibration(); }, [calibrationUrl]); - return { calibrationData, error }; + return { calibrationData, error, loading }; } diff --git a/src/hooks/useDeviceInfo.jsx b/src/hooks/useDeviceInfo.jsx index cf0d324dc5f2..897d4abf58da 100644 --- a/src/hooks/useDeviceInfo.jsx +++ b/src/hooks/useDeviceInfo.jsx @@ -3,22 +3,27 @@ import { useEffect, useState } from 'react' export const useDeviceInfo = (deviceInfoUrl) => { const [deviceInfo, setDeviceInfo] = useState(null); const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { const fetchDeviceInfo = async () => { const url = deviceInfoUrl; + setLoading(true); try { const resp = await fetch (url); const result = await resp.json(); setDeviceInfo(result?.data || null); + setError(null); } catch (err) { console.error(err); setError(err); + } finally { + setLoading(false); } } fetchDeviceInfo(); }, [deviceInfoUrl]); - return { deviceInfo, error }; + return { deviceInfo, error, loading }; } diff --git a/src/hooks/useStatus.jsx b/src/hooks/useStatus.jsx index b1e68505b31a..ae5f5213bc36 100644 --- a/src/hooks/useStatus.jsx +++ b/src/hooks/useStatus.jsx @@ -1,24 +1,31 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' export const useStatus = (statusUrl) => { const [status, setStatus] = useState([]) const [error, setError] = useState(null); + // Starts true so the first render shows a loading state rather than + // reporting every device as offline before the fetch resolves. + const [loading, setLoading] = useState(true); - useEffect(() => { - const fetchStatus = async () => { + const fetchStatus = useCallback(async () => { const url = statusUrl; + setLoading(true); try { - const resp = await fetch (url); + const resp = await fetch (url, { cache: 'no-store' }); const result = await resp.json(); setStatus(result?.data || []); + setError(null); } catch (err) { console.error(err); setError(err); + } finally { + setLoading(false); } - } + }, [statusUrl]); - fetchStatus(); -}, [statusUrl]); + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); - return { status, error }; + return { status, error, loading, refetch: fetchStatus }; }