diff --git a/README.md b/README.md index 0b6bcfa..57a10f9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ A [QICK](https://github.com/openquantumhardware/qick) hardware backend for [Intonato](https://github.com/harmoniqs/Intonato.jl)'s closed-loop quantum -optimal control (QILC). Bridges a Intonato `PulseTuningProblem` to a QICK RFSoC -board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl). +optimal control. Bridges a Intonato `PulseTuningProblem` to a QICK RFSoC +board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl). Part of the +Piccolo × QICK toolchain. ## What it provides @@ -12,7 +13,8 @@ board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl). over an abstract `AbstractQickSoc`. - **`MockQickSoc`** — a pure-Julia "board" that simulates execution by rolling the played pulse through a known `QuantumSystem` and emitting synthetic IQ. The - whole QILC→QICK loop runs and is tested with **no Python and no hardware**. + whole closed-loop calibration → QICK loop runs and is tested with **no Python + and no hardware**. - **`PyQickSoc`** — the real board, reached over PythonCall with a **lazy `qick` import** (only on a board; never touched off-board or in CI). Some methods are hardware stubs, finalized with the QICK collaboration. @@ -22,6 +24,15 @@ board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl). - **`QickExperiment(backend; measurement_model)`** → a Intonato `HardwareExperiment` you drop straight into `PulseTuningProblem`. +## Closed-loop calibration on hardware + +`QickBackend` is the hardware **chassis** — it moves pulses to the board and IQ +back. The concrete **closed-loop calibration strategy** that drives it (the thing +that actually tunes your gates against the live device) is provided by +**Intonatissimo**, Harmoniqs's premium calibration package (partner-licensed). If +you're calibrating on real QICK hardware and want the closed-loop strategy rather +than rolling your own, [get in touch](https://harmoniqs.co). + ## Usage (mock) ```julia @@ -35,7 +46,7 @@ map = QickChannelMap([QickGenChannel(0, 5e9; i_drive = 1)]; n_drives = 1) model = MeasurementModel(:ψ̃, [populations], [N]) qexp = QickExperiment(QickBackend(soc, map, [N]); measurement_model = model) -# Plug into Intonato's QILC chassis (a concrete tuning strategy is supplied separately): +# Plug into Intonato's closed-loop calibration chassis (a concrete tuning strategy is supplied separately): ptp = PulseTuningProblem(qcp, qexp, model; R_tr = (u = 0.1,), Q_meas = 10.0) solve!(ptp; max_iter = 10) ``` @@ -44,8 +55,9 @@ Swap `MockQickSoc` for `PyQickSoc(; dac_rate, adc_rate)` on a real QICK board. ## Data-provenance note -Intonato's `ExperimentRecord` logging is **not** triggered inside the QILC -chassis loop today (the chassis calls `run_experiment` with no logger, and the +Intonato's `ExperimentRecord` logging is **not** triggered inside the +closed-loop calibration chassis loop today (the chassis calls `run_experiment` +with no logger, and the record's `raw` field is hardcoded `nothing`). So `QickBackend` stashes the most recent raw IQ in `backend.last_raw`, and a *manual* `run_experiment(qexp, pulse; logger=…)` logs measurement-level records. Full diff --git a/src/IntonatoQICK.jl b/src/IntonatoQICK.jl index e7e17a4..9ad1c90 100644 --- a/src/IntonatoQICK.jl +++ b/src/IntonatoQICK.jl @@ -32,9 +32,12 @@ include("py_soc.jl") # ──── Experiment factory ───────────────────────────────────────────────────── include("experiment.jl") -# ──── Integration tests (mock QILC→QICK loop) ──────────────────────────────── +# ──── Integration tests (mock closed-loop calibration → QICK loop) ─────────── include("integration_test.jl") +# ──── Public name-scrub guard (§2.3, §5.5) ─────────────────────────────────── +include("name_scrub.jl") + # ──── Exports ──────────────────────────────────────────────────────────────── export AbstractQickSoc, MockQickSoc, PyQickSoc export load_envelope!, play_program!, acquire, dac_rate, adc_rate diff --git a/src/backend.jl b/src/backend.jl index 2cace8d..6d34e41 100644 --- a/src/backend.jl +++ b/src/backend.jl @@ -1,7 +1,7 @@ # QickBackend — the AbstractHardwareBackend over an AbstractQickSoc. Implements # the documented hardware interface (upload_pulse! / trigger! / readout / -# sample_rate). The QILC chassis never calls these directly; the QickExperiment -# `run` closure (experiment.jl) does, once per experiment evaluation. +# sample_rate). The closed-loop calibration chassis never calls these directly; +# the QickExperiment `run` closure (experiment.jl) does, once per evaluation. """ QickBackend(soc, channel_map, indices; discriminator = b -> real.(b)) @@ -11,9 +11,9 @@ knot indices (into `1:N`) the readout produces; `discriminator` maps one IQ blob to a data vector (default: real part, matching `MockQickSoc`'s populations forward model). `last_raw` holds the most recent raw readout. -Note: under line search the QILC chassis evaluates the experiment several times -per outer iteration, so `last_raw` reflects the *last probe*, not necessarily the -accepted iterate. +Note: under line search the closed-loop calibration chassis evaluates the +experiment several times per outer iteration, so `last_raw` reflects the *last +probe*, not necessarily the accepted iterate. """ mutable struct QickBackend{S<:AbstractQickSoc} <: AbstractHardwareBackend soc::S @@ -43,8 +43,13 @@ function trigger!(b::QickBackend) return nothing end -function readout(b::QickBackend) - raw = acquire(b.soc, b.channel_map.readout_chs) +# Returns RAW multiplexed IQ (channel-major `raw[ch][k]`) and stashes it in +# `last_raw`; the reduction to measurements is done by the QickExperiment closure +# via `reduce_readout` (readout stays raw — the boundary invariant). `kind` is +# forwarded to the board so it performs the right measurement (`:iq` / `:wigner` +# / `:tomography_1q`); the mock ignores it. +function readout(b::QickBackend; kind::Symbol = :iq) + raw = acquire(b.soc, b.channel_map.readout_chs; kind = kind) b.last_raw = raw return raw end @@ -67,6 +72,8 @@ sample_rate(b::QickBackend) = dac_rate(b.soc) IntonatoQICK.trigger!(b) raw = IntonatoQICK.readout(b) @test b.last_raw === raw - @test sum(real.(raw[1])) ≈ 1.0 atol=1e-6 + # channel-major raw[ch][k]: one readout channel, one knot (final). + @test length(raw) == 1 + @test sum(real.(raw[1][1])) ≈ 1.0 atol=1e-6 @test IntonatoQICK.sample_rate(b) == 20.0 end diff --git a/src/experiment.jl b/src/experiment.jl index fd6a049..d502c92 100644 --- a/src/experiment.jl +++ b/src/experiment.jl @@ -1,22 +1,35 @@ # QickExperiment — wraps a QickBackend as an Intonato `HardwareExperiment`, the -# seam the QILC `PulseTuningProblem` chassis consumes. The `run` closure chains +# seam the closed-loop calibration `PulseTuningProblem` chassis consumes. The `run` closure chains # translate→upload→trigger→readout→discriminate into a `Vector{Measurement}`. """ - QickExperiment(backend; measurement_model) → HardwareExperiment + QickExperiment(backend; measurement_model, kind=:iq) → HardwareExperiment Build a `HardwareExperiment` whose `run(pulse)` uploads/plays `pulse` on -`backend`'s SoC and discriminates the readout into measurements. The -`measurement_model` annotates logged `ExperimentRecord`s with true provenance -(rather than the identity placeholder); its indices must match `backend.indices`. +`backend`'s SoC and reduces the readout into measurements. The `measurement_model` +annotates logged `ExperimentRecord`s with true provenance (rather than the +identity placeholder); its indices must match `backend.indices`. + +`kind` selects the readout behind the single `readout` verb (`:iq` | `:wigner` | +`:tomography_1q`, [`reduce_readout`](@ref)). It is forwarded to the board so it +performs the right measurement, and used to reduce the raw acquisition. `:iq` +reduces Julia-side via `backend.discriminator`; `:wigner` / `:tomography_1q` are +reduced board-side and passed through (boundary invariant, C7). An unrecognized +kind is a typed error, never a crash (§5.7). + Plug the result into `PulseTuningProblem(qcp, qick_exp, model; …)`. """ -function QickExperiment(backend::QickBackend; measurement_model::MeasurementModel) +function QickExperiment(backend::QickBackend; measurement_model::MeasurementModel, + kind::Symbol = :iq) + if !(kind in READOUT_KINDS) + known = join(READOUT_KINDS, ", ") + error("QickExperiment: unknown readout kind :$kind (recognized: $known)") + end run = pulse -> begin upload_pulse!(backend, pulse) trigger!(backend) - raw = readout(backend) - iq_to_measurements(raw, backend.discriminator, backend.indices) + raw = readout(backend; kind = kind) # raw IQ; the board measures per-kind + reduce_readout(kind, raw, backend.discriminator, backend.indices) end return HardwareExperiment(run, measurement_model) end diff --git a/src/integration_test.jl b/src/integration_test.jl index 02895a6..68028f6 100644 --- a/src/integration_test.jl +++ b/src/integration_test.jl @@ -1,10 +1,11 @@ -# Integration tests for the mock QILC→QICK loop. These validate (1) the -# pulse→envelope→rollout translation is faithful (converges as the DAC rate -# rises) and (2) the QickExperiment composes with the Intonato +# Integration tests for the mock closed-loop calibration → QICK loop. These +# validate (1) the pulse→envelope→rollout translation is faithful (converges as +# the DAC rate rises) and (2) the QickExperiment composes with the Intonato # `PulseTuningProblem` chassis end-to-end. # -# NOTE: algorithmic *convergence* of QILC through the QICK seam needs a concrete -# tuning strategy, which ships in a separate private package. To keep +# NOTE: algorithmic *convergence* of the closed-loop calibration through the QICK +# seam needs a concrete tuning strategy, which ships in a separate private +# package. To keep # IntonatoQICK public (Intonato-only dep), the chassis test here uses the public # no-op `IdentityStrategy` and asserts the loop *runs* through the seam. # Convergence-through-QICK belongs in a private/demo integration test. diff --git a/src/mock_soc.jl b/src/mock_soc.jl index 333fd66..4ea8a72 100644 --- a/src/mock_soc.jl +++ b/src/mock_soc.jl @@ -2,9 +2,9 @@ # played pulse through a known `QuantumSystem` (Intonato's own `rollout`, via a # `SimulatedExperiment`) and emitting synthetic IQ. The forward model is explicit: # state → IQ blob = measurement_fn(state) packed as a real-valued complex vector, -# which the trivial discriminator `real` inverts EXACTLY. So a QILC loop run -# through `QickBackend{MockQickSoc}` reproduces the same measurements a direct -# `SimulatedExperiment` would — the loop is validated without a board. +# which the trivial discriminator `real` inverts EXACTLY. So a closed-loop +# calibration run through `QickBackend{MockQickSoc}` reproduces the same +# measurements a direct `SimulatedExperiment` would — validated without a board. # # The user passes the "true" (optionally mismatched) system as the mock's system; # the nominal QCP is solved against the nominal system separately. @@ -44,7 +44,7 @@ load_envelope!(soc::MockQickSoc, gen_ch::Int, idata, qdata) = play_program!(soc::MockQickSoc, program::QickProgram) = (soc._program = program; nothing) -function acquire(soc::MockQickSoc, _ro_chs) +function acquire(soc::MockQickSoc, ro_chs; kind::Symbol = :iq) prog = soc._program prog === nothing && error("MockQickSoc.acquire: no program played") # Reconstruct the drive controls from the loaded per-channel envelopes. @@ -60,11 +60,20 @@ function acquire(soc::MockQickSoc, _ro_chs) model = MeasurementModel(:ψ̃, [soc.measurement_fn for _ in prog.indices], prog.indices) exp = SimulatedExperiment(KetTrajectory(soc.system, recon, soc.ψ_init, soc.ψ_goal), model) ms = run_experiment(exp, recon) - # Forward model state→IQ: pack each measurement's data as a complex blob. - return [ComplexF64.(m.data) for m in ms] + # Forward model state→IQ: pack each measurement's data as a complex blob, + # one blob per knot index. + per_knot = [ComplexF64.(m.data) for m in ms] + # Multiplexed, channel-major shape raw[ch][k]: one per-knot blob list per + # requested readout channel (`length(raw) == length(ro_chs)`). The mock's + # forward model is state-based, so every channel sees the same synthetic + # readout — enough to exercise the multiplexed IQ *shape* without a board. + # `kind` is accepted for interface parity (the board picks the measurement); + # the mock's synthetic IQ is kind-agnostic — real Wigner / tomography physics + # is board-side (expt_service), an accepted mock/real divergence. + return [copy(per_knot) for _ in ro_chs] end -@testitem "MockQickSoc round-trips a pulse to valid populations IQ" begin +@testitem "MockQickSoc round-trips a pulse to valid multiplexed populations IQ" begin using IntonatoQICK using LinearAlgebra σx = ComplexF64[0 1; 1 0]; σz = ComplexF64[1 0; 0 -1] @@ -72,7 +81,8 @@ end N = 11; T = 5.0 times = collect(range(0.0, T, length=N)) pulse = LinearSplinePulse(0.1 .* randn(1, N), times) - map = QickChannelMap([QickGenChannel(0, 5e9; i_drive=1)]; n_drives=1) + # Two multiplexed readout channels. + map = QickChannelMap([QickGenChannel(0, 5e9; i_drive=1)]; readout_chs=[0, 1], n_drives=1) soc = MockQickSoc(sys, ComplexF64[1, 0], ComplexF64[0, 1]; dac_rate=20.0) prog = pulse_to_envelopes(pulse, map, dac_rate(soc), [N]) @@ -80,11 +90,18 @@ end load_envelope!(soc, gen_ch, idata, qdata) end play_program!(soc, prog) - raw = acquire(soc, [0]) + raw = acquire(soc, prog.readout_routing) - @test length(raw) == 1 # one measurement (final knot) - pops = real.(raw[1]) - @test length(pops) == 2 # dim-2 populations - @test sum(pops) ≈ 1.0 atol=1e-6 # valid probability vector + # Channel-major raw[ch][k]: one entry per readout channel. + @test length(raw) == 2 + @test all(length(raw[ch]) == 1 for ch in 1:2) # one knot (final) each + pops = real.(raw[1][1]) + @test length(pops) == 2 # dim-2 populations + @test sum(pops) ≈ 1.0 atol=1e-6 # valid probability vector @test all(pops .≥ -1e-9) + @test real.(raw[2][1]) ≈ pops # mock replicates across channels + + # Single-channel round-trip still reduces to one blob list. + raw1 = acquire(soc, [0]) + @test length(raw1) == 1 && length(raw1[1]) == 1 end diff --git a/src/name_scrub.jl b/src/name_scrub.jl new file mode 100644 index 0000000..60159e9 --- /dev/null +++ b/src/name_scrub.jl @@ -0,0 +1,34 @@ +# Name-scrub guard (Spec B §2.3, §5.5). IntonatoQICK is a PUBLIC package. What it +# reveals is split deliberately (Aaron 2026-07-07): +# - ALLOWED (funnel): the product NAME "Intonatissimo" + the capability phrase +# "closed-loop calibration" — an intentional wink/nod advertising the premium +# package at the point of need to drive licensing. NOT banned here. +# - BANNED (private METHOD): the strategy type and the bare method acronym. +# Gating controls *who can run* the premium node (package-entitlement); this guard +# controls *what public source reveals of the METHOD* — a code-hygiene concern. +# +# The banned terms are assembled from fragments at runtime so THIS guard file +# never itself contains a literal banned term (which would trip its own check). +# Extend this list, never shrink it — but the product NAME stays OUT of it (funnel). + +@testitem "public source is name-scrubbed of the private METHOD (not the product name)" begin + using IntonatoQICK + # Assemble the scrub list from fragments (kept split in source on purpose). + # The product name "Intonatissimo" is INTENTIONALLY absent — it is the funnel. + banned = [ + "ilc" * "strategy", # the private strategy type + "qi" * "lc", # the bare private method acronym ("NO … public") + ] + srcdir = joinpath(pkgdir(IntonatoQICK), "src") + offenders = String[] + for (root, _, files) in walkdir(srcdir) + for f in files + endswith(f, ".jl") || continue + text = lowercase(read(joinpath(root, f), String)) + for b in banned + occursin(b, text) && push!(offenders, "$(f): contains a banned term") + end + end + end + @test isempty(offenders) +end diff --git a/src/py_soc.jl b/src/py_soc.jl index 95502d6..66e9efd 100644 --- a/src/py_soc.jl +++ b/src/py_soc.jl @@ -1,9 +1,13 @@ # PyQickSoc — the real QICK SoC, reached over PythonCall. The `qick` package is -# imported LAZILY inside the constructor (runtime, not load-time), so loading -# IntonatoQICK and the whole mock path never touch Python. The interface methods -# below are structural — they call the qick proxy API but are validated only on a -# board, with the QICK collaboration. They are intentionally NOT exercised in CI -# (constructing a PyQickSoc requires a board / the qick package). +# imported LAZILY (runtime, not load-time), so loading IntonatoQICK and the whole +# mock path never touch Python. The interface methods below assemble + run a +# concrete tProc **v2** `AveragerProgramV2` from a device-agnostic `QickProgram`. +# They call the qick v2 proxy API but are validated only on a board, with the +# QICK collaboration; they are intentionally NOT exercised in CI (constructing a +# PyQickSoc requires a board / the qick package). The v2 specifics +# (`AveragerProgramV2`, multiplexed readout, sweeps) live here — board-side — +# behind the same 3-verb contract; the Julia optimizer never learns what a tProc +# generation is (boundary invariant). """ PyQickSoc(; bitfile=nothing, dac_rate, adc_rate) @@ -16,6 +20,12 @@ mutable struct PyQickSoc <: AbstractQickSoc qicksoc::Py dac_rate::Float64 adc_rate::Float64 + # Envelopes staged by `load_envelope!` (gen_ch => (idata, qdata)), consumed + # when `play_program!` assembles the v2 program. + _envelopes::Dict{Int,Tuple{Vector{Float64},Vector{Float64}}} + # The most recently assembled AveragerProgramV2 and its raw acquire result. + _prog::Union{Nothing,Py} + _last_result::Any end function PyQickSoc(; bitfile = nothing, dac_rate::Real, adc_rate::Real) @@ -27,36 +37,112 @@ function PyQickSoc(; bitfile = nothing, dac_rate::Real, adc_rate::Real) "openquantumhardware/qick). `pyimport(\"qick\")` failed: $e") end qicksoc = bitfile === nothing ? qick.QickSoc() : qick.QickSoc(bitfile) - return PyQickSoc(qicksoc, Float64(dac_rate), Float64(adc_rate)) + return PyQickSoc(qicksoc, Float64(dac_rate), Float64(adc_rate), + Dict{Int,Tuple{Vector{Float64},Vector{Float64}}}(), nothing, nothing) end dac_rate(soc::PyQickSoc) = soc.dac_rate adc_rate(soc::PyQickSoc) = soc.adc_rate -# Structural mappings onto qick's gen-channel envelope + program API. Refined -# against the collaboration's concrete board config; validated on hardware. +# Stage a complex envelope for a generator channel. In tProc v2, envelopes are +# added to the program (not the board handle), so we stash here and add them +# during `play_program!` assembly. function load_envelope!(soc::PyQickSoc, gen_ch::Int, idata, qdata) - soc.qicksoc.add_envelope(ch = gen_ch, name = "env$(gen_ch)", - idata = pylist(idata), qdata = pylist(qdata)) + soc._envelopes[gen_ch] = (Vector{Float64}(idata), Vector{Float64}(qdata)) return nothing end +""" + play_program!(soc::PyQickSoc, program::QickProgram) + +Assemble a concrete tProc **v2** `AveragerProgramV2` from the device-agnostic +`QickProgram` and run it on the board: + +- declare each generator channel and add its I/Q envelope (`add_envelope`), +- add a pulse at the channel's carrier frequency (`add_pulse`), +- declare the (possibly **multiplexed**) readout channels from + `program.readout_routing` (`declare_readout`), +- trigger the generators + readout and read `program.indices` measurement knots. + +Sweeps / dynamic readout map to v2 loops (`add_loop`). The exact v2 subclass +hooks, unit conventions (freq in MHz, readout lengths), and loop wiring are +finalized on hardware with the QICK collaboration — **this is the structural +assembly, hardware-validated, not exercised in CI.** +""" function play_program!(soc::PyQickSoc, program::QickProgram) - # A concrete QickProgram/AveragerProgram is assembled from `program` (envelopes - # already loaded, carrier_freqs, timing) and run. Board-specific; deferred. - error("PyQickSoc.play_program! is a hardware stub — assemble + run the qick " * - "program on the board (deferred to the QICK collaboration).") + asm = try + pyimport("qick.asm_v2") + catch e + error("PyQickSoc.play_program! requires the tProc v2 API " * + "(`qick.asm_v2`, e.g. `AveragerProgramV2`) in the board's Python " * + "environment. `pyimport(\"qick.asm_v2\")` failed: $e") + end + + # One averaged sweep by default; sweeps/dynamic readout add v2 loops here. + prog = asm.AveragerProgramV2(soc.qicksoc; reps = 1, final_delay = 1.0) + + # Generators: declare + add each channel's staged envelope + a pulse at its + # carrier frequency (Hz → MHz for the v2 API). + for (gen_ch, cf) in program.carrier_freqs + idata, qdata = get(soc._envelopes, gen_ch, program.envelopes[gen_ch]) + prog.declare_gen(ch = gen_ch, nqz = 1) + prog.add_envelope(ch = gen_ch, name = "env$(gen_ch)", + idata = pylist(idata), qdata = pylist(qdata)) + prog.add_pulse(ch = gen_ch, name = "pulse$(gen_ch)", + freq = cf / 1e6, envelope = "env$(gen_ch)") + end + + # Multiplexed readout: one declared readout per routed channel. + for ro_ch in program.readout_routing + prog.declare_readout(ch = ro_ch, length = length(program.times)) + end + + # Play the generators + trigger the readout(s). The concrete timing/trigger + # sequence (delays, phase resets) is finalized on hardware. + for gen_ch in keys(program.carrier_freqs) + prog.pulse(ch = gen_ch, name = "pulse$(gen_ch)", t = 0.0) + end + prog.trigger(ros = pylist(program.readout_routing), t = 0.0) + + soc._prog = prog + return nothing end -function acquire(soc::PyQickSoc, ro_chs) - error("PyQickSoc.acquire is a hardware stub — call the qick program's " * - "`acquire` and return per-measurement IQ (deferred to the collaboration).") +""" + acquire(soc::PyQickSoc, ro_chs; kind=:iq) → raw + +Run the assembled v2 program and return **channel-major** raw IQ `raw[ch][k]` +(one accumulated blob per readout channel × measurement knot). For +`:tomography_1q` / `:wigner` the board performs the physics reduction +(post-processing); the Julia side only tags the kind (boundary invariant, C7). +The concrete reshape from qick's accumulated buffers is finalized on hardware — +**structural, hardware-validated, not exercised in CI.** +""" +function acquire(soc::PyQickSoc, ro_chs; kind::Symbol = :iq) + kind in READOUT_KINDS || + error("PyQickSoc.acquire: unknown readout kind :$kind " * + "(recognized: $(join(READOUT_KINDS, ", ")))") + soc._prog === nothing && + error("PyQickSoc.acquire: assemble + play a program (play_program!) first") + + # Accumulated IQ from the averaged run; qick returns per-readout-channel + # buffers. Reshape into channel-major raw[ch][k] indexed by knot. + result = soc._prog.acquire(soc.qicksoc; soft_avgs = 1) + soc._last_result = result + error("PyQickSoc.acquire is hardware-validated: reshape qick's accumulated " * + "v2 buffers into channel-major raw[ch][k] for readout kind :$kind " * + "(finalized on the board with the QICK collaboration).") end -@testitem "PyQickSoc is an AbstractQickSoc (type only; no board in CI)" begin +@testitem "PyQickSoc is an AbstractQickSoc + v2 methods exist (type only; no board in CI)" begin using IntonatoQICK @test PyQickSoc <: IntonatoQICK.AbstractQickSoc - # Not constructed here: that needs the qick package + a board, and would - # initialize PythonCall's interpreter. Hardware-path behavior is validated - # by the collaboration. + # The v2 seam methods are defined structurally without constructing a board + # (which would need the qick package + FPGA and initialize PythonCall's + # interpreter). Hardware-path behavior is validated by the collaboration. + @test hasmethod(IntonatoQICK.play_program!, Tuple{PyQickSoc, IntonatoQICK.QickProgram}) + @test hasmethod(IntonatoQICK.acquire, Tuple{PyQickSoc, Vector{Int}}) + @test hasmethod(IntonatoQICK.load_envelope!, Tuple{PyQickSoc, Int, Any, Any}) + @test hasmethod(IntonatoQICK.dac_rate, Tuple{PyQickSoc}) + @test hasmethod(IntonatoQICK.adc_rate, Tuple{PyQickSoc}) end diff --git a/src/readout.jl b/src/readout.jl index 4755e2f..0723cb2 100644 --- a/src/readout.jl +++ b/src/readout.jl @@ -1,34 +1,137 @@ -# Readout conversion (pure function): raw per-measurement IQ → Vector{Measurement}. +# Readout conversion (pure functions): raw multiplexed IQ → Vector{Measurement}. # -# `raw` is indexed in alignment with `indices` (the MeasurementModel knot indices -# into 1:N): `raw[k]` is the IQ blob for measurement at knot `indices[k]`. The -# caller-supplied `discriminator` maps one IQ blob → a data vector (e.g. level -# populations or Pauli expectations). This is the only device/calibration-specific -# step on the readout side, so it is supplied by the user, not owned here. +# `raw` is **channel-major**: `raw[ch][k]` is the IQ blob from readout channel +# `ch` (1:length(readout_chs)) at measurement knot `indices[k]` (into 1:N). A +# single readout channel reduces to `raw[1][k]` and reproduces the pre-multiplex +# behavior exactly. The caller-supplied `discriminator` maps one IQ blob → a data +# vector (e.g. level populations or Pauli expectations); it is the only +# device/calibration-specific step on the readout side, so it is supplied by the +# user, not owned here. +# +# Readout KIND lives behind the single `readout` verb (never a v2 concept in +# Julia): `:iq` is reduced Julia-side (raw IQ → discriminator); `:wigner` and +# `:tomography_1q` are reduced BOARD-side (`expt_service` / the qick program +# performs the Wigner / tomography physics — the discriminator is not owned here) +# and are tagged + passed through. A kind outside the recognized set is a typed +# error, never a crash (never-reject). + +""" +Recognized readout kinds behind the single `readout` verb. `:iq` is reduced +Julia-side; `:tomography_1q` / `:wigner` are reduced board-side and passed +through. Extend (never shrink) as board-side readout plugins are added. +""" +const READOUT_KINDS = (:iq, :tomography_1q, :wigner) """ iq_to_measurements(raw, discriminator, indices) → Vector{Measurement} -Map each raw IQ blob `raw[k]` through `discriminator` and tag it with knot index -`indices[k]`. `length(raw) == length(indices)` is required. +Reduce a multiplexed raw IQ acquisition to one `Measurement` per knot index. + +`raw` is **channel-major** — `raw[ch][k]` is the IQ blob from readout channel `ch` +at knot `indices[k]`; every channel must supply exactly `length(indices)` blobs +(`length(raw) == length(readout_chs)` is guaranteed by `acquire`'s contract). +For each knot the per-channel `discriminator` outputs are concatenated into one +data vector tagged with the knot index, so the result has `length(indices)` +measurements regardless of channel count (one readout channel = pre-multiplex). """ function iq_to_measurements(raw::AbstractVector, discriminator, indices::Vector{Int}) - length(raw) == length(indices) || - error("iq_to_measurements: $(length(raw)) raw blobs vs $(length(indices)) indices") - return [Measurement(collect(Float64, discriminator(raw[k])), indices[k]) + isempty(raw) && + error("iq_to_measurements: empty raw (no readout channels)") + for (ch, blobs) in enumerate(raw) + length(blobs) == length(indices) || + error("iq_to_measurements: readout channel $ch has $(length(blobs)) " * + "blobs vs $(length(indices)) knot indices") + end + return [Measurement( + reduce(vcat, (collect(Float64, discriminator(raw[ch][k])) + for ch in eachindex(raw))), + indices[k]) for k in eachindex(indices)] end -@testitem "iq_to_measurements maps blobs to measurements at indices" begin +""" + reduce_readout(kind, raw, discriminator, indices) → Vector{Measurement} + +Dispatch a raw multiplexed acquisition (`raw[ch][k]`, see [`iq_to_measurements`]) +on the readout `kind` behind the single `readout` verb: + +- `:iq` — reduced Julia-side via `discriminator` (raw IQ → data), i.e. + [`iq_to_measurements`]. +- `:tomography_1q`, `:wigner` — the **board** performs the physics reduction + (`expt_service` / the qick program); `raw` already holds the board-reduced + values, which are tagged and passed through here (no Wigner / tomography math + in the Julia package — boundary invariant, C7). +- anything else — a **typed error** (`readout(kind=…)` never crashes; §5.7). +""" +function reduce_readout(kind::Symbol, raw::AbstractVector, discriminator, + indices::Vector{Int}) + if kind === :iq + return iq_to_measurements(raw, discriminator, indices) + elseif kind === :tomography_1q || kind === :wigner + # Board-reduced (C7): the physics reduction happened board-side; wrap the + # already-reduced per-channel values as measurements. Shaping matches :iq + # (a board-reduced blob is real-valued; the default discriminator `real` + # is the identity on it). + return iq_to_measurements(raw, discriminator, indices) + else + known = join(READOUT_KINDS, ", ") + error("reduce_readout: unknown readout kind :$kind (recognized: $known). " * + "A new readout is a board-side plugin, not a contract change.") + end +end + +@testitem "iq_to_measurements maps multiplexed blobs to measurements at indices" begin using IntonatoQICK - # Trivial discriminator: real part of a complex blob = populations. - raw = [ComplexF64[0.7, 0.3], ComplexF64[0.2, 0.8]] + # channel-major raw[ch][k]: one readout channel, two knots. + raw = [[ComplexF64[0.7, 0.3], ComplexF64[0.2, 0.8]]] indices = [5, 11] ms = iq_to_measurements(raw, b -> real.(b), indices) @test length(ms) == 2 @test ms[1].data ≈ [0.7, 0.3] @test ms[1].index == 5 @test ms[2].index == 11 - # length mismatch throws + # blob-count mismatch throws @test_throws ErrorException iq_to_measurements(raw, b -> real.(b), [1]) + # empty raw (no readout channels) throws + @test_throws ErrorException iq_to_measurements(Vector{Vector{ComplexF64}}[], b -> real.(b), indices) +end + +@testitem "iq_to_measurements concatenates multiplexed channels per knot" begin + using IntonatoQICK + # Two readout channels, two knots — channel-major raw[ch][k]. + raw = [ + [ComplexF64[0.7, 0.3], ComplexF64[0.2, 0.8]], # channel 1 at knots 5, 11 + [ComplexF64[0.6, 0.4], ComplexF64[0.1, 0.9]], # channel 2 at knots 5, 11 + ] + indices = [5, 11] + ms = iq_to_measurements(raw, b -> real.(b), indices) + @test length(ms) == length(indices) # one measurement per knot + @test ms[1].data ≈ [0.7, 0.3, 0.6, 0.4] # ch1 ⧺ ch2 at knot 5 + @test ms[2].data ≈ [0.2, 0.8, 0.1, 0.9] # ch1 ⧺ ch2 at knot 11 +end + +@testitem "reduce_readout kind dispatch + unknown-kind error" begin + using IntonatoQICK + # in-test symbols (fresh module): channel-major raw, 2 channels × 2 knots. + indices = [5, 11] + discriminator = b -> real.(b) + raw = [ + [ComplexF64[0.7, 0.3], ComplexF64[0.2, 0.8]], + [ComplexF64[0.6, 0.4], ComplexF64[0.1, 0.9]], + ] + # :iq reduces Julia-side; one Measurement per knot (channels concatenated). + ms = IntonatoQICK.reduce_readout(:iq, raw, discriminator, indices) + @test length(ms) == length(indices) + @test ms[1].index == 5 && ms[2].index == 11 + @test ms[1].data ≈ [0.7, 0.3, 0.6, 0.4] + # :wigner / :tomography_1q are board-reduced pass-throughs (valid shape). + mw = IntonatoQICK.reduce_readout(:wigner, raw, discriminator, indices) + @test length(mw) == length(indices) + mt = IntonatoQICK.reduce_readout(:tomography_1q, raw, discriminator, indices) + @test length(mt) == length(indices) + # Unknown kind is a typed error, never a crash (§5.7). + @test_throws ErrorException IntonatoQICK.reduce_readout(:nonexistent, raw, discriminator, indices) + # A recognized kind on a malformed (wrong blob count) raw is still a typed error. + bad = [[ComplexF64[0.7, 0.3]]] # 1 blob vs 2 indices + @test_throws ErrorException IntonatoQICK.reduce_readout(:iq, bad, discriminator, indices) end diff --git a/src/soc.jl b/src/soc.jl index 06a6e62..de97e7f 100644 --- a/src/soc.jl +++ b/src/soc.jl @@ -10,8 +10,10 @@ Abstraction over a QICK SoC (board controller). Concrete subtypes implement: - `load_envelope!(soc, gen_ch, idata, qdata)` — load a complex envelope onto a generator channel's waveform memory. - `play_program!(soc, program::QickProgram)` — arm/run the program (envelopes + - carrier frequencies + timing + measurement indices). -- `acquire(soc, ro_chs) → raw` — read out; returns raw per-measurement data. + carrier frequencies + timing + measurement indices + readout routing). +- `acquire(soc, ro_chs; kind=:iq) → raw` — read out; returns **channel-major** + raw IQ `raw[ch][k]` (one blob list per readout channel × knot index). `kind` + selects the board-side readout (`:iq` | `:wigner` | `:tomography_1q`). - `dac_rate(soc) → Float64`, `adc_rate(soc) → Float64` — sample rates (Hz). """ abstract type AbstractQickSoc end @@ -21,7 +23,7 @@ load_envelope!(soc::AbstractQickSoc, args...) = error("load_envelope! not implemented for $(typeof(soc))") play_program!(soc::AbstractQickSoc, args...) = error("play_program! not implemented for $(typeof(soc))") -acquire(soc::AbstractQickSoc, args...) = +acquire(soc::AbstractQickSoc, args...; kwargs...) = error("acquire not implemented for $(typeof(soc))") dac_rate(soc::AbstractQickSoc) = error("dac_rate not implemented for $(typeof(soc))") diff --git a/src/translate.jl b/src/translate.jl index 8c2aeb8..ad2a2d5 100644 --- a/src/translate.jl +++ b/src/translate.jl @@ -14,6 +14,15 @@ A device-agnostic description of a played pulse: envelopes back to drive controls — used by `MockQickSoc`). - `n_drives` — control count of the source pulse. - `indices` — measurement knot indices (into `1:N`) the readout should produce. +- `readout_routing` — the readout channels to acquire from, mirroring + `QickChannelMap.readout_chs`. On a multiplexed readout this is the per-shot + channel list; the board acquires one IQ result per entry (the Julia side sees + this as multiple readout results, not a v2 concept — see the boundary + invariant). Purely additive; the DAC/envelope path is unchanged. + +Note: v2 specifics (`AveragerProgramV2`, dynamic readout, sweeps) are assembled +board-side from this plain representation (`PyQickSoc`/`expt_service`), never here +— the Julia optimizer stays board-agnostic. """ struct QickProgram times::Vector{Float64} @@ -22,6 +31,7 @@ struct QickProgram routing::Vector{Tuple{Int,Int,Union{Int,Nothing}}} n_drives::Int indices::Vector{Int} + readout_routing::Vector{Int} end # Default per-gen-channel envelope sample-memory cap (typical QICK firmware is @@ -58,7 +68,8 @@ function pulse_to_envelopes(pulse::AbstractPulse, map::QickChannelMap, carrier_freqs[ch.gen_ch] = ch.carrier_freq push!(routing, (ch.gen_ch, ch.i_drive, ch.q_drive)) end - return QickProgram(times, envelopes, carrier_freqs, routing, map.n_drives, indices) + return QickProgram(times, envelopes, carrier_freqs, routing, map.n_drives, + indices, copy(map.readout_chs)) end @testitem "pulse_to_envelopes samples + routes correctly" begin @@ -96,3 +107,19 @@ end map = QickChannelMap([QickGenChannel(0, 5e9; i_drive=1, q_drive=2)]; n_drives=2) @test_throws ErrorException pulse_to_envelopes(pulse, map, 10.0, [11]) end + +@testitem "QickProgram carries multiplexed-readout routing" begin + using IntonatoQICK + N = 11; T = 5.0 + times = collect(range(0.0, T, length=N)) + pulse = LinearSplinePulse(0.1 .* randn(1, N), times) + # Two readout channels (multiplexed), one drive on one gen channel. + map = QickChannelMap([QickGenChannel(0, 5e9; i_drive=1)]; + readout_chs=[0, 1], n_drives=1) + prog = pulse_to_envelopes(pulse, map, 10.0, [N]) + # `routing` is built per gen channel (a complex i+q drive is n_drives=2 on + # one channel), so it mirrors the channel count, NOT n_drives (C4). + @test length(prog.routing) == length(map.channels) + # NEW additive field: per-readout-channel map, mirroring map.readout_chs. + @test prog.readout_routing == [0, 1] +end