diff --git a/examples/canvas_layout_visualization_demo.py b/examples/canvas_layout_visualization_demo.py index bffef40a..ab27f375 100644 --- a/examples/canvas_layout_visualization_demo.py +++ b/examples/canvas_layout_visualization_demo.py @@ -25,9 +25,9 @@ # ============================================================================= # Configuration - Edit these values # ============================================================================= -spec_name = "design/L_patch.yml" +spec_name = "/home/masato/git-repos/pyzx-mbqc/ls-pattern-compile/examples/output/adder_n4_slices_crop_x5-10_y5-11.yml" code_distance = 3 -target_z = 1 +target_z = 2 # ============================================================================= diff --git a/examples/canvas_z_sweep_3d_movie.py b/examples/canvas_z_sweep_3d_movie.py new file mode 100644 index 00000000..291c892b --- /dev/null +++ b/examples/canvas_z_sweep_3d_movie.py @@ -0,0 +1,37 @@ +"""Export a 3D z-sweep MP4 movie from a canvas YAML spec.""" + +from __future__ import annotations + +from pathlib import Path + +from lspattern.canvas_loader import load_canvas +from lspattern.video_3d import export_canvas_z_sweep_3d_mp4 + +spec_name = "design/distillation_canvas.yml" +code_distance = 5 +z_window = 60 +output_dir = Path(__file__).parent / "output" +output_dir.mkdir(parents=True, exist_ok=True) + +canvas, spec = load_canvas(spec_name, code_distance=code_distance) +out_path = output_dir / f"{Path(spec_name).stem}_z_sweep_3d.mp4" + +export_canvas_z_sweep_3d_mp4( + canvas, + out_path, + fps=15, + z_window=z_window, + width=640, + height=640, + node_size_scale=0.2, + edge_width_scale=0.2, + highlight_size_scale=1.0, + highlight_current_layer=True, + non_current_alpha=0.1, + camera_eye=(1.5, 1, 1), + lock_view=True, + aspect_ratio=(1.5, 0.9, 0.6), + show_progress_bar=True, +) + +print(f"Exported 3D z-sweep for '{spec.name}' to {out_path}") diff --git a/examples/convert_liblsqecc_json.py b/examples/convert_liblsqecc_json.py new file mode 100644 index 00000000..c43ad8d8 --- /dev/null +++ b/examples/convert_liblsqecc_json.py @@ -0,0 +1,45 @@ +"""Convert liblsqecc slices JSON to canvas YAML. + +This script converts liblsqecc output (slices JSON format) to the +canvas YAML format used by ls-pattern-compile. +""" + +from __future__ import annotations + +from pathlib import Path + +from lspattern.importer.liblsqecc import ( + convert_slices_file_to_canvas_yaml, +) + +# ============================================================================= +# Configuration - Edit these values +# ============================================================================= +input_json = Path( + "/home/masato/git-repos/pyzx-mbqc/FTQC-compiler-survey/mf/e2edemo/output/adder_n4_slices_crop_x5-11_y5-11.json" +) # Path to liblsqecc slices JSON +output_dir = Path(__file__).parent / "output" + +# ============================================================================= + +# Ensure output directory exists +output_dir.mkdir(parents=True, exist_ok=True) + +yaml_path = output_dir / f"{input_json.stem}.yml" + +print(f"Input JSON: {input_json}") +print(f"Output YAML: {yaml_path}") +print() + +print("Converting liblsqecc slices JSON to canvas YAML...") +yaml_text = convert_slices_file_to_canvas_yaml( + input_json, + yaml_path, + name=input_json.stem, + description=f"Imported from {input_json.name}", +) + +print(f" Generated YAML: {yaml_path}") +print(f" YAML size: {len(yaml_text):,} characters") +print() +print("Conversion completed successfully!") diff --git a/examples/design/distillation_canvas.yml b/examples/design/distillation_canvas.yml new file mode 100644 index 00000000..e7a5770b --- /dev/null +++ b/examples/design/distillation_canvas.yml @@ -0,0 +1,698 @@ +name: 15-to-1 MSD Canvas +description: Global config for rotated surface code based blocks + +layout: rotated_surface_code # required + +cube: + # layer 0 + - position: [2, 0, 0] + block: InitPlusBlock + boundary: XXZZ + - position: [3, 0, 0] + block: InitPlusBlock + boundary: XXZZ + - position: [2, 2, 0] + block: InitPlusBlock + boundary: XXZZ + - position: [3, 2, 0] + block: InitPlusBlock + boundary: XXZZ + # layer 1 + - position: [2, 0, 1] + block: MemoryBlock + boundary: XOZZ + - position: [3, 0, 1] + block: MemoryBlock + boundary: XOZZ + - position: [2, 2, 1] + block: MemoryBlock + boundary: OXZZ + - position: [3, 2, 1] + block: MemoryBlock + boundary: XXZZ + - position: [0, 1, 1] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 1] + block: ShortXMemoryBlock + boundary: OXZZ + - position: [1, 1, 1] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [1, 2, 1] + block: InitPlusBlock + boundary: OXZZ + - position: [2, 1, 1] + block: ShortXMemoryBlock + boundary: OOOO + - position: [3, 1, 1] + block: ShortXMemoryBlock + boundary: OZOO + - position: [4, 1, 1] + block: InitPlusBlock + boundary: ZZOX + # layer 2 + - position: [2, 0, 2] + block: MemoryBlock + boundary: XOZZ + - position: [3, 0, 2] + block: MemoryBlock + boundary: XXZZ + - position: [2, 2, 2] + block: MemoryBlock + boundary: OXZZ + - position: [3, 2, 2] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 2] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 2] + block: ShortXMemoryBlock + boundary: OXZZ + - position: [1, 1, 2] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [1, 2, 2] + block: MeasureXBlock # Y fixup + boundary: XXZZ + - position: [2, 1, 2] + block: ShortXMemoryBlock + boundary: OOOO + - position: [3, 1, 2] + block: ShortXMemoryBlock + boundary: ZOOX + - position: [4, 1, 2] + block: MemoryBlock + boundary: ZZXX + # layer 3 + - position: [2, 0, 3] + block: MemoryBlock + boundary: XOZZ + - position: [3, 0, 3] + block: MemoryBlock + boundary: XOZZ + - position: [2, 2, 3] + block: MemoryBlock + boundary: XXZZ + - position: [3, 2, 3] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 3] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 3] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 3] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 3] + block: ShortXMemoryBlock + boundary: OZOO + - position: [3, 1, 3] + block: ShortXMemoryBlock + boundary: ZOOX + - position: [4, 1, 3] + block: MemoryBlock + boundary: ZZXX + # layer 4 + - position: [2, 0, 4] + block: MemoryBlock + boundary: XXZZ + - position: [3, 0, 4] + block: MemoryBlock + boundary: XOZZ + - position: [2, 2, 4] + block: MemoryBlock + boundary: OXZZ + - position: [3, 2, 4] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 4] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 4] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 4] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 4] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [3, 1, 4] + block: ShortXMemoryBlock + boundary: ZOOX + - position: [4, 1, 4] + block: MeasureXBlock # Y fixup + boundary: ZZXX + # layer 5 + - position: [2, 0, 5] + block: MemoryBlock + boundary: XOZZ + - position: [3, 0, 5] + block: MemoryBlock + boundary: XOZZ + - position: [2, 2, 5] + block: MemoryBlock + boundary: OXZZ + - position: [3, 2, 5] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 5] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 5] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 5] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 5] + block: ShortXMemoryBlock + boundary: OOOO + - position: [3, 1, 5] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [4, 1, 5] + block: InitPlusBlock + boundary: ZZOX + # layer 6 + - position: [2, 0, 6] + block: MemoryBlock + boundary: XXZZ + - position: [3, 0, 6] + block: MemoryBlock + boundary: XOZZ + - position: [2, 2, 6] + block: MemoryBlock + boundary: XXZZ + - position: [3, 2, 6] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 6] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 6] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 6] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 6] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [3, 1, 6] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [4, 1, 6] + block: MemoryBlock + boundary: ZZOX + # layer 7 + - position: [2, 0, 7] + block: MemoryBlock + boundary: XXZZ + - position: [3, 0, 7] + block: MemoryBlock + boundary: XXZZ + - position: [2, 2, 7] + block: MemoryBlock + boundary: OXZZ + - position: [3, 2, 7] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 7] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 7] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 7] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 7] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [3, 1, 7] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [4, 1, 7] + block: MemoryBlock + boundary: ZZOX + # layer 8 + - position: [2, 0, 8] + block: MemoryBlock + boundary: XOZZ + - position: [3, 0, 8] + block: MemoryBlock + boundary: XXZZ + - position: [2, 2, 8] + block: MemoryBlock + boundary: XXZZ + - position: [3, 2, 8] + block: MeasureXBulkBlock + boundary: OXZZ + - position: [0, 1, 8] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 8] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 8] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 8] + block: ShortXMemoryBlock + boundary: OZOO + - position: [3, 1, 8] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [4, 1, 8] + block: MemoryBlock + boundary: ZZOX + # layer 9 + - position: [2, 0, 9] + block: MemoryBlock + boundary: XXZZ + - position: [3, 0, 9] + block: MemoryBlock + boundary: XOZZ + - position: [2, 2, 9] + block: MemoryBlock + boundary: OXZZ + - position: [0, 1, 9] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 9] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 9] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 9] + block: ShortXMemoryBlock + boundary: ZOOO + - position: [3, 1, 9] + block: ShortXMemoryBlock + boundary: OZOO + - position: [4, 1, 9] + block: MemoryBlock + boundary: ZZOX + # layer 10 + - position: [2, 0, 10] + block: MemoryBlock + boundary: XOZZ + - position: [3, 0, 10] + block: MeasureXBulkBlock + boundary: XOZZ + - position: [2, 2, 10] + block: MemoryBlock + boundary: XXZZ + - position: [0, 1, 10] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 10] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 10] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 10] + block: ShortXMemoryBlock + boundary: OZOO + - position: [3, 1, 10] + block: ShortXMemoryBlock + boundary: OZOO + - position: [4, 1, 10] + block: MemoryBlock + boundary: ZZOX + # layer 11 + - position: [2, 0, 11] + block: MeasureXBulkBlock + boundary: XOZZ + - position: [2, 2, 11] + block: MeasureXBulkBlock + boundary: OXZZ + - position: [0, 1, 11] + block: ShortXMemoryBlock + boundary: ZOZO + - position: [0, 2, 11] + block: ShortXMemoryBlock + boundary: XXZZ + - position: [1, 1, 11] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [2, 1, 11] + block: ShortXMemoryBlock + boundary: OOOO + - position: [3, 1, 11] + block: ShortXMemoryBlock + boundary: ZZOO + - position: [4, 1, 11] + block: MemoryBlock + boundary: ZZOX + + - position: [4, 1, 12] + block: MeasureXBlock + boundary: ZZXX + +pipe: + # layer 1 + - start: [0, 1, 1] + end: [0, 2, 1] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 1] + end: [1, 1, 1] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 1] + end: [1, 2, 1] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [1, 1, 1] + end: [2, 1, 1] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 1] + end: [2, 1, 1] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 1] + end: [2, 2, 1] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 1] + end: [3, 1, 1] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 0, 1] + end: [3, 1, 1] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 1] + end: [4, 1, 1] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 2 + - start: [0, 1, 2] + end: [0, 2, 2] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 2] + end: [1, 1, 2] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 2] + end: [2, 1, 2] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 2] + end: [2, 1, 2] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 2] + end: [2, 2, 2] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 2] + end: [3, 1, 2] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 2] + end: [3, 2, 2] + block: ShortXMemoryBlock + boundary: OOZZ + # layer 3 + - start: [0, 1, 3] + end: [0, 2, 3] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 3] + end: [1, 1, 3] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 3] + end: [2, 1, 3] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 3] + end: [2, 1, 3] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 0, 3] + end: [3, 1, 3] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 3] + end: [3, 1, 3] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 3] + end: [3, 2, 3] + block: ShortXMemoryBlock + boundary: OOZZ + # layer 4 + - start: [0, 1, 4] + end: [0, 2, 4] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 4] + end: [1, 1, 4] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 4] + end: [2, 1, 4] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 1, 4] + end: [2, 2, 4] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 0, 4] + end: [3, 1, 4] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 4] + end: [3, 1, 4] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 4] + end: [3, 2, 4] + block: ShortXMemoryBlock + boundary: OOZZ + # layer 5 + - start: [0, 1, 5] + end: [0, 2, 5] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 5] + end: [1, 1, 5] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 5] + end: [2, 1, 5] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 5] + end: [2, 1, 5] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 5] + end: [2, 2, 5] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 0, 5] + end: [3, 1, 5] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 5] + end: [3, 1, 5] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 5] + end: [3, 2, 5] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 5] + end: [4, 1, 5] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 6 + - start: [0, 1, 6] + end: [0, 2, 6] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 6] + end: [1, 1, 6] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 6] + end: [2, 1, 6] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 0, 6] + end: [3, 1, 6] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 6] + end: [3, 1, 6] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 6] + end: [3, 2, 6] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 6] + end: [4, 1, 6] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 7 + - start: [0, 1, 7] + end: [0, 2, 7] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 7] + end: [1, 1, 7] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 7] + end: [2, 1, 7] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 1, 7] + end: [2, 2, 7] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 7] + end: [3, 1, 7] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 7] + end: [3, 2, 7] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 7] + end: [4, 1, 7] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 8 + - start: [0, 1, 8] + end: [0, 2, 8] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 8] + end: [1, 1, 8] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 8] + end: [2, 1, 8] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 8] + end: [2, 1, 8] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 8] + end: [3, 1, 8] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 8] + end: [3, 2, 8] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 8] + end: [4, 1, 8] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 9 + - start: [0, 1, 9] + end: [0, 2, 9] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 9] + end: [1, 1, 9] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 9] + end: [2, 1, 9] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 1, 9] + end: [2, 2, 9] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 9] + end: [3, 1, 9] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 0, 9] + end: [3, 1, 9] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 9] + end: [4, 1, 9] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 10 + - start: [0, 1, 10] + end: [0, 2, 10] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 10] + end: [1, 1, 10] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 10] + end: [2, 1, 10] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 10] + end: [2, 1, 10] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 10] + end: [3, 1, 10] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 0, 10] + end: [3, 1, 10] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [3, 1, 10] + end: [4, 1, 10] + block: ShortXMemoryBlock + boundary: ZZOO + # layer 11 + - start: [0, 1, 11] + end: [0, 2, 11] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [0, 1, 11] + end: [1, 1, 11] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [1, 1, 11] + end: [2, 1, 11] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [2, 0, 11] + end: [2, 1, 11] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 11] + end: [2, 2, 11] + block: ShortXMemoryBlock + boundary: OOZZ + - start: [2, 1, 11] + end: [3, 1, 11] + block: ShortXMemoryBlock + boundary: ZZOO + - start: [3, 1, 11] + end: [4, 1, 11] + block: ShortXMemoryBlock + boundary: ZZOO + + +logical_observables: null diff --git a/examples/design/patch_rotation2.yml b/examples/design/patch_rotation2.yml new file mode 100644 index 00000000..d4671e67 --- /dev/null +++ b/examples/design/patch_rotation2.yml @@ -0,0 +1,64 @@ +name: 2 Block Patch Rotation Canvas +description: Global config for rotated surface code based blocks + +layout: rotated_surface_code # required + +cube: + - position: [0, 0, 0] + block: InitPlusBlock + boundary: ZZXX + - position: [0, 0, 1] + block: MemoryBlock + boundary: ZZXO + - position: [1, 0, 1] + block: InitPlusBlock + boundary: ZXOZ + - position: [0, 0, 2] + block: MeasureXBlock + boundary: ZZXO + logical_observables: RL + - position: [1, 0, 2] + block: MemoryBlock + boundary: XXZZ + - position: [0, 0, 3] + block: InitPlusBlock + boundary: XXZO + - position: [1, 0, 3] + block: MemoryBlock + boundary: XXOZ + - position: [0, 0, 4] + block: MemoryBlock + boundary: XXZZ + - position: [1, 0, 4] + block: MeasureXBlock + boundary: XXOZ + - position: [0, 0, 5] + block: MeasureXBlock + boundary: XXZZ + logical_observables: TB + +pipe: + - start: [0, 0, 1] + end: [1, 0, 1] + block: InitPlusBlock + boundary: ZZOO + logical_observables: X + - start: [0, 0, 2] + end: [1, 0, 2] + block: MeasureXBlock + boundary: ZZOX + logical_observables: RL + - start: [0, 0, 3] + end: [1, 0, 3] + block: InitPlusBlock + boundary: XXOO + logical_observables: Z + - start: [0, 0, 4] + end: [1, 0, 4] + block: MeasureXBlock + boundary: XXZO + + +logical_observables: + - cube: [[0, 0, 2], [0, 0, 5]] + pipe: [[[0, 0, 2], [1, 0, 2]], [[0, 0, 3], [1, 0, 3]]] diff --git a/examples/design/utils/_patch_rotation2_bottom.yml b/examples/design/utils/_patch_rotation2_bottom.yml new file mode 100644 index 00000000..44ec0bf5 --- /dev/null +++ b/examples/design/utils/_patch_rotation2_bottom.yml @@ -0,0 +1,51 @@ +name: 2 Block Patch Rotation Canvas +description: Global config for rotated surface code based blocks + +layout: rotated_surface_code # required + +cube: + - position: [0, 1, 0] + block: MemoryBlock + boundary: OXZZ + - position: [0, 0, 0] + block: InitPlusBlock + boundary: ZOXZ + - position: [0, 1, 1] + block: MeasureXBlock + boundary: OXZZ + - position: [0, 0, 1] + block: MemoryBlock + boundary: ZZXX + - position: [0, 1, 2] + block: InitPlusBlock + boundary: OZXX + - position: [0, 0, 2] + block: MemoryBlock + boundary: ZOXX + - position: [0, 1, 3] + block: MemoryBlock + boundary: OZXX + - position: [0, 0, 3] + block: MeasureXBlock + boundary: ZOXX + +pipe: + - start: [0, 0, 0] + end: [0, 1, 0] + block: InitPlusBlock + boundary: OOZZ + logical_observables: X + - start: [0, 0, 1] + end: [0, 1, 1] + block: MeasureXBlock + boundary: XOZZ + logical_observables: RL + - start: [0, 0, 2] + end: [0, 1, 2] + block: InitPlusBlock + boundary: OOXX + logical_observables: Z + - start: [0, 0, 3] + end: [0, 1, 3] + block: MeasureXBlock + boundary: OZXX diff --git a/examples/design/utils/_patch_rotation2_left.yml b/examples/design/utils/_patch_rotation2_left.yml new file mode 100644 index 00000000..e24ddaa5 --- /dev/null +++ b/examples/design/utils/_patch_rotation2_left.yml @@ -0,0 +1,51 @@ +name: 2 Block Patch Rotation Canvas +description: Global config for rotated surface code based blocks + +layout: rotated_surface_code # required + +cube: + - position: [0, 0, 0] + block: MemoryBlock + boundary: XXZO + - position: [1, 0, 0] + block: InitPlusBlock + boundary: XZOX + - position: [0, 0, 1] + block: MeasureXBlock + boundary: XXZO + - position: [1, 0, 1] + block: MemoryBlock + boundary: ZZXX + - position: [0, 0, 2] + block: InitPlusBlock + boundary: ZZXO + - position: [1, 0, 2] + block: MemoryBlock + boundary: ZZOX + - position: [0, 0, 3] + block: MemoryBlock + boundary: ZZXX + - position: [1, 0, 3] + block: MeasureXBlock + boundary: ZZOX + +pipe: + - start: [0, 0, 0] + end: [1, 0, 0] + block: InitPlusBlock + boundary: XXOO + logical_observables: X + - start: [0, 0, 1] + end: [1, 0, 1] + block: MeasureXBlock + boundary: XXOZ + logical_observables: RL + - start: [0, 0, 2] + end: [1, 0, 2] + block: InitPlusBlock + boundary: ZZOO + logical_observables: Z + - start: [0, 0, 3] + end: [1, 0, 3] + block: MeasureXBlock + boundary: ZZXO diff --git a/examples/design/utils/_patch_rotation2_right.yml b/examples/design/utils/_patch_rotation2_right.yml new file mode 100644 index 00000000..565bda95 --- /dev/null +++ b/examples/design/utils/_patch_rotation2_right.yml @@ -0,0 +1,51 @@ +name: 2 Block Patch Rotation Canvas +description: Global config for rotated surface code based blocks + +layout: rotated_surface_code # required + +cube: + - position: [1, 0, 0] + block: MemoryBlock + boundary: XXOZ + - position: [0, 0, 0] + block: InitPlusBlock + boundary: XZXO + - position: [1, 0, 1] + block: MeasureXBlock + boundary: XXOZ + - position: [0, 0, 1] + block: MemoryBlock + boundary: ZZXX + - position: [1, 0, 2] + block: InitPlusBlock + boundary: ZZOX + - position: [0, 0, 2] + block: MemoryBlock + boundary: ZZXO + - position: [1, 0, 3] + block: MemoryBlock + boundary: ZZXX + - position: [0, 0, 3] + block: MeasureXBlock + boundary: ZZXO + +pipe: + - start: [0, 0, 0] + end: [1, 0, 0] + block: InitPlusBlock + boundary: XXOO + logical_observables: X + - start: [0, 0, 1] + end: [1, 0, 1] + block: MeasureXBlock + boundary: XXZO + logical_observables: RL + - start: [0, 0, 2] + end: [1, 0, 2] + block: InitPlusBlock + boundary: ZZOO + logical_observables: Z + - start: [0, 0, 3] + end: [1, 0, 3] + block: MeasureXBlock + boundary: ZZOX diff --git a/examples/design/utils/_patch_rotation2_top.yml b/examples/design/utils/_patch_rotation2_top.yml new file mode 100644 index 00000000..3cc63e57 --- /dev/null +++ b/examples/design/utils/_patch_rotation2_top.yml @@ -0,0 +1,51 @@ +name: 2 Block Patch Rotation Canvas +description: Global config for rotated surface code based blocks + +layout: rotated_surface_code # required + +cube: + - position: [0, 0, 0] + block: MemoryBlock + boundary: ZOZZ + - position: [0, 1, 0] + block: InitPlusBlock + boundary: OZXZ + - position: [0, 0, 1] + block: MeasureXBlock + boundary: XOZZ + - position: [0, 1, 1] + block: MemoryBlock + boundary: ZZXX + - position: [0, 0, 2] + block: InitPlusBlock + boundary: ZOXX + - position: [0, 1, 2] + block: MemoryBlock + boundary: OZXX + - position: [0, 0, 3] + block: MemoryBlock + boundary: ZOXX + - position: [0, 1, 3] + block: MeasureXBlock + boundary: OZXX + +pipe: + - start: [0, 0, 0] + end: [0, 1, 0] + block: InitPlusBlock + boundary: OOZZ + logical_observables: X + - start: [0, 0, 1] + end: [0, 1, 1] + block: MeasureXBlock + boundary: OXZZ + logical_observables: RL + - start: [0, 0, 2] + end: [0, 1, 2] + block: InitPlusBlock + boundary: OOXX + logical_observables: Z + - start: [0, 0, 3] + end: [0, 1, 3] + block: MeasureXBlock + boundary: ZOXX diff --git a/examples/export_to_graphqomb_studio.py b/examples/export_to_graphqomb_studio.py index 2cb5b57f..87bc0b61 100644 --- a/examples/export_to_graphqomb_studio.py +++ b/examples/export_to_graphqomb_studio.py @@ -12,10 +12,18 @@ # ============================================================================= # Configuration - modify these parameters as needed # ============================================================================= -spec_name = "design/cnot.yml" -code_distance = 3 +spec_name = "/home/masato/git-repos/pyzx-mbqc/SimulatorTeamWS/mf/cnot_lspattern/design/cnot_XIXX.yml" +code_distance = 7 output_dir = Path("output") +# Optional coordinate range filter (closed intervals). Use None for unbounded. +x_min: int | None = None +x_max: int | None = None +y_min: int | None = None +y_max: int | None = None +z_min: int | None = None +z_max: int | None = None + # %% # Load canvas from YAML spec print(f"Loading canvas from '{spec_name}' (d={code_distance})...") @@ -37,7 +45,16 @@ # Preview JSON structure (first N lines) PREVIEW_LINES = 100 print("\n=== GraphQOMB Studio JSON Preview ===") -result = canvas_to_graphqomb_studio_dict(canvas, name=spec.name) +result = canvas_to_graphqomb_studio_dict( + canvas, + name=spec.name, + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, +) json_str = json.dumps(result, indent=2) all_lines = json_str.split("\n") preview_lines = all_lines[:PREVIEW_LINES] @@ -51,7 +68,17 @@ spec_stem = Path(spec_name).stem output_path = output_dir / f"{spec_stem}_d{code_distance}.json" -export_canvas_to_graphqomb_studio(canvas, output_path, name=spec.name) +export_canvas_to_graphqomb_studio( + canvas, + output_path, + name=spec.name, + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, +) print("\n=== Exported ===") print(f"Output: {output_path}") print(f"File size: {output_path.stat().st_size:,} bytes") diff --git a/examples/profile_canvas.py b/examples/profile_canvas.py new file mode 100644 index 00000000..c2beaa66 --- /dev/null +++ b/examples/profile_canvas.py @@ -0,0 +1,223 @@ +"""Profile canvas YAML: load, visualize, and compile to MBQC pattern. + +This script loads a canvas YAML file and performs: +1. Load canvas and display statistics +2. 3D Plotly visualization (optional) +3. 2D slice visualization (optional) +4. Flow cycle check (debug) +5. Compile to Pattern and show profile +6. Export pattern to .ptn +""" + +# %% +from __future__ import annotations + +from pathlib import Path +from statistics import mean + +import yaml +from graphqomb.command import M +from graphqomb.ptn_format import dump as dump_ptn + +from lspattern.canvas_loader import load_canvas +from lspattern.compiler import compile_canvas_to_pattern +from lspattern.debug_utils import check_coord_flow_cycle +from lspattern.visualizer import visualize_canvas_plotly +from lspattern.visualizer_2d import visualize_canvas_matplotlib_2d + +# ============================================================================= +# Configuration - Edit these values +# ============================================================================= +input_yaml = Path(__file__).parent / "design" / "distillation_canvas.yml" +code_distance = 5 +output_dir = Path(__file__).parent / "output" +profile_table_config = Path(__file__).parent / "profile_table_config.yml" + +# Visualization options +enable_3d_viz = False # Set to False to skip 3D visualization +slice_z: int | None = 60 # Z coordinate for 2D slice (None to skip) +aspect_ratio: tuple[int, int, int] | None = None # Plotly 3D aspect ratio + +# Ensure output directory exists +output_dir.mkdir(parents=True, exist_ok=True) + +print(f"Input YAML: {input_yaml}") +print(f"Code distance: {code_distance}") +print(f"Output directory: {output_dir}") +print() + +# %% +# Step 1: Load canvas from YAML +print("Step 1: Loading canvas from YAML...") +canvas, spec = load_canvas(input_yaml, code_distance=code_distance) + +num_nodes = len(canvas.nodes) +num_edges = len(canvas.edges) +print(f" Canvas name: {spec.name}") +print(f" Nodes: {num_nodes:,}") +print(f" Edges: {num_edges:,}") +print() + +# %% +# Step 2: Visualizations (optional) +if enable_3d_viz: + print("Step 2: Generating 3D Plotly visualization...") + fig_3d = visualize_canvas_plotly(canvas, aspect_ratio=aspect_ratio) + fig_3d.update_layout(title=f"3D Graph: {spec.name} (d={code_distance})") + + # Save as HTML for later viewing + html_path = output_dir / f"{input_yaml.stem}_3d.html" + fig_3d.write_html(str(html_path)) + print(f" Saved 3D view: {html_path}") + + # Show interactive 3D view + fig_3d.show() + +# %% +if slice_z is not None: + print(f"Step 2b: Creating 2D slice visualization at Z={slice_z}...") + available_z = sorted({node.z for node in canvas.nodes}) + if slice_z not in available_z: + msg = f"Requested slice_z={slice_z} is not available. Available z values: {available_z}" + raise ValueError(msg) + + fig_slice = visualize_canvas_matplotlib_2d( + canvas, + target_z=slice_z, + reverse_axes=True, + ) + fig_slice.suptitle(f"2D Slice: {spec.name} (d={code_distance}, z={slice_z})") + slice_path = output_dir / f"{input_yaml.stem}_z{slice_z}_slice.png" + fig_slice.savefig(slice_path, dpi=200, bbox_inches="tight") + print(f" Saved 2D slice: {slice_path}") + fig_slice.show() + +# %% +# Step 3: Check for flow cycles (debug) +print() +print("Step 3: Checking flow graph for cycles...") +try: + check_coord_flow_cycle(canvas.flow.flow, canvas.edges) + print(" No cycles detected in flow graph.") +except ValueError as e: + print(f" ERROR: {e}") + print() + print("Debug info:") + print(f" Total flow entries: {len(canvas.flow.flow)}") + print(f" Total edges: {len(canvas.edges)}") + raise + +# %% +# Step 4: Compile to Pattern +print() +print("Step 4: Compiling canvas to MBQC pattern...") +pattern, graph, node_map = compile_canvas_to_pattern(canvas) + +num_commands = len(pattern) +print(f" Pattern commands: {num_commands:,}") +print(f" Graph nodes: {len(graph.physical_nodes):,}") +print(f" Node map entries: {len(node_map):,}") +print() + +# %% +# Step 5: Profile pattern +idle_values = list(pattern.idle_times.values()) +try: + throughput = pattern.throughput +except ValueError: + throughput = None + +measurement_depth = pattern.depth_of((M,)) +pattern_profile = { + "commands": len(pattern), + "max_space": pattern.max_space, + "depth": pattern.depth, + "depth_m": measurement_depth, + "active_volume": pattern.active_volume, + "volume": pattern.volume, + "throughput": throughput, + "idle_qubits": len(idle_values), + "idle_min": min(idle_values) if idle_values else 0, + "idle_mean": mean(idle_values) if idle_values else 0.0, + "idle_max": max(idle_values) if idle_values else 0, +} + +print("Pattern profile:") +for key, value in pattern_profile.items(): + print(f" {key}: {value}") +print() + +# %% +# Step 5b: Generate profile summary table (Matplotlib) +import matplotlib.pyplot as plt + +commands_no_tick = len(pattern) - pattern.depth +all_metrics = { + "commands_no_tick": commands_no_tick, + "max_space": pattern.max_space, + "depth": pattern.depth, + "depth_m": measurement_depth, + "active_volume": pattern.active_volume, + "volume": pattern.volume, + "throughput": throughput, + "idle_qubits": len(idle_values), + "idle_min": min(idle_values) if idle_values else 0, + "idle_mean": mean(idle_values) if idle_values else 0.0, + "idle_max": max(idle_values) if idle_values else 0, +} + +with open(profile_table_config) as f: + table_cfg = yaml.safe_load(f) + +metric_entries = table_cfg["metrics"] +style = table_cfg.get("style", {}) + +col_labels = [m["label"] for m in metric_entries] +cell_values = [f"{all_metrics[m['key']]:,}" for m in metric_entries] + +figsize = style.get("figsize", [5.0, 1.5]) +fig_table, ax = plt.subplots(figsize=figsize) +ax.axis("off") + +tbl = ax.table( + cellText=[cell_values], + colLabels=col_labels, + loc="center", + cellLoc="center", +) +tbl.auto_set_font_size(False) +tbl.set_fontsize(style.get("fontsize", 11)) +tbl.scale(1.0, style.get("row_height", 1.6)) + +header_bg = style.get("header_bg", "#4472C4") +header_fg = style.get("header_fg", "white") +cell_bg = style.get("cell_bg", "#D9E2F3") +for j in range(len(col_labels)): + tbl[0, j].set_facecolor(header_bg) + tbl[0, j].set_text_props(color=header_fg, fontweight="bold") + tbl[1, j].set_facecolor(cell_bg) + +fig_table.suptitle( + f"{spec.name} (d={code_distance})", + fontsize=style.get("title_fontsize", 12), + fontweight="bold", +) +fig_table.tight_layout() + +table_path = output_dir / f"{input_yaml.stem}_profile_table.png" +fig_table.savefig(table_path, dpi=style.get("dpi", 200), bbox_inches="tight") +print(f" Saved profile table: {table_path}") +fig_table.show() + +# %% +print("Step 6: Exporting pattern to .ptn...") +ptn_path = output_dir / f"{input_yaml.stem}.ptn" +dump_ptn(pattern, ptn_path) +print(f" M-command depth: {measurement_depth}") +print(f" Saved pattern: {ptn_path}") +print() + +# %% +print() +print("Pipeline completed successfully!") +print(f" Output directory: {output_dir}") diff --git a/examples/profile_table_config.yml b/examples/profile_table_config.yml new file mode 100644 index 00000000..c9c01859 --- /dev/null +++ b/examples/profile_table_config.yml @@ -0,0 +1,35 @@ +# Profile table configuration for Matplotlib output. + +# Metrics to display in the table. +# Available keys: +# commands_no_tick - Number of commands excluding TICK +# max_space - Maximum number of active qubits +# depth - Total depth (number of TICKs) +# depth_m - Measurement depth +# active_volume - Active volume (max_space * depth) +# volume - Total volume +# throughput - Throughput +# idle_qubits - Number of idle qubits +# idle_min - Minimum idle time +# idle_mean - Mean idle time +# idle_max - Maximum idle time +metrics: + - key: commands_no_tick + label: "Commands" + - key: max_space + label: "Max space" + - key: depth_m + label: "Depth (M)" + - key: active_volume + label: "Active volume" + +# Figure style +style: + figsize: [10.0, 3.0] + dpi: 200 + fontsize: 14 + row_height: 2 # scale factor for row height + header_bg: "#4472C4" + header_fg: "white" + cell_bg: "#D9E2F3" + title_fontsize: 16 diff --git a/examples/verification_canvas.py b/examples/verification_canvas.py index 6962284e..1fdfdc2b 100644 --- a/examples/verification_canvas.py +++ b/examples/verification_canvas.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from lspattern.mytype import Coord3D -spec_name = "design/cnot.yml" +spec_name = "/home/masato/git-repos/pyzx-mbqc/SimulatorTeamWS/mf/cnot_lspattern/design/cnot_IXIX.yml" code_distance = 3 canvas, spec = load_canvas(spec_name, code_distance=code_distance) diff --git a/lspattern/compiler.py b/lspattern/compiler.py index 7240680e..6dbae5e8 100644 --- a/lspattern/compiler.py +++ b/lspattern/compiler.py @@ -13,6 +13,9 @@ from lspattern.detector import construct_detector if TYPE_CHECKING: + from graphqomb.graphstate import BaseGraphState + from graphqomb.pattern import Pattern + from lspattern.canvas import Canvas from lspattern.canvas_loader import CompositeLogicalObservableSpec from lspattern.mytype import Coord3D @@ -76,15 +79,29 @@ def _collect_logical_observable_nodes( # noqa: C901 return nodes -def compile_canvas_to_stim( +def compile_canvas_to_pattern( canvas: Canvas, - p_depol_after_clifford: float, - p_before_meas_flip: float, -) -> str: +) -> tuple[Pattern, BaseGraphState, dict[Coord3D, int]]: + """Compile a canvas into an MBQC measurement pattern. + + Parameters + ---------- + canvas : Canvas + The canvas containing graph-state, flow, and scheduling data. + + Returns + ------- + tuple[Pattern, BaseGraphState, dict[Coord3D, int]] + The compiled pattern, the underlying graph state, and the + coordinate-to-node-index mapping. + """ + # Cache pauli_axes to avoid repeated dict copies (property returns dict() each time) + pauli_axes = canvas.pauli_axes + graph, node_map = GraphState.from_graph( nodes=canvas.nodes, edges=canvas.edges, - meas_bases={node: AxisMeasBasis(canvas.pauli_axes[node], Sign.PLUS) for node in canvas.nodes}, + meas_bases={node: AxisMeasBasis(pauli_axes[node], Sign.PLUS) for node in canvas.nodes}, ) flow = canvas.flow.to_node_flow(node_map) @@ -104,6 +121,32 @@ def compile_canvas_to_stim( detectors.append(frozenset(det_nodes)) pattern = qompile(graph, flow, parity_check_group=detectors, scheduler=scheduler) + return pattern, graph, node_map + + +def compile_canvas_to_stim( + canvas: Canvas, + p_depol_after_clifford: float, + p_before_meas_flip: float, +) -> str: + """Compile a canvas into a stim circuit string. + + Parameters + ---------- + canvas : Canvas + The canvas containing graph-state, flow, and scheduling data. + p_depol_after_clifford : float + Depolarization error rate after Clifford gates. + p_before_meas_flip : float + Bit-flip error rate before measurement. + + Returns + ------- + str + The stim circuit as a string. + """ + pattern, _graph, node_map = compile_canvas_to_pattern(canvas) + # extract logical observables from canvas logical_observables_nodes: dict[int, set[int]] = {} for key, composite_logical_obs in enumerate(canvas.logical_observables): diff --git a/lspattern/debug_utils.py b/lspattern/debug_utils.py new file mode 100644 index 00000000..47831b7f --- /dev/null +++ b/lspattern/debug_utils.py @@ -0,0 +1,59 @@ +"""Debug utilities for flow cycle detection at coordinate level.""" + +from __future__ import annotations + +from functools import reduce +from operator import xor +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from lspattern.mytype import Coord3D + + +def check_coord_flow_cycle( + xflow: dict[Coord3D, set[Coord3D]], + edges: set[tuple[Coord3D, Coord3D]], +) -> None: + """Check for cycles in the flow graph at coordinate level. + + This function performs the same DAG construction as graphqomb.qompile() but + at the coordinate level, providing more informative error messages when + cycles are detected. + + Parameters + ---------- + xflow : dict[Coord3D, set[Coord3D]] + X correction flow at coordinate level (from CoordFlowAccumulator.flow). + edges : set[tuple[Coord3D, Coord3D]] + Graph edges at coordinate level (from Canvas.edges). + + Raises + ------ + ValueError + If a cycle is detected, with coordinate information in the message. + """ + # Build adjacency once + adjacency: dict[Coord3D, set[Coord3D]] = {} + for a, b in edges: + adjacency.setdefault(a, set()).add(b) + adjacency.setdefault(b, set()).add(a) + + # Compute zflow from xflow: zflow[node] = XOR of neighbors of xflow targets + zflow: dict[Coord3D, set[Coord3D]] = {} + for node, targets in xflow.items(): + neighbor_sets = [adjacency.get(t, set()) for t in targets] + zflow[node] = reduce(xor, neighbor_sets, set()) + + # Build DAG: (xflow | zflow) - self-loops + dag: dict[Coord3D, set[Coord3D]] = {} + for node in xflow: + x_targets = xflow.get(node, set()) + z_targets = zflow.get(node, set()) + dag[node] = (x_targets | z_targets) - {node} # Remove self-loops + + # Check for direct cycles (A -> B and B -> A) + for node, children in dag.items(): + for child in children: + if child in dag and node in dag[child]: + msg = f"Cycle detected in flow graph at coordinate level:\n {node} -> {child}\n {child} -> {node}" + raise ValueError(msg) diff --git a/lspattern/export.py b/lspattern/export.py index 8ad64363..738a3c41 100644 --- a/lspattern/export.py +++ b/lspattern/export.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from lspattern.accumulator import CoordScheduleAccumulator from lspattern.canvas import Canvas + from lspattern.canvas_loader import CompositeLogicalObservableSpec from lspattern.mytype import Coord3D @@ -73,7 +74,92 @@ def _axis_to_string(axis: Axis) -> str: return "Z" -def _convert_nodes(canvas: Canvas) -> list[dict[str, Any]]: +def _validate_coordinate_range( + *, + x_min: int | None = None, + x_max: int | None = None, + y_min: int | None = None, + y_max: int | None = None, + z_min: int | None = None, + z_max: int | None = None, +) -> None: + """Validate axis-aligned coordinate range bounds. + + Raises + ------ + ValueError + If any axis has min > max. + """ + axis_bounds = ( + ("x", x_min, x_max), + ("y", y_min, y_max), + ("z", z_min, z_max), + ) + for axis_name, min_value, max_value in axis_bounds: + if min_value is not None and max_value is not None and min_value > max_value: + msg = ( + f"Invalid coordinate range: {axis_name}_min ({min_value}) must be less than or equal to " + f"{axis_name}_max ({max_value})" + ) + raise ValueError(msg) + + +def _coord_in_range( + coord: Coord3D, + *, + x_min: int | None = None, + x_max: int | None = None, + y_min: int | None = None, + y_max: int | None = None, + z_min: int | None = None, + z_max: int | None = None, +) -> bool: + """Return whether a coordinate is inside the closed axis-aligned range.""" + return ( + (x_min is None or coord.x >= x_min) + and (x_max is None or coord.x <= x_max) + and (y_min is None or coord.y >= y_min) + and (y_max is None or coord.y <= y_max) + and (z_min is None or coord.z >= z_min) + and (z_max is None or coord.z <= z_max) + ) + + +def _build_allowed_nodes( + canvas: Canvas, + *, + x_min: int | None = None, + x_max: int | None = None, + y_min: int | None = None, + y_max: int | None = None, + z_min: int | None = None, + z_max: int | None = None, +) -> set[Coord3D]: + """Build the coordinate set that is kept by export range filtering.""" + _validate_coordinate_range( + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, + ) + return { + coord + for coord in canvas.nodes + if _coord_in_range( + coord, + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, + ) + } + + +def _convert_nodes(canvas: Canvas, allowed_nodes: set[Coord3D] | None = None) -> list[dict[str, Any]]: """Convert canvas nodes to GraphQOMB Studio format. Parameters @@ -87,9 +173,11 @@ def _convert_nodes(canvas: Canvas) -> list[dict[str, Any]]: List of node dictionaries in GraphQOMB Studio format. """ nodes = [] - for coord in sorted(canvas.nodes): + coords = canvas.nodes if allowed_nodes is None else allowed_nodes + pauli_axes = canvas.pauli_axes + for coord in sorted(coords): node_id = _coord_to_node_id(coord) - axis = canvas.pauli_axes.get(coord, Axis.Z) + axis = pauli_axes.get(coord, Axis.Z) node_dict: dict[str, Any] = { "id": node_id, "coordinate": {"x": coord.x, "y": coord.y, "z": coord.z}, @@ -104,7 +192,7 @@ def _convert_nodes(canvas: Canvas) -> list[dict[str, Any]]: return nodes -def _convert_edges(canvas: Canvas) -> list[dict[str, str]]: +def _convert_edges(canvas: Canvas, allowed_nodes: set[Coord3D] | None = None) -> list[dict[str, str]]: """Convert canvas edges to GraphQOMB Studio format. Parameters @@ -120,6 +208,8 @@ def _convert_edges(canvas: Canvas) -> list[dict[str, str]]: edges = [] seen_ids: set[str] = set() for coord1, coord2 in canvas.edges: + if allowed_nodes is not None and (coord1 not in allowed_nodes or coord2 not in allowed_nodes): + continue source_id = _coord_to_node_id(coord1) target_id = _coord_to_node_id(coord2) edge_id = _normalize_edge_id(source_id, target_id) @@ -134,7 +224,7 @@ def _convert_edges(canvas: Canvas) -> list[dict[str, str]]: return sorted(edges, key=operator.itemgetter("id")) -def _convert_xflow(canvas: Canvas) -> dict[str, list[str]]: +def _convert_xflow(canvas: Canvas, allowed_nodes: set[Coord3D] | None = None) -> dict[str, list[str]]: """Convert canvas flow to GraphQOMB Studio xflow format. Parameters @@ -149,8 +239,14 @@ def _convert_xflow(canvas: Canvas) -> dict[str, list[str]]: """ xflow: dict[str, list[str]] = {} for from_coord, to_coords in canvas.flow.flow.items(): + if allowed_nodes is not None and from_coord not in allowed_nodes: + continue from_id = _coord_to_node_id(from_coord) - to_ids = sorted(_coord_to_node_id(c) for c in to_coords) + to_ids = sorted( + _coord_to_node_id(c) + for c in to_coords + if allowed_nodes is None or c in allowed_nodes + ) if to_ids: xflow[from_id] = to_ids return xflow @@ -159,6 +255,7 @@ def _convert_xflow(canvas: Canvas) -> dict[str, list[str]]: def _build_node_time_map( canvas: Canvas, time_dict: dict[int, set[Coord3D]], + allowed_nodes: set[Coord3D] | None = None, ) -> dict[str, int | None]: """Build a mapping from node IDs to their scheduled time. @@ -174,16 +271,19 @@ def _build_node_time_map( dict[str, int | None] Mapping from node ID to scheduled time (or None if not scheduled). """ - result: dict[str, int | None] = {_coord_to_node_id(c): None for c in canvas.nodes} + nodes_for_map = canvas.nodes if allowed_nodes is None else allowed_nodes + result: dict[str, int | None] = {_coord_to_node_id(c): None for c in nodes_for_map} for time, coords in time_dict.items(): for coord in coords: - node_id = _coord_to_node_id(coord) - if node_id in result: - result[node_id] = time + if coord in nodes_for_map: + result[_coord_to_node_id(coord)] = time return result -def _build_entangle_time_map(scheduler: CoordScheduleAccumulator) -> dict[str, int]: +def _build_entangle_time_map( + scheduler: CoordScheduleAccumulator, + allowed_nodes: set[Coord3D] | None = None, +) -> dict[str, int]: """Build a mapping from edge IDs to their entanglement time. Parameters @@ -199,6 +299,8 @@ def _build_entangle_time_map(scheduler: CoordScheduleAccumulator) -> dict[str, i result: dict[str, int] = {} for time, edges in scheduler.entangle_time.items(): for coord1, coord2 in edges: + if allowed_nodes is not None and (coord1 not in allowed_nodes or coord2 not in allowed_nodes): + continue source_id = _coord_to_node_id(coord1) target_id = _coord_to_node_id(coord2) edge_id = _normalize_edge_id(source_id, target_id) @@ -206,7 +308,10 @@ def _build_entangle_time_map(scheduler: CoordScheduleAccumulator) -> dict[str, i return result -def _build_timeline(scheduler: CoordScheduleAccumulator) -> list[dict[str, Any]]: +def _build_timeline( + scheduler: CoordScheduleAccumulator, + allowed_nodes: set[Coord3D] | None = None, +) -> list[dict[str, Any]]: """Build timeline array from scheduler data. Parameters @@ -226,12 +331,23 @@ def _build_timeline(scheduler: CoordScheduleAccumulator) -> list[dict[str, Any]] timeline: list[dict[str, Any]] = [] for time in sorted(all_times): - prep_nodes = sorted(_coord_to_node_id(c) for c in scheduler.prep_time.get(time, set())) - meas_nodes = sorted(_coord_to_node_id(c) for c in scheduler.meas_time.get(time, set())) + prep_nodes = sorted( + _coord_to_node_id(c) + for c in scheduler.prep_time.get(time, set()) + if allowed_nodes is None or c in allowed_nodes + ) + meas_nodes = sorted( + _coord_to_node_id(c) + for c in scheduler.meas_time.get(time, set()) + if allowed_nodes is None or c in allowed_nodes + ) entangle_edges = sorted( _normalize_edge_id(_coord_to_node_id(c1), _coord_to_node_id(c2)) for c1, c2 in scheduler.entangle_time.get(time, set()) + if allowed_nodes is None or (c1 in allowed_nodes and c2 in allowed_nodes) ) + if not prep_nodes and not entangle_edges and not meas_nodes: + continue timeline.append( { "time": time, @@ -243,7 +359,7 @@ def _build_timeline(scheduler: CoordScheduleAccumulator) -> list[dict[str, Any]] return timeline -def _convert_schedule(canvas: Canvas) -> dict[str, Any]: +def _convert_schedule(canvas: Canvas, allowed_nodes: set[Coord3D] | None = None) -> dict[str, Any]: """Convert canvas scheduler to GraphQOMB Studio schedule format. Parameters @@ -258,14 +374,14 @@ def _convert_schedule(canvas: Canvas) -> dict[str, Any]: """ scheduler = canvas.scheduler return { - "prepareTime": _build_node_time_map(canvas, scheduler.prep_time), - "measureTime": _build_node_time_map(canvas, scheduler.meas_time), - "entangleTime": _build_entangle_time_map(scheduler), - "timeline": _build_timeline(scheduler), + "prepareTime": _build_node_time_map(canvas, scheduler.prep_time, allowed_nodes=allowed_nodes), + "measureTime": _build_node_time_map(canvas, scheduler.meas_time, allowed_nodes=allowed_nodes), + "entangleTime": _build_entangle_time_map(scheduler, allowed_nodes=allowed_nodes), + "timeline": _build_timeline(scheduler, allowed_nodes=allowed_nodes), } -def _convert_detectors(canvas: Canvas) -> list[list[str]]: +def _convert_detectors(canvas: Canvas, allowed_nodes: set[Coord3D] | None = None) -> list[list[str]]: """Convert canvas parity accumulator to detector groups. Parameters @@ -281,14 +397,17 @@ def _convert_detectors(canvas: Canvas) -> list[list[str]]: detectors = construct_detector(canvas.parity_accumulator) result: list[list[str]] = [] for _, coords in sorted(detectors.items()): - group = sorted(_coord_to_node_id(c) for c in coords) + filtered_coords = coords if allowed_nodes is None else {c for c in coords if c in allowed_nodes} + group = sorted(_coord_to_node_id(c) for c in filtered_coords) if group: result.append(group) return result -def _convert_logical_observables(canvas: Canvas) -> dict[str, list[str]]: - """Convert canvas couts and pipe_couts to logical observable groups. +def _convert_logical_observables( + canvas: Canvas, allowed_nodes: set[Coord3D] | None = None +) -> dict[str, list[str]]: + """Convert composite logical observables to GraphQOMB Studio groups. Parameters ---------- @@ -300,35 +419,74 @@ def _convert_logical_observables(canvas: Canvas) -> dict[str, list[str]]: dict[str, list[str]] Dictionary mapping observable labels to lists of node IDs. """ - merged: dict[str, set[Coord3D]] = {} - - # Merge cube couts - for label_map in canvas.couts.values(): - for label, coords in label_map.items(): - if label not in merged: - merged[label] = set() - merged[label].update(coords) - - # Merge pipe couts - for label_map in canvas.pipe_couts.values(): - for label, coords in label_map.items(): - if label not in merged: - merged[label] = set() - merged[label].update(coords) - - # Convert to output format result: dict[str, list[str]] = {} - for label in sorted(merged.keys()): - coords = merged[label] - result[label] = sorted(_coord_to_node_id(c) for c in coords) + for idx, composite_obs in enumerate(canvas.logical_observables): + nodes = _collect_composite_logical_observable_nodes(canvas, composite_obs) + if allowed_nodes is not None: + nodes = {coord for coord in nodes if coord in allowed_nodes} + if not nodes: + continue + result[f"obs_{idx}"] = sorted(_coord_to_node_id(c) for c in nodes) return result +def _collect_composite_logical_observable_nodes( # noqa: C901 + canvas: Canvas, + composite_obs: CompositeLogicalObservableSpec, +) -> set[Coord3D]: + """Collect node coordinates referenced by one composite observable.""" + nodes: set[Coord3D] = set() + + for cube_ref in composite_obs.cubes: + if cube_ref.position not in canvas.couts: + msg = f"Cube {cube_ref.position} not found in canvas.couts. Available cubes: {sorted(canvas.couts.keys())}" + raise KeyError(msg) + cube_couts = canvas.couts[cube_ref.position] + if cube_ref.label is not None: + if cube_ref.label not in cube_couts: + msg = ( + f"Label '{cube_ref.label}' not found in cube {cube_ref.position}. " + f"Available labels: {sorted(cube_couts.keys())}" + ) + raise KeyError(msg) + nodes |= cube_couts[cube_ref.label] + else: + for coords in cube_couts.values(): + nodes |= coords + + for pipe_ref in composite_obs.pipes: + pipe_key = (pipe_ref.start, pipe_ref.end) + if pipe_key not in canvas.pipe_couts: + msg = f"Pipe {pipe_key} not found in canvas.pipe_couts. Available pipes: {sorted(canvas.pipe_couts.keys())}" + raise KeyError(msg) + pipe_couts = canvas.pipe_couts[pipe_key] + if pipe_ref.label is not None: + if pipe_ref.label not in pipe_couts: + msg = ( + f"Label '{pipe_ref.label}' not found in pipe {pipe_key}. " + f"Available labels: {sorted(pipe_couts.keys())}" + ) + raise KeyError(msg) + nodes |= pipe_couts[pipe_ref.label] + else: + for coords in pipe_couts.values(): + nodes |= coords + + return nodes + + def export_canvas_to_graphqomb_studio( canvas: Canvas, output_path: str | Path, name: str = "Exported from lspattern", + *, + x_min: int | None = None, + x_max: int | None = None, + y_min: int | None = None, + y_max: int | None = None, + z_min: int | None = None, + z_max: int | None = None, ) -> None: """Export Canvas to GraphQOMB Studio JSON format. @@ -340,6 +498,9 @@ def export_canvas_to_graphqomb_studio( Path to write the JSON file. name : str Project name in the exported JSON. + x_min, x_max, y_min, y_max, z_min, z_max : int | None + Closed-range bounds for node coordinates to export. Unspecified bounds + are treated as unbounded. Examples -------- @@ -348,20 +509,29 @@ def export_canvas_to_graphqomb_studio( >>> canvas = Canvas(config) >>> export_canvas_to_graphqomb_studio(canvas, "output.json", name="My Project") """ + allowed_nodes = _build_allowed_nodes( + canvas, + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, + ) project: dict[str, Any] = { "$schema": "graphqomb-studio/v1", "name": name, - "nodes": _convert_nodes(canvas), - "edges": _convert_edges(canvas), + "nodes": _convert_nodes(canvas, allowed_nodes=allowed_nodes), + "edges": _convert_edges(canvas, allowed_nodes=allowed_nodes), "flow": { - "xflow": _convert_xflow(canvas), + "xflow": _convert_xflow(canvas, allowed_nodes=allowed_nodes), "zflow": "auto", }, "ftqc": { - "parityCheckGroup": _convert_detectors(canvas), - "logicalObservableGroup": _convert_logical_observables(canvas), + "parityCheckGroup": _convert_detectors(canvas, allowed_nodes=allowed_nodes), + "logicalObservableGroup": _convert_logical_observables(canvas, allowed_nodes=allowed_nodes), }, - "schedule": _convert_schedule(canvas), + "schedule": _convert_schedule(canvas, allowed_nodes=allowed_nodes), } with Path(output_path).open("w", encoding="utf-8") as f: @@ -371,6 +541,13 @@ def export_canvas_to_graphqomb_studio( def canvas_to_graphqomb_studio_dict( canvas: Canvas, name: str = "Exported from lspattern", + *, + x_min: int | None = None, + x_max: int | None = None, + y_min: int | None = None, + y_max: int | None = None, + z_min: int | None = None, + z_max: int | None = None, ) -> dict[str, Any]: """Convert Canvas to GraphQOMB Studio JSON dictionary. @@ -382,24 +559,36 @@ def canvas_to_graphqomb_studio_dict( The canvas to convert. name : str Project name in the output dictionary. + x_min, x_max, y_min, y_max, z_min, z_max : int | None + Closed-range bounds for node coordinates to export. Unspecified bounds + are treated as unbounded. Returns ------- dict[str, Any] The GraphQOMB Studio JSON dictionary. """ + allowed_nodes = _build_allowed_nodes( + canvas, + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, + ) return { "$schema": "graphqomb-studio/v1", "name": name, - "nodes": _convert_nodes(canvas), - "edges": _convert_edges(canvas), + "nodes": _convert_nodes(canvas, allowed_nodes=allowed_nodes), + "edges": _convert_edges(canvas, allowed_nodes=allowed_nodes), "flow": { - "xflow": _convert_xflow(canvas), + "xflow": _convert_xflow(canvas, allowed_nodes=allowed_nodes), "zflow": "auto", }, "ftqc": { - "parityCheckGroup": _convert_detectors(canvas), - "logicalObservableGroup": _convert_logical_observables(canvas), + "parityCheckGroup": _convert_detectors(canvas, allowed_nodes=allowed_nodes), + "logicalObservableGroup": _convert_logical_observables(canvas, allowed_nodes=allowed_nodes), }, - "schedule": _convert_schedule(canvas), + "schedule": _convert_schedule(canvas, allowed_nodes=allowed_nodes), } diff --git a/lspattern/fragment_builder.py b/lspattern/fragment_builder.py index b2257eb1..152d4048 100644 --- a/lspattern/fragment_builder.py +++ b/lspattern/fragment_builder.py @@ -327,25 +327,25 @@ def _build_layer1( # noqa: C901 if init_flow_directions is not None: move_vec = init_flow_directions.get(InitFlowLayerKey(layer_idx, 1)) if move_vec is None: - msg = f"Missing init flow direction for layer1 (unit={layer_idx})." - raise ValueError(msg) - ancilla_flow = RotatedSurfaceCodeLayoutBuilder.construct_initial_ancilla_flow( - code_distance, - Coord2D(0, 0), - boundary, - edge_spec, - move_vec, - adjacent_data=adjacent_pipe_data, - ) - for src_2d, tgt_2d_set in ancilla_flow.items(): - src_coord = Coord3D(src_2d.x, src_2d.y, z) - for tgt_2d in tgt_2d_set: - tgt_coord = Coord3D(tgt_2d.x, tgt_2d.y, z) - # Only check src_coord in nodes; tgt_coord may be in adjacent pipe - # for cubes with O (open) boundaries - if src_coord in nodes: - _validate_flow_target(tgt_2d, tgt_coord, data2d, adjacent_pipe_data) - flow.add_flow(src_coord, tgt_coord) + pass # No init flow needed (e.g., cube below provides temporal flow) + else: + ancilla_flow = RotatedSurfaceCodeLayoutBuilder.construct_initial_ancilla_flow( + code_distance, + Coord2D(0, 0), + boundary, + edge_spec, + move_vec, + adjacent_data=adjacent_pipe_data, + ) + for src_2d, tgt_2d_set in ancilla_flow.items(): + src_coord = Coord3D(src_2d.x, src_2d.y, z) + for tgt_2d in tgt_2d_set: + tgt_coord = Coord3D(tgt_2d.x, tgt_2d.y, z) + # Only check src_coord in nodes; tgt_coord may be in adjacent pipe + # for cubes with O (open) boundaries + if src_coord in nodes: + _validate_flow_target(tgt_2d, tgt_coord, data2d, adjacent_pipe_data) + flow.add_flow(src_coord, tgt_coord) def _build_layer2( # noqa: C901 @@ -462,25 +462,25 @@ def _build_layer2( # noqa: C901 if init_flow_directions is not None: move_vec = init_flow_directions.get(InitFlowLayerKey(layer_idx, 2)) if move_vec is None: - msg = f"Missing init flow direction for layer2 (unit={layer_idx})." - raise ValueError(msg) - ancilla_flow = RotatedSurfaceCodeLayoutBuilder.construct_initial_ancilla_flow( - code_distance, - Coord2D(0, 0), - boundary, - edge_spec, - move_vec, - adjacent_data=adjacent_pipe_data, - ) - for src_2d, tgt_2d_set in ancilla_flow.items(): - src_coord = Coord3D(src_2d.x, src_2d.y, z + 1) - for tgt_2d in tgt_2d_set: - tgt_coord = Coord3D(tgt_2d.x, tgt_2d.y, z + 1) - # Only check src_coord in nodes; tgt_coord may be in adjacent pipe - # for cubes with O (open) boundaries - if src_coord in nodes: - _validate_flow_target(tgt_2d, tgt_coord, data2d, adjacent_pipe_data) - flow.add_flow(src_coord, tgt_coord) + pass # No init flow needed (e.g., cube below provides temporal flow) + else: + ancilla_flow = RotatedSurfaceCodeLayoutBuilder.construct_initial_ancilla_flow( + code_distance, + Coord2D(0, 0), + boundary, + edge_spec, + move_vec, + adjacent_data=adjacent_pipe_data, + ) + for src_2d, tgt_2d_set in ancilla_flow.items(): + src_coord = Coord3D(src_2d.x, src_2d.y, z + 1) + for tgt_2d in tgt_2d_set: + tgt_coord = Coord3D(tgt_2d.x, tgt_2d.y, z + 1) + # Only check src_coord in nodes; tgt_coord may be in adjacent pipe + # for cubes with O (open) boundaries + if src_coord in nodes: + _validate_flow_target(tgt_2d, tgt_coord, data2d, adjacent_pipe_data) + flow.add_flow(src_coord, tgt_coord) def build_patch_cube_fragment( diff --git a/lspattern/importer/__init__.py b/lspattern/importer/__init__.py index 03efa02c..c605a69b 100644 --- a/lspattern/importer/__init__.py +++ b/lspattern/importer/__init__.py @@ -6,3 +6,12 @@ from lspattern.importer.las import ( convert_lasre_to_yamls as convert_lasre_to_yamls, ) +from lspattern.importer.liblsqecc import ( + LibLsQeccImportError as LibLsQeccImportError, +) +from lspattern.importer.liblsqecc import ( + convert_slices_file_to_canvas_yaml as convert_slices_file_to_canvas_yaml, +) +from lspattern.importer.liblsqecc import ( + convert_slices_to_canvas_yaml as convert_slices_to_canvas_yaml, +) diff --git a/lspattern/importer/liblsqecc.py b/lspattern/importer/liblsqecc.py new file mode 100644 index 00000000..2e84ed68 --- /dev/null +++ b/lspattern/importer/liblsqecc.py @@ -0,0 +1,1205 @@ +"""Convert liblsqecc slices JSON into lspattern canvas YAML.""" + +from __future__ import annotations + +import json +import re +from collections import defaultdict +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import lru_cache +from operator import itemgetter +from pathlib import Path +from typing import Any + +import yaml + +_QUBIT_PATCH_TYPE = "Qubit" +_ANCILLA_PATCH_TYPE = "Ancilla" +_MEASUREMENT_ACTIVITY = "Measurement" +_BOUNDARY_KEYS = ("Top", "Bottom", "Left", "Right") +_QUBIT_ID_RE = re.compile(r"^\s*Id:\s*(-?\d+)\s*$") +_ANCILLA_JOIN = "ancillajoin" +_SOLID_STITCHED_VALUES = {"solidstiched", "solidstitched"} +_DASHED_STITCHED_VALUES = {"dashedstiched", "dashedstitched"} +_SIDE_TO_OFFSET = { + "Top": (0, -1), + "Bottom": (0, 1), + "Left": (-1, 0), + "Right": (1, 0), +} +_OPPOSITE_SIDE = { + "Top": "Bottom", + "Bottom": "Top", + "Left": "Right", + "Right": "Left", +} +_SIDE_INDEX = {side: idx for idx, side in enumerate(_BOUNDARY_KEYS)} +_ROTATION_TEMPLATE_FILES = { + "left": "_patch_rotation2_left.yml", + "right": "_patch_rotation2_right.yml", + "top": "_patch_rotation2_top.yml", + "bottom": "_patch_rotation2_bottom.yml", +} +_ROTATION_INPUT_REL = { + "left": (0, 0), + "right": (1, 0), + "top": (0, 0), + "bottom": (0, 1), +} +_ROTATION_HELPER_REL = { + "left": (1, 0), + "right": (0, 0), + "top": (0, 1), + "bottom": (0, 0), +} + +Coord2D = tuple[int, int] +Coord3D = tuple[int, int, int] + +_DISTILLATION_PATCH_TYPE = "DistillationQubit" + + +class LibLsQeccImportError(RuntimeError): + """Raised when liblsqecc slices cannot be converted into canvas YAML.""" + + +@dataclass(frozen=True) +class _AncillaComponent: + z: int + basis: str # "ZZ" or "XX" + cells: frozenset[Coord2D] + ancilla_links: frozenset[tuple[Coord2D, Coord2D]] + qubit_links: frozenset[tuple[Coord2D, Coord2D]] + + +@dataclass(frozen=True) +class _AncillaComponentExtracted: + z: int + cells: frozenset[Coord2D] + ancilla_links: frozenset[tuple[Coord2D, Coord2D]] + qubit_links: frozenset[tuple[Coord2D, Coord2D]] + endpoint_stitch_kinds: tuple[tuple[Coord2D, frozenset[str]], ...] + + +@dataclass(frozen=True) +class _PipeInternal: + start: Coord3D + end: Coord3D + block: str + basis: str # "ZZ" or "XX" + + +@dataclass(frozen=True) +class _RotationTemplate: + direction: str + input_rel: Coord2D + helper_rel: Coord2D + input_boundary_at_z0: str + cubes: tuple[tuple[Coord3D, str, str], ...] + pipes: tuple[tuple[Coord3D, Coord3D, str, str], ...] + + +def _normalize_edge_value(edge: object, *, context: str) -> str: + if not isinstance(edge, str): + msg = f"{context}: edge value must be a string, got {type(edge)}" + raise LibLsQeccImportError(msg) + return edge.strip().lower() + + +def _edge_to_boundary_char(edge: object, *, context: str) -> str: + normalized = _normalize_edge_value(edge, context=context) + if normalized == "solid": + return "Z" + if normalized == "dashed": + return "X" + if normalized in {"none", *_SOLID_STITCHED_VALUES, *_DASHED_STITCHED_VALUES}: + return "O" + + msg = f"{context}: unsupported edge value {edge!r}" + raise LibLsQeccImportError(msg) + + +def _is_ancilla_join(edge: object) -> bool: + if not isinstance(edge, str): + return False + return edge.strip().lower() == _ANCILLA_JOIN + + +def _stitched_kind(edge: object) -> str | None: + if not isinstance(edge, str): + return None + normalized = edge.strip().lower() + if normalized in _SOLID_STITCHED_VALUES: + return "solid" + if normalized in _DASHED_STITCHED_VALUES: + return "dashed" + return None + + +def _extract_qubit_id(cell: Mapping[str, Any]) -> int | None: + text = cell.get("text") + if not isinstance(text, str): + return None + match = _QUBIT_ID_RE.match(text) + if match is None: + return None + return int(match.group(1)) + + +def _is_qubit_cell(cell: Mapping[str, Any] | None) -> bool: + return cell is not None and cell.get("patch_type") == _QUBIT_PATCH_TYPE + + +def _is_distillation_cell(cell: Mapping[str, Any] | None) -> bool: + return cell is not None and cell.get("patch_type") == _DISTILLATION_PATCH_TYPE + + +def _is_qubit_like_cell( + cell: Mapping[str, Any] | None, + *, + treat_distillation_as_qubit: bool, +) -> bool: + if _is_qubit_cell(cell): + return True + if not treat_distillation_as_qubit: + return False + return _is_distillation_cell(cell) + + +def _is_ancilla_cell(cell: Mapping[str, Any] | None) -> bool: + return cell is not None and cell.get("patch_type") == _ANCILLA_PATCH_TYPE + + +def _is_measurement_cell(cell: Mapping[str, Any]) -> bool: + activity = cell.get("activity") + if not isinstance(activity, Mapping): + msg = "Internal error: validated cell is missing activity mapping." + raise LibLsQeccImportError(msg) + return activity.get("activity_type") == _MEASUREMENT_ACTIVITY + + +def _is_init_cell( + current: Mapping[str, Any], + previous: Mapping[str, Any] | None, + *, + treat_distillation_as_qubit: bool, +) -> bool: + if not _is_qubit_like_cell(previous, treat_distillation_as_qubit=treat_distillation_as_qubit): + return True + + current_id = _extract_qubit_id(current) + previous_id = _extract_qubit_id(previous) + return current_id is not None and previous_id is not None and current_id != previous_id + + +def _cell_context(z: int, y: int, x: int) -> str: + return f"slice={z}, row={y}, col={x}" + + +def _validate_cell(cell: object, *, z: int, y: int, x: int) -> Mapping[str, Any]: + context = _cell_context(z, y, x) + if not isinstance(cell, Mapping): + msg = f"{context}: cell must be mapping or null, got {type(cell)}" + raise LibLsQeccImportError(msg) + + if "patch_type" not in cell: + msg = f"{context}: missing required key 'patch_type'" + raise LibLsQeccImportError(msg) + if not isinstance(cell["patch_type"], str): + msg = f"{context}: 'patch_type' must be a string" + raise LibLsQeccImportError(msg) + + edges = cell.get("edges") + if not isinstance(edges, Mapping): + msg = f"{context}: missing or invalid 'edges' mapping" + raise LibLsQeccImportError(msg) + for key in _BOUNDARY_KEYS: + if key not in edges: + msg = f"{context}: missing edges.{key}" + raise LibLsQeccImportError(msg) + if not isinstance(edges[key], str): + msg = f"{context}: edges.{key} must be a string" + raise LibLsQeccImportError(msg) + + activity = cell.get("activity") + if not isinstance(activity, Mapping): + msg = f"{context}: missing or invalid 'activity' mapping" + raise LibLsQeccImportError(msg) + if "activity_type" not in activity: + msg = f"{context}: missing activity.activity_type" + raise LibLsQeccImportError(msg) + + return cell + + +def _validate_and_normalize_slices(raw_slices: object) -> list[list[list[Mapping[str, Any] | None]]]: # noqa: C901 + if not isinstance(raw_slices, list): + msg = f"Top-level slices must be a list, got {type(raw_slices)}" + raise LibLsQeccImportError(msg) + + normalized: list[list[list[Mapping[str, Any] | None]]] = [] + expected_rows: int | None = None + expected_cols: int | None = None + + for z, slice_obj in enumerate(raw_slices): + if not isinstance(slice_obj, list): + msg = f"slice={z}: each slice must be a list of rows, got {type(slice_obj)}" + raise LibLsQeccImportError(msg) + + if expected_rows is None: + expected_rows = len(slice_obj) + elif len(slice_obj) != expected_rows: + msg = f"slice={z}: inconsistent row count (expected {expected_rows}, got {len(slice_obj)})" + raise LibLsQeccImportError(msg) + + normalized_rows: list[list[Mapping[str, Any] | None]] = [] + for y, row_obj in enumerate(slice_obj): + if not isinstance(row_obj, list): + msg = f"slice={z}, row={y}: row must be a list, got {type(row_obj)}" + raise LibLsQeccImportError(msg) + + if expected_cols is None: + expected_cols = len(row_obj) + elif len(row_obj) != expected_cols: + msg = ( + f"slice={z}, row={y}: inconsistent column count " + f"(expected {expected_cols}, got {len(row_obj)})" + ) + raise LibLsQeccImportError(msg) + + normalized_cells: list[Mapping[str, Any] | None] = [] + for x, cell in enumerate(row_obj): + if cell is None: + normalized_cells.append(None) + continue + normalized_cells.append(_validate_cell(cell, z=z, y=y, x=x)) + normalized_rows.append(normalized_cells) + normalized.append(normalized_rows) + + return normalized + + +def _cell_boundary_string(cell: Mapping[str, Any], *, z: int, y: int, x: int) -> str: + context = _cell_context(z, y, x) + edges = cell.get("edges") + if not isinstance(edges, Mapping): + msg = f"{context}: missing or invalid 'edges' mapping" + raise LibLsQeccImportError(msg) + top = _edge_to_boundary_char(edges["Top"], context=f"{context} edges.Top") + bottom = _edge_to_boundary_char(edges["Bottom"], context=f"{context} edges.Bottom") + left = _edge_to_boundary_char(edges["Left"], context=f"{context} edges.Left") + right = _edge_to_boundary_char(edges["Right"], context=f"{context} edges.Right") + return f"{top}{bottom}{left}{right}" + + +def _cube_sort_key(entry: Mapping[str, object]) -> tuple[int, int, int]: + position = entry.get("position") + if not isinstance(position, list) or len(position) != 3: # noqa: PLR2004 + msg = f"Internal error: invalid cube position entry {position!r}" + raise LibLsQeccImportError(msg) + + x, y, z = position + if not isinstance(x, int) or not isinstance(y, int) or not isinstance(z, int): + msg = f"Internal error: cube position must be integer triplet, got {position!r}" + raise LibLsQeccImportError(msg) + return z, y, x + + +def _pipe_sort_key(entry: Mapping[str, object]) -> tuple[int, int, int, int, int, int]: + start = entry.get("start") + end = entry.get("end") + if not isinstance(start, list) or len(start) != 3 or not isinstance(end, list) or len(end) != 3: # noqa: PLR2004 + msg = f"Internal error: invalid pipe entry: {entry!r}" + raise LibLsQeccImportError(msg) + if not all(isinstance(v, int) for v in [*start, *end]): + msg = f"Internal error: pipe coordinates must be integers: {entry!r}" + raise LibLsQeccImportError(msg) + return start[2], start[1], start[0], end[2], end[1], end[0] + + +def _coord_side_to_neighbor(coord: Coord2D, side: str) -> Coord2D: + dx, dy = _SIDE_TO_OFFSET[side] + return coord[0] + dx, coord[1] + dy + + +def _in_bounds(x: int, y: int, width: int, height: int) -> bool: + return 0 <= x < width and 0 <= y < height + + +def _basis_from_stitch_kinds(kinds: set[str], *, context: str) -> str: + if kinds == {"solid"}: + return "ZZ" + if kinds == {"dashed"}: + return "XX" + msg = f"{context}: unsupported or mixed stitched basis kinds: {sorted(kinds)}" + raise LibLsQeccImportError(msg) + + +def _extract_ancilla_components( # noqa: C901 + slice_rows: list[list[Mapping[str, Any] | None]], + *, + z: int, + treat_distillation_as_qubit: bool, +) -> list[_AncillaComponentExtracted]: + height = len(slice_rows) + width = len(slice_rows[0]) if height > 0 else 0 + + ancilla_cells: dict[Coord2D, Mapping[str, Any]] = {} + for y, row in enumerate(slice_rows): + for x, cell in enumerate(row): + if _is_ancilla_cell(cell): + if cell is None: + msg = f"Internal error: ancilla cell unexpectedly None at slice={z}, row={y}, col={x}" + raise LibLsQeccImportError(msg) + ancilla_cells[x, y] = cell + + adjacency: dict[Coord2D, set[Coord2D]] = {coord: set() for coord in ancilla_cells} + for coord, cell in ancilla_cells.items(): + edges = cell["edges"] + if not isinstance(edges, Mapping): + msg = f"slice={z}: invalid ancilla edges at coord={coord}" + raise LibLsQeccImportError(msg) + + for side in _BOUNDARY_KEYS: + if not _is_ancilla_join(edges[side]): + continue + neighbor = _coord_side_to_neighbor(coord, side) + neighbor_cell = ancilla_cells.get(neighbor) + if neighbor_cell is None: + continue + + neighbor_edges = neighbor_cell["edges"] + if not isinstance(neighbor_edges, Mapping): + msg = f"slice={z}: invalid ancilla edges at neighbor coord={neighbor}" + raise LibLsQeccImportError(msg) + if _is_ancilla_join(neighbor_edges[_OPPOSITE_SIDE[side]]): + adjacency[coord].add(neighbor) + + components: list[_AncillaComponentExtracted] = [] + visited: set[Coord2D] = set() + + for start in sorted(ancilla_cells): + if start in visited: + continue + + stack = [start] + cells: set[Coord2D] = set() + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + cells.add(current) + stack.extend(neighbor for neighbor in adjacency[current] if neighbor not in visited) + + ancilla_links: set[tuple[Coord2D, Coord2D]] = set() + for coord in cells: + for neighbor in adjacency[coord]: + if neighbor not in cells: + continue + if coord < neighbor: + ancilla_links.add((coord, neighbor)) + + endpoint_stitch_kinds: dict[Coord2D, set[str]] = defaultdict(set) + qubit_links: set[tuple[Coord2D, Coord2D]] = set() + + for coord in cells: + x, y = coord + for side in _BOUNDARY_KEYS: + nx, ny = _coord_side_to_neighbor(coord, side) + if not _in_bounds(nx, ny, width, height): + continue + neighbor_cell = slice_rows[ny][nx] + if not _is_qubit_like_cell(neighbor_cell, treat_distillation_as_qubit=treat_distillation_as_qubit): + continue + if neighbor_cell is None: + msg = f"Internal error: qubit cell unexpectedly None at slice={z}, row={ny}, col={nx}" + raise LibLsQeccImportError(msg) + + neighbor_edges = neighbor_cell.get("edges") + if not isinstance(neighbor_edges, Mapping): + msg = f"slice={z}, row={ny}, col={nx}: invalid qubit edges" + raise LibLsQeccImportError(msg) + + stitched_kind = _stitched_kind(neighbor_edges[_OPPOSITE_SIDE[side]]) + if stitched_kind is None: + continue + + qubit_coord = (nx, ny) + endpoint_stitch_kinds[qubit_coord].add(stitched_kind) + qubit_links.add((coord, qubit_coord)) + + frozen_endpoint_kinds = tuple( + sorted((coord, frozenset(kinds)) for coord, kinds in endpoint_stitch_kinds.items()), + ) + components.append( + _AncillaComponentExtracted( + z=z, + cells=frozenset(cells), + ancilla_links=frozenset(ancilla_links), + qubit_links=frozenset(qubit_links), + endpoint_stitch_kinds=frozen_endpoint_kinds, + ), + ) + + return components + + +def _extract_valid_ancilla_components( + slice_rows: list[list[Mapping[str, Any] | None]], + *, + z: int, + treat_distillation_as_qubit: bool, +) -> list[_AncillaComponent]: + extracted = _extract_ancilla_components( + slice_rows, + z=z, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + components: list[_AncillaComponent] = [] + for component in extracted: + endpoint_stitch_kinds = dict(component.endpoint_stitch_kinds) + + # Ignore non-measurement ancilla regions (e.g., distillation-side routing artifacts). + if len(endpoint_stitch_kinds) != 2: # noqa: PLR2004 + continue + + stitched_kinds: set[str] = set() + for kinds in endpoint_stitch_kinds.values(): + stitched_kinds.update(kinds) + + basis = _basis_from_stitch_kinds( + stitched_kinds, + context=f"slice={z}, ancilla_component_start={next(iter(component.cells), None)}", + ) + components.append( + _AncillaComponent( + z=component.z, + basis=basis, + cells=component.cells, + ancilla_links=component.ancilla_links, + qubit_links=component.qubit_links, + ), + ) + return components + + +def _swap_xz_boundary(boundary: str) -> str: + trans = {"X": "Z", "Z": "X"} + return "".join(trans.get(ch, ch) for ch in boundary) + + +def _side_from_to(a: Coord2D, b: Coord2D) -> str | None: + dx = b[0] - a[0] + dy = b[1] - a[1] + if dx == 1 and dy == 0: + return "Right" + if dx == -1 and dy == 0: + return "Left" + if dx == 0 and dy == 1: + return "Bottom" + if dx == 0 and dy == -1: + return "Top" + return None + + +def _rotation_direction_from_input(input_coord: Coord2D, helper_coord: Coord2D) -> str | None: + if helper_coord[0] == input_coord[0] - 1 and helper_coord[1] == input_coord[1]: + return "right" + if helper_coord[0] == input_coord[0] + 1 and helper_coord[1] == input_coord[1]: + return "left" + if helper_coord[0] == input_coord[0] and helper_coord[1] == input_coord[1] - 1: + return "bottom" + if helper_coord[0] == input_coord[0] and helper_coord[1] == input_coord[1] + 1: + return "top" + return None + + +def _can_match_rotation_boundary( + source_boundary: str, + template_boundary: str, + *, + join_side: str, + swap_xz: bool, +) -> bool: + join_idx = _SIDE_INDEX[join_side] + candidate = _swap_xz_boundary(template_boundary) if swap_xz else template_boundary + for idx, (source_ch, cand_ch) in enumerate(zip(source_boundary, candidate, strict=True)): + if idx == join_idx: + continue + if source_ch in {"X", "Z"} and cand_ch in {"X", "Z"} and source_ch != cand_ch: + return False + return True + + +@lru_cache(maxsize=1) +def _load_rotation_templates() -> dict[str, _RotationTemplate]: # noqa: C901 + base_dir = Path(__file__).resolve().parents[2] / "examples" / "design" / "utils" + templates: dict[str, _RotationTemplate] = {} + + for direction, filename in _ROTATION_TEMPLATE_FILES.items(): + path = base_dir / filename + if not path.exists(): + return {} + + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(loaded, Mapping): + msg = f"Rotation template must be a mapping: {path}" + raise LibLsQeccImportError(msg) + + cube_raw = loaded.get("cube") + pipe_raw = loaded.get("pipe") + if not isinstance(cube_raw, list) or not isinstance(pipe_raw, list): + msg = f"Rotation template must define list-valued cube/pipe sections: {path}" + raise LibLsQeccImportError(msg) + + cubes: list[tuple[Coord3D, str, str]] = [] + pipes: list[tuple[Coord3D, Coord3D, str, str]] = [] + for cube in cube_raw: + if not isinstance(cube, Mapping): + msg = f"Rotation template cube entry must be mapping: {path}" + raise LibLsQeccImportError(msg) + pos = cube.get("position") + block = cube.get("block") + boundary = cube.get("boundary") + if ( + not isinstance(pos, list) + or len(pos) != 3 # noqa: PLR2004 + or not all(isinstance(v, int) for v in pos) + or not isinstance(block, str) + or not isinstance(boundary, str) + or len(boundary) != 4 # noqa: PLR2004 + ): + msg = f"Invalid cube entry in rotation template: {path}" + raise LibLsQeccImportError(msg) + cubes.append(((pos[0], pos[1], pos[2]), block, boundary)) + + for pipe in pipe_raw: + if not isinstance(pipe, Mapping): + msg = f"Rotation template pipe entry must be mapping: {path}" + raise LibLsQeccImportError(msg) + start = pipe.get("start") + end = pipe.get("end") + block = pipe.get("block") + boundary = pipe.get("boundary") + if ( + not isinstance(start, list) + or len(start) != 3 # noqa: PLR2004 + or not all(isinstance(v, int) for v in start) + or not isinstance(end, list) + or len(end) != 3 # noqa: PLR2004 + or not all(isinstance(v, int) for v in end) + or not isinstance(block, str) + or not isinstance(boundary, str) + or len(boundary) != 4 # noqa: PLR2004 + ): + msg = f"Invalid pipe entry in rotation template: {path}" + raise LibLsQeccImportError(msg) + pipes.append(((start[0], start[1], start[2]), (end[0], end[1], end[2]), block, boundary)) + + input_rel = _ROTATION_INPUT_REL[direction] + helper_rel = _ROTATION_HELPER_REL[direction] + input_boundary = None + for rel, _block, boundary in cubes: + if rel[2] == 0 and (rel[0], rel[1]) == input_rel: + input_boundary = boundary + break + + if input_boundary is None: + msg = f"Rotation template missing input boundary cube at z=0: {path}" + raise LibLsQeccImportError(msg) + + templates[direction] = _RotationTemplate( + direction=direction, + input_rel=input_rel, + helper_rel=helper_rel, + input_boundary_at_z0=input_boundary, + cubes=tuple(cubes), + pipes=tuple(pipes), + ) + + return templates + + +def _detect_rotation_overrides( # noqa: C901 + normalized: list[list[list[Mapping[str, Any] | None]]], + *, + treat_distillation_as_qubit: bool, +) -> tuple[dict[Coord3D, dict[str, object]], dict[tuple[Coord3D, Coord3D], dict[str, object]]]: + templates = _load_rotation_templates() + if not templates: + return {}, {} + + components_per_slice: list[list[_AncillaComponentExtracted]] = [] + for z, slice_rows in enumerate(normalized): + components_per_slice.append( + _extract_ancilla_components( + slice_rows, + z=z, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + ) + + candidate_zs_by_cells: dict[frozenset[Coord2D], list[int]] = defaultdict(list) + for z, components in enumerate(components_per_slice): + for component in components: + if len(component.endpoint_stitch_kinds) != 0: + continue + if len(component.cells) != 2: # noqa: PLR2004 + continue + if len(component.ancilla_links) != 1: + continue + candidate_zs_by_cells[component.cells].append(z) + + cube_overrides: dict[Coord3D, dict[str, object]] = {} + pipe_overrides: dict[tuple[Coord3D, Coord3D], dict[str, object]] = {} + + for cells, zs in candidate_zs_by_cells.items(): + zs_sorted = sorted(zs) + if not zs_sorted: + continue + + run_start = zs_sorted[0] + run_end = zs_sorted[0] + + def process_run(start: int, end: int, cells_in_run: frozenset[Coord2D] = cells) -> None: # noqa: C901 + run_len = end - start + 1 + if run_len != 3: # noqa: PLR2004 + return + if start <= 0: + return + z_after = end + 1 + if z_after >= len(normalized): + return + + coords = sorted(cells_in_run) + input_candidates: list[tuple[Coord2D, str]] = [] + for coord in coords: + x, y = coord + prev_cell = normalized[start - 1][y][x] + after_cell = normalized[z_after][y][x] + prev_is_qubit_like = _is_qubit_like_cell( + prev_cell, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + after_is_qubit_like = _is_qubit_like_cell( + after_cell, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + if not (prev_is_qubit_like and after_is_qubit_like): + continue + if prev_cell is None or after_cell is None: + continue + + prev_id = _extract_qubit_id(prev_cell) + after_id = _extract_qubit_id(after_cell) + same_id = prev_id is not None and after_id is not None and prev_id == after_id + if not same_id: + continue + source_boundary = _cell_boundary_string(prev_cell, z=start - 1, y=y, x=x) + input_candidates.append((coord, source_boundary)) + + if len(input_candidates) != 1: + return + + input_coord, source_boundary = input_candidates[0] + helper_coord = coords[0] if coords[1] == input_coord else coords[1] + direction = _rotation_direction_from_input(input_coord, helper_coord) + if direction is None: + return + + template = templates.get(direction) + if template is None: + return + + join_side = _side_from_to(input_coord, helper_coord) + if join_side is None: + return + + swap_xz: bool | None = None + for swap_candidate in (False, True): + if _can_match_rotation_boundary( + source_boundary, + template.input_boundary_at_z0, + join_side=join_side, + swap_xz=swap_candidate, + ): + swap_xz = swap_candidate + break + + if swap_xz is None: + return + + anchor_x = input_coord[0] - template.input_rel[0] + anchor_y = input_coord[1] - template.input_rel[1] + anchor_z = start + + for rel_coord, block, boundary in template.cubes: + x = anchor_x + rel_coord[0] + y = anchor_y + rel_coord[1] + z = anchor_z + rel_coord[2] + if z < 0 or z >= len(normalized): + return + if y < 0 or y >= len(normalized[z]): + return + if x < 0 or x >= len(normalized[z][y]): + return + adjusted_boundary = _swap_xz_boundary(boundary) if swap_xz else boundary + cube_overrides[x, y, z] = { + "position": [x, y, z], + "block": block, + "boundary": adjusted_boundary, + } + + for rel_start, rel_end, block, boundary in template.pipes: + start_abs = (anchor_x + rel_start[0], anchor_y + rel_start[1], anchor_z + rel_start[2]) + end_abs = (anchor_x + rel_end[0], anchor_y + rel_end[1], anchor_z + rel_end[2]) + key = _pipe_key(start_abs, end_abs) + adjusted_boundary = _swap_xz_boundary(boundary) if swap_xz else boundary + pipe_overrides[key] = { + "start": [start_abs[0], start_abs[1], start_abs[2]], + "end": [end_abs[0], end_abs[1], end_abs[2]], + "block": block, + "boundary": adjusted_boundary, + } + + for z in zs_sorted[1:]: + if z == run_end + 1: + run_end = z + continue + process_run(run_start, run_end) + run_start = z + run_end = z + process_run(run_start, run_end) + + return cube_overrides, pipe_overrides + + +def _short_block_for_basis(basis: str) -> str: + if basis == "ZZ": + return "ShortXMemoryBlock" + if basis == "XX": + return "ShortZMemoryBlock" + msg = f"Internal error: unsupported basis for short block: {basis}" + raise LibLsQeccImportError(msg) + + +def _init_block_for_basis(basis: str) -> str: + if basis == "ZZ": + return "InitPlusBlock" + if basis == "XX": + return "InitZeroBlock" + msg = f"Internal error: unsupported basis for init block: {basis}" + raise LibLsQeccImportError(msg) + + +def _measure_block_for_basis(basis: str) -> str: + if basis == "ZZ": + return "MeasureXBlock" + if basis == "XX": + return "MeasureZBlock" + msg = f"Internal error: unsupported basis for measure block: {basis}" + raise LibLsQeccImportError(msg) + + +def _fill_char_for_basis(basis: str) -> str: + if basis == "ZZ": + return "X" + if basis == "XX": + return "Z" + msg = f"Internal error: unsupported basis for fill boundary: {basis}" + raise LibLsQeccImportError(msg) + + +def _build_ancilla_blocks( # noqa: C901 + components: Sequence[_AncillaComponent], +) -> tuple[dict[Coord3D, str], dict[Coord3D, str]]: + basis_by_coord3: dict[Coord3D, str] = {} + timeline_by_coord2: dict[Coord2D, list[tuple[int, str]]] = defaultdict(list) + + for component in components: + for x, y in component.cells: + coord3 = (x, y, component.z) + existing_basis = basis_by_coord3.get(coord3) + if existing_basis is not None and existing_basis != component.basis: + msg = ( + "Conflicting ancilla basis at the same coordinate/time: " + f"coord={coord3}, {existing_basis} vs {component.basis}" + ) + raise LibLsQeccImportError(msg) + basis_by_coord3[coord3] = component.basis + timeline_by_coord2[x, y].append((component.z, component.basis)) + + block_by_coord3: dict[Coord3D, str] = {} + + for coord2, timeline in timeline_by_coord2.items(): + timeline_sorted = sorted(timeline, key=itemgetter(0)) + segment_start = 0 + for idx in range(1, len(timeline_sorted) + 1): + end_segment = idx == len(timeline_sorted) + if not end_segment: + prev_z, prev_basis = timeline_sorted[idx - 1] + cur_z, cur_basis = timeline_sorted[idx] + if cur_z == prev_z + 1 and cur_basis == prev_basis: + continue + + segment = timeline_sorted[segment_start:idx] + basis = segment[0][1] + zs = [z for z, _basis in segment] + if len(segment) == 1: + block = _short_block_for_basis(basis) + block_by_coord3[coord2[0], coord2[1], zs[0]] = block + else: + z_first = zs[0] + z_last = zs[-1] + for z in zs: + if z == z_first: + block = _init_block_for_basis(basis) + elif z == z_last: + block = _measure_block_for_basis(basis) + else: + block = "MemoryBlock" + block_by_coord3[coord2[0], coord2[1], z] = block + + segment_start = idx + + return block_by_coord3, basis_by_coord3 + + +def _direction_side(from_coord: Coord3D, to_coord: Coord3D) -> str: + fx, fy, fz = from_coord + tx, ty, tz = to_coord + if fz != tz: + msg = f"Only in-slice pipes are supported, got z mismatch: {from_coord} -> {to_coord}" + raise LibLsQeccImportError(msg) + + dx = tx - fx + dy = ty - fy + if abs(dx) + abs(dy) != 1: + msg = f"Pipe endpoints must be 4-neighbors: {from_coord} -> {to_coord}" + raise LibLsQeccImportError(msg) + + if dx == 1: + return "Right" + if dx == -1: + return "Left" + if dy == 1: + return "Bottom" + return "Top" + + +def _pipe_key(a: Coord3D, b: Coord3D) -> tuple[Coord3D, Coord3D]: + return (a, b) if a <= b else (b, a) + + +def _add_pipe( + pipes_by_key: dict[tuple[Coord3D, Coord3D], _PipeInternal], + *, + a: Coord3D, + b: Coord3D, + block: str, + basis: str, +) -> None: + if a == b: + msg = f"Internal error: zero-length pipe at {a}" + raise LibLsQeccImportError(msg) + + key = _pipe_key(a, b) + existing = pipes_by_key.get(key) + if existing is not None: + if existing.basis != basis: + msg = f"Conflicting pipe basis for edge {key}: {existing.basis} vs {basis}" + raise LibLsQeccImportError(msg) + if existing.block != block: + msg = f"Conflicting pipe block for edge {key}: {existing.block} vs {block}" + raise LibLsQeccImportError(msg) + return + + pipes_by_key[key] = _PipeInternal(start=key[0], end=key[1], block=block, basis=basis) + + +def _boundary_chars_to_string(chars: Mapping[str, str]) -> str: + return "".join(chars[side] for side in _BOUNDARY_KEYS) + + +def _parse_boundary_string(boundary: object, *, context: str) -> dict[str, str]: + if not isinstance(boundary, str) or len(boundary) != 4: # noqa: PLR2004 + msg = f"{context}: boundary must be 4-char string, got {boundary!r}" + raise LibLsQeccImportError(msg) + return {side: boundary[idx] for idx, side in enumerate(_BOUNDARY_KEYS)} + + +def convert_slices_to_canvas_yaml( # noqa: C901 + slices: Sequence[object], + *, + name: str, + description: str | None = None, + treat_distillation_as_qubit: bool = True, +) -> str: + """Convert liblsqecc slices JSON content to lspattern canvas YAML text.""" + if not name.strip(): + msg = "Canvas name must not be empty." + raise LibLsQeccImportError(msg) + + normalized = _validate_and_normalize_slices(list(slices)) + rotation_cube_overrides, rotation_pipe_overrides = _detect_rotation_overrides( + normalized, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + + cube_entries_by_coord: dict[Coord3D, dict[str, object]] = {} + qubit_coords: set[Coord3D] = set() + + previous_slice: list[list[Mapping[str, Any] | None]] | None = None + for z, slice_rows in enumerate(normalized): + for y, row in enumerate(slice_rows): + for x, cell in enumerate(row): + if not _is_qubit_like_cell(cell, treat_distillation_as_qubit=treat_distillation_as_qubit): + continue + if cell is None: + continue + + previous_cell: Mapping[str, Any] | None = None + if previous_slice is not None: + previous_cell = previous_slice[y][x] + + is_init = _is_init_cell( + cell, + previous_cell, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + is_measure = _is_measurement_cell(cell) + + if is_init: + block = "InitZeroBlock" + elif is_measure: + block = "MeasureZBlock" + else: + block = "MemoryBlock" + + coord3 = (x, y, z) + cube_entries_by_coord[coord3] = { + "position": [x, y, z], + "block": block, + "boundary": _cell_boundary_string(cell, z=z, y=y, x=x), + } + qubit_coords.add(coord3) + previous_slice = slice_rows + + ancilla_components: list[_AncillaComponent] = [] + for z, slice_rows in enumerate(normalized): + ancilla_components.extend( + _extract_valid_ancilla_components( + slice_rows, + z=z, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + ) + + ancilla_block_by_coord, ancilla_basis_by_coord = _build_ancilla_blocks(ancilla_components) + + ancilla_coords: set[Coord3D] = set() + for coord3, block in ancilla_block_by_coord.items(): + if coord3 in cube_entries_by_coord: + msg = f"Conflicting cube assignment at {coord3}: qubit and ancilla overlap" + raise LibLsQeccImportError(msg) + x, y, z = coord3 + cube_entries_by_coord[coord3] = { + "position": [x, y, z], + "block": block, + "boundary": "", # Filled after pipe analysis. + } + ancilla_coords.add(coord3) + + pipes_by_key: dict[tuple[Coord3D, Coord3D], _PipeInternal] = {} + + for component in ancilla_components: + z = component.z + + for anc_a_2d, anc_b_2d in component.ancilla_links: + anc_a_3d = (anc_a_2d[0], anc_a_2d[1], z) + anc_b_3d = (anc_b_2d[0], anc_b_2d[1], z) + block_a = ancilla_block_by_coord.get(anc_a_3d) + block_b = ancilla_block_by_coord.get(anc_b_3d) + if block_a is None or block_b is None: + msg = f"Missing ancilla cube for internal pipe at z={z}: {anc_a_2d} - {anc_b_2d}" + raise LibLsQeccImportError(msg) + + # Adjacent ancilla cells in the same bus may have different + # block kinds when their timelines differ (e.g., one cell + # lives for a single clock while a neighbor spans multiple). + # Use the block of the lexicographically smaller coordinate + # for deterministic pipe assignment. + pipe_block = block_a if anc_a_3d <= anc_b_3d else block_b + + _add_pipe( + pipes_by_key, + a=anc_a_3d, + b=anc_b_3d, + block=pipe_block, + basis=component.basis, + ) + + for anc_2d, qubit_2d in component.qubit_links: + anc_3d = (anc_2d[0], anc_2d[1], z) + qubit_3d = (qubit_2d[0], qubit_2d[1], z) + if qubit_3d not in qubit_coords: + msg = f"Missing qubit cube at {qubit_3d} for ancilla connection" + raise LibLsQeccImportError(msg) + block = ancilla_block_by_coord.get(anc_3d) + if block is None: + msg = f"Missing ancilla cube at {anc_3d} for qubit connection" + raise LibLsQeccImportError(msg) + + _add_pipe( + pipes_by_key, + a=anc_3d, + b=qubit_3d, + block=block, + basis=component.basis, + ) + + # Detect direct qubit-to-qubit stitched connections (lattice surgery + # merges without an intermediate ancilla bus) and create pipes for + # them so that O boundaries are properly backed by pipe entries. + for z, slice_rows in enumerate(normalized): + height = len(slice_rows) + width = len(slice_rows[0]) if height > 0 else 0 + for y, row in enumerate(slice_rows): + for x, cell in enumerate(row): + if not _is_qubit_like_cell(cell, treat_distillation_as_qubit=treat_distillation_as_qubit): + continue + if cell is None: + continue + edges = cell["edges"] + for side in ("Bottom", "Right"): + kind = _stitched_kind(edges[side]) + if kind is None: + continue + nx, ny = _coord_side_to_neighbor((x, y), side) + if not _in_bounds(nx, ny, width, height): + continue + neighbor = slice_rows[ny][nx] + if not _is_qubit_like_cell(neighbor, treat_distillation_as_qubit=treat_distillation_as_qubit): + continue + if neighbor is None: + continue + opp_kind = _stitched_kind(neighbor["edges"][_OPPOSITE_SIDE[side]]) + if opp_kind is None or opp_kind != kind: + continue + basis = "ZZ" if kind == "solid" else "XX" + block = _short_block_for_basis(basis) + a_3d = (x, y, z) + b_3d = (nx, ny, z) + _add_pipe(pipes_by_key, a=a_3d, b=b_3d, block=block, basis=basis) + + connected_sides: dict[Coord3D, set[str]] = defaultdict(set) + pipe_entries: list[dict[str, object]] = [] + + pipe_iteration = sorted( + pipes_by_key.values(), + key=lambda item: (item.start[2], item.start[1], item.start[0], item.end[2], item.end[1], item.end[0]), + ) + for pipe in pipe_iteration: + side_from_start = _direction_side(pipe.start, pipe.end) + side_from_end = _OPPOSITE_SIDE[side_from_start] + + connected_sides[pipe.start].add(side_from_start) + connected_sides[pipe.end].add(side_from_end) + + fill = _fill_char_for_basis(pipe.basis) + boundary_chars = dict.fromkeys(_BOUNDARY_KEYS, fill) + boundary_chars[side_from_start] = "O" + boundary_chars[side_from_end] = "O" + + pipe_entries.append( + { + "start": [pipe.start[0], pipe.start[1], pipe.start[2]], + "end": [pipe.end[0], pipe.end[1], pipe.end[2]], + "block": pipe.block, + "boundary": _boundary_chars_to_string(boundary_chars), + } + ) + + for coord3, entry in cube_entries_by_coord.items(): + boundary_chars: dict[str, str] + + if coord3 in ancilla_coords: + basis = ancilla_basis_by_coord.get(coord3) + if basis is None: + msg = f"Internal error: missing ancilla basis for {coord3}" + raise LibLsQeccImportError(msg) + fill = _fill_char_for_basis(basis) + boundary_chars = dict.fromkeys(_BOUNDARY_KEYS, fill) + else: + boundary_chars = _parse_boundary_string(entry["boundary"], context=f"cube@{coord3}") + + for side in connected_sides.get(coord3, set()): + boundary_chars[side] = "O" + + entry["boundary"] = _boundary_chars_to_string(boundary_chars) + + cube_entries_by_coord.update(rotation_cube_overrides) + + if rotation_pipe_overrides: + pipe_entries_by_key = { + _pipe_key( + tuple(entry["start"]), # type: ignore[arg-type] + tuple(entry["end"]), # type: ignore[arg-type] + ): entry + for entry in pipe_entries + } + pipe_entries_by_key.update(rotation_pipe_overrides) + pipe_entries = list(pipe_entries_by_key.values()) + + cube_entries = list(cube_entries_by_coord.values()) + cube_entries.sort(key=_cube_sort_key) + pipe_entries.sort(key=_pipe_sort_key) + + canvas_dict = { + "name": name, + "description": description or "Imported from liblsqecc slices JSON", + "layout": "rotated_surface_code", + "cube": cube_entries, + "pipe": pipe_entries, + } + return yaml.safe_dump(canvas_dict, sort_keys=False, width=1000, default_flow_style=False) + + +def convert_slices_file_to_canvas_yaml( + input_json: Path | str, + output_yml: Path | str | None = None, + *, + name: str | None = None, + description: str | None = None, + treat_distillation_as_qubit: bool = True, +) -> str: + """Convert a liblsqecc slices JSON file into lspattern canvas YAML text.""" + input_path = Path(input_json) + try: + raw = json.loads(input_path.read_text(encoding="utf-8")) + except OSError as exc: + msg = f"Failed to read input JSON file {input_path}: {exc}" + raise LibLsQeccImportError(msg) from exc + except json.JSONDecodeError as exc: + msg = f"Invalid JSON in {input_path}: {exc}" + raise LibLsQeccImportError(msg) from exc + + canvas_name = name or input_path.stem + yaml_text = convert_slices_to_canvas_yaml( + raw, + name=canvas_name, + description=description, + treat_distillation_as_qubit=treat_distillation_as_qubit, + ) + + if output_yml is not None: + output_path = Path(output_yml) + try: + output_path.write_text(yaml_text, encoding="utf-8") + except OSError as exc: + msg = f"Failed to write output YAML file {output_path}: {exc}" + raise LibLsQeccImportError(msg) from exc + + return yaml_text diff --git a/lspattern/init_flow_analysis.py b/lspattern/init_flow_analysis.py index f9ddd33f..f5c85b5a 100644 --- a/lspattern/init_flow_analysis.py +++ b/lspattern/init_flow_analysis.py @@ -9,7 +9,7 @@ from lspattern.mytype import Coord2D, Coord3D if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Callable, Mapping, Sequence from pathlib import Path from lspattern.canvas_loader import CanvasPipeSpec, CanvasSpec @@ -95,6 +95,78 @@ def _adjacent_pairs(positions: set[Coord3D]) -> list[tuple[Coord3D, Coord3D, Bou return pairs +def _physical_z(slot_z: int, code_distance: int, unit_layer_idx: int, sublayer: int) -> int: + if sublayer == _SUBLAYER_1: + sublayer_offset = 0 + elif sublayer == _SUBLAYER_2: + sublayer_offset = 1 + else: + msg = f"Invalid init sublayer: {sublayer}." + raise ValueError(msg) + return slot_z * 2 * code_distance + unit_layer_idx * 2 + sublayer_offset + + +def _slice_basis_at_physical_z( + block_config: BlockConfig, + *, + slot_z: int, + code_distance: int, + physical_z: int, +) -> object | None: + if getattr(block_config, "graph_spec", None) is not None: + return None + + block_base = slot_z * 2 * code_distance + local_z = physical_z - block_base + if local_z < 0: + return None + + unit_layer_idx = local_z // 2 + if unit_layer_idx < 0 or unit_layer_idx >= len(block_config): + return None + + layer_cfg = block_config[unit_layer_idx] + if local_z % 2 == 0: + return layer_cfg.layer1.basis + return layer_cfg.layer2.basis + + +def _get_or_load_block_config( + block_name: str, + cache: dict[str, BlockConfig], + *, + load_block_config: Callable[[str], BlockConfig], +) -> BlockConfig: + cfg = cache.get(block_name) + if cfg is None: + cfg = load_block_config(block_name) + cache[block_name] = cfg + return cfg + + +def _pipe_slice_has_non_null_basis( + pipe: CanvasPipeSpec, + *, + slot_z: int, + code_distance: int, + physical_z: int, + pipe_block_cache: dict[str, BlockConfig], + load_block_config: Callable[[str], BlockConfig], +) -> bool: + block_config = _get_or_load_block_config( + pipe.block, + pipe_block_cache, + load_block_config=load_block_config, + ) + basis = _slice_basis_at_physical_z( + block_config, + slot_z=slot_z, + code_distance=code_distance, + physical_z=physical_z, + ) + return basis is not None + + def _violates_pair(dir_a: BoundarySide, dir_b: BoundarySide, dir_a_to_b: BoundarySide) -> bool: if dir_a_to_b == BoundarySide.RIGHT: return dir_a == BoundarySide.RIGHT and dir_b == BoundarySide.LEFT @@ -161,13 +233,58 @@ def _register_init_layer_candidates( invert_ancilla_order: bool, layer_idx: int, sublayer: int, + pipes: Sequence[CanvasPipeSpec], + code_distance: int, + pipe_block_cache: dict[str, BlockConfig], + load_block_config: Callable[[str], BlockConfig], ) -> None: key = InitFlowLayerKey(layer_idx, sublayer) ancilla_type = _ancilla_type_for_init_layer(sublayer, invert_ancilla_order) candidates = _candidate_sides(cube_boundary, ancilla_type) + physical_z = _physical_z(cube_position.z, code_distance, layer_idx, sublayer) + check_z = physical_z - 1 + + # Pipe-only exclusion rule: + # For O-boundary sides with adjacent pipes, exclude if physical_z-1 + # corresponds to a non-null basis slice in either same-slot pipe + # or z-1-slot pipe for the same side. + to_exclude = set() + for side in candidates: + if cube_boundary[side] != EdgeSpecValue.O: + continue + + same_slot_pipe = _find_adjacent_pipe(cube_position, side, pipes) + if same_slot_pipe is None: + continue + if _pipe_slice_has_non_null_basis( + same_slot_pipe, + slot_z=cube_position.z, + code_distance=code_distance, + physical_z=check_z, + pipe_block_cache=pipe_block_cache, + load_block_config=load_block_config, + ): + to_exclude.add(side) + continue + + below_cube = Coord3D(cube_position.x, cube_position.y, cube_position.z - 1) + below_slot_pipe = _find_adjacent_pipe(below_cube, side, pipes) + if below_slot_pipe is None: + continue + if _pipe_slice_has_non_null_basis( + below_slot_pipe, + slot_z=below_cube.z, + code_distance=code_distance, + physical_z=check_z, + pipe_block_cache=pipe_block_cache, + load_block_config=load_block_config, + ): + to_exclude.add(side) + + candidates -= to_exclude + if not candidates: - msg = f"No candidate directions for cube {cube_position} layer{sublayer} init." - raise ValueError(msg) + return # No valid directions; temporal flow from below handles this cube group_candidates.setdefault(key, {})[cube_position] = candidates @@ -298,16 +415,23 @@ def analyze_init_flow_directions( directions = InitFlowDirections() block_cache: dict[str, BlockConfig] = {} + pipe_block_cache: dict[str, BlockConfig] = {} merged_paths: tuple[Path | str, ...] = (*spec.search_paths, *extra_paths) + def _load_block(block_name: str) -> BlockConfig: + return load_block_config_from_name( + block_name, + code_distance=code_distance, + extra_paths=merged_paths, + ) + group_candidates: dict[InitFlowLayerKey, dict[Coord3D, set[BoundarySide]]] = {} for cube in spec.cubes: - block_config = block_cache.get(cube.block) - if block_config is None: - block_config = load_block_config_from_name( - cube.block, code_distance=code_distance, extra_paths=merged_paths - ) - block_cache[cube.block] = block_config + block_config = _get_or_load_block_config( + cube.block, + block_cache, + load_block_config=_load_block, + ) if getattr(block_config, "graph_spec", None) is not None: continue @@ -315,11 +439,29 @@ def analyze_init_flow_directions( for layer_idx, layer_cfg in enumerate(block_config): if layer_cfg.layer1.init: _register_init_layer_candidates( - group_candidates, cube.position, cube.boundary, cube.invert_ancilla_order, layer_idx, _SUBLAYER_1 + group_candidates, + cube.position, + cube.boundary, + cube.invert_ancilla_order, + layer_idx, + _SUBLAYER_1, + spec.pipes, + code_distance, + pipe_block_cache, + _load_block, ) if layer_cfg.layer2.init: _register_init_layer_candidates( - group_candidates, cube.position, cube.boundary, cube.invert_ancilla_order, layer_idx, _SUBLAYER_2 + group_candidates, + cube.position, + cube.boundary, + cube.invert_ancilla_order, + layer_idx, + _SUBLAYER_2, + spec.pipes, + code_distance, + pipe_block_cache, + _load_block, ) for key, candidates in group_candidates.items(): diff --git a/lspattern/patch_layout/blocks/measure_x_bulk_block.yml b/lspattern/patch_layout/blocks/measure_x_bulk_block.yml new file mode 100644 index 00000000..1f534b70 --- /dev/null +++ b/lspattern/patch_layout/blocks/measure_x_bulk_block.yml @@ -0,0 +1,9 @@ +name: MeasureXBulkBlock +description: | + This block represents a measure X bulk block. The terminate timing is synchronized with short x memory block. + +layers: # list of layers in this block + - type: MemoryUnit + num_layers_from_distance: rest + - type: MeasureXUnit + num_layers: 1 diff --git a/lspattern/patch_layout/blocks/measure_z_bulk_block.yml b/lspattern/patch_layout/blocks/measure_z_bulk_block.yml new file mode 100644 index 00000000..4fac7543 --- /dev/null +++ b/lspattern/patch_layout/blocks/measure_z_bulk_block.yml @@ -0,0 +1,9 @@ +name: MeasureZBulkBlock +description: | + This block represents a measure Z bulk block. The terminate timing is synchronized with short z memory block. + +layers: # list of layers in this block + - type: MemoryUnit + num_layers_from_distance: rest + - type: MeasureZShiftedUnit + num_layers: 1 diff --git a/lspattern/video_3d.py b/lspattern/video_3d.py new file mode 100644 index 00000000..cb2d78b9 --- /dev/null +++ b/lspattern/video_3d.py @@ -0,0 +1,255 @@ +"""3D z-sweep MP4 export utilities for compiled canvases.""" + +from __future__ import annotations + +import importlib +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, cast + +import numpy as np +import plotly.graph_objects as go +import plotly.io as pio + +from lspattern.visualizer import CanvasLike, render_canvas_z_window_plotly_figure + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +class _FrameWriter(Protocol): + """Minimal writer protocol used by imageio ffmpeg backend.""" + + def append_data(self, frame: NDArray[np.uint8]) -> None: + """Append one RGB frame.""" + + def close(self) -> None: + """Close the writer and flush output.""" + + +class _ImageIOModule(Protocol): + """Protocol for the subset of imageio.v2 APIs used by this module.""" + + def get_writer(self, uri: str, **kwargs: object) -> _FrameWriter: + """Create a writer for the target URI.""" + + def imread(self, uri: BytesIO, **kwargs: object) -> NDArray[np.generic]: + """Read an image from an in-memory stream.""" + + +def _get_imageio_v2() -> _ImageIOModule: + """Import imageio.v2 lazily with a clear dependency error.""" + + try: + imageio = importlib.import_module("imageio.v2") + except ImportError as exc: # pragma: no cover - environment-dependent + msg = "imageio and imageio-ffmpeg are required for MP4 export. Install both packages first." + raise RuntimeError(msg) from exc + + return cast("_ImageIOModule", imageio) + + +def _validate_positive_int(value: int, *, name: str) -> int: + """Validate that an integer argument is positive.""" + + numeric = int(value) + if numeric <= 0: + msg = f"{name} must be positive." + raise ValueError(msg) + return numeric + + +def _validate_positive_float(value: float, *, name: str) -> float: + """Validate that a numeric argument is positive.""" + + numeric = float(value) + if numeric <= 0: + msg = f"{name} must be positive." + raise ValueError(msg) + return numeric + + +def _validate_alpha(value: float, *, name: str) -> float: + """Validate opacity-like values in [0, 1].""" + + numeric = float(value) + if not 0.0 <= numeric <= 1.0: + msg = f"{name} must be between 0.0 and 1.0." + raise ValueError(msg) + return numeric + + +def _figure_to_rgb_array( + fig: go.Figure, + *, + width: int, + height: int, +) -> NDArray[np.uint8]: + """Convert a Plotly figure into an RGB uint8 frame.""" + rgb_channels = 3 + + try: + image_bytes = pio.to_image(fig, format="png", width=width, height=height, scale=1) + except ValueError as exc: # pragma: no cover - environment-dependent + msg = "Plotly static export requires 'kaleido'. Install kaleido to enable MP4 export." + raise RuntimeError(msg) from exc + + imageio = _get_imageio_v2() + frame = np.asarray(imageio.imread(BytesIO(image_bytes), format="png")) + + if frame.ndim != rgb_channels or frame.shape[2] < rgb_channels: + msg = "Unexpected image shape returned from Plotly frame rendering." + raise RuntimeError(msg) + + rgb = frame[:, :, :rgb_channels] + if rgb.dtype == np.uint8: + return cast("NDArray[np.uint8]", rgb) + + rgb_float = np.asarray(rgb, dtype=np.float64) + if np.issubdtype(rgb.dtype, np.floating): + scaled = np.clip(rgb_float * 255.0, 0.0, 255.0) + else: + scaled = np.clip(rgb_float, 0.0, 255.0) + return cast("NDArray[np.uint8]", scaled.astype(np.uint8)) + + +def _add_progress_bar_overlay( + fig: go.Figure, + *, + progress: float, +) -> None: + """Draw a visual progress bar in figure paper coordinates.""" + + clamped = min(max(float(progress), 0.0), 1.0) + x_start = 0.05 + x_end = 0.95 + y_bottom = 0.02 + y_top = 0.045 + bar_width = x_end - x_start + + fig.add_shape( + type="rect", + xref="paper", + yref="paper", + x0=x_start, + x1=x_end, + y0=y_bottom, + y1=y_top, + line={"width": 0}, + fillcolor="rgba(0, 0, 0, 0.20)", + ) + fig.add_shape( + type="rect", + xref="paper", + yref="paper", + x0=x_start, + x1=x_start + (bar_width * clamped), + y0=y_bottom, + y1=y_top, + line={"width": 0}, + fillcolor="rgba(0, 120, 255, 0.95)", + ) + + +def export_canvas_z_sweep_3d_mp4( + canvas: CanvasLike, + output_path: str | Path, + *, + fps: int = 24, + z_window: int = 6, + node_size_scale: float = 1.0, + edge_width_scale: float = 1.0, + tail_alpha: float = 0.25, + non_current_alpha: float | None = None, + current_alpha: float = 1.0, + highlight_size_scale: float = 1.0, + highlight_current_layer: bool = False, + width: int = 1280, + height: int = 720, + reverse_axes: bool = True, + aspect_ratio: tuple[float, float, float] | None = None, + lock_view: bool = True, + axis_padding: float = 1.0, + camera_eye: tuple[float, float, float] | None = (1.8, 1.8, 0.9), + projection_type: str = "orthographic", + codec: str = "libx264", + crf: int = 20, + preset: str = "medium", + show_progress_bar: bool = False, +) -> Path: + """Export a 3D z-sweep MP4 movie from z=0 up to max-z. + + Frames are rendered with a sliding z-window ``[z_current-z_window+1, z_current]`` + and the current layer emphasized with larger highlighted markers. + """ + + output = Path(output_path) + if output.suffix.lower() != ".mp4": + msg = "output_path must use .mp4 extension." + raise ValueError(msg) + + fps_value = _validate_positive_int(fps, name="fps") + z_window_value = _validate_positive_int(z_window, name="z_window") + width_value = _validate_positive_int(width, name="width") + height_value = _validate_positive_int(height, name="height") + node_size_scale_value = _validate_positive_float(node_size_scale, name="node_size_scale") + edge_width_scale_value = _validate_positive_float(edge_width_scale, name="edge_width_scale") + highlight_size_scale_value = _validate_positive_float(highlight_size_scale, name="highlight_size_scale") + tail_alpha_value = _validate_alpha(tail_alpha, name="tail_alpha") + if non_current_alpha is None: + non_current_alpha_value = tail_alpha_value + else: + non_current_alpha_value = _validate_alpha(non_current_alpha, name="non_current_alpha") + current_alpha_value = _validate_alpha(current_alpha, name="current_alpha") + + if crf < 0: + msg = "crf must be non-negative." + raise ValueError(msg) + + z_values = sorted({coord.z for coord in canvas.nodes}) + if not z_values: + msg = "Cannot export video from an empty canvas." + raise ValueError(msg) + + output.parent.mkdir(parents=True, exist_ok=True) + + imageio = _get_imageio_v2() + ffmpeg_params = ["-crf", str(int(crf)), "-preset", preset] + writer = imageio.get_writer( + str(output), + fps=fps_value, + codec=codec, + ffmpeg_params=ffmpeg_params, + ) + + total_frames = len(z_values) + try: + for frame_index, current_z in enumerate(z_values, start=1): + fig = render_canvas_z_window_plotly_figure( + canvas, + current_z=current_z, + z_window=z_window_value, + node_size_scale=node_size_scale_value, + edge_width_scale=edge_width_scale_value, + tail_alpha=tail_alpha_value, + non_current_alpha=non_current_alpha_value, + current_alpha=current_alpha_value, + highlight_size_scale=highlight_size_scale_value, + highlight_current_layer=highlight_current_layer, + width=width_value, + height=height_value, + reverse_axes=reverse_axes, + aspect_ratio=aspect_ratio, + lock_view=lock_view, + axis_padding=axis_padding, + camera_eye=camera_eye, + projection_type=projection_type, + ) + if show_progress_bar: + _add_progress_bar_overlay(fig, progress=frame_index / total_frames) + frame = _figure_to_rgb_array(fig, width=width_value, height=height_value) + writer.append_data(frame) + finally: + writer.close() + + return output diff --git a/lspattern/visualizer.py b/lspattern/visualizer.py index 63798747..cf99d9fa 100644 --- a/lspattern/visualizer.py +++ b/lspattern/visualizer.py @@ -1,7 +1,8 @@ from __future__ import annotations import operator -from typing import TYPE_CHECKING, TypedDict +from types import SimpleNamespace +from typing import TYPE_CHECKING, Protocol, TypedDict, cast import plotly.graph_objects as go @@ -12,11 +13,18 @@ from graphqomb.common import Axis - from lspattern.canvas import Canvas - type NodeIndex = int | Coord3D +class CanvasLike(Protocol): + """Structural protocol for canvas-like objects used by visualizers.""" + + nodes: set[Coord3D] + edges: set[tuple[Coord3D, Coord3D]] + coord2role: dict[Coord3D, NodeRole] + pauli_axes: dict[Coord3D, Axis | None] + + class NodeStyleSpec(TypedDict): """Style specification for node visualization. @@ -169,19 +177,170 @@ def _format_node_labels( return labels +def _build_plotly_scene( + *, + reverse_axes: bool, + aspect_ratio: tuple[float, float, float] | None, +) -> dict[str, object]: + """Build 3D scene configuration with optional manual axis scaling.""" + + manual_aspect = _normalize_manual_aspect_ratio(aspect_ratio) + + scene: dict[str, object] = { + "xaxis_title": "X", + "yaxis_title": "Y", + "zaxis_title": "Z", + } + + if manual_aspect is None: + scene["aspectmode"] = "data" + else: + scene["aspectmode"] = "manual" + scene["aspectratio"] = manual_aspect + + if reverse_axes: + scene["xaxis"] = {"autorange": "reversed"} + scene["yaxis"] = {"autorange": "reversed"} + + return scene + + +def _normalize_manual_aspect_ratio( + aspect_ratio: tuple[float, float, float] | None, +) -> dict[str, float] | None: + """Normalize optional aspect ratio tuple into Plotly-compatible mapping.""" + + if aspect_ratio is None: + return None + + try: + x_scale, y_scale, z_scale = aspect_ratio + x = float(x_scale) + y = float(y_scale) + z = float(z_scale) + except (TypeError, ValueError) as exc: + msg = "aspect_ratio must be a tuple of three positive numbers." + raise ValueError(msg) from exc + + if x <= 0 or y <= 0 or z <= 0: + msg = "aspect_ratio values must be positive." + raise ValueError(msg) + + return {"x": x, "y": y, "z": z} + + +def _validate_positive_float(value: float, *, name: str) -> float: + """Validate that a value is a strictly positive float.""" + + numeric = float(value) + if numeric <= 0: + msg = f"{name} must be positive." + raise ValueError(msg) + return numeric + + +def _validate_alpha(value: float, *, name: str) -> float: + """Validate opacity-like values in [0, 1].""" + + numeric = float(value) + if not 0.0 <= numeric <= 1.0: + msg = f"{name} must be between 0.0 and 1.0." + raise ValueError(msg) + return numeric + + +def _validate_non_negative_float(value: float, *, name: str) -> float: + """Validate that a numeric value is non-negative.""" + + numeric = float(value) + if numeric < 0: + msg = f"{name} must be non-negative." + raise ValueError(msg) + return numeric + + +def _validate_projection_type(value: str) -> str: + """Validate camera projection type used by Plotly.""" + + projection = str(value).strip().lower() + if projection not in {"perspective", "orthographic"}: + msg = "projection_type must be either 'perspective' or 'orthographic'." + raise ValueError(msg) + return projection + + +def _compute_axis_range( + values: list[float], + *, + reverse: bool, + padding: float, +) -> tuple[float, float]: + """Compute a fixed axis range with optional padding and reversal.""" + + low = min(values) - padding + high = max(values) + padding + if low == high: + low -= 0.5 + high += 0.5 + return (high, low) if reverse else (low, high) + + +def _compute_window_z_axis_range( + *, + current_z: int, + z_window: int, + padding: float, +) -> tuple[float, float]: + """Compute a z-axis range centered on the current sliding z-window.""" + + low = float(int(current_z) - int(z_window) + 1) - padding + high = float(int(current_z)) + padding + if low == high: + low -= 0.5 + high += 0.5 + return (low, high) + + +def _compute_scene_axis_ranges( + nodes: set[Coord3D], + *, + reverse_axes: bool, + padding: float, +) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]: + """Compute deterministic x/y/z axis ranges from full-canvas nodes.""" + + if not nodes: + msg = "Cannot compute scene ranges from an empty canvas." + raise ValueError(msg) + + x_values = [float(node.x) for node in nodes] + y_values = [float(node.y) for node in nodes] + z_values = [float(node.z) for node in nodes] + return ( + _compute_axis_range(x_values, reverse=reverse_axes, padding=padding), + _compute_axis_range(y_values, reverse=reverse_axes, padding=padding), + _compute_axis_range(z_values, reverse=False, padding=padding), + ) + + def visualize_canvas_plotly( - canvas: Canvas, + canvas: CanvasLike, *, show_edges: bool = True, edge_width: float = 3.0, + edge_width_scale: float = 1.0, edge_color: str = "rgba(60, 60, 60, 0.7)", + node_size_scale: float = 1.0, + node_alpha: float = 0.9, highlight_nodes: Iterable[Coord3D] | None = None, highlight_color: str = "red", highlight_line_color: str = "darkred", - highlight_size: int = 11, + highlight_size: float = 11.0, + highlight_alpha: float = 0.98, width: int = 900, height: int = 700, reverse_axes: bool = True, + aspect_ratio: tuple[float, float, float] | None = None, ) -> go.Figure: """Create an interactive 3D visualization of a Canvas using Plotly. @@ -197,8 +356,16 @@ def visualize_canvas_plotly( Whether to display edges between nodes, by default True. edge_width : float, optional Width of edge lines in pixels, by default 3.0. + edge_width_scale : float, optional + Global multiplier for `edge_width`. Must be positive. Default 1.0. edge_color : str, optional RGBA color string for edges, by default "rgba(60, 60, 60, 0.7)". + node_size_scale : float, optional + Global marker-size multiplier for all node traces. Must be positive. + Default 1.0. + node_alpha : float, optional + Marker opacity for non-highlighted nodes. Must be in [0.0, 1.0]. + Default 0.9. highlight_nodes : Iterable[Coord3D] | None, optional Optional iterable of node coordinates to emphasize. Highlighted nodes are drawn in red on top of the regular markers. Default None. @@ -206,8 +373,11 @@ def visualize_canvas_plotly( Fill color for highlighted nodes. Default "red". highlight_line_color : str, optional Outline color for highlighted nodes. Default "darkred". - highlight_size : int, optional + highlight_size : float, optional Marker size for highlighted nodes. Default 11. + highlight_alpha : float, optional + Marker opacity for highlighted nodes. Must be in [0.0, 1.0]. + Default 0.98. width : int, optional Figure width in pixels, by default 900. height : int, optional @@ -215,6 +385,10 @@ def visualize_canvas_plotly( reverse_axes : bool, optional Reverse X and Y axes to match quantum circuit layout convention, by default True. + aspect_ratio : tuple[float, float, float] | None, optional + Manual display scaling ratio for X/Y/Z axes. For example, + ``(1.0, 1.0, 0.25)`` visually compresses Z. If None, Plotly + auto-scales with ``aspectmode="data"`` (default). Returns ------- @@ -226,6 +400,10 @@ def visualize_canvas_plotly( >>> fig = visualize_canvas_plotly(canvas) >>> fig.show() """ + node_size_scale_value = _validate_positive_float(node_size_scale, name="node_size_scale") + edge_width_scale_value = _validate_positive_float(edge_width_scale, name="edge_width_scale") + node_alpha_value = _validate_alpha(node_alpha, name="node_alpha") + highlight_alpha_value = _validate_alpha(highlight_alpha, name="highlight_alpha") nodes = canvas.nodes coord2role = canvas.coord2role @@ -247,10 +425,10 @@ def visualize_canvas_plotly( z=pts["z"], mode="markers", marker={ - "size": spec["size"], + "size": spec["size"] * node_size_scale_value, "color": spec["color"], "line": {"color": spec["line_color"], "width": 1.5}, - "opacity": 0.9, + "opacity": node_alpha_value, }, name=spec["label"], text=[_node_hover_label(c, spec["label"], pauli_axes.get(c)) for c in coords], @@ -269,10 +447,10 @@ def visualize_canvas_plotly( z=[c.z for c in highlight_coords], mode="markers", marker={ - "size": highlight_size, + "size": float(highlight_size) * node_size_scale_value, "color": highlight_color, "line": {"color": highlight_line_color, "width": 2}, - "opacity": 0.98, + "opacity": highlight_alpha_value, "symbol": "diamond", }, name="Highlighted", @@ -292,22 +470,14 @@ def visualize_canvas_plotly( y=edge_y, z=edge_z, mode="lines", - line={"color": edge_color, "width": edge_width}, + line={"color": edge_color, "width": edge_width * edge_width_scale_value}, name="Edges", showlegend=False, hoverinfo="none", ) ) - scene: dict[str, object] = { - "xaxis_title": "X", - "yaxis_title": "Y", - "zaxis_title": "Z", - "aspectmode": "data", - } - if reverse_axes: - scene["xaxis"] = {"autorange": "reversed"} - scene["yaxis"] = {"autorange": "reversed"} + scene = _build_plotly_scene(reverse_axes=reverse_axes, aspect_ratio=aspect_ratio) fig.update_layout( scene=scene, @@ -321,10 +491,198 @@ def visualize_canvas_plotly( return fig -def visualize_detectors_plotly( # noqa: C901 +def render_canvas_z_window_plotly_figure( + canvas: CanvasLike, + *, + current_z: int, + z_window: int = 6, + node_size_scale: float = 1.0, + edge_width_scale: float = 1.0, + tail_alpha: float = 0.25, + non_current_alpha: float | None = None, + current_alpha: float = 1.0, + highlight_size_scale: float = 1.0, + highlight_current_layer: bool = False, + edge_color: str = "rgba(60, 60, 60, 0.7)", + width: int = 900, + height: int = 700, + reverse_axes: bool = True, + aspect_ratio: tuple[float, float, float] | None = None, + lock_view: bool = True, + axis_padding: float = 1.0, + camera_eye: tuple[float, float, float] | None = (1.8, 1.8, 0.9), + projection_type: str = "orthographic", +) -> go.Figure: + """Render one z-sweep frame with a sliding z-window. + + Parameters + ---------- + canvas : Canvas + Canvas to render. + current_z : int + Current z-layer to highlight. + z_window : int, optional + Number of recent z-layers to keep visible, by default 6. + node_size_scale : float, optional + Global node marker size multiplier, by default 1.0. + edge_width_scale : float, optional + Global edge width multiplier, by default 1.0. + tail_alpha : float, optional + Opacity used for non-highlighted nodes in the visible z-window, + by default 0.25. + non_current_alpha : float | None, optional + Explicit opacity for nodes outside ``current_z``. When provided, this + value overrides ``tail_alpha``. Must be in [0.0, 1.0]. + current_alpha : float, optional + Opacity used for highlighted current-layer nodes when + ``highlight_current_layer`` is True, by default 1.0. + highlight_size_scale : float, optional + Multiplier applied to the regular node size baseline when + ``highlight_current_layer`` is True, by default 1.0. + highlight_current_layer : bool, optional + Whether to highlight the current z-layer. Default False. + edge_color : str, optional + Edge color string, by default "rgba(60, 60, 60, 0.7)". + width : int, optional + Figure width in pixels, by default 900. + height : int, optional + Figure height in pixels, by default 700. + reverse_axes : bool, optional + Whether to reverse X/Y axes, by default True. + aspect_ratio : tuple[float, float, float] | None, optional + Manual display scaling ratio for X/Y/Z axes. + lock_view : bool, optional + Keep camera view and axis ranges fixed across frames. X/Y use full-canvas + ranges, while Z uses the current sliding ``z_window`` range, by default True. + axis_padding : float, optional + Extra margin added to fixed axis ranges when ``lock_view`` is True. + Must be non-negative. Default 1.0. + camera_eye : tuple[float, float, float] | None, optional + Fixed Plotly camera eye when ``lock_view`` is True. + Set to None to keep Plotly default camera. + projection_type : str, optional + Plotly camera projection type. Use ``"orthographic"`` to suppress + perspective size changes over depth. Default ``"orthographic"``. + + Returns + ------- + go.Figure + Plotly figure for a single z-window frame. + """ + if z_window < 1: + msg = "z_window must be at least 1." + raise ValueError(msg) + + tail_alpha_value = _validate_alpha(tail_alpha, name="tail_alpha") + if non_current_alpha is None: + non_current_alpha_value = tail_alpha_value + else: + non_current_alpha_value = _validate_alpha(non_current_alpha, name="non_current_alpha") + axis_padding_value = _validate_non_negative_float(axis_padding, name="axis_padding") + projection_type_value = _validate_projection_type(projection_type) + + z_min = int(current_z) - z_window + 1 + visible_nodes = {node for node in canvas.nodes if z_min <= node.z <= current_z} + visible_edges = { + (start, end) + for start, end in canvas.edges + if start in visible_nodes and end in visible_nodes + } + + window_canvas = cast( + "CanvasLike", + SimpleNamespace( + nodes=visible_nodes, + edges=visible_edges, + coord2role=canvas.coord2role, + pauli_axes=canvas.pauli_axes, + ), + ) + + if highlight_current_layer: + highlight_size_scale_value = _validate_positive_float(highlight_size_scale, name="highlight_size_scale") + current_layer_nodes = {node for node in visible_nodes if node.z == current_z} + base_highlight_size = max(spec["size"] for spec in _COLOR_MAP.values()) + highlight_size = max(float(base_highlight_size) * highlight_size_scale_value, 1.0) + fig = visualize_canvas_plotly( + window_canvas, + show_edges=True, + edge_width_scale=edge_width_scale, + edge_color=edge_color, + node_size_scale=node_size_scale, + node_alpha=non_current_alpha_value, + highlight_nodes=current_layer_nodes, + highlight_size=highlight_size, + highlight_alpha=current_alpha, + width=width, + height=height, + reverse_axes=reverse_axes, + aspect_ratio=aspect_ratio, + ) + else: + fig = visualize_canvas_plotly( + window_canvas, + show_edges=True, + edge_width_scale=edge_width_scale, + edge_color=edge_color, + node_size_scale=node_size_scale, + node_alpha=non_current_alpha_value, + width=width, + height=height, + reverse_axes=reverse_axes, + aspect_ratio=aspect_ratio, + ) + + if lock_view: + x_range, y_range, _ = _compute_scene_axis_ranges( + canvas.nodes, + reverse_axes=reverse_axes, + padding=axis_padding_value, + ) + z_range = _compute_window_z_axis_range( + current_z=current_z, + z_window=z_window, + padding=axis_padding_value, + ) + x_span = abs(x_range[1] - x_range[0]) + y_span = abs(y_range[1] - y_range[0]) + z_span = abs(z_range[1] - z_range[0]) + manual_aspect = _normalize_manual_aspect_ratio(aspect_ratio) + if manual_aspect is None: + max_span = max(x_span, y_span, z_span) + aspect_ratio_fixed = {"x": x_span / max_span, "y": y_span / max_span, "z": z_span / max_span} + else: + aspect_ratio_fixed = manual_aspect + scene_update: dict[str, object] = { + "xaxis": {"range": [x_range[0], x_range[1]], "autorange": False}, + "yaxis": {"range": [y_range[0], y_range[1]], "autorange": False}, + "zaxis": {"range": [z_range[0], z_range[1]], "autorange": False}, + "aspectmode": "manual", + "aspectratio": aspect_ratio_fixed, + } + camera_update: dict[str, object] = { + "projection": {"type": projection_type_value}, + "center": {"x": 0.0, "y": 0.0, "z": 0.0}, + "up": {"x": 0.0, "y": 0.0, "z": 1.0}, + } + if camera_eye is not None: + try: + eye_x, eye_y, eye_z = camera_eye + except (TypeError, ValueError) as exc: + msg = "camera_eye must be a tuple of three numbers or None." + raise ValueError(msg) from exc + camera_update["eye"] = {"x": float(eye_x), "y": float(eye_y), "z": float(eye_z)} + scene_update["camera"] = camera_update + fig.update_layout(scene=scene_update) + + fig.update_layout(title=f"Canvas Z-Sweep (z={current_z})") + return fig + + +def visualize_detectors_plotly( detectors: Mapping[Coord3D, Iterable[NodeIndex]], *, - canvas: Canvas | None = None, + canvas: CanvasLike | None = None, show_canvas_nodes: bool = True, show_canvas_edges: bool = True, show_node_indices_on_hover: bool = True, @@ -341,6 +699,7 @@ def visualize_detectors_plotly( # noqa: C901 width: int = 900, height: int = 700, reverse_axes: bool = True, + aspect_ratio: tuple[float, float, float] | None = None, ) -> go.Figure: """Visualize detectors in 3D with Plotly and optionally show involved node indices on hover. @@ -386,6 +745,10 @@ def visualize_detectors_plotly( # noqa: C901 Figure height in pixels. Default 700. reverse_axes : bool, optional Reverse X/Y axes to mimic circuit-style layout. Default True. + aspect_ratio : tuple[float, float, float] | None, optional + Manual display scaling ratio for X/Y/Z axes. For example, + ``(1.0, 1.0, 0.25)`` visually compresses Z. If None, Plotly + auto-scales with ``aspectmode="data"`` (default). Returns ------- @@ -500,15 +863,7 @@ def visualize_detectors_plotly( # noqa: C901 ) ) - scene: dict[str, object] = { - "xaxis_title": "X", - "yaxis_title": "Y", - "zaxis_title": "Z", - "aspectmode": "data", - } - if reverse_axes: - scene["xaxis"] = {"autorange": "reversed"} - scene["yaxis"] = {"autorange": "reversed"} + scene = _build_plotly_scene(reverse_axes=reverse_axes, aspect_ratio=aspect_ratio) fig.update_layout( scene=scene, diff --git a/requirements.txt b/requirements.txt index fee65f3d..3646fb8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,9 @@ ortools>=9,<10 typing_extensions>=4.0 nbformat>=4.20 plotly>=6 +kaleido +imageio +imageio-ffmpeg graphqomb>=0.1.1 pydantic>=2.0.0 pyyaml>=6 diff --git a/tests/test_export.py b/tests/test_export.py index e97b585d..5d611c6f 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -10,6 +10,7 @@ from graphqomb.common import Axis from lspattern.canvas import Canvas, CanvasConfig +from lspattern.canvas_loader import CompositeLogicalObservableSpec, CubeObservableRef, PipeObservableRef from lspattern.export import ( _axis_to_string, _convert_detectors, @@ -23,7 +24,7 @@ canvas_to_graphqomb_studio_dict, export_canvas_to_graphqomb_studio, ) -from lspattern.mytype import Coord3D +from lspattern.mytype import Coord2D, Coord3D @pytest.fixture @@ -67,6 +68,10 @@ def simple_canvas() -> Canvas: # Add couts (logical observables) canvas.couts[Coord3D(0, 0, 0)] = {"obs_X": {coords[0], coords[1]}, "obs_Z": {coords[2], coords[3]}} + canvas.logical_observables = ( + CompositeLogicalObservableSpec(cubes=(CubeObservableRef(position=Coord3D(0, 0, 0), label="obs_X"),), pipes=()), + CompositeLogicalObservableSpec(cubes=(CubeObservableRef(position=Coord3D(0, 0, 0), label="obs_Z"),), pipes=()), + ) return canvas @@ -272,10 +277,31 @@ def test_empty_canvas(self, empty_canvas: Canvas) -> None: def test_simple_canvas_observables(self, simple_canvas: Canvas) -> None: """Test logical observable conversion for simple canvas.""" observables = _convert_logical_observables(simple_canvas) - assert "obs_X" in observables - assert "obs_Z" in observables - assert sorted(observables["obs_X"]) == ["n_0_0_0", "n_1_0_0"] - assert sorted(observables["obs_Z"]) == ["n_0_1_0", "n_1_1_0"] + assert "obs_0" in observables + assert "obs_1" in observables + assert sorted(observables["obs_0"]) == ["n_0_0_0", "n_1_0_0"] + assert sorted(observables["obs_1"]) == ["n_0_1_0", "n_1_1_0"] + + def test_uses_only_canvas_level_logical_observables(self, simple_canvas: Canvas) -> None: + """Per-cube couts are not exported unless referenced by canvas.logical_observables.""" + simple_canvas.logical_observables = () + observables = _convert_logical_observables(simple_canvas) + assert observables == {} + + def test_pipe_reference_observable(self, simple_canvas: Canvas) -> None: + """Pipe-referenced composite observables are exported.""" + start = Coord3D(0, 0, 0) + end = Coord3D(1, 0, 0) + simple_canvas.pipe_couts[start, end] = {"obs_pipe": {Coord3D(0, 0, 0), Coord3D(0, 1, 0)}} + simple_canvas.logical_observables = ( + CompositeLogicalObservableSpec( + cubes=(), + pipes=(PipeObservableRef(start=start, end=end, label="obs_pipe"),), + ), + ) + + observables = _convert_logical_observables(simple_canvas) + assert observables["obs_0"] == ["n_0_0_0", "n_0_1_0"] class TestExportCanvasToGraphqombStudio: @@ -347,3 +373,85 @@ def test_serializable_to_json(self, simple_canvas: Canvas) -> None: # Should be parseable parsed = json.loads(json_str) assert parsed == result + + +class TestRangeFilteredExport: + """Tests for coordinate-range filtered export behavior.""" + + def test_range_filters_nodes_edges_flow_schedule_observables(self, simple_canvas: Canvas) -> None: + """Filter keeps only in-range node-linked information.""" + # Add an out-of-range source flow to verify source pruning. + simple_canvas.flow.add_flow(Coord3D(1, 0, 0), Coord3D(0, 0, 0)) + + result = canvas_to_graphqomb_studio_dict(simple_canvas, x_min=0, x_max=0) + + node_ids = [node["id"] for node in result["nodes"]] + assert node_ids == ["n_0_0_0", "n_0_1_0"] + + edges = result["edges"] + assert len(edges) == 1 + assert edges[0]["id"] == "n_0_0_0-n_0_1_0" + + assert result["flow"]["xflow"] == {"n_0_0_0": ["n_0_1_0"]} + + observables = result["ftqc"]["logicalObservableGroup"] + assert observables["obs_0"] == ["n_0_0_0"] + assert observables["obs_1"] == ["n_0_1_0"] + + schedule = result["schedule"] + assert set(schedule["prepareTime"]) == {"n_0_0_0", "n_0_1_0"} + assert set(schedule["measureTime"]) == {"n_0_0_0", "n_0_1_0"} + assert schedule["prepareTime"]["n_0_0_0"] == 0 + assert schedule["prepareTime"]["n_0_1_0"] == 0 + assert schedule["measureTime"]["n_0_0_0"] == 2 + assert schedule["measureTime"]["n_0_1_0"] == 2 + assert schedule["entangleTime"] == {} + + timeline = schedule["timeline"] + assert [entry["time"] for entry in timeline] == [0, 2] + time0 = next(entry for entry in timeline if entry["time"] == 0) + assert time0["prepareNodes"] == ["n_0_0_0", "n_0_1_0"] + assert time0["entangleEdges"] == [] + assert time0["measureNodes"] == [] + time2 = next(entry for entry in timeline if entry["time"] == 2) + assert time2["prepareNodes"] == [] + assert time2["entangleEdges"] == [] + assert time2["measureNodes"] == ["n_0_0_0", "n_0_1_0"] + + def test_range_filters_detectors_and_drops_empty_groups(self, simple_canvas: Canvas) -> None: + """Detector groups are filtered by node range and empty groups are removed.""" + coord_in = Coord3D(0, 0, 0) + coord_out = Coord3D(1, 0, 0) + parity = simple_canvas.parity_accumulator + parity.add_syndrome_measurement(Coord2D(0, 0), 0, [coord_in, coord_out]) + parity.add_syndrome_measurement(Coord2D(1, 0), 0, [coord_out]) + + result = canvas_to_graphqomb_studio_dict(simple_canvas, x_min=0, x_max=0) + assert result["ftqc"]["parityCheckGroup"] == [["n_0_0_0"]] + + def test_invalid_range_raises(self, simple_canvas: Canvas) -> None: + """Invalid min/max ordering raises ValueError.""" + with pytest.raises(ValueError, match="x_min"): + canvas_to_graphqomb_studio_dict(simple_canvas, x_min=2, x_max=1) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f: + output_path = Path(f.name) + try: + with pytest.raises(ValueError, match="y_min"): + export_canvas_to_graphqomb_studio(simple_canvas, output_path, y_min=3, y_max=2) + finally: + output_path.unlink(missing_ok=True) + + def test_no_range_keeps_backward_compatibility(self, simple_canvas: Canvas) -> None: + """No range bounds preserves original output shape and content.""" + baseline = canvas_to_graphqomb_studio_dict(simple_canvas) + explicit_none = canvas_to_graphqomb_studio_dict( + simple_canvas, + x_min=None, + x_max=None, + y_min=None, + y_max=None, + z_min=None, + z_max=None, + ) + assert explicit_none == baseline diff --git a/tests/test_importer_liblsqecc.py b/tests/test_importer_liblsqecc.py new file mode 100644 index 00000000..fc9f0933 --- /dev/null +++ b/tests/test_importer_liblsqecc.py @@ -0,0 +1,391 @@ +"""Tests for liblsqecc slices importer.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + +import pytest +import yaml + +from lspattern.importer.liblsqecc import ( + LibLsQeccImportError, + convert_slices_file_to_canvas_yaml, + convert_slices_to_canvas_yaml, +) + + +def _qubit_cell( + *, + qid: int, + top: str = "Solid", + bottom: str = "Solid", + left: str = "Dashed", + right: str = "Dashed", + measurement: bool = False, +) -> dict[str, Any]: + return { + "patch_type": "Qubit", + "edges": { + "Top": top, + "Bottom": bottom, + "Left": left, + "Right": right, + }, + "activity": {"activity_type": "Measurement" if measurement else None}, + "text": f"Id: {qid}", + } + + +def _ancilla_cell( + *, + top: str = "None", + bottom: str = "None", + left: str = "None", + right: str = "None", +) -> dict[str, Any]: + return { + "patch_type": "Ancilla", + "edges": { + "Top": top, + "Bottom": bottom, + "Left": left, + "Right": right, + }, + "activity": {"activity_type": None}, + "text": "", + } + + +def _distillation_cell() -> dict[str, Any]: + return _distillation_cell_with() + + +def _distillation_cell_with( + *, + qid: int | None = None, + top: str = "None", + bottom: str = "None", + left: str = "None", + right: str = "None", + measurement: bool = False, + text: str | None = None, +) -> dict[str, Any]: + if text is None: + text = f"Id: {qid}" if qid is not None else "" + return { + "patch_type": "DistillationQubit", + "edges": { + "Top": top, + "Bottom": bottom, + "Left": left, + "Right": right, + }, + "activity": {"activity_type": "Measurement" if measurement else None}, + "text": text, + } + + +def _load_yaml(yaml_text: str) -> dict[str, Any]: + loaded = yaml.safe_load(yaml_text) + assert isinstance(loaded, dict) + return loaded + + +def _cube_entry(canvas: dict[str, Any], position: list[int]) -> dict[str, Any]: + cube = canvas["cube"] + assert isinstance(cube, list) + for entry in cube: + assert isinstance(entry, dict) + if entry.get("position") == position: + return entry + msg = f"Cube entry not found at position {position}" + raise AssertionError(msg) + + +def _pipe_entry(canvas: dict[str, Any], start: list[int], end: list[int]) -> dict[str, Any]: + pipe = canvas["pipe"] + assert isinstance(pipe, list) + for entry in pipe: + assert isinstance(entry, dict) + if (entry.get("start") == start and entry.get("end") == end) or ( + entry.get("start") == end and entry.get("end") == start + ): + return entry + msg = f"Pipe entry not found for edge {start} <-> {end}" + raise AssertionError(msg) + + +def test_basic_cube_generation_from_minimal_slices() -> None: + slices = [ + [[None, _qubit_cell(qid=7)]], + [[None, _qubit_cell(qid=7)]], + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="minimal")) + + assert canvas["name"] == "minimal" + assert canvas["layout"] == "rotated_surface_code" + assert _cube_entry(canvas, [1, 0, 0])["block"] == "InitZeroBlock" + assert _cube_entry(canvas, [1, 0, 1])["block"] == "MemoryBlock" + + +def test_measurement_maps_to_measurez() -> None: + slices = [ + [[_qubit_cell(qid=1)]], + [[_qubit_cell(qid=1, measurement=True)]], + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="meas")) + assert _cube_entry(canvas, [0, 0, 1])["block"] == "MeasureZBlock" + + +def test_init_detection_by_appearance_and_id_change() -> None: + slices = [ + [[_qubit_cell(qid=1)]], + [[_qubit_cell(qid=1)]], + [[_qubit_cell(qid=2)]], # ID change at same coordinate + [[None]], + [[_qubit_cell(qid=3)]], # re-appearance after disappearance + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="init-detect")) + assert _cube_entry(canvas, [0, 0, 0])["block"] == "InitZeroBlock" + assert _cube_entry(canvas, [0, 0, 1])["block"] == "MemoryBlock" + assert _cube_entry(canvas, [0, 0, 2])["block"] == "InitZeroBlock" + assert _cube_entry(canvas, [0, 0, 4])["block"] == "InitZeroBlock" + + +def test_boundary_stitched_edges_become_open_boundary() -> None: + slices = [ + [[_qubit_cell(qid=4, top="Solid", bottom="SolidStiched", left="DashedStiched", right="Dashed")]] + ] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="boundary")) + assert _cube_entry(canvas, [0, 0, 0])["boundary"] == "ZOOX" + + +def test_distillation_cells_treated_as_qubits_by_default() -> None: + slices = [ + [[_distillation_cell(), _ancilla_cell(), _qubit_cell(qid=9)]], + ] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ignore")) + assert len(canvas["cube"]) == 2 + assert _cube_entry(canvas, [0, 0, 0])["block"] == "InitZeroBlock" + assert _cube_entry(canvas, [2, 0, 0])["block"] == "InitZeroBlock" + + +def test_distillation_cells_can_be_ignored_with_flag() -> None: + slices = [ + [[_distillation_cell(), _ancilla_cell(), _qubit_cell(qid=9)]], + ] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ignore-flag", treat_distillation_as_qubit=False)) + assert len(canvas["cube"]) == 1 + assert _cube_entry(canvas, [2, 0, 0])["block"] == "InitZeroBlock" + + +def test_distillation_boundary_is_generated_from_edges() -> None: + slices = [[[_distillation_cell_with(top="Solid", bottom="Dashed", left="None", right="None")]]] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="distill-boundary")) + assert _cube_entry(canvas, [0, 0, 0])["boundary"] == "ZXOO" + + +def test_distillation_and_qubit_stitched_connection_generates_pipe() -> None: + slices = [[[ + _distillation_cell_with(right="SolidStiched"), + _qubit_cell(qid=12, left="SolidStiched"), + ]]] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="distill-stitched")) + assert _pipe_entry(canvas, [0, 0, 0], [1, 0, 0])["block"] == "ShortXMemoryBlock" + assert _pipe_entry(canvas, [0, 0, 0], [1, 0, 0])["boundary"] == "XXOO" + + +def test_distillation_without_id_stays_memory_after_first_slice() -> None: + slices = [ + [[_distillation_cell_with()]], + [[_distillation_cell_with()]], + ] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="distill-no-id")) + assert _cube_entry(canvas, [0, 0, 0])["block"] == "InitZeroBlock" + assert _cube_entry(canvas, [0, 0, 1])["block"] == "MemoryBlock" + + +def test_output_has_empty_pipe_section() -> None: + slices = [[[_qubit_cell(qid=0)]]] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="pipe-empty")) + assert canvas["pipe"] == [] + + +def test_short_zz_ancilla_maps_to_shortx_and_generates_pipes() -> None: + slices = [ + [[ + _qubit_cell(qid=1, right="SolidStiched"), + _ancilla_cell(), + _qubit_cell(qid=2, left="SolidStiched"), + ]] + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ancilla-short-zz")) + + assert _cube_entry(canvas, [1, 0, 0])["block"] == "ShortXMemoryBlock" + assert _cube_entry(canvas, [1, 0, 0])["boundary"] == "XXOO" + assert _cube_entry(canvas, [0, 0, 0])["boundary"] == "ZZXO" + assert _cube_entry(canvas, [2, 0, 0])["boundary"] == "ZZOX" + + left_pipe = _pipe_entry(canvas, [0, 0, 0], [1, 0, 0]) + right_pipe = _pipe_entry(canvas, [1, 0, 0], [2, 0, 0]) + assert left_pipe["block"] == "ShortXMemoryBlock" + assert right_pipe["block"] == "ShortXMemoryBlock" + assert left_pipe["boundary"] == "XXOO" + assert right_pipe["boundary"] == "XXOO" + + +def test_short_xx_ancilla_maps_to_shortz_and_generates_pipes() -> None: + slices = [ + [[ + _qubit_cell(qid=1, right="DashedStiched"), + _ancilla_cell(), + _qubit_cell(qid=2, left="DashedStiched"), + ]] + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ancilla-short-xx")) + + assert _cube_entry(canvas, [1, 0, 0])["block"] == "ShortZMemoryBlock" + assert _cube_entry(canvas, [1, 0, 0])["boundary"] == "ZZOO" + assert _pipe_entry(canvas, [0, 0, 0], [1, 0, 0])["boundary"] == "ZZOO" + assert _pipe_entry(canvas, [1, 0, 0], [2, 0, 0])["boundary"] == "ZZOO" + + +def test_long_lived_zz_ancilla_maps_to_init_memory_measure() -> None: + slices = [ + [[ + _qubit_cell(qid=1, right="SolidStiched"), + _ancilla_cell(), + _qubit_cell(qid=2, left="SolidStiched"), + ]], + [[ + _qubit_cell(qid=1, right="SolidStiched"), + _ancilla_cell(), + _qubit_cell(qid=2, left="SolidStiched"), + ]], + [[ + _qubit_cell(qid=1, right="SolidStiched"), + _ancilla_cell(), + _qubit_cell(qid=2, left="SolidStiched"), + ]], + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ancilla-long-zz")) + + assert _cube_entry(canvas, [1, 0, 0])["block"] == "InitPlusBlock" + assert _cube_entry(canvas, [1, 0, 1])["block"] == "MemoryBlock" + assert _cube_entry(canvas, [1, 0, 2])["block"] == "MeasureXBlock" + + pipe = canvas["pipe"] + assert isinstance(pipe, list) + assert len(pipe) == 6 + expected_by_z = {0: "InitPlusBlock", 1: "MemoryBlock", 2: "MeasureXBlock"} + for z, expected_block in expected_by_z.items(): + z_pipes = [entry for entry in pipe if entry["start"][2] == z and entry["end"][2] == z] + assert len(z_pipes) == 2 + assert {entry["block"] for entry in z_pipes} == {expected_block} + assert {entry["boundary"] for entry in z_pipes} == {"XXOO"} + + +def test_adjacent_ancilla_cells_generate_internal_pipe() -> None: + slices = [ + [[ + _qubit_cell(qid=1, right="SolidStiched"), + _ancilla_cell(right="AncillaJoin"), + _ancilla_cell(left="AncillaJoin"), + _qubit_cell(qid=2, left="SolidStiched"), + ]] + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ancilla-internal-pipe")) + + assert _cube_entry(canvas, [1, 0, 0])["boundary"] == "XXOO" + assert _cube_entry(canvas, [2, 0, 0])["boundary"] == "XXOO" + assert _pipe_entry(canvas, [1, 0, 0], [2, 0, 0])["block"] == "ShortXMemoryBlock" + assert _pipe_entry(canvas, [1, 0, 0], [2, 0, 0])["boundary"] == "XXOO" + assert len(canvas["pipe"]) == 3 + + +def test_ancilla_without_two_stitched_endpoints_is_ignored() -> None: + slices = [[[_ancilla_cell()]]] + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="ancilla-ignored")) + assert canvas["cube"] == [] + assert canvas["pipe"] == [] + + +def test_rotation_component_inserts_right_rotation_template() -> None: + slices = [ + [[None, _qubit_cell(qid=5, top="Dashed", bottom="Dashed", left="Solid", right="Solid")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[None, _qubit_cell(qid=5)]], + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="rotation-right")) + + assert _cube_entry(canvas, [1, 0, 1])["block"] == "MemoryBlock" + assert _cube_entry(canvas, [1, 0, 1])["boundary"] == "XXOZ" + assert _cube_entry(canvas, [1, 0, 4])["block"] == "MemoryBlock" + assert _cube_entry(canvas, [0, 0, 4])["block"] == "MeasureXBlock" + assert _pipe_entry(canvas, [0, 0, 1], [1, 0, 1])["boundary"] == "XXOO" + + +def test_rotation_component_boundary_swaps_xz_when_needed() -> None: + slices = [ + [[None, _qubit_cell(qid=7)]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[None, _qubit_cell(qid=7)]], + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="rotation-right-swapped")) + + assert _cube_entry(canvas, [1, 0, 1])["boundary"] == "ZZOX" + assert _pipe_entry(canvas, [0, 0, 1], [1, 0, 1])["boundary"] == "ZZOO" + + +def test_rotation_component_detects_distillation_cells_when_enabled() -> None: + slices = [ + [[None, _distillation_cell_with(qid=7)]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[_ancilla_cell(right="AncillaJoin"), _ancilla_cell(left="AncillaJoin")]], + [[None, _distillation_cell_with(qid=7)]], + ] + + canvas = _load_yaml(convert_slices_to_canvas_yaml(slices, name="rotation-right-distillation")) + + assert _cube_entry(canvas, [1, 0, 1])["boundary"] == "XXOZ" + assert _pipe_entry(canvas, [0, 0, 1], [1, 0, 1])["boundary"] == "XXOO" + + +def test_invalid_input_shape_raises() -> None: + slices = [ + [[None], [None, None]], + ] + with pytest.raises(LibLsQeccImportError, match="column count"): + convert_slices_to_canvas_yaml(slices, name="invalid") + + +def test_file_conversion_writes_yaml(tmp_path: Path) -> None: + slices = [[[_qubit_cell(qid=11)]]] + input_path = tmp_path / "sample_slices.json" + output_path = tmp_path / "sample_canvas.yml" + input_path.write_text(json.dumps(slices), encoding="utf-8") + + yaml_text = convert_slices_file_to_canvas_yaml(input_path, output_path) + loaded = _load_yaml(yaml_text) + + assert output_path.read_text(encoding="utf-8") == yaml_text + assert loaded["name"] == "sample_slices" + assert _cube_entry(loaded, [0, 0, 0])["block"] == "InitZeroBlock" diff --git a/tests/test_init_flow_direction_analysis.py b/tests/test_init_flow_direction_analysis.py index 77a1445b..152694b3 100644 --- a/tests/test_init_flow_direction_analysis.py +++ b/tests/test_init_flow_direction_analysis.py @@ -1,8 +1,11 @@ from __future__ import annotations +from pathlib import Path +from textwrap import dedent + import pytest -from lspattern.canvas_loader import CanvasCubeSpec, CanvasSpec +from lspattern.canvas_loader import CanvasCubeSpec, CanvasPipeSpec, CanvasSpec from lspattern.consts import BoundarySide, EdgeSpecValue from lspattern.init_flow_analysis import ( InitFlowLayerKey, @@ -31,18 +34,39 @@ def _boundary( } -def _spec_with_cubes(*cubes: CanvasCubeSpec) -> CanvasSpec: +def _spec_with_cubes( + *cubes: CanvasCubeSpec, + pipes: tuple[CanvasPipeSpec, ...] = (), + search_paths: tuple[Path, ...] = (), +) -> CanvasSpec: return CanvasSpec( name="test-canvas", description="", layout="rotated_surface_code", cubes=list(cubes), - pipes=[], - search_paths=(), + pipes=list(pipes), + search_paths=search_paths, logical_observables=(), ) +def _pipe( + start: Coord3D, + end: Coord3D, + boundary: dict[BoundarySide, EdgeSpecValue], + *, + block: str = "memory_block.yml", +) -> CanvasPipeSpec: + return CanvasPipeSpec( + start=start, + end=end, + block=block, + boundary=boundary, + logical_observables=None, + invert_ancilla_order=False, + ) + + def test_init_flow_direction_single_cube_prefers_left_candidate() -> None: boundary = _boundary( top=EdgeSpecValue.Z, @@ -108,6 +132,153 @@ def test_init_flow_direction_avoids_opposing_adjacent() -> None: assert directions.cube_directions(right_pos)[key] == Coord2D(0, +1) # BOTTOM +def test_init_flow_direction_excludes_side_with_same_slot_pipe_basis_non_null_at_physical_z_minus_1( + tmp_path: Path, +) -> None: + delayed_init_block = dedent(""" + name: DelayedInitBlock + description: init layer appears after one memory unit + layers: + - type: MemoryUnit + num_layers: 1 + - type: InitPlusUnit + num_layers: 1 + """) + (tmp_path / "delayed_init_block.yml").write_text(delayed_init_block, encoding="utf-8") + + pos = Coord3D(0, 0, 1) + boundary = _boundary( + top=EdgeSpecValue.Z, + bottom=EdgeSpecValue.Z, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.O, + ) + pipe_boundary = _boundary( + top=EdgeSpecValue.X, + bottom=EdgeSpecValue.X, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.Z, + ) + spec = _spec_with_cubes( + CanvasCubeSpec( + position=pos, + block="delayed_init_block.yml", + boundary=boundary, + logical_observables=None, + invert_ancilla_order=False, + ), + pipes=( + _pipe(Coord3D(0, 0, 1), Coord3D(1, 0, 1), pipe_boundary), + ), + search_paths=(tmp_path,), + ) + + directions = analyze_init_flow_directions(spec, code_distance=3) + key = InitFlowLayerKey(1, 1) + assert key not in directions.cube_directions(pos) + + +def test_init_flow_direction_excludes_side_with_lower_slot_pipe_basis_non_null_at_physical_z_minus_1() -> None: + pos = Coord3D(0, 0, 1) + boundary = _boundary( + top=EdgeSpecValue.Z, + bottom=EdgeSpecValue.Z, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.O, + ) + pipe_boundary = _boundary( + top=EdgeSpecValue.X, + bottom=EdgeSpecValue.X, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.Z, + ) + spec = _spec_with_cubes( + CanvasCubeSpec( + position=pos, + block="init_plus_block.yml", + boundary=boundary, + logical_observables=None, + invert_ancilla_order=False, + ), + pipes=( + # same-slot pipe exists but its z-1 slice basis is null at d=3 + _pipe(Coord3D(0, 0, 1), Coord3D(1, 0, 1), pipe_boundary, block="measure_x_block.yml"), + # lower-slot pipe has non-null basis at the same physical z-1 + _pipe(Coord3D(0, 0, 0), Coord3D(1, 0, 0), pipe_boundary, block="memory_block.yml"), + ), + ) + + directions = analyze_init_flow_directions(spec, code_distance=3) + key = InitFlowLayerKey(0, 1) + assert key not in directions.cube_directions(pos) + + +def test_init_flow_direction_not_excluded_when_lower_slot_pipe_basis_null_at_physical_z_minus_1() -> None: + pos = Coord3D(0, 0, 1) + boundary = _boundary( + top=EdgeSpecValue.Z, + bottom=EdgeSpecValue.Z, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.O, + ) + pipe_boundary = _boundary( + top=EdgeSpecValue.X, + bottom=EdgeSpecValue.X, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.Z, + ) + spec = _spec_with_cubes( + CanvasCubeSpec( + position=pos, + block="init_plus_block.yml", + boundary=boundary, + logical_observables=None, + invert_ancilla_order=False, + ), + pipes=( + # lower-slot pipe exists but its local z=5 basis is null for measure_x_block at d=3 + _pipe(Coord3D(0, 0, 0), Coord3D(1, 0, 0), pipe_boundary, block="measure_x_block.yml"), + ), + ) + + directions = analyze_init_flow_directions(spec, code_distance=3) + key = InitFlowLayerKey(0, 1) + assert directions.cube_directions(pos)[key] == Coord2D(1, 0) + + +def test_init_flow_direction_not_excluded_by_other_side_lower_slot_pipe() -> None: + pos = Coord3D(0, 0, 1) + boundary = _boundary( + top=EdgeSpecValue.Z, + bottom=EdgeSpecValue.Z, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.O, + ) + pipe_boundary = _boundary( + top=EdgeSpecValue.X, + bottom=EdgeSpecValue.X, + left=EdgeSpecValue.Z, + right=EdgeSpecValue.Z, + ) + spec = _spec_with_cubes( + CanvasCubeSpec( + position=pos, + block="init_plus_block.yml", + boundary=boundary, + logical_observables=None, + invert_ancilla_order=False, + ), + pipes=( + _pipe(Coord3D(0, 0, 1), Coord3D(1, 0, 1), pipe_boundary, block="measure_x_block.yml"), + _pipe(Coord3D(0, 0, 0), Coord3D(-1, 0, 0), pipe_boundary, block="memory_block.yml"), + ), + ) + + directions = analyze_init_flow_directions(spec, code_distance=3) + key = InitFlowLayerKey(0, 1) + assert directions.cube_directions(pos)[key] == Coord2D(1, 0) + + # ============================================================================= # Tests for _ancilla_type_for_init_layer # ============================================================================= diff --git a/tests/test_video_3d_export.py b/tests/test_video_3d_export.py new file mode 100644 index 00000000..8db042d3 --- /dev/null +++ b/tests/test_video_3d_export.py @@ -0,0 +1,261 @@ +"""Tests for 3D z-sweep MP4 export.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np +import plotly.graph_objects as go +import pytest + +from lspattern import video_3d +from lspattern.mytype import Coord3D, NodeRole + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +@dataclass +class DummyCanvas: + """Minimal canvas-like object for video export tests.""" + + nodes: set[Coord3D] + edges: set[tuple[Coord3D, Coord3D]] + coord2role: dict[Coord3D, NodeRole] + pauli_axes: dict[Coord3D, Any] + + +class _DummyWriter: + """In-memory writer stub.""" + + def __init__(self) -> None: + self.frames: list[np.ndarray[Any, np.dtype[np.uint8]]] = [] + self.closed = False + + def append_data(self, frame: np.ndarray[Any, np.dtype[np.uint8]]) -> None: + self.frames.append(frame) + + def close(self) -> None: + self.closed = True + + +class _DummyImageIOModule: + """Stub imageio module exposing only get_writer.""" + + def __init__(self, writer: _DummyWriter) -> None: + self.writer = writer + self.uri: str | None = None + self.kwargs: dict[str, object] = {} + + def get_writer(self, uri: str, **kwargs: object) -> _DummyWriter: + self.uri = uri + self.kwargs = kwargs + return self.writer + + +def _make_dummy_canvas() -> DummyCanvas: + z0 = Coord3D(0, 0, 0) + z1 = Coord3D(0, 0, 1) + z2 = Coord3D(0, 0, 2) + return DummyCanvas( + nodes={z0, z1, z2}, + edges={(z0, z1), (z1, z2)}, + coord2role={z0: NodeRole.DATA, z1: NodeRole.ANCILLA_X, z2: NodeRole.ANCILLA_Z}, + pauli_axes={}, + ) + + +def test_export_sweeps_all_z_layers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + canvas = _make_dummy_canvas() + writer = _DummyWriter() + imageio_stub = _DummyImageIOModule(writer) + + render_calls: list[int] = [] + + def fake_render( + _canvas: DummyCanvas, + *, + current_z: int, + **_kwargs: object, + ) -> go.Figure: + render_calls.append(current_z) + return go.Figure() + + def fake_frame(_fig: go.Figure, *, width: int, height: int) -> np.ndarray[Any, np.dtype[np.uint8]]: + return np.zeros((height, width, 3), dtype=np.uint8) + + monkeypatch.setattr(video_3d, "_get_imageio_v2", lambda: imageio_stub) + monkeypatch.setattr(video_3d, "render_canvas_z_window_plotly_figure", fake_render) + monkeypatch.setattr(video_3d, "_figure_to_rgb_array", fake_frame) + + output = video_3d.export_canvas_z_sweep_3d_mp4( + canvas, + tmp_path / "movie.mp4", + fps=12, + z_window=2, + width=8, + height=4, + codec="libx264", + crf=18, + preset="fast", + ) + + assert output.suffix == ".mp4" + assert render_calls == [0, 1, 2] + assert len(writer.frames) == 3 + assert writer.closed + assert imageio_stub.uri is not None + assert imageio_stub.uri.endswith("movie.mp4") + assert imageio_stub.kwargs["fps"] == 12 + assert imageio_stub.kwargs["codec"] == "libx264" + assert imageio_stub.kwargs["ffmpeg_params"] == ["-crf", "18", "-preset", "fast"] + + +def test_export_adds_progress_bar_when_enabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + canvas = _make_dummy_canvas() + writer = _DummyWriter() + imageio_stub = _DummyImageIOModule(writer) + bar_ranges: list[tuple[float, float]] = [] + + def fake_render( + _canvas: DummyCanvas, + *, + current_z: int, + **_kwargs: object, + ) -> go.Figure: + _ = current_z + return go.Figure() + + def fake_frame(fig: go.Figure, *, width: int, height: int) -> np.ndarray[Any, np.dtype[np.uint8]]: + _ = (width, height) + shapes = tuple(fig.layout.shapes) if fig.layout.shapes is not None else () + assert len(shapes) == 2 + background, progress = shapes + assert float(background.x0) == pytest.approx(0.05) + assert float(background.x1) == pytest.approx(0.95) + assert float(background.y0) == pytest.approx(0.02) + assert float(background.y1) == pytest.approx(0.045) + assert float(progress.x0) == pytest.approx(0.05) + bar_ranges.append((float(progress.x0), float(progress.x1))) + return np.zeros((4, 8, 3), dtype=np.uint8) + + monkeypatch.setattr(video_3d, "_get_imageio_v2", lambda: imageio_stub) + monkeypatch.setattr(video_3d, "render_canvas_z_window_plotly_figure", fake_render) + monkeypatch.setattr(video_3d, "_figure_to_rgb_array", fake_frame) + + video_3d.export_canvas_z_sweep_3d_mp4( + canvas, + tmp_path / "movie.mp4", + width=8, + height=4, + show_progress_bar=True, + ) + + assert len(bar_ranges) == 3 + assert [start for start, _ in bar_ranges] == pytest.approx([0.05, 0.05, 0.05]) + assert [end for _, end in bar_ranges] == pytest.approx([0.35, 0.65, 0.95]) + + +def test_export_does_not_add_progress_bar_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + canvas = _make_dummy_canvas() + writer = _DummyWriter() + imageio_stub = _DummyImageIOModule(writer) + + def fake_render( + _canvas: DummyCanvas, + *, + current_z: int, + **_kwargs: object, + ) -> go.Figure: + _ = current_z + return go.Figure() + + def fake_frame(fig: go.Figure, *, width: int, height: int) -> np.ndarray[Any, np.dtype[np.uint8]]: + _ = (width, height) + assert not fig.layout.shapes + return np.zeros((4, 8, 3), dtype=np.uint8) + + monkeypatch.setattr(video_3d, "_get_imageio_v2", lambda: imageio_stub) + monkeypatch.setattr(video_3d, "render_canvas_z_window_plotly_figure", fake_render) + monkeypatch.setattr(video_3d, "_figure_to_rgb_array", fake_frame) + + video_3d.export_canvas_z_sweep_3d_mp4( + canvas, + tmp_path / "movie.mp4", + width=8, + height=4, + ) + + assert len(writer.frames) == 3 + + +@pytest.mark.parametrize( + ("invalid_call", "match"), + [ + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, fps=0), + "fps", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, z_window=0), + "z_window", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, node_size_scale=0.0), + "node_size_scale", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, edge_width_scale=0.0), + "edge_width_scale", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, tail_alpha=1.1), + "tail_alpha", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, current_alpha=-0.1), + "current_alpha", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, non_current_alpha=1.1), + "non_current_alpha", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, width=0), + "width", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, height=0), + "height", + ), + ( + lambda canvas, path: video_3d.export_canvas_z_sweep_3d_mp4(canvas, path, crf=-1), + "crf", + ), + ], +) +def test_invalid_parameters_raise( + invalid_call: Callable[[DummyCanvas, Path], Path], + match: str, + tmp_path: Path, +) -> None: + canvas = _make_dummy_canvas() + + with pytest.raises(ValueError, match=match): + invalid_call(canvas, tmp_path / "movie.mp4") + + +def test_non_mp4_extension_raises(tmp_path: Path) -> None: + canvas = _make_dummy_canvas() + + with pytest.raises(ValueError, match=r"\.mp4"): + video_3d.export_canvas_z_sweep_3d_mp4(canvas, tmp_path / "movie.gif") + + +def test_empty_canvas_raises(tmp_path: Path) -> None: + canvas = DummyCanvas(nodes=set(), edges=set(), coord2role={}, pauli_axes={}) + + with pytest.raises(ValueError, match="empty canvas"): + video_3d.export_canvas_z_sweep_3d_mp4(canvas, tmp_path / "movie.mp4") diff --git a/tests/test_visualizer_plotly.py b/tests/test_visualizer_plotly.py new file mode 100644 index 00000000..43d71a6c --- /dev/null +++ b/tests/test_visualizer_plotly.py @@ -0,0 +1,231 @@ +"""Tests for Plotly 3D visualizer axis scaling options.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from lspattern.mytype import Coord3D, NodeRole +from lspattern.visualizer import ( + render_canvas_z_window_plotly_figure, + visualize_canvas_plotly, + visualize_detectors_plotly, +) + + +@dataclass +class DummyCanvas: + """Minimal canvas-like object for visualizer tests.""" + + nodes: set[Coord3D] + edges: set[tuple[Coord3D, Coord3D]] + coord2role: dict[Coord3D, NodeRole] + pauli_axes: dict[Coord3D, Any] + + +def _make_dummy_canvas() -> DummyCanvas: + a = Coord3D(0, 0, 0) + b = Coord3D(0, 1, 4) + return DummyCanvas( + nodes={a, b}, + edges={(a, b)}, + coord2role={a: NodeRole.DATA, b: NodeRole.ANCILLA_X}, + pauli_axes={}, + ) + + +class TestVisualizeCanvasPlotlyAspectRatio: + """Tests for aspect-ratio control in visualize_canvas_plotly.""" + + def test_default_uses_data_aspectmode(self) -> None: + fig = visualize_canvas_plotly(_make_dummy_canvas()) + + assert fig.layout.scene.aspectmode == "data" + assert fig.layout.scene.aspectratio.to_plotly_json() == {} + + def test_manual_aspect_ratio_is_applied(self) -> None: + fig = visualize_canvas_plotly(_make_dummy_canvas(), aspect_ratio=(1.0, 1.0, 0.2)) + + assert fig.layout.scene.aspectmode == "manual" + assert fig.layout.scene.aspectratio.x == 1.0 + assert fig.layout.scene.aspectratio.y == 1.0 + assert fig.layout.scene.aspectratio.z == 0.2 + + def test_non_positive_aspect_ratio_raises(self) -> None: + with pytest.raises(ValueError, match="positive"): + visualize_canvas_plotly(_make_dummy_canvas(), aspect_ratio=(1.0, 0.0, 1.0)) + + +class TestVisualizeDetectorsPlotlyAspectRatio: + """Tests for aspect-ratio control in visualize_detectors_plotly.""" + + def test_manual_aspect_ratio_is_applied(self) -> None: + fig = visualize_detectors_plotly( + detectors={Coord3D(1, 2, 6): [1, 2]}, + aspect_ratio=(1.0, 1.0, 0.1), + ) + + assert fig.layout.scene.aspectmode == "manual" + assert fig.layout.scene.aspectratio.x == 1.0 + assert fig.layout.scene.aspectratio.y == 1.0 + assert fig.layout.scene.aspectratio.z == 0.1 + + +class TestVisualizeCanvasPlotlySizing: + """Tests for node/edge size scaling in visualize_canvas_plotly.""" + + def test_node_and_edge_scale_are_applied(self) -> None: + fig = visualize_canvas_plotly( + _make_dummy_canvas(), + node_size_scale=2.0, + edge_width_scale=1.5, + ) + + data_trace = next(trace for trace in fig.data if trace.name == "Data") + edge_trace = next(trace for trace in fig.data if trace.mode == "lines") + assert data_trace.marker.size == pytest.approx(16.0) + assert edge_trace.line.width == pytest.approx(4.5) + + def test_non_positive_node_size_scale_raises(self) -> None: + with pytest.raises(ValueError, match="node_size_scale"): + visualize_canvas_plotly(_make_dummy_canvas(), node_size_scale=0.0) + + +class TestRenderCanvasZWindowPlotlyFigure: + """Tests for sliding-window frame rendering helper.""" + + def test_default_disables_highlight_and_locks_view(self) -> None: + a = Coord3D(0, 0, 0) + b = Coord3D(2, 4, 6) + canvas = DummyCanvas( + nodes={a, b}, + edges={(a, b)}, + coord2role={a: NodeRole.DATA, b: NodeRole.ANCILLA_X}, + pauli_axes={}, + ) + + fig = render_canvas_z_window_plotly_figure( + canvas, + current_z=6, + z_window=1, + reverse_axes=True, + ) + + assert all(trace.name != "Highlighted" for trace in fig.data) + assert tuple(fig.layout.scene.xaxis.range) == pytest.approx((3.0, -1.0)) + assert tuple(fig.layout.scene.yaxis.range) == pytest.approx((5.0, -1.0)) + assert tuple(fig.layout.scene.zaxis.range) == pytest.approx((5.0, 7.0)) + assert fig.layout.scene.camera.eye.x == pytest.approx(1.8) + assert fig.layout.scene.camera.eye.y == pytest.approx(1.8) + assert fig.layout.scene.camera.eye.z == pytest.approx(0.9) + assert fig.layout.scene.camera.projection.type == "orthographic" + + def test_lock_view_allows_negative_z_window_start(self) -> None: + a = Coord3D(0, 0, 0) + b = Coord3D(2, 4, 6) + canvas = DummyCanvas( + nodes={a, b}, + edges={(a, b)}, + coord2role={a: NodeRole.DATA, b: NodeRole.ANCILLA_X}, + pauli_axes={}, + ) + + fig = render_canvas_z_window_plotly_figure( + canvas, + current_z=0, + z_window=3, + lock_view=True, + reverse_axes=True, + ) + + assert tuple(fig.layout.scene.zaxis.range) == pytest.approx((-3.0, 1.0)) + + def test_current_layer_highlight_can_be_enabled(self) -> None: + a = Coord3D(0, 0, 0) + b = Coord3D(1, 0, 1) + canvas = DummyCanvas( + nodes={a, b}, + edges={(a, b)}, + coord2role={a: NodeRole.DATA, b: NodeRole.ANCILLA_X}, + pauli_axes={}, + ) + + fig = render_canvas_z_window_plotly_figure( + canvas, + current_z=1, + z_window=2, + node_size_scale=2.0, + edge_width_scale=2.0, + tail_alpha=0.2, + current_alpha=0.95, + highlight_size_scale=1.5, + highlight_current_layer=True, + ) + + data_trace = next(trace for trace in fig.data if trace.name == "Data") + highlight_trace = next(trace for trace in fig.data if trace.name == "Highlighted") + edge_trace = next(trace for trace in fig.data if trace.mode == "lines") + assert data_trace.marker.opacity == pytest.approx(0.2) + assert highlight_trace.marker.opacity == pytest.approx(0.95) + assert highlight_trace.marker.size == pytest.approx(24.0) + assert edge_trace.line.width == pytest.approx(6.0) + + def test_non_current_alpha_overrides_tail_alpha(self) -> None: + a = Coord3D(0, 0, 0) + b = Coord3D(1, 0, 1) + canvas = DummyCanvas( + nodes={a, b}, + edges={(a, b)}, + coord2role={a: NodeRole.DATA, b: NodeRole.ANCILLA_X}, + pauli_axes={}, + ) + + fig = render_canvas_z_window_plotly_figure( + canvas, + current_z=1, + z_window=2, + tail_alpha=0.2, + non_current_alpha=0.65, + highlight_current_layer=True, + ) + + data_trace = next(trace for trace in fig.data if trace.name == "Data") + assert data_trace.marker.opacity == pytest.approx(0.65) + + def test_lock_view_respects_manual_aspect_ratio(self) -> None: + a = Coord3D(0, 0, 0) + b = Coord3D(2, 4, 6) + canvas = DummyCanvas( + nodes={a, b}, + edges={(a, b)}, + coord2role={a: NodeRole.DATA, b: NodeRole.ANCILLA_X}, + pauli_axes={}, + ) + + fig = render_canvas_z_window_plotly_figure( + canvas, + current_z=6, + z_window=2, + lock_view=True, + aspect_ratio=(1.5, 1.5, 0.6), + ) + + assert fig.layout.scene.aspectmode == "manual" + assert fig.layout.scene.aspectratio.x == pytest.approx(1.5) + assert fig.layout.scene.aspectratio.y == pytest.approx(1.5) + assert fig.layout.scene.aspectratio.z == pytest.approx(0.6) + assert tuple(fig.layout.scene.zaxis.range) == pytest.approx((4.0, 7.0)) + + def test_invalid_z_window_raises(self) -> None: + with pytest.raises(ValueError, match="z_window"): + render_canvas_z_window_plotly_figure(_make_dummy_canvas(), current_z=0, z_window=0) + + def test_invalid_non_current_alpha_raises(self) -> None: + with pytest.raises(ValueError, match="non_current_alpha"): + render_canvas_z_window_plotly_figure( + _make_dummy_canvas(), + current_z=0, + non_current_alpha=1.1, + )