Skip to content

feat: tProc v2 backend — multiplexed readout, AveragerProgramV2, premium seam + name-scrub guard#5

Open
aarontrowbridge wants to merge 6 commits into
mainfrom
qick-v2
Open

feat: tProc v2 backend — multiplexed readout, AveragerProgramV2, premium seam + name-scrub guard#5
aarontrowbridge wants to merge 6 commits into
mainfrom
qick-v2

Conversation

@aarontrowbridge

Copy link
Copy Markdown
Member

What

The QICK tProc v2 port (Spec B, spec-20260706-221349), rebased onto main (post Intonato 0.3 compat) and pushed at Aaron's direction. Six commits:

  • QickProgram carries multiplexed-readout routing (§2.1) — the v2 program model.
  • Readout-kind dispatch (§2.2, §5.7) — iq / wigner / tomography_1q, with a typed error for unknown kinds (never a silent misread).
  • MockQickSoc multiplexed round-trip test (§5.1) — the whole loop stays testable with no Python and no hardware.
  • PyQickSoc assembles AveragerProgramV2 (§2.1) — structural assembly for the v2 tProc; on-board execution paths remain the explicit collaboration-finalized stubs.
  • Premium seam (§2.3, §5.5) — package-entitlement gating for the closed-loop calibration node.
  • Name-scrub guard — a testitem that fails if the private method's name ever appears in public source; the Intonatissimo product name and "closed-loop calibration" capability phrase are deliberately exempt (the funnel). The diff removes every prior occurrence of the private acronym from README/source.

Verification

  • Rebased clean onto main (8fd80c4, Intonato 0.2, 0.3 compat).
  • Full suite at the rebased tip against registered Intonato 0.3: 61/61 pass (52.6 s).
  • Diff audited before push: zero additions of the scrubbed method terms; all its occurrences in the diff are deletions.

Notes for review

  • Acceptance remains mock-backed (Spec B §5.4's accepted risk) — real-board validation lands with the Fermilab/Stanford collaboration (Sho, Seb).
  • The upstreamable v1→v2 migration docs from Spec B are a separate follow-up, not in this PR.

🤖 Generated with Claude Code

aarontrowbridge and others added 6 commits July 12, 2026 01:02
Add additive `readout_routing` field to QickProgram mirroring
QickChannelMap.readout_chs, populated in pulse_to_envelopes. The board
(PyQickSoc/expt_service) assembles the AveragerProgramV2 + multiplexed
readout from this plain representation; the Julia side stays board-agnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nown-kind error (Spec B §2.2,§5.7)

Add reduce_readout(kind, raw, discriminator, indices): :iq reduces
Julia-side via the discriminator (iq_to_measurements); :wigner and
:tomography_1q are reduced BOARD-side (expt_service / qick program) and
tagged + passed through (no Wigner/tomography math in the Julia package,
C7); any other kind is a typed error, never a crash. Migrate
iq_to_measurements to the multiplexed channel-major raw[ch][k] shape
(C3), concatenating per-channel discriminator outputs into one
Measurement per knot index. Route the QickExperiment closure through
reduce_readout with a kind (default :iq); readout(b) stays raw (C2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MockQickSoc.acquire returns the multiplexed channel-major shape
raw[ch][k] (one per-knot blob list per requested readout channel,
length(raw)==length(ro_chs)) via the existing synthetic state->IQ
forward model; accepts a kind kwarg for interface parity (mock is
kind-agnostic; real Wigner/tomography physics is board-side). Thread
kind through readout(b)/acquire (readout return stays raw). Update the
mock + backend round-trip testitems and the AbstractQickSoc acquire
docstring/fallback to the channel-major shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-validated) (Spec B §2.1)

play_program! assembles a concrete tProc v2 AveragerProgramV2 from the
device-agnostic QickProgram (declare_gen + add_envelope + add_pulse at
carrier freq, multiplexed declare_readout from readout_routing, trigger);
acquire runs it and returns channel-major raw[ch][k], dispatching
board-side reduction on kind (:iq/:wigner/:tomography_1q). Both use the
lazy qick.asm_v2 import with actionable errors and are hardware-only,
NOT exercised in CI (the existing py_soc discipline). Type-only testitem
asserts the v2 methods exist without constructing a board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…c B §2.3,§5.5)

Add a name-scrub @testitem (src/name_scrub.jl) that walks src/*.jl and
fails if any private-package identifier appears (the private calibration
package name, its strategy type, the bare private acronym). The pattern
is assembled from fragments so the guard file never trips itself; extend
never shrink. Scrub the pre-existing occurrences from public comments,
docstrings, and the README (replaced with 'closed-loop calibration' /
'Piccolo x QICK'); grep -riE over src is now empty.

Entitlement (Step 1): the public package exposes only the un-gated
chassis + standard readouts; the premium calibration strategy ships in
the private package. The entitlement predicate (private package
resolvable in the Julia env) is consumed by Spec A — no public code
change needed. Closes-the-loop test (Step 3) requires the private
package -> lives in the private repo/demo, NOT public CI (deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ETHOD

Aaron 2026-07-07: the product name 'Intonatissimo' + 'closed-loop calibration'
capability are an intentional wink/nod (advertise the premium package at point of
need). name_scrub guard now forbids only the strategy type + method acronym, NOT
the product name. README gains a 'Closed-loop calibration on hardware' funnel
section pointing at Intonatissimo (partner-licensed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/py_soc.jl
Comment on lines +82 to +105
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned - this doesn't quite work. The idiom being used here is that you instantiate the QICK program object, then append the declarations and instructions to it. That works for the base QickProgramV2 class, but not the AveragerProgramV2 which is what is usually used. So (all examples from the notebook https://github.com/meeg/qick_demos_sho/blob/main/tprocv2/00_intro.ipynb):

  1. keep this idiom, using QickProgramV2 instead of AveragerProgramV2 (like in the "using macro methods" example late in my notebook). Instead of using reps and final_delay and having AveragerProgramV2 write the program loop for you, you will need to write that loop yourself. I don't like this, because this basically means duplicating AveragerProgramV2 on the Julia side.
  2. use AveragerProgramV2 correctly (like in most of my notebook). Need to make your own program class that subclasses of AveragerProgramV2, with your declarations and instructions in the overriden _initialize() and _body() methods. I have no idea if this is doable within Julia.
  3. expect the user to provide their own program class in a Python file, where anything we want to change from the Julia side is parametrized in the config dictionary. In other words - we import MultiPulseProgram, we make a config dictionary (setting gen_ch, ro_ch, freq, trig_time, ro_len), instantiate the program. We lose the ability to write the pulse sequence in Julia, but is that something we wanted?
  4. add some new program class on the QICK side that is more suited for the Julia interface - e.g. take two AsmV2 objects in place of the initialize() and body()

prefer option 3, but option 4 might make sense.

Comment thread src/py_soc.jl
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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno what the dac_rate and adc_rate are supposed to be here. The Python QickSoc class has dac_sample_rates and adc_sample_rates parameteres but those are not normally used.

Comment thread src/py_soc.jl
Comment on lines +3 to +7
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, you don't need hardware to instantiate an AveragerProgramV2 - all you need is a JSON dump of the firmware configuration (which you can get by running QickSoc.dump_cfg() on a board: https://docs.qick.dev/latest/_autosummary/qick.qick_asm.html#qick.qick_asm.QickConfig.dump_cfg). Then you can instantiate a QickConfig object from JSON, and pass that to your AveragerProgramV2.

Comment thread src/py_soc.jl
Comment on lines +84 to +93
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (and the prog.pulse() below) are setting up and playing just one pulse per generator, and all pulses are arbitrary-envelope. In most cases you will have multiple drive pulses (you could concatenate everything, but then the gaps between pulses are 0's, and you waste memory), and some pulses (esp. readout pulses) will be constant-envelope or flat-top (which again you could encode as arb-envelope, but that's a waste).

Comment thread src/py_soc.jl
Comment on lines +100 to +101
# Play the generators + trigger the readout(s). The concrete timing/trigger
# sequence (delays, phase resets) is finalized on hardware.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "finalized on hardware" mean?

Comment thread src/py_soc.jl
Comment on lines +81 to +82
# One averaged sweep by default; sweeps/dynamic readout add v2 loops here.
prog = asm.AveragerProgramV2(soc.qicksoc; reps = 1, final_delay = 1.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what "one averaged sweep" means, but reps=1 means we are just running a single shot and averaging or sweeping is meaningless. It is true that if you want to do a sweep, you need to add a loop.

@aarontrowbridge

Copy link
Copy Markdown
Member Author

Thanks @meeg — this is exactly the ground truth we needed, and it reshaped our approach. Point-by-point, and then the bigger decision your first comment forces:

On the program model (your top comment — options 1–4). You're right that appending to a bare program object is a QickProgramV2-only idiom and that AveragerProgramV2 wants a subclass overriding _initialize()/_body(). Rather than reimplement the averager loop in Julia (option 1) or fight Julia subclassing of a Python class (option 2), we're going with your option 3/4, done on the Python side: a small standalone package, qicklab, whose QickLabProgram(AveragerProgramV2) overrides _initialize/_body. The one thing we didn't want to give up (your worry about option 3) is expressing the sequence — so the sequence is authored in a tiny typed pulse IR and lowered by a compiler, not frozen into a fixed config dict. IntonatoQICK.jl then stops assembling AveragerProgramV2 inline and just delegates to qicklab over PythonCall (hand it the pulse, get measurements back). That removes the awkward layer this PR was reaching for. I've added you to harmoniqs/qicklab — the spec captures this in D25 + §16.4 and I'd value your eyes.

No board needed via dump_cfg() (your line-7 comment). This is load-bearing for us — we're building the whole offline/CI path around QickConfig(json) from a committed dump_cfg() snapshot, so a real AveragerProgramV2 gets constructed and validated with no FPGA (only the leaf acquire calls are mocked). Could you share a dump_cfg() JSON from your board? We'll commit it as the reference soccfg snapshot.

Multiple pulses + const/flat-top (line 93). Agreed — one giant arb per generator wastes memory. qicklab's IR has a typed envelope union (Gaussian/FlatTop/Const/Arb) and multiple pulses per generator, precisely so readout/flat pulses aren't forced into arb.

reps=1 / "one averaged sweep" (line 82). Right — that was a placeholder single-shot; averaging/sweeps use add_loop. qicklab models sweeps as add_loop from the start.

dac_rate/adc_rate (line 40). Also agreed those were a confusion — sample rates come from the config, not hand-passed constructor args. Dropping them; rates read from the soccfg snapshot.

"finalized on hardware" (line 101). Fair callout — it was vague. It meant only the trigger/timing sequence needed board validation; given the dump_cfg() path above, almost everything is now validated offline and only real acquisition needs the board. I'll scrub that phrasing.

Net: this PR's inline PyQickSoc.play_program! assembly is superseded by the delegate-to-qicklab approach, so I'd propose we hold it rather than merge as-is, and refocus the IntonatoQICK backend on the thin PythonCall seam. Would love to sync — I'll ping you on Slack.

@aarontrowbridge

Copy link
Copy Markdown
Member Author

Quick follow-up @meeg: we renamed the package qicklab → strumento (Italian for "instrument" — QICK is the Instrumentation Control Kit, and this is the framework that sits on top; it also keeps us out of QICK's namespace / clear of KIT's qiclib, and it's OSS-name-free). Everything in my comment above stands — just read qicklabstrumento and QickLabProgramStrumentoProgram. The repo is now https://github.com/harmoniqs/strumento (your collaborator invite carries over; the old link auto-redirects).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants