diff --git a/.github/workflows/qcvt.yml b/.github/workflows/qcvt.yml new file mode 100644 index 00000000..712da0a2 --- /dev/null +++ b/.github/workflows/qcvt.yml @@ -0,0 +1,43 @@ +name: QCVT + +on: + push: + paths: + - "qick_lib/qcvt/**" + - "qick_demos/qcvt/**" + - "setup.py" + - ".github/workflows/qcvt.yml" + pull_request: + paths: + - "qick_lib/qcvt/**" + - "qick_demos/qcvt/**" + - "setup.py" + - ".github/workflows/qcvt.yml" + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install qick with QCVT extras + run: | + python -m pip install --upgrade pip + pip install -e ".[qcvt]" pytest + + - name: Show versions + run: | + python -c "import qick, qcvt; print('qick', qick.__version__); print('qcvt', qcvt.__version__)" + + - name: Run QCVT tests + run: pytest qick_lib/qcvt/tests/ -v diff --git a/.gitignore b/.gitignore index 823a8176..fc7eb67e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ Temporary Items !/.gitignore !/README.md + +# local conda env accidentally under repo +.conda/ diff --git a/README.md b/README.md index 276a61ba..ffcedf34 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ We generally recommend using the newer generation of RFSoCs (ZCU216 and RFSoC4x2 It consists of: * Firmware for the supported RFSoC boards, both compiled bitstreams and source for the designs and modules * The `qick` Python package, which includes the interface to the firmware and an API for writing QICK programs +* The `qcvt` package (install with `pip install "qick[qcvt]"`), which visualizes `AveragerProgramV2` pulse schedules offline — see [qick_demos/qcvt](qick_demos/qcvt) * [Jupyter notebooks](qick_demos) demonstrating usage See our [Read the Docs site](https://docs.qick.dev/) for: diff --git a/qick_demos/README.md b/qick_demos/README.md index 5e878cfb..1d068e4f 100644 --- a/qick_demos/README.md +++ b/qick_demos/README.md @@ -25,6 +25,11 @@ Below is a brief summary of the QICK software capabilities. * The ability to write data to and read data from tProc memory addresses (which tProc registers can access) * Addition, subtraction, multiplication, bit shifting +## Pulse-schedule visualization (QCVT) + +* Offline visualization of compiled `AveragerProgramV2` schedules: [qick_demos/qcvt](qcvt) +* Install extras: `pip install "qick[qcvt]"`, then `python qick_demos/qcvt/run_offline_example.py` + ## Measurements on quantum devices * Measuring a single qubit [Demo 06_qubit_demos](https://github.com/openquantumhardware/qick/blob/main/qick_demos/06_qubit_demos.ipynb) diff --git a/qick_demos/qcvt/README.md b/qick_demos/qcvt/README.md new file mode 100644 index 00000000..241b130a --- /dev/null +++ b/qick_demos/qcvt/README.md @@ -0,0 +1,51 @@ +# QCVT — QICK Control Visualization Tool + +Visualize and export the pulse schedule of a QICK `asm_v2` program **before it is +sent to an RFSoC**. Works online (connected) and fully offline (from a saved +config or a compiled-program pickle). + +Originally developed at https://github.com/DRNag2/QCVT and proposed for inclusion +in QICK in https://github.com/openquantumhardware/qick/issues/229. + +![Example schedule](example_schedule.png) + +## Install + +```bash +# from the QICK repo root +pip install -e ".[qcvt]" +# optional: load compiled-program pickles +pip install -e ".[qcvt-pickle]" +``` + +## Quick start (offline demo) + +```bash +python qick_demos/qcvt/run_offline_example.py +python qick_demos/qcvt/walkthrough_demo.py +``` + +No RFSoC needed — these use the bundled `qick_config.json`. + +## In your experiment code + +```python +from qcvt import show_schedule, review_schedule, extract_schedule + +prog = YourProgram(soccfg, reps=1, final_delay=0, cfg=config) +show_schedule(prog, title="My experiment") + +# Pre-submit gate +ok = review_schedule(prog, save_dir="qcvt_reviews/my_exp", show=True, confirm=True) +if not ok: + raise RuntimeError("aborted") + +sched = extract_schedule(prog, strict=True) +``` + +## Tests + +```bash +pip install -e ".[qcvt]" pytest +pytest qick_lib/qcvt/tests/ -v +``` diff --git a/qick_demos/qcvt/example_schedule.png b/qick_demos/qcvt/example_schedule.png new file mode 100644 index 00000000..f9933847 Binary files /dev/null and b/qick_demos/qcvt/example_schedule.png differ diff --git a/qick_demos/qcvt/qick_config.json b/qick_demos/qcvt/qick_config.json new file mode 100644 index 00000000..b9f0060e --- /dev/null +++ b/qick_demos/qcvt/qick_config.json @@ -0,0 +1,738 @@ +{ + "board": "ZCU216", + "sw_version": "0.2.367", + "extra_description": [], + "fw_timestamp": "Wed Mar 19 09:14:54 2025", + "rf": { + "type": "usp_rf_data_converter", + "fullpath": "usp_rf_data_converter_0", + "ip_type": 2, + "hs_adc": false, + "tiles": { + "dac": { + "0": { + "coupling": "AC", + "fabric_div": 16, + "out_div": 1, + "blocks": [ + "00", + "01", + "02", + "03" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "f_out": 614.4 + }, + "1": { + "coupling": "AC", + "fabric_div": 16, + "out_div": 1, + "blocks": [ + "10", + "11", + "12", + "13" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "f_out": 614.4 + }, + "2": { + "coupling": "AC", + "fabric_div": 16, + "out_div": 1, + "blocks": [ + "20" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "f_out": 614.4 + } + }, + "adc": { + "2": { + "coupling": "AC", + "fabric_div": 8, + "out_div": 1, + "blocks": [ + "20", + "21", + "22" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "f_out": 307.2 + } + } + }, + "dacs": { + "00": { + "index": [ + 0, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "01": { + "index": [ + 0, + 1 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "02": { + "index": [ + 0, + 2 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "03": { + "index": [ + 0, + 3 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "10": { + "index": [ + 1, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "11": { + "index": [ + 1, + 1 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "12": { + "index": [ + 1, + 2 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "13": { + "index": [ + 1, + 3 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "20": { + "index": [ + 2, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + } + }, + "adcs": { + "20": { + "index": [ + 2, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "decimation": 1 + }, + "21": { + "index": [ + 2, + 1 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "decimation": 1 + }, + "22": { + "index": [ + 2, + 2 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "decimation": 1 + } + }, + "clk_groups": [ + [ + [ + "qick_processor_0", + "timing clock" + ], + [ + "dac", + 0 + ], + [ + "dac", + 1 + ], + [ + "dac", + 2 + ] + ], + [ + [ + "adc", + 2 + ] + ] + ], + "revision": 6, + "version": "2.6" + }, + "refclk_freq": 245.76, + "mr_buf": { + "type": "mr_buffer_et", + "fullpath": "mr_buffer_et_0", + "maxlen": 8192, + "junk_len": 8, + "readouts": [ + "axis_avg_buffer_0", + "axis_avg_buffer_1" + ], + "revision": 3, + "version": "1.0", + "trigger_type": "tport", + "trigger_port": 8, + "trigger_bit": 0 + }, + "ddr4_buf": { + "type": "axis_buffer_ddr_v1", + "fullpath": "ddr4/axis_buffer_ddr_v1_0", + "burst_len": 128, + "junk_len": 401, + "junk_nt": 4, + "readouts": [ + "axis_avg_buffer_0", + "axis_avg_buffer_1" + ], + "revision": 3, + "version": "1.0", + "maxlen": 1073741824, + "trigger_type": "tport", + "trigger_port": 9, + "trigger_bit": 0 + }, + "gens": [ + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_0", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "00", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 0 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_1", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "01", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 1 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_2", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "02", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 2 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_3", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "03", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 3 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_4", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "10", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 4 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_5", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "11", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 5 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_6", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "12", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 6 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_7", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "13", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 7 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_8", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "20", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 8 + } + ], + "iqs": [], + "time_taggers": [], + "readouts": [ + { + "avg_maxlen": 16384, + "buf_maxlen": 4096, + "has_edge_counter": true, + "has_weights": false, + "trigger_type": "tport", + "trigger_port": 10, + "trigger_bit": 0, + "tproc_ch": 0, + "tproc_ctrl": 9, + "adc": "20", + "b_phase": 32, + "fs": 2457.6, + "fs_mult": 40, + "fs_div": 4, + "decimation": 1, + "f_fabric": 307.2, + "f_dds": 2457.6, + "fdds_div": 4, + "f_output": 307.2, + "b_dds": 32, + "iq_offset": 0.0, + "has_outsel": true, + "avgbuf_type": "axis_avg_buffer", + "ro_type": "axis_dyn_readout_v1", + "avgbuf_revision": 4, + "ro_revision": 3, + "avgbuf_version": "1.1", + "ro_version": "1.0", + "avgbuf_fullpath": "axis_avg_buffer_0", + "ro_fullpath": "axis_dyn_readout_v1_0" + }, + { + "avg_maxlen": 16384, + "buf_maxlen": 4096, + "has_edge_counter": true, + "has_weights": false, + "trigger_type": "tport", + "trigger_port": 11, + "trigger_bit": 0, + "tproc_ch": 1, + "tproc_ctrl": 10, + "adc": "22", + "b_phase": 32, + "fs": 2457.6, + "fs_mult": 40, + "fs_div": 4, + "decimation": 1, + "f_fabric": 307.2, + "f_dds": 2457.6, + "fdds_div": 4, + "f_output": 307.2, + "b_dds": 32, + "iq_offset": 0.0, + "has_outsel": true, + "avgbuf_type": "axis_avg_buffer", + "ro_type": "axis_dyn_readout_v1", + "avgbuf_revision": 4, + "ro_revision": 3, + "avgbuf_version": "1.1", + "ro_version": "1.0", + "avgbuf_fullpath": "axis_avg_buffer_1", + "ro_fullpath": "axis_dyn_readout_v1_1" + }, + { + "avg_maxlen": 16384, + "buf_maxlen": 4096, + "has_edge_counter": true, + "has_weights": false, + "trigger_type": "tport", + "trigger_port": 12, + "trigger_bit": 0, + "tproc_ch": 2, + "tproc_ctrl": 11, + "adc": "21", + "b_phase": 32, + "fs": 2457.6, + "fs_mult": 40, + "fs_div": 4, + "decimation": 1, + "f_fabric": 614.4, + "f_dds": 2457.6, + "fdds_div": 4, + "f_output": 614.4, + "b_dds": 32, + "iq_offset": -0.5, + "has_outsel": true, + "avgbuf_type": "axis_avg_buffer", + "ro_type": "axis_readout_v3", + "avgbuf_revision": 4, + "ro_revision": 4, + "avgbuf_version": "1.1", + "ro_version": "1.0", + "avgbuf_fullpath": "axis_avg_buffer_2", + "ro_fullpath": "axis_readout_v3_0" + } + ], + "tprocs": [ + { + "type": "qick_processor", + "fullpath": "qick_processor_0", + "pmem_size": 4096, + "dmem_size": 16384, + "wmem_size": 1024, + "dreg_qty": 16, + "in_port_qty": 3, + "out_trig_qty": 13, + "out_dport_qty": 1, + "out_dport_dw": 8, + "out_wport_qty": 12, + "has_lfsr": 1, + "has_divider": 1, + "has_arith": 1, + "has_time_read": 1, + "has_qcom": 0, + "has_custom_periph": 0, + "has_io_ctrl": 1, + "has_ext_flag": 0, + "has_qnet": 0, + "fifo_depth": 512, + "call_depth": 255, + "debug": 0, + "clk_srcs": { + "core clock": { + "source": [ + "PS", + 0 + ], + "f_clk": 200.00000000000003, + "src_range": [ + 66.66666666666667, + 133.33333333333334 + ] + }, + "timing clock": { + "source": [ + "dac", + 2 + ], + "f_clk": 614.4, + "src_range": null + } + }, + "revision": 21, + "version": "2.0", + "f_core": 200.00000000000003, + "f_time": 614.4, + "output_pins": [ + [ + "trig", + 0, + 0, + "PMOD0_0_LS" + ], + [ + "trig", + 1, + 0, + "PMOD0_1_LS" + ], + [ + "trig", + 2, + 0, + "PMOD0_2_LS" + ], + [ + "trig", + 3, + 0, + "PMOD0_3_LS" + ], + [ + "trig", + 4, + 0, + "PMOD0_4_LS" + ], + [ + "trig", + 5, + 0, + "PMOD0_5_LS" + ], + [ + "trig", + 6, + 0, + "PMOD0_6_LS" + ], + [ + "trig", + 7, + 0, + "PMOD0_7_LS" + ] + ], + "start_pin": "PMOD1_0_LS", + "stop_pin": "PMOD1_1_LS" + } + ] + } \ No newline at end of file diff --git a/qick_demos/qcvt/run_offline_example.py b/qick_demos/qcvt/run_offline_example.py new file mode 100644 index 00000000..a97f9175 --- /dev/null +++ b/qick_demos/qcvt/run_offline_example.py @@ -0,0 +1,89 @@ +"""Build a small QICK program from a saved ``soccfg`` and visualize it offline. + +No RFSoC connection is required: the repository ships an example +``examples/qick_config.json`` captured from a real board. To regenerate it for +your own hardware, run once while connected:: + + from qcvt import save_soccfg_to_json + save_soccfg_to_json(soc, "qick_config.json") + +Then, offline:: + + python examples/run_offline_example.py + +You can also visualize a compiled-program pickle directly (no qick needed to +plot, only to unpickle):: + + from qcvt import visualize_from_pickle + visualize_from_pickle("path/to/prog.pkl", output_path="schedule.png") +""" +from __future__ import annotations + +import os +import sys + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "qick_config.json") + + +def main() -> int: + import matplotlib + matplotlib.use("Agg") + + try: + from qcvt import load_soccfg_from_json, visualize_all + from qick.asm_v2 import AveragerProgramV2, QickSweep1D + except ImportError as exc: + print("Install qcvt with qick support: pip install -e '.[qick]'", file=sys.stderr) + raise exc + + if not os.path.isfile(CONFIG_PATH): + print(f"No soccfg found at {CONFIG_PATH}. Save one with save_soccfg_to_json(soc, path).") + return 1 + + soccfg = load_soccfg_from_json(CONFIG_PATH) + + class ExampleProgram(AveragerProgramV2): + """Gaussian qubit pulse (gain sweep) + CW pump + square readout.""" + + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=2) # qubit drive + self.declare_gen(ch=6, nqz=2) # readout drive + self.declare_gen(ch=4, nqz=1) # CW pump + self.add_loop("gainloop", 5) + self.declare_readout(ch=0, length=8.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + self.add_gauss(ch=2, name="gaussenv", sigma=0.3, length=1.8) + self.add_pulse(ch=4, name="pump", style="const", length=2.0, + freq=500, phase=0, gain=0.2, mode="periodic") + self.add_pulse(ch=2, name="qubit", ro_ch=0, style="arb", envelope="gaussenv", + freq=3200, phase=0, gain=QickSweep1D("gainloop", 0.1, 0.9)) + self.add_pulse(ch=6, name="readout", ro_ch=0, style="const", length=8.0, + freq=1000, phase=0, gain=0.5) + + def _body(self, cfg): + self.pulse(ch=4, name="pump", t=0) + self.send_readoutconfig(ch=0, name="ro", t=0) + self.pulse(ch=2, name="qubit", t=1.0) + self.delay_auto(0.05) + self.pulse(ch=6, name="readout", t=0) + self.trigger(ros=[0], pins=[0], t=0.3) + + prog = ExampleProgram(soccfg, reps=1, final_delay=50, cfg={}, reps_innermost=False) + + out_dir = os.path.join(os.path.dirname(__file__), "output") + outputs = visualize_all( + prog, + out_dir=out_dir, + title="QCVT example: gaussian qubit pulse (gain sweep), CW pump, readout", + show_amplitude=True, + physical_port_labels={"02": "qubit drive", "26": "readout out", + "24": "pump", "20": "input 0"}, + ) + for key, path in outputs.items(): + if path: + print(f" {key}: {path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qick_demos/qcvt/walkthrough_demo.py b/qick_demos/qcvt/walkthrough_demo.py new file mode 100644 index 00000000..52d3bd05 --- /dev/null +++ b/qick_demos/qcvt/walkthrough_demo.py @@ -0,0 +1,217 @@ +"""QCVT walkthrough: build several example pulse programs and visualize them. + +No RFSoC needed — uses the bundled ``examples/qick_config.json``. + + python examples/walkthrough_demo.py + +Writes PNGs (and CSVs) under ``examples/walkthrough_output/``. +""" +from __future__ import annotations + +import os +import sys + +import matplotlib + +matplotlib.use("Agg") + +from qick.asm_v2 import AveragerProgramV2, QickSweep1D +from qcvt import load_soccfg_from_json, visualize_all + +HERE = os.path.dirname(__file__) +CONFIG_PATH = os.path.join(HERE, "qick_config.json") +OUT = os.path.join(HERE, "walkthrough_output") + + +def _save(prog, name: str, title: str, **kwargs): + out_dir = os.path.join(OUT, name) + print(f"\n=== {name} ===") + print(f" {title}") + outputs = visualize_all( + prog, + out_dir=out_dir, + title=title, + show_amplitude=True, + **kwargs, + ) + for key, path in outputs.items(): + if path: + print(f" {key}: {path}") + return outputs + + +def demo_1_const_and_readout(soccfg): + """Simplest case: square drive + readout pulse + ADC window.""" + + class Prog(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=1) + self.declare_gen(ch=6, nqz=1) + self.declare_readout(ch=0, length=5.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + self.add_pulse(ch=2, name="drive", style="const", length=2.0, + freq=3200, phase=0, gain=0.6) + self.add_pulse(ch=6, name="readout", style="const", length=5.0, + freq=1000, phase=0, gain=0.4) + + def _body(self, cfg): + self.send_readoutconfig(ch=0, name="ro", t=0) + self.pulse(ch=2, name="drive", t=0) + self.delay_auto(0.1) # wait until drive ends + 0.1 us + self.pulse(ch=6, name="readout", t=0) + self.trigger(ros=[0], t=0.2) # ADC window starts 0.2 us into readout + + prog = Prog(soccfg, reps=1, final_delay=10, cfg={}) + return _save( + prog, + "01_const_readout", + "1. Square drive + readout + ADC window", + physical_port_labels={"02": "drive", "26": "readout", "20": "ADC 0"}, + ) + + +def demo_2_gaussian_and_flat_top(soccfg): + """Envelope styles: gaussian (arb) and flat_top on two generators.""" + + class Prog(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=1) + self.declare_gen(ch=3, nqz=1) + self.declare_gen(ch=6, nqz=1) + self.declare_readout(ch=0, length=4.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + + self.add_gauss(ch=2, name="genv", sigma=0.25, length=1.5) + self.add_pulse(ch=2, name="gauss", style="arb", envelope="genv", + freq=3200, phase=0, gain=0.8) + + # flat_top: rising ramp + plateau + falling ramp + self.add_gauss(ch=3, name="ftenvi", sigma=0.1, length=0.5) + self.add_pulse(ch=3, name="ft", style="flat_top", envelope="ftenvi", + freq=2800, phase=0, gain=0.5, length=2.0) + + self.add_pulse(ch=6, name="readout", style="const", length=4.0, + freq=1000, phase=0, gain=0.35) + + def _body(self, cfg): + self.send_readoutconfig(ch=0, name="ro", t=0) + self.pulse(ch=2, name="gauss", t=0) + self.pulse(ch=3, name="ft", t=0.2) + self.delay_auto(0.15) + self.pulse(ch=6, name="readout", t=0) + self.trigger(ros=[0], t=0.2) + + prog = Prog(soccfg, reps=1, final_delay=10, cfg={}) + return _save( + prog, + "02_gaussian_flattop", + "2. Gaussian (arb) + flat_top envelopes", + physical_port_labels={"02": "gaussian", "03": "flat_top", + "26": "readout", "20": "ADC 0"}, + ) + + +def demo_3_cw_pump_and_gain_sweep(soccfg): + """CW (periodic) pump + qubit pulse with a swept gain (power Rabi style).""" + + class Prog(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=0, nqz=1) # CW pump + self.declare_gen(ch=2, nqz=1) # qubit + self.declare_gen(ch=6, nqz=1) # readout + self.declare_readout(ch=0, length=6.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + + self.add_loop("gainloop", 8) + self.add_pulse(ch=0, name="pump", style="const", length=1.0, + freq=500, phase=0, gain=0.25, mode="periodic") + self.add_pulse(ch=0, name="pump_off", style="const", length=1.0, + freq=500, phase=0, gain=0.25, mode="oneshot") + + self.add_gauss(ch=2, name="genv", sigma=0.3, length=1.8) + self.add_pulse(ch=2, name="qubit", style="arb", envelope="genv", + freq=3200, phase=0, + gain=QickSweep1D("gainloop", 0.0, 0.9)) + + self.add_pulse(ch=6, name="readout", style="const", length=6.0, + freq=1000, phase=0, gain=0.4) + + # Start CW pump in setup (before the loop body) + self.pulse(ch=0, name="pump", t=0) + self.delay(0.5) + + def _body(self, cfg): + self.send_readoutconfig(ch=0, name="ro", t=0) + self.pulse(ch=2, name="qubit", t=0) + self.delay_auto(0.05) + self.pulse(ch=6, name="readout", t=0) + self.trigger(ros=[0], t=0.3) + + def _cleanup(self, cfg): + # One-shot pulse ends the periodic pump + self.pulse(ch=0, name="pump_off", t="auto") + + prog = Prog(soccfg, reps=1, final_delay=10, cfg={}) + return _save( + prog, + "03_cw_and_sweep", + "3. CW pump (hatched) + swept-gain qubit pulse", + physical_port_labels={"00": "CW pump", "02": "qubit (sweep)", + "26": "readout", "20": "ADC 0"}, + time_origin="body", # t=0 at start of loop body (matches _body times) + ) + + +def demo_4_multi_timescale(soccfg): + """Short ns-scale pulse next to a long readout — triggers zoom inset.""" + + class Prog(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=1) + self.declare_gen(ch=6, nqz=1) + self.declare_readout(ch=0, length=40.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + + # ~100 ns gaussian next to a 40 us readout + self.add_gauss(ch=2, name="short", sigma=0.02, length=0.1) + self.add_pulse(ch=2, name="pi", style="arb", envelope="short", + freq=4500, phase=0, gain=0.9) + self.add_pulse(ch=6, name="readout", style="const", length=40.0, + freq=1000, phase=0, gain=0.3) + + def _body(self, cfg): + self.send_readoutconfig(ch=0, name="ro", t=0) + self.pulse(ch=2, name="pi", t=0) + self.delay_auto(0.02) + self.pulse(ch=6, name="readout", t=0) + self.trigger(ros=[0], t=0.5) + + prog = Prog(soccfg, reps=1, final_delay=10, cfg={}) + return _save( + prog, + "04_multi_timescale", + "4. Multi-timescale: 100 ns pulse + 40 us readout (auto inset)", + physical_port_labels={"02": "short pi", "26": "readout", "20": "ADC 0"}, + ) + + +def main() -> int: + if not os.path.isfile(CONFIG_PATH): + print(f"Missing {CONFIG_PATH}", file=sys.stderr) + return 1 + + soccfg = load_soccfg_from_json(CONFIG_PATH) + os.makedirs(OUT, exist_ok=True) + + demo_1_const_and_readout(soccfg) + demo_2_gaussian_and_flat_top(soccfg) + demo_3_cw_pump_and_gain_sweep(soccfg) + demo_4_multi_timescale(soccfg) + + print(f"\nAll figures written under {OUT}/") + print("Open each subdirectory's schedule.png (and edges_*.png) to inspect.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qick_lib/qcvt/__init__.py b/qick_lib/qcvt/__init__.py new file mode 100644 index 00000000..abeb2802 --- /dev/null +++ b/qick_lib/qcvt/__init__.py @@ -0,0 +1,46 @@ +"""QCVT: visualization and edge-matrix export for QICK ``asm_v2`` pulse programs.""" + +from .model import ( + PulseEvent, + QCVTError, + Schedule, + amplitude_trace, + extract_schedule, + is_strict, + strict_mode, +) +from .plotting import plot_pulse_schedule, show_schedule +from .export import ( + csv_to_table_png, + export_edge_matrix_csv, +) +from .io import ( + load_program_pickle, + load_soccfg_from_json, + review_schedule, + save_soccfg_to_json, + visualize_all, + visualize_from_pickle, +) + +__all__ = [ + "PulseEvent", + "Schedule", + "QCVTError", + "extract_schedule", + "strict_mode", + "is_strict", + "amplitude_trace", + "plot_pulse_schedule", + "show_schedule", + "review_schedule", + "export_edge_matrix_csv", + "csv_to_table_png", + "save_soccfg_to_json", + "load_soccfg_from_json", + "load_program_pickle", + "visualize_from_pickle", + "visualize_all", +] + +__version__ = "0.2.2" diff --git a/qick_lib/qcvt/cli.py b/qick_lib/qcvt/cli.py new file mode 100644 index 00000000..7fec9275 --- /dev/null +++ b/qick_lib/qcvt/cli.py @@ -0,0 +1,83 @@ +"""Command-line interface for QCVT. + +Plot a pulse schedule and export a state edge matrix from a compiled QICK +program pickle, without a live RFSoC connection:: + + qcvt --pickle prog.pkl --out-dir ./out --show-amplitude +""" +from __future__ import annotations + +import argparse +import os +import sys + + +def main() -> int: + parser = argparse.ArgumentParser( + description="QCVT: visualize a compiled QICK asm_v2 program and export a state edge matrix.", + ) + parser.add_argument("--pickle", required=True, help="Path to compiled program pickle (.pkl)") + parser.add_argument("--out-dir", default=".", help="Output directory (default: current dir)") + parser.add_argument("--title", default="Pulse schedule", help="Schedule plot title") + parser.add_argument("--show-amplitude", action="store_true", + help="Add an amplitude vs. time panel to the schedule plot") + parser.add_argument("--amplitude-units", choices=("dac", "norm"), default="dac", + help="Amplitude units for the plot panel (default: dac)") + parser.add_argument("--no-table-png", action="store_true", + help="Write the edge-matrix CSV only (skip the table PNG)") + parser.add_argument("--strict", action="store_true", + help="Raise on incomplete/ambiguous schedule extraction") + parser.add_argument("--no-suppress-off", action="store_true", + help="Do not hide '*_off' cleanup pulses co-timed with CW") + parser.add_argument("--t0", type=float, default=0.0, help="Export window start (µs)") + parser.add_argument("--t1", type=float, default=None, + help="Export window end (µs); default: infer from schedule") + parser.add_argument("--time-origin", choices=("program", "body"), default="program", + help="Time axis origin for the schedule plot: 'program' " + "(absolute, default) or 'body' (t=0 at loop-body start). " + "Exports always use the absolute timeline.") + args = parser.parse_args() + + if not os.path.isfile(args.pickle): + print(f"Error: pickle file not found: {args.pickle}", file=sys.stderr) + return 1 + + # Non-interactive backend before importing pyplot anywhere. + import matplotlib + matplotlib.use("Agg") + + from .io import load_program_pickle, visualize_all + from .model import QCVTError + + try: + prog = load_program_pickle(args.pickle) + except Exception as exc: + print(f"Error: could not load pickle ({exc}).", file=sys.stderr) + return 1 + + try: + outputs = visualize_all( + prog, + out_dir=args.out_dir, + title=args.title, + show_amplitude=args.show_amplitude, + amplitude_units=args.amplitude_units, + t0_us=args.t0, + t1_us=args.t1, + time_origin=args.time_origin, + write_table_png=not args.no_table_png, + strict=args.strict, + suppress_off_pulses=not args.no_suppress_off, + ) + except QCVTError as exc: + print(f"Error: strict schedule extraction failed ({exc}).", file=sys.stderr) + return 1 + + for key, path in outputs.items(): + if path: + print(f"Saved {path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qick_lib/qcvt/export.py b/qick_lib/qcvt/export.py new file mode 100644 index 00000000..78a02fa4 --- /dev/null +++ b/qick_lib/qcvt/export.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +""" +CSV / table exports derived from a QICK pulse :class:`~qcvt.model.Schedule`. + +:func:`export_edge_matrix_csv` writes a compact on/off "edge matrix": one row +per lane, one column per timestamp at which some lane's state changes. + +:func:`csv_to_table_png` renders that CSV as a highlighted table image. +""" + +from __future__ import annotations + +import csv as _csv +from typing import List, Optional, Tuple + +from .model import Schedule, extract_schedule + + +def _as_schedule(prog_or_schedule) -> Schedule: + if isinstance(prog_or_schedule, Schedule): + return prog_or_schedule + return extract_schedule(prog_or_schedule) + + +def _gen_intervals(sched: Schedule, window_end_us: float): + """Return ``{ch: [(t0, t1), ...]}`` for generator channels.""" + draw_lengths = sched.draw_lengths(window_end_us) + suppressed = sched.suppressed_events() + out = {} + for e in sched.gen_events: + if id(e) in suppressed: + continue + draw_len = draw_lengths.get(id(e), e.length) + if draw_len <= 0.0: + continue + out.setdefault(e.ch, []).append((float(e.t_start), float(e.t_start + draw_len))) + return out + + +def _adc_intervals(sched: Schedule): + out = {} + for e in sched.adc_events: + out.setdefault(e.ch, []).append((float(e.t_start), float(e.t_end))) + return out + + +def export_edge_matrix_csv( + prog, + out_prefix: str, + t0_us: float, + t1_us: Optional[float], + rows: Optional[List[Tuple[str, str, int]]] = None, + schedule: Optional[Schedule] = None, +) -> str: + """Export an on/off edge matrix as ``{out_prefix}_state.csv``. + + Columns are timestamps (ns) at which at least one lane changes state. + Entries are ``on`` / ``off``. + + ``rows`` is a list of ``(label, kind, ch)`` with ``kind`` in ``{"gen","adc"}``; + when ``None`` it defaults to every generator then every readout channel. + + Returns the CSV path. + """ + sched = schedule if schedule is not None else _as_schedule(prog) + if not sched: + raise RuntimeError("No schedule could be extracted from this program.") + if t1_us is None: + t1_us = sched.end_us() + + gen_intervals = _gen_intervals(sched, t1_us) + adc_intervals = _adc_intervals(sched) + intervals = {("gen", ch): segs for ch, segs in gen_intervals.items()} + intervals.update({("adc", ch): segs for ch, segs in adc_intervals.items()}) + + if rows is None: + rows = ([(f"gen {ch}", "gen", ch) for ch in sorted(gen_intervals)] + + [(f"ro {ch}", "adc", ch) for ch in sorted(adc_intervals)]) + + def on_at(kind: str, ch: int, t: float) -> bool: + for t0, t1 in intervals.get((kind, ch), []): + if t0 <= t < t1: + return True + return False + + edge_times = {float(t0_us), float(t1_us)} + for segs in intervals.values(): + for a, b in segs: + if t0_us <= a <= t1_us: + edge_times.add(float(a)) + if t0_us <= b <= t1_us: + edge_times.add(float(b)) + edge_times = sorted(edge_times) + + # Keep only timestamps where some lane's on/off state actually changes. + columns: List[float] = [] + for t in edge_times: + if not columns: + columns.append(t) + continue + prev = columns[-1] + changed = any( + on_at(kind, ch, prev) != on_at(kind, ch, t) + for _label, kind, ch in rows + ) + if changed: + columns.append(t) + + state_rows = [] + for label, kind, ch in rows: + srow = [label] + for col in columns: + srow.append("on" if on_at(kind, int(ch), float(col)) else "off") + state_rows.append(srow) + + header = ["timestamp (ns)"] + _unique_time_labels([c * 1_000.0 for c in columns]) + state_path = f"{out_prefix}_state.csv" + with open(state_path, "w", newline="") as f: + w = _csv.writer(f) + w.writerow(header) + w.writerows(state_rows) + return state_path + + +def _unique_time_labels(values_ns) -> List[str]: + """Format timestamps with the fewest decimals that keep them distinct.""" + for decimals in range(2, 10): + labels = [] + for v in values_ns: + mant, exp = f"{v:.{decimals}e}".split("e") + mant = mant.rstrip("0").rstrip(".") + labels.append(f"{mant}e{int(exp)}") + if len(labels) == len(set(labels)): + return labels + seen, out = {}, [] + for lbl in labels: + n = seen.get(lbl, 0) + 1 + seen[lbl] = n + out.append(lbl if n == 1 else f"{lbl}({n})") + return out + + +def csv_to_table_png(csv_path: str, png_path: str, title: str = "") -> None: + """Render a CSV (e.g. an edge matrix) as a PNG table. + + Cells that are ``on`` are highlighted. Uses the stdlib ``csv`` module + (no pandas required). + """ + import matplotlib.pyplot as plt + + with open(csv_path, newline="") as f: + rows = list(_csv.reader(f)) + if not rows: + raise ValueError(f"empty CSV: {csv_path}") + header, data = rows[0], rows[1:] + + def _display_col(col: str) -> str: + if col == header[0]: + return col + s = str(col).strip() + if "(" in s and s.endswith(")"): + base, suf = s.rsplit("(", 1) + base = base.strip() + try: + float(base) + return base + "(" + suf + except ValueError: + pass + return s + + display_cols = [_display_col(c) for c in header] + fig_h = max(2.5, 0.55 * (len(data) + 1)) + fig_w = max(8.0, 0.8 * len(header)) + fig, ax = plt.subplots(figsize=(fig_w, fig_h)) + ax.axis("off") + cell_text = data if data else [[""] * len(header)] + tbl = ax.table( + cellText=cell_text, colLabels=display_cols, loc="center", cellLoc="center", + ) + tbl.auto_set_font_size(False) + tbl.set_fontsize(9) + tbl.scale(1.0, 1.35) + try: + first_w = tbl[(0, 0)].get_width() + for (r, c), cell in tbl.get_celld().items(): + if c == 0: + cell.set_width(first_w * 1.8) + except Exception: + pass + + highlight = "#d9ecff" + for (r, c), cell in tbl.get_celld().items(): + if r == 0 or c <= 0: + continue + try: + val = data[r - 1][c] + except (IndexError, TypeError): + continue + on = isinstance(val, str) and val.strip().lower() == "on" + if on: + cell.set_facecolor(highlight) + + if title: + ax.set_title(title, pad=6) + fig.tight_layout() + fig.savefig(png_path, dpi=200, bbox_inches="tight") + plt.close(fig) diff --git a/qick_lib/qcvt/io.py b/qick_lib/qcvt/io.py new file mode 100644 index 00000000..4c29cafb --- /dev/null +++ b/qick_lib/qcvt/io.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +""" +Input/output helpers and one-call orchestration for QCVT. + +These functions cover the two offline entry points: + +* load a compiled-program pickle (as saved into a QCoDeS dataset) and visualize it; +* save/load a ``soccfg`` JSON so programs can be rebuilt and visualized without a + live RFSoC connection. + +:func:`visualize_all` extracts the schedule once and produces every artifact +(schedule PNG, state edge matrix and its table PNG). +""" + +from __future__ import annotations + +import json +import os +from typing import List, Optional, Tuple + +from .export import ( + csv_to_table_png, + export_edge_matrix_csv, +) +from .model import extract_schedule +from .plotting import plot_pulse_schedule + +try: # optional: only needed to build QickConfig from JSON + from qick.qick_asm import QickConfig +except Exception: # pragma: no cover - qick not installed + QickConfig = None + + +def save_soccfg_to_json(soc, path: str) -> None: + """Save the current RFSoC config to JSON so programs can be built offline. + + Call this once while connected:: + + save_soccfg_to_json(soc, "qick_config.json") + """ + with open(path, "w") as f: + json.dump(soc.get_cfg(), f, indent=2) + print(f"Saved soccfg to {path}") + + +def load_soccfg_from_json(path: str): + """Load a :class:`QickConfig` from a JSON file (requires ``qick``).""" + if QickConfig is None: + raise ImportError("qick is required for load_soccfg_from_json") + return QickConfig(path) + + +def load_program_pickle(pickle_path: str): + """Load a compiled program from a cloudpickle/pickle file. + + Prefers ``cloudpickle`` when installed (``pip install qcvt[pickle]``); + falls back to the stdlib ``pickle`` module. + """ + try: + import cloudpickle as pickle_mod + except ImportError: + import pickle as pickle_mod + with open(pickle_path, "rb") as f: + return pickle_mod.load(f) + + +def visualize_from_pickle( + pickle_path: str, + output_path: Optional[str] = None, + title: Optional[str] = None, + show_amplitude: bool = True, + amplitude_units: str = "dac", + show: bool = False, + t0_us: float = 0.0, + max_time_us: Optional[float] = None, +): + """Load a compiled-program pickle and plot its pulse schedule. + + Parameters + ---------- + pickle_path : str + Path to the ``.pkl`` file (e.g. ``compiled_program_pickle`` from a dataset). + output_path : str, optional + If set, save the figure here. + title : str, optional + Plot title. + show_amplitude : bool + Include the amplitude panel. + amplitude_units : str + ``"dac"`` or ``"norm"``. + show : bool + Call ``plt.show()`` (defaults to ``False`` so the function is headless-safe). + t0_us, max_time_us : + Optional viewing window in microseconds. + + Returns + ------- + (prog, ax) + """ + import matplotlib.pyplot as plt + + prog = load_program_pickle(pickle_path) + result = plot_pulse_schedule( + prog, + show_amplitude=show_amplitude, + amplitude_units=amplitude_units, + title=title or "Pulse schedule", + t0_us=t0_us, + max_time_us=max_time_us, + ) + ax = result[0] if isinstance(result, tuple) else result + if output_path: + ax.figure.savefig(output_path, dpi=150, bbox_inches="tight") + print(f"Saved figure to {output_path}") + if show: + plt.show() + return prog, ax + + +def review_schedule( + prog, + save_dir: Optional[str] = None, + title: str = "Pulse schedule (pre-submit review)", + show_amplitude: bool = True, + amplitude_units: str = "dac", + gen_ch_labels: Optional[dict] = None, + physical_port_labels: Optional[dict] = None, + t0_us: float = 0.0, + max_time_us: Optional[float] = None, + show: bool = False, + confirm: bool = False, + full_export: bool = False, + time_origin: str = "program", + insets: Optional[bool] = None, + strict: bool = False, + suppress_off_pulses: bool = True, +) -> bool: + """Pre-submission gate: visualize the schedule before sending it to the RFSoC. + + Always saves a schedule PNG when ``save_dir`` is set (created if missing). + Optionally displays the figure and/or prompts for confirmation. + + Parameters + ---------- + prog : + Compiled QICK program. + save_dir : str, optional + Directory for the review PNG (and optional full export). If ``None``, + nothing is written to disk. + title : str + Plot title. + show : bool + If ``True``, call ``plt.show()`` (interactive). Defaults to ``False`` + so this is safe in headless / scripted runs. + confirm : bool + If ``True``, prompt the user with ``Proceed with acquisition? [y/N]``. + Returns ``False`` if they decline (caller should abort ``prog.acquire``). + full_export : bool + If ``True`` and ``save_dir`` is set, also write the state edge matrix + via :func:`visualize_all`. + time_origin : str + ``"program"`` (absolute timeline, default) or ``"body"`` (t = 0 at the + start of the loop body). Affects plots only; exports stay absolute. + t0_us, max_time_us : + Optional viewing window (µs). Use to zoom on short pulses next to long ones. + insets : bool or None + Zoom inset around short pulses. ``None`` = auto when length dynamic range + is large; ``True``/``False`` force on/off. + + Returns + ------- + bool + ``True`` if acquisition should proceed, ``False`` if the user aborted. + When ``confirm`` is ``False``, always returns ``True``. + """ + import matplotlib.pyplot as plt + + schedule_path = None + sched = extract_schedule( + prog, strict=strict, suppress_off_pulses=suppress_off_pulses, + ) + if save_dir is not None and full_export: + os.makedirs(save_dir, exist_ok=True) + visualize_all( + prog, + out_dir=save_dir, + title=title, + show_amplitude=show_amplitude, + amplitude_units=amplitude_units, + t0_us=t0_us, + t1_us=max_time_us, + gen_ch_labels=gen_ch_labels, + physical_port_labels=physical_port_labels, + show=False, + time_origin=time_origin, + insets=insets, + strict=strict, + suppress_off_pulses=suppress_off_pulses, + ) + schedule_path = os.path.join(save_dir, "schedule.png") + print(f"QCVT review saved: {schedule_path}") + if show: + plot_pulse_schedule( + prog, + schedule=sched, + show_amplitude=show_amplitude, + amplitude_units=amplitude_units, + gen_ch_labels=gen_ch_labels, + physical_port_labels=physical_port_labels, + title=title, + t0_us=t0_us, + max_time_us=max_time_us, + time_origin=time_origin, + insets=insets, + ) + plt.show() + else: + result = plot_pulse_schedule( + prog, + schedule=sched, + show_amplitude=show_amplitude, + amplitude_units=amplitude_units, + gen_ch_labels=gen_ch_labels, + physical_port_labels=physical_port_labels, + title=title, + t0_us=t0_us, + max_time_us=max_time_us, + time_origin=time_origin, + insets=insets, + ) + ax = result[0] if isinstance(result, tuple) else result + if save_dir is not None: + os.makedirs(save_dir, exist_ok=True) + schedule_path = os.path.join(save_dir, "schedule.png") + ax.figure.savefig(schedule_path, dpi=150, bbox_inches="tight") + print(f"QCVT review saved: {schedule_path}") + if show: + plt.show() + else: + plt.close(ax.figure) + + if not confirm: + return True + try: + answer = input("Proceed with RFSoC acquisition? [y/N] ").strip().lower() + except EOFError: + answer = "" + if answer in ("y", "yes"): + return True + print("Acquisition aborted by user.") + return False + + +def visualize_all( + prog, + out_dir: str, + title: str = "Pulse schedule", + show_amplitude: bool = True, + amplitude_units: str = "dac", + t0_us: float = 0.0, + t1_us: Optional[float] = None, + rows: Optional[List[Tuple[str, str, int]]] = None, + gen_ch_labels: Optional[dict] = None, + physical_port_labels: Optional[dict] = None, + schedule_dpi: int = 150, + show: bool = False, + insets: Optional[bool] = None, + time_origin: str = "program", + write_table_png: bool = True, + strict: bool = False, + suppress_off_pulses: bool = True, +) -> dict: + """Generate every visualization artifact for ``prog`` in ``out_dir``. + + Returns a dict of output paths (values are ``None`` when a step is skipped). + + ``time_origin="body"`` shifts the schedule *plot* so t = 0 is the start of + the loop body; the edge-matrix export always stays on the absolute timeline. + + ``write_table_png=False`` writes the edge-matrix CSV only (skips the table + PNG). ``strict`` and ``suppress_off_pulses`` are forwarded to + :func:`~qcvt.model.extract_schedule`. + """ + import matplotlib.pyplot as plt + + os.makedirs(out_dir, exist_ok=True) + sched = extract_schedule( + prog, strict=strict, suppress_off_pulses=suppress_off_pulses, + ) + results: dict = {} + + schedule_path = os.path.join(out_dir, "schedule.png") + result = plot_pulse_schedule( + prog, + schedule=sched, + gen_ch_labels=gen_ch_labels, + physical_port_labels=physical_port_labels, + show_amplitude=show_amplitude, + amplitude_units=amplitude_units, + title=title, + t0_us=t0_us, + max_time_us=t1_us, + insets=insets, + time_origin=time_origin, + ) + ax = result[0] if isinstance(result, tuple) else result + ax.figure.savefig(schedule_path, dpi=schedule_dpi, bbox_inches="tight") + if show: + plt.show() + plt.close(ax.figure) + results["schedule_png"] = schedule_path + + edges_prefix = os.path.join(out_dir, "edges") + try: + state_csv = export_edge_matrix_csv( + prog, out_prefix=edges_prefix, t0_us=t0_us, t1_us=t1_us, + rows=rows, schedule=sched, + ) + results["edges_state_csv"] = state_csv + if write_table_png: + state_png = edges_prefix + "_state.png" + csv_to_table_png(state_csv, state_png, "State edge summary") + results["edges_state_png"] = state_png + else: + results["edges_state_png"] = None + except RuntimeError: + results["edges_state_csv"] = None + results["edges_state_png"] = None + + return results diff --git a/qick_lib/qcvt/model.py b/qick_lib/qcvt/model.py new file mode 100644 index 00000000..48bdb1b4 --- /dev/null +++ b/qick_lib/qcvt/model.py @@ -0,0 +1,702 @@ +# -*- coding: utf-8 -*- +""" +Data model and schedule extraction for QCVT. + +The core idea: a compiled QICK ``asm_v2`` program stores everything we need to +draw a pulse schedule, but in a form that is awkward to consume directly. This +module turns a compiled program into a small, explicit, sweep-aware +:class:`Schedule` made of :class:`PulseEvent` objects, all expressed in +**microseconds**. + +Why microseconds (and not cycles)? QICK stores each timed instruction's time as +a ``QickParam`` in microseconds (``macro.t_params[...]``) and each pulse's length +in microseconds (``pulse.get_length()``). Working in microseconds sidesteps the +per-channel clock conversions (generators, readouts and the tProc all run at +different clock rates) that are a common source of off-by-a-clock-ratio bugs, and +lets every channel share one correct time axis. + +Absolute timing: pulses are scheduled at a *local* time ``t`` relative to a moving +reference. ``Delay`` instructions advance that reference (their stored ``t`` is +the fully resolved delay, including ``delay_auto``), so we accumulate delays as we +walk the macro list to recover absolute times. ``Resync`` also advances the +reference (by at most ``t``; times after it are upper bounds). ``Wait`` stalls +the processor but does not move the reference, so it is ignored for placement. +""" + +from __future__ import annotations + +import re +import warnings +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, List, Optional, Tuple + +import numpy as np + +# Timed macros that legitimately place nothing on the timeline. +_IGNORED_TIMED_MACROS = frozenset({"Wait", "ConfigReadout"}) + +# Matches pulse names whose final token is "off"/"turnoff" (e.g. "pump_off", +# "turn_off", "turnoff", "off") without matching "offset_cal" or +# "off_resonant_probe", where "off" is not a trailing cleanup token. +# Lab convention for CW cleanup pulses; disable via suppress_off_pulses=False. +_OFF_PULSE_RE = re.compile(r"(^|[_\-\s])(turn[_\-\s]?)?off$") + +_STRICT: ContextVar[bool] = ContextVar("qcvt_strict", default=False) + + +class QCVTError(RuntimeError): + """Raised in strict mode when schedule extraction cannot proceed safely.""" + + +def is_strict() -> bool: + """Return whether the current thread/context is in strict extraction mode.""" + return _STRICT.get() + + +@contextmanager +def strict_mode(enabled: bool = True) -> Iterator[None]: + """Context manager that makes schedule extraction raise on ambiguities. + + Default (non-strict) mode skips unparseable macros with a warning so a plot + can still be produced. Strict mode is for verification gates where a + silently incomplete schedule is worse than an error. + """ + token = _STRICT.set(bool(enabled)) + try: + yield + finally: + _STRICT.reset(token) + + +def _fail(message: str, cause: Optional[BaseException] = None) -> None: + """Raise :class:`QCVTError` in strict mode; otherwise emit a warning.""" + if is_strict(): + if cause is not None: + raise QCVTError(message) from cause + raise QCVTError(message) + warnings.warn(f"QCVT: {message}") + + +# --------------------------------------------------------------------------- # +# QickParam helpers (work on plain numbers too, so nothing here requires qick) +# --------------------------------------------------------------------------- # +def _is_qickparam(x: Any) -> bool: + """True if ``x`` looks like a QickParam (has ``.start`` and ``.spans``).""" + return hasattr(x, "start") and hasattr(x, "spans") + + +def param_nominal(x: Any) -> float: + """Return a single representative float from a QickParam or number.""" + if _is_qickparam(x): + try: + return float(x.start) + except Exception: + try: + return float(x.minval()) + except Exception as exc: + _fail(f"could not read QickParam nominal value from {x!r}", exc) + return float("nan") + try: + return float(x) + except Exception as exc: + _fail(f"could not coerce {x!r} to float", exc) + return float("nan") + + +def param_range(x: Any) -> Tuple[float, float, bool]: + """Return ``(min, max, is_swept)`` for a QickParam or number (same units).""" + if _is_qickparam(x): + swept = bool(getattr(x, "spans", None)) + try: + lo, hi = float(x.minval()), float(x.maxval()) + except Exception as exc: + _fail(f"could not read QickParam range from {x!r}", exc) + v = param_nominal(x) + lo = hi = v + return lo, hi, swept + v = param_nominal(x) + return v, v, False + + +def _finite(*vals: float) -> bool: + return all(np.isfinite(v) for v in vals) + + +# --------------------------------------------------------------------------- # +# Data model +# --------------------------------------------------------------------------- # +@dataclass +class PulseEvent: + """A single generator pulse or ADC integration window, in microseconds.""" + + ch: int + name: str + kind: str # "gen" or "adc" + t_start: float # nominal absolute start time (us) + length: float # nominal length (us) + t_min: float = 0.0 + t_max: float = 0.0 + len_min: float = 0.0 + len_max: float = 0.0 + style: str = "const" + envelope: Optional[str] = None + periodic: bool = False + gain: float = 0.0 + gain_min: float = 0.0 + gain_max: float = 0.0 + freq: Optional[float] = None + phase: Optional[float] = None + swept_params: Tuple[str, ...] = () + + @property + def t_end(self) -> float: + return self.t_start + self.length + + @property + def time_swept(self) -> bool: + return not np.isclose(self.t_min, self.t_max) + + @property + def length_swept(self) -> bool: + return not np.isclose(self.len_min, self.len_max) + + @property + def gain_swept(self) -> bool: + return not np.isclose(self.gain_min, self.gain_max) + + +def representative_gain(event: "PulseEvent") -> float: + """A single gain value suitable for drawing/exporting one pulse. + + For a swept gain the nominal value is the sweep *start*, which is 0.0 for a + power Rabi — that would render the pulse under test as a flat zero line. + Gains are signed in QICK (sweeps like -0.6..0.6 are common), so use the + sweep endpoint with the largest magnitude: the pulse is drawn/exported at + its largest amplitude extent. + """ + if not event.gain_swept: + return event.gain + if abs(event.gain_max) >= abs(event.gain_min): + return event.gain_max + return event.gain_min + + +def gain_band(event: "PulseEvent") -> Tuple[float, float]: + """``(lo, hi)`` bounds of ``|gain|`` over a pulse's sweep. + + Used to draw the amplitude min→max band. A sweep whose sign changes + (e.g. -0.6..0.6) passes through zero amplitude, so ``lo`` is 0. + """ + if not event.gain_swept: + g = abs(event.gain) + return g, g + hi = max(abs(event.gain_min), abs(event.gain_max)) + if event.gain_min < 0.0 < event.gain_max: + lo = 0.0 + else: + lo = min(abs(event.gain_min), abs(event.gain_max)) + return lo, hi + + +@dataclass +class Schedule: + """A normalized, sweep-aware view of a compiled QICK program.""" + + events: List[PulseEvent] = field(default_factory=list) + soccfg: Any = None + prog: Any = None + loop_dict: Dict[str, int] = field(default_factory=dict) + # Absolute time (us) at which the loop body starts (first OpenLoop); used + # for time_origin="body" plotting. Everything before it is _initialize(). + body_start_us: float = 0.0 + # Hide non-periodic "*_off"/"turnoff" pulses that share a timestamp with a + # periodic pulse on the same channel (common CW cleanup convention). + suppress_off_pulses: bool = True + + @property + def gen_events(self) -> List[PulseEvent]: + return [e for e in self.events if e.kind == "gen"] + + @property + def adc_events(self) -> List[PulseEvent]: + return [e for e in self.events if e.kind == "adc"] + + @property + def gen_chs(self) -> List[int]: + return sorted({e.ch for e in self.gen_events if e.ch >= 0}) + + @property + def adc_chs(self) -> List[int]: + return sorted({e.ch for e in self.adc_events}) + + def __bool__(self) -> bool: + return bool(self.events) + + def __len__(self) -> int: + return len(self.events) + + def end_us(self) -> float: + """Nominal end time of the last event (us).""" + return max((e.t_end for e in self.events), default=1.0) + + def draw_lengths(self, window_end_us: Optional[float] = None) -> Dict[int, float]: + """Resolve display lengths, extending ``periodic`` pulses to the next + event on the same channel (or the window end). Keyed by ``id(event)``. + """ + if window_end_us is None: + window_end_us = self.end_us() + out: Dict[int, float] = {} + by_ch: Dict[int, List[PulseEvent]] = {} + for e in self.gen_events: + by_ch.setdefault(e.ch, []).append(e) + for ch, evs in by_ch.items(): + evs_sorted = sorted(evs, key=lambda e: e.t_start) + for i, e in enumerate(evs_sorted): + if not e.periodic: + out[id(e)] = e.length + continue + # Extend to the next strictly-later event on this channel. + k = i + 1 + while k < len(evs_sorted) and evs_sorted[k].t_start <= e.t_start + 1e-9: + k += 1 + nxt = evs_sorted[k].t_start if k < len(evs_sorted) else window_end_us + out[id(e)] = max(0.0, nxt - e.t_start) + return out + + def suppressed_events(self) -> set: + """Return ids of events to hide: a non-periodic "off"/"turnoff" pulse + scheduled at the same time as a periodic pulse on the same channel + (a common cleanup artifact that would otherwise clutter the plot). + + Disabled when ``suppress_off_pulses`` is ``False``. + """ + if not self.suppress_off_pulses: + return set() + skip = set() + by_key: Dict[Tuple[int, float], List[PulseEvent]] = {} + for e in self.gen_events: + by_key.setdefault((e.ch, round(e.t_start, 9)), []).append(e) + for evs in by_key.values(): + if len(evs) < 2: + continue + if any(e.periodic for e in evs): + for e in evs: + n = e.name.lower() + if not e.periodic and _OFF_PULSE_RE.search(n): + skip.add(id(e)) + return skip + + +# --------------------------------------------------------------------------- # +# Pulse parameter lookups +# --------------------------------------------------------------------------- # +def _call(obj: Any, name: str, *args, default: Any = None): + """Call ``obj.name(*args)`` if it exists; otherwise return ``default``.""" + fn = getattr(obj, name, None) + if callable(fn): + return fn(*args) + return default + + +def _macro_time_param(macro: Any, name: str): + """Return the (rounded, sweep-aware) time QickParam for ``name`` in us.""" + getter = getattr(macro, "get_time_param", None) + if callable(getter): + try: + return getter(name) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"get_time_param({name!r}) failed on {type(macro).__name__}" + ) from exc + return getattr(macro, "t_params", {}).get(name) + + +def _pulse_param_range(prog: Any, name: str, param: str) -> Tuple[float, float, float, bool]: + """Return ``(nominal, min, max, is_swept)`` for a pulse parameter. + + Prefers ``prog.get_pulse_param`` (fully rounded, loop-aware); falls back to + the raw ``pulse.params`` entry. + """ + getter = getattr(prog, "get_pulse_param", None) + if callable(getter): + try: + arr = np.asarray(getter(name, param, as_array=True), dtype=float).ravel() + if arr.size: + lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr)) + return float(arr.flat[0]), lo, hi, not np.isclose(lo, hi) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"get_pulse_param({name!r}, {param!r}) failed" + ) from exc + try: + pulses = _call(prog, "get_pulses", default=None) or getattr(prog, "pulses", {}) + pulse = pulses[name] + params = _call(pulse, "get_params", default=None) or getattr(pulse, "params", {}) + p = params.get(param) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"could not read pulse param {param!r} for {name!r}" + ) from exc + p = None + if p is None: + return 0.0, 0.0, 0.0, False + lo, hi, swept = param_range(p) + return param_nominal(p), lo, hi, swept + + +def _ro_length_us(prog: Any, ro: int) -> Optional[float]: + """ADC integration-window length (us) for readout channel ``ro``.""" + getter = getattr(prog, "get_ro_length_us", None) + if callable(getter): + try: + return float(getter(ro)) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"could not resolve readout length for channel {ro}" + ) from exc + try: + ro_chs = _call(prog, "get_ro_chs", default=None) or prog.ro_chs + rc = ro_chs[ro] + if "length_us" in rc: + return float(rc["length_us"]) + length = rc["length"] + soccfg = getattr(prog, "soccfg", None) + f_output = _call(soccfg, "get_ro_f_output", ro, default=None) + if f_output is None: + f_output = soccfg["readouts"][ro]["f_output"] + return float(length) / float(f_output) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"could not resolve readout length for channel {ro}" + ) from exc + return None + + +# --------------------------------------------------------------------------- # +# Extraction +# --------------------------------------------------------------------------- # +def extract_schedule( + prog: Any, + *, + strict: bool = False, + suppress_off_pulses: bool = True, +) -> Schedule: + """Build a :class:`Schedule` from a compiled QICK ``asm_v2`` program. + + The program must be compiled (``AveragerProgramV2`` compiles on construction). + Timing is recovered in microseconds and is sweep-aware. + + Parameters + ---------- + prog : + Compiled ``AveragerProgramV2`` (or compatible) instance. + strict : bool + If ``False`` (default), a macro that fails to parse is skipped with a + warning. If ``True``, extraction raises :class:`QCVTError` on parse + failures, unhandled timed macros, and ``Resync`` (whose drawn times are + only upper bounds). Prefer ``strict=True`` for pre-submit verification. + suppress_off_pulses : bool + If ``True`` (default), hide non-periodic pulses whose name ends in + ``off`` / ``turnoff`` when they share a timestamp with a periodic pulse + on the same channel. Set ``False`` if that naming heuristic does not + match your lab's convention. + """ + with strict_mode(strict): + return _extract_schedule(prog, suppress_off_pulses=suppress_off_pulses) + + +def _extract_schedule(prog: Any, *, suppress_off_pulses: bool) -> Schedule: + sched = Schedule( + soccfg=getattr(prog, "soccfg", None), + prog=prog, + loop_dict=dict( + _call(prog, "get_loop_dict", default=None) + or getattr(prog, "loop_dict", {}) + or {} + ), + suppress_off_pulses=suppress_off_pulses, + ) + + macro_list = _call(prog, "get_macro_list", default=None) or getattr(prog, "macro_list", None) or [] + pulses = _call(prog, "get_pulses", default=None) or getattr(prog, "pulses", None) or {} + if not macro_list: + return sched + + # Moving reference offset (us), tracked with its sweep range. + ref_nom = ref_min = ref_max = 0.0 + # Per-call warning/bookkeeping state. + resync_warned = False + unknown_warned: set = set() + body_started = False + + for macro in macro_list: + cname = type(macro).__name__ + try: + if cname in ("Delay", "Resync"): + # Resync advances the reference like Delay (both compile to + # TIME inc_ref), but at runtime it applies max(0, t - elapsed), + # so the drawn position is an upper bound. QICK's own timestamp + # bookkeeping uses the full t, so we match it. + if cname == "Resync" and not resync_warned: + _fail( + "program contains Resync; times after it are " + "upper bounds (Resync applies max(0, t - elapsed) at " + "runtime)." + ) + resync_warned = True + tp = _macro_time_param(macro, "t") + if tp is None: + continue + lo, hi, _ = param_range(tp) + ref_nom += param_nominal(tp) + ref_min += lo + ref_max += hi + + elif cname == "Pulse": + ch = getattr(macro, "ch", None) + name = getattr(macro, "name", None) + if ch is None or name is None or name not in pulses: + continue + tp = _macro_time_param(macro, "t") + if tp is None: + continue + t_nom = param_nominal(tp) + t_lo, t_hi, _ = param_range(tp) + pulse = pulses[name] + length_qp = pulse.get_length() + l_nom = param_nominal(length_qp) + l_lo, l_hi, _ = param_range(length_qp) + if not _finite(t_nom, l_nom) or l_nom < 0: + continue + params = getattr(pulse, "params", {}) or {} + style = str(params.get("style", "const")) + envelope = params.get("envelope") + periodic = params.get("mode") == "periodic" + g_nom, g_lo, g_hi, g_sw = _pulse_param_range(prog, name, "gain") + f_nom, _, _, f_sw = _pulse_param_range(prog, name, "freq") + p_nom, _, _, _ = _pulse_param_range(prog, name, "phase") + swept = [] + if not np.isclose(t_lo, t_hi): + swept.append("time") + if not np.isclose(l_lo, l_hi): + swept.append("length") + if g_sw: + swept.append("gain") + if f_sw: + swept.append("freq") + sched.events.append(PulseEvent( + ch=int(ch), name=str(name), kind="gen", + t_start=ref_nom + t_nom, length=l_nom, + t_min=ref_min + t_lo, t_max=ref_max + t_hi, + len_min=l_lo, len_max=l_hi, + style=style, envelope=envelope, periodic=periodic, + gain=g_nom, gain_min=g_lo, gain_max=g_hi, + freq=f_nom, phase=p_nom, swept_params=tuple(swept), + )) + + elif cname == "Trigger": + ros = getattr(macro, "ros", None) or [] + if not ros: + continue + tp = _macro_time_param(macro, "t") + if tp is None: + continue + t_nom = param_nominal(tp) + t_lo, t_hi, _ = param_range(tp) + width_qp = _macro_time_param(macro, "width") + for ro in ros: + # Prefer the true integration length; fall back to trigger width. + length = _ro_length_us(prog, int(ro)) + if length is None: + length = param_nominal(width_qp) if width_qp is not None else 0.0 + if not _finite(t_nom, length) or length < 0: + continue + sched.events.append(PulseEvent( + ch=int(ro), name="readout", kind="adc", + t_start=ref_nom + t_nom, length=float(length), + t_min=ref_min + t_lo, t_max=ref_max + t_hi, + len_min=float(length), len_max=float(length), + style="const", + )) + + elif cname == "OpenLoop": + # Everything before the first loop is _initialize(); the loop + # body starts here. Recorded for time_origin="body" plotting. + if not body_started: + sched.body_start_us = ref_nom + body_started = True + + else: + # Register ops, loop control and labels carry no timing. + # Anything with t_params is a TimedMacro we don't know about — + # that's a real gap in the schedule. + if hasattr(macro, "t_params") and cname not in _IGNORED_TIMED_MACROS: + if cname not in unknown_warned: + _fail( + f"unhandled timed macro {cname!r}; schedule " + f"may be incomplete or misaligned after this point." + ) + unknown_warned.add(cname) + except QCVTError: + raise + except Exception as exc: + # keep going in default mode; a single bad macro shouldn't kill the plot + _fail(f"skipping macro {cname}: {exc}", exc) + + return sched + + +# --------------------------------------------------------------------------- # +# Amplitude reconstruction +# --------------------------------------------------------------------------- # +def _gencfg(prog: Any, ch: int) -> dict: + try: + soccfg = getattr(prog, "soccfg", None) + cfg = _call(soccfg, "get_gen_cfg", ch, default=None) + if cfg is not None: + return dict(cfg) + return dict(soccfg["gens"][ch]) + except Exception as exc: + if is_strict(): + raise QCVTError(f"could not read generator config for ch {ch}") from exc + return {} + + +def _sample_dt_us(gencfg: dict) -> float: + """Per-sample spacing (us) for a generator envelope at the DAC sample rate.""" + fs = float(gencfg.get("fs", 0.0)) + if fs <= 0: + f_fabric = float(gencfg.get("f_fabric", 1000.0)) or 1000.0 + samps_per_clk = float(gencfg.get("samps_per_clk", 1)) or 1.0 + fs = f_fabric * samps_per_clk + return 1.0 / fs + + +def _envelope_magnitude(prog: Any, ch: int, envelope: str) -> Optional[np.ndarray]: + """Return the envelope magnitude samples (unitless DAC counts), or ``None``.""" + try: + data = _call(prog, "get_envelope_data", ch, envelope, default=None) + if data is None: + envelopes = _call(prog, "get_envelopes", default=None) or getattr(prog, "envelopes", None) + data = envelopes[ch]["envs"][envelope]["data"] + data = np.asarray(data) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"could not read envelope {envelope!r} on ch {ch}" + ) from exc + return None + if data.size == 0: + return None + if data.ndim == 2 and data.shape[1] >= 2: + return np.hypot(data[:, 0].astype(float), data[:, 1].astype(float)) + return np.abs(data.astype(float)) + + +def _flat_top_plateau_us(prog: Any, event: PulseEvent) -> float: + """Duration of the flat (DDS) segment of a ``flat_top`` pulse, in us.""" + try: + pulses = _call(prog, "get_pulses", default=None) or getattr(prog, "pulses", {}) + pulse = pulses[event.name] + params = _call(pulse, "get_params", default=None) or getattr(pulse, "params", {}) + length = params.get("length") + return max(0.0, param_nominal(length)) + except Exception as exc: + if is_strict(): + raise QCVTError( + f"could not resolve flat_top plateau for {event.name!r}" + ) from exc + # Fall back: total length minus whatever we can attribute to the ramps. + return max(0.0, event.length) + + +def amplitude_trace(prog: Any, event: PulseEvent, length_us: Optional[float] = None, + dac_units: bool = True, gain_override: Optional[float] = None): + """Return ``(t_us, amp)`` samples describing one pulse's amplitude envelope. + + Supported styles: + + * ``const`` — rectangle at ``|gain| * scale`` for the pulse length. + * ``arb`` — stored envelope magnitude (gaussian, DRAG, arbitrary I/Q, …), + scaled by ``|gain| * scale``. + * ``flat_top`` — first half of the envelope as the rising ramp, a plateau of + ``params['length']``, then the second half as the falling ramp (QICK's + three-segment flat_top convention). + + ``scale`` is ``maxv`` (DAC units) when ``dac_units`` else 1.0 (normalized). + ``length_us`` overrides the pulse length (used for periodic extension of + ``const`` pulses). When ``gain_override`` is ``None``, swept-gain pulses + use their sweep maximum (see :func:`representative_gain`) so they are not + drawn/exported at zero amplitude. + """ + if length_us is None: + length_us = event.length + t0 = event.t_start + gain = abs(representative_gain(event) if gain_override is None else gain_override) + gencfg = _gencfg(prog, event.ch) + soccfg = getattr(prog, "soccfg", None) + maxv = _call(soccfg, "get_maxv", event.ch, default=None) + if maxv is None: + maxv = int(gencfg.get("maxv", 32766)) + scale = int(maxv) if dac_units else 1.0 + amp_peak = gain * scale + + def _box(duration: float = length_us): + return (np.array([t0, t0, t0 + duration, t0 + duration]), + np.array([0.0, amp_peak, amp_peak, 0.0])) + + style = event.style + if style == "const" or not event.envelope: + return _box() + + mag = _envelope_magnitude(prog, event.ch, event.envelope) + if mag is None or mag.size == 0: + return _box() + + dt_us = _sample_dt_us(gencfg) + peak = float(np.max(mag)) or 1.0 + unit = mag / peak + + if style == "flat_top": + # QICK convention: the envelope is a full up+down shape; the first half + # is the rising ramp, the second half the falling ramp, and a DDS + # plateau of params['length'] sits between them. Odd-length envelopes + # skip the middle sample. + n = unit.size + mid = n // 2 + up = unit[:mid] + down = unit[mid + (n % 2):] + plateau = _flat_top_plateau_us(prog, event) + t_up = t0 + np.arange(up.size) * dt_us + t_flat0 = t0 + up.size * dt_us + t_flat1 = t_flat0 + plateau + t_down = t_flat1 + np.arange(down.size) * dt_us + t_parts = [np.array([t0])] + a_parts = [np.array([0.0])] + if up.size: + t_parts.append(t_up) + a_parts.append(up * amp_peak) + t_parts.append(np.array([t_flat0, t_flat1])) + a_parts.append(np.array([amp_peak, amp_peak])) + if down.size: + t_parts.append(t_down) + a_parts.append(down * amp_peak) + t_parts.append(np.array([t_down[-1]])) + else: + t_parts.append(np.array([t_flat1])) + a_parts.append(np.array([0.0])) + return np.concatenate(t_parts), np.concatenate(a_parts) + + # arb (and any other envelope-driven style): play the full envelope. + t = t0 + np.arange(unit.size) * dt_us + amp = unit * amp_peak + t = np.concatenate([[t0], t, [t[-1]]]) + amp = np.concatenate([[0.0], amp, [0.0]]) + return t, amp diff --git a/qick_lib/qcvt/plotting.py b/qick_lib/qcvt/plotting.py new file mode 100644 index 00000000..e6038ca4 --- /dev/null +++ b/qick_lib/qcvt/plotting.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- +""" +Matplotlib rendering of a QICK pulse :class:`~qcvt.model.Schedule`. + +The schedule plot shows one horizontal lane per generator/readout channel with +every pulse drawn as a labelled bar on a shared microsecond axis. An optional +amplitude panel reconstructs the output amplitude vs. time. Swept parameters +(time, length, gain) are drawn as translucent ranges so you can see, at a glance, +what the loop actually varies before the program is sent to the RFSoC. + +Multi-timescale programs (ns-scale qubit pulses next to µs-scale readout / ms-scale +CW pumps) are handled by: + +* an explicit ``t0_us`` / ``max_time_us`` viewing window; +* duration callouts + tick marks for pulses that would otherwise be invisible; +* optional zoom insets around clusters of short pulses when the dynamic range + of pulse lengths is large. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import List, Optional, Sequence, Tuple + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.patches import Patch + +from .model import PulseEvent, Schedule, amplitude_trace, extract_schedule, gain_band + + +_GEN_HEIGHT = 0.62 +_ADC_HEIGHT = 0.4 +_ADC_COLOR = "#1a7a1a" +# Pulses shorter than this fraction of the viewing window get a duration callout. +_SHORT_FRAC = 0.015 +# If longest/shortest (visible) gen pulse exceeds this, offer an auto-inset. +_INSET_DYNAMIC_RANGE = 25.0 + + +def _as_schedule(prog_or_schedule) -> Schedule: + if isinstance(prog_or_schedule, Schedule): + return prog_or_schedule + return extract_schedule(prog_or_schedule) + + +def _shifted_schedule(sched: Schedule, offset_us: float) -> Schedule: + """Draw-time copy of ``sched`` with all event times shifted earlier by + ``offset_us``. The original schedule is untouched, so exports made from it + stay on the absolute program timeline.""" + events = [replace(e, + t_start=e.t_start - offset_us, + t_min=e.t_min - offset_us, + t_max=e.t_max - offset_us) + for e in sched.events] + return Schedule( + events=events, soccfg=sched.soccfg, prog=sched.prog, + loop_dict=dict(sched.loop_dict), body_start_us=0.0, + suppress_off_pulses=sched.suppress_off_pulses, + ) + + +# tab10 index 2 is green and collides with ADC lane color ``_ADC_COLOR``. +_GEN_CMAP_INDICES = (0, 1, 3, 4, 5, 6, 7, 8, 9) + + +def _channel_colors(gen_chs): + cmap = plt.cm.tab10 + n = len(_GEN_CMAP_INDICES) + return {ch: cmap(_GEN_CMAP_INDICES[i % n]) for i, ch in enumerate(gen_chs)} + + +def _gen_label(sched: Schedule, ch: int, gen_ch_labels, physical_port_labels) -> str: + label = (gen_ch_labels or {}).get(ch, f"gen {ch}") + soccfg = sched.soccfg + if soccfg is not None: + try: + getter = getattr(soccfg, "get_gen_cfg", None) + gencfg = getter(ch) if callable(getter) else soccfg["gens"][ch] + dac_id = gencfg.get("dac") + except Exception: + dac_id = None + if dac_id is not None: + phys = (physical_port_labels or {}).get(str(dac_id)) + label = f"{label} ({phys or 'dac ' + str(dac_id)})" + return label + + +def _adc_label(sched: Schedule, ch: int, physical_port_labels) -> str: + label = f"ro {ch}" + soccfg = sched.soccfg + if soccfg is not None: + try: + getter = getattr(soccfg, "get_ro_cfg", None) + rocfg = getter(ch) if callable(getter) else soccfg["readouts"][ch] + adc_id = rocfg.get("adc") + except Exception: + adc_id = None + if adc_id is not None: + phys = (physical_port_labels or {}).get(str(adc_id)) + label = f"{label} ({phys or 'adc ' + str(adc_id)})" + return label + + +def _format_duration(us: float) -> str: + if us < 0.001: + return f"{us * 1e6:.0f} ps" + if us < 1.0: + return f"{us * 1e3:.2g} ns" + if us < 1000.0: + return f"{us:.3g} µs" + return f"{us / 1000.0:.3g} ms" + + +def _short_events(events: Sequence[PulseEvent], draw_lengths: dict, + window_us: float) -> List[PulseEvent]: + thresh = max(_SHORT_FRAC * window_us, 1e-6) + out = [] + for e in events: + if e.kind != "gen": + continue + if draw_lengths.get(id(e), e.length) < thresh: + out.append(e) + return out + + +def _choose_inset_window(short: Sequence[PulseEvent], pad_us: float) -> Optional[Tuple[float, float]]: + if not short: + return None + starts = [e.t_start for e in short] + ends = [e.t_start + e.length for e in short] + lo, hi = min(starts), max(ends) + span = max(hi - lo, 1e-3) + return max(0.0, lo - pad_us), hi + pad_us + 0.05 * span + + +def plot_pulse_schedule( + prog, + ax=None, + t0_us: float = 0.0, + max_time_us: Optional[float] = None, + gen_ch_labels: Optional[dict] = None, + physical_port_labels: Optional[dict] = None, + show_readout_triggers: bool = True, + show_amplitude: bool = False, + amplitude_units: str = "dac", + title: Optional[str] = None, + label_pulses: bool = True, + schedule: Optional[Schedule] = None, + insets: Optional[bool] = None, + time_origin: str = "program", +): + """Plot a pulse schedule from a compiled QICK ``asm_v2`` program. + + Parameters + ---------- + prog : + Compiled program (``AveragerProgramV2``) or a pre-built :class:`Schedule`. + ax : matplotlib axes, optional + Axes to draw the schedule on. If ``None`` a new figure is created (and, + when ``show_amplitude`` is set, a second amplitude panel is added). + t0_us : float + Left limit of the time axis (viewing-window start). + max_time_us : float, optional + Right limit of the time axis. If ``None`` it is inferred from the schedule. + gen_ch_labels : dict, optional + Map ``gen_ch (int) -> label`` for lane labels. + physical_port_labels : dict, optional + Map RFDC ids (e.g. dac ``'00'``, adc ``'20'``) -> human labels. + show_readout_triggers : bool + Draw ADC integration windows as their own lanes. + show_amplitude : bool + Add an amplitude-vs-time panel (only when ``ax`` is not supplied). + amplitude_units : str + ``"dac"`` (0..maxv) or ``"norm"`` (0..1) for the amplitude panel. + title : str, optional + Plot title. + label_pulses : bool + Write each pulse's name on its bar. + schedule : Schedule, optional + Pre-extracted schedule (avoids re-extraction when plotting repeatedly). + insets : bool or None + If ``True``, always try to add a zoom inset around short pulses. + If ``None`` (default), add an inset automatically when the schedule's + pulse-length dynamic range is large. If ``False``, never add an inset. + time_origin : str + ``"program"`` (default): absolute program timeline, including any + initial delay from ``_initialize()``. ``"body"``: shift the time axis + so t = 0 is the start of the loop body, matching how times read inside + ``_body()``. Draw-time only; exports keep the absolute timeline. + + Returns + ------- + ax, or (ax, ax_amp) + ``ax_amp`` is ``None`` when the amplitude panel was not created. + """ + if amplitude_units not in ("dac", "norm"): + raise ValueError("amplitude_units must be 'dac' or 'norm'") + if time_origin not in ("program", "body"): + raise ValueError("time_origin must be 'program' or 'body'") + + sched = schedule if schedule is not None else _as_schedule(prog) + if time_origin == "body" and sched and sched.body_start_us: + sched = _shifted_schedule(sched, sched.body_start_us) + ax_amp = None + want_amp = show_amplitude + + if not sched: + if ax is None: + _, ax = plt.subplots(figsize=(7, 3)) + ax.text(0.5, 0.5, "No pulse schedule could be extracted from this program.", + transform=ax.transAxes, ha="center", va="center") + if title: + ax.set_title(title) + return (ax, None) if want_amp else ax + + owns_figure = ax is None + draw_amp = show_amplitude + if show_amplitude and owns_figure: + _, (ax, ax_amp) = plt.subplots( + 2, 1, figsize=(10, 6.5), height_ratios=[1.35, 1.0], sharex=True, + constrained_layout=True, + ) + elif owns_figure: + _, ax = plt.subplots(figsize=(10, 4.5), constrained_layout=True) + elif show_amplitude: + draw_amp = False + + gen_chs = sched.gen_chs + adc_chs = sched.adc_chs if show_readout_triggers else [] + colors = _channel_colors(gen_chs) + + y_pos = {} + idx = 0 + for ch in gen_chs: + y_pos[("gen", ch)] = idx + idx += 1 + for ch in adc_chs: + y_pos[("adc", ch)] = idx + idx += 1 + + # Window end used for periodic extension + short-pulse detection. + if max_time_us is not None: + end_us = float(max_time_us) + else: + ends = [max(e.t_end, e.t_max + e.len_max) for e in sched.events] + end_us = max(ends, default=t0_us + 1.0) * 1.03 + end_us = max(end_us, t0_us + 1e-6) + window_us = end_us - t0_us + + draw_lengths = sched.draw_lengths(end_us) + suppressed = sched.suppressed_events() + + _draw_schedule_bars( + ax, sched, y_pos, colors, draw_lengths, suppressed, + gen_ch_labels, physical_port_labels, label_pulses, + t0_us, end_us, window_us, gen_chs, adc_chs, + ) + if time_origin == "body": + ax.set_xlabel("Time (µs, relative to body start)") + if title: + ax.set_title(title, fontsize=11) + + # Duration callouts for pulses that would be invisible at this zoom. + short = [e for e in _short_events(sched.gen_events, draw_lengths, window_us) + if id(e) not in suppressed and t0_us <= e.t_start <= end_us] + for e in short: + y = y_pos.get(("gen", e.ch)) + if y is None: + continue + color = colors.get(e.ch, "C0") + ax.plot([e.t_start, e.t_start], [y - _GEN_HEIGHT / 2, y + _GEN_HEIGHT / 2], + color=color, linewidth=1.6, zorder=4) + ax.annotate( + f"{e.name}\n{_format_duration(e.length)}", + xy=(e.t_start, y), xytext=(6, 10), textcoords="offset points", + fontsize=6.5, color=color, + arrowprops=dict(arrowstyle="-", color=color, lw=0.7), + zorder=5, + ) + + # Auto inset when short and long pulses coexist in the same window. + gen_lens = [draw_lengths.get(id(e), e.length) for e in sched.gen_events + if id(e) not in suppressed and e.length > 0] + use_inset = insets + if use_inset is None and gen_lens and short: + use_inset = (max(gen_lens) / max(min(gen_lens), 1e-9)) >= _INSET_DYNAMIC_RANGE + if use_inset and owns_figure and short: + inset_win = _choose_inset_window(short, pad_us=max(0.05, 0.15 * window_us * _SHORT_FRAC)) + if inset_win is not None: + _add_zoom_inset( + ax, sched, y_pos, colors, draw_lengths, suppressed, + gen_ch_labels, physical_port_labels, gen_chs, adc_chs, + inset_win[0], inset_win[1], + ) + + if draw_amp and ax_amp is not None: + _draw_amplitude_panel(ax_amp, sched, colors, draw_lengths, + amplitude_units, t0_us, end_us, gen_ch_labels) + if time_origin == "body": + ax_amp.set_xlabel("Time (µs, relative to body start)") + + return (ax, ax_amp) if want_amp else ax + + +def _draw_schedule_bars( + ax, sched, y_pos, colors, draw_lengths, suppressed, + gen_ch_labels, physical_port_labels, label_pulses, + t0_us, end_us, window_us, gen_chs, adc_chs, +): + for e in sched.gen_events: + if id(e) in suppressed: + continue + y = y_pos.get(("gen", e.ch)) + if y is None: + continue + color = colors.get(e.ch, "C0") + draw_len = draw_lengths.get(id(e), e.length) + t_end = e.t_start + max(draw_len, 0.0) + # Skip pulses that do not intersect the viewing window. + if t_end < t0_us or e.t_start > end_us: + continue + + if e.time_swept: + ax.barh(y, (e.t_max + draw_len) - e.t_min, left=e.t_min, height=_GEN_HEIGHT, + color=color, alpha=0.15, edgecolor="none", zorder=1) + elif e.length_swept: + ax.barh(y, e.len_max, left=e.t_start, height=_GEN_HEIGHT, + color=color, alpha=0.15, edgecolor="none", zorder=1) + + ax.barh(y, max(draw_len, 0.0), left=e.t_start, height=_GEN_HEIGHT, + color=color, edgecolor="black", linewidth=0.6, zorder=2, + hatch="////" if e.periodic else None, + alpha=0.55 if e.periodic else 1.0) + + # Label at the midpoint of the *visible* segment so a zoomed window + # does not place text far outside xlim (which blows up bbox_inches=tight). + vis_lo = max(e.t_start, t0_us) + vis_hi = min(t_end, end_us) + vis_len = max(vis_hi - vis_lo, 0.0) + if label_pulses and vis_len >= _SHORT_FRAC * window_us: + label = e.name + if e.swept_params: + label += f"\n[sweep: {', '.join(e.swept_params)}]" + if e.style and e.style not in ("const",): + label += f"\n({e.style})" + center = 0.5 * (vis_lo + vis_hi) + wide_enough = vis_len > 0.08 * window_us + if wide_enough: + ax.text(center, y, label, ha="center", va="center", + fontsize=7, color="white", zorder=3, fontweight="bold", + clip_on=True) + else: + ax.text(vis_lo, y + _GEN_HEIGHT / 2 + 0.02, label, ha="left", + va="bottom", fontsize=6.5, color=color, zorder=3, + clip_on=True) + + for e in sched.adc_events: + y = y_pos.get(("adc", e.ch)) + if y is None: + continue + if e.t_end < t0_us or e.t_start > end_us: + continue + ax.barh(y, max(e.length, 0.01), left=e.t_start, height=_ADC_HEIGHT, + color=_ADC_COLOR, alpha=0.7, edgecolor="black", linewidth=0.8, zorder=2) + + y_ticks, y_labels = [], [] + for ch in gen_chs: + y_ticks.append(y_pos[("gen", ch)]) + lab = _gen_label(sched, ch, gen_ch_labels, physical_port_labels) + freqs = {round(e.freq, 3) for e in sched.gen_events if e.ch == ch and e.freq is not None} + if len(freqs) == 1: + lab += f"\n{next(iter(freqs)):g} MHz" + y_labels.append(lab) + for ch in adc_chs: + y_ticks.append(y_pos[("adc", ch)]) + y_labels.append(_adc_label(sched, ch, physical_port_labels)) + + ax.set_yticks(y_ticks) + ax.set_yticklabels(y_labels, fontsize=8) + ax.set_ylim(-0.6, max(len(y_pos) - 0.4, 0.5)) + ax.set_xlim(t0_us, end_us) + ax.set_xlabel("Time (µs)") + ax.grid(True, axis="x", alpha=0.3) + + legend_items = [] + if any(e.periodic for e in sched.gen_events): + legend_items.append(Patch(facecolor="0.6", hatch="////", alpha=0.55, + edgecolor="black", label="periodic (CW)")) + if adc_chs: + legend_items.append(Patch(facecolor=_ADC_COLOR, alpha=0.7, + edgecolor="black", label="ADC integration")) + if any(e.swept_params for e in sched.gen_events): + legend_items.append(Patch(facecolor="0.6", alpha=0.15, edgecolor="none", + label="swept range")) + if legend_items: + ax.legend(handles=legend_items, loc="upper right", fontsize=7, framealpha=0.9) + + +def _add_zoom_inset( + ax, sched, y_pos, colors, draw_lengths, suppressed, + gen_ch_labels, physical_port_labels, gen_chs, adc_chs, + z0, z1, +): + """Overlay a zoom inset on the schedule axes around ``[z0, z1]``.""" + # Place the inset in the upper-left of the schedule panel. + inset = ax.inset_axes([0.02, 0.55, 0.38, 0.42]) + # Re-draw bars into the inset without lane labels / legend clutter. + for e in sched.gen_events: + if id(e) in suppressed: + continue + if e.t_end < z0 or e.t_start > z1: + continue + y = y_pos.get(("gen", e.ch)) + if y is None: + continue + color = colors.get(e.ch, "C0") + draw_len = draw_lengths.get(id(e), e.length) + inset.barh(y, max(draw_len, 0.0), left=e.t_start, height=_GEN_HEIGHT, + color=color, edgecolor="black", linewidth=0.5, zorder=2, + hatch="////" if e.periodic else None, + alpha=0.55 if e.periodic else 1.0) + # Center the label on the segment visible inside the inset window, not + # the whole pulse (whose midpoint may lie far outside and blow up the + # figure bbox on save). + vis_lo = max(e.t_start, z0) + vis_hi = min(e.t_start + max(draw_len, 0.0), z1) + inset.text(0.5 * (vis_lo + vis_hi), y, + f"{e.name}\n{_format_duration(e.length)}", + ha="center", va="center", fontsize=6, color="white", + fontweight="bold", zorder=3, clip_on=True) + for e in sched.adc_events: + if e.t_end < z0 or e.t_start > z1: + continue + y = y_pos.get(("adc", e.ch)) + if y is None: + continue + inset.barh(y, max(e.length, 0.0), left=e.t_start, height=_ADC_HEIGHT, + color=_ADC_COLOR, alpha=0.7, edgecolor="black", linewidth=0.6) + + inset.set_xlim(z0, z1) + inset.set_ylim(ax.get_ylim()) + inset.set_yticks([]) + inset.set_title(f"zoom {_format_duration(z0)}–{_format_duration(z1)}", fontsize=7) + inset.grid(True, axis="x", alpha=0.3) + for spine in inset.spines.values(): + spine.set_color("#444444") + spine.set_linewidth(1.0) + # Indicate the zoomed region on the parent axes. + try: + ax.indicate_inset_zoom(inset, edgecolor="#444444") + except Exception: + ax.axvspan(z0, z1, color="0.5", alpha=0.08, zorder=0) + + +def _draw_amplitude_panel(ax_amp, sched: Schedule, colors, draw_lengths, + amplitude_units, t0_us, end_us, gen_ch_labels): + dac_units = amplitude_units == "dac" + prog = sched.prog + seen = set() + for e in sched.gen_events: + if e.t_end < t0_us or e.t_start > end_us: + continue + draw_len = draw_lengths.get(id(e), e.length) + color = colors.get(e.ch, "C0") + label = (gen_ch_labels or {}).get(e.ch, f"gen {e.ch}") + legend_label = label if e.ch not in seen else "_nolegend_" + + if e.gain_swept: + # Band spans |gain| over the sweep; a sign-crossing sweep (e.g. + # -0.6..0.6) reaches zero amplitude, so the band starts at 0. + band_lo, band_hi = gain_band(e) + t_lo, a_lo = amplitude_trace(prog, e, length_us=draw_len, + dac_units=dac_units, gain_override=band_lo) + t_hi, a_hi = amplitude_trace(prog, e, length_us=draw_len, + dac_units=dac_units, gain_override=band_hi) + if t_lo is not None and t_hi is not None and np.array_equal(t_lo, t_hi): + ax_amp.fill_between(t_lo, np.abs(a_lo), np.abs(a_hi), color=color, + alpha=0.3, linewidth=0, label="_nolegend_") + ax_amp.plot(t_lo, (np.abs(a_lo) + np.abs(a_hi)) / 2, color=color, + linewidth=2.0, label=legend_label) + seen.add(e.ch) + continue + + t_arr, amp = amplitude_trace(prog, e, length_us=draw_len, dac_units=dac_units) + if t_arr is None: + continue + ax_amp.plot(t_arr, np.abs(amp), color=color, linewidth=2.0, label=legend_label) + seen.add(e.ch) + + for e in sched.adc_events: + if e.t_end < t0_us or e.t_start > end_us: + continue + ax_amp.axvspan(e.t_start, e.t_end, color=_ADC_COLOR, alpha=0.2, lw=0) + + ax_amp.set_xlim(t0_us, end_us) + ax_amp.set_ylim(bottom=0) + ax_amp.set_xlabel("Time (µs)") + ax_amp.set_ylabel("Amplitude (DAC units)" if dac_units else "Amplitude (norm)") + ax_amp.grid(True, alpha=0.3) + handles, labels = ax_amp.get_legend_handles_labels() + if sched.adc_events: + handles.append(Patch(facecolor=_ADC_COLOR, alpha=0.2, edgecolor="none")) + labels.append("ADC integration") + if handles: + ax_amp.legend(handles=handles, labels=labels, loc="upper right", + fontsize=7, framealpha=0.9) + + +def show_schedule( + prog, + title: str = "Pulse schedule", + show_amplitude: bool = True, + amplitude_units: str = "dac", + gen_ch_labels: Optional[dict] = None, + physical_port_labels: Optional[dict] = None, + t0_us: float = 0.0, + max_time_us: Optional[float] = None, + insets: Optional[bool] = None, + time_origin: str = "program", +) -> None: + """Quickly display a pulse schedule interactively (no files saved). + + Intended for a fast look while running experiments, e.g. right before sending + a program to the RFSoC. + """ + plot_pulse_schedule( + prog, + show_amplitude=show_amplitude, + amplitude_units=amplitude_units, + gen_ch_labels=gen_ch_labels, + physical_port_labels=physical_port_labels, + title=title, + t0_us=t0_us, + max_time_us=max_time_us, + insets=insets, + time_origin=time_origin, + ) + plt.show() diff --git a/qick_lib/qcvt/tests/qick_config.json b/qick_lib/qcvt/tests/qick_config.json new file mode 100644 index 00000000..b9f0060e --- /dev/null +++ b/qick_lib/qcvt/tests/qick_config.json @@ -0,0 +1,738 @@ +{ + "board": "ZCU216", + "sw_version": "0.2.367", + "extra_description": [], + "fw_timestamp": "Wed Mar 19 09:14:54 2025", + "rf": { + "type": "usp_rf_data_converter", + "fullpath": "usp_rf_data_converter_0", + "ip_type": 2, + "hs_adc": false, + "tiles": { + "dac": { + "0": { + "coupling": "AC", + "fabric_div": 16, + "out_div": 1, + "blocks": [ + "00", + "01", + "02", + "03" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "f_out": 614.4 + }, + "1": { + "coupling": "AC", + "fabric_div": 16, + "out_div": 1, + "blocks": [ + "10", + "11", + "12", + "13" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "f_out": 614.4 + }, + "2": { + "coupling": "AC", + "fabric_div": 16, + "out_div": 1, + "blocks": [ + "20" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "f_out": 614.4 + } + }, + "adc": { + "2": { + "coupling": "AC", + "fabric_div": 8, + "out_div": 1, + "blocks": [ + "20", + "21", + "22" + ], + "f_ref": 245.76, + "ref_div": 1, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "f_out": 307.2 + } + } + }, + "dacs": { + "00": { + "index": [ + 0, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "01": { + "index": [ + 0, + 1 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "02": { + "index": [ + 0, + 2 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "03": { + "index": [ + 0, + 3 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "10": { + "index": [ + 1, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "11": { + "index": [ + 1, + 1 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "12": { + "index": [ + 1, + 2 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "13": { + "index": [ + 1, + 3 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + }, + "20": { + "index": [ + 2, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 1, + "fs": 9830.4, + "f_fabric": 614.4, + "interpolation": 1, + "datapath": 4 + } + }, + "adcs": { + "20": { + "index": [ + 2, + 0 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "decimation": 1 + }, + "21": { + "index": [ + 2, + 1 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "decimation": 1 + }, + "22": { + "index": [ + 2, + 2 + ], + "coupling": "AC", + "f_ref": 245.76, + "fs_mult": 40, + "fs_div": 4, + "fs": 2457.6, + "f_fabric": 307.2, + "decimation": 1 + } + }, + "clk_groups": [ + [ + [ + "qick_processor_0", + "timing clock" + ], + [ + "dac", + 0 + ], + [ + "dac", + 1 + ], + [ + "dac", + 2 + ] + ], + [ + [ + "adc", + 2 + ] + ] + ], + "revision": 6, + "version": "2.6" + }, + "refclk_freq": 245.76, + "mr_buf": { + "type": "mr_buffer_et", + "fullpath": "mr_buffer_et_0", + "maxlen": 8192, + "junk_len": 8, + "readouts": [ + "axis_avg_buffer_0", + "axis_avg_buffer_1" + ], + "revision": 3, + "version": "1.0", + "trigger_type": "tport", + "trigger_port": 8, + "trigger_bit": 0 + }, + "ddr4_buf": { + "type": "axis_buffer_ddr_v1", + "fullpath": "ddr4/axis_buffer_ddr_v1_0", + "burst_len": 128, + "junk_len": 401, + "junk_nt": 4, + "readouts": [ + "axis_avg_buffer_0", + "axis_avg_buffer_1" + ], + "revision": 3, + "version": "1.0", + "maxlen": 1073741824, + "trigger_type": "tport", + "trigger_port": 9, + "trigger_bit": 0 + }, + "gens": [ + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_0", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "00", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 0 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_1", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "01", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 1 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_2", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "02", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 2 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_3", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "03", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 3 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_4", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "10", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 4 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_5", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "11", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 5 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_6", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "12", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 6 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_7", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "13", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 7 + }, + { + "type": "axis_signal_gen_v6", + "fullpath": "axis_signal_gen_v6_8", + "maxlen": 16384, + "complex_env": true, + "samps_per_clk": 16, + "maxv_scale": 1.0, + "has_dds": true, + "b_dds": 32, + "b_phase": 32, + "maxv": 32766, + "has_mixer": false, + "revision": 4, + "version": "1.0", + "dac": "20", + "fs": 9830.4, + "fs_mult": 40, + "fs_div": 1, + "interpolation": 1, + "f_fabric": 614.4, + "f_dds": 9830.4, + "fdds_div": 1, + "tproc_ch": 8 + } + ], + "iqs": [], + "time_taggers": [], + "readouts": [ + { + "avg_maxlen": 16384, + "buf_maxlen": 4096, + "has_edge_counter": true, + "has_weights": false, + "trigger_type": "tport", + "trigger_port": 10, + "trigger_bit": 0, + "tproc_ch": 0, + "tproc_ctrl": 9, + "adc": "20", + "b_phase": 32, + "fs": 2457.6, + "fs_mult": 40, + "fs_div": 4, + "decimation": 1, + "f_fabric": 307.2, + "f_dds": 2457.6, + "fdds_div": 4, + "f_output": 307.2, + "b_dds": 32, + "iq_offset": 0.0, + "has_outsel": true, + "avgbuf_type": "axis_avg_buffer", + "ro_type": "axis_dyn_readout_v1", + "avgbuf_revision": 4, + "ro_revision": 3, + "avgbuf_version": "1.1", + "ro_version": "1.0", + "avgbuf_fullpath": "axis_avg_buffer_0", + "ro_fullpath": "axis_dyn_readout_v1_0" + }, + { + "avg_maxlen": 16384, + "buf_maxlen": 4096, + "has_edge_counter": true, + "has_weights": false, + "trigger_type": "tport", + "trigger_port": 11, + "trigger_bit": 0, + "tproc_ch": 1, + "tproc_ctrl": 10, + "adc": "22", + "b_phase": 32, + "fs": 2457.6, + "fs_mult": 40, + "fs_div": 4, + "decimation": 1, + "f_fabric": 307.2, + "f_dds": 2457.6, + "fdds_div": 4, + "f_output": 307.2, + "b_dds": 32, + "iq_offset": 0.0, + "has_outsel": true, + "avgbuf_type": "axis_avg_buffer", + "ro_type": "axis_dyn_readout_v1", + "avgbuf_revision": 4, + "ro_revision": 3, + "avgbuf_version": "1.1", + "ro_version": "1.0", + "avgbuf_fullpath": "axis_avg_buffer_1", + "ro_fullpath": "axis_dyn_readout_v1_1" + }, + { + "avg_maxlen": 16384, + "buf_maxlen": 4096, + "has_edge_counter": true, + "has_weights": false, + "trigger_type": "tport", + "trigger_port": 12, + "trigger_bit": 0, + "tproc_ch": 2, + "tproc_ctrl": 11, + "adc": "21", + "b_phase": 32, + "fs": 2457.6, + "fs_mult": 40, + "fs_div": 4, + "decimation": 1, + "f_fabric": 614.4, + "f_dds": 2457.6, + "fdds_div": 4, + "f_output": 614.4, + "b_dds": 32, + "iq_offset": -0.5, + "has_outsel": true, + "avgbuf_type": "axis_avg_buffer", + "ro_type": "axis_readout_v3", + "avgbuf_revision": 4, + "ro_revision": 4, + "avgbuf_version": "1.1", + "ro_version": "1.0", + "avgbuf_fullpath": "axis_avg_buffer_2", + "ro_fullpath": "axis_readout_v3_0" + } + ], + "tprocs": [ + { + "type": "qick_processor", + "fullpath": "qick_processor_0", + "pmem_size": 4096, + "dmem_size": 16384, + "wmem_size": 1024, + "dreg_qty": 16, + "in_port_qty": 3, + "out_trig_qty": 13, + "out_dport_qty": 1, + "out_dport_dw": 8, + "out_wport_qty": 12, + "has_lfsr": 1, + "has_divider": 1, + "has_arith": 1, + "has_time_read": 1, + "has_qcom": 0, + "has_custom_periph": 0, + "has_io_ctrl": 1, + "has_ext_flag": 0, + "has_qnet": 0, + "fifo_depth": 512, + "call_depth": 255, + "debug": 0, + "clk_srcs": { + "core clock": { + "source": [ + "PS", + 0 + ], + "f_clk": 200.00000000000003, + "src_range": [ + 66.66666666666667, + 133.33333333333334 + ] + }, + "timing clock": { + "source": [ + "dac", + 2 + ], + "f_clk": 614.4, + "src_range": null + } + }, + "revision": 21, + "version": "2.0", + "f_core": 200.00000000000003, + "f_time": 614.4, + "output_pins": [ + [ + "trig", + 0, + 0, + "PMOD0_0_LS" + ], + [ + "trig", + 1, + 0, + "PMOD0_1_LS" + ], + [ + "trig", + 2, + 0, + "PMOD0_2_LS" + ], + [ + "trig", + 3, + 0, + "PMOD0_3_LS" + ], + [ + "trig", + 4, + 0, + "PMOD0_4_LS" + ], + [ + "trig", + 5, + 0, + "PMOD0_5_LS" + ], + [ + "trig", + 6, + 0, + "PMOD0_6_LS" + ], + [ + "trig", + 7, + 0, + "PMOD0_7_LS" + ] + ], + "start_pin": "PMOD1_0_LS", + "stop_pin": "PMOD1_1_LS" + } + ] + } \ No newline at end of file diff --git a/qick_lib/qcvt/tests/test_basic_schedule.py b/qick_lib/qcvt/tests/test_basic_schedule.py new file mode 100644 index 00000000..83b48d2c --- /dev/null +++ b/qick_lib/qcvt/tests/test_basic_schedule.py @@ -0,0 +1,370 @@ +"""Tests for QCVT schedule extraction, plotting and exports. + +Run with: pytest tests/ -v +""" +from __future__ import annotations + +import os + +import matplotlib + +matplotlib.use("Agg") + +import pytest + +CONFIG = os.path.join(os.path.dirname(__file__), "qick_config.json") +try: + import qick # noqa: F401 + HAVE_QICK = True +except Exception: + HAVE_QICK = False + +needs_qick = pytest.mark.skipif( + not (HAVE_QICK and os.path.isfile(CONFIG)), + reason="requires qick and qick_config.json", +) + + +def test_public_api(): + import qcvt + + for name in [ + "plot_pulse_schedule", "show_schedule", "visualize_all", + "visualize_from_pickle", "extract_schedule", "Schedule", "PulseEvent", + "QCVTError", "strict_mode", "is_strict", + "export_edge_matrix_csv", "csv_to_table_png", + "save_soccfg_to_json", "load_soccfg_from_json", + "review_schedule", + ]: + assert hasattr(qcvt, name), name + + +def test_csv_to_table_png_without_pandas(tmp_path, monkeypatch): + """Table PNG rendering must not need pandas, which is not a dependency. + + pandas may still be installed in the test environment, so block the import + outright rather than trusting its absence. + """ + import builtins + + real_import = builtins.__import__ + + def no_pandas(name, *args, **kwargs): + if name == "pandas" or name.startswith("pandas."): + raise ImportError("pandas is not a QCVT dependency") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_pandas) + + from qcvt.export import csv_to_table_png + + csv_path = tmp_path / "edges_state.csv" + csv_path.write_text("timestamp (ns),0.00e0,1.00e3\ngen 0,on,off\n") + png_path = tmp_path / "edges_state.png" + csv_to_table_png(str(csv_path), str(png_path), title="state") + assert png_path.is_file() and png_path.stat().st_size > 0 + + +def test_extract_schedule_empty(): + from qcvt import extract_schedule + + class Empty: + macro_list = [] + pulses = {} + soccfg = None + + sched = extract_schedule(Empty()) + assert len(sched) == 0 + assert not sched + + +def test_param_helpers(): + from qcvt.model import param_nominal, param_range + + assert param_nominal(3.0) == 3.0 + assert param_range(5)[:2] == (5.0, 5.0) + assert param_range(5)[2] is False + + class FakeSweep: + start = 1.0 + spans = {"loop": 4.0} + + def minval(self): + return 1.0 + + def maxval(self): + return 5.0 + + lo, hi, swept = param_range(FakeSweep()) + assert (lo, hi, swept) == (1.0, 5.0, True) + + +def test_representative_gain_and_band(): + """QICK gains are signed: sweeps like -0.6..0.6 or -0.6..0 must not + render/export near zero, and the amplitude band must reach 0 when the + sweep crosses zero.""" + from qcvt.model import PulseEvent, gain_band, representative_gain + + def ev(g, gmin, gmax): + return PulseEvent(ch=0, name="p", kind="gen", t_start=0.0, length=1.0, + gain=g, gain_min=gmin, gain_max=gmax) + + # constant gain (incl. negative) + assert representative_gain(ev(-0.5, -0.5, -0.5)) == -0.5 + assert gain_band(ev(-0.5, -0.5, -0.5)) == (0.5, 0.5) + # positive sweep (power Rabi 0..1) + assert representative_gain(ev(0.0, 0.0, 1.0)) == 1.0 + assert gain_band(ev(0.0, 0.0, 1.0)) == (0.0, 1.0) + # sign-crossing sweep (kerrcat -0.6..0.6): largest |endpoint|, band reaches 0 + assert abs(representative_gain(ev(-0.6, -0.6, 0.6))) == pytest.approx(0.6) + assert gain_band(ev(-0.6, -0.6, 0.6)) == (0.0, pytest.approx(0.6)) + # negative-only sweep: must pick -0.6, not the near-zero max + assert representative_gain(ev(-0.6, -0.6, 0.0)) == -0.6 + assert gain_band(ev(-0.6, -0.6, 0.0)) == (0.0, pytest.approx(0.6)) + assert gain_band(ev(-0.6, -0.6, -0.2)) == (pytest.approx(0.2), pytest.approx(0.6)) + + +# --------------------------------------------------------------------------- # +# Golden tests against a real (offline-built) program +# --------------------------------------------------------------------------- # +def _build_spec_program(): + from qick.asm_v2 import AveragerProgramV2, QickSweep1D + from qcvt import load_soccfg_from_json + + soccfg = load_soccfg_from_json(CONFIG) + + class Spec(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=2) + self.declare_gen(ch=6, nqz=2) + self.add_loop("freqloop", 11) + self.declare_readout(ch=0, length=10.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + self.add_pulse(ch=2, name="qpulse", ro_ch=0, style="const", + length=5.0, freq=QickSweep1D("freqloop", 3000, 3200), + phase=0, gain=0.3) + self.add_pulse(ch=6, name="readout", ro_ch=0, style="const", + length=10.0, freq=1000, phase=0, gain=0.5) + + def _body(self, cfg): + self.send_readoutconfig(ch=0, name="ro", t=0) + self.pulse(ch=2, name="qpulse", t=0) + self.delay_auto(0.01) + self.pulse(ch=6, name="readout", t=0) + self.trigger(ros=[0], pins=[0], t=0.5) + + return Spec(soccfg, reps=2, final_delay=100, cfg={}, reps_innermost=False) + + +@needs_qick +def test_timing_and_sweeps(): + from qcvt import extract_schedule + + sched = extract_schedule(_build_spec_program()) + by_name = {e.name: e for e in sched.gen_events} + + q = by_name["qpulse"] + assert q.length == pytest.approx(5.0, abs=1e-2) + assert q.t_start == pytest.approx(1.0, abs=1e-2) # includes initial sync delay + assert "freq" in q.swept_params + + r = by_name["readout"] + # readout follows qpulse + delay_auto(0.01): ~1.0 + 5.0 + 0.01 + assert r.t_start == pytest.approx(6.01, abs=5e-2) + assert r.length == pytest.approx(10.0, abs=1e-2) + + adc = sched.adc_events[0] + assert adc.t_start == pytest.approx(6.51, abs=5e-2) + # ADC window uses the readout integration length, not the tiny trigger width. + assert adc.length == pytest.approx(10.0, abs=1e-1) + + +@needs_qick +def test_qick_accessors_match_dicts(): + """Documented accessors used by QCVT must match the underlying dicts.""" + prog = _build_spec_program() + soccfg = prog.soccfg + + gencfg = soccfg.get_gen_cfg(2) + assert gencfg["fs"] == soccfg["gens"][2]["fs"] + assert gencfg["f_fabric"] == soccfg["gens"][2]["f_fabric"] + assert gencfg["maxv"] == soccfg["gens"][2]["maxv"] + assert soccfg.get_gen_fs(2) == pytest.approx(float(soccfg["gens"][2]["fs"])) + assert soccfg.get_maxv(2) == int( + __import__("numpy").floor( + soccfg["gens"][2]["maxv"] * soccfg["gens"][2]["maxv_scale"] + ) + ) + + rocfg = soccfg.get_ro_cfg(0) + assert rocfg["f_output"] == soccfg["readouts"][0]["f_output"] + assert soccfg.get_ro_f_output(0) == pytest.approx(float(soccfg["readouts"][0]["f_output"])) + + assert list(prog.get_macro_list()) == list(prog.macro_list) + assert prog.get_pulses() is prog.pulses + assert dict(prog.get_loop_dict()) == dict(prog.loop_dict) + assert prog.get_ro_chs() is prog.ro_chs + assert prog.get_ro_length_us(0) == pytest.approx(10.0, abs=1e-2) + assert "qpulse" in prog.get_pulses() + assert "gain" in prog.get_pulses()["qpulse"].get_params() + env = prog.get_envelopes() + assert env is prog.envelopes + + +@needs_qick +def test_plot_with_ax_and_amplitude_no_crash(): + import matplotlib.pyplot as plt + from qcvt import plot_pulse_schedule + + prog = _build_spec_program() + fig, ax = plt.subplots() + result = plot_pulse_schedule(prog, ax=ax, show_amplitude=True) + assert isinstance(result, tuple) and len(result) == 2 + plt.close("all") + + +@needs_qick +def test_visualize_all_writes_all_outputs(tmp_path): + from qcvt import visualize_all + + prog = _build_spec_program() + out = visualize_all(prog, str(tmp_path), title="spec", show_amplitude=True) + for key in ("schedule_png", "edges_state_csv", "edges_state_png"): + assert out[key] and os.path.isfile(out[key]), key + + +@needs_qick +def test_edge_matrix_state_values(tmp_path): + import csv + + from qcvt import export_edge_matrix_csv + + prog = _build_spec_program() + state_csv = export_edge_matrix_csv( + prog, out_prefix=str(tmp_path / "edges"), t0_us=0.0, t1_us=None, + ) + with open(state_csv) as f: + rows = list(csv.reader(f)) + labels = [r[0] for r in rows[1:]] + assert any("gen 2" in lbl for lbl in labels) + assert any("gen 6" in lbl for lbl in labels) + # Each generator row should be on for at least one timestamp. + for row in rows[1:]: + if row[0].startswith("gen "): + assert "on" in row[1:] + + +def _build_flat_top_program(): + from qick.asm_v2 import AveragerProgramV2 + from qcvt import load_soccfg_from_json + + soccfg = load_soccfg_from_json(CONFIG) + + class FlatTopProg(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=2) + self.add_gauss(ch=2, name="ramp", sigma=0.05, length=0.3, even_length=True) + self.add_pulse(ch=2, name="ft", style="flat_top", envelope="ramp", + freq=3000, phase=0, gain=0.5, length=2.0) + + def _body(self, cfg): + self.pulse(ch=2, name="ft", t=0.5) + + return FlatTopProg(soccfg, reps=1, final_delay=1, cfg={}, reps_innermost=False) + + +@needs_qick +def test_flat_top_amplitude_has_ramps_and_plateau(): + from qcvt import extract_schedule + from qcvt.model import amplitude_trace + + prog = _build_flat_top_program() + sched = extract_schedule(prog) + e = next(x for x in sched.gen_events if x.name == "ft") + assert e.style == "flat_top" + assert e.length == pytest.approx(2.3, abs=0.05) # plateau 2.0 + ramps ~0.3 + + t, amp = amplitude_trace(prog, e, dac_units=True) + assert t is not None and amp is not None + # Should have rising samples, a high plateau, then falling samples. + peak = float(amp.max()) + assert peak == pytest.approx(0.5 * 32766, rel=0.05) + # Plateau: many consecutive samples near the peak. + on_plateau = amp > 0.95 * peak + assert on_plateau.sum() >= 2 + # Ramps: amplitude takes intermediate values, not just 0 and peak. + mid = (amp > 0.05 * peak) & (amp < 0.95 * peak) + assert mid.sum() >= 4 + # Total span matches get_length. + assert (t[-1] - t[0]) == pytest.approx(e.length, abs=0.05) + + +@needs_qick +def test_review_schedule_saves_and_returns_true(tmp_path): + from qcvt import review_schedule + + prog = _build_spec_program() + ok = review_schedule( + prog, + save_dir=str(tmp_path / "review"), + title="review test", + show=False, + confirm=False, + ) + assert ok is True + assert os.path.isfile(tmp_path / "review" / "schedule.png") + + +@needs_qick +def test_multi_timescale_window_and_insets(): + """Short ns-scale pulse next to a long readout still plots; zoom window works.""" + import matplotlib.pyplot as plt + from qick.asm_v2 import AveragerProgramV2 + from qcvt import load_soccfg_from_json, plot_pulse_schedule, extract_schedule + + soccfg = load_soccfg_from_json(CONFIG) + + class Mixed(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=2, nqz=2) + self.declare_gen(ch=6, nqz=2) + self.declare_readout(ch=0, length=50.0) + self.add_readoutconfig(ch=0, name="ro", freq=1000, gen_ch=6) + self.add_gauss(ch=2, name="g", sigma=0.01, length=0.05) + self.add_pulse(ch=2, name="short", style="arb", envelope="g", + freq=3200, phase=0, gain=0.8) + self.add_pulse(ch=6, name="long", style="const", length=50.0, + freq=1000, phase=0, gain=0.4) + + def _body(self, cfg): + self.pulse(ch=2, name="short", t=0.1) + self.delay_auto(0.05) + self.pulse(ch=6, name="long", t=0) + self.trigger(ros=[0], pins=[0], t=0.2) + + prog = Mixed(soccfg, reps=1, final_delay=10, cfg={}, reps_innermost=False) + sched = extract_schedule(prog) + short = next(e for e in sched.gen_events if e.name == "short") + long = next(e for e in sched.gen_events if e.name == "long") + assert short.length < 0.2 + assert long.length == pytest.approx(50.0, abs=0.1) + + # Full window + forced inset must not crash, and pulse labels must not + # blow up the tight bbox (long pulses labelled at off-window midpoints + # once produced ~40000 px wide PNGs). + ax, ax_amp = plot_pulse_schedule(prog, show_amplitude=True, insets=True) + assert ax.get_xlim()[1] > 40 + fig = ax.figure + fig.canvas.draw() + bbox = fig.get_tightbbox(fig.canvas.get_renderer()) + assert bbox.width < 3 * fig.get_size_inches()[0], "tight bbox exploded" + plt.close("all") + + # Zoomed window around the short pulse: same bbox sanity check. + ax = plot_pulse_schedule(prog, show_amplitude=False, t0_us=0.0, max_time_us=1.0, insets=False) + assert ax.get_xlim() == pytest.approx((0.0, 1.0)) + fig = ax.figure + fig.canvas.draw() + bbox = fig.get_tightbbox(fig.canvas.get_renderer()) + assert bbox.width < 3 * fig.get_size_inches()[0], "tight bbox exploded" + plt.close("all") diff --git a/qick_lib/qcvt/tests/test_power_rabi_timing.py b/qick_lib/qcvt/tests/test_power_rabi_timing.py new file mode 100644 index 00000000..cab4cae2 --- /dev/null +++ b/qick_lib/qcvt/tests/test_power_rabi_timing.py @@ -0,0 +1,293 @@ +""" +Offline regression test for QCVT against the Power_rabi case reported by the +PhD student. Requires no RFSoC: `qick`'s QickConfig is built from the bundled +qick_config.json, and AveragerProgramV2 compiles purely in software. + +Run: pytest tests/test_power_rabi_timing.py -v +""" +from pathlib import Path + +import numpy as np +import pytest + +qick = pytest.importorskip("qick") +from qick.asm_v2 import AveragerProgramV2, QickSweep1D +from qick.qick_asm import QickConfig + +from qcvt.model import amplitude_trace, extract_schedule + +# Resolve the bundled config relative to this file so the test runs from any cwd. +CFG = str(Path(__file__).resolve().parent / "qick_config.json") + +QUBIT_CH, RES_CH, PUMP_CH, RO_CH = 6, 4, 0, 0 +QUBIT_LEN, RES_LEN = 1.62, 30.0 +GAP, ADC_OFF = 0.01, 0.474 +TOL = 5e-3 # us; QICK rounds times to tProc cycles + + +class PowerRabi(AveragerProgramV2): + """setup: CW pump on gen 0. body: qubit pulse -> readout pulse -> ADC. + cleanup: one-shot pulse on gen 0 to turn the pump off.""" + + def _initialize(self, cfg): + for ch in (PUMP_CH, QUBIT_CH, RES_CH): + self.declare_gen(ch=ch, nqz=1) + self.declare_readout(ch=RO_CH, length=RES_LEN) + self.add_readoutconfig(ch=RO_CH, name="ro", freq=500.0, gen_ch=RES_CH) + + self.add_pulse(ch=PUMP_CH, name="pump", style="const", freq=1000.0, + phase=0, gain=0.5, length=1.0, mode="periodic") + self.add_pulse(ch=PUMP_CH, name="pump_off", style="const", freq=1000.0, + phase=0, gain=0.5, length=1.0, mode="oneshot") + + self.add_gauss(ch=QUBIT_CH, name="qgauss", sigma=QUBIT_LEN / 5, + length=QUBIT_LEN, even_length=True) + self.add_loop("gainloop", 10) + self.add_pulse(ch=QUBIT_CH, name="qubit", style="arb", envelope="qgauss", + freq=3000.0, phase=0, + gain=QickSweep1D("gainloop", 0.0, 1.0)) + + self.add_pulse(ch=RES_CH, name="res", style="const", freq=500.0, + phase=0, gain=1.0, length=RES_LEN) + + self.send_readoutconfig(ch=RO_CH, name="ro", t=0) + self.pulse(ch=PUMP_CH, name="pump", t=0) + self.delay(1.0) + + def _body(self, cfg): + self.pulse(ch=QUBIT_CH, name="qubit", t=0) + self.pulse(ch=RES_CH, name="res", t=QUBIT_LEN + GAP) + self.trigger(ros=[RO_CH], t=QUBIT_LEN + GAP + ADC_OFF) + + def _cleanup(self, cfg): + self.delay_auto(1.0) + self.pulse(ch=PUMP_CH, name="pump_off", t=0) + + +@pytest.fixture(scope="module") +def sched(): + soccfg = QickConfig(CFG) + return extract_schedule(PowerRabi(soccfg, reps=2, final_delay=10.0, cfg={})) + + +def _one(sched, name): + hits = [e for e in sched.events if e.name == name] + assert len(hits) == 1, f"expected exactly one {name!r}, got {len(hits)}" + return hits[0] + + +def test_body_relative_timing(sched): + """The reported bug: relative offsets inside _body must be exact.""" + q, res, adc = _one(sched, "qubit"), _one(sched, "res"), _one(sched, "readout") + + assert q.length == pytest.approx(QUBIT_LEN, abs=TOL) + # readout pulse starts GAP after the qubit pulse ends + assert res.t_start - q.t_end == pytest.approx(GAP, abs=TOL) + # integration starts ADC_OFF after the readout pulse starts + assert adc.t_start - res.t_start == pytest.approx(ADC_OFF, abs=TOL) + + +def test_durations(sched): + assert _one(sched, "res").length == pytest.approx(RES_LEN, abs=TOL) + assert _one(sched, "readout").length == pytest.approx(RES_LEN, abs=TOL) + + +def test_periodic_pump_extends_to_cleanup(sched): + pump, off = _one(sched, "pump"), _one(sched, "pump_off") + assert pump.periodic and not off.periodic + drawn = sched.draw_lengths()[id(pump)] + assert drawn == pytest.approx(off.t_start - pump.t_start, abs=TOL) + + +def test_gain_sweep_range_captured(sched): + q = _one(sched, "qubit") + assert "gain" in q.swept_params + assert q.gain_min == pytest.approx(0.0, abs=1e-3) + assert q.gain_max == pytest.approx(1.0, abs=1e-3) + + +def test_swept_pulse_has_nonzero_amplitude(sched): + q = _one(sched, "qubit") + _, amp = amplitude_trace(sched.prog, q) + assert np.max(np.abs(amp)) > 0.0 + + +def test_resync_advances_reference(): + soccfg = QickConfig(CFG) + + class WithResync(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=6, nqz=1) + for n in ("a", "b"): + self.add_pulse(ch=6, name=n, style="const", freq=1000.0, + phase=0, gain=0.5, length=1.0) + + def _body(self, cfg): + self.pulse(ch=6, name="a", t=0) + self.resync(5.0) + self.pulse(ch=6, name="b", t=0) + self.resync(5.0) + + with pytest.warns(UserWarning, match="Resync") as record: + s = extract_schedule(WithResync(soccfg, reps=1, final_delay=1.0, cfg={})) + # One warning per program, not one per Resync macro. + assert sum("Resync" in str(w.message) for w in record) == 1 + a, b = _one(s, "a"), _one(s, "b") + assert b.t_start - a.t_start == pytest.approx(5.0, abs=TOL) + + +def test_negative_gain_sweep_amplitude(): + """Signed gain sweeps (kerrcat sweeps -0.6..0.6) must render at the + largest |gain|, not at the sweep maximum (≈0 for a -0.6..0 sweep).""" + soccfg = QickConfig(CFG) + + class NegSweep(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=6, nqz=1) + self.add_loop("gainloop", 11) + self.add_pulse(ch=6, name="drive", style="const", length=1.0, + freq=1000.0, phase=0, + gain=QickSweep1D("gainloop", -0.6, 0.0)) + + def _body(self, cfg): + self.pulse(ch=6, name="drive", t=0) + + s = extract_schedule(NegSweep(soccfg, reps=1, final_delay=1.0, cfg={})) + d = _one(s, "drive") + _, amp = amplitude_trace(s.prog, d) + assert np.max(np.abs(amp)) == pytest.approx(0.6 * 32766, rel=0.02) + + +def test_swept_pulse_in_edge_matrix(sched, tmp_path): + """The swept gen 6 pulse must appear in the state edge matrix.""" + import csv + + from qcvt.export import export_edge_matrix_csv + + path = export_edge_matrix_csv(sched.prog, out_prefix=str(tmp_path / "edges"), + t0_us=0.0, t1_us=None, schedule=sched) + with open(path, newline="") as f: + rows = list(csv.reader(f)) + labels = [r[0] for r in rows[1:]] + assert f"gen {QUBIT_CH}" in labels + qubit_row = next(r for r in rows[1:] if r[0] == f"gen {QUBIT_CH}") + assert "on" in qubit_row[1:] + + +def test_unhandled_timed_macro_warns_once(): + soccfg = QickConfig(CFG) + + class Minimal(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=6, nqz=1) + self.add_pulse(ch=6, name="a", style="const", freq=1000.0, + phase=0, gain=0.5, length=1.0) + + def _body(self, cfg): + self.pulse(ch=6, name="a", t=0) + + class FutureTimedMacro: + """Stand-in for a timed macro QCVT doesn't know about.""" + t_params: dict = {} + + prog = Minimal(soccfg, reps=1, final_delay=1.0, cfg={}) + prog.macro_list.extend([FutureTimedMacro(), FutureTimedMacro()]) + with pytest.warns(UserWarning, match="FutureTimedMacro") as record: + extract_schedule(prog) + assert sum("FutureTimedMacro" in str(w.message) for w in record) == 1 + + +def test_untimed_macros_do_not_warn(sched): + """Loop control / register ops / labels in the compiled Power_rabi program + must not trigger the unhandled-macro warning (fixture compiled cleanly).""" + import warnings as _warnings + + with _warnings.catch_warnings(): + _warnings.simplefilter("error") + extract_schedule(sched.prog) + + +def test_off_suppression_names(): + from qcvt.model import PulseEvent, Schedule + + def sched_with(name, suppress=True): + cw = PulseEvent(ch=0, name="pump", kind="gen", t_start=0.0, length=1.0, + periodic=True) + cand = PulseEvent(ch=0, name=name, kind="gen", t_start=0.0, length=1.0) + return Schedule(events=[cw, cand], suppress_off_pulses=suppress), cand + + for name in ("pump_off", "turnoff", "turn_off", "off"): + s, cand = sched_with(name) + assert id(cand) in s.suppressed_events(), name + for name in ("offset_cal", "off_resonant_probe", "readout_offset"): + s, cand = sched_with(name) + assert id(cand) not in s.suppressed_events(), name + + s, cand = sched_with("pump_off", suppress=False) + assert id(cand) not in s.suppressed_events() + + +def test_strict_mode_raises_on_unhandled_timed_macro(): + from qcvt.model import QCVTError, extract_schedule as es + + class FutureTimedMacro: + t_params: dict = {} + + class Prog: + macro_list = [FutureTimedMacro()] + pulses = {} + soccfg = None + loop_dict = {} + + with pytest.raises(QCVTError, match="FutureTimedMacro"): + es(Prog(), strict=True) + + +def test_strict_mode_raises_on_resync(): + from qcvt.model import QCVTError + + soccfg = QickConfig(CFG) + + class WithResync(AveragerProgramV2): + def _initialize(self, cfg): + self.declare_gen(ch=6, nqz=1) + self.add_pulse(ch=6, name="a", style="const", freq=1000.0, + phase=0, gain=0.5, length=1.0) + + def _body(self, cfg): + self.pulse(ch=6, name="a", t=0) + self.resync(1.0) + + with pytest.raises(QCVTError, match="Resync"): + extract_schedule(WithResync(soccfg, reps=1, final_delay=1.0, cfg={}), + strict=True) + + +def test_gen_colors_avoid_adc_green(): + """Generator lane colors must not reuse the ADC green (tab10 index 2).""" + import matplotlib.pyplot as plt + from qcvt.plotting import _ADC_COLOR, _channel_colors + + colors = _channel_colors(list(range(12))) + adc = plt.matplotlib.colors.to_rgb(_ADC_COLOR) + tab10_green = plt.cm.tab10(2)[:3] + for ch, rgba in colors.items(): + assert rgba[:3] != pytest.approx(tab10_green, abs=1e-6), ch + dist = sum((a - b) ** 2 for a, b in zip(rgba[:3], adc)) ** 0.5 + assert dist > 0.15, ch + + +def test_body_time_origin(sched): + """body_start_us puts the first _body() pulse at t = 0.""" + q = _one(sched, "qubit") + assert sched.body_start_us > 0.5 # initial_delay + explicit delay(1.0) + assert q.t_start - sched.body_start_us == pytest.approx(0.0, abs=TOL) + + import matplotlib + matplotlib.use("Agg") + from qcvt.plotting import plot_pulse_schedule + + ax = plot_pulse_schedule(sched.prog, schedule=sched, time_origin="body") + assert "body start" in ax.get_xlabel() + # Draw-time only: the schedule itself must keep the absolute timeline. + assert _one(sched, "qubit").t_start == q.t_start \ No newline at end of file diff --git a/qick_lib/qick/VERSION b/qick_lib/qick/VERSION index 15c5c135..e476f8a0 100644 --- a/qick_lib/qick/VERSION +++ b/qick_lib/qick/VERSION @@ -1 +1 @@ -0.2.422 +0.2.426 diff --git a/qick_lib/qick/asm_v2.py b/qick_lib/qick/asm_v2.py index d9b5893a..bdf48626 100644 --- a/qick_lib/qick/asm_v2.py +++ b/qick_lib/qick/asm_v2.py @@ -479,6 +479,14 @@ def add_wave(self, waveform): else: raise RuntimeError("add_wave argument {waveform} is neither a Waveform nor a waveform name") + def get_params(self): + """Pulse parameters as specified in add_pulse() (may include QickParams). + + Prefer :meth:`QickProgramV2.get_pulse_param` for fully rounded, + loop-aware values after compilation. + """ + return self.params + def get_length(self): # always returns a QickParam length = QickParam(start=0) @@ -799,10 +807,15 @@ class TimedMacro(Macro): """Timed instructions have parameters corresponding to times or durations. Add additional methods used for handling these time parameters. + + ``t_params`` holds the user-unit (microsecond) QickParam for each named + time; :meth:`get_time_param` is the supported accessor. This is + load-bearing for schedule visualization (QCVT). """ def __init__(self, *args, **kwargs): # pass through any init arguments super().__init__(*args, **kwargs) + # user-unit times; use get_time_param() (QCVT / external tools) self.t_params = {} self.t_regs = {} @@ -1149,6 +1162,19 @@ def append_macro(self, macro): """ self.macro_list.append(macro) + def get_macro_list(self): + """High-level instruction list (Pulse, Delay, Trigger, OpenLoop, ...). + + Timed macros expose :meth:`TimedMacro.get_time_param`. This is a + supported interface for schedule visualization (QCVT). + + Returns + ------- + list + macros in program order + """ + return self.macro_list + def extend_macros(self, asm): """Append all the given instructions onto this list of instructions. Named by analogy to Python list.extend(). @@ -2082,6 +2108,7 @@ def _init_declarations(self): # high-level macros # this also gets reset in AsmV2.__init__(), but that's OK + # Load-bearing for schedule visualization (QCVT); use get_macro_list(). self.macro_list = [] # generator managers handle a gen's envelopes and add_pulse logic @@ -2089,6 +2116,7 @@ def _init_declarations(self): self._ro_mgrs = [ReadoutManager(self, iCh) if 'tproc_ctrl' in ch else None for iCh, ch in enumerate(self.soccfg['readouts'])] # pulses are software constructs, each is a set of 1 or more waveforms + # Load-bearing for schedule visualization (QCVT); use get_pulses(). self.pulses = {} # waveforms consist of initial parameters (to be written to the wave memory) and sweeps (to be applied when looping) @@ -2108,6 +2136,7 @@ def _init_instructions(self): self.time_dict = {} # lookup dict for timed instructions with tags + # Load-bearing for schedule visualization (QCVT); use get_loop_dict(). self.loop_dict = OrderedDict() self.loop_stack = [] @@ -2455,6 +2484,34 @@ def list_pulse_params(self, pulsename): pulse = self.pulses[pulsename] return pulse.numeric_params + def get_pulses(self): + """Named pulses defined by add_pulse(). + + Each value is a :class:`Pulse` with :meth:`Pulse.get_params` and + :meth:`Pulse.get_length`. After compilation, prefer + :meth:`get_pulse_param` for rounded, loop-aware values. + + This is a supported interface for schedule visualization (QCVT). + + Returns + ------- + dict + pulse name -> Pulse + """ + return self.pulses + + def get_loop_dict(self): + """Loop names and iteration counts, outermost first. + + This is a supported interface for schedule visualization (QCVT). + + Returns + ------- + OrderedDict + loop name -> number of iterations + """ + return self.loop_dict + def get_pulse_param(self, pulsename, parname, as_array=False): """Get the fully rounded value of a pulse parameter, in the same units that are used to specify the parameter in add_pulse(). By default, a swept parameter will be returned as a QickParam. diff --git a/qick_lib/qick/qick_asm.py b/qick_lib/qick/qick_asm.py index e43d88e8..42f5cd1f 100644 --- a/qick_lib/qick/qick_asm.py +++ b/qick_lib/qick/qick_asm.py @@ -955,6 +955,85 @@ def get_maxv(self, gen_ch): gencfg = self['gens'][gen_ch] return int(np.floor(gencfg['maxv']*gencfg['maxv_scale'])) + def get_gen_cfg(self, gen_ch): + """Return a copy of the firmware config dict for a signal generator. + + Useful keys include ``fs`` (DAC sample rate, Msps), ``f_fabric`` + (fabric clock, MHz), ``samps_per_clk``, ``maxv``, and ``dac`` + (RFSoC tile/block name). See also :meth:`get_maxv` and + :meth:`get_gen_fs`. + + This is a supported interface for tools that need generator clocks or + amplitude scales (including schedule visualization / QCVT). + + Parameters + ---------- + gen_ch : int + generator channel (index in 'gens' list) + + Returns + ------- + dict + generator firmware config + """ + return dict(self['gens'][gen_ch]) + + def get_ro_cfg(self, ro_ch): + """Return a copy of the firmware config dict for a readout. + + Useful keys include ``f_output`` (output/decimated sample rate, MHz), + ``fs`` (ADC sample rate, Msps), and ``adc`` (RFSoC tile/block name). + See also :meth:`get_ro_f_output`. + + This is a supported interface for tools that need readout clocks + (including schedule visualization / QCVT). + + Parameters + ---------- + ro_ch : int + readout channel (index in 'readouts' list) + + Returns + ------- + dict + readout firmware config + """ + return dict(self['readouts'][ro_ch]) + + def get_gen_fs(self, gen_ch): + """Look up a generator's DAC sample rate. + + Parameters + ---------- + gen_ch : int + generator channel (index in 'gens' list) + + Returns + ------- + float + sample rate in Msps + """ + gencfg = self['gens'][gen_ch] + fs = gencfg.get('fs') + if not fs: + fs = gencfg['f_fabric'] * gencfg.get('samps_per_clk', 1) + return float(fs) + + def get_ro_f_output(self, ro_ch): + """Look up a readout's output (decimated) sample rate. + + Parameters + ---------- + ro_ch : int + readout channel (index in 'readouts' list) + + Returns + ------- + float + sample rate in MHz + """ + return float(self['readouts'][ro_ch]['f_output']) + class AbsQickProgram(ABC): """Generic QICK program, including support for generator and readout configuration but excluding tProc-specific code. QickProgram/QickProgramV2 are the concrete subclasses for tProc v1/v2. @@ -981,7 +1060,9 @@ class AbsQickProgram(ABC): 'reg2freq', 'reg2freq_adc', 'cycles2us', 'us2cycles', 'deg2reg', 'reg2deg', - 'roundfreq', 'get_maxv'] + 'roundfreq', 'get_maxv', + 'get_gen_cfg', 'get_ro_cfg', + 'get_gen_fs', 'get_ro_f_output'] # if true, duration units in declare_readout and envelope definitions are in user units (float, us), not raw (int, clock ticks) USER_DURATIONS = False @@ -1013,9 +1094,12 @@ def _init_declarations(self): If a program is filled using a make_program() that is called during compilation, this should also be called before make_program(). """ logger.debug("init_declarations") - # Pulse envelopes. + # Pulse envelopes. Load-bearing for schedule visualization (QCVT); + # prefer get_envelopes() / get_envelope_data(). self.envelopes = [{"next_addr": 0, "envs": {}} for ch in self.soccfg['gens']] - # readout channels to configure before running the program + # readout channels to configure before running the program. + # Load-bearing for schedule visualization (QCVT); prefer get_ro_chs() + # / get_ro_length_us(). self.ro_chs = OrderedDict() # signal generator channels to configure before running the program self.gen_chs = OrderedDict() @@ -1050,6 +1134,70 @@ def __getattr__(self, a): else: return object.__getattribute__(self, a) + def get_ro_chs(self): + """Declared readout channels and their config. + + Returns an OrderedDict mapping channel index to the dict written by + :meth:`declare_readout`. ``length`` is in readout clock cycles; + ``length_us`` is the same window in microseconds. + + This is a supported interface for schedule visualization (QCVT). + + Returns + ------- + OrderedDict + channel index -> readout declaration + """ + return self.ro_chs + + def get_ro_length_us(self, ro_ch): + """ADC integration-window length for a declared readout, in microseconds. + + Parameters + ---------- + ro_ch : int + readout channel (index in 'readouts' list) + + Returns + ------- + float + integration length in microseconds + """ + return float(self.ro_chs[ro_ch]['length_us']) + + def get_envelopes(self): + """Envelope library for this program, one entry per generator channel. + + Each entry is ``{"next_addr": int, "envs": {name: {"data", "addr"}}}``. + ``data`` is an (N, 2) int16 array of I/Q samples. + See also :meth:`get_envelope_data`. + + This is a supported interface for schedule visualization (QCVT). + + Returns + ------- + list of dict + envelope library per generator + """ + return self.envelopes + + def get_envelope_data(self, gen_ch, name): + """I/Q sample array for a named envelope on a generator. + + Parameters + ---------- + gen_ch : int + generator channel (index in 'gens' list) + name : str + envelope name (as passed to add_gauss / add_envelope / ...) + + Returns + ------- + numpy.ndarray + shape (N, 2) int16, columns I and Q + """ + return self.envelopes[gen_ch]['envs'][name]['data'] + @abstractmethod def compile(self): """Fills self.binprog with a binary representation of the program. diff --git a/setup.py b/setup.py index d3b88845..07d308cc 100644 --- a/setup.py +++ b/setup.py @@ -172,6 +172,9 @@ def get_version(rel_path): extras_require={ # install all extra optional dependencies 'full': ['pyro4'], + # QCVT pulse-schedule visualizer (matplotlib plots; cloudpickle for pickles) + 'qcvt': ['matplotlib>=3.5', 'numpy'], + 'qcvt-pickle': ['matplotlib>=3.5', 'numpy', 'cloudpickle>=2.0'], }, # If there are data files included in your packages that need to be @@ -182,7 +185,8 @@ def get_version(rel_path): 'ipq_pynq_utils/ipq_pynq_utils/data/*.txt', 'ipq_pynq_utils/ipq_pynq_utils/data/*.h', 'ipq_pynq_utils/ipq_pynq_utils/data/clockFiles/*.txt', - ] + ], + 'qcvt': ['tests/qick_config.json'], }, include_package_data=True, @@ -200,11 +204,11 @@ def get_version(rel_path): # # For example, the following would provide a command called `sample` which # executes the function `main` from this package when invoked: - # entry_points={ # Optional - # 'console_scripts': [ - # 'sample=sample:main', - # ], - # }, + entry_points={ # Optional + 'console_scripts': [ + 'qcvt=qcvt.cli:main', + ], + }, # List additional URLs that are relevant to your project as a dict. #